feat(前端): 流式聊天支持 Pyodide 图形渲染

useStreamChat.ts:
- 处理 pyodide_execution_required 事件触发浏览器端执行
- 处理 tool_execution_result 事件接收服务端执行结果
- 添加 Pyodide 加载状态管理和进度显示
- 实现图片数据保存到数据库
- ChatMessage 类型增加 images 和 pyodideStatus 属性

page.tsx:
- 从数据库加载历史消息的图片数据
- 传递 images 和 pyodideStatus 到 MessageBubble 组件
This commit is contained in:
gaoziman 2025-12-19 20:21:00 +08:00
parent e5c5593686
commit 7d8a6a6939
2 changed files with 134 additions and 1 deletions

View File

@ -66,6 +66,8 @@ export default function ChatPage({ params }: PageProps) {
status: 'completed' as const, status: 'completed' as const,
inputTokens: msg.inputTokens || undefined, inputTokens: msg.inputTokens || undefined,
outputTokens: msg.outputTokens || undefined, outputTokens: msg.outputTokens || undefined,
// 从数据库加载图片数据
images: (msg.images as string[]) || undefined,
})); }));
setInitialMessages(historyMessages); setInitialMessages(historyMessages);
} }
@ -272,6 +274,8 @@ export default function ChatPage({ params }: PageProps) {
thinkingContent={message.thinkingContent} thinkingContent={message.thinkingContent}
isStreaming={message.status === 'streaming'} isStreaming={message.status === 'streaming'}
error={message.error} error={message.error}
images={message.images}
pyodideStatus={message.pyodideStatus}
/> />
)) ))
)} )}

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { executePythonInPyodide, type LoadingCallback } from '@/services/tools/pyodideRunner';
export interface StreamMessage { export interface StreamMessage {
type: 'thinking' | 'text' | 'tool_use_start' | 'done' | 'error'; type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'pyodide_execution_required' | 'done' | 'error';
content?: string; content?: string;
id?: string; id?: string;
name?: string; name?: string;
@ -11,6 +12,13 @@ export interface StreamMessage {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
error?: string; error?: string;
// Pyodide 执行相关
code?: string;
language?: string;
// 工具执行结果
success?: boolean;
result?: string;
images?: string[];
} }
export interface ChatMessage { export interface ChatMessage {
@ -22,6 +30,37 @@ export interface ChatMessage {
error?: string; error?: string;
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
// 工具执行产生的图片
images?: 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);
}
} }
export function useStreamChat() { export function useStreamChat() {
@ -29,6 +68,8 @@ export function useStreamChat() {
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
// 临时存储 Pyodide 执行产生的图片,等待 messageId
const pendingImagesRef = useRef<string[]>([]);
// 发送消息 // 发送消息
const sendMessage = useCallback(async (options: { const sendMessage = useCallback(async (options: {
@ -137,7 +178,95 @@ export function useStreamChat() {
} }
return updated; 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 === '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') { } else if (event.type === 'done') {
// 如果有待保存的图片,保存到数据库
if (event.messageId && pendingImagesRef.current.length > 0) {
saveMessageImages(event.messageId, pendingImagesRef.current);
pendingImagesRef.current = []; // 清空临时存储
}
setMessages((prev) => { setMessages((prev) => {
const updated = [...prev]; const updated = [...prev];
const lastIndex = updated.length - 1; const lastIndex = updated.length - 1;