Spaces:
Running
Running
File size: 9,582 Bytes
5732e05 827cd2d 60395e6 5732e05 60395e6 304dfbc 5732e05 827cd2d 60395e6 5732e05 827cd2d 5732e05 60395e6 827cd2d 5732e05 60395e6 827cd2d 60395e6 827cd2d 60395e6 827cd2d 5732e05 60395e6 5732e05 60395e6 827cd2d 5732e05 827cd2d 5732e05 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | 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;
}
|