agent-manager-template / web /src /lib /attachments.ts
Agent Manager
Show attachments in optimistic reader prompts
304dfbc
Raw
History Blame Contribute Delete
9.58 kB
import * as api from '../api';
import type { Attachment } from '../api';
export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const;
export const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
export const MAX_ATTACHMENTS = 5;
export type PendingAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
export interface PendingAttachment {
key: string;
file: File;
previewUrl?: string;
status: PendingAttachmentStatus;
uploadedBytes?: number;
uploadController?: AbortController;
error?: string;
attachment?: Attachment;
}
const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}`;
const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp)$/i;
function normalizedMime(type: string) {
const value = String(type || '').split(';', 1)[0].trim().toLowerCase();
return value === 'image/jpg' || value === 'image/pjpeg' ? 'image/jpeg' : value;
}
// Clipboard and drag sources sometimes omit MIME metadata. Use it only to
// decide whether a local thumbnail is useful; the server independently decides
// which uploads are validated/native images and which remain inert files.
export function isPreviewableImageFile(file: Pick<File, 'name' | 'type'>) {
const type = normalizedMime(file.type);
return (IMAGE_MIMES as readonly string[]).includes(type) || IMAGE_EXTENSION.test(file.name || '');
}
export function attachmentFileError(file: Pick<File, 'size'>) {
if (file.size > MAX_ATTACHMENT_BYTES) return 'too large (100 MB max)';
if (file.size === 0) return 'empty file';
return undefined;
}
export function transferMayContainFile(transfer: DataTransfer) {
return Array.from(transfer.items || []).some((item) => item.kind === 'file')
|| Array.from(transfer.files || []).length > 0;
}
/**
* DataTransfer exposes the same file through both `files` and `items` in some
* browsers. The two views are not guaranteed to return the same File object —
* clipboard-created files can even get different `lastModified` values — so
* merging them can turn one paste into two attachments. `files` is the
* canonical list; `items` is only a fallback for browsers that leave it empty.
*/
export function filesFromTransfer(transfer: DataTransfer) {
const listed = Array.from(transfer.files || []);
if (listed.length) return listed;
const files: File[] = [];
const seen = new Set<string>();
const add = (file: File | null) => {
if (!file) return;
const id = identity(file);
if (seen.has(id)) return;
seen.add(id);
files.push(file);
};
for (const item of Array.from(transfer.items || [])) {
if (item.kind === 'file') add(item.getAsFile());
}
return files;
}
const extensionForMime = (type: string) => ({
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/tiff': 'tiff',
'image/heic': 'heic',
'application/pdf': 'pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
}[normalizedMime(type)] || 'bin');
/** One ClipboardItem can advertise the same logical file in several formats. */
export async function filesFromClipboardItems(items: ClipboardItem[]) {
const files: File[] = [];
for (const item of items) {
const imageTypes = item.types.filter((type) => normalizedMime(type).startsWith('image/'));
const fileTypes = item.types.filter((type) => !['text/plain', 'text/html'].includes(normalizedMime(type)));
const hasText = item.types.some((type) => ['text/plain', 'text/html'].includes(normalizedMime(type)));
const type = imageTypes.find((candidate) =>
(IMAGE_MIMES as readonly string[]).includes(normalizedMime(candidate)))
|| imageTypes[0]
|| (!hasText ? fileTypes[0] : undefined);
if (!type) continue;
const blob = await item.getType(type);
const mime = blob.type || type;
files.push(new File([blob], `Clipboard.${extensionForMime(mime)}`, { type: mime }));
}
return files;
}
export function pendingAttachmentsFromFiles(files: File[], currentCount = 0) {
const remaining = Math.max(0, MAX_ATTACHMENTS - currentCount);
const accepted = files.slice(0, remaining).map((file): PendingAttachment => {
const error = attachmentFileError(file);
return {
key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`,
file,
previewUrl: isPreviewableImageFile(file) ? URL.createObjectURL(file) : undefined,
status: error ? 'error' : 'pending',
error,
};
});
return {
attachments: accepted,
error: files.length > remaining ? `At most ${MAX_ATTACHMENTS} files can be attached.` : null,
};
}
export function revokePendingAttachments(attachments: PendingAttachment[]) {
for (const attachment of attachments) {
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
}
}
/** Abandon an unsent chip: stop its transfer and remove any stored server file. */
export function discardPendingAttachment(sessionId: string, attachment: PendingAttachment) {
attachment.uploadController?.abort();
if (attachment.attachment) {
void api.deleteAttachment(sessionId, attachment.attachment.id).catch(() => {});
}
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
}
export function discardPendingAttachments(sessionId: string, attachments: PendingAttachment[]) {
for (const attachment of attachments) discardPendingAttachment(sessionId, attachment);
}
export interface PendingPrompt {
/** The operator's words (or the attachment-only fallback), used to recognise
* the real transcript turn when it arrives. */
text: string;
/** What the optimistic exchange shows until that transcript turn arrives. */
displayText: string;
}
/**
* The server sends the prompt and its attachment paths as one string. Mirror
* that small presentation rule in the optimistic exchange so the first paint
* says exactly what was sent, without making transcript catch-up depend on a
* CLI retaining the path syntax verbatim.
*/
export function buildPendingPrompt(
cli: string,
text: string,
attachments: Pick<Attachment, 'kind' | 'path'>[],
): PendingPrompt {
const onlyImages = attachments.length > 0 && attachments.every((attachment) => attachment.kind === 'image');
const prompt = String(text || '').trim() || (onlyImages
? `Please inspect the attached screenshot${attachments.length === 1 ? '' : 's'}.`
: `Please inspect the attached file${attachments.length === 1 ? '' : 's'}.`);
if (!attachments.length) return { text: prompt, displayText: prompt };
const paths = attachments.map((attachment) => attachment.path);
const attachmentText = cli === 'gemini'
? paths.map((filePath) => `@${JSON.stringify(filePath)}`).join('\n')
: `Attached files:\n${paths.map((filePath) => `- ${JSON.stringify(filePath)}`).join('\n')}`;
return { text: prompt, displayText: `${prompt}\n\n${attachmentText}` };
}
/** Upload in display order. Already-successful items are reused on retry. */
export async function uploadPendingAttachments(
sessionId: string,
attachments: PendingAttachment[],
update: (key: string, patch: Partial<PendingAttachment>) => void,
) {
const uploaded: Attachment[] = [];
let firstFailure: Error | null = null;
const controllers = new Map<string, AbortController>();
for (const attachment of attachments) {
if (attachment.attachment || attachmentFileError(attachment.file)) continue;
const controller = new AbortController();
controllers.set(attachment.key, controller);
update(attachment.key, { uploadController: controller });
}
for (const attachment of attachments) {
if (attachment.attachment) {
uploaded.push(attachment.attachment);
continue;
}
const invalid = attachmentFileError(attachment.file);
if (invalid) {
update(attachment.key, { status: 'error', error: invalid });
firstFailure ??= new Error(invalid);
continue;
}
const controller = controllers.get(attachment.key)!;
if (controller.signal.aborted) continue;
update(attachment.key, { status: 'uploading', uploadedBytes: 0, error: undefined });
try {
const stored = await api.uploadAttachment(sessionId, attachment.file, {
signal: controller.signal,
onProgress: ({ loaded }) => update(attachment.key, { uploadedBytes: loaded }),
});
// An abort can race the final response: XHR may have settled while the
// click that removed the chip already marked the controller aborted.
// In that narrow window we learned the id, so remove the now-unsent file.
if (controller.signal.aborted) {
await api.deleteAttachment(sessionId, stored.id).catch(() => undefined);
continue;
}
update(attachment.key, {
status: 'uploaded', uploadedBytes: attachment.file.size,
uploadController: undefined, attachment: stored,
});
uploaded.push(stored);
} catch (error) {
if (controller.signal.aborted) {
update(attachment.key, { uploadController: undefined });
continue;
}
const message = error instanceof Error ? error.message : 'upload failed';
update(attachment.key, { status: 'error', uploadController: undefined, error: message });
firstFailure ??= error instanceof Error ? error : new Error(message);
}
}
if (firstFailure) throw firstFailure;
return uploaded;
}