feat: add builtin Python plugin (#8873)

* feat: 初步完成

* chore: type

* feat: 图片功能

* feat: 文件下载功能

* refactor: 简化代码

* chore: 清理代码

* chore: clean

* chore: 清理代码

* chore: 清理代码

* chore: 小改进

* fix: 上传完成前图片无法显示

* refactor: 增加 python-interpreter package

* chore: 清理

* feat: 传入上下文中的文件

* chore: 小优化

* chore: 中文字体

* chore: clean

* fix: 服务端部署

* fix: 重复文件检查

* test: 增加 interpreter.test.ts

* test: add worker.test.ts

* style: fix import

* test: fix

* style: fix import

* style: move env file to envs

* style: 限制代码框高度

* style: 重命名

* misc: 小修小补

* refactor: 重命名为 code-interpreter

---------

Co-authored-by: Arvin Xu <arvinx@foxmail.com>
This commit is contained in:
Aloxaf 2025-09-30 04:20:57 +08:00 committed by GitHub
parent a30a65cd4c
commit fa6ef94067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1183 additions and 1 deletions

View file

@ -18,6 +18,7 @@ jobs:
- web-crawler
- electron-server-ipc
- utils
- python-interpreter
- context-engine
- agent-runtime

View file

@ -1,4 +1,11 @@
{
"codeInterpreter": {
"error": "执行错误",
"executing": "执行中...",
"files": "文件:",
"output": "输出:",
"returnValue": "返回值:"
},
"dalle": {
"autoGenerate": "自动生成",
"downloading": "DallE3 生成的图片链接有效期仅1小时正在缓存图片到本地...",

View file

@ -153,6 +153,7 @@
"@lobechat/model-runtime": "workspace:*",
"@lobechat/observability-otel": "workspace:*",
"@lobechat/prompts": "workspace:*",
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/utils": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/analytics": "^1.6.0",

View file

@ -0,0 +1,15 @@
{
"name": "@lobechat/python-interpreter",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"comlink": "^4.4.2",
"pyodide": "^0.28.2",
"url-join": "^5.0.0"
}
}

View file

@ -0,0 +1,27 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('Python interpreter', () => {
beforeEach(() => {
vi.resetModules();
});
it('should be undefined if is not in browser', async () => {
const { PythonInterpreter } = await import('../index');
expect(PythonInterpreter).toBeUndefined();
});
it('should be defined if is in browser', async () => {
const MockWorker = vi.fn().mockImplementation(() => ({
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
vi.stubGlobal('Worker', MockWorker);
const { PythonInterpreter } = await import('../index');
expect(PythonInterpreter).toBeDefined();
});
});

View file

@ -0,0 +1,252 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('comlink', () => ({
expose: vi.fn(),
}));
describe('PythonWorker', () => {
const mockPyodide = {
FS: {
mkdirTree: vi.fn(),
chdir: vi.fn(),
writeFile: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
},
loadPackage: vi.fn(),
pyimport: vi.fn(),
loadPackagesFromImports: vi.fn(),
setStdout: vi.fn(),
setStderr: vi.fn(),
runPythonAsync: vi.fn(),
loadedPackages: {},
};
const mockMicropip = {
set_index_urls: vi.fn(),
install: vi.fn(),
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Setup minimal global mocks
vi.stubGlobal('importScripts', vi.fn());
vi.stubGlobal('loadPyodide', vi.fn().mockResolvedValue(mockPyodide));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
}),
);
mockPyodide.pyimport.mockReturnValue(mockMicropip);
mockPyodide.loadedPackages = {};
});
const importWorker = async () => {
const { PythonWorker } = await import('../worker');
return { PythonWorker };
};
describe('constructor', () => {
it('should initialize with default options', () => {
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker({});
expect(worker.pypiIndexUrl).toBe('PYPI');
expect(worker.pyodideIndexUrl).toBe('https://cdn.jsdelivr.net/pyodide/v0.28.2/full');
expect(worker.uploadedFiles).toEqual([]);
});
});
it('should initialize with custom options', () => {
const options = {
pyodideIndexUrl: 'https://test.cdn.com/pyodide',
pypiIndexUrl: 'https://test.pypi.org',
};
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker(options);
expect(worker.pypiIndexUrl).toBe('https://test.pypi.org');
expect(worker.pyodideIndexUrl).toBe('https://test.cdn.com/pyodide');
});
});
it('should call importScripts with pyodide.js', () => {
return importWorker().then(({ PythonWorker }) => {
new PythonWorker({});
expect(globalThis.importScripts).toHaveBeenCalledWith(
expect.stringContaining('/pyodide.js'),
);
});
});
});
describe('pyodide getter', () => {
it('should throw error when pyodide is not initialized', () => {
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker({});
expect(() => worker.pyodide).toThrow('Python interpreter not initialized');
});
});
it('should return pyodide when initialized', async () => {
const { PythonWorker } = await importWorker();
const worker = new PythonWorker({});
await worker.init();
expect(worker.pyodide).toBe(mockPyodide);
});
});
describe('init', () => {
it('should initialize pyodide and setup filesystem', async () => {
const { PythonWorker } = await importWorker();
const worker = new PythonWorker({
pyodideIndexUrl: 'https://test.cdn.com/pyodide',
});
await worker.init();
expect(globalThis.loadPyodide).toHaveBeenCalledWith({
indexURL: 'https://test.cdn.com/pyodide',
});
expect(mockPyodide.FS.mkdirTree).toHaveBeenCalledWith('/mnt/data');
expect(mockPyodide.FS.chdir).toHaveBeenCalledWith('/mnt/data');
});
});
describe('file operations', () => {
let worker: any;
beforeEach(async () => {
const { PythonWorker } = await importWorker();
worker = new PythonWorker({});
await worker.init();
});
it('should upload files correctly', async () => {
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
await worker.uploadFiles([mockFile]);
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith(
'/mnt/data/test.txt',
expect.any(Uint8Array),
);
expect(worker.uploadedFiles).toContain(mockFile);
});
it('should upload files with absolute path as-is', async () => {
const absFile = new File([Uint8Array.from([1, 2])], '/abs.txt');
await worker.uploadFiles([absFile]);
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith('/abs.txt', expect.any(Uint8Array));
});
it('should download new files from filesystem', async () => {
const mockFileContent = new Uint8Array([1, 2, 3, 4]);
mockPyodide.FS.readdir.mockReturnValue(['.', '..', 'output.txt']);
(mockPyodide.FS as any).readFile.mockReturnValue(mockFileContent);
const files = await worker.downloadFiles();
expect(files).toHaveLength(1);
expect(files[0].name).toBe('/mnt/data/output.txt');
});
it('should skip identical files in download (dedup)', async () => {
const same = new File([Uint8Array.from([7, 8])], 'same.txt');
await worker.uploadFiles([same]);
mockPyodide.FS.readdir.mockReturnValue(['.', '..', 'same.txt']);
(mockPyodide.FS as any).readFile.mockReturnValue(Uint8Array.from([7, 8]));
const files = await worker.downloadFiles();
expect(files).toHaveLength(0);
});
});
describe('runPython', () => {
let worker: any;
beforeEach(async () => {
const { PythonWorker } = await importWorker();
worker = new PythonWorker({});
await worker.init();
});
it('should execute python code successfully', async () => {
const code = 'print("Hello, World!")';
const expectedResult = 'Hello, World!';
mockPyodide.runPythonAsync.mockResolvedValue(expectedResult);
const result = await worker.runPython(code);
expect(result.success).toBe(true);
expect(result.result).toBe(expectedResult);
expect(mockPyodide.runPythonAsync).toHaveBeenCalledWith(code);
});
it('should call loadPackagesFromImports with code', async () => {
const code = 'print("x")';
mockPyodide.runPythonAsync.mockResolvedValue('x');
await worker.runPython(code);
expect(mockPyodide.loadPackagesFromImports).toHaveBeenCalledWith(code);
});
it('should handle python execution errors', async () => {
const error = new Error('SyntaxError: invalid syntax');
mockPyodide.runPythonAsync.mockRejectedValue(error);
const result = await worker.runPython('invalid code');
expect(result.success).toBe(false);
expect(result.output).toContainEqual({
data: 'SyntaxError: invalid syntax',
type: 'stderr',
});
});
it('should install packages using micropip', async () => {
const packages = ['numpy', 'pandas'];
await worker.installPackages(packages);
expect(mockPyodide.loadPackage).toHaveBeenCalledWith('micropip');
expect(mockMicropip.set_index_urls).toHaveBeenCalledWith([worker.pypiIndexUrl, 'PYPI']);
expect(mockMicropip.install).toHaveBeenCalledWith(packages);
});
it('should patch matplotlib when loaded', async () => {
mockPyodide.loadedPackages = { matplotlib: true } as any;
mockPyodide.runPythonAsync.mockResolvedValueOnce(undefined).mockResolvedValueOnce('ok');
const res = await worker.runPython('print(1)');
expect(res.success).toBe(true);
expect(mockPyodide.runPythonAsync).toHaveBeenNthCalledWith(
1,
expect.stringContaining('patch_matplotlib()'),
);
});
it('should write fonts into truetype directory before run', async () => {
mockPyodide.runPythonAsync.mockResolvedValue('ok');
await worker.runPython('print(1)');
expect(mockPyodide.FS.mkdirTree).toHaveBeenCalledWith('/usr/share/fonts/truetype');
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith(
expect.stringContaining('/usr/share/fonts/truetype/STSong.ttf'),
expect.any(Uint8Array),
);
});
it('should stringify non-string result', async () => {
mockPyodide.runPythonAsync.mockResolvedValue({ toString: () => '42' });
const r = await worker.runPython('1+41');
expect(r.success).toBe(true);
expect(r.result).toBe('42');
});
});
});

View file

@ -0,0 +1,2 @@
export { PythonInterpreter } from './interpreter';
export * from './types';

View file

@ -0,0 +1,13 @@
import * as Comlink from 'comlink';
import type { PythonWorkerType } from './worker';
export const PythonInterpreter = (() => {
if (typeof Worker !== 'undefined') {
let worker = new Worker(new URL('worker.ts', import.meta.url), {
type: 'module',
});
return Comlink.wrap<PythonWorkerType>(worker);
}
return undefined;
})();

View file

@ -0,0 +1,23 @@
export interface PythonOptions {
/**
* Pyodide CDN URL
*/
pyodideIndexUrl?: string;
/**
* PyPI URL [JSON API](https://warehouse.pypa.io/api-reference/json.html)
*
* `https://pypi.org/pypi/{package_name}/json`
*/
pypiIndexUrl?: string;
}
export interface PythonOutput {
data: string;
type: 'stdout' | 'stderr';
}
export interface PythonResult {
output?: PythonOutput[];
result?: string;
success: boolean;
}

View file

@ -0,0 +1,211 @@
import * as Comlink from 'comlink';
import { PyodideAPI, loadPyodide as loadPyodideType } from 'pyodide';
import urlJoin from 'url-join';
import { PythonOptions, PythonOutput, PythonResult } from './types';
declare global {
// eslint-disable-next-line no-var
var loadPyodide: typeof loadPyodideType;
}
const PATCH_MATPLOTLIB = `
def patch_matplotlib():
import matplotlib
import matplotlib.pyplot as plt
from matplotlib import font_manager
# patch plt.show
matplotlib.use('Agg')
index = 1
def show():
nonlocal index
plt.savefig(f'/mnt/data/plot_{index}.png', format="png")
plt.clf()
index += 1
plt.show = show
# patch fonts
font_path = '/usr/share/fonts/truetype/STSong.ttf'
font_manager.fontManager.addfont(font_path)
plt.rcParams['font.family'] = 'STSong'
patch_matplotlib()`;
// Pyodide 对象不能在 Worker 之间传递,因此定义为全局变量
let pyodide: PyodideAPI | undefined;
class PythonWorker {
pyodideIndexUrl: string;
pypiIndexUrl: string;
uploadedFiles: File[];
constructor(options: PythonOptions) {
this.pypiIndexUrl = options.pypiIndexUrl || 'PYPI';
this.pyodideIndexUrl =
options.pyodideIndexUrl || 'https://cdn.jsdelivr.net/pyodide/v0.28.2/full';
globalThis.importScripts(urlJoin(this.pyodideIndexUrl, 'pyodide.js'));
this.uploadedFiles = [];
}
get pyodide() {
if (!pyodide) {
throw new Error('Python interpreter not initialized');
}
return pyodide;
}
/**
* Python
*/
async init() {
pyodide = await globalThis.loadPyodide({
indexURL: this.pyodideIndexUrl,
});
pyodide.FS.mkdirTree('/mnt/data');
pyodide.FS.chdir('/mnt/data');
}
/**
*
* @param files
*/
async uploadFiles(files: File[]) {
for (const file of files) {
const content = new Uint8Array(await file.arrayBuffer());
// TODO: 此处可以考虑使用 WORKERFS 减少一次拷贝
if (file.name.startsWith('/')) {
this.pyodide.FS.writeFile(file.name, content);
} else {
this.pyodide.FS.writeFile(`/mnt/data/${file.name}`, content);
}
this.uploadedFiles.push(file);
}
}
/**
*
* @param files
*/
async downloadFiles() {
const result: File[] = [];
for (const entry of this.pyodide.FS.readdir('/mnt/data')) {
if (entry === '.' || entry === '..') continue;
const filePath = `/mnt/data/${entry}`;
// pyodide 的 FS 类型定义有问题,只能采用 any
const content = (this.pyodide.FS as any).readFile(filePath, { encoding: 'binary' });
const blob = new Blob([content]);
const file = new File([blob], filePath);
if (await this.isNewFile(file)) {
result.push(file);
}
}
return result;
}
/**
* Python
* @param packages
*/
async installPackages(packages: string[]) {
await this.pyodide.loadPackage('micropip');
const micropip = this.pyodide.pyimport('micropip');
micropip.set_index_urls([this.pypiIndexUrl, 'PYPI']);
await micropip.install(packages);
}
/**
* Python
* @param code
*/
async runPython(code: string): Promise<PythonResult> {
await this.patchFonts();
// NOTE: loadPackagesFromImports 只会处理 pyodide 官方包
await this.pyodide.loadPackagesFromImports(code);
await this.patchPackages();
// 安装依赖后再捕获标准输出,避免记录安装日志
const output: PythonOutput[] = [];
this.pyodide.setStdout({
batched: (o: string) => {
output.push({ data: o, type: 'stdout' });
},
});
this.pyodide.setStderr({
batched: (o: string) => {
output.push({ data: o, type: 'stderr' });
},
});
// 执行代码
let result;
let success = false;
try {
result = await this.pyodide.runPythonAsync(code);
success = true;
} catch (error) {
output.push({
data: error instanceof Error ? error.message : String(error),
type: 'stderr',
});
}
return {
output,
result: result?.toString(),
success,
};
}
private async patchPackages() {
const hasMatplotlib = Object.keys(this.pyodide.loadedPackages).includes('matplotlib');
if (hasMatplotlib) {
await this.pyodide.runPythonAsync(PATCH_MATPLOTLIB);
}
}
private async patchFonts() {
this.pyodide.FS.mkdirTree('/usr/share/fonts/truetype');
const fontFiles = {
'STSong.ttf':
'https://cdn.jsdelivr.net/gh/Haixing-Hu/latex-chinese-fonts@latest/chinese/宋体/STSong.ttf',
};
for (const [filename, url] of Object.entries(fontFiles)) {
const buffer = await fetch(url, { cache: 'force-cache' }).then((res) => res.arrayBuffer());
// NOTE: 此处理论上使用 createLazyFile 更好,但 pyodide 中使用会导致报错
this.pyodide.FS.writeFile(`/usr/share/fonts/truetype/${filename}`, new Uint8Array(buffer));
}
}
private async isNewFile(file: File) {
const isSameFile = async (a: File, b: File) => {
// a 是传入的文件,可能使用了绝对路径或相对路径
// b 是解释器环境中的文件,使用绝对路径
if (a.name.startsWith('/')) {
if (a.name !== b.name) return false;
} else {
if (`/mnt/data/${a.name}` !== b.name) return false;
}
if (a.size !== b.size) return false;
const aBuffer = await a.arrayBuffer();
const bBuffer = await b.arrayBuffer();
const aArray = new Uint8Array(aBuffer);
const bArray = new Uint8Array(bBuffer);
const length = aArray.length;
for (let i = 0; i < length; i++) {
if (aArray[i] !== bArray[i]) return false;
}
return true;
};
const t = await Promise.all(this.uploadedFiles.map((f) => isSameFile(f, file)));
return t.every((f) => !f);
}
}
Comlink.expose(PythonWorker);
export { PythonWorker };
export type PythonWorkerType = typeof PythonWorker;

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'node',
},
});

View file

@ -24,4 +24,5 @@ export interface LobeTool {
export type LobeToolRenderType = LobePluginType | 'builtin';
export * from './builtin';
export * from './interpreter';
export * from './plugin';

View file

@ -0,0 +1,21 @@
import { PythonResult } from '@lobechat/python-interpreter';
export interface CodeInterpreterParams {
code: string;
packages: string[];
}
export interface CodeInterpreterFileItem {
data?: File;
fileId?: string;
filename: string;
previewUrl?: string;
}
export interface CodeInterpreterResponse extends PythonResult {
files?: CodeInterpreterFileItem[];
}
export interface CodeInterpreterState {
error?: any;
}

17
src/envs/python.ts Normal file
View file

@ -0,0 +1,17 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const getPythonConfig = () => {
return createEnv({
client: {
NEXT_PUBLIC_PYODIDE_INDEX_URL: z.string().url().optional(),
NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL: z.string().url().optional(),
},
runtimeEnv: {
NEXT_PUBLIC_PYODIDE_INDEX_URL: process.env.NEXT_PUBLIC_PYODIDE_INDEX_URL,
NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL: process.env.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL,
},
});
};
export const pythonEnv = getPythonConfig();

View file

@ -1,4 +1,11 @@
export default {
codeInterpreter: {
error: '执行错误',
executing: '执行中...',
files: '文件:',
output: '输出:',
returnValue: '返回值:',
},
dalle: {
autoGenerate: '自动生成',
downloading: 'DallE3 生成的图片链接有效期仅1小时正在缓存图片到本地...',

33
src/services/python.ts Normal file
View file

@ -0,0 +1,33 @@
import { PythonInterpreter } from '@lobechat/python-interpreter';
import { CodeInterpreterResponse } from '@lobechat/types';
class PythonService {
async runPython(
code: string,
packages: string[],
files: File[],
): Promise<CodeInterpreterResponse | undefined> {
if (typeof Worker === 'undefined') return;
const interpreter = await new PythonInterpreter!({
pyodideIndexUrl: process.env.NEXT_PUBLIC_PYODIDE_INDEX_URL!,
pypiIndexUrl: process.env.NEXT_PUBLIC_PYPI_INDEX_URL!,
});
await interpreter.init();
await interpreter.installPackages(packages.filter((p) => p !== ''));
await interpreter.uploadFiles(files);
const result = await interpreter.runPython(code);
const resultFiles = await interpreter.downloadFiles();
return {
files: resultFiles.map((file) => ({
data: file,
filename: file.name,
previewUrl: URL.createObjectURL(file),
})),
...result,
};
}
}
export const pythonService = new PythonService();

View file

@ -3,10 +3,15 @@ import { StateCreator } from 'zustand/vanilla';
import { ChatStore } from '@/store/chat/store';
import { ChatDallEAction, dalleSlice } from './dalle';
import { ChatCodeInterpreterAction, codeInterpreterSlice } from './interpreter';
import { LocalFileAction, localFileSlice } from './localFile';
import { SearchAction, searchSlice } from './search';
export interface ChatBuiltinToolAction extends ChatDallEAction, SearchAction, LocalFileAction {}
export interface ChatBuiltinToolAction
extends ChatDallEAction,
SearchAction,
LocalFileAction,
ChatCodeInterpreterAction {}
export const chatToolSlice: StateCreator<
ChatStore,
@ -17,4 +22,5 @@ export const chatToolSlice: StateCreator<
...dalleSlice(...params),
...searchSlice(...params),
...localFileSlice(...params),
...codeInterpreterSlice(...params),
});

View file

@ -0,0 +1,169 @@
import {
CodeInterpreterFileItem,
CodeInterpreterParams,
CodeInterpreterResponse,
} from '@lobechat/types';
import { produce } from 'immer';
import pMap from 'p-map';
import { SWRResponse } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { useClientDataSWR } from '@/libs/swr';
import { fileService } from '@/services/file';
import { pythonService } from '@/services/python';
import { chatSelectors } from '@/store/chat/selectors';
import { ChatStore } from '@/store/chat/store';
import { useFileStore } from '@/store/file';
import { CodeInterpreterIdentifier } from '@/tools/code-interpreter';
import { setNamespace } from '@/utils/storeDebug';
const n = setNamespace('codeInterpreter');
const SWR_FETCH_INTERPRETER_FILE_KEY = 'FetchCodeInterpreterFileItem';
export interface ChatCodeInterpreterAction {
python: (id: string, params: CodeInterpreterParams) => Promise<boolean | undefined>;
toggleInterpreterExecuting: (id: string, loading: boolean) => void;
updateInterpreterFileItem: (
id: string,
updater: (data: CodeInterpreterResponse) => void,
) => Promise<void>;
uploadInterpreterFiles: (id: string, files: CodeInterpreterFileItem[]) => Promise<void>;
useFetchInterpreterFileItem: (id?: string) => SWRResponse;
}
export const codeInterpreterSlice: StateCreator<
ChatStore,
[['zustand/devtools', never]],
[],
ChatCodeInterpreterAction
> = (set, get) => ({
python: async (id: string, params: CodeInterpreterParams) => {
const {
toggleInterpreterExecuting,
updatePluginState,
internal_updateMessageContent,
uploadInterpreterFiles,
} = get();
toggleInterpreterExecuting(id, true);
// TODO: 应该只下载 AI 用到的文件
const files: File[] = [];
for (const message of chatSelectors.mainDisplayChats(get())) {
for (const file of message.fileList ?? []) {
const blob = await fetch(file.url).then((res) => res.blob());
files.push(new File([blob], file.name));
}
for (const image of message.imageList ?? []) {
const blob = await fetch(image.url).then((res) => res.blob());
files.push(new File([blob], image.alt));
}
for (const tool of message.tools ?? []) {
if (tool.identifier === CodeInterpreterIdentifier) {
const message = chatSelectors.getMessageByToolCallId(tool.id)(get());
if (message?.content) {
const content = JSON.parse(message.content) as CodeInterpreterResponse;
for (const file of content.files ?? []) {
const item = await fileService.getFile(file.fileId!);
const blob = await fetch(item.url).then((res) => res.blob());
files.push(new File([blob], file.filename));
}
}
}
}
}
try {
const result = await pythonService.runPython(params.code, params.packages, files);
if (result?.files) {
await internal_updateMessageContent(id, JSON.stringify(result));
await uploadInterpreterFiles(id, result.files);
} else {
await internal_updateMessageContent(id, JSON.stringify(result));
}
} catch (error) {
updatePluginState(id, { error });
// 如果调用过程中出现了错误,不要触发 AI 消息
return;
} finally {
toggleInterpreterExecuting(id, false);
}
return true;
},
toggleInterpreterExecuting: (id: string, executing: boolean) => {
set(
{ codeInterpreterExecuting: { ...get().codeInterpreterExecuting, [id]: executing } },
false,
n('toggleInterpreterExecuting'),
);
},
updateInterpreterFileItem: async (
id: string,
updater: (data: CodeInterpreterResponse) => void,
) => {
const message = chatSelectors.getMessageById(id)(get());
if (!message) return;
const result: CodeInterpreterResponse = JSON.parse(message.content);
if (!result.files) return;
const nextResult = produce(result, updater);
await get().internal_updateMessageContent(id, JSON.stringify(nextResult));
},
uploadInterpreterFiles: async (id: string, files: CodeInterpreterFileItem[]) => {
const { updateInterpreterFileItem } = get();
if (!files) return;
await pMap(files, async (file, index) => {
if (!file.data) return;
try {
const uploadResult = await useFileStore.getState().uploadWithProgress({
file: file.data,
skipCheckFileType: true,
});
if (uploadResult?.id) {
await updateInterpreterFileItem(id, (draft) => {
if (draft.files?.[index]) {
draft.files[index].fileId = uploadResult.id;
draft.files[index].previewUrl = undefined;
draft.files[index].data = undefined;
}
});
}
} catch (error) {
console.error('Failed to upload CodeInterpreter file:', error);
}
});
},
useFetchInterpreterFileItem: (id) =>
useClientDataSWR(id ? [SWR_FETCH_INTERPRETER_FILE_KEY, id] : null, async () => {
if (!id) return null;
const item = await fileService.getFile(id);
set(
produce((draft) => {
if (!draft.codeInterpreterFileMap) {
draft.codeInterpreterFileMap = {};
}
if (draft.codeInterpreterFileMap[id]) return;
draft.codeInterpreterFileMap[id] = item;
}),
false,
n('useFetchInterpreterFileItem'),
);
return item;
}),
});

View file

@ -2,6 +2,8 @@ import { FileItem } from '@/types/files';
export interface ChatToolState {
activePageContentUrl?: string;
codeInterpreterExecuting: Record<string, boolean>;
codeInterpreterImageMap: Record<string, FileItem>;
dalleImageLoading: Record<string, boolean>;
dalleImageMap: Record<string, FileItem>;
localFileLoading: Record<string, boolean>;
@ -9,6 +11,8 @@ export interface ChatToolState {
}
export const initialToolState: ChatToolState = {
codeInterpreterExecuting: {},
codeInterpreterImageMap: {},
dalleImageLoading: {},
dalleImageMap: {},
localFileLoading: {},

View file

@ -5,12 +5,16 @@ const isDallEImageGenerating = (id: string) => (s: ChatStoreState) => s.dalleIma
const isGeneratingDallEImage = (s: ChatStoreState) =>
Object.values(s.dalleImageLoading).some(Boolean);
const isInterpreterExecuting = (id: string) => (s: ChatStoreState) =>
s.codeInterpreterExecuting[id];
const isSearXNGSearching = (id: string) => (s: ChatStoreState) => s.searchLoading[id];
const isSearchingLocalFiles = (id: string) => (s: ChatStoreState) => s.localFileLoading[id];
export const chatToolSelectors = {
isDallEImageGenerating,
isGeneratingDallEImage,
isInterpreterExecuting,
isSearXNGSearching,
isSearchingLocalFiles,
};

View file

@ -0,0 +1,57 @@
import { CodeInterpreterFileItem } from '@lobechat/types';
import { PreviewGroup } from '@lobehub/ui';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import GalleyGrid from '@/components/GalleyGrid';
import { ResultFile, ResultImage } from './ResultFileItem';
const ResultFileGallery = memo<{ files: CodeInterpreterFileItem[] }>(({ files }) => {
if (!files || files.length === 0) {
return null;
}
// 分离图片和其他文件
const imageFiles = [];
const otherFiles = [];
for (const file of files) {
if (/\.(png|jpg|jpeg|gif|bmp|webp|svg)$/i.test(file.filename)) {
imageFiles.push(file);
} else {
otherFiles.push(file);
}
}
return (
<Flexbox gap={16}>
{/* 图片预览组 */}
{imageFiles.length > 0 && (
<PreviewGroup>
{imageFiles.length === 1 ? (
// 单张图片时占据更大空间
<Flexbox style={{ maxWidth: 400 }}>
<ResultImage {...imageFiles[0]} />
</Flexbox>
) : (
<GalleyGrid
items={imageFiles.map((file) => ({ ...file }))}
renderItem={(props) => <ResultImage {...props} />}
/>
)}
</PreviewGroup>
)}
{/* 其他文件列表 */}
{otherFiles.length > 0 && (
<Flexbox gap={8} horizontal wrap="wrap">
{otherFiles.map((file, index) => (
<ResultFile key={`${file.filename}-${index}`} {...file} />
))}
</Flexbox>
)}
</Flexbox>
);
});
export default ResultFileGallery;

View file

@ -0,0 +1,106 @@
import { CodeInterpreterFileItem } from '@lobechat/types';
import { Icon, Image, MaterialFileTypeIcon, Text, Tooltip } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Download } from 'lucide-react';
import React, { memo } from 'react';
import { fileService } from '@/services/file';
import { useChatStore } from '@/store/chat';
const useImageStyles = createStyles(({ css, token }) => ({
container: css`
overflow: hidden;
border: 1px solid ${token.colorBorder};
border-radius: 8px;
background: ${token.colorBgContainer};
transition: all 0.2s ease;
&:hover {
border-color: ${token.colorPrimary};
box-shadow: 0 2px 8px ${token.colorFillQuaternary};
}
`,
}));
const useFileStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
display: inline-flex;
gap: ${token.marginXS}px;
align-items: center;
padding-block: ${token.paddingXS}px;
padding-inline: ${token.paddingSM}px;
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusSM}px;
font-size: ${token.fontSizeSM}px;
color: ${token.colorText};
background: ${token.colorBgContainer};
transition: all 0.2s ease;
&:hover {
border-color: ${token.colorPrimary};
background: ${token.colorBgTextHover};
}
`,
}));
function basename(filename: string) {
return filename.split('/').pop() ?? filename;
}
// 图片显示子组件
const ResultImage = memo<CodeInterpreterFileItem>(({ filename, previewUrl, fileId }) => {
const [useFetchPythonFileItem] = useChatStore((s) => [s.useFetchInterpreterFileItem]);
const { data } = useFetchPythonFileItem(fileId);
const { styles } = useImageStyles();
const imageUrl = data?.url ?? previewUrl;
const baseName = basename(data?.filename ?? filename);
if (imageUrl) {
return (
<div className={styles.container}>
<Tooltip title={baseName}>
<Image alt={baseName} onLoad={() => URL.revokeObjectURL(imageUrl)} src={imageUrl} />
</Tooltip>
</div>
);
}
return null;
});
// 文件显示子组件
const ResultFile = memo<CodeInterpreterFileItem>(({ filename, fileId, previewUrl }) => {
const { styles } = useFileStyles();
const baseName = basename(filename);
const onDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
let downloadUrl = previewUrl;
if (!downloadUrl) {
const { url } = await fileService.getFile(fileId!);
downloadUrl = url;
}
const link = document.createElement('a');
link.href = downloadUrl;
link.download = baseName;
link.click();
};
return (
<div className={styles.container} onClick={onDownload}>
<MaterialFileTypeIcon filename={baseName} size={20} type="file" />
<Text>{baseName}</Text>
<Icon icon={Download} size={'small'} />
</div>
);
});
export { ResultFile, ResultImage };

View file

@ -0,0 +1,119 @@
import {
BuiltinRenderProps,
CodeInterpreterParams,
CodeInterpreterResponse,
CodeInterpreterState,
} from '@lobechat/types';
import { Alert, Highlighter, Text } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import BubblesLoading from '@/components/BubblesLoading';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/slices/builtinTool/selectors';
import ResultFileGallery from './components/ResultFileGallery';
const CodeInterpreter = memo<
BuiltinRenderProps<CodeInterpreterResponse, CodeInterpreterParams, CodeInterpreterState>
>(({ content, args, pluginState, messageId, apiName }) => {
const { t } = useTranslation('tool');
const theme = useTheme();
const isExecuting = useChatStore(chatToolSelectors.isInterpreterExecuting(messageId));
if (pluginState?.error) {
console.error(pluginState.error);
}
return (
<Flexbox gap={12}>
{/* 代码显示 */}
<Flexbox>
<Highlighter
actionIconSize="small"
language={apiName!}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'scroll', width: '100%' }}
>
{args.code}
</Highlighter>
</Flexbox>
{/* 执行状态 */}
{isExecuting && (
<Flexbox gap={8} horizontal>
<BubblesLoading />
<Text type="secondary">{t('codeInterpreter.executing')}</Text>
</Flexbox>
)}
{/* 执行错误 */}
{!isExecuting && pluginState?.error && (
<Alert
description={String(pluginState.error)}
message={t('codeInterpreter.error')}
showIcon
type="error"
/>
)}
{!isExecuting && content && (
<Flexbox gap={8}>
{/* 返回值 */}
{content.result && (
<Flexbox>
<Text strong style={{ marginBottom: 4 }}>
{t('codeInterpreter.returnValue')}
</Text>
<Highlighter copyable={false} language="text" showLanguage={false}>
{content.result}
</Highlighter>
</Flexbox>
)}
{/* 输出 */}
{content?.output && content.output.length > 0 && (
<Flexbox>
<Text strong style={{ marginBottom: 4 }}>
{t('codeInterpreter.output')}
</Text>
<div
style={{
backgroundColor: theme.colorBgContainer,
border: `1px solid ${theme.colorBorder}`,
borderRadius: theme.borderRadius,
fontSize: 13,
lineHeight: 1.5,
overflow: 'auto',
padding: 12,
whiteSpace: 'pre',
}}
>
{content.output?.map((item, index) => (
<Text code key={index} type={item.type === 'stderr' ? 'danger' : undefined}>
{item.data}
</Text>
))}
</div>
</Flexbox>
)}
{/* 文件显示 */}
{content?.files && content.files.length > 0 && (
<Flexbox>
<Text strong style={{ marginBottom: 8 }}>
{t('codeInterpreter.files')}
</Text>
<ResultFileGallery files={content.files} />
</Flexbox>
)}
</Flexbox>
)}
</Flexbox>
);
});
export default CodeInterpreter;

View file

@ -0,0 +1,67 @@
import { BuiltinToolManifest } from '@lobechat/types';
export const CodeInterpreterIdentifier = 'lobe-code-interpreter';
export const CodeInterpreterManifest: BuiltinToolManifest = {
api: [
{
description: 'A Python interpreter. Use this tool to run Python code. ',
name: 'python',
parameters: {
properties: {
code: {
description: 'The Python code to run.',
type: 'string',
},
packages: {
description: 'The packages to install before running the code.',
items: {
type: 'string',
},
type: 'array',
},
},
required: ['packages', 'code'],
type: 'object',
},
},
],
identifier: CodeInterpreterIdentifier,
meta: {
avatar:
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPgo8c3ZnIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMy4wMTY0IDJDMTAuODE5MyAyIDkuMDM4MjUgMy43MjQ1MyA5LjAzODI1IDUuODUxODVWOC41MTg1MkgxNS45MjM1VjkuMjU5MjZINS45NzgxNEMzLjc4MTA3IDkuMjU5MjYgMiAxMC45ODM4IDIgMTMuMTExMUwyIDE4Ljg4ODlDMiAyMS4wMTYyIDMuNzgxMDcgMjIuNzQwNyA1Ljk3ODE0IDIyLjc0MDdIOC4yNzMyMlYxOS40ODE1QzguMjczMjIgMTcuMzU0MiAxMC4wNTQzIDE1LjYyOTYgMTIuMjUxNCAxNS42Mjk2SDE5LjU5NTZDMjEuNDU0NyAxNS42Mjk2IDIyLjk2MTcgMTQuMTcwNCAyMi45NjE3IDEyLjM3MDRWNS44NTE4NUMyMi45NjE3IDMuNzI0NTMgMjEuMTgwNyAyIDE4Ljk4MzYgMkgxMy4wMTY0Wk0xMi4wOTg0IDYuNzQwNzRDMTIuODU4OSA2Ljc0MDc0IDEzLjQ3NTQgNi4xNDM3OCAxMy40NzU0IDUuNDA3NDFDMTMuNDc1NCA0LjY3MTAzIDEyLjg1ODkgNC4wNzQwNyAxMi4wOTg0IDQuMDc0MDdDMTEuMzM3OCA0LjA3NDA3IDEwLjcyMTMgNC42NzEwMyAxMC43MjEzIDUuNDA3NDFDMTAuNzIxMyA2LjE0Mzc4IDExLjMzNzggNi43NDA3NCAxMi4wOTg0IDYuNzQwNzRaIiBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfODdfODIwNCkiLz4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTguOTgzNCAzMEMyMS4xODA1IDMwIDIyLjk2MTYgMjguMjc1NSAyMi45NjE2IDI2LjE0ODJWMjMuNDgxNUwxNi4wNzYzIDIzLjQ4MTVMMTYuMDc2MyAyMi43NDA4TDI2LjAyMTcgMjIuNzQwOEMyOC4yMTg4IDIyLjc0MDggMjkuOTk5OCAyMS4wMTYyIDI5Ljk5OTggMTguODg4OVYxMy4xMTExQzI5Ljk5OTggMTAuOTgzOCAyOC4yMTg4IDkuMjU5MjggMjYuMDIxNyA5LjI1OTI4TDIzLjcyNjYgOS4yNTkyOFYxMi41MTg1QzIzLjcyNjYgMTQuNjQ1OSAyMS45NDU1IDE2LjM3MDQgMTkuNzQ4NSAxNi4zNzA0TDEyLjQwNDIgMTYuMzcwNEMxMC41NDUxIDE2LjM3MDQgOS4wMzgwOSAxNy44Mjk2IDkuMDM4MDkgMTkuNjI5Nkw5LjAzODA5IDI2LjE0ODJDOS4wMzgwOSAyOC4yNzU1IDEwLjgxOTIgMzAgMTMuMDE2MiAzMEgxOC45ODM0Wk0xOS45MDE1IDI1LjI1OTNDMTkuMTQwOSAyNS4yNTkzIDE4LjUyNDQgMjUuODU2MiAxOC41MjQ0IDI2LjU5MjZDMTguNTI0NCAyNy4zMjkgMTkuMTQwOSAyNy45MjU5IDE5LjkwMTUgMjcuOTI1OUMyMC42NjIgMjcuOTI1OSAyMS4yNzg1IDI3LjMyOSAyMS4yNzg1IDI2LjU5MjZDMjEuMjc4NSAyNS44NTYyIDIwLjY2MiAyNS4yNTkzIDE5LjkwMTUgMjUuMjU5M1oiIGZpbGw9InVybCgjcGFpbnQxX2xpbmVhcl84N184MjA0KSIvPg0KPGRlZnM+DQo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MF9saW5lYXJfODdfODIwNCIgeDE9IjEyLjQ4MDkiIHkxPSIyIiB4Mj0iMTIuNDgwOSIgeTI9IjIyLjc0MDciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4NCjxzdG9wIHN0b3AtY29sb3I9IiMzMjdFQkQiLz4NCjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NjVBNyIvPg0KPC9saW5lYXJHcmFkaWVudD4NCjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQxX2xpbmVhcl84N184MjA0IiB4MT0iMTkuNTE5IiB5MT0iOS4yNTkyOCIgeDI9IjE5LjUxOSIgeTI9IjMwIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+DQo8c3RvcCBzdG9wLWNvbG9yPSIjRkZEQTRCIi8+DQo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGOUM2MDAiLz4NCjwvbGluZWFyR3JhZGllbnQ+DQo8L2RlZnM+DQo8L3N2Zz4=',
title: 'Code Interpreter',
},
systemRole: `When you send a message containing Python code to python, it will be executed in a temporary pyodide python environment in browser.
python will respond with the output of the execution or time out after 60.0 seconds.
The drive at '/mnt/data' can be used to save and persist user files.
If you are using matplotlib:
- never use seaborn
- give each chart its own distinct plot (no subplots)
- never set any specific colors unless explicitly asked to by the user
If you are accessing the internet, You MUST use pyfetch from pyodide.http package. Any other methods of accessing the internet will fail.
pyfetch is a wrapper of js fetch API, it is a async function that returns a pyodide.http.FetchResponse object.
Here are some useful methods of FetchResponse:
- async bytes(): returns a bytes object
- async text(): returns a string
- async json(): returns a json object
If you are generating files:
- You MUST use the instructed library for each supported file format. (Do not assume any other libraries are available):
- pdf --> reportlab
- docx --> python-docx
- xlsx --> openpyxl
- pptx --> python-pptx
- csv --> pandas
- ods --> odfpy
- odt --> odfpy
- odp --> odfpy
- None of the above packages are installed by default. You MUST include them in the packages parameter to install them EVERY TIME.
- If you are generating a pdf
- You MUST prioritize generating text content using reportlab.platypus rather than canvas
- If you are generating text in Chinese, you MUST use STSong. To use the font, you must call pdfmetrics.registerFont(TTFont('STSong', 'STSong.ttf')) and apply the style to all text elements
`,
type: 'builtin',
};

View file

@ -2,6 +2,7 @@ import { isDesktop } from '@/const/version';
import { LobeBuiltinTool } from '@/types/tool';
import { ArtifactsManifest } from './artifacts';
import { CodeInterpreterManifest } from './code-interpreter';
import { DalleManifest } from './dalle';
import { LocalSystemManifest } from './local-system';
import { WebBrowsingManifest } from './web-browsing';
@ -29,4 +30,9 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: WebBrowsingManifest,
type: 'builtin',
},
{
identifier: CodeInterpreterManifest.identifier,
manifest: CodeInterpreterManifest,
type: 'builtin',
},
];

View file

@ -1,5 +1,7 @@
import { BuiltinRender } from '@/types/tool';
import { CodeInterpreterManifest } from './code-interpreter';
import CodeInterpreterRender from './code-interpreter/Render';
import { DalleManifest } from './dalle';
import DalleRender from './dalle/Render';
import { LocalSystemManifest } from './local-system';
@ -11,4 +13,5 @@ export const BuiltinToolsRenders: Record<string, BuiltinRender> = {
[DalleManifest.identifier]: DalleRender as BuiltinRender,
[WebBrowsingManifest.identifier]: WebBrowsing as BuiltinRender,
[LocalSystemManifest.identifier]: LocalFilesRender as BuiltinRender,
[CodeInterpreterManifest.identifier]: CodeInterpreterRender as BuiltinRender,
};