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;
|
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. */
|
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
|
||||||
async function readImage(file: File): Promise<ImageData> {
|
async function readImage(file: File): Promise<ImageData> {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
@@ -51,6 +57,9 @@ export function Composer({
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [images, setImages] = useState<ImageData[]>([]);
|
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 [active, setActive] = useState(0);
|
||||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||||
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||||
@@ -74,9 +83,12 @@ export function Composer({
|
|||||||
}, [text, cmdData, t]);
|
}, [text, cmdData, t]);
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (!text.trim() && images.length === 0) return;
|
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||||
onSend(text, images);
|
// 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([]);
|
setImages([]);
|
||||||
|
setFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -101,13 +113,38 @@ export function Composer({
|
|||||||
if (e.key === "Escape" && busy) onAbort();
|
if (e.key === "Escape" && busy) onAbort();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addFiles = async (files: FileList | File[]) => {
|
const addFiles = async (list: FileList | File[]) => {
|
||||||
|
const all = [...list];
|
||||||
try {
|
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]);
|
setImages((cur) => [...cur, ...imgs]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message ?? String(e));
|
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 (
|
return (
|
||||||
@@ -140,8 +177,22 @@ export function Composer({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{images.length > 0 && (
|
{(images.length > 0 || files.length > 0 || uploading > 0) && (
|
||||||
<div className="attachments">
|
<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) => (
|
{images.map((img, i) => (
|
||||||
<div className="att" key={i}>
|
<div className="att" key={i}>
|
||||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||||
@@ -163,10 +214,10 @@ export function Composer({
|
|||||||
}}
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
const files = [...e.clipboardData.files];
|
const pasted = [...e.clipboardData.files];
|
||||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
if (pasted.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void addFiles(files);
|
void addFiles(pasted);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={t("composer.placeholder")}
|
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")} />
|
<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")}>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="composer-actions">
|
<div className="composer-actions">
|
||||||
@@ -209,7 +260,7 @@ export function Composer({
|
|||||||
■ {t("composer.stop")}
|
■ {t("composer.stop")}
|
||||||
</button>
|
</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")}
|
{busy ? t("composer.queue") : t("composer.send")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-2
@@ -123,6 +123,8 @@ export const de: Messages = {
|
|||||||
"git.commit": "Commit",
|
"git.commit": "Commit",
|
||||||
"git.noCommits": "Noch keine Commits.",
|
"git.noCommits": "Noch keine Commits.",
|
||||||
"git.noDiff": "Keine Unterschiede.",
|
"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.prompt": "Melde dich an, um fortzufahren.",
|
||||||
"login.invalid": "Ungültiges Token.",
|
"login.invalid": "Ungültiges Token.",
|
||||||
"login.submit": "Anmelden",
|
"login.submit": "Anmelden",
|
||||||
@@ -175,7 +177,7 @@ export const de: Messages = {
|
|||||||
"chat.delete": "Session löschen",
|
"chat.delete": "Session löschen",
|
||||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
"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.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||||
"composer.send": "Senden",
|
"composer.send": "Senden",
|
||||||
"composer.queue": "Einreihen",
|
"composer.queue": "Einreihen",
|
||||||
@@ -183,7 +185,7 @@ export const de: Messages = {
|
|||||||
"composer.mode": "Modus",
|
"composer.mode": "Modus",
|
||||||
"composer.model": "Modell",
|
"composer.model": "Modell",
|
||||||
"composer.permissionMode": "Berechtigungsmodus",
|
"composer.permissionMode": "Berechtigungsmodus",
|
||||||
"composer.attach": "Bilder anhängen",
|
"composer.attach": "Dateien anhängen",
|
||||||
|
|
||||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||||
"command.new": "Neue Session starten",
|
"command.new": "Neue Session starten",
|
||||||
|
|||||||
+4
-2
@@ -122,6 +122,8 @@ export const en = {
|
|||||||
"git.commit": "Commit",
|
"git.commit": "Commit",
|
||||||
"git.noCommits": "No commits yet.",
|
"git.noCommits": "No commits yet.",
|
||||||
"git.noDiff": "No differences.",
|
"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.prompt": "Sign in to continue.",
|
||||||
"login.invalid": "Invalid token.",
|
"login.invalid": "Invalid token.",
|
||||||
"login.submit": "Sign in",
|
"login.submit": "Sign in",
|
||||||
@@ -174,7 +176,7 @@ export const en = {
|
|||||||
"chat.delete": "Delete session",
|
"chat.delete": "Delete session",
|
||||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
"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.placeholderBusy": "Message is queued and sent at the next step…",
|
||||||
"composer.send": "Send",
|
"composer.send": "Send",
|
||||||
"composer.queue": "Queue",
|
"composer.queue": "Queue",
|
||||||
@@ -182,7 +184,7 @@ export const en = {
|
|||||||
"composer.mode": "Mode",
|
"composer.mode": "Mode",
|
||||||
"composer.model": "Model",
|
"composer.model": "Model",
|
||||||
"composer.permissionMode": "Permission mode",
|
"composer.permissionMode": "Permission mode",
|
||||||
"composer.attach": "Attach images",
|
"composer.attach": "Attach files",
|
||||||
|
|
||||||
"command.compact": "Summarize the conversation to free up context",
|
"command.compact": "Summarize the conversation to free up context",
|
||||||
"command.new": "Start a new session",
|
"command.new": "Start a new session",
|
||||||
|
|||||||
@@ -710,6 +710,27 @@ textarea:focus {
|
|||||||
.attachments .att {
|
.attachments .att {
|
||||||
position: relative;
|
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 {
|
.attachments img {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
Reference in new issue
Block a user