import { TemplatePicker } from "./TemplatePicker"; import { Icon } from "./Icon"; import { formatSize, imageSrc } from "./FileFigure"; import { uploadFile } from "../api"; import { useEffect, useMemo, useRef, useState } from "react"; import { useAgents, useFetch, useModels } from "../hooks"; import { useT, type Key } from "../i18n"; import type { ImageData, Session } from "../types"; import { ModelSelect } from "./ModelSelect"; import { EffortSelect } from "./Regenerate"; import { VoiceButton } from "./Voice"; import { Markdown } from "./Markdown"; 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; /** Read an image and downscale it: models don't use more detail, and uploads stay small. */ export 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[], effort?: string) => 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); // Reasoning effort for the next message only ("" = model default). const [effort, setEffort] = useState(""); const [interim, setInterim] = useState(""); const [picker, setPicker] = useState(false); // Rendered Markdown instead of the text field (to check formatting before sending). const [preview, setPreview] = useState(false); const models = useModels(); const [prov, ...mid] = session.model.split("/"); const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning; // @file mentions: token before the caret and matching files. const [mention, setMention] = useState<{ start: number; end: number; q: string }>(); const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]); 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 updateMention = (value: string, caret: number) => { const m = value.slice(0, caret).match(/(?:^|\s)@([\w./~+\-]*)$/); setMention(m ? { start: caret - m[1].length - 1, end: caret, q: m[1] } : undefined); }; useEffect(() => { if (!mention || mention.q.length < 1) { setMentionHits([]); return; } let alive = true; const timer = setTimeout(() => { fetch(`/api/files/search?path=${encodeURIComponent(session.cwd)}&q=${encodeURIComponent(mention.q)}`, { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : [])) .then((hits: { rel: string; is_dir: boolean }[]) => alive && setMentionHits(hits.slice(0, 12))) .catch(() => {}); }, 120); return () => { alive = false; clearTimeout(timer); }; }, [mention?.q, session.cwd]); const suggestions = useMemo((): { key: string; label: string; description: string; apply: () => void }[] => { if (mention && mentionHits.length) { return mentionHits.map((h) => ({ key: h.rel, label: `@${h.rel}${h.is_dir ? "/" : ""}`, description: h.is_dir ? t("composer.folder") : "", apply: () => { const insert = `@${h.rel}${h.is_dir ? "/" : ""} `; const next = text.slice(0, mention.start) + insert + text.slice(mention.end); setText(next); setMention(undefined); setMentionHits([]); const caret = mention.start + insert.length; requestAnimationFrame(() => ref.current?.setSelectionRange(caret, caret)); }, })); } 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) .map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) })); }, [text, cmdData, t, mention, mentionHits]); 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, effort); setEffort(""); 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(); suggestions[Math.min(active, suggestions.length - 1)].apply(); return; } if (e.key === "Escape") { e.preventDefault(); setMention(undefined); setMentionHits([]); 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" }); await uploadFile(path, f).catch((e) => { throw new Error(`${f.name}: ${e.message}`); }); 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(); s.apply(); ref.current?.focus(); }} > {s.label} {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) => (
))}
)} {preview && (
(setPreview(false), setTimeout(() => ref.current?.focus(), 0))} onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))} > {text.trim() ? : {t("composer.previewEmpty")}}
)}