- 集成文档类型检测工具 - 支持PDF/Word/Excel文件上传 - PDF文档转Base64传给后端API - Office文档(Word/Excel)传给后端解析 - 文本文件直接读取内容 - 添加文档大小验证
850 lines
30 KiB
TypeScript
850 lines
30 KiB
TypeScript
'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,
|
||
};
|
||
}
|