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 回复
|
// POST /api/chat - 发送消息并获取 AI 回复
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -393,6 +401,26 @@ export async function POST(request: Request) {
|
|||||||
// 执行工具
|
// 执行工具
|
||||||
const result = await executeTool(tc.name, tc.input);
|
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({
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||||
type: 'tool_execution_result',
|
type: 'tool_execution_result',
|
||||||
@ -400,6 +428,7 @@ export async function POST(request: Request) {
|
|||||||
name: tc.name,
|
name: tc.name,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
result: result.displayResult,
|
result: result.displayResult,
|
||||||
|
images: result.images,
|
||||||
})}\n\n`));
|
})}\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,
|
status: 'completed' as const,
|
||||||
inputTokens: msg.inputTokens || undefined,
|
inputTokens: msg.inputTokens || undefined,
|
||||||
outputTokens: msg.outputTokens || undefined,
|
outputTokens: msg.outputTokens || undefined,
|
||||||
|
// 从数据库加载图片数据
|
||||||
|
images: (msg.images as string[]) || undefined,
|
||||||
}));
|
}));
|
||||||
setInitialMessages(historyMessages);
|
setInitialMessages(historyMessages);
|
||||||
}
|
}
|
||||||
@ -272,6 +274,8 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
thinkingContent={message.thinkingContent}
|
thinkingContent={message.thinkingContent}
|
||||||
isStreaming={message.status === 'streaming'}
|
isStreaming={message.status === 'streaming'}
|
||||||
error={message.error}
|
error={message.error}
|
||||||
|
images={message.images}
|
||||||
|
pyodideStatus={message.pyodideStatus}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 { Avatar } from '@/components/ui/Avatar';
|
||||||
import { AILogo } from '@/components/ui/AILogo';
|
import { AILogo } from '@/components/ui/AILogo';
|
||||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||||
|
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Message, User } from '@/types';
|
import type { Message, User, ToolResult } from '@/types';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@ -14,9 +15,17 @@ interface MessageBubbleProps {
|
|||||||
thinkingContent?: string;
|
thinkingContent?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
error?: string;
|
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 isUser = message.role === 'user';
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@ -96,6 +105,34 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 && (
|
{isStreaming && message.content && (
|
||||||
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
|
<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>
|
</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 (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
{...props}
|
||||||
alt={alt || ''}
|
alt={props.alt || ''}
|
||||||
className="max-w-full h-auto rounded-lg my-3"
|
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,
|
"when": 1766110727907,
|
||||||
"tag": "0002_bizarre_sunfire",
|
"tag": "0002_bizarre_sunfire",
|
||||||
"breakpoints": true
|
"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[]>(),
|
toolCalls: jsonb('tool_calls').$type<ToolCall[]>(),
|
||||||
toolResults: jsonb('tool_results').$type<ToolResult[]>(),
|
toolResults: jsonb('tool_results').$type<ToolResult[]>(),
|
||||||
|
// 代码执行产生的图片(Base64 编码数组)
|
||||||
|
images: jsonb('images').$type<string[]>(),
|
||||||
// Token 统计
|
// Token 统计
|
||||||
inputTokens: integer('input_tokens').default(0),
|
inputTokens: integer('input_tokens').default(0),
|
||||||
outputTokens: integer('output_tokens').default(0),
|
outputTokens: integer('output_tokens').default(0),
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { executePythonInPyodide, type LoadingCallback } from '@/services/tools/pyodideRunner';
|
||||||
|
|
||||||
export interface StreamMessage {
|
export interface StreamMessage {
|
||||||
type: 'thinking' | 'text' | 'tool_use_start' | 'done' | 'error';
|
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'pyodide_execution_required' | 'done' | 'error';
|
||||||
content?: string;
|
content?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -11,6 +12,13 @@ export interface StreamMessage {
|
|||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// Pyodide 执行相关
|
||||||
|
code?: string;
|
||||||
|
language?: string;
|
||||||
|
// 工具执行结果
|
||||||
|
success?: boolean;
|
||||||
|
result?: string;
|
||||||
|
images?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@ -22,6 +30,37 @@ export interface ChatMessage {
|
|||||||
error?: string;
|
error?: string;
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
|
// 工具执行产生的图片
|
||||||
|
images?: string[];
|
||||||
|
// Pyodide 加载状态
|
||||||
|
pyodideStatus?: {
|
||||||
|
stage: 'loading' | 'ready' | 'error';
|
||||||
|
message: string;
|
||||||
|
progress?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新消息图片到数据库
|
||||||
|
*/
|
||||||
|
async function saveMessageImages(messageId: string, images: string[]): Promise<void> {
|
||||||
|
if (!messageId || !images || images.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/messages/${messageId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ images }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to save message images:', await response.text());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving message images:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStreamChat() {
|
export function useStreamChat() {
|
||||||
@ -29,6 +68,8 @@ export function useStreamChat() {
|
|||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
// 临时存储 Pyodide 执行产生的图片,等待 messageId
|
||||||
|
const pendingImagesRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
const sendMessage = useCallback(async (options: {
|
const sendMessage = useCallback(async (options: {
|
||||||
@ -137,7 +178,95 @@ export function useStreamChat() {
|
|||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
} else if (event.type === 'tool_execution_result') {
|
||||||
|
// 处理工具执行结果(包括图片)
|
||||||
|
if (event.images && event.images.length > 0) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const lastIndex = updated.length - 1;
|
||||||
|
if (updated[lastIndex]?.role === 'assistant') {
|
||||||
|
const existingImages = updated[lastIndex].images || [];
|
||||||
|
updated[lastIndex] = {
|
||||||
|
...updated[lastIndex],
|
||||||
|
images: [...existingImages, ...event.images!],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (event.type === 'pyodide_execution_required') {
|
||||||
|
// 需要在浏览器端执行 Python 图形代码
|
||||||
|
const code = event.code || '';
|
||||||
|
|
||||||
|
// 更新 Pyodide 加载状态
|
||||||
|
const updatePyodideStatus: LoadingCallback = (status) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const lastIndex = updated.length - 1;
|
||||||
|
if (updated[lastIndex]?.role === 'assistant') {
|
||||||
|
updated[lastIndex] = {
|
||||||
|
...updated[lastIndex],
|
||||||
|
pyodideStatus: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行 Pyodide
|
||||||
|
try {
|
||||||
|
const result = await executePythonInPyodide(code, updatePyodideStatus);
|
||||||
|
|
||||||
|
// 添加执行结果文本
|
||||||
|
const resultText = result.success
|
||||||
|
? `\n\n✅ Python [Pyodide] 代码执行完成 (${result.executionTime}ms)${result.images.length > 0 ? `,生成 ${result.images.length} 张图表` : ''}\n\n`
|
||||||
|
: `\n\n❌ Python 代码执行失败: ${result.error}\n\n`;
|
||||||
|
|
||||||
|
fullContent += resultText;
|
||||||
|
|
||||||
|
// 将图片存入临时变量,等待 messageId 后保存到数据库
|
||||||
|
if (result.images && result.images.length > 0) {
|
||||||
|
pendingImagesRef.current = [...pendingImagesRef.current, ...result.images];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新消息,添加图片
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const lastIndex = updated.length - 1;
|
||||||
|
if (updated[lastIndex]?.role === 'assistant') {
|
||||||
|
const existingImages = updated[lastIndex].images || [];
|
||||||
|
updated[lastIndex] = {
|
||||||
|
...updated[lastIndex],
|
||||||
|
content: fullContent,
|
||||||
|
images: [...existingImages, ...result.images],
|
||||||
|
pyodideStatus: undefined, // 清除加载状态
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (pyodideError) {
|
||||||
|
const errorMsg = pyodideError instanceof Error ? pyodideError.message : '未知错误';
|
||||||
|
fullContent += `\n\n❌ Pyodide 执行错误: ${errorMsg}\n\n`;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const lastIndex = updated.length - 1;
|
||||||
|
if (updated[lastIndex]?.role === 'assistant') {
|
||||||
|
updated[lastIndex] = {
|
||||||
|
...updated[lastIndex],
|
||||||
|
content: fullContent,
|
||||||
|
pyodideStatus: { stage: 'error', message: errorMsg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
|
// 如果有待保存的图片,保存到数据库
|
||||||
|
if (event.messageId && pendingImagesRef.current.length > 0) {
|
||||||
|
saveMessageImages(event.messageId, pendingImagesRef.current);
|
||||||
|
pendingImagesRef.current = []; // 清空临时存储
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
const lastIndex = updated.length - 1;
|
const lastIndex = updated.length - 1;
|
||||||
|
|||||||
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 工具服务
|
* Code Execution 工具服务
|
||||||
* 使用 Piston API 实现代码执行
|
* 混合架构:Pyodide (浏览器端 Python) + Piston API (服务端多语言)
|
||||||
|
*
|
||||||
|
* - Python + 图形绘制 → Pyodide (支持 matplotlib 渲染)
|
||||||
|
* - 其他语言/普通代码 → Piston API
|
||||||
|
*
|
||||||
* Piston API 文档: https://github.com/engineer-man/piston
|
* Piston API 文档: https://github.com/engineer-man/piston
|
||||||
|
* Pyodide 文档: https://pyodide.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldUsePyodide,
|
||||||
|
executePythonInPyodide,
|
||||||
|
type LoadingCallback,
|
||||||
|
type PyodideExecutionResult,
|
||||||
|
} from './pyodideRunner';
|
||||||
|
|
||||||
export interface CodeExecutionInput {
|
export interface CodeExecutionInput {
|
||||||
code: string;
|
code: string;
|
||||||
language: string;
|
language: string;
|
||||||
stdin?: string;
|
stdin?: string;
|
||||||
|
/** Pyodide 加载进度回调(可选) */
|
||||||
|
onProgress?: LoadingCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeExecutionResponse {
|
export interface CodeExecutionResponse {
|
||||||
@ -16,6 +30,12 @@ export interface CodeExecutionResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
/** Base64 编码的图片数组(matplotlib 输出) */
|
||||||
|
images?: string[];
|
||||||
|
/** 执行引擎: 'pyodide' | 'piston' */
|
||||||
|
engine?: 'pyodide' | 'piston';
|
||||||
|
/** 执行时间 (ms) */
|
||||||
|
executionTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Piston API 支持的语言映射
|
// 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> {
|
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()];
|
const langConfig = LANGUAGE_MAP[language.toLowerCase()];
|
||||||
@ -63,6 +141,8 @@ export async function executeCode(input: CodeExecutionInput): Promise<CodeExecut
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `不支持的编程语言: ${language}。支持的语言: ${Object.keys(LANGUAGE_MAP).join(', ')}`,
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `代码执行 API 错误: ${response.status}`,
|
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 || '编译错误',
|
error: data.compile.stderr || data.compile.output || '编译错误',
|
||||||
language: langConfig.language,
|
language: langConfig.language,
|
||||||
version: langConfig.version,
|
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 || '',
|
error: data.run.stderr || '',
|
||||||
language: langConfig.language,
|
language: langConfig.language,
|
||||||
version: langConfig.version,
|
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 || '',
|
error: data.run?.stderr || '',
|
||||||
language: langConfig.language,
|
language: langConfig.language,
|
||||||
version: langConfig.version,
|
version: langConfig.version,
|
||||||
|
engine: 'piston',
|
||||||
|
executionTime: Date.now() - start,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Code execution error:', error);
|
console.error('Code execution error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : '未知错误',
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
engine: 'piston',
|
||||||
|
executionTime: Date.now() - start,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,20 +263,37 @@ function getFileName(language: string): string {
|
|||||||
* 格式化代码执行结果为文本(完整版 - 发送给 AI)
|
* 格式化代码执行结果为文本(完整版 - 发送给 AI)
|
||||||
*/
|
*/
|
||||||
export function formatExecutionResult(response: CodeExecutionResponse): string {
|
export function formatExecutionResult(response: CodeExecutionResponse): string {
|
||||||
if (!response.success && !response.output) {
|
if (!response.success && !response.output && !response.images?.length) {
|
||||||
return `❌ 执行失败: ${response.error}`;
|
return `❌ 执行失败: ${response.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = '';
|
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) {
|
if (response.output) {
|
||||||
result += `## 输出结果\n\`\`\`\n${response.output}\n\`\`\`\n`;
|
result += `## 输出结果\n\`\`\`\n${response.output}\n\`\`\`\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图片信息(仅标记有图片,实际渲染由前端处理)
|
||||||
|
if (response.images && response.images.length > 0) {
|
||||||
|
result += `\n## 图形输出\n已生成 ${response.images.length} 张图表\n`;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
result += `\n## 错误信息\n\`\`\`\n${response.error}\n\`\`\`\n`;
|
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 {
|
export function formatExecutionResultShort(response: CodeExecutionResponse, language: string): string {
|
||||||
if (!response.success && !response.output) {
|
if (!response.success && !response.output && !response.images?.length) {
|
||||||
return `❌ 代码执行失败: ${response.error}`;
|
return `❌ 代码执行失败: ${response.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const langDisplay = response.language || language;
|
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) {
|
if (response.error && !response.output && !response.images?.length) {
|
||||||
return `⚠️ ${langDisplay}${versionDisplay} 代码执行出错`;
|
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 WebFetchInput,
|
||||||
type WebFetchResponse,
|
type WebFetchResponse,
|
||||||
} from './webFetch';
|
} from './webFetch';
|
||||||
|
import { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
|
||||||
|
|
||||||
|
// 导出代码分析函数供外部使用
|
||||||
|
export { shouldUsePyodide, analyzeCode, type LoadingCallback } from './codeAnalyzer';
|
||||||
|
|
||||||
export interface ToolExecutionResult {
|
export interface ToolExecutionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@ -33,17 +37,27 @@ export interface ToolExecutionResult {
|
|||||||
displayResult: string;
|
displayResult: string;
|
||||||
/** 原始数据 */
|
/** 原始数据 */
|
||||||
rawData?: unknown;
|
rawData?: unknown;
|
||||||
|
/** Base64 编码的图片数组(代码执行时可能产生) */
|
||||||
|
images?: string[];
|
||||||
|
/** 是否需要浏览器端 Pyodide 执行 */
|
||||||
|
requiresPyodide?: boolean;
|
||||||
|
/** 代码内容(当 requiresPyodide 为 true 时) */
|
||||||
|
code?: string;
|
||||||
|
/** 语言(当 requiresPyodide 为 true 时) */
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行工具
|
* 执行工具
|
||||||
* @param toolName 工具名称
|
* @param toolName 工具名称
|
||||||
* @param input 工具输入参数
|
* @param input 工具输入参数
|
||||||
|
* @param onProgress Pyodide 加载进度回调(可选)
|
||||||
* @returns 执行结果(包含完整版和简短版)
|
* @returns 执行结果(包含完整版和简短版)
|
||||||
*/
|
*/
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>,
|
||||||
|
onProgress?: LoadingCallback
|
||||||
): Promise<ToolExecutionResult> {
|
): Promise<ToolExecutionResult> {
|
||||||
try {
|
try {
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
@ -61,10 +75,26 @@ export async function executeTool(
|
|||||||
|
|
||||||
case 'code_execution': {
|
case 'code_execution': {
|
||||||
const language = String(input.language || 'python');
|
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 = {
|
const codeInput: CodeExecutionInput = {
|
||||||
code: String(input.code || ''),
|
code,
|
||||||
language,
|
language,
|
||||||
stdin: input.stdin ? String(input.stdin) : undefined,
|
stdin: input.stdin ? String(input.stdin) : undefined,
|
||||||
|
onProgress, // 传递 Pyodide 加载进度回调
|
||||||
};
|
};
|
||||||
const response: CodeExecutionResponse = await executeCode(codeInput);
|
const response: CodeExecutionResponse = await executeCode(codeInput);
|
||||||
return {
|
return {
|
||||||
@ -72,6 +102,7 @@ export async function executeTool(
|
|||||||
fullResult: formatExecutionResult(response),
|
fullResult: formatExecutionResult(response),
|
||||||
displayResult: formatExecutionResultShort(response, language),
|
displayResult: formatExecutionResultShort(response, language),
|
||||||
rawData: response,
|
rawData: response,
|
||||||
|
images: response.images, // 传递图片数据
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +123,7 @@ export async function executeTool(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
fullResult: `未知的工具: ${toolName}`,
|
fullResult: `未知的工具: ${toolName}`,
|
||||||
displayResult: `❌ 未知的工具: ${toolName}`,
|
displayResult: `未知的工具: ${toolName}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -101,7 +132,7 @@ export async function executeTool(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
fullResult: `工具执行错误: ${errorMsg}`,
|
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';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
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