Files
kandev/apps/web/components/task/chat/file-attachment.ts
T
João Salavisa b082d5e1b9 refactor(web): use the shared formatBytes in chat attachments (#2071)
Delete the two byte-identical local formatBytes copies from the chat-attachment
modules and repoint all call sites at the canonical lib/utils/format-bytes.ts,
which its own doc comment asks new code to use.

The copies had no upper clamp (>= 1 TB rendered "1 undefined") and no NaN or
negative guard (NaN rendered "NaN undefined"). Size labels now carry one
decimal ("10.0 MB" instead of "10 MB") because the canonical helper does not
strip a trailing zero; unit and e2e expectations updated to match.
2026-07-30 22:40:50 +01:00

100 lines
2.9 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;
data: string; // Base64-encoded content (without data: prefix)
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 = 10 * 1024 * 1024; // 10MB per file
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total
export const MAX_FILES = 10;
function isPreviewableImage(mimeType: string): boolean {
return PREVIEWABLE_IMAGE_TYPES.includes(mimeType);
}
/**
* Process a file into a FileAttachment.
* Images get a preview data URL; other files just get base64 data.
* 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 reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (!dataUrl) {
resolve(null);
return;
}
const base64 = dataUrl.split(",")[1];
const mimeType =
file.type || dataUrl.split(";")[0].split(":")[1] || "application/octet-stream";
const isImage = isPreviewableImage(mimeType);
if (isImage) {
// For images, load to verify and generate preview
const img = new Image();
img.onload = () => {
resolve({
id: generateUUID(),
data: base64,
mimeType,
fileName: file.name,
size: file.size,
preview: dataUrl,
isImage: true,
deliveryMode: "prompt",
});
};
img.onerror = () => resolve(null);
img.src = dataUrl;
} else {
resolve({
id: generateUUID(),
data: base64,
mimeType,
fileName: file.name,
size: file.size,
isImage: false,
deliveryMode: "path",
});
}
};
reader.onerror = () => {
console.error("Failed to read file:", file.name);
resolve(null);
};
reader.readAsDataURL(file);
});
}