Compare commits

...

10 Commits

Author SHA1 Message Date
gaoziman
7d8a6a6939 feat(前端): 流式聊天支持 Pyodide 图形渲染
useStreamChat.ts:
- 处理 pyodide_execution_required 事件触发浏览器端执行
- 处理 tool_execution_result 事件接收服务端执行结果
- 添加 Pyodide 加载状态管理和进度显示
- 实现图片数据保存到数据库
- ChatMessage 类型增加 images 和 pyodideStatus 属性

page.tsx:
- 从数据库加载历史消息的图片数据
- 传递 images 和 pyodideStatus 到 MessageBubble 组件
2025-12-19 20:21:00 +08:00
gaoziman
e5c5593686 feat(组件): 消息气泡支持图形展示
MessageBubble.tsx:
- 集成 CodeExecutionResult 组件显示代码执行图片
- 添加 Pyodide 加载状态显示
- 支持 images 和 pyodideStatus 属性
- 新增 ToolResultDisplay 子组件处理工具结果

MarkdownRenderer.tsx:
- 修复图片组件属性传递问题
- 改用 spread 操作符传递所有 img 属性
2025-12-19 20:20:33 +08:00
gaoziman
58d288637a feat(组件): 添加代码执行结果展示组件
- 新增 CodeExecutionResult 组件展示代码执行输出和图形
- 支持 Base64 图片渲染和点击放大查看
- 显示执行引擎(Pyodide/Piston)和执行时间
- 新增 PyodideLoading 组件显示 Python 环境加载进度
- 支持暗色主题
2025-12-19 20:20:11 +08:00
gaoziman
5fe0552338 feat(API): 聊天接口支持 Pyodide 图形代码执行
- 添加图表绘制规范到系统提示词
- 支持发送 pyodide_execution_required 事件通知前端
- 工具执行结果增加图片数据传递
- 优化图表绘制指导(单次绘图、子图展示、中文支持)
2025-12-19 20:19:51 +08:00
gaoziman
5cc4fbb7a0 feat(API): 添加消息更新接口
- 新增 PATCH /api/messages/[messageId] 用于更新消息
- 支持更新消息的图片数据(用于保存 Pyodide 执行结果)
- 支持追加模式更新图片数组
- 新增 GET /api/messages/[messageId] 获取单个消息
2025-12-19 20:19:26 +08:00
gaoziman
68ba9b3204 feat(工具): 实现混合代码执行架构
codeExecution.ts:
- 实现 Pyodide + Piston 混合执行架构
- Python 图形代码使用 Pyodide 在浏览器端执行
- 其他代码使用 Piston API 在服务端执行
- 响应增加 images、engine、executionTime 字段

executor.ts:
- 集成代码分析工具判断执行方式
- 支持返回 requiresPyodide 标记浏览器端执行需求
- 传递图片数据到执行结果
2025-12-19 20:18:58 +08:00
gaoziman
ef45e14534 feat(工具): 添加 Pyodide 浏览器端 Python 运行时
- 实现基于 WebAssembly 的 Python 运行环境
- 支持 matplotlib 图形渲染并输出为 Base64 图片
- 实现中文字体加载(Noto Sans SC)
- 预注册 seaborn-whitegrid 等多种图表样式
- 单例模式管理 Pyodide 实例,优化加载性能
2025-12-19 20:18:34 +08:00
gaoziman
ba4e00a341 feat(工具): 添加代码分析工具模块
- 新增 codeAnalyzer.ts 用于分析代码特征
- 实现 detectGraphicsCode 检测图形绘制代码
- 实现 shouldUsePyodide 判断是否使用浏览器端执行
- 支持 matplotlib、seaborn、plotly 等可视化库检测
2025-12-19 20:18:11 +08:00
gaoziman
bfcaf5a53a feat(类型): 添加工具调用相关类型定义
- 添加 ToolCall 接口定义工具调用记录
- 添加 ToolResult 接口定义工具执行结果
- 支持图片数据、执行引擎和执行时间属性
2025-12-19 20:17:50 +08:00
gaoziman
c59dee8db9 feat(数据库): 为消息表添加 images 字段支持
- 在 messages 表添加 images jsonb 字段用于存储代码执行产生的图片
- 新增迁移文件 0003_melted_shockwave.sql
- 更新迁移元数据和快照
2025-12-19 20:17:28 +08:00
16 changed files with 2123 additions and 22 deletions

View File

@ -92,7 +92,15 @@ const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵
##
-
-
- `;
-
##
使 code_execution
1. **** code_execution
2. ****
3. **使** seaborn-whitegrid plt.style.use()
4. ****Noto Sans SC使
5. ****使使(subplot)`;
// POST /api/chat - 发送消息并获取 AI 回复
export async function POST(request: Request) {
@ -393,6 +401,26 @@ export async function POST(request: Request) {
// 执行工具
const result = await executeTool(tc.name, tc.input);
// 检查是否需要浏览器端 Pyodide 执行
if (result.requiresPyodide) {
// 发送 Pyodide 执行请求事件
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'pyodide_execution_required',
id: tc.id,
name: tc.name,
code: result.code,
language: result.language,
})}\n\n`));
// 将占位工具结果发送给 AI稍后会被前端执行结果替换
toolResults.push({
type: 'tool_result',
tool_use_id: tc.id,
content: '代码正在浏览器端执行中,请稍候...',
});
continue;
}
// 发送工具执行结果事件(使用简短版本)
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_execution_result',
@ -400,6 +428,7 @@ export async function POST(request: Request) {
name: tc.name,
success: result.success,
result: result.displayResult,
images: result.images,
})}\n\n`));
// 将简短的工具结果显示给用户

View File

@ -0,0 +1,119 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { messages } from '@/drizzle/schema';
import { eq } from 'drizzle-orm';
interface RouteParams {
params: Promise<{ messageId: string }>;
}
/**
* PATCH /api/messages/[messageId] -
* Pyodide
*/
export async function PATCH(request: Request, { params }: RouteParams) {
try {
const { messageId } = await params;
const body = await request.json();
// 验证 messageId
if (!messageId) {
return NextResponse.json(
{ error: 'Message ID is required' },
{ status: 400 }
);
}
// 查找消息
const existingMessage = await db.query.messages.findFirst({
where: eq(messages.messageId, messageId),
});
if (!existingMessage) {
return NextResponse.json(
{ error: 'Message not found' },
{ status: 404 }
);
}
// 构建更新数据
const updateData: {
images?: string[];
content?: string;
updatedAt: Date;
} = {
updatedAt: new Date(),
};
// 更新图片(追加模式)
if (body.images && Array.isArray(body.images)) {
const existingImages = (existingMessage.images as string[]) || [];
updateData.images = [...existingImages, ...body.images];
}
// 更新内容(如果提供)
if (body.content !== undefined) {
updateData.content = body.content;
}
// 执行更新
await db
.update(messages)
.set(updateData)
.where(eq(messages.messageId, messageId));
// 返回更新后的消息
const updatedMessage = await db.query.messages.findFirst({
where: eq(messages.messageId, messageId),
});
return NextResponse.json({
success: true,
message: updatedMessage,
});
} catch (error) {
console.error('Update message error:', error);
return NextResponse.json(
{ error: 'Failed to update message' },
{ status: 500 }
);
}
}
/**
* GET /api/messages/[messageId] -
*/
export async function GET(request: Request, { params }: RouteParams) {
try {
const { messageId } = await params;
if (!messageId) {
return NextResponse.json(
{ error: 'Message ID is required' },
{ status: 400 }
);
}
const message = await db.query.messages.findFirst({
where: eq(messages.messageId, messageId),
});
if (!message) {
return NextResponse.json(
{ error: 'Message not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message,
});
} catch (error) {
console.error('Get message error:', error);
return NextResponse.json(
{ error: 'Failed to get message' },
{ status: 500 }
);
}
}

View File

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

View File

@ -0,0 +1,206 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
interface CodeExecutionResultProps {
/** 执行输出文本 */
output?: string;
/** 错误信息 */
error?: string;
/** Base64 编码的图片数组 */
images?: string[];
/** 执行语言 */
language?: string;
/** 执行引擎 */
engine?: 'pyodide' | 'piston';
/** 执行时间 (ms) */
executionTime?: number;
/** 是否执行成功 */
success?: boolean;
}
/**
*
*
*/
export function CodeExecutionResult({
output,
error,
images,
language,
engine,
executionTime,
success = true,
}: CodeExecutionResultProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const hasOutput = output && output.trim().length > 0;
const hasError = error && error.trim().length > 0;
const hasImages = images && images.length > 0;
// 如果没有任何内容,不渲染
if (!hasOutput && !hasError && !hasImages) {
return null;
}
return (
<div className="code-execution-result mt-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden bg-gray-50 dark:bg-gray-800">
{/* 头部信息 */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${success ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{success ? '执行成功' : '执行失败'}
</span>
{language && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{language}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
{engine && (
<span className="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-600">
{engine === 'pyodide' ? 'Pyodide' : 'Piston'}
</span>
)}
{executionTime && (
<span>{executionTime}ms</span>
)}
</div>
</div>
{/* 图片输出 */}
{hasImages && (
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap gap-3">
{images.map((img, index) => (
<div
key={index}
className="relative cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setSelectedImage(img)}
>
<Image
src={`data:image/png;base64,${img}`}
alt={`Chart ${index + 1}`}
width={400}
height={300}
className="rounded-lg shadow-md max-w-full h-auto"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/50 text-white text-xs rounded">
{index + 1}
</div>
</div>
))}
</div>
</div>
)}
{/* 文本输出 */}
{hasOutput && (
<div className="p-3">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1"></div>
<pre className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap font-mono bg-white dark:bg-gray-900 p-2 rounded border border-gray-200 dark:border-gray-700 max-h-60 overflow-auto">
{output}
</pre>
</div>
)}
{/* 错误信息 */}
{hasError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20">
<div className="text-xs text-red-600 dark:text-red-400 mb-1"></div>
<pre className="text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono">
{error}
</pre>
</div>
)}
{/* 图片放大模态框 */}
{selectedImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-[90vw] max-h-[90vh]">
<Image
src={`data:image/png;base64,${selectedImage}`}
alt="Chart enlarged"
width={1200}
height={900}
className="rounded-lg shadow-2xl"
style={{ maxWidth: '100%', maxHeight: '90vh', objectFit: 'contain' }}
/>
<button
className="absolute top-2 right-2 w-8 h-8 flex items-center justify-center bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg hover:bg-white dark:hover:bg-gray-700 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedImage(null);
}}
>
<svg className="w-5 h-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
/**
* Pyodide
*/
interface PyodideLoadingProps {
stage: 'loading' | 'ready' | 'error';
message: string;
progress?: number;
}
export function PyodideLoading({ stage, message, progress }: PyodideLoadingProps) {
if (stage === 'ready') {
return null;
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
{stage === 'loading' && (
<>
<div className="relative w-5 h-5">
<div className="absolute inset-0 border-2 border-blue-200 dark:border-blue-700 rounded-full" />
<div
className="absolute inset-0 border-2 border-blue-500 rounded-full animate-spin"
style={{ borderTopColor: 'transparent', borderRightColor: 'transparent' }}
/>
</div>
<div className="flex-1">
<div className="text-sm text-blue-700 dark:text-blue-300">{message}</div>
{progress !== undefined && (
<div className="mt-1 h-1.5 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
</>
)}
{stage === 'error' && (
<>
<div className="w-5 h-5 text-red-500">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="text-sm text-red-700 dark:text-red-300">{message}</div>
</>
)}
</div>
);
}
export default CodeExecutionResult;

View File

@ -5,8 +5,9 @@ import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, L
import { Avatar } from '@/components/ui/Avatar';
import { AILogo } from '@/components/ui/AILogo';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
import { cn } from '@/lib/utils';
import type { Message, User } from '@/types';
import type { Message, User, ToolResult } from '@/types';
interface MessageBubbleProps {
message: Message;
@ -14,9 +15,17 @@ interface MessageBubbleProps {
thinkingContent?: string;
isStreaming?: boolean;
error?: string;
/** 代码执行产生的图片Base64 */
images?: string[];
/** Pyodide 加载状态 */
pyodideStatus?: {
stage: 'loading' | 'ready' | 'error';
message: string;
progress?: number;
};
}
export function MessageBubble({ message, user, thinkingContent, isStreaming, error }: MessageBubbleProps) {
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, pyodideStatus }: MessageBubbleProps) {
const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false);
@ -96,6 +105,34 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
) : null}
</div>
{/* 工具调用结果 - 代码执行图片展示 */}
{message.toolResults && message.toolResults.length > 0 && (
<div className="mt-4">
{message.toolResults.map((result, index) => (
<ToolResultDisplay key={index} result={result} />
))}
</div>
)}
{/* Pyodide 加载状态 */}
{pyodideStatus && (
<div className="mt-4">
<PyodideLoading
stage={pyodideStatus.stage}
message={pyodideStatus.message}
progress={pyodideStatus.progress}
/>
</div>
)}
{/* 代码执行图片(从 props 传入) */}
{images && images.length > 0 && (
<CodeExecutionResult
images={images}
success={true}
/>
)}
{/* 流式状态指示器 */}
{isStreaming && message.content && (
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
@ -149,3 +186,32 @@ function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
</button>
);
}
/**
*
*
*/
interface ToolResultDisplayProps {
result: ToolResult;
}
function ToolResultDisplay({ result }: ToolResultDisplayProps) {
// 只有代码执行工具才显示图片
if (result.toolName !== 'code_execution') {
return null;
}
// 如果没有图片,不显示
if (!result.images || result.images.length === 0) {
return null;
}
return (
<CodeExecutionResult
images={result.images}
engine={result.engine}
executionTime={result.executionTime}
success={!result.isError}
/>
);
}

View File

@ -196,11 +196,11 @@ const markdownComponents = {
},
// 图片
img({ src, alt }: { src?: string; alt?: string }) {
img(props: React.ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
src={src}
alt={alt || ''}
{...props}
alt={props.alt || ''}
className="max-w-full h-auto rounded-lg my-3"
/>
);

View File

@ -0,0 +1 @@
ALTER TABLE "messages" ADD COLUMN "images" jsonb;

View File

@ -0,0 +1,609 @@
{
"id": "43d40968-3585-47d7-ac89-873c6b72655b",
"prevId": "25c992de-4501-4c60-97bb-c5cbfd4ef130",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.conversations": {
"name": "conversations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "'新对话'"
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"tools": {
"name": "tools",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"enable_thinking": {
"name": "enable_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"system_prompt": {
"name": "system_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"temperature": {
"name": "temperature",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"message_count": {
"name": "message_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"total_tokens": {
"name": "total_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"is_pinned": {
"name": "is_pinned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_message_at": {
"name": "last_message_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"conversations_conversation_id_unique": {
"name": "conversations_conversation_id_unique",
"nullsNotDistinct": false,
"columns": [
"conversation_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thinking_content": {
"name": "thinking_content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"thinking_collapsed": {
"name": "thinking_collapsed",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"tool_calls": {
"name": "tool_calls",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"tool_results": {
"name": "tool_results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"images": {
"name": "images",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"input_tokens": {
"name": "input_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"output_tokens": {
"name": "output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"status": {
"name": "status",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'completed'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"feedback": {
"name": "feedback",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"messages_message_id_unique": {
"name": "messages_message_id_unique",
"nullsNotDistinct": false,
"columns": [
"message_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.models": {
"name": "models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"supports_tools": {
"name": "supports_tools",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"supports_thinking": {
"name": "supports_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"supports_vision": {
"name": "supports_vision",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"max_tokens": {
"name": "max_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 8192
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 200000
},
"is_enabled": {
"name": "is_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"models_model_id_unique": {
"name": "models_model_id_unique",
"nullsNotDistinct": false,
"columns": [
"model_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tools": {
"name": "tools",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"tool_id": {
"name": "tool_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"input_schema": {
"name": "input_schema",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_enabled": {
"name": "is_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tools_tool_id_unique": {
"name": "tools_tool_id_unique",
"nullsNotDistinct": false,
"columns": [
"tool_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_settings": {
"name": "user_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"cch_url": {
"name": "cch_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"default": "'http://localhost:13500'"
},
"cch_api_key": {
"name": "cch_api_key",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"cch_api_key_configured": {
"name": "cch_api_key_configured",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"default_model": {
"name": "default_model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"default": "'claude-sonnet-4-20250514'"
},
"default_tools": {
"name": "default_tools",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[\"web_search\",\"code_execution\",\"web_fetch\"]'::jsonb"
},
"system_prompt": {
"name": "system_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"temperature": {
"name": "temperature",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false,
"default": "'0.7'"
},
"theme": {
"name": "theme",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'light'"
},
"language": {
"name": "language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false,
"default": "'zh-CN'"
},
"font_size": {
"name": "font_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 15
},
"enable_thinking": {
"name": "enable_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"save_chat_history": {
"name": "save_chat_history",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -22,6 +22,13 @@
"when": 1766110727907,
"tag": "0002_bizarre_sunfire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1766143752533,
"tag": "0003_melted_shockwave",
"breakpoints": true
}
]
}

View File

@ -83,6 +83,8 @@ export const messages = pgTable('messages', {
// 工具调用记录
toolCalls: jsonb('tool_calls').$type<ToolCall[]>(),
toolResults: jsonb('tool_results').$type<ToolResult[]>(),
// 代码执行产生的图片Base64 编码数组)
images: jsonb('images').$type<string[]>(),
// Token 统计
inputTokens: integer('input_tokens').default(0),
outputTokens: integer('output_tokens').default(0),

View File

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

View File

@ -0,0 +1,107 @@
/**
*
*
*
*/
// 加载状态回调类型(定义在这里以便服务端使用)
export type LoadingCallback = (status: {
stage: 'loading' | 'ready' | 'error';
message: string;
progress?: number;
}) => void;
/**
*
*/
export function detectGraphicsCode(code: string): boolean {
const graphicsKeywords = [
// matplotlib
'matplotlib',
'pyplot',
'plt.',
'.plot(',
'.scatter(',
'.bar(',
'.barh(',
'.hist(',
'.pie(',
'.boxplot(',
'.violinplot(',
'.heatmap(',
'.imshow(',
'.contour(',
'.fill(',
'.errorbar(',
'.stem(',
'.step(',
'savefig',
'show()',
'.figure(',
'.subplot(',
'.subplots(',
// seaborn
'seaborn',
'sns.',
// plotly
'plotly',
'px.',
'go.Figure',
// 其他可视化库
'bokeh',
'altair',
];
const lowerCode = code.toLowerCase();
return graphicsKeywords.some(keyword => lowerCode.includes(keyword.toLowerCase()));
}
/**
* 使 Pyodide
* @param code
* @param language
* @returns Pyodide
*/
export function shouldUsePyodide(code: string, language: string): boolean {
// 只有 Python 代码才考虑使用 Pyodide
if (!['python', 'python3', 'py'].includes(language.toLowerCase())) {
return false;
}
// 如果代码包含图形绘制,需要使用 Pyodide
return detectGraphicsCode(code);
}
/**
*
*/
export interface CodeAnalysis {
language: string;
hasGraphics: boolean;
requiresPyodide: boolean;
estimatedComplexity: 'simple' | 'medium' | 'complex';
}
/**
*
*/
export function analyzeCode(code: string, language: string): CodeAnalysis {
const hasGraphics = detectGraphicsCode(code);
const requiresPyodide = shouldUsePyodide(code, language);
// 估算复杂度
const lines = code.split('\n').filter(l => l.trim()).length;
let complexity: 'simple' | 'medium' | 'complex' = 'simple';
if (lines > 50 || hasGraphics) {
complexity = 'complex';
} else if (lines > 20) {
complexity = 'medium';
}
return {
language: language.toLowerCase(),
hasGraphics,
requiresPyodide,
estimatedComplexity: complexity,
};
}

View File

@ -1,13 +1,27 @@
/**
* Code Execution
* 使 Piston API
* Pyodide ( Python) + Piston API ()
*
* - Python + Pyodide ( matplotlib )
* - / Piston API
*
* Piston API 文档: https://github.com/engineer-man/piston
* Pyodide 文档: https://pyodide.org/
*/
import {
shouldUsePyodide,
executePythonInPyodide,
type LoadingCallback,
type PyodideExecutionResult,
} from './pyodideRunner';
export interface CodeExecutionInput {
code: string;
language: string;
stdin?: string;
/** Pyodide 加载进度回调(可选) */
onProgress?: LoadingCallback;
}
export interface CodeExecutionResponse {
@ -16,6 +30,12 @@ export interface CodeExecutionResponse {
error?: string;
language?: string;
version?: string;
/** Base64 编码的图片数组matplotlib 输出) */
images?: string[];
/** 执行引擎: 'pyodide' | 'piston' */
engine?: 'pyodide' | 'piston';
/** 执行时间 (ms) */
executionTime?: number;
}
// Piston API 支持的语言映射
@ -53,9 +73,67 @@ const PISTON_API_URL = 'https://emkc.org/api/v2/piston/execute';
/**
*
*
* - Python + Pyodide ()
* - Piston API ()
*/
export async function executeCode(input: CodeExecutionInput): Promise<CodeExecutionResponse> {
const { code, language, stdin } = input;
const { code, language, stdin, onProgress } = input;
const startTime = Date.now();
// 检查是否应该使用 PyodidePython + 图形)
if (shouldUsePyodide(code, language)) {
return executePythonWithPyodide(code, onProgress, startTime);
}
// 使用 Piston API 执行
return executePythonWithPiston(code, language, stdin, startTime);
}
/**
* 使 Pyodide Python matplotlib
*/
async function executePythonWithPyodide(
code: string,
onProgress?: LoadingCallback,
startTime?: number
): Promise<CodeExecutionResponse> {
const start = startTime || Date.now();
try {
const result: PyodideExecutionResult = await executePythonInPyodide(code, onProgress);
return {
success: result.success,
output: result.output,
error: result.error,
language: 'python',
version: 'Pyodide (WebAssembly)',
images: result.images,
engine: 'pyodide',
executionTime: Date.now() - start,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '执行错误',
language: 'python',
engine: 'pyodide',
executionTime: Date.now() - start,
};
}
}
/**
* 使 Piston API
*/
async function executePythonWithPiston(
code: string,
language: string,
stdin?: string,
startTime?: number
): Promise<CodeExecutionResponse> {
const start = startTime || Date.now();
// 获取语言映射
const langConfig = LANGUAGE_MAP[language.toLowerCase()];
@ -63,6 +141,8 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
return {
success: false,
error: `不支持的编程语言: ${language}。支持的语言: ${Object.keys(LANGUAGE_MAP).join(', ')}`,
engine: 'piston',
executionTime: Date.now() - start,
};
}
@ -96,6 +176,8 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
return {
success: false,
error: `代码执行 API 错误: ${response.status}`,
engine: 'piston',
executionTime: Date.now() - start,
};
}
@ -108,6 +190,8 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
error: data.compile.stderr || data.compile.output || '编译错误',
language: langConfig.language,
version: langConfig.version,
engine: 'piston',
executionTime: Date.now() - start,
};
}
@ -119,6 +203,8 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
error: data.run.stderr || '',
language: langConfig.language,
version: langConfig.version,
engine: 'piston',
executionTime: Date.now() - start,
};
}
@ -128,12 +214,16 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
error: data.run?.stderr || '',
language: langConfig.language,
version: langConfig.version,
engine: 'piston',
executionTime: Date.now() - start,
};
} catch (error) {
console.error('Code execution error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '未知错误',
engine: 'piston',
executionTime: Date.now() - start,
};
}
}
@ -173,20 +263,37 @@ function getFileName(language: string): string {
* - AI
*/
export function formatExecutionResult(response: CodeExecutionResponse): string {
if (!response.success && !response.output) {
if (!response.success && !response.output && !response.images?.length) {
return `❌ 执行失败: ${response.error}`;
}
let result = '';
if (response.language && response.version) {
result += `**语言**: ${response.language} (v${response.version})\n\n`;
// 执行引擎信息
if (response.engine) {
const engineName = response.engine === 'pyodide' ? 'Pyodide (浏览器)' : 'Piston (服务器)';
result += `**执行引擎**: ${engineName}\n`;
}
if (response.language && response.version) {
result += `**语言**: ${response.language} (${response.version})\n`;
}
if (response.executionTime) {
result += `**执行时间**: ${response.executionTime}ms\n`;
}
result += '\n';
if (response.output) {
result += `## 输出结果\n\`\`\`\n${response.output}\n\`\`\`\n`;
}
// 图片信息(仅标记有图片,实际渲染由前端处理)
if (response.images && response.images.length > 0) {
result += `\n## 图形输出\n已生成 ${response.images.length} 张图表\n`;
}
if (response.error) {
result += `\n## 错误信息\n\`\`\`\n${response.error}\n\`\`\`\n`;
}
@ -198,21 +305,34 @@ export function formatExecutionResult(response: CodeExecutionResponse): string {
*
*/
export function formatExecutionResultShort(response: CodeExecutionResponse, language: string): string {
if (!response.success && !response.output) {
if (!response.success && !response.output && !response.images?.length) {
return `❌ 代码执行失败: ${response.error}`;
}
const langDisplay = response.language || language;
const versionDisplay = response.version ? ` (v${response.version})` : '';
const engineDisplay = response.engine === 'pyodide' ? ' [Pyodide]' : '';
const timeDisplay = response.executionTime ? ` (${response.executionTime}ms)` : '';
if (response.error && !response.output) {
return `⚠️ ${langDisplay}${versionDisplay} 代码执行出错`;
if (response.error && !response.output && !response.images?.length) {
return `⚠️ ${langDisplay}${engineDisplay} 代码执行出错`;
}
// 计算输出行数
const outputLines = response.output?.split('\n').length || 0;
const outputLines = response.output?.split('\n').filter(l => l.trim()).length || 0;
const imageCount = response.images?.length || 0;
return `${langDisplay}${versionDisplay} 代码执行完成,输出 ${outputLines}`;
// 构建结果描述
const parts: string[] = [];
if (outputLines > 0) {
parts.push(`输出 ${outputLines}`);
}
if (imageCount > 0) {
parts.push(`生成 ${imageCount} 张图表`);
}
const resultDesc = parts.length > 0 ? parts.join('') : '无输出';
return `${langDisplay}${engineDisplay} 代码执行完成${timeDisplay}${resultDesc}`;
}
/**

View File

@ -24,6 +24,10 @@ import {
type WebFetchInput,
type WebFetchResponse,
} from './webFetch';
import { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
// 导出代码分析函数供外部使用
export { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
export interface ToolExecutionResult {
success: boolean;
@ -33,17 +37,27 @@ export interface ToolExecutionResult {
displayResult: string;
/** 原始数据 */
rawData?: unknown;
/** Base64 编码的图片数组(代码执行时可能产生) */
images?: string[];
/** 是否需要浏览器端 Pyodide 执行 */
requiresPyodide?: boolean;
/** 代码内容(当 requiresPyodide 为 true 时) */
code?: string;
/** 语言(当 requiresPyodide 为 true 时) */
language?: string;
}
/**
*
* @param toolName
* @param input
* @param onProgress Pyodide
* @returns
*/
export async function executeTool(
toolName: string,
input: Record<string, unknown>
input: Record<string, unknown>,
onProgress?: LoadingCallback
): Promise<ToolExecutionResult> {
try {
switch (toolName) {
@ -61,10 +75,26 @@ export async function executeTool(
case 'code_execution': {
const language = String(input.language || 'python');
const code = String(input.code || '');
// 检测是否需要浏览器端 Pyodide 执行Python + 图形代码)
if (shouldUsePyodide(code, language)) {
return {
success: true,
fullResult: '需要在浏览器端执行 Python 图形代码',
displayResult: '检测到图形绑制代码,正在准备浏览器端执行...',
requiresPyodide: true,
code,
language,
};
}
// 使用 Piston API 执行(服务端)
const codeInput: CodeExecutionInput = {
code: String(input.code || ''),
code,
language,
stdin: input.stdin ? String(input.stdin) : undefined,
onProgress, // 传递 Pyodide 加载进度回调
};
const response: CodeExecutionResponse = await executeCode(codeInput);
return {
@ -72,6 +102,7 @@ export async function executeTool(
fullResult: formatExecutionResult(response),
displayResult: formatExecutionResultShort(response, language),
rawData: response,
images: response.images, // 传递图片数据
};
}
@ -92,7 +123,7 @@ export async function executeTool(
return {
success: false,
fullResult: `未知的工具: ${toolName}`,
displayResult: `未知的工具: ${toolName}`,
displayResult: `未知的工具: ${toolName}`,
};
}
} catch (error) {
@ -101,7 +132,7 @@ export async function executeTool(
return {
success: false,
fullResult: `工具执行错误: ${errorMsg}`,
displayResult: `工具执行错误: ${errorMsg}`,
displayResult: `工具执行错误: ${errorMsg}`,
};
}
}

View File

@ -0,0 +1,646 @@
/**
* Pyodide
* 使 CDN Pyodide Python
* matplotlib
*/
import { type LoadingCallback } from './codeAnalyzer';
// 重新导出 LoadingCallback 类型,方便外部使用
export { type LoadingCallback } from './codeAnalyzer';
// Pyodide CDN URL
const PYODIDE_CDN_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
// 中文字体 CDN URL使用 Google Fonts 的思源黑体)
// 这些 URL 按优先级排序,会依次尝试
const CHINESE_FONT_URLS = [
// Google Fonts 官方 CDN - Noto Sans SC
'https://fonts.gstatic.com/s/notosanssc/v36/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYxNbPzS5HE.ttf',
// jsDelivr 上的 Noto Sans SC 镜像
'https://cdn.jsdelivr.net/npm/@aspect-dev/font-noto-sans-cjk-sc@2.001.0/NotoSansCJKsc-Regular.otf',
// 备用:使用静态托管的字体(如果上面的都失败)
'/fonts/NotoSansSC-Regular.ttf',
];
const CHINESE_FONT_FILENAME = 'NotoSansSC-Regular.ttf';
// 字体是否已加载
let chineseFontLoaded = false;
// Pyodide 实例类型定义
interface PyodideInterface {
runPythonAsync(code: string): Promise<unknown>;
loadPackagesFromImports(code: string): Promise<void>;
loadPackage(packages: string | string[]): Promise<void>;
FS: {
readFile(path: string, options?: { encoding?: string }): Uint8Array | string;
writeFile(path: string, data: string | Uint8Array): void;
unlink(path: string): void;
readdir(path: string): string[];
};
globals: {
get(name: string): unknown;
set(name: string, value: unknown): void;
};
}
// 全局 Pyodide 实例(单例模式)
let pyodideInstance: PyodideInterface | null = null;
let pyodideLoadPromise: Promise<PyodideInterface> | null = null;
let isLoading = false;
/**
* Pyodide
* 使
*/
export async function loadPyodide(onProgress?: LoadingCallback): Promise<PyodideInterface> {
// 如果已经加载完成,直接返回
if (pyodideInstance) {
onProgress?.({ stage: 'ready', message: 'Pyodide 已就绪' });
return pyodideInstance;
}
// 如果正在加载中,等待加载完成
if (pyodideLoadPromise) {
return pyodideLoadPromise;
}
// 开始加载
isLoading = true;
onProgress?.({ stage: 'loading', message: '正在加载 Python 运行时...', progress: 0 });
pyodideLoadPromise = new Promise(async (resolve, reject) => {
try {
// 动态加载 Pyodide 脚本
onProgress?.({ stage: 'loading', message: '正在下载 Pyodide 核心...', progress: 10 });
// 检查是否在浏览器环境
if (typeof window === 'undefined') {
throw new Error('Pyodide 只能在浏览器环境中运行');
}
// 检查是否已加载 Pyodide 脚本
if (!(window as unknown as { loadPyodide?: unknown }).loadPyodide) {
// 动态插入 Pyodide 脚本
await loadPyodideScript();
}
onProgress?.({ stage: 'loading', message: '正在初始化 Python 环境...', progress: 30 });
// 初始化 Pyodide
const loadPyodideFn = (window as unknown as { loadPyodide: (options: { indexURL: string }) => Promise<PyodideInterface> }).loadPyodide;
const pyodide = await loadPyodideFn({
indexURL: PYODIDE_CDN_URL,
});
onProgress?.({ stage: 'loading', message: '正在安装 matplotlib...', progress: 50 });
// 预加载常用的数据可视化包
await pyodide.loadPackage(['matplotlib', 'numpy']);
onProgress?.({ stage: 'loading', message: '正在加载中文字体...', progress: 70 });
// 加载中文字体
const fontLoaded = await loadChineseFont(pyodide, onProgress);
onProgress?.({ stage: 'loading', message: '正在配置图形后端...', progress: 90 });
// 配置 matplotlib 使用 Agg 后端(无头模式)并设置字体
const fontConfig = fontLoaded
? `
# 使 Noto Sans SC
plt.rcParams['font.family'] = 'Noto Sans SC'
plt.rcParams['font.sans-serif'] = ['Noto Sans SC', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
print("已配置 Noto Sans SC 中文字体")
`
: `
# 使
import matplotlib.font_manager as fm
available_fonts = [f.name for f in fm.fontManager.ttflist]
chinese_fonts = ['Noto Sans CJK SC', 'SimHei', 'Microsoft YaHei', 'DejaVu Sans']
selected_font = 'DejaVu Sans'
for font in chinese_fonts:
if font in available_fonts:
selected_font = font
break
plt.rcParams['font.family'] = selected_font
plt.rcParams['font.sans-serif'] = [selected_font] + chinese_fonts
plt.rcParams['axes.unicode_minus'] = False
if selected_font == 'DejaVu Sans':
print("警告: 未找到中文字体,中文可能显示为方块。建议使用英文标签。")
else:
print(f"使用字体: {selected_font}")
`;
// 配置 matplotlib 使用 Agg 后端(无头模式)
await pyodide.runPythonAsync(`
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import io
import base64
${fontConfig}
# seaborn Pyodide seaborn
import matplotlib.style as mpl_style
# Seaborn whitegrid
seaborn_whitegrid = {
'axes.axisbelow': True,
'axes.edgecolor': '.8',
'axes.facecolor': 'white',
'axes.grid': True,
'axes.labelcolor': '.15',
'axes.linewidth': 1.0,
'figure.facecolor': 'white',
'font.family': ['Noto Sans SC', 'sans-serif'],
'grid.color': '.8',
'grid.linestyle': '-',
'grid.linewidth': 1.0,
'image.cmap': 'viridis',
'legend.frameon': False,
'legend.numpoints': 1,
'legend.scatterpoints': 1,
'lines.solid_capstyle': 'round',
'text.color': '.15',
'xtick.color': '.15',
'xtick.direction': 'out',
'xtick.major.size': 0.0,
'xtick.minor.size': 0.0,
'ytick.color': '.15',
'ytick.direction': 'out',
'ytick.major.size': 0.0,
'ytick.minor.size': 0.0,
'axes.spines.left': True,
'axes.spines.bottom': True,
'axes.spines.right': True,
'axes.spines.top': True,
}
# Seaborn darkgrid
seaborn_darkgrid = {
'axes.axisbelow': True,
'axes.edgecolor': 'white',
'axes.facecolor': '#EAEAF2',
'axes.grid': True,
'axes.labelcolor': '.15',
'axes.linewidth': 0.0,
'figure.facecolor': 'white',
'font.family': ['Noto Sans SC', 'sans-serif'],
'grid.color': 'white',
'grid.linestyle': '-',
'grid.linewidth': 1.0,
'image.cmap': 'viridis',
'legend.frameon': False,
'legend.numpoints': 1,
'legend.scatterpoints': 1,
'lines.solid_capstyle': 'round',
'text.color': '.15',
'xtick.color': '.15',
'xtick.direction': 'out',
'xtick.major.size': 0.0,
'xtick.minor.size': 0.0,
'ytick.color': '.15',
'ytick.direction': 'out',
'ytick.major.size': 0.0,
'ytick.minor.size': 0.0,
}
# 使
modern_clean = {
'axes.axisbelow': True,
'axes.edgecolor': '#333333',
'axes.facecolor': '#FAFAFA',
'axes.grid': True,
'axes.labelcolor': '#333333',
'axes.labelsize': 11,
'axes.linewidth': 0.8,
'axes.titlesize': 13,
'axes.titleweight': 'bold',
'figure.facecolor': 'white',
'figure.figsize': [10, 6],
'figure.dpi': 100,
'font.family': ['Noto Sans SC', 'sans-serif'],
'font.size': 10,
'grid.alpha': 0.4,
'grid.color': '#CCCCCC',
'grid.linestyle': '--',
'grid.linewidth': 0.5,
'legend.fontsize': 10,
'legend.frameon': True,
'legend.framealpha': 0.9,
'legend.edgecolor': '#CCCCCC',
'lines.linewidth': 2,
'lines.markersize': 6,
'text.color': '#333333',
'xtick.color': '#333333',
'xtick.labelsize': 9,
'ytick.color': '#333333',
'ytick.labelsize': 9,
'axes.spines.top': False,
'axes.spines.right': False,
}
#
dark_modern = {
'axes.axisbelow': True,
'axes.edgecolor': '#888888',
'axes.facecolor': '#2D2D2D',
'axes.grid': True,
'axes.labelcolor': '#EEEEEE',
'axes.labelsize': 11,
'axes.linewidth': 0.8,
'axes.titlesize': 13,
'axes.titleweight': 'bold',
'figure.facecolor': '#1E1E1E',
'figure.figsize': [10, 6],
'figure.dpi': 100,
'font.family': ['Noto Sans SC', 'sans-serif'],
'font.size': 10,
'grid.alpha': 0.3,
'grid.color': '#555555',
'grid.linestyle': '--',
'grid.linewidth': 0.5,
'legend.fontsize': 10,
'legend.frameon': True,
'legend.framealpha': 0.9,
'legend.edgecolor': '#555555',
'legend.facecolor': '#2D2D2D',
'legend.labelcolor': '#EEEEEE',
'lines.linewidth': 2,
'lines.markersize': 6,
'text.color': '#EEEEEE',
'xtick.color': '#EEEEEE',
'xtick.labelsize': 9,
'ytick.color': '#EEEEEE',
'ytick.labelsize': 9,
'axes.spines.top': False,
'axes.spines.right': False,
}
# matplotlib
mpl_style.library['seaborn-whitegrid'] = seaborn_whitegrid
mpl_style.library['seaborn-v0_8-whitegrid'] = seaborn_whitegrid #
mpl_style.library['seaborn-darkgrid'] = seaborn_darkgrid
mpl_style.library['seaborn-v0_8-darkgrid'] = seaborn_darkgrid
mpl_style.library['modern-clean'] = modern_clean
mpl_style.library['dark-modern'] = dark_modern
# seaborn-whitegrid
plt.style.use('seaborn-whitegrid')
print("已注册自定义样式: seaborn-whitegrid, seaborn-darkgrid, modern-clean, dark-modern")
print("默认样式: seaborn-whitegrid")
#
def _save_figure_to_base64():
"""将当前图形保存为 Base64 编码的 PNG"""
buf = io.BytesIO()
#
fig = plt.gcf()
facecolor = fig.get_facecolor()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
facecolor=facecolor, edgecolor='none')
buf.seek(0)
img_base64 = base64.b64encode(buf.read()).decode('utf-8')
buf.close()
plt.close('all') #
return img_base64
print("Matplotlib 配置完成")
`);
onProgress?.({ stage: 'ready', message: 'Python 环境已就绪', progress: 100 });
pyodideInstance = pyodide;
isLoading = false;
resolve(pyodide);
} catch (error) {
isLoading = false;
pyodideLoadPromise = null;
onProgress?.({ stage: 'error', message: `加载失败: ${error instanceof Error ? error.message : '未知错误'}` });
reject(error);
}
});
return pyodideLoadPromise;
}
/**
* Pyodide
*/
async function loadPyodideScript(): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${PYODIDE_CDN_URL}pyodide.js`;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('无法加载 Pyodide 脚本'));
document.head.appendChild(script);
});
}
/**
* Pyodide
*/
async function loadChineseFont(pyodide: PyodideInterface, onProgress?: LoadingCallback): Promise<boolean> {
if (chineseFontLoaded) {
return true;
}
try {
onProgress?.({ stage: 'loading', message: '正在下载中文字体...', progress: 75 });
let fontData: ArrayBuffer | null = null;
// 尝试每个字体 URL
for (const url of CHINESE_FONT_URLS) {
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
});
if (response.ok) {
fontData = await response.arrayBuffer();
console.log(`成功从 ${url} 下载字体`);
break;
}
} catch (e) {
console.warn(`无法从 ${url} 下载字体:`, e);
}
}
if (!fontData) {
console.warn('所有字体 URL 都无法访问,将使用默认字体');
return false;
}
const fontBytes = new Uint8Array(fontData);
onProgress?.({ stage: 'loading', message: '正在安装中文字体...', progress: 85 });
// 在 Pyodide 虚拟文件系统中创建字体目录并写入字体文件
await pyodide.runPythonAsync(`
import os
#
font_dir = '/home/pyodide/.fonts'
os.makedirs(font_dir, exist_ok=True)
`);
// 将字体数据写入虚拟文件系统
const fontPath = `/home/pyodide/.fonts/${CHINESE_FONT_FILENAME}`;
pyodide.FS.writeFile(fontPath, fontBytes);
// 注册字体到 matplotlib
await pyodide.runPythonAsync(`
import matplotlib.font_manager as fm
# matplotlib
font_path = '${fontPath}'
try:
fm.fontManager.addfont(font_path)
print(f"成功加载中文字体: {font_path}")
except Exception as e:
print(f"加载字体失败: {e}")
`);
chineseFontLoaded = true;
onProgress?.({ stage: 'loading', message: '中文字体加载完成', progress: 90 });
return true;
} catch (error) {
console.warn('加载中文字体失败:', error);
return false;
}
}
/**
* Pyodide
*/
export interface PyodideExecutionResult {
success: boolean;
output: string;
error?: string;
/** Base64 编码的图片数组 */
images: string[];
/** 执行时间 (ms) */
executionTime: number;
}
/**
* Pyodide Python
* matplotlib
*/
export async function executePythonInPyodide(
code: string,
onProgress?: LoadingCallback
): Promise<PyodideExecutionResult> {
const startTime = Date.now();
const images: string[] = [];
let output = '';
try {
// 确保 Pyodide 已加载
const pyodide = await loadPyodide(onProgress);
// 检测代码中是否包含图形绘制
const hasGraphics = detectGraphicsCode(code);
// 包装代码以捕获输出和图形
const wrappedCode = `
import sys
from io import StringIO
#
_captured_output = StringIO()
_old_stdout = sys.stdout
sys.stdout = _captured_output
_execution_error = None
_figure_base64 = None
# plt.show()
import matplotlib.pyplot as plt
_original_show = plt.show
def _custom_show(*args, **kwargs):
global _figure_base64
if plt.get_fignums() and _figure_base64 is None:
_figure_base64 = _save_figure_to_base64()
plt.show = _custom_show
try:
${code.split('\n').map(line => ' ' + line).join('\n')}
#
if plt.get_fignums() and _figure_base64 is None:
_figure_base64 = _save_figure_to_base64()
except Exception as e:
_execution_error = str(e)
import traceback
_execution_error = traceback.format_exc()
finally:
sys.stdout = _old_stdout
plt.show = _original_show # show
#
{
'output': _captured_output.getvalue(),
'error': _execution_error,
'figure': _figure_base64
}
`;
// 执行代码
const result = await pyodide.runPythonAsync(wrappedCode);
// 解析结果 - Pyodide 返回的是 Python 字典,需要转换
let resultObj: { output: string; error: string | null; figure: string | null };
// 检查 result 是否有 toJs 方法Pyodide proxy 对象)
if (result && typeof (result as { toJs?: () => unknown }).toJs === 'function') {
const jsResult = (result as { toJs: () => Map<string, unknown> }).toJs();
// toJs() 返回 Map 对象
if (jsResult instanceof Map) {
resultObj = {
output: (jsResult.get('output') as string) || '',
error: (jsResult.get('error') as string | null) || null,
figure: (jsResult.get('figure') as string | null) || null,
};
} else {
resultObj = jsResult as { output: string; error: string | null; figure: string | null };
}
} else {
// 直接作为 JS 对象处理
resultObj = result as { output: string; error: string | null; figure: string | null };
}
output = resultObj.output || '';
if (resultObj.figure) {
images.push(resultObj.figure);
}
if (resultObj.error) {
return {
success: false,
output,
error: resultObj.error,
images,
executionTime: Date.now() - startTime,
};
}
return {
success: true,
output,
images,
executionTime: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
output,
error: error instanceof Error ? error.message : '执行错误',
images,
executionTime: Date.now() - startTime,
};
}
}
/**
*
*/
export function detectGraphicsCode(code: string): boolean {
const graphicsKeywords = [
// matplotlib
'matplotlib',
'pyplot',
'plt.',
'.plot(',
'.scatter(',
'.bar(',
'.barh(',
'.hist(',
'.pie(',
'.boxplot(',
'.violinplot(',
'.heatmap(',
'.imshow(',
'.contour(',
'.fill(',
'.errorbar(',
'.stem(',
'.step(',
'savefig',
'show()',
'.figure(',
'.subplot(',
'.subplots(',
// seaborn
'seaborn',
'sns.',
// plotly
'plotly',
'px.',
'go.Figure',
// 其他可视化库
'bokeh',
'altair',
];
const lowerCode = code.toLowerCase();
return graphicsKeywords.some(keyword => lowerCode.includes(keyword.toLowerCase()));
}
/**
* 使 Pyodide
* @param code
* @param language
* @returns 使 Pyodide
*/
export function shouldUsePyodide(code: string, language: string): boolean {
// 只有 Python 代码才考虑使用 Pyodide
if (!['python', 'python3', 'py'].includes(language.toLowerCase())) {
return false;
}
// 如果代码包含图形绘制,使用 Pyodide
if (detectGraphicsCode(code)) {
return true;
}
// 如果代码包含需要交互的库,使用 Pyodide
const interactiveKeywords = ['input(', 'tkinter', 'pygame'];
if (interactiveKeywords.some(kw => code.includes(kw))) {
return false; // 这些在 Pyodide 中也不支持,使用 Piston
}
// 默认:简单 Python 代码使用 Piston更快
return false;
}
/**
* Pyodide
*/
export function getPyodideStatus(): {
isLoaded: boolean;
isLoading: boolean;
} {
return {
isLoaded: pyodideInstance !== null,
isLoading,
};
}
/**
* Pyodide
*/
export function resetPyodide(): void {
pyodideInstance = null;
pyodideLoadPromise = null;
isLoading = false;
}

View File

@ -27,6 +27,31 @@ export interface Message {
role: 'user' | 'assistant';
content: string;
timestamp: Date;
/** 工具调用记录 */
toolCalls?: ToolCall[];
/** 工具调用结果 */
toolResults?: ToolResult[];
}
// 工具调用记录
export interface ToolCall {
id: string;
name: string;
input: Record<string, unknown>;
}
// 工具调用结果
export interface ToolResult {
toolUseId: string;
toolName: string;
content: string;
isError?: boolean;
/** 代码执行产生的图片Base64 */
images?: string[];
/** 执行引擎 */
engine?: 'pyodide' | 'piston';
/** 执行时间 (ms) */
executionTime?: number;
}
// 用户类型