diff --git a/src/hooks/useFileUpload.ts b/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..eb50392 --- /dev/null +++ b/src/hooks/useFileUpload.ts @@ -0,0 +1,251 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { + UploadFile, + FileUploadConfig, + DEFAULT_UPLOAD_CONFIG, + getFileTypeFromMime, + getFileTypeFromExtension, + validateFile, +} from '@/types/file-upload'; + +// 生成唯一ID +function generateId(): string { + return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +interface UseFileUploadOptions { + config?: Partial; + onUploadComplete?: (file: UploadFile) => void; + onUploadError?: (file: UploadFile, error: string) => void; +} + +export interface UseFileUploadReturn { + files: UploadFile[]; + isDragging: boolean; + addFiles: (files: FileList | File[]) => void; + removeFile: (fileId: string) => void; + clearFiles: () => void; + uploadFiles: () => Promise; + handleDragEnter: (e: React.DragEvent) => void; + handleDragLeave: (e: React.DragEvent) => void; + handleDragOver: (e: React.DragEvent) => void; + handleDrop: (e: React.DragEvent) => void; + handlePaste: (e: React.ClipboardEvent) => void; +} + +export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn { + const { config: userConfig, onUploadComplete, onUploadError } = options; + const config: FileUploadConfig = { ...DEFAULT_UPLOAD_CONFIG, ...userConfig }; + + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + + // 添加文件 + const addFiles = useCallback( + (inputFiles: FileList | File[]) => { + const fileArray = Array.from(inputFiles); + + // 检查文件数量限制 + const remainingSlots = config.maxFiles - files.length; + if (remainingSlots <= 0) { + console.warn(`已达到最大文件数量限制(${config.maxFiles}个)`); + return; + } + + const filesToAdd = fileArray.slice(0, remainingSlots); + + const newFiles = filesToAdd + .map((file): UploadFile | null => { + // 验证文件 + const validation = validateFile(file, config); + if (!validation.valid) { + console.warn(`文件 ${file.name} 验证失败: ${validation.error}`); + return null; + } + + // 确定文件类型 + const fileType = getFileTypeFromMime(file.type) || getFileTypeFromExtension(file.name); + + // 创建预览URL(仅图片) + let previewUrl: string | undefined; + if (fileType === 'image') { + previewUrl = URL.createObjectURL(file); + } + + return { + id: generateId(), + file, + name: file.name, + size: file.size, + type: fileType, + mimeType: file.type, + previewUrl, + uploadProgress: 0, + status: 'pending' as const, + }; + }) + .filter((f): f is UploadFile => f !== null); + + setFiles((prev) => [...prev, ...newFiles]); + }, + [files.length, config] + ); + + // 移除文件 + const removeFile = useCallback((fileId: string) => { + setFiles((prev) => { + const file = prev.find((f) => f.id === fileId); + // 释放预览URL + if (file?.previewUrl) { + URL.revokeObjectURL(file.previewUrl); + } + return prev.filter((f) => f.id !== fileId); + }); + }, []); + + // 清空所有文件 + const clearFiles = useCallback(() => { + // 释放所有预览URL + files.forEach((file) => { + if (file.previewUrl) { + URL.revokeObjectURL(file.previewUrl); + } + }); + setFiles([]); + }, [files]); + + // 上传文件 + const uploadFiles = useCallback(async () => { + const pendingFiles = files.filter((f) => f.status === 'pending'); + + for (const file of pendingFiles) { + // 更新状态为上传中 + setFiles((prev) => + prev.map((f) => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)) + ); + + try { + const formData = new FormData(); + formData.append('file', file.file); + + const response = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.statusText}`); + } + + const result = await response.json(); + + // 更新状态为成功 + setFiles((prev) => + prev.map((f) => + f.id === file.id + ? { + ...f, + status: 'success' as const, + uploadProgress: 100, + uploadedUrl: result.url, + } + : f + ) + ); + + onUploadComplete?.({ ...file, status: 'success', uploadProgress: 100, uploadedUrl: result.url }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '上传失败'; + + // 更新状态为错误 + setFiles((prev) => + prev.map((f) => + f.id === file.id ? { ...f, status: 'error' as const, error: errorMessage } : f + ) + ); + + onUploadError?.({ ...file, status: 'error', error: errorMessage }, errorMessage); + } + } + }, [files, onUploadComplete, onUploadError]); + + // 拖拽事件处理 + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current++; + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + setIsDragging(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current--; + if (dragCounter.current === 0) { + setIsDragging(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + dragCounter.current = 0; + + const droppedFiles = e.dataTransfer.files; + if (droppedFiles && droppedFiles.length > 0) { + addFiles(droppedFiles); + } + }, + [addFiles] + ); + + // 粘贴事件处理 + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData.items; + const pastedFiles: File[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + pastedFiles.push(file); + } + } + } + + if (pastedFiles.length > 0) { + e.preventDefault(); // 阻止默认粘贴行为 + addFiles(pastedFiles); + } + }, + [addFiles] + ); + + return { + files, + isDragging, + addFiles, + removeFile, + clearFiles, + uploadFiles, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handlePaste, + }; +} diff --git a/src/hooks/useStreamChat.ts b/src/hooks/useStreamChat.ts index 629b575..a925810 100644 --- a/src/hooks/useStreamChat.ts +++ b/src/hooks/useStreamChat.ts @@ -32,6 +32,10 @@ export interface ChatMessage { outputTokens?: number; // 工具执行产生的图片 images?: string[]; + // 用户上传的图片(Base64) + uploadedImages?: string[]; + // 用户上传的文档 + uploadedDocuments?: UploadedDocument[]; // Pyodide 加载状态 pyodideStatus?: { stage: 'loading' | 'ready' | 'error'; @@ -63,6 +67,91 @@ async function saveMessageImages(messageId: string, images: string[]): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // 移除 data:image/xxx;base64, 前缀,只保留 base64 数据 + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** + * 读取文本文件内容 + */ +async function readTextFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/** + * 判断是否为文本类文件 + */ +function isTextFile(file: File): boolean { + const textMimeTypes = [ + 'text/plain', + 'text/markdown', + 'text/csv', + 'text/html', + 'text/css', + 'text/javascript', + 'text/typescript', + 'text/xml', + 'application/json', + 'application/xml', + 'application/javascript', + ]; + + // 检查 MIME 类型 + if (textMimeTypes.includes(file.type) || file.type.startsWith('text/')) { + return true; + } + + // 检查文件扩展名 + const textExtensions = [ + '.txt', '.md', '.markdown', '.json', '.xml', '.html', '.css', + '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.c', '.cpp', '.h', + '.go', '.rs', '.rb', '.php', '.sh', '.bash', '.yaml', '.yml', + '.sql', '.csv', '.log', '.ini', '.conf', '.env', + ]; + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + return textExtensions.includes(ext); +} + +/** + * 上传的文件信息 + */ +export interface UploadedFile { + file: File; + type: string; + previewUrl?: string; +} + +/** + * 上传的文档信息(用于显示和预览) + */ +export interface UploadedDocument { + name: string; + size: number; + type: string; + /** 文档内容,用于预览 */ + content: string; +} + export function useStreamChat() { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); @@ -78,15 +167,81 @@ export function useStreamChat() { model?: string; tools?: string[]; enableThinking?: boolean; + files?: UploadedFile[]; }) => { - const { conversationId, message, model, tools, enableThinking } = options; + const { conversationId, message, model, tools, enableThinking, files } = options; + + // 处理上传的文件 + const uploadedImages: string[] = []; + const uploadedDocuments: UploadedDocument[] = []; + const imageContents: { type: 'image'; media_type: string; data: string }[] = []; + const documentContents: { name: string; content: string }[] = []; + + if (files && files.length > 0) { + console.log('[useStreamChat] Processing files:', files.length); + for (const fileInfo of files) { + console.log('[useStreamChat] File info:', { + name: fileInfo.file.name, + type: fileInfo.file.type, + size: fileInfo.file.size, + isImage: fileInfo.file.type.startsWith('image/'), + }); + // 处理图片文件 + if (fileInfo.file.type.startsWith('image/')) { + try { + const base64 = await fileToBase64(fileInfo.file); + const mediaType = fileInfo.file.type as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + console.log('[useStreamChat] Image converted to base64, length:', base64.length, 'media_type:', mediaType); + imageContents.push({ + type: 'image', + media_type: mediaType, + data: base64, + }); + // 使用 base64 data URL 而不是 blob URL(因为 blob URL 会在 clearFiles 时被释放) + const dataUrl = `data:${fileInfo.file.type};base64,${base64}`; + uploadedImages.push(dataUrl); + } catch (err) { + console.error('Failed to convert image to base64:', err); + } + } + // 处理文本类文件 + else if (isTextFile(fileInfo.file)) { + try { + const content = await readTextFile(fileInfo.file); + documentContents.push({ + name: fileInfo.file.name, + content: content, + }); + uploadedDocuments.push({ + name: fileInfo.file.name, + size: fileInfo.file.size, + type: fileInfo.type, + content: content, // 保存内容用于预览 + }); + } catch (err) { + console.error('Failed to read text file:', err); + } + } + } + } + + // 构建最终消息:如果有文档,将文档内容附加到消息中 + let finalMessage = message; + if (documentContents.length > 0) { + const docParts = documentContents.map(doc => + `\n\n--- 文件:${doc.name} ---\n${doc.content}\n--- 文件结束 ---` + ).join('\n'); + finalMessage = message + docParts; + } // 添加用户消息 const userMessage: ChatMessage = { id: `user-${Date.now()}`, role: 'user', - content: message, + content: message, // 显示原始消息,不包含文档内容 status: 'completed', + uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined, + uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined, }; // 添加 AI 消息占位 @@ -106,6 +261,21 @@ export function useStreamChat() { abortControllerRef.current = new AbortController(); try { + // 调试日志:确认图片数据 + console.log('[useStreamChat] Sending request with:', { + conversationId, + messageLength: finalMessage.length, + model, + tools, + enableThinking, + imagesCount: imageContents.length, + images: imageContents.length > 0 ? imageContents.map(img => ({ + type: img.type, + media_type: img.media_type, + dataLength: img.data.length, + })) : undefined, + }); + const response = await fetch('/api/chat', { method: 'POST', headers: { @@ -113,10 +283,17 @@ export function useStreamChat() { }, body: JSON.stringify({ conversationId, - message, + message: finalMessage, // 包含文档内容的消息(用于 AI 处理) + displayMessage: message, // 原始用户输入(用于数据库存储和显示) model, tools, enableThinking, + // 传递图片内容给后端(用于 AI 识图) + images: imageContents.length > 0 ? imageContents : undefined, + // 传递上传的图片 URL 用于保存到数据库(用于显示) + uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined, + // 传递上传的文档用于保存到数据库 + uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined, }), signal: abortControllerRef.current.signal, });