Attach any file type in chat and queue (uploaded into the project)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49262c612c
commit
fab9add5d5
4 files changed
+90
-14
No files matched your search
@@ -16,6 +16,12 @@ const BUILTIN: { name: string; key: Key }[] = [
|
||||
|
||||
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<ImageData> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
@@ -51,6 +57,9 @@ export function Composer({
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [images, setImages] = useState<ImageData[]>([]);
|
||||
// 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;
|
||||
@@ -74,9 +83,12 @@ export function Composer({
|
||||
}, [text, cmdData, t]);
|
||||
|
||||
const send = () => {
|
||||
if (!text.trim() && images.length === 0) return;
|
||||
onSend(text, images);
|
||||
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) => {
|
||||
@@ -101,13 +113,38 @@ export function Composer({
|
||||
if (e.key === "Escape" && busy) onAbort();
|
||||
};
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const addFiles = async (list: FileList | File[]) => {
|
||||
const all = [...list];
|
||||
try {
|
||||
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
|
||||
setImages((cur) => [...cur, ...imgs]);
|
||||
} catch (e: any) {
|
||||
alert(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) {
|
||||
alert(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 (
|
||||
@@ -140,8 +177,22 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
{(images.length > 0 || files.length > 0 || uploading > 0) && (
|
||||
<div className="attachments">
|
||||
{files.map((f) => (
|
||||
<div className="att file" key={f.path} title={f.path}>
|
||||
<span>📄 {f.name}</span>
|
||||
<small>{formatSize(f.size)}</small>
|
||||
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{uploading > 0 && (
|
||||
<div className="att file">
|
||||
<span>{t("composer.uploading", { n: uploading })}</span>
|
||||
</div>
|
||||
)}
|
||||
{images.map((img, i) => (
|
||||
<div className="att" key={i}>
|
||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||
@@ -163,10 +214,10 @@ export function Composer({
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(e) => {
|
||||
const files = [...e.clipboardData.files];
|
||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
if (pasted.length > 0) {
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
void addFiles(pasted);
|
||||
}
|
||||
}}
|
||||
aria-label={t("composer.placeholder")}
|
||||
@@ -200,7 +251,7 @@ export function Composer({
|
||||
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
📎
|
||||
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
|
||||
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="composer-actions">
|
||||
@@ -209,7 +260,7 @@ export function Composer({
|
||||
■ {t("composer.stop")}
|
||||
</button>
|
||||
)}
|
||||
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
|
||||
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
|
||||
{busy ? t("composer.queue") : t("composer.send")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user