import type { Logger } from '@sim/logger' import { omit } from '@sim/utils/object' import type { StorageContext } from '@/lib/uploads' import { ACCEPTED_FILE_TYPES, SUPPORTED_ARCHIVE_EXTENSIONS, SUPPORTED_DOCUMENT_EXTENSIONS, } from '@/lib/uploads/utils/validation' import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' interface FileAttachment { id: string key: string filename: string media_type: string size: number } export interface MessageContent { type: 'text' | 'image' | 'document' | 'audio' | 'video' text?: string source?: { type: 'base64' media_type: string data: string } } /** * Mapping of MIME types to content types */ export const MIME_TYPE_MAPPING: Record = { // Images 'image/jpeg': 'image', 'image/jpg': 'image', 'image/png': 'image', 'image/gif': 'image', 'image/webp': 'image', 'image/svg+xml': 'image', // SVG upload is allowed; createFileContent handles it separately for Claude API 'image/bmp': 'image', 'image/tiff': 'image', 'image/heic': 'image', 'image/heif': 'image', 'image/avif': 'image', 'image/x-icon': 'image', 'image/vnd.microsoft.icon': 'image', // Documents 'application/pdf': 'document', 'text/plain': 'document', 'text/csv': 'document', 'application/json': 'document', 'application/xml': 'document', 'text/xml': 'document', 'text/html': 'document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx 'application/msword': 'document', // .doc 'application/vnd.ms-excel': 'document', // .xls 'application/vnd.ms-powerpoint': 'document', // .ppt 'text/markdown': 'document', 'application/rtf': 'document', // Audio 'audio/mpeg': 'audio', // .mp3 'audio/mp3': 'audio', 'audio/mp4': 'audio', // .m4a 'audio/x-m4a': 'audio', 'audio/m4a': 'audio', 'audio/wav': 'audio', 'audio/wave': 'audio', 'audio/x-wav': 'audio', 'audio/webm': 'audio', 'audio/ogg': 'audio', 'audio/vorbis': 'audio', 'audio/flac': 'audio', 'audio/x-flac': 'audio', 'audio/aac': 'audio', 'audio/x-aac': 'audio', 'audio/opus': 'audio', // Video 'video/mp4': 'video', 'video/mpeg': 'video', 'video/quicktime': 'video', // .mov 'video/x-quicktime': 'video', 'video/x-msvideo': 'video', // .avi 'video/avi': 'video', 'video/x-matroska': 'video', // .mkv 'video/webm': 'video', } /** * Get the content type for a given MIME type */ export function getContentType(mimeType: string): 'image' | 'document' | 'audio' | 'video' | null { return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null } /** * Check if a MIME type is supported */ export function isSupportedFileType(mimeType: string): boolean { return mimeType.toLowerCase() in MIME_TYPE_MAPPING } /** * Check if a MIME type is an image type (for copilot uploads) */ const IMAGE_MIME_TYPES = new Set( Object.entries(MIME_TYPE_MAPPING) .filter(([, v]) => v === 'image') .map(([k]) => k) ) export function isImageFileType(mimeType: string): boolean { return IMAGE_MIME_TYPES.has(mimeType.toLowerCase()) } /** * Check if a MIME type is an audio type */ export function isAudioFileType(mimeType: string): boolean { return getContentType(mimeType) === 'audio' } /** * Check if a MIME type is a video type */ export function isVideoFileType(mimeType: string): boolean { return getContentType(mimeType) === 'video' } /** * Check if a MIME type is an audio or video type */ export function isMediaFileType(mimeType: string): boolean { const contentType = getContentType(mimeType) return contentType === 'audio' || contentType === 'video' } /** * Convert a file buffer to base64 */ export function bufferToBase64(buffer: Buffer): string { return buffer.toString('base64') } /** * Create message content from file data */ export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null { return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType) } /** * Create message content from base64-encoded file data. */ export function createFileContentFromBase64( base64: string, mimeType: string ): MessageContent | null { // SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP), // so send SVGs as an XML document instead if (mimeType.toLowerCase() === 'image/svg+xml') { return { type: 'document', source: { type: 'base64', media_type: 'text/xml', data: base64, }, } } const contentType = getContentType(mimeType) if (!contentType) { return null } if (contentType === 'image' && !MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase())) { return null } return { type: contentType, source: { type: 'base64', media_type: mimeType, data: base64, }, } } export const MODEL_SUPPORTED_IMAGE_MIME_TYPES = new Set([ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', ]) /** * Extract file extension from filename */ export function getFileExtension(filename: string): string { const lastDot = filename.lastIndexOf('.') return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' } /** * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by * its `text/markdown` MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc * merge) to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { if (file.type === 'text/markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } /** * Extensions whose stored bytes may be a generation source that renders to a larger * binary. Everything else stores exactly what it serves, so its declared size is * an accurate byte budget. */ const RENDERABLE_DOCUMENT_EXTENSIONS = new Set(['pdf', 'docx', 'pptx', 'xlsx']) /** * Content types under which a generated document's *generation source* is stored. A * file carrying one of these renders to something other than its stored bytes, so any * surface that hands out the file itself has to resolve it first. Both PDF generators * are here: the E2B path stores Python, the isolated-vm path stores pdf-lib JS. */ export const GENERATED_DOCUMENT_SOURCE_TYPES = new Set([ 'text/x-docxjs', 'text/x-pptxgenjs', 'text/x-pdflibjs', 'text/x-python-pdf', 'text/x-python-xlsx', ]) /** True when the stored bytes for `contentType` are a generation source. */ export function isGeneratedDocumentSourceType(contentType: string | undefined | null): boolean { return contentType ? GENERATED_DOCUMENT_SOURCE_TYPES.has(contentType) : false } /** * Ceiling on a single rendered generated document. A generator source is text and is * orders of magnitude smaller than the document it produces, so the declared size is no * bound at all and the rendered bytes need a cap of their own. */ export const MAX_RENDERED_DOCUMENT_BYTES = 50 * 1024 * 1024 /** True when `fileName` may be backed by a generation source rather than final bytes. */ export function isRenderableDocumentName(fileName: string): boolean { return RENDERABLE_DOCUMENT_EXTENSIONS.has(getFileExtension(fileName)) } const ARCHIVE_EXTENSIONS = new Set(SUPPORTED_ARCHIVE_EXTENSIONS) /** * True when a file name is a supported archive (zip). Detection is by extension * so it is robust to the varied/empty MIME types browsers assign to archives. */ export function isArchiveFileName(filename: string): boolean { return ARCHIVE_EXTENSIONS.has(getFileExtension(filename)) } /** * Single source of truth for the "extract a .zip first" guidance shown wherever * the agent tries to read/grep a raw archive (upload reader, chat payload). A * `.zip`'s contents aren't readable until it is decompressed into workspace * `files/`, so this points at the explicit one-time extract step. */ export function buildArchiveExtractGuidance(name: string): string { return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with materialize_file(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` } const EXTENSION_TO_MIME: Record = { // Images jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', heic: 'image/heic', heif: 'image/heif', avif: 'image/avif', ico: 'image/x-icon', // Documents pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', xml: 'application/xml', html: 'text/html', htm: 'text/html', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', doc: 'application/msword', xls: 'application/vnd.ms-excel', ppt: 'application/vnd.ms-powerpoint', md: 'text/markdown', yaml: 'application/x-yaml', yml: 'application/x-yaml', rtf: 'application/rtf', // Archives zip: 'application/zip', gz: 'application/gzip', // Code / plain-text source py: 'text/x-python', js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript', ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript', go: 'text/x-go', rs: 'text/x-rust', java: 'text/x-java', kt: 'text/x-kotlin', c: 'text/x-c', cpp: 'text/x-c++', h: 'text/x-c', hpp: 'text/x-c++', cs: 'text/x-csharp', rb: 'text/x-ruby', php: 'text/x-php', swift: 'text/x-swift', sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript', r: 'text/x-r', sql: 'text/x-sql', scala: 'text/x-scala', lua: 'text/x-lua', pl: 'text/x-perl', toml: 'text/x-toml', ini: 'text/plain', cfg: 'text/plain', conf: 'text/plain', env: 'text/plain', log: 'text/plain', makefile: 'text/x-makefile', dockerfile: 'text/x-dockerfile', css: 'text/css', scss: 'text/x-scss', less: 'text/x-less', graphql: 'text/x-graphql', gql: 'text/x-graphql', proto: 'text/x-protobuf', // Audio mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', webm: 'audio/webm', ogg: 'audio/ogg', flac: 'audio/flac', aac: 'audio/aac', opus: 'audio/opus', // Video mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', mkv: 'video/x-matroska', } const GENERIC_MIME_TYPE = 'application/octet-stream' /** * Containers that hold either audio or video, mapped to the kind this app presents them as. * A filename cannot say which a `.webm` is, and the viewer already routes it to the video * player, so everything user-facing has to agree — otherwise one file reads "Audio" in the * Type column and opens in a `