feat(前端): 流式聊天支持 Pyodide 图形渲染
useStreamChat.ts: - 处理 pyodide_execution_required 事件触发浏览器端执行 - 处理 tool_execution_result 事件接收服务端执行结果 - 添加 Pyodide 加载状态管理和进度显示 - 实现图片数据保存到数据库 - ChatMessage 类型增加 images 和 pyodideStatus 属性 page.tsx: - 从数据库加载历史消息的图片数据 - 传递 images 和 pyodideStatus 到 MessageBubble 组件
This commit is contained in:
parent
e5c5593686
commit
7d8a6a6939
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user