mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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:
parent
a30a65cd4c
commit
fa6ef94067
26 changed files with 1183 additions and 1 deletions
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
|
|
@ -18,6 +18,7 @@ jobs:
|
|||
- web-crawler
|
||||
- electron-server-ipc
|
||||
- utils
|
||||
- python-interpreter
|
||||
- context-engine
|
||||
- agent-runtime
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
{
|
||||
"codeInterpreter": {
|
||||
"error": "执行错误",
|
||||
"executing": "执行中...",
|
||||
"files": "文件:",
|
||||
"output": "输出:",
|
||||
"returnValue": "返回值:"
|
||||
},
|
||||
"dalle": {
|
||||
"autoGenerate": "自动生成",
|
||||
"downloading": "DallE3 生成的图片链接有效期仅1小时,正在缓存图片到本地...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
packages/python-interpreter/package.json
Normal file
15
packages/python-interpreter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
252
packages/python-interpreter/src/__tests__/worker.test.ts
Normal file
252
packages/python-interpreter/src/__tests__/worker.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
2
packages/python-interpreter/src/index.ts
Normal file
2
packages/python-interpreter/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { PythonInterpreter } from './interpreter';
|
||||
export * from './types';
|
||||
13
packages/python-interpreter/src/interpreter.ts
Normal file
13
packages/python-interpreter/src/interpreter.ts
Normal 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;
|
||||
})();
|
||||
23
packages/python-interpreter/src/types.ts
Normal file
23
packages/python-interpreter/src/types.ts
Normal 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;
|
||||
}
|
||||
211
packages/python-interpreter/src/worker.ts
Normal file
211
packages/python-interpreter/src/worker.ts
Normal 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;
|
||||
10
packages/python-interpreter/vitest.config.mts
Normal file
10
packages/python-interpreter/vitest.config.mts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
|
|
@ -24,4 +24,5 @@ export interface LobeTool {
|
|||
export type LobeToolRenderType = LobePluginType | 'builtin';
|
||||
|
||||
export * from './builtin';
|
||||
export * from './interpreter';
|
||||
export * from './plugin';
|
||||
|
|
|
|||
21
packages/types/src/tool/interpreter.ts
Normal file
21
packages/types/src/tool/interpreter.ts
Normal 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
17
src/envs/python.ts
Normal 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();
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
export default {
|
||||
codeInterpreter: {
|
||||
error: '执行错误',
|
||||
executing: '执行中...',
|
||||
files: '文件:',
|
||||
output: '输出:',
|
||||
returnValue: '返回值:',
|
||||
},
|
||||
dalle: {
|
||||
autoGenerate: '自动生成',
|
||||
downloading: 'DallE3 生成的图片链接有效期仅1小时,正在缓存图片到本地...',
|
||||
|
|
|
|||
33
src/services/python.ts
Normal file
33
src/services/python.ts
Normal 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();
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
169
src/store/chat/slices/builtinTool/actions/interpreter.ts
Normal file
169
src/store/chat/slices/builtinTool/actions/interpreter.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
106
src/tools/code-interpreter/Render/components/ResultFileItem.tsx
Normal file
106
src/tools/code-interpreter/Render/components/ResultFileItem.tsx
Normal 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 };
|
||||
119
src/tools/code-interpreter/Render/index.tsx
Normal file
119
src/tools/code-interpreter/Render/index.tsx
Normal 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;
|
||||
67
src/tools/code-interpreter/index.ts
Normal file
67
src/tools/code-interpreter/index.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue