'use client'; import { useState, useCallback, useRef } from 'react'; import { executePythonInPyodide, type LoadingCallback } from '@/services/tools/pyodideRunner'; import { detectDocumentType, isPdfFile, isOfficeDocument, validateDocumentSize, fileToBase64 as documentFileToBase64, type PdfDocumentData, type OfficeDocumentData, getFileMimeType, } from '@/utils/document-utils'; export interface StreamMessage { type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'tool_search_videos' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error'; content?: string; id?: string; name?: string; messageId?: string; inputTokens?: number; outputTokens?: number; error?: string; // Pyodide 执行相关 code?: string; language?: string; // 工具执行结果 success?: boolean; result?: string; images?: string[]; // 搜索到的图片 searchImages?: SearchImageData[]; // 搜索到的视频 searchVideos?: SearchVideoData[]; // 工具使用相关 toolName?: string; usedTools?: string[]; } // 搜索图片数据类型 export interface SearchImageData { title: string; imageUrl: string; width: number; height: number; score: string; position: number; sourceUrl?: string; } // 搜索视频数据类型 export interface SearchVideoData { title: string; link: string; snippet: string; score: string; position: number; authors: string[]; date: string; duration: string; coverImage: string; } export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; thinkingContent?: string; status: 'pending' | 'streaming' | 'completed' | 'error'; error?: string; inputTokens?: number; outputTokens?: number; // 工具执行产生的图片 images?: string[]; // 搜索到的图片 searchImages?: SearchImageData[]; // 搜索到的视频 searchVideos?: SearchVideoData[]; // 用户上传的图片(Base64) uploadedImages?: string[]; // 用户上传的文档 uploadedDocuments?: UploadedDocument[]; // 使用的工具列表 usedTools?: string[]; // Pyodide 加载状态 pyodideStatus?: { stage: 'loading' | 'ready' | 'error'; message: string; progress?: number; }; } /** * 更新消息图片到数据库 */ async function saveMessageImages(messageId: string, images: string[]): Promise { if (!messageId || !images || images.length === 0) return; try { const response = await fetch(`/api/messages/${messageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ images }), }); if (!response.ok) { console.error('Failed to save message images:', await response.text()); } } catch (error) { console.error('Error saving message images:', error); } } /** * 保存搜索到的图片到数据库 */ async function saveMessageSearchImages(messageId: string, searchImages: SearchImageData[]): Promise { if (!messageId || !searchImages || searchImages.length === 0) return; try { const response = await fetch(`/api/messages/${messageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ searchImages }), }); if (!response.ok) { console.error('Failed to save search images:', await response.text()); } } catch (error) { console.error('Error saving search images:', error); } } /** * 保存搜索到的视频到数据库 */ async function saveMessageSearchVideos(messageId: string, searchVideos: SearchVideoData[]): Promise { if (!messageId || !searchVideos || searchVideos.length === 0) return; try { const response = await fetch(`/api/messages/${messageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ searchVideos }), }); if (!response.ok) { console.error('Failed to save search videos:', await response.text()); } } catch (error) { console.error('Error saving search videos:', error); } } /** * 将文件转换为 Base64 */ async function fileToBase64(file: File): 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); const [error, setError] = useState(null); const abortControllerRef = useRef(null); // 临时存储 Pyodide 执行产生的图片,等待 messageId const pendingImagesRef = useRef([]); // 临时存储搜索到的图片,等待 messageId const pendingSearchImagesRef = useRef([]); // 临时存储搜索到的视频,等待 messageId const pendingSearchVideosRef = useRef([]); // 发送消息 const sendMessage = useCallback(async (options: { conversationId: string; message: string; model?: string; tools?: string[]; enableThinking?: boolean; files?: UploadedFile[]; }) => { 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 }[] = []; // PDF 文档(直接传给 Claude API) const pdfDocuments: PdfDocumentData[] = []; // Office 文档(Word/Excel,需要后端解析) const officeDocuments: OfficeDocumentData[] = []; if (files && files.length > 0) { console.log('[useStreamChat] Processing files:', files.length); for (const fileInfo of files) { const docType = detectDocumentType(fileInfo.file); console.log('[useStreamChat] File info:', { name: fileInfo.file.name, type: fileInfo.file.type, size: fileInfo.file.size, isImage: fileInfo.file.type.startsWith('image/'), docType: docType, }); // 处理图片文件 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); } } // 处理 PDF 文件(使用 Claude 原生支持) else if (isPdfFile(fileInfo.file)) { // 验证文件大小 const validation = validateDocumentSize(fileInfo.file); if (!validation.valid) { console.error('[useStreamChat] PDF validation failed:', validation.error); // 可以选择抛出错误或显示提示 continue; } try { const base64 = await documentFileToBase64(fileInfo.file); console.log('[useStreamChat] PDF converted to base64, length:', base64.length); pdfDocuments.push({ name: fileInfo.file.name, size: fileInfo.file.size, data: base64, media_type: 'application/pdf', }); // 保存到 uploadedDocuments 用于前端显示 uploadedDocuments.push({ name: fileInfo.file.name, size: fileInfo.file.size, type: 'pdf', content: `[PDF 文档: ${fileInfo.file.name}]`, // PDF 内容由 Claude 直接处理 }); } catch (err) { console.error('Failed to convert PDF to base64:', err); } } // 处理 Office 文档(Word/Excel,需要后端解析) else if (isOfficeDocument(fileInfo.file)) { // 验证文件大小 const validation = validateDocumentSize(fileInfo.file); if (!validation.valid) { console.error('[useStreamChat] Office document validation failed:', validation.error); continue; } try { const base64 = await documentFileToBase64(fileInfo.file); const mimeType = getFileMimeType(fileInfo.file); console.log('[useStreamChat] Office document converted to base64, length:', base64.length, 'type:', docType); officeDocuments.push({ name: fileInfo.file.name, size: fileInfo.file.size, data: base64, type: docType as 'word' | 'excel', mimeType: mimeType, }); // 保存到 uploadedDocuments 用于前端显示 uploadedDocuments.push({ name: fileInfo.file.name, size: fileInfo.file.size, type: docType, content: `[${docType === 'word' ? 'Word' : 'Excel'} 文档: ${fileInfo.file.name}]`, // 内容由后端解析后补充 }); } catch (err) { console.error('Failed to convert Office document 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, // 显示原始消息,不包含文档内容 status: 'completed', uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined, uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined, }; // 添加 AI 消息占位 const assistantMessage: ChatMessage = { id: `assistant-${Date.now()}`, role: 'assistant', content: '', thinkingContent: '', status: 'streaming', }; setMessages((prev) => [...prev, userMessage, assistantMessage]); setIsStreaming(true); setError(null); // 创建 AbortController abortControllerRef.current = new AbortController(); try { // 调试日志:确认图片和文档数据 console.log('[useStreamChat] Sending request with:', { conversationId, messageLength: finalMessage.length, model, tools, enableThinking, imagesCount: imageContents.length, pdfDocumentsCount: pdfDocuments.length, officeDocumentsCount: officeDocuments.length, images: imageContents.length > 0 ? imageContents.map(img => ({ type: img.type, media_type: img.media_type, dataLength: img.data.length, })) : undefined, pdfDocuments: pdfDocuments.length > 0 ? pdfDocuments.map(doc => ({ name: doc.name, size: doc.size, dataLength: doc.data.length, })) : undefined, officeDocuments: officeDocuments.length > 0 ? officeDocuments.map(doc => ({ name: doc.name, size: doc.size, type: doc.type, dataLength: doc.data.length, })) : undefined, }); const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ conversationId, 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, // 传递 PDF 文档给后端(用于 Claude 原生 document 类型) pdfDocuments: pdfDocuments.length > 0 ? pdfDocuments : undefined, // 传递 Office 文档给后端(需要后端解析) officeDocuments: officeDocuments.length > 0 ? officeDocuments : undefined, }), signal: abortControllerRef.current.signal, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to send message'); } const reader = response.body?.getReader(); if (!reader) { throw new Error('No response body'); } const decoder = new TextDecoder(); let buffer = ''; let fullContent = ''; let thinkingContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') continue; try { const event: StreamMessage = JSON.parse(data); if (event.type === 'thinking') { thinkingContent += event.content || ''; setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], thinkingContent, }; } return updated; }); } else if (event.type === 'text') { fullContent += event.content || ''; setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], content: fullContent, }; } return updated; }); } else if (event.type === 'tool_execution_result') { // 处理工具执行结果(包括图片) if (event.images && event.images.length > 0) { setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { const existingImages = updated[lastIndex].images || []; updated[lastIndex] = { ...updated[lastIndex], images: [...existingImages, ...event.images!], }; } return updated; }); } } else if (event.type === 'tool_search_images') { // 处理图片搜索结果 if (event.searchImages && event.searchImages.length > 0) { // 存储到临时变量,等待 messageId 后保存到数据库 pendingSearchImagesRef.current = [ ...pendingSearchImagesRef.current, ...event.searchImages, ]; // 更新 UI setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { const existingSearchImages = updated[lastIndex].searchImages || []; updated[lastIndex] = { ...updated[lastIndex], searchImages: [...existingSearchImages, ...event.searchImages!], }; } return updated; }); } } else if (event.type === 'tool_search_videos') { // 处理视频搜索结果 if (event.searchVideos && event.searchVideos.length > 0) { // 存储到临时变量,等待 messageId 后保存到数据库 pendingSearchVideosRef.current = [ ...pendingSearchVideosRef.current, ...event.searchVideos, ]; // 更新 UI setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { const existingSearchVideos = updated[lastIndex].searchVideos || []; updated[lastIndex] = { ...updated[lastIndex], searchVideos: [...existingSearchVideos, ...event.searchVideos!], }; } return updated; }); } } else if (event.type === 'tool_used') { // 实时工具使用事件 if (event.toolName) { setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { const existingTools = updated[lastIndex].usedTools || []; // 避免重复添加 if (!existingTools.includes(event.toolName!)) { updated[lastIndex] = { ...updated[lastIndex], usedTools: [...existingTools, event.toolName!], }; } } return updated; }); } } else if (event.type === 'pyodide_execution_required') { // 需要在浏览器端执行 Python 图形代码 const code = event.code || ''; // 更新 Pyodide 加载状态 const updatePyodideStatus: LoadingCallback = (status) => { setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], pyodideStatus: status, }; } return updated; }); }; // 执行 Pyodide try { const result = await executePythonInPyodide(code, updatePyodideStatus); // 添加执行结果文本 const resultText = result.success ? `\n\n✅ Python [Pyodide] 代码执行完成 (${result.executionTime}ms)${result.images.length > 0 ? `,生成 ${result.images.length} 张图表` : ''}\n\n` : `\n\n❌ Python 代码执行失败: ${result.error}\n\n`; fullContent += resultText; // 将图片存入临时变量,等待 messageId 后保存到数据库 if (result.images && result.images.length > 0) { pendingImagesRef.current = [...pendingImagesRef.current, ...result.images]; } // 更新消息,添加图片 setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { const existingImages = updated[lastIndex].images || []; updated[lastIndex] = { ...updated[lastIndex], content: fullContent, images: [...existingImages, ...result.images], pyodideStatus: undefined, // 清除加载状态 }; } return updated; }); } catch (pyodideError) { const errorMsg = pyodideError instanceof Error ? pyodideError.message : '未知错误'; fullContent += `\n\n❌ Pyodide 执行错误: ${errorMsg}\n\n`; setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], content: fullContent, pyodideStatus: { stage: 'error', message: errorMsg }, }; } return updated; }); } } else if (event.type === 'done') { // 如果有待保存的图片,保存到数据库 if (event.messageId && pendingImagesRef.current.length > 0) { saveMessageImages(event.messageId, pendingImagesRef.current); pendingImagesRef.current = []; // 清空临时存储 } // 如果有待保存的搜索图片,保存到数据库 if (event.messageId && pendingSearchImagesRef.current.length > 0) { saveMessageSearchImages(event.messageId, pendingSearchImagesRef.current); pendingSearchImagesRef.current = []; // 清空临时存储 } // 如果有待保存的搜索视频,保存到数据库 if (event.messageId && pendingSearchVideosRef.current.length > 0) { saveMessageSearchVideos(event.messageId, pendingSearchVideosRef.current); pendingSearchVideosRef.current = []; // 清空临时存储 } setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { // 如果 done 事件包含 usedTools,使用它(保证完整性) const finalUsedTools = event.usedTools || updated[lastIndex].usedTools; updated[lastIndex] = { ...updated[lastIndex], id: event.messageId || updated[lastIndex].id, status: 'completed', inputTokens: event.inputTokens, outputTokens: event.outputTokens, usedTools: finalUsedTools, }; } return updated; }); } else if (event.type === 'error') { throw new Error(event.error || 'Unknown error'); } } catch (e) { if (e instanceof SyntaxError) { // 忽略 JSON 解析错误 continue; } throw e; } } } } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { // 用户取消 setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], status: 'completed', content: updated[lastIndex].content || '(已取消)', }; } return updated; }); } else { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; setError(errorMessage); setMessages((prev) => { const updated = [...prev]; const lastIndex = updated.length - 1; if (updated[lastIndex]?.role === 'assistant') { updated[lastIndex] = { ...updated[lastIndex], status: 'error', error: errorMessage, content: updated[lastIndex].content || '', }; } return updated; }); } } finally { setIsStreaming(false); abortControllerRef.current = null; } }, []); // 停止生成 const stopGeneration = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }, []); // 清空消息 const clearMessages = useCallback(() => { setMessages([]); setError(null); }, []); // 设置初始消息 const setInitialMessages = useCallback((initialMessages: ChatMessage[]) => { setMessages(initialMessages); }, []); /** * 重新生成消息 * @param assistantMessageId 要重新生成的 AI 消息 ID * @returns 对应的用户消息信息,用于重新发送;如果找不到则返回 null */ const regenerateMessage = useCallback(async (assistantMessageId: string): Promise<{ userMessage: ChatMessage; } | null> => { // 1. 在消息列表中找到该 AI 消息的索引 const assistantIndex = messages.findIndex(m => m.id === assistantMessageId); if (assistantIndex === -1) { console.error('[regenerateMessage] AI message not found:', assistantMessageId); return null; } // 2. 找到对应的用户消息(AI 消息的前一条) const userIndex = assistantIndex - 1; if (userIndex < 0 || messages[userIndex].role !== 'user') { console.error('[regenerateMessage] User message not found for AI message:', assistantMessageId); return null; } const userMessage = { ...messages[userIndex] }; // 3. 尝试从数据库删除 AI 消息(只有保存到数据库的消息才需要删除) // 临时消息 ID 格式为 'assistant-timestamp',数据库消息 ID 是 nanoid 格式 if (!assistantMessageId.startsWith('assistant-')) { try { const response = await fetch(`/api/messages/${assistantMessageId}`, { method: 'DELETE', }); if (!response.ok) { console.warn('[regenerateMessage] Failed to delete message from database:', await response.text()); } } catch (err) { console.warn('[regenerateMessage] Error deleting message from database:', err); } } // 4. 从前端状态中移除用户消息、AI 消息及其后的所有消息 // 因为 sendMessage 会重新添加用户消息和 AI 消息占位 setMessages(prev => prev.slice(0, userIndex)); // 5. 返回用户消息信息 return { userMessage, }; }, [messages]); return { messages, isStreaming, error, sendMessage, stopGeneration, clearMessages, setInitialMessages, regenerateMessage, }; }