131 lines
9.7 KiB
TypeScript
131 lines
9.7 KiB
TypeScript
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
|
|
export type AgentAttachment = {
|
|
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
|
|
sizeBytes: number; sha256: string; workspacePath?: string;
|
|
};
|
|
export type AgentAttachmentCapability = {
|
|
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
|
|
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
|
|
maxConcurrentUploads: number; maxConcurrentDecodes: number;
|
|
};
|
|
export function parseAgentAttachment(value: unknown): AgentAttachment {
|
|
const item = value as AgentAttachment | null;
|
|
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|
|
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|
|
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|
|
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|
|
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
|
|
throw new Error("Agent returned an invalid attachment descriptor.");
|
|
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
|
|
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
|
|
}
|
|
export function agentAttachments(metadata: unknown, role = "user"): AgentAttachment[] {
|
|
if (role !== "user" && role !== "assistant") return [];
|
|
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
|
|
if (!Array.isArray(values) || values.length > 8) return [];
|
|
try {
|
|
const files = values.map(value => {
|
|
// Source paths are not part of the assistant output contract, even if an
|
|
// older/malformed server supplies one. Only the ID authorizes retrieval.
|
|
const file = parseAgentAttachment(role === "assistant" ? { ...value, workspacePath: undefined } : value);
|
|
if (role === "assistant" && (file.kind !== "image" || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.mimeType)
|
|
|| file.sizeBytes > 6 * 1024 * 1024 || file.filename.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(file.filename)))
|
|
throw new Error("Invalid assistant image.");
|
|
return file;
|
|
});
|
|
if (new Set(files.map(file => file.id)).size !== files.length || files.reduce((total, file) => total + file.sizeBytes, 0) > 32 * 1024 * 1024) return [];
|
|
return files;
|
|
} catch { return []; }
|
|
}
|
|
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
|
|
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
|
|
}
|
|
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
|
|
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
|
|
export type AgentUpload = {
|
|
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
|
|
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
|
|
};
|
|
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
|
|
|
|
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
|
|
export function createAgentUploadQueue(options: {
|
|
capability(): AgentAttachmentCapability | undefined;
|
|
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
|
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
|
|
}) {
|
|
const rows = new Map<string, AgentUpload>();
|
|
let running: { id: string; controller: AbortController } | undefined, disposed = false;
|
|
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
|
|
const publish = () => { if (!disposed) options.changed(); };
|
|
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
|
|
const restored = options.storage?.read();
|
|
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
|
|
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|
|
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
|
|
try {
|
|
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
|
|
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
|
|
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
|
|
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
|
|
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
|
|
}
|
|
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
|
|
const pump = () => {
|
|
if (disposed || running) return;
|
|
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
|
|
if (!row) return;
|
|
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
|
|
void (async () => {
|
|
row.sha256 ||= await digest(row.file!);
|
|
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
|
// Persist the upload identity AND original byte hash before any network request.
|
|
persist();
|
|
const value = await options.upload(row.file!, row.uploadId, controller.signal);
|
|
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
|
const attachment = parseAgentAttachment(value);
|
|
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
|
|
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
|
|
row.attachment = attachment; row.status = "ready"; delete row.error;
|
|
})().catch(error => {
|
|
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
|
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
|
|
}).finally(() => { running = undefined; save(); pump(); });
|
|
};
|
|
return {
|
|
add(files: Iterable<File>) {
|
|
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
|
|
const selected = Array.from(files);
|
|
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
|
|
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
|
|
throw new Error("Attachments exceed 32 MiB per message.");
|
|
for (const file of selected) {
|
|
const image = cap.imageMimeTypes.includes(file.type);
|
|
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
|
|
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
|
|
}
|
|
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
|
|
for (const row of added) rows.set(row.uploadId, row);
|
|
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
|
|
publish(); pump();
|
|
},
|
|
async reselect(id: string, file: File) {
|
|
const row = rows.get(id); if (!row || row.status !== "reselect") return;
|
|
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
|
|
const hash = await digest(file);
|
|
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
|
|
if (rows.get(id) !== row) return;
|
|
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
|
|
},
|
|
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
|
|
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
|
|
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
|
|
ready(): AgentAttachment[] {
|
|
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
|
|
return [...rows.values()].map(row => row.attachment!);
|
|
},
|
|
clear() { rows.clear(); running?.controller.abort(); save(); },
|
|
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
|
|
};
|
|
}
|