claude-code-cchui/src/hooks/useStreamChat.ts
gaoziman c776fb95b7 feat(前端): 聊天钩子支持文档上传和解析
- 集成文档类型检测工具
- 支持PDF/Word/Excel文件上传
- PDF文档转Base64传给后端API
- Office文档(Word/Excel)传给后端解析
- 文本文件直接读取内容
- 添加文档大小验证
2025-12-22 23:22:39 +08:00

850 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
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<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// 临时存储 Pyodide 执行产生的图片,等待 messageId
const pendingImagesRef = useRef<string[]>([]);
// 临时存储搜索到的图片,等待 messageId
const pendingSearchImagesRef = useRef<SearchImageData[]>([]);
// 临时存储搜索到的视频,等待 messageId
const pendingSearchVideosRef = useRef<SearchVideoData[]>([]);
// 发送消息
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,
};
}