* fix(ai): infer MIME type from file extension for YAML and other code files When uploading YAML files (and other code/text files) via Electron, file.type is often empty, causing the system to default to 'application/octet-stream'. AI providers reject this media type with 'functionality not supported'. Fix by inferring the correct MIME type from the file extension when file.type is empty. Includes mappings for YAML, JSON, TOML, shell scripts, and 50+ common code/text file extensions. Fixes #1287 * fix(ai): use text/plain for all code/text files to ensure provider compatibility Change all non-standard MIME types (text/x-*, application/x-*) to text/plain for maximum provider compatibility. Anthropic and other providers reject non-standard MIME types like application/x-yaml with 'UnsupportedFunctionalityError'. Changes: - All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain - Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain - yaml/yml → text/plain (was application/x-yaml) - dockerfile → text/plain (was text/x-dockerfile) - Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
148 lines
4.1 KiB
TypeScript
148 lines
4.1 KiB
TypeScript
/**
|
|
* File upload conversion helpers for AI draft attachments.
|
|
*
|
|
* Supports images, PDFs, and other document types.
|
|
* Ported from 1code's use-agents-file-upload.ts
|
|
*/
|
|
import type { UploadedFile } from '../../infrastructure/ai/types';
|
|
import { getPathForFile } from '../../lib/sftpFileUtils';
|
|
|
|
export type { UploadedFile } from '../../infrastructure/ai/types';
|
|
|
|
/** Reject only known binary blobs that AI models can't process */
|
|
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
|
|
|
/**
|
|
* Infer MIME type from file extension when the browser/Electron doesn't
|
|
* provide one (common for .yaml, .sh, .toml, and other code/text files).
|
|
*/
|
|
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
|
// Code & Scripts — all use text/plain for maximum provider compatibility
|
|
js: 'text/plain',
|
|
mjs: 'text/plain',
|
|
cjs: 'text/plain',
|
|
jsx: 'text/plain',
|
|
ts: 'text/plain',
|
|
tsx: 'text/plain',
|
|
py: 'text/plain',
|
|
rb: 'text/plain',
|
|
rs: 'text/plain',
|
|
go: 'text/plain',
|
|
java: 'text/plain',
|
|
c: 'text/plain',
|
|
h: 'text/plain',
|
|
cpp: 'text/plain',
|
|
hpp: 'text/plain',
|
|
cs: 'text/plain',
|
|
swift: 'text/plain',
|
|
kt: 'text/plain',
|
|
scala: 'text/plain',
|
|
php: 'text/plain',
|
|
pl: 'text/plain',
|
|
sh: 'text/plain',
|
|
bash: 'text/plain',
|
|
zsh: 'text/plain',
|
|
fish: 'text/plain',
|
|
ps1: 'text/plain',
|
|
bat: 'text/plain',
|
|
cmd: 'text/plain',
|
|
sql: 'text/plain',
|
|
r: 'text/plain',
|
|
lua: 'text/plain',
|
|
dart: 'text/plain',
|
|
// Web
|
|
html: 'text/html',
|
|
htm: 'text/html',
|
|
css: 'text/css',
|
|
scss: 'text/plain',
|
|
sass: 'text/plain',
|
|
less: 'text/plain',
|
|
vue: 'text/plain',
|
|
svelte: 'text/plain',
|
|
// Config / Data
|
|
yaml: 'text/plain',
|
|
yml: 'text/plain',
|
|
json: 'application/json',
|
|
jsonc: 'application/json',
|
|
jsonl: 'application/jsonl',
|
|
xml: 'application/xml',
|
|
toml: 'application/toml',
|
|
csv: 'text/csv',
|
|
tsv: 'text/tab-separated-values',
|
|
ini: 'text/plain',
|
|
cfg: 'text/plain',
|
|
conf: 'text/plain',
|
|
env: 'text/plain',
|
|
// Docs
|
|
md: 'text/markdown',
|
|
markdown: 'text/markdown',
|
|
txt: 'text/plain',
|
|
tex: 'text/x-tex',
|
|
rst: 'text/x-rst',
|
|
log: 'text/plain',
|
|
// Other typed files
|
|
pdf: 'application/pdf',
|
|
dockerfile: 'text/plain',
|
|
};
|
|
|
|
function getExtension(fileName: string): string {
|
|
const dot = fileName.lastIndexOf('.');
|
|
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
|
|
return fileName.slice(dot + 1).toLowerCase();
|
|
}
|
|
|
|
function inferMediaType(fileName: string, fileType: string): string {
|
|
if (fileType) return fileType;
|
|
const ext = getExtension(fileName);
|
|
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
function isSupportedFile(file: File): boolean {
|
|
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
|
if (!file.type) return true;
|
|
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
|
|
}
|
|
|
|
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const dataUrl = reader.result as string;
|
|
const base64 = dataUrl.split(',')[1] || '';
|
|
resolve({ dataUrl, base64 });
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
|
|
const supported = inputFiles.filter(isSupportedFile);
|
|
if (supported.length === 0) return [];
|
|
|
|
const uploads: Array<UploadedFile | null> = await Promise.all(
|
|
supported.map(async (file) => {
|
|
const id = crypto.randomUUID();
|
|
const filename = file.name || `file-${Date.now()}`;
|
|
const mediaType = inferMediaType(filename, file.type);
|
|
try {
|
|
const result = await fileToDataUrl(file);
|
|
const filePath = getPathForFile(file);
|
|
return {
|
|
id,
|
|
filename,
|
|
dataUrl: result.dataUrl,
|
|
base64Data: result.base64,
|
|
mediaType,
|
|
filePath,
|
|
};
|
|
} catch (err) {
|
|
console.error('[useFileUpload] Failed to convert:', err);
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
return uploads.filter((upload): upload is UploadedFile => upload !== null);
|
|
}
|