import { useEffect, useMemo, useRef, useState } from "react"; import { useAgents, useFetch } from "../hooks"; import { useT, type Key } from "../i18n"; import type { ImageData, Session } from "../types"; import { ModelSelect } from "./ModelSelect"; import { confirmDialog, promptDialog, toast } from "../dialogs"; interface Suggestion { name: string; description: string; } const BUILTIN: { name: string; key: Key }[] = [ { name: "compact", key: "command.compact" }, { name: "new", key: "command.new" }, ]; const MAX_SIDE = 2000; function formatSize(n: number) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } /** Read an image and downscale it: models don't use more detail, and uploads stay small. */ async function readImage(file: File): Promise { const bitmap = await createImageBitmap(file); const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height)); const canvas = document.createElement("canvas"); canvas.width = Math.round(bitmap.width * scale); canvas.height = Math.round(bitmap.height * scale); canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height); bitmap.close(); // PNG keeps screenshots crisp; photos become JPEG. const type = file.type === "image/png" && file.size < 1_500_000 ? "image/png" : "image/jpeg"; const url = canvas.toDataURL(type, 0.85); return { media_type: type, data: url.slice(url.indexOf(",") + 1) }; } export function Composer({ session, busy, text, setText, onSend, onAbort, onUpdate, }: { session: Session; busy: boolean; text: string; setText: (s: string) => void; onSend: (text: string, images: ImageData[]) => void; onAbort: () => void; onUpdate: (patch: Partial) => void; }) { const t = useT(); const ref = useRef(null); const [images, setImages] = useState([]); // Non-image attachments, already uploaded into the project. const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]); const [uploading, setUploading] = useState(0); const [active, setActive] = useState(0); const agents = useAgents(session.cwd).filter((a) => a.mode === "primary"); const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data; // Auto-grow. useEffect(() => { const el = ref.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`; }, [text]); const suggestions = useMemo(() => { const m = text.match(/^\/(\S*)$/); if (!m) return []; const all: Suggestion[] = [ ...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })), ...(cmdData?.commands ?? []), ]; return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12); }, [text, cmdData, t]); const send = () => { if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return; // Files are referenced by path; the agent reads them with its tools. const refs = files.length ? `\n\n${t("composer.attachedFiles")}\n${files.map((f) => `- ${f.path} (${formatSize(f.size)})`).join("\n")}` : ""; onSend(`${text}${refs}`, images); setImages([]); setFiles([]); }; const onKeyDown = (e: React.KeyboardEvent) => { if (suggestions.length > 0) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a + (e.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length); return; } if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `); return; } } // Enter sends on desktop; on touch devices Enter inserts a newline. const touch = matchMedia("(pointer: coarse)").matches; if (e.key === "Enter" && !e.shiftKey && !touch && !e.nativeEvent.isComposing) { e.preventDefault(); send(); } if (e.key === "Escape" && busy) onAbort(); }; const addFiles = async (list: FileList | File[]) => { const all = [...list]; try { const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage)); setImages((cur) => [...cur, ...imgs]); } catch (e: any) { toast(e.message ?? String(e)); } // Everything else is uploaded into the project folder. for (const f of all.filter((f) => !f.type.startsWith("image/"))) { const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14); const path = `${session.cwd}/.codecraft/uploads/${stamp}-${f.name.replace(/[\\/]/g, "_")}`; setUploading((n) => n + 1); try { await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft`, dir: true }) }); await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) }); // Keep uploads out of git status without touching the project's .gitignore. await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" }); const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}`, { method: "PUT", body: f, credentials: "same-origin" }); if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`); setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]); } catch (e: any) { toast(e.message ?? String(e)); } finally { setUploading((n) => n - 1); } } }; const removeFile = (path: string) => { setFiles((cur) => cur.filter((f) => f.path !== path)); void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" }); }; return (
e.preventDefault()} onDrop={(e) => { e.preventDefault(); void addFiles(e.dataTransfer.files); }} > {suggestions.length > 0 && (
{suggestions.map((s, i) => (
{ e.preventDefault(); setText(`/${s.name} `); ref.current?.focus(); }} > /{s.name} {s.description}
))}
)} {(images.length > 0 || files.length > 0 || uploading > 0) && (
{files.map((f) => (
📄 {f.name} {formatSize(f.size)}
))} {uploading > 0 && (
{t("composer.uploading", { n: uploading })}
)} {images.map((img, i) => (
))}
)}