diff --git a/src/services/tools/pyodideRunner.ts b/src/services/tools/pyodideRunner.ts new file mode 100644 index 0000000..08f3ce1 --- /dev/null +++ b/src/services/tools/pyodideRunner.ts @@ -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; + loadPackagesFromImports(code: string): Promise; + loadPackage(packages: string | string[]): Promise; + 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 | null = null; +let isLoading = false; + +/** + * 加载 Pyodide 运行时 + * 使用单例模式,确保只加载一次 + */ +export async function loadPyodide(onProgress?: LoadingCallback): Promise { + // 如果已经加载完成,直接返回 + 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 }).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 { + 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 { + 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 { + 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 }).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; +}