feat(工具): 添加 Pyodide 浏览器端 Python 运行时
- 实现基于 WebAssembly 的 Python 运行环境 - 支持 matplotlib 图形渲染并输出为 Base64 图片 - 实现中文字体加载(Noto Sans SC) - 预注册 seaborn-whitegrid 等多种图表样式 - 单例模式管理 Pyodide 实例,优化加载性能
This commit is contained in:
parent
ba4e00a341
commit
ef45e14534
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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user