Compare commits
10 Commits
aa469438c2
...
7d8a6a6939
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8a6a6939 | ||
|
|
e5c5593686 | ||
|
|
58d288637a | ||
|
|
5fe0552338 | ||
|
|
5cc4fbb7a0 | ||
|
|
68ba9b3204 | ||
|
|
ef45e14534 | ||
|
|
ba4e00a341 | ||
|
|
bfcaf5a53a | ||
|
|
c59dee8db9 |
@ -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`));
|
||||
|
||||
// 将简短的工具结果显示给用户
|
||||
|
||||
119
src/app/api/messages/[messageId]/route.ts
Normal file
119
src/app/api/messages/[messageId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
206
src/components/features/CodeExecutionResult.tsx
Normal file
206
src/components/features/CodeExecutionResult.tsx
Normal 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;
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
1
src/drizzle/migrations/0003_melted_shockwave.sql
Normal file
1
src/drizzle/migrations/0003_melted_shockwave.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "messages" ADD COLUMN "images" jsonb;
|
||||
609
src/drizzle/migrations/meta/0003_snapshot.json
Normal file
609
src/drizzle/migrations/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,13 @@
|
||||
"when": 1766110727907,
|
||||
"tag": "0002_bizarre_sunfire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1766143752533,
|
||||
"tag": "0003_melted_shockwave",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
107
src/services/tools/codeAnalyzer.ts
Normal file
107
src/services/tools/codeAnalyzer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
// 检查是否应该使用 Pyodide(Python + 图形)
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
646
src/services/tools/pyodideRunner.ts
Normal file
646
src/services/tools/pyodideRunner.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// 用户类型
|
||||
|
||||
Loading…
Reference in New Issue
Block a user