diff --git a/docs/technical/code-execution.md b/docs/technical/code-execution.md new file mode 100644 index 0000000000..50cb7b2b3a --- /dev/null +++ b/docs/technical/code-execution.md @@ -0,0 +1,127 @@ +# 代码执行功能 + +本文档说明了代码块的 Python 代码执行功能。该实现利用 [Pyodide][pyodide-link] 在浏览器环境中直接运行 Python 代码,并将其置于 Web Worker 中,以避免阻塞主 UI 线程。 + +整个实现分为三个主要部分:UI 层、服务层和 Worker 层。 + +## 执行流程图 + +```mermaid +sequenceDiagram + participant 用户 + participant CodeBlockView (UI) + participant PyodideService (服务) + participant PyodideWorker (Worker) + + 用户->>CodeBlockView (UI): 点击“运行”按钮 + CodeBlockView (UI)->>PyodideService (服务): 调用 runScript(code) + PyodideService (服务)->>PyodideWorker (Worker): 发送 postMessage({ id, python: code }) + PyodideWorker (Worker)->>PyodideWorker (Worker): 加载 Pyodide 和相关包 + PyodideWorker (Worker)->>PyodideWorker (Worker): (按需)注入垫片并合并代码 + PyodideWorker (Worker)->>PyodideWorker (Worker): 执行合并后的 Python 代码 + PyodideWorker (Worker)-->>PyodideService (服务): 返回 postMessage({ id, output }) + PyodideService (服务)-->>CodeBlockView (UI): 返回 { text, image } 对象 + CodeBlockView (UI)->>用户: 在状态栏中显示文本和/或图像输出 +``` + +## 1. UI 层 + +面向用户的代码执行组件是 [CodeBlockView][codeblock-view-link]。 + +### 关键机制: + +- **运行按钮**:当代码块语言为 `python` 且 `codeExecution.enabled` 设置为 true 时,`CodeToolbar` 中会条件性地渲染一个“运行”按钮。 +- **事件处理**:运行按钮的 `onClick` 事件会触发 `handleRunScript` 函数。 +- **服务调用**:`handleRunScript` 调用 `pyodideService.runScript(code)`,将代码块中的 Python 代码传递给服务。 +- **状态管理与输出显示**:使用 `executionResult` 来管理所有执行输出,只要有任何结果(文本或图像),[StatusBar][statusbar-link] 组件就会被渲染以统一显示。 + +```typescript +// src/renderer/src/components/CodeBlockView/view.tsx +const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null) + +const handleRunScript = useCallback(() => { + setIsRunning(true) + setExecutionResult(null) + + pyodideService + .runScript(children, {}, codeExecution.timeoutMinutes * 60000) + .then((result) => { + setExecutionResult(result) + }) + .catch((error) => { + console.error('Unexpected error:', error) + setExecutionResult({ + text: `Unexpected error: ${error.message || 'Unknown error'}` + }) + }) + .finally(() => { + setIsRunning(false) + }) +}, [children, codeExecution.timeoutMinutes]); + +// ... 在 JSX 中 +{isExecutable && executionResult && ( + + {executionResult.text} + {executionResult.image && ( + + Matplotlib plot + + )} + +)} +``` + +## 2. 服务层 + +服务层充当 UI 组件和运行 Pyodide 的 Web Worker 之间的桥梁。其逻辑封装在位于单例类 [PyodideService][pyodide-service-link]。 + +### 主要职责: + +- **Worker 管理**:初始化、管理并与 Pyodide Web Worker 通信。 +- **请求处理**:使用 `resolvers` Map 管理并发请求,通过唯一 ID 匹配请求和响应。 +- **为 UI 提供 API**:向 UI 提供 `runScript(script, context, timeout)` 方法。此方法的返回值已修改为 `Promise<{ text: string; image?: string }>`,以支持包括图像在内的多种输出类型。 +- **输出处理**:从 Worker 接收包含文本、错误和可选图像数据的 `output` 对象。它将文本和错误格式化为对用户友好的单个字符串,然后连同图像数据一起包装成对象返回给 UI 层。 +- **IPC 端点**:该服务还提供了一个 `python-execution-request` IPC 端点,允许主进程请求执行 Python 代码,展示了其灵活的架构。 + +## 3. Worker 层 + +核心的 Python 执行发生在 [pyodide.worker.ts][pyodide-worker-link] 中定义的 Web Worker 内部。这确保了计算密集的 Python 代码不会冻结用户界面。 + +### Worker 逻辑: + +- **Pyodide 加载**:Worker 从 CDN 加载 Pyodide 引擎,并设置处理器以捕获 Python 的 `stdout` 和 `stderr`。 +- **动态包安装**:使用 `pyodide.loadPackagesFromImports()` 自动分析并安装代码中导入的依赖包。 +- **按需执行垫片代码**:Worker 会检查传入的代码中是否包含 "matplotlib" 字符串。如果是,它会先执行一段 Python“垫片”代码确保图像输出到全局命名空间。 +- **结果序列化**:执行结果通过 `.toJs()` 等方法被递归转换为可序列化的标准 JavaScript 对象。 +- **返回结构化输出**:执行后,Worker 将一个包含 `id` 和 `output` 对象的-消息发回服务层。`output` 对象是一个结构化对象,包含 `result`、`text`、`error` 以及一个可选的 `image` 字段(用于 Base64 图像数据)。 + +### 数据流 + +最终的数据流如下: + +1. **UI 层 ([CodeBlockView][codeblock-view-link])**: 用户点击“运行”按钮。 +2. **服务层 ([PyodideService][pyodide-service-link])**: + - 接收到代码执行请求。 + - 调用 Web Worker ([pyodide.worker.ts][pyodide-worker-link]),传递用户代码。 +3. **Worker 层 ([pyodide.worker.ts][pyodide-worker-link])**: + - 加载 Pyodide 运行时。 + - 动态安装代码中 `import` 语句声明的依赖包。 + - **注入 Matplotlib 垫片**: 如果代码中包含 `matplotlib`,则在用户代码前拼接垫片代码,强制使用 `AGG` 后端。 + - **执行代码并捕获输出**: 在代码执行后,检查 `matplotlib.pyplot` 的所有 figure,如果存在图像,则将其保存到内存中的 `BytesIO` 对象,并编码为 Base64 字符串。 + - **结构化返回**: 将捕获的文本输出和 Base64 图像数据封装在一个 JSON 对象中 (`{ "text": "...", "image": "data:image/png;base64,..." }`) 返回给主线程。 +4. **服务层 ([PyodideService][pyodide-service-link])**: + - 接收来自 Worker 的结构化数据。 + - 将数据原样传递给 UI 层。 +5. **UI 层 ([CodeBlockView][codeblock-view-link])**: + - 接收包含文本和图像数据的对象。 + - 使用一个 `useState` 来管理执行结果 (`executionResult`)。 + - 在界面上分别渲染文本输出和图像(如果存在)。 + + + +[pyodide-link]: https://pyodide.org/ +[codeblock-view-link]: /src/renderer/src/components/CodeBlockView/view.tsx +[pyodide-service-link]: /src/renderer/src/services/PyodideService.ts +[pyodide-worker-link]: /src/renderer/src/workers/pyodide.worker.ts +[statusbar-link]: /src/renderer/src/components/CodeBlockView/StatusBar.tsx diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx index 7e4c5e9e04..651405863f 100644 --- a/src/renderer/src/components/CodeBlockView/StatusBar.tsx +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -1,20 +1,21 @@ -import { FC, memo } from 'react' +import { Flex } from 'antd' +import { FC, memo, ReactNode } from 'react' import styled from 'styled-components' interface Props { - children: string + children: string | ReactNode } const StatusBar: FC = ({ children }) => { return {children} } -const Container = styled.div` - margin: 10px; +const Container = styled(Flex)` + background-color: var(--color-background-mute); + padding: 12px; display: flex; - flex-direction: row; + flex-direction: column; gap: 8px; - padding-bottom: 10px; overflow-y: auto; text-wrap: wrap; ` diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 28e371feb5..9c90d47427 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -12,6 +12,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import ImageViewer from '../ImageViewer' import CodePreview from './CodePreview' import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import HtmlArtifactsCard from './HtmlArtifactsCard' @@ -48,7 +49,7 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave const [viewMode, setViewMode] = useState('special') const [isRunning, setIsRunning] = useState(false) - const [output, setOutput] = useState('') + const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null) const [tools, setTools] = useState([]) const { registerTool, removeTool } = useCodeTool(setTools) @@ -87,16 +88,18 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave const handleRunScript = useCallback(() => { setIsRunning(true) - setOutput('') + setExecutionResult(null) pyodideService .runScript(children, {}, codeExecution.timeoutMinutes * 60000) - .then((formattedOutput) => { - setOutput(formattedOutput) + .then((result) => { + setExecutionResult(result) }) .catch((error) => { logger.error('Unexpected error:', error) - setOutput(`Unexpected error: ${error.message || 'Unknown error'}`) + setExecutionResult({ + text: `Unexpected error: ${error.message || 'Unknown error'}` + }) }) .finally(() => { setIsRunning(false) @@ -241,7 +244,14 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave {renderHeader} {renderContent} - {isExecutable && output && {output}} + {isExecutable && executionResult && ( + + {executionResult.text} + {executionResult.image && ( + + )} + + )} ) }) diff --git a/src/renderer/src/pages/home/Markdown/ImagePreview.tsx b/src/renderer/src/pages/home/Markdown/ImagePreview.tsx deleted file mode 100644 index 3ed40bef38..0000000000 --- a/src/renderer/src/pages/home/Markdown/ImagePreview.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - DownloadOutlined, - RotateLeftOutlined, - RotateRightOutlined, - SwapOutlined, - UndoOutlined, - ZoomInOutlined, - ZoomOutOutlined -} from '@ant-design/icons' -import { download } from '@renderer/utils/download' -import { Image as AntImage, ImageProps as AntImageProps, Space } from 'antd' -import React from 'react' -import styled from 'styled-components' - -interface ImagePreviewProps extends AntImageProps { - src: string -} - -const ImagePreview: React.FC = ({ src, ...props }) => { - return ( - ( - - - - - - - - - download(src)} /> - - ) - }} - /> - ) -} - -const ToobarWrapper = styled(Space)` - padding: 0px 24px; - color: #fff; - font-size: 20px; - background-color: rgba(0, 0, 0, 0.1); - border-radius: 100px; - .anticon { - padding: 12px; - cursor: pointer; - } - .anticon:hover { - opacity: 0.3; - } -` - -export default ImagePreview diff --git a/src/renderer/src/services/PyodideService.ts b/src/renderer/src/services/PyodideService.ts index 8c6849ae8b..c01cc8bc08 100644 --- a/src/renderer/src/services/PyodideService.ts +++ b/src/renderer/src/services/PyodideService.ts @@ -3,11 +3,27 @@ import { uuid } from '@renderer/utils' const logger = loggerService.withContext('PyodideService') +const SERVICE_CONFIG = { + WORKER: { + MAX_INIT_RETRY: 5, // 最大初始化重试次数 + REQUEST_TIMEOUT: { + INIT: 30000, // 30 秒初始化超时 + RUN: 60000 // 60 秒默认运行超时 + } + } +} + // 定义结果类型接口 export interface PyodideOutput { result: any text: string | null error: string | null + image?: string +} + +export interface PyodideExecutionResult { + text: string + image?: string } /** @@ -19,7 +35,6 @@ class PyodideService { private worker: Worker | null = null private initPromise: Promise | null = null private initRetryCount: number = 0 - private static readonly MAX_INIT_RETRY = 2 private resolvers: Map void; reject: (error: Error) => void }> = new Map() private constructor() { @@ -46,7 +61,7 @@ class PyodideService { if (this.worker) { return Promise.resolve() } - if (this.initRetryCount >= PyodideService.MAX_INIT_RETRY) { + if (this.initRetryCount >= SERVICE_CONFIG.WORKER.MAX_INIT_RETRY) { return Promise.reject(new Error('Pyodide worker initialization failed too many times')) } @@ -65,7 +80,7 @@ class PyodideService { this.initPromise = null this.initRetryCount++ reject(new Error('Pyodide initialization timeout')) - }, 10000) // 10秒初始化超时 + }, SERVICE_CONFIG.WORKER.REQUEST_TIMEOUT.INIT) // 设置初始化处理器 const initHandler = (event: MessageEvent) => { @@ -75,7 +90,7 @@ class PyodideService { this.initRetryCount = 0 this.initPromise = null resolve() - } else if (event.data?.type === 'error') { + } else if (event.data?.type === 'init-error') { clearTimeout(timeout) this.worker?.removeEventListener('message', initHandler) this.worker?.terminate() @@ -103,8 +118,16 @@ class PyodideService { * 处理来自 Worker 的消息 */ private handleMessage(event: MessageEvent): void { + const { type, error } = event.data + + // 记录 Worker 错误消息 + if (type === 'system-error') { + logger.error(error) + return + } + // 忽略初始化消息,已由专门的处理器处理 - if (event.data?.type === 'initialized' || event.data?.type === 'error') { + if (type === 'initialized' || type === 'init-error') { return } @@ -125,17 +148,23 @@ class PyodideService { * @param timeout 超时时间(毫秒) * @returns 格式化后的执行结果 */ - public async runScript(script: string, context: Record = {}, timeout: number = 60000): Promise { + public async runScript( + script: string, + context: Record = {}, + timeout: number = SERVICE_CONFIG.WORKER.REQUEST_TIMEOUT.RUN + ): Promise { // 确保Pyodide已初始化 try { await this.initialize() } catch (error: unknown) { logger.error('Pyodide initialization failed, cannot execute Python code', error) - return `Initialization failed: ${error instanceof Error ? error.message : String(error)}` + const text = `Initialization failed: ${error instanceof Error ? error.message : String(error)}` + return { text } } if (!this.worker) { - return 'Internal error: Pyodide worker is not initialized' + const text = 'Internal error: Pyodide worker is not initialized' + return { text } } try { @@ -166,9 +195,10 @@ class PyodideService { }) }) - return this.formatOutput(output) + return { text: this.formatOutput(output), image: output.image } } catch (error: unknown) { - return `Internal error: ${error instanceof Error ? error.message : String(error)}` + const text = `Internal error: ${error instanceof Error ? error.message : String(error)}` + return { text } } } @@ -200,7 +230,7 @@ class PyodideService { // 如果有错误信息,附加显示 if (output.error) { if (displayText) displayText += '\n\n' - displayText += `Error: ${output.error.trim()}` + displayText += output.error.trim() } // 如果没有任何输出,提供清晰提示 @@ -250,13 +280,13 @@ if (typeof window !== 'undefined' && window.electron?.ipcRenderer) { window.electron.ipcRenderer.on('python-execution-request', async (_, request: PythonExecutionRequest) => { try { - const result = await pyodideService.runScript(request.script, request.context, request.timeout) + const { text } = await pyodideService.runScript(request.script, request.context, request.timeout) const response: PythonExecutionResponse = { id: request.id, - result + result: text } window.electron.ipcRenderer.send('python-execution-response', response) - } catch (error) { + } catch (error: unknown) { const response: PythonExecutionResponse = { id: request.id, error: error instanceof Error ? error.message : String(error) diff --git a/src/renderer/src/services/ShikiStreamService.ts b/src/renderer/src/services/ShikiStreamService.ts index 2438c2c761..f0e348d68e 100644 --- a/src/renderer/src/services/ShikiStreamService.ts +++ b/src/renderer/src/services/ShikiStreamService.ts @@ -13,6 +13,30 @@ import { ShikiStreamTokenizer, ShikiStreamTokenizerOptions } from './ShikiStream const logger = loggerService.withContext('ShikiStreamService') +const SERVICE_CONFIG = { + // LRU 缓存配置 + TOKENIZER_CACHE: { + MAX_SIZE: 100, // 最大缓存数量 + TTL: 1000 * 60 * 30 // 30 分钟过期时间(毫秒) + }, + + // 降级策略配置 + DEGRADATION_CACHE: { + MAX_SIZE: 500, // 最大记录数量 + TTL: 1000 * 60 * 60 * 12 // 12 小时自动过期(毫秒) + }, + + // Worker 初始化配置 + WORKER: { + MAX_INIT_RETRY: 2, // 最大初始化重试次数 + REQUEST_TIMEOUT: { + INIT: 5000, // 初始化操作超时时间(毫秒) + HIGHLIGHT: 30000, // 高亮操作超时时间(毫秒) + DEFAULT: 10000 // 默认超时时间(毫秒) + } + } +} + export type ShikiPreProperties = { class: string style: string @@ -42,8 +66,8 @@ class ShikiStreamService { // 保存以 callerId-language-theme 为键的 tokenizer map private tokenizerCache = new LRUCache({ - max: 100, // 最大缓存数量 - ttl: 1000 * 60 * 30, // 30分钟过期时间 + max: SERVICE_CONFIG.TOKENIZER_CACHE.MAX_SIZE, + ttl: SERVICE_CONFIG.TOKENIZER_CACHE.TTL, updateAgeOnGet: true, dispose: (value) => { if (value) value.clear() @@ -52,8 +76,8 @@ class ShikiStreamService { // 缓存每个 callerId 对应的已处理内容 private codeCache = new LRUCache({ - max: 100, // 最大缓存数量 - ttl: 1000 * 60 * 30, // 30分钟过期时间 + max: SERVICE_CONFIG.TOKENIZER_CACHE.MAX_SIZE, + ttl: SERVICE_CONFIG.TOKENIZER_CACHE.TTL, updateAgeOnGet: true }) @@ -61,7 +85,6 @@ class ShikiStreamService { private worker: Worker | null = null private workerInitPromise: Promise | null = null private workerInitRetryCount: number = 0 - private static readonly MAX_WORKER_INIT_RETRY = 2 private pendingRequests = new Map< number, { @@ -73,8 +96,8 @@ class ShikiStreamService { // 降级策略相关变量,用于记录调用 worker 失败过的 callerId private workerDegradationCache = new LRUCache({ - max: 1000, // 最大记录数量 - ttl: 1000 * 60 * 60 * 12 // 12小时自动过期 + max: SERVICE_CONFIG.DEGRADATION_CACHE.MAX_SIZE, + ttl: SERVICE_CONFIG.DEGRADATION_CACHE.TTL }) constructor() { @@ -103,7 +126,7 @@ class ShikiStreamService { if (this.workerInitPromise) return this.workerInitPromise if (this.worker) return - if (this.workerInitRetryCount >= ShikiStreamService.MAX_WORKER_INIT_RETRY) { + if (this.workerInitRetryCount >= SERVICE_CONFIG.WORKER.MAX_INIT_RETRY) { logger.debug('ShikiStream worker initialization failed too many times, stop trying') return } @@ -191,13 +214,13 @@ class ShikiStreamService { const getTimeoutForMessageType = (type: string): number => { switch (type) { case 'init': - return 5000 // 初始化操作 (5秒) + return SERVICE_CONFIG.WORKER.REQUEST_TIMEOUT.INIT case 'highlight': - return 30000 // 高亮操作 (30秒) + return SERVICE_CONFIG.WORKER.REQUEST_TIMEOUT.HIGHLIGHT case 'cleanup': case 'dispose': default: - return 10000 // 其他操作 (10秒) + return SERVICE_CONFIG.WORKER.REQUEST_TIMEOUT.DEFAULT } } diff --git a/src/renderer/src/workers/pyodide.worker.ts b/src/renderer/src/workers/pyodide.worker.ts index b3d0f62102..335ac1b143 100644 --- a/src/renderer/src/workers/pyodide.worker.ts +++ b/src/renderer/src/workers/pyodide.worker.ts @@ -1,16 +1,63 @@ /// -import { loggerService } from '@logger' - -const logger = loggerService.initWindowSource('Worker').withContext('Pyodide') +interface WorkerResponse { + type: 'initialized' | 'init-error' | 'system-error' + id?: string + output?: PyodideOutput + error?: string +} // 定义输出结构类型 interface PyodideOutput { result: any text: string | null error: string | null + image?: string } +const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.28.0/full/' +const PYODIDE_MODULE_URL = PYODIDE_INDEX_URL + 'pyodide.mjs' + +// 垫片代码,用于在 Worker 中捕获 Matplotlib 绘图 +const MATPLOTLIB_SHIM_CODE = ` +def __cherry_studio_matplotlib_setup(): + import os + # 在导入 pyplot 前设置后端 + os.environ["MPLBACKEND"] = "AGG" + import io + import base64 + import matplotlib.pyplot as plt + + # 保存原始的 show 函数 + _original_show = plt.show + + # 定义并替换为新的 show 函数 + def _new_show(*args, **kwargs): + global pyodide_matplotlib_image + fig = plt.gcf() + + if not fig.canvas.get_renderer()._renderer: + return + + buf = io.BytesIO() + fig.savefig(buf, format='png') + buf.seek(0) + + img_str = base64.b64encode(buf.read()).decode('utf-8') + + # 通过全局变量传递数据 + pyodide_matplotlib_image = f"data:image/png;base64,{img_str}" + + plt.clf() + plt.close(fig) + + # 替换全局的 show 函数 + plt.show = _new_show + +__cherry_studio_matplotlib_setup() +del __cherry_studio_matplotlib_setup +` + // 声明全局变量用于输出 let output: PyodideOutput = { result: null, @@ -28,12 +75,11 @@ const pyodidePromise = (async () => { try { // 动态加载 Pyodide 脚本 - // @ts-ignore - 忽略动态导入错误 - const pyodideModule = await import('https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.mjs') + const pyodideModule = await import(/* @vite-ignore */ PYODIDE_MODULE_URL) // 加载 Pyodide 并捕获标准输出/错误 return await pyodideModule.loadPyodide({ - indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/', + indexURL: PYODIDE_INDEX_URL, stdout: (text: string) => { if (output.text) { output.text += `${text}\n` @@ -51,13 +97,12 @@ const pyodidePromise = (async () => { }) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) - logger.error('Failed to load Pyodide:', errorMessage) // 通知主线程初始化错误 self.postMessage({ - type: 'error', + type: 'init-error', error: errorMessage - }) + } as WorkerResponse) throw error } @@ -81,7 +126,6 @@ function processResult(result: any): any { return result } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) - logger.error('Result processing error:', errorMessage) return { __error__: 'Result processing failed', details: errorMessage } } } @@ -89,17 +133,19 @@ function processResult(result: any): any { // 通知主线程已加载 pyodidePromise .then(() => { - self.postMessage({ type: 'initialized' }) + self.postMessage({ type: 'initialized' } as WorkerResponse) }) .catch((error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error) - logger.error('Failed to load Pyodide:', errorMessage) - self.postMessage({ type: 'error', error: errorMessage }) + self.postMessage({ + type: 'init-error', + error: errorMessage + } as WorkerResponse) }) // 处理消息 self.onmessage = async (event) => { - const { id, python, context } = event.data + const { id, python } = event.data // 重置输出变量 output = { @@ -111,12 +157,6 @@ self.onmessage = async (event) => { try { const pyodide = await pyodidePromise - // 将上下文变量设置为全局作用域变量 - const globalContext: Record = {} - for (const key of Object.keys(context || {})) { - globalContext[key] = context[key] - } - // 载入需要的包 try { await pyodide.loadPackagesFromImports(python) @@ -125,14 +165,23 @@ self.onmessage = async (event) => { throw new Error(`Failed to load required packages: ${errorMessage}`) } - // 创建 Python 上下文 - const globals = pyodide.globals.get('dict')(Object.entries(context || {})) - // 执行代码 try { - output.result = await pyodide.runPythonAsync(python, { globals }) + // 注入 Matplotlib 垫片代码 + if (python.includes('matplotlib')) { + await pyodide.runPythonAsync(MATPLOTLIB_SHIM_CODE) + } + + output.result = await pyodide.runPythonAsync(python) // 处理结果,确保安全序列化 output.result = processResult(output.result) + + // 检查是否有 Matplotlib 图像输出 + const image = pyodide.globals.get('pyodide_matplotlib_image') + if (image) { + output.image = image + pyodide.globals.delete('pyodide_matplotlib_image') + } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) // 不设置 output.result,但设置错误信息 @@ -145,15 +194,21 @@ self.onmessage = async (event) => { } catch (error: unknown) { // 处理所有其他错误 const errorMessage = error instanceof Error ? error.message : String(error) - logger.error('Python processing error:', errorMessage) if (output.error) { output.error += `\nSystem error:\n${errorMessage}` } else { output.error = `System error:\n${errorMessage}` } + + // 发送错误信息 + self.postMessage({ + type: 'system-error', + id, + error: errorMessage + } as WorkerResponse) } finally { // 统一发送处理后的输出对象 - self.postMessage({ id, output }) + self.postMessage({ id, output } as WorkerResponse) } }