Files
kandev/apps/web/components/task/chat/file-attachment.ts
T
2026-08-04 22:11:13 +01:00

128 lines
3.8 KiB
TypeScript

/**
* Unified file attachment type and processing utilities.
* Handles both images (with preview) and arbitrary files (code, docs, etc.).
*/
import { generateUUID } from "@/lib/utils";
import { formatBytes } from "@/lib/utils/format-bytes";
export type FileAttachment = {
id: string;
/** Legacy inline data. New uploads use `file` + `attachmentId` instead. */
data?: string;
file?: File;
attachmentId?: string;
uploadStatus?: "pending" | "uploading" | "ready" | "failed";
uploadError?: string;
mimeType: string; // MIME type
fileName: string; // Original file name
size: number; // File size in bytes
preview?: string; // Data URL for image preview (only for images)
isImage: boolean; // Whether this is a previewable image
deliveryMode: "prompt" | "path"; // Native prompt block or writable executor file path
};
export const PREVIEWABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB per file
export const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100 MiB total
export const MAX_FILES = 10;
const MAX_INLINE_PREVIEW_BYTES = 5 * 1024 * 1024;
function isPreviewableImage(mimeType: string): boolean {
return PREVIEWABLE_IMAGE_TYPES.includes(mimeType);
}
/**
* Process a file into a FileAttachment.
* Small images get a preview data URL; larger files retain the File object so
* the browser can stream them to the attachment endpoint without creating a
* 100 MiB base64 copy in memory.
* Returns null if the file is invalid (too large, etc.)
*/
export function processFile(file: File): Promise<FileAttachment | null> {
return new Promise((resolve) => {
if (file.size > MAX_FILE_SIZE) {
console.warn(
`File too large: ${formatBytes(file.size)} (max: ${formatBytes(MAX_FILE_SIZE)})`,
);
resolve(null);
return;
}
// Skip directories and empty files
if (file.size === 0 && file.type === "") {
resolve(null);
return;
}
const mimeType = file.type || "application/octet-stream";
const isImage = isPreviewableImage(mimeType);
if (file.size > MAX_INLINE_PREVIEW_BYTES) {
resolve({
id: generateUUID(),
file,
mimeType,
fileName: file.name,
size: file.size,
isImage,
deliveryMode: isImage ? "prompt" : "path",
uploadStatus: "pending",
});
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (!dataUrl) {
resolve(null);
return;
}
const base64 = dataUrl.split(",")[1];
const resolvedMimeType =
file.type || dataUrl.split(";")[0].split(":")[1] || "application/octet-stream";
const resolvedIsImage = isPreviewableImage(resolvedMimeType);
if (resolvedIsImage) {
// For images, load to verify and generate preview
const img = new Image();
img.onload = () => {
resolve({
id: generateUUID(),
data: base64,
file,
mimeType: resolvedMimeType,
fileName: file.name,
size: file.size,
preview: dataUrl,
isImage: true,
deliveryMode: "prompt",
uploadStatus: "pending",
});
};
img.onerror = () => resolve(null);
img.src = dataUrl;
} else {
resolve({
id: generateUUID(),
data: base64,
file,
mimeType: resolvedMimeType,
fileName: file.name,
size: file.size,
isImage: false,
deliveryMode: "path",
uploadStatus: "pending",
});
}
};
reader.onerror = () => {
console.error("Failed to read file:", file.name);
resolve(null);
};
reader.readAsDataURL(file);
});
}