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>
|
||||
|
||||
+4
-2
@@ -123,6 +123,8 @@ export const de: Messages = {
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "Noch keine Commits.",
|
||||
"git.noDiff": "Keine Unterschiede.",
|
||||
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
||||
"composer.uploading": "Lade {n} hoch…",
|
||||
"login.prompt": "Melde dich an, um fortzufahren.",
|
||||
"login.invalid": "Ungültiges Token.",
|
||||
"login.submit": "Anmelden",
|
||||
@@ -175,7 +177,7 @@ export const de: Messages = {
|
||||
"chat.delete": "Session löschen",
|
||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, Bilder einfügen)",
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, Dateien anhängen oder einfügen)",
|
||||
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||
"composer.send": "Senden",
|
||||
"composer.queue": "Einreihen",
|
||||
@@ -183,7 +185,7 @@ export const de: Messages = {
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.attach": "Bilder anhängen",
|
||||
"composer.attach": "Dateien anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
"command.new": "Neue Session starten",
|
||||
|
||||
+4
-2
@@ -122,6 +122,8 @@ export const en = {
|
||||
"git.commit": "Commit",
|
||||
"git.noCommits": "No commits yet.",
|
||||
"git.noDiff": "No differences.",
|
||||
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
||||
"composer.uploading": "Uploading {n}…",
|
||||
"login.prompt": "Sign in to continue.",
|
||||
"login.invalid": "Invalid token.",
|
||||
"login.submit": "Sign in",
|
||||
@@ -174,7 +176,7 @@ export const en = {
|
||||
"chat.delete": "Delete session",
|
||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||
|
||||
"composer.placeholder": "Message… (/ for commands, paste images)",
|
||||
"composer.placeholder": "Message… (/ for commands, attach or paste files)",
|
||||
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||
"composer.send": "Send",
|
||||
"composer.queue": "Queue",
|
||||
@@ -182,7 +184,7 @@ export const en = {
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.attach": "Attach images",
|
||||
"composer.attach": "Attach files",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
"command.new": "Start a new session",
|
||||
|
||||
@@ -710,6 +710,27 @@ textarea:focus {
|
||||
.attachments .att {
|
||||
position: relative;
|
||||
}
|
||||
.attachments .att.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 22px 0 10px;
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: 6px;
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.attachments .att.file span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attachments .att.file small {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attachments img {
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
|
||||
Reference in new issue
Block a user