mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
4e26852482
* feat: support multimodal attachments (images, PDFs, files) in AI chat Previously uploaded images were displayed in the UI but never sent to the AI model, and non-image files (PDF, text) were silently rejected. - Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/* - Rename ChatMessageImage → ChatMessageAttachment with filePath support - Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent - Fix ACP agent path: images inline, non-image files via local path hint so ACP agents (Claude Code, etc.) read them with native file access - Use Electron webUtils.getPathForFile() for reliable file path capture - Compact user message bubble padding Closes #294 (AI file upload issues) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show real tool names in AI chat instead of ACP wrapper names - Unwrap ACP dynamic tool calls in serializeStreamChunk to extract real tool name, args, and toolCallId from chunk.input - Simplify MCP tool name prefixes (mcp__server__tool → tool) - Pass toolCallId from ACP tool-call events to match tool results - Prevent onToolResult from overwriting correct names with wrapper name - Build toolCallNames map in ChatMessageList for tool result display Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: backward-compatible fallback for legacy `images` field in chat messages Persisted sessions may still have `images` instead of `attachments`. Add `?? m.images` fallback in SDK message builder and renderer so historical image attachments are not silently dropped after upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: broaden file type support and handle pasted files without path - Accept all file types except video/audio (instead of allowlist) so .json, .yaml, .sh, etc. are not silently rejected - For ACP agents, save pasted/virtual files (no filePath) to temp directory so the agent can still read them Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use managed temp dir for pasted ACP attachments Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path so pasted file attachments are tracked by the app's cleanup system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
80 lines
2.6 KiB
TypeScript
80 lines
2.6 KiB
TypeScript
/**
|
|
* useFileUpload - Handle file paste/drop with base64 conversion
|
|
*
|
|
* Supports images, PDFs, and other document types.
|
|
* Ported from 1code's use-agents-file-upload.ts
|
|
*/
|
|
import { useCallback, useState } from 'react';
|
|
import { getPathForFile } from '../../lib/sftpFileUtils';
|
|
|
|
export interface UploadedFile {
|
|
id: string;
|
|
filename: string;
|
|
dataUrl: string; // data:...;base64,... for preview
|
|
base64Data: string; // raw base64 for API
|
|
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
|
filePath?: string; // original filesystem path (Electron only)
|
|
}
|
|
|
|
/** Reject only known binary blobs that AI models can't process */
|
|
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
|
|
|
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 function useFileUpload() {
|
|
const [files, setFiles] = useState<UploadedFile[]>([]);
|
|
|
|
const addFiles = useCallback(async (inputFiles: File[]) => {
|
|
const supported = inputFiles.filter(isSupportedFile);
|
|
if (supported.length === 0) return;
|
|
|
|
const newFiles: UploadedFile[] = await Promise.all(
|
|
supported.map(async (file) => {
|
|
const id = crypto.randomUUID();
|
|
const filename = file.name || `file-${Date.now()}`;
|
|
const mediaType = file.type || 'application/octet-stream';
|
|
let dataUrl = '';
|
|
let base64Data = '';
|
|
try {
|
|
const result = await fileToDataUrl(file);
|
|
dataUrl = result.dataUrl;
|
|
base64Data = result.base64;
|
|
} catch (err) {
|
|
console.error('[useFileUpload] Failed to convert:', err);
|
|
}
|
|
const filePath = getPathForFile(file);
|
|
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
|
}),
|
|
);
|
|
|
|
setFiles((prev) => [...prev, ...newFiles]);
|
|
}, []);
|
|
|
|
const removeFile = useCallback((id: string) => {
|
|
setFiles((prev) => prev.filter((f) => f.id !== id));
|
|
}, []);
|
|
|
|
const clearFiles = useCallback(() => {
|
|
setFiles([]);
|
|
}, []);
|
|
|
|
return { files, addFiles, removeFile, clearFiles };
|
|
}
|