diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9f020025c..937a762036 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - web-crawler - electron-server-ipc - utils + - python-interpreter - context-engine - agent-runtime diff --git a/locales/zh-CN/tool.json b/locales/zh-CN/tool.json index d1e44cb027..d2d7cec8df 100644 --- a/locales/zh-CN/tool.json +++ b/locales/zh-CN/tool.json @@ -1,4 +1,11 @@ { + "codeInterpreter": { + "error": "执行错误", + "executing": "执行中...", + "files": "文件:", + "output": "输出:", + "returnValue": "返回值:" + }, "dalle": { "autoGenerate": "自动生成", "downloading": "DallE3 生成的图片链接有效期仅1小时,正在缓存图片到本地...", diff --git a/package.json b/package.json index 9649965f6e..97da0361d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/python-interpreter/package.json b/packages/python-interpreter/package.json new file mode 100644 index 0000000000..d02cd374f7 --- /dev/null +++ b/packages/python-interpreter/package.json @@ -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" + } +} diff --git a/packages/python-interpreter/src/__tests__/interpreter.test.ts b/packages/python-interpreter/src/__tests__/interpreter.test.ts new file mode 100644 index 0000000000..c16d24558c --- /dev/null +++ b/packages/python-interpreter/src/__tests__/interpreter.test.ts @@ -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(); + }); +}); diff --git a/packages/python-interpreter/src/__tests__/worker.test.ts b/packages/python-interpreter/src/__tests__/worker.test.ts new file mode 100644 index 0000000000..2d2d18c07f --- /dev/null +++ b/packages/python-interpreter/src/__tests__/worker.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/python-interpreter/src/index.ts b/packages/python-interpreter/src/index.ts new file mode 100644 index 0000000000..c8afca58cb --- /dev/null +++ b/packages/python-interpreter/src/index.ts @@ -0,0 +1,2 @@ +export { PythonInterpreter } from './interpreter'; +export * from './types'; diff --git a/packages/python-interpreter/src/interpreter.ts b/packages/python-interpreter/src/interpreter.ts new file mode 100644 index 0000000000..2c593c6d25 --- /dev/null +++ b/packages/python-interpreter/src/interpreter.ts @@ -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(worker); + } + return undefined; +})(); diff --git a/packages/python-interpreter/src/types.ts b/packages/python-interpreter/src/types.ts new file mode 100644 index 0000000000..754e491531 --- /dev/null +++ b/packages/python-interpreter/src/types.ts @@ -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; +} diff --git a/packages/python-interpreter/src/worker.ts b/packages/python-interpreter/src/worker.ts new file mode 100644 index 0000000000..814846fcb4 --- /dev/null +++ b/packages/python-interpreter/src/worker.ts @@ -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 { + 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; diff --git a/packages/python-interpreter/vitest.config.mts b/packages/python-interpreter/vitest.config.mts new file mode 100644 index 0000000000..78007b7aeb --- /dev/null +++ b/packages/python-interpreter/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + reporter: ['text', 'json', 'lcov', 'text-summary'], + }, + environment: 'node', + }, +}); diff --git a/packages/types/src/tool/index.ts b/packages/types/src/tool/index.ts index 5786be98a6..60375151d6 100644 --- a/packages/types/src/tool/index.ts +++ b/packages/types/src/tool/index.ts @@ -24,4 +24,5 @@ export interface LobeTool { export type LobeToolRenderType = LobePluginType | 'builtin'; export * from './builtin'; +export * from './interpreter'; export * from './plugin'; diff --git a/packages/types/src/tool/interpreter.ts b/packages/types/src/tool/interpreter.ts new file mode 100644 index 0000000000..3969a66ebe --- /dev/null +++ b/packages/types/src/tool/interpreter.ts @@ -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; +} diff --git a/src/envs/python.ts b/src/envs/python.ts new file mode 100644 index 0000000000..cd175062f2 --- /dev/null +++ b/src/envs/python.ts @@ -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(); diff --git a/src/locales/default/tool.ts b/src/locales/default/tool.ts index 5a728f5da9..294166107a 100644 --- a/src/locales/default/tool.ts +++ b/src/locales/default/tool.ts @@ -1,4 +1,11 @@ export default { + codeInterpreter: { + error: '执行错误', + executing: '执行中...', + files: '文件:', + output: '输出:', + returnValue: '返回值:', + }, dalle: { autoGenerate: '自动生成', downloading: 'DallE3 生成的图片链接有效期仅1小时,正在缓存图片到本地...', diff --git a/src/services/python.ts b/src/services/python.ts new file mode 100644 index 0000000000..fe3fc9c114 --- /dev/null +++ b/src/services/python.ts @@ -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 { + 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(); diff --git a/src/store/chat/slices/builtinTool/actions/index.ts b/src/store/chat/slices/builtinTool/actions/index.ts index 840c1a351f..518e2ae423 100644 --- a/src/store/chat/slices/builtinTool/actions/index.ts +++ b/src/store/chat/slices/builtinTool/actions/index.ts @@ -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), }); diff --git a/src/store/chat/slices/builtinTool/actions/interpreter.ts b/src/store/chat/slices/builtinTool/actions/interpreter.ts new file mode 100644 index 0000000000..3aaa98a7b4 --- /dev/null +++ b/src/store/chat/slices/builtinTool/actions/interpreter.ts @@ -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; + toggleInterpreterExecuting: (id: string, loading: boolean) => void; + updateInterpreterFileItem: ( + id: string, + updater: (data: CodeInterpreterResponse) => void, + ) => Promise; + uploadInterpreterFiles: (id: string, files: CodeInterpreterFileItem[]) => Promise; + 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; + }), +}); diff --git a/src/store/chat/slices/builtinTool/initialState.ts b/src/store/chat/slices/builtinTool/initialState.ts index 461ff1ee14..1bc33a59ea 100644 --- a/src/store/chat/slices/builtinTool/initialState.ts +++ b/src/store/chat/slices/builtinTool/initialState.ts @@ -2,6 +2,8 @@ import { FileItem } from '@/types/files'; export interface ChatToolState { activePageContentUrl?: string; + codeInterpreterExecuting: Record; + codeInterpreterImageMap: Record; dalleImageLoading: Record; dalleImageMap: Record; localFileLoading: Record; @@ -9,6 +11,8 @@ export interface ChatToolState { } export const initialToolState: ChatToolState = { + codeInterpreterExecuting: {}, + codeInterpreterImageMap: {}, dalleImageLoading: {}, dalleImageMap: {}, localFileLoading: {}, diff --git a/src/store/chat/slices/builtinTool/selectors.ts b/src/store/chat/slices/builtinTool/selectors.ts index 512e772c73..40dc5c3b6e 100644 --- a/src/store/chat/slices/builtinTool/selectors.ts +++ b/src/store/chat/slices/builtinTool/selectors.ts @@ -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, }; diff --git a/src/tools/code-interpreter/Render/components/ResultFileGallery.tsx b/src/tools/code-interpreter/Render/components/ResultFileGallery.tsx new file mode 100644 index 0000000000..bd3eb77acf --- /dev/null +++ b/src/tools/code-interpreter/Render/components/ResultFileGallery.tsx @@ -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 ( + + {/* 图片预览组 */} + {imageFiles.length > 0 && ( + + {imageFiles.length === 1 ? ( + // 单张图片时占据更大空间 + + + + ) : ( + ({ ...file }))} + renderItem={(props) => } + /> + )} + + )} + + {/* 其他文件列表 */} + {otherFiles.length > 0 && ( + + {otherFiles.map((file, index) => ( + + ))} + + )} + + ); +}); + +export default ResultFileGallery; diff --git a/src/tools/code-interpreter/Render/components/ResultFileItem.tsx b/src/tools/code-interpreter/Render/components/ResultFileItem.tsx new file mode 100644 index 0000000000..3c61bb24e6 --- /dev/null +++ b/src/tools/code-interpreter/Render/components/ResultFileItem.tsx @@ -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(({ 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 ( +
+ + {baseName} URL.revokeObjectURL(imageUrl)} src={imageUrl} /> + +
+ ); + } + + return null; +}); + +// 文件显示子组件 +const ResultFile = memo(({ 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 ( +
+ + {baseName} + +
+ ); +}); + +export { ResultFile, ResultImage }; diff --git a/src/tools/code-interpreter/Render/index.tsx b/src/tools/code-interpreter/Render/index.tsx new file mode 100644 index 0000000000..09c744c851 --- /dev/null +++ b/src/tools/code-interpreter/Render/index.tsx @@ -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 +>(({ 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 ( + + {/* 代码显示 */} + + + {args.code} + + + + {/* 执行状态 */} + {isExecuting && ( + + + {t('codeInterpreter.executing')} + + )} + + {/* 执行错误 */} + {!isExecuting && pluginState?.error && ( + + )} + + {!isExecuting && content && ( + + {/* 返回值 */} + {content.result && ( + + + {t('codeInterpreter.returnValue')} + + + {content.result} + + + )} + + {/* 输出 */} + {content?.output && content.output.length > 0 && ( + + + {t('codeInterpreter.output')} + +
+ {content.output?.map((item, index) => ( + + {item.data} + + ))} +
+
+ )} + + {/* 文件显示 */} + {content?.files && content.files.length > 0 && ( + + + {t('codeInterpreter.files')} + + + + )} +
+ )} +
+ ); +}); + +export default CodeInterpreter; diff --git a/src/tools/code-interpreter/index.ts b/src/tools/code-interpreter/index.ts new file mode 100644 index 0000000000..c99480949b --- /dev/null +++ b/src/tools/code-interpreter/index.ts @@ -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', +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 39bc58cd5a..926ea0106d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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', + }, ]; diff --git a/src/tools/renders.ts b/src/tools/renders.ts index a999f0f5de..c36cd7f6a4 100644 --- a/src/tools/renders.ts +++ b/src/tools/renders.ts @@ -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 = { [DalleManifest.identifier]: DalleRender as BuiltinRender, [WebBrowsingManifest.identifier]: WebBrowsing as BuiltinRender, [LocalSystemManifest.identifier]: LocalFilesRender as BuiltinRender, + [CodeInterpreterManifest.identifier]: CodeInterpreterRender as BuiltinRender, };