From 3df89088516f69d1083188c343620667cf2b756f Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:02:59 +0800 Subject: [PATCH] feat(sandbox): support multimedia preview and source/preview toggle in editor (#6723) * style: re-component Editor * style: re-component Editor * feat: sandbox file preview support with binary detection and mime type handling * feat: preview support for markdown, svg, and html files in sandbox editor * feat(sandbox): support multimedia preview and source/preview toggle in editor * fix: XSS SVG rendering with MyPhotoView * refactor: blob URL lifecycle management, improve filename encoding in downloads * feat: implement S3-based HTML preview for sandbox editor and add PDF support to binary file detection * refactor: improve sandbox editor stability by adding file size validation * feat: introduce fileService to encapsulate sandbox file operations and add unit tests * refactor: secure HTML sandbox preview by fetching content from server and injecting CSP meta tags * refactor: replace unified file operation API with dedicated endpoints for list, read, write, and download operations * chore: remove packageManager field from package.json * fix: sandbox file read error message * refactor: improve sandbox editor UI styling, type safety, and CSP security policy * feat: HTML preview link API and standardize sandbox request/response types * fix: improve log view layout responsiveness by adding overflow handling and flex constraints * perf: fix review --------- Co-authored-by: archer <545436317@qq.com> --- package.json | 2 +- .../global/openapi/core/ai/sandbox/api.ts | 121 ++-- .../global/openapi/core/ai/sandbox/index.ts | 112 +++- packages/service/package.json | 2 +- .../web/components/common/Icon/constants.ts | 1 + .../common/Icon/icons/common/htmlPreview.svg | 3 + packages/web/i18n/en/chat.json | 5 +- packages/web/i18n/zh-CN/chat.json | 5 +- packages/web/i18n/zh-Hant/chat.json | 5 +- pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 1 + projects/app/package.json | 1 + .../pageComponents/app/detail/Logs/index.tsx | 8 +- .../chat/SandboxEditor/Editor.tsx | 625 ++++-------------- .../pageComponents/chat/SandboxEditor/api.ts | 96 ++- .../components/EditorContent.tsx | 297 +++++++++ .../SandboxEditor/components/FileTabs.tsx | 99 +++ .../SandboxEditor/components/FileTree.tsx | 186 ++++++ .../chat/SandboxEditor/modal.tsx | 1 - .../chat/SandboxEditor/utils.tsx | 170 +++-- .../src/pages/api/core/ai/sandbox/download.ts | 98 +-- .../app/src/pages/api/core/ai/sandbox/file.ts | 101 --- .../api/core/ai/sandbox/getHtmlPreviewLink.ts | 71 ++ .../app/src/pages/api/core/ai/sandbox/list.ts | 34 + .../app/src/pages/api/core/ai/sandbox/read.ts | 30 + .../src/pages/api/core/ai/sandbox/write.ts | 34 + .../src/service/core/sandbox/fileService.ts | 105 +++ .../service/core/sandbox/fileService.test.ts | 393 +++++++++++ 28 files changed, 1768 insertions(+), 847 deletions(-) create mode 100644 packages/web/components/common/Icon/icons/common/htmlPreview.svg create mode 100644 projects/app/src/pageComponents/chat/SandboxEditor/components/EditorContent.tsx create mode 100644 projects/app/src/pageComponents/chat/SandboxEditor/components/FileTabs.tsx create mode 100644 projects/app/src/pageComponents/chat/SandboxEditor/components/FileTree.tsx delete mode 100644 projects/app/src/pages/api/core/ai/sandbox/file.ts create mode 100644 projects/app/src/pages/api/core/ai/sandbox/getHtmlPreviewLink.ts create mode 100644 projects/app/src/pages/api/core/ai/sandbox/list.ts create mode 100644 projects/app/src/pages/api/core/ai/sandbox/read.ts create mode 100644 projects/app/src/pages/api/core/ai/sandbox/write.ts create mode 100644 projects/app/src/service/core/sandbox/fileService.ts create mode 100644 projects/app/test/service/core/sandbox/fileService.test.ts diff --git a/package.json b/package.json index 90ea06f38c..8e38063c81 100644 --- a/package.json +++ b/package.json @@ -55,4 +55,4 @@ "node": ">=20", "pnpm": "9.x" } -} +} \ No newline at end of file diff --git a/packages/global/openapi/core/ai/sandbox/api.ts b/packages/global/openapi/core/ai/sandbox/api.ts index 418686b787..cb29592833 100644 --- a/packages/global/openapi/core/ai/sandbox/api.ts +++ b/packages/global/openapi/core/ai/sandbox/api.ts @@ -1,80 +1,89 @@ import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { z } from 'zod'; -/** - * 文件操作 - 统一请求体 - */ -export const SandboxFileOperationBodySchema = z.union([ - z.object({ - action: z.literal('list'), - appId: z.string(), - chatId: z.string(), - path: z.string().default('.').describe('目录路径'), - outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') - }), - z.object({ - action: z.literal('read'), - appId: z.string(), - chatId: z.string(), - path: z.string().describe('文件路径'), - outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') - }), - z.object({ - action: z.literal('write'), - appId: z.string(), - chatId: z.string(), - path: z.string().describe('文件路径'), - content: z.string().describe('文件内容'), - outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') - }) -]); - -export type SandboxFileOperationBody = z.infer; +const SandboxBaseSchema = z.object({ + appId: z.string(), + chatId: z.string(), + outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') +}); /** - * 文件项 + * 列出目录 - 请求/响应 */ +export const SandboxListBodySchema = SandboxBaseSchema.extend({ + path: z.string().default('.').describe('目录路径') +}); +export type SandboxListBody = z.infer; + export const SandboxFileItemSchema = z.object({ name: z.string().describe('文件名'), path: z.string().describe('完整路径'), type: z.enum(['file', 'directory']).describe('文件类型'), size: z.number().optional().describe('文件大小(字节数)') }); - export type SandboxFileItem = z.infer; -/** - * 文件操作 - 响应体 - */ -export const SandboxFileOperationResponseSchema = z.union([ - z.object({ - action: z.literal('list'), - files: z.array(SandboxFileItemSchema) - }), - z.object({ - action: z.literal('read'), - content: z.string().describe('文件内容') - }), - z.object({ - action: z.literal('write'), - success: z.boolean() - }) -]); +export const SandboxListResponseSchema = z.object({ + files: z.array(SandboxFileItemSchema) +}); +export type SandboxListResponse = z.infer; -export type SandboxFileOperationResponse = z.infer; +/** + * 写入文件 - 请求/响应 + */ +export const SandboxWriteBodySchema = SandboxBaseSchema.extend({ + path: z.string().describe('文件路径'), + content: z.string().describe('文件内容') +}); +export type SandboxWriteBody = z.infer; + +export const SandboxWriteResponseSchema = z.object({ + success: z.boolean() +}); +export type SandboxWriteResponse = z.infer; + +/** + * 读取文件内容 - 请求体(响应为原始文件流) + */ +export const SandboxReadBodySchema = SandboxBaseSchema.extend({ + path: z.string().describe('文件路径') +}); +export type SandboxReadBody = z.infer; + +export const SandboxReadResponseSchema = z + .string() + .openapi({ format: 'binary', description: '文件内容流' }); + +/** + * 下载文件或目录 - 请求体(响应为文件流或 ZIP) + */ +export const SandboxDownloadBodySchema = SandboxBaseSchema.extend({ + path: z.string().optional().default('.').describe('要下载的路径(文件或目录)') +}); +export type SandboxDownloadBody = z.input; + +export const SandboxDownloadResponseSchema = z + .string() + .openapi({ format: 'binary', description: '文件流或 ZIP 包' }); /** * 检查沙盒是否存在 */ -export const SandboxCheckExistBodySchema = z.object({ - appId: z.string(), - chatId: z.string(), - outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') -}); - +export const SandboxCheckExistBodySchema = SandboxBaseSchema; export const SandboxCheckExistResponseSchema = z.object({ exists: z.boolean().describe('沙盒是否存在') }); - export type SandboxCheckExistBody = z.infer; export type SandboxCheckExistResponse = z.infer; + +/** + * 获取 HTML 预览链接 - 请求/响应 + */ +export const SandboxGetHtmlPreviewLinkBodySchema = SandboxBaseSchema.extend({ + filePath: z.string().describe('文件路径') +}); +export const SandboxGetHtmlPreviewLinkResponseSchema = z.string().describe('HTML 预览链接'); +export type SandboxGetHtmlPreviewLinkBody = z.infer; +export type SandboxGetHtmlPreviewLinkResponse = z.infer< + typeof SandboxGetHtmlPreviewLinkResponseSchema +>; diff --git a/packages/global/openapi/core/ai/sandbox/index.ts b/packages/global/openapi/core/ai/sandbox/index.ts index 55a68c880b..16be077c30 100644 --- a/packages/global/openapi/core/ai/sandbox/index.ts +++ b/packages/global/openapi/core/ai/sandbox/index.ts @@ -1,31 +1,88 @@ import type { OpenAPIPath } from '../../../type'; import { TagsMap } from '../../../tag'; import { - SandboxFileOperationBodySchema, - SandboxFileOperationResponseSchema, + SandboxListBodySchema, + SandboxListResponseSchema, + SandboxWriteBodySchema, + SandboxWriteResponseSchema, + SandboxReadBodySchema, + SandboxReadResponseSchema, + SandboxDownloadBodySchema, + SandboxDownloadResponseSchema, SandboxCheckExistBodySchema, - SandboxCheckExistResponseSchema + SandboxCheckExistResponseSchema, + SandboxGetHtmlPreviewLinkBodySchema, + SandboxGetHtmlPreviewLinkResponseSchema } from './api'; export const SandboxPath: OpenAPIPath = { - '/core/ai/sandbox/file': { + '/core/ai/sandbox/list': { post: { - summary: '沙盒文件操作', - description: '统一文件操作接口,支持列出目录(list)、读取文件(read)、写入文件(write)', + summary: '列出沙盒目录', + description: '列出指定目录下的文件和子目录', tags: [TagsMap.sandbox], requestBody: { content: { 'application/json': { - schema: SandboxFileOperationBodySchema + schema: SandboxListBodySchema } } }, responses: { 200: { - description: '操作成功', + description: '目录内容', content: { 'application/json': { - schema: SandboxFileOperationResponseSchema + schema: SandboxListResponseSchema + } + } + } + } + } + }, + + '/core/ai/sandbox/write': { + post: { + summary: '写入沙盒文件', + description: '将内容写入指定路径的文件', + tags: [TagsMap.sandbox], + requestBody: { + content: { + 'application/json': { + schema: SandboxWriteBodySchema + } + } + }, + responses: { + 200: { + description: '写入成功', + content: { + 'application/json': { + schema: SandboxWriteResponseSchema + } + } + } + } + } + }, + + '/core/ai/sandbox/read': { + post: { + summary: '读取沙盒文件内容', + description: '读取文件内容并以对应 MIME 类型内联返回,适用于预览场景', + tags: [TagsMap.sandbox], + requestBody: { + content: { + 'application/json': { + schema: SandboxReadBodySchema + } + } + }, + responses: { + 200: { + content: { + '*/*': { + schema: SandboxReadResponseSchema } } } @@ -36,26 +93,45 @@ export const SandboxPath: OpenAPIPath = { '/core/ai/sandbox/download': { post: { summary: '下载沙盒文件或目录', - description: '将指定路径的文件或目录打包为 zip 并下载', + description: '下载指定路径的文件,或将目录打包为 ZIP 下载', tags: [TagsMap.sandbox], requestBody: { content: { 'application/json': { - schema: SandboxCheckExistBodySchema.extend({ - path: SandboxFileOperationBodySchema.options[0].shape.path - }) + schema: SandboxDownloadBodySchema } } }, responses: { 200: { - description: '返回 zip 文件流', content: { 'application/octet-stream': { - schema: { - type: 'string', - format: 'binary' - } + schema: SandboxDownloadResponseSchema + } + } + } + } + } + }, + + '/core/ai/sandbox/getHtmlPreviewLink': { + post: { + summary: '获取 HTML 文件预览链接', + description: '返回用于在浏览器中预览 HTML 文件的链接(S3 托管)', + tags: [TagsMap.sandbox], + requestBody: { + content: { + 'application/json': { + schema: SandboxGetHtmlPreviewLinkBodySchema + } + } + }, + responses: { + 200: { + description: 'HTML 预览链接', + content: { + 'application/json': { + schema: SandboxGetHtmlPreviewLinkResponseSchema } } } diff --git a/packages/service/package.json b/packages/service/package.json index c8330eafdb..76e68e8fad 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -43,7 +43,7 @@ "jsonwebtoken": "^9.0.2", "lodash": "catalog:", "mammoth": "^1.11.0", - "mime": "^4.1.0", + "mime": "catalog:", "minio": "catalog:", "mongoose": "^8.10.1", "multer": "2.1.0", diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 7b0457cad7..bcbdd43812 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -40,6 +40,7 @@ export const iconPaths = { 'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'), 'common/download': () => import('./icons/common/download.svg'), 'common/downloadLine': () => import('./icons/common/downloadLine.svg'), + 'common/htmlPreview': () => import('./icons/common/htmlPreview.svg'), 'common/edit': () => import('./icons/common/edit.svg'), 'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'), 'common/ellipsis': () => import('./icons/common/ellipsis.svg'), diff --git a/packages/web/components/common/Icon/icons/common/htmlPreview.svg b/packages/web/components/common/Icon/icons/common/htmlPreview.svg new file mode 100644 index 0000000000..ef96358807 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/htmlPreview.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/i18n/en/chat.json b/packages/web/i18n/en/chat.json index d289f5e858..cf665ee546 100644 --- a/packages/web/i18n/en/chat.json +++ b/packages/web/i18n/en/chat.json @@ -105,7 +105,10 @@ "sandbox_entry_tooltip": "View Sandbox files", "sandbox_files": "Sandbox files", "sandbox_no_file": "There are no files in the sandbox yet", - "sandbox_not_utf_file_tip": "The file cannot be previewed, please download and view it directly.", + "sandbox_source": "Source", + "sandbox_preview": "Preview", + "sandbox_html_preview_failed": "Failed to generate preview link", + "sandbox_binary_file_no_preview": "The file cannot be previewed, please download and view it directly", "sandbox_search_files": "Search files", "sandbox_select_file_edit": "Select a file to edit", "sandox.files": "Sandbox files", diff --git a/packages/web/i18n/zh-CN/chat.json b/packages/web/i18n/zh-CN/chat.json index b0c79d42dd..97b55bc10e 100644 --- a/packages/web/i18n/zh-CN/chat.json +++ b/packages/web/i18n/zh-CN/chat.json @@ -105,10 +105,13 @@ "sandbox_entry_tooltip": "查看虚拟机文件", "sandbox_files": "虚拟机文件", "sandbox_no_file": "虚拟机里还没有文件", - "sandbox_not_utf_file_tip": "无法预览该文件,请直接下载查看", + "sandbox_binary_file_no_preview": "无法预览该文件,请直接下载查看", "sandbox_search_files": "搜索文件", "sandbox_select_file_edit": "选择一个文件进行编辑", "sandox.files": "虚拟机文件", + "sandbox_source": "原文", + "sandbox_preview": "预览", + "sandbox_html_preview_failed": "预览链接生成失败", "search_results": "搜索结果", "select": "选择", "select_file": "上传文件", diff --git a/packages/web/i18n/zh-Hant/chat.json b/packages/web/i18n/zh-Hant/chat.json index 20a3ecf9a4..35ffd2bba9 100644 --- a/packages/web/i18n/zh-Hant/chat.json +++ b/packages/web/i18n/zh-Hant/chat.json @@ -100,9 +100,12 @@ "response_rerank_tokens": "重排模型 Tokens", "response_search_results": "搜索結果({{len}})", "sandbox_entry_tooltip": "查看虛擬機器文件", + "sandbox_source": "原文", + "sandbox_preview": "預覽", + "sandbox_html_preview_failed": "預覽連結產生失敗", "sandbox_files": "虛擬機器文件", "sandbox_no_file": "虛擬機器裡還沒有文件", - "sandbox_not_utf_file_tip": "無法預覽該文件,請直接下載查看", + "sandbox_binary_file_no_preview": "無法預覽該文件,請直接下載查看", "sandbox_search_files": "搜尋文件", "sandbox_select_file_edit": "選擇一個文件進行編輯", "sandox.files": "虛擬機器文件", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b037bc5155..9017ca21d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ catalogs: lodash: specifier: 4.17.23 version: 4.17.23 + mime: + specifier: ^4.1.0 + version: 4.1.0 minio: specifier: 8.0.7 version: 8.0.7 @@ -352,7 +355,7 @@ importers: specifier: ^1.11.0 version: 1.11.0 mime: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 minio: specifier: 'catalog:' @@ -740,6 +743,9 @@ importers: mermaid: specifier: ^10.9.4 version: 10.9.4 + mime: + specifier: 'catalog:' + version: 4.1.0 minio: specifier: 'catalog:' version: 8.0.7 @@ -5345,6 +5351,7 @@ packages: '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@zag-js/dom-query@0.31.1': resolution: {integrity: sha512-oiuohEXAXhBxpzzNm9k2VHGEOLC1SXlXSbRPcfBZ9so5NRQUA++zCE7cyQJqGLTZR0t3itFLlZqDbYEXRrefwg==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f08ebd1df7..a71bc6265c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,6 +40,7 @@ catalog: js-yaml: ^4.1.1 json5: ^2.2.3 lodash: 4.17.23 + mime: ^4.1.0 minio: 8.0.7 next: 16.2.1 next-i18next: 15.4.2 diff --git a/projects/app/package.json b/projects/app/package.json index 184bb87881..e0b93b09a5 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -56,6 +56,7 @@ "lodash": "catalog:", "jszip": "^3.10.1", "mermaid": "^10.9.4", + "mime": "catalog:", "minio": "catalog:", "nanoid": "^5.1.3", "next": "catalog:", diff --git a/projects/app/src/pageComponents/app/detail/Logs/index.tsx b/projects/app/src/pageComponents/app/detail/Logs/index.tsx index 96d5ad6507..6c5e65a8a2 100644 --- a/projects/app/src/pageComponents/app/detail/Logs/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Logs/index.tsx @@ -47,8 +47,9 @@ const Logs = () => { borderColor={'myGray.200'} alignItems={'center'} > - + { borderRadius={'8px'} bg={viewMode === 'chart' ? 'myGray.05' : 'transparent'} _hover={{ bg: 'myGray.05' }} + alignItems={'center'} + whiteSpace={'nowrap'} > @@ -65,6 +68,7 @@ const Logs = () => { { borderRadius={'8px'} bg={viewMode === 'table' ? 'myGray.05' : 'transparent'} _hover={{ bg: 'myGray.05' }} + alignItems={'center'} + whiteSpace={'nowrap'} > {t('app:log_detail')} diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/Editor.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/Editor.tsx index 0a0977f4f7..e98de6d9f5 100644 --- a/projects/app/src/pageComponents/chat/SandboxEditor/Editor.tsx +++ b/projects/app/src/pageComponents/chat/SandboxEditor/Editor.tsx @@ -1,29 +1,20 @@ import React, { useEffect, useState, useRef } from 'react'; -import { - Box, - Center, - Text, - VStack, - IconButton, - Flex, - Spinner, - Input, - InputGroup, - InputLeftElement, - Button -} from '@chakra-ui/react'; +import { Center, VStack } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; -import MyIcon from '@fastgpt/web/components/common/Icon'; import MyBox from '@fastgpt/web/components/common/MyBox'; -import Editor from '@monaco-editor/react'; -import { listSandboxFiles, readSandboxFile, writeSandboxFile, downloadSandbox } from './api'; +import type Editor from '@monaco-editor/react'; +import { listSandboxFiles, writeSandboxFile, downloadSandbox, getSandboxFile } from './api'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; -import { useMount } from 'ahooks'; +import { useMount, useLatest } from 'ahooks'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; -import { getIconByFilename } from './utils'; import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; +import FileTree, { type TreeNode } from './components/FileTree'; +import FileTabs, { type OpenedFile } from './components/FileTabs'; +import EditorContent from './components/EditorContent'; +import { getLanguageByFileName, updateTreeNode, filterTree, getIsBinaryByLanguage } from './utils'; + type EditorInstance = Parameters[0]['onMount']>>[0]; export type Props = { @@ -32,27 +23,6 @@ export type Props = { outLinkAuthData?: OutLinkChatAuthProps; }; -type FileItem = { - name: string; - path: string; - type: 'file' | 'directory'; - size?: number; -}; - -type TreeNode = FileItem & { - children?: TreeNode[]; - level: number; - loaded?: boolean; // 标记目录是否已加载 -}; - -type OpenedFile = { - path: string; - name: string; - content: string; - language: string; - isDirty: boolean; -}; - const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { const { t } = useTranslation(); const editorRef = useRef(); @@ -71,6 +41,19 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { return openedFiles.find((f) => f.path === activeFilePath); }, [openedFiles, activeFilePath]); + const openedFilesRef = useLatest(openedFiles); + + // Clean up blob URLs when component unmounts + useEffect(() => { + return () => { + openedFilesRef.current?.forEach((file) => { + if (file.isBinary && file.content.startsWith('blob:')) { + URL.revokeObjectURL(file.content); + } + }); + }; + }, []); + // 加载目录 - 改为普通异步函数,避免 useRequest 的并发问题 const { runAsync: loadDirectory } = useRequest( async (path: string, level: number) => { @@ -92,19 +75,26 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { return nodes; }, - { - manual: true - } + { manual: true } ); // 初始加载根目录的 loading 状态 const [loadingRoot, setLoadingRoot] = useState(false); - // 读取文件 + // 读取文件内容 - 根据 language 来决定解码策略 const { runAsync: loadFile, loading: loadingFile } = useRequest( - async (filePath: string) => { - const data = await readSandboxFile({ appId, chatId, outLinkAuthData, path: filePath }); - return data.content; + async (filePath: string, language: string): Promise => { + const response = await getSandboxFile({ appId, chatId, outLinkAuthData, path: filePath }); + + const isBinary = getIsBinaryByLanguage(language); + + if (isBinary) { + const blob = await response.blob(); + return URL.createObjectURL(blob); + } else { + const content = await response.text(); + return content; + } }, { manual: true } ); @@ -116,7 +106,7 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { if (!targetPath) return; const targetFile = openedFiles.find((f) => f.path === targetPath); - if (!targetFile) return; + if (!targetFile || targetFile.isBinary) return; await writeSandboxFile({ appId, @@ -153,37 +143,6 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { { manual: true } ); - // 获取文件语言 - const getLanguageByExtension = (ext?: string): string => { - const langMap: Record = { - py: 'python', - js: 'javascript', - ts: 'typescript', - jsx: 'javascript', - tsx: 'typescript', - json: 'json', - md: 'markdown', - html: 'html', - css: 'css', - scss: 'scss', - less: 'less', - sh: 'shell', - bash: 'shell', - yml: 'yaml', - yaml: 'yaml', - xml: 'xml', - sql: 'sql', - go: 'go', - rs: 'rust', - java: 'java', - c: 'c', - cpp: 'cpp', - h: 'c', - hpp: 'cpp' - }; - return langMap[ext?.toLowerCase() || ''] || 'plaintext'; - }; - // 打开文件 const openFile = async (filePath: string) => { // 检查是否已打开 @@ -197,16 +156,18 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { // 新打开文件 try { - const content = await loadFile(filePath); const fileName = filePath.split('/').pop() || ''; - const ext = fileName.split('.').pop(); - const language = getLanguageByExtension(ext); + const language = getLanguageByFileName(fileName); + const isBinary = getIsBinaryByLanguage(language); + + const content = await loadFile(filePath, language); const newFile: OpenedFile = { path: filePath, name: fileName, content, language, + isBinary, isDirty: false }; @@ -221,24 +182,25 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { const closeFile = (filePath: string, e?: React.MouseEvent) => { e?.stopPropagation(); - const file = openedFiles.find((f) => f.path === filePath); - - // TODO: 如果有未保存的修改,提示用户 - // if (file?.isDirty) { - // // 显示确认对话框 - // } - - setOpenedFiles((prev) => prev.filter((f) => f.path !== filePath)); - // 如果关闭的是当前文件,切换到其他文件 - if (activeFilePath === filePath) { - const remainingFiles = openedFiles.filter((f) => f.path !== filePath); - if (remainingFiles.length > 0) { - setActiveFilePath(remainingFiles[remainingFiles.length - 1].path); - } else { - setActiveFilePath(''); + setOpenedFiles((prev) => { + const target = prev.find((f) => f.path === filePath); + if (target?.isBinary && target.content.startsWith('blob:')) { + URL.revokeObjectURL(target.content); } - } + + const newOpenedFiles = prev.filter((f) => f.path !== filePath); + + if (activeFilePath === filePath) { + if (newOpenedFiles.length > 0) { + setActiveFilePath(newOpenedFiles[newOpenedFiles.length - 1].path); + } else { + setActiveFilePath(''); + } + } + + return newOpenedFiles; + }); }; // 初始化加载根目录 @@ -251,9 +213,8 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { // 当切换 tab 时,更新编辑器内容 useEffect(() => { - if (!editorRef.current || !activeFilePath) return; - - if (!activeFile) return; + if (!editorRef.current || !activeFilePath || !activeFile) return; + if (activeFile.isBinary) return; // 使用 ref 标记防止循环更新 isUpdatingRef.current = true; @@ -265,24 +226,6 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { }, 0); }, [activeFilePath]); - // 更新树节点 - const updateTreeNode = ( - tree: TreeNode[], - targetPath: string, - children: TreeNode[], - loaded: boolean = false - ): TreeNode[] => { - return tree.map((node) => { - if (node.path === targetPath) { - return { ...node, children, loaded }; - } - if (node.children) { - return { ...node, children: updateTreeNode(node.children, targetPath, children, loaded) }; - } - return node; - }); - }; - // 切换目录展开/折叠 const toggleDirectory = async (node: TreeNode) => { if (node.type !== 'directory') return; @@ -302,7 +245,7 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { setLoadingDirs((prev) => { const next = new Set(prev); next.add(node.path); - console.log('Add loading:', node.path, next); + return next; }); @@ -324,7 +267,7 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { setLoadingDirs((prev) => { const next = new Set(prev); next.delete(node.path); - console.log('Remove loading:', node.path, next); + return next; }); }); @@ -339,98 +282,84 @@ const SandboxEditor = ({ appId, chatId, outLinkAuthData }: Props) => { } }; - // 过滤文件树 - const filterTree = (nodes: TreeNode[], query: string): TreeNode[] => { - if (!query) return nodes; + const filteredTree = filterTree(fileTree, searchQuery); - return nodes - .map((node) => { - if (node.type === 'file' && node.name.toLowerCase().includes(query.toLowerCase())) { - return node; - } - if (node.children) { - const filteredChildren = filterTree(node.children, query); - if (filteredChildren.length > 0) { - return { ...node, children: filteredChildren }; - } - } - return null; - }) - .filter((node): node is TreeNode => node !== null); - }; + const renderContent = () => { + if (fileTree.length === 0) { + if (loadingRoot) return null; - // 渲染树节点 - const renderTreeNode = (node: TreeNode): React.ReactNode => { - const isExpanded = expandedDirs.has(node.path); - const isLoading = loadingDirs.has(node.path); - const isActive = activeFile?.path === node.path; - - // 判断是否显示箭头: - // 1. 必须是目录 - // 2. 未加载过 OR 已加载且有子节点 - const shouldShowArrow = - node.type === 'directory' && (!node.loaded || (node.children && node.children.length > 0)); + return ( +
+ + + +
+ ); + } return ( - - { - if (node.type === 'file') { - openFile(node.path); - } else { - toggleDirectory(node); - } - }} - align="center" - fontSize="12px" - color={isActive ? 'myGray.600' : 'myGray.600'} + <> + {/* 左侧: 文件浏览器 */} + + + {/* 右侧: 编辑器区域 */} + - - {shouldShowArrow ? ( - isLoading ? ( - - ) : ( - - ) - ) : null} - - - - {node.name} - - - {shouldShowArrow && isExpanded && node.children && node.children.map(renderTreeNode)} - + {openedFiles.length > 0 ? ( + <> + + + + ) : ( + filteredTree.length > 0 && ( +
+ + + +
+ ) + )} + + ); }; - const filteredTree = filterTree(fileTree, searchQuery); - return ( { border="1px solid" borderColor="myGray.200" > - {fileTree.length > 0 ? ( - <> - {/* 左侧: 文件浏览器 */} - - {/* 搜索框 */} - - - - - - setSearchQuery(e.target.value)} - bg="white" - fontSize="12px" - h="32px" - borderRadius="6px" - borderColor="myGray.200" - _placeholder={{ color: 'myGray.500' }} - /> - - - - {/* 文件树 */} - - - {filteredTree.map(renderTreeNode)} - - - - {/* 下载所有按钮 */} - - - {/* 右侧: 编辑器区域 */} - - {openedFiles.length > 0 ? ( - <> - {/* Tab 栏 */} - - - {openedFiles.map((file) => { - const active = activeFilePath === file.path; - return ( - setActiveFilePath(file.path)} - maxW="150px" - flexShrink={0} - position="relative" - boxShadow={'1.5'} - _hover={{ - bg: active ? 'white' : 'myGray.50' - }} - > - - - {file.name} - - {file.isDirty && ( - - )} - closeFile(file.path, e)} - /> - - ); - })} - - - - - {/* 文件信息栏 */} - - - {activeFile?.name || ''} - - - {/* 下载当前文件按钮 */} - {activeFilePath && ( - } - aria-label="Download" - onClick={downloadCurrentFile} - isLoading={downloadingFile} - variant="whiteBase" - /> - )} - {/* 未保存标签和保存按钮 */} - {activeFile?.isDirty && ( - } - aria-label="Save" - onClick={() => saveFile()} - isLoading={saving} - variant="whiteBase" - /> - )} - - - - {/* 编辑器 */} - - {activeFile?.content === '[Binary File - Cannot Display]' ? ( - t('chat:sandbox_not_utf_file_tip') - ) : ( - { - editorRef.current = editor; - - // 保存快捷键 Ctrl/Cmd + S - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { - saveFile(); - }); - - // 全部保存快捷键 Ctrl/Cmd + Shift + S - editor.addCommand( - monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS, - () => { - // 保存所有未保存的文件 - openedFiles.forEach((file) => { - if (file.isDirty) { - saveFile(file.path); - } - }); - } - ); - }} - onChange={(value) => { - // 防止循环更新 - if (isUpdatingRef.current) return; - - // 更新当前文件内容 - if (activeFilePath && value !== undefined) { - setOpenedFiles((prev) => - prev.map((f) => - f.path === activeFilePath - ? { ...f, content: value, isDirty: true } - : f - ) - ); - } - }} - /> - )} - - - - ) : filteredTree.length > 0 ? ( -
- - - -
- ) : null} -
- - ) : !loadingRoot ? ( -
- - - -
- ) : null} + {renderContent()}
); }; diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/api.ts b/projects/app/src/pageComponents/chat/SandboxEditor/api.ts index 7cfaffa068..aef7b6692f 100644 --- a/projects/app/src/pageComponents/chat/SandboxEditor/api.ts +++ b/projects/app/src/pageComponents/chat/SandboxEditor/api.ts @@ -1,57 +1,51 @@ import type { - SandboxFileOperationBody, - SandboxFileOperationResponse + SandboxListBody, + SandboxListResponse, + SandboxWriteBody, + SandboxWriteResponse, + SandboxReadBody, + SandboxDownloadBody, + SandboxCheckExistBody, + SandboxCheckExistResponse, + SandboxGetHtmlPreviewLinkBody, + SandboxGetHtmlPreviewLinkResponse } from '@fastgpt/global/openapi/core/ai/sandbox/api'; import { POST } from '@/web/common/api/request'; /** * 列出目录文件 */ -export const listSandboxFiles = async ( - data: Omit, 'action'> -) => - POST>('/core/ai/sandbox/file', { - ...data, - action: 'list' as const - }); - -/** - * 读取文件内容 - */ -export const readSandboxFile = async ( - data: Omit, 'action'> -) => - POST>( - '/core/ai/sandbox/file', - { - ...data, - action: 'read' as const - }, - { - maxQuantity: 1 - } - ); +export const listSandboxFiles = async (data: SandboxListBody) => + POST('/core/ai/sandbox/list', data); /** * 写入文件内容 */ -export const writeSandboxFile = async ( - data: Omit, 'action'> -) => - POST>('/core/ai/sandbox/file', { - ...data, - action: 'write' as const - }); +export const writeSandboxFile = async (data: SandboxWriteBody) => + POST('/core/ai/sandbox/write', data); /** - * 下载文件或目录 + * 读取文件内容(内联预览) */ -export const downloadSandbox = async (data: { - appId: string; - chatId: string; - path?: string; - outLinkAuthData?: any; -}) => { +export const getSandboxFile = async (data: SandboxReadBody) => { + const response = await fetch('/api/core/ai/sandbox/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(errText || `Fetch file failed: ${response.status}`); + } + + return response; +}; + +/** + * 下载文件或目录(强制下载) + */ +export const downloadSandbox = async (data: SandboxDownloadBody) => { const response = await fetch('/api/core/ai/sandbox/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -67,23 +61,25 @@ export const downloadSandbox = async (data: { const a = document.createElement('a'); a.href = url; - // 从响应头获取文件名 - const contentDisposition = response.headers.get('Content-Disposition'); - const fileNameMatch = contentDisposition?.match(/filename="(.+)"/); - const fileName = fileNameMatch ? fileNameMatch[1] : `download-${Date.now()}.zip`; + const contentDisposition = response.headers.get('Content-Disposition') || ''; + const match = contentDisposition.match(/filename="?([^";]+)"?/i); + const fileName = match ? decodeURIComponent(match[1]) : `download-${Date.now()}.zip`; a.download = fileName; document.body.appendChild(a); a.click(); - document.body.removeChild(a); + a.remove(); window.URL.revokeObjectURL(url); }; /** * 检查沙盒是否存在 */ -export const checkSandboxExist = async (data: { - appId: string; - chatId: string; - outLinkAuthData?: any; -}) => POST<{ exists: boolean }>('/core/ai/sandbox/checkExist', data); +export const checkSandboxExist = async (data: SandboxCheckExistBody) => + POST('/core/ai/sandbox/checkExist', data); + +/** + * 获取 HTML 预览链接 (S3 托管) + */ +export const getHtmlPreviewLink = (data: SandboxGetHtmlPreviewLinkBody) => + POST('/core/ai/sandbox/getHtmlPreviewLink', data); diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/components/EditorContent.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/components/EditorContent.tsx new file mode 100644 index 0000000000..125e20d946 --- /dev/null +++ b/projects/app/src/pageComponents/chat/SandboxEditor/components/EditorContent.tsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Flex, IconButton, Center } from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import Editor from '@monaco-editor/react'; +import { useTranslation } from 'next-i18next'; +import { useLatest } from 'ahooks'; +import type { OpenedFile } from './FileTabs'; +import Markdown from '@fastgpt/web/components/common/Markdown'; +import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; +import MyPhotoView from '@fastgpt/web/components/common/Image/PhotoView'; +import { getHtmlPreviewLink } from '../api'; +import { getSupportsPreviewToggle } from '../utils'; +import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +type EditorInstance = Parameters[0]['onMount']>>[0]; + +type Props = { + activeFile: OpenedFile | undefined; + activeFilePath: string; + saving: boolean; + downloadingFile: boolean; + downloadCurrentFile: () => void; + saveFile: (path?: string) => void; + setOpenedFiles: React.Dispatch>; + openedFiles: OpenedFile[]; + editorRef: React.MutableRefObject; + isUpdatingRef: React.MutableRefObject; + appId: string; + chatId: string; + outLinkAuthData?: OutLinkChatAuthProps; +}; + +const EditorContent = ({ + activeFile, + activeFilePath, + saving, + downloadingFile, + downloadCurrentFile, + saveFile, + setOpenedFiles, + openedFiles, + editorRef, + isUpdatingRef, + appId, + chatId, + outLinkAuthData +}: Props) => { + const { t } = useTranslation(); + const { toast } = useToast(); + const [viewMode, setViewMode] = useState<'source' | 'preview'>('source'); + const [generatingLink, setGeneratingLink] = useState(false); + const openedFilesRef = useLatest(openedFiles); + + // 切换文件时,如果新文件不支持预览模式,重置为 source + useEffect(() => { + if (!getSupportsPreviewToggle(activeFile?.language) && viewMode === 'preview') { + setViewMode('source'); + } + }, [activeFilePath]); + + const handleHtmlPreview = async () => { + if (!activeFile) return; + + try { + setGeneratingLink(true); + const url = await getHtmlPreviewLink({ + appId, + chatId, + filePath: activeFile.path, + outLinkAuthData + }); + window.open(url, '_blank'); + } catch (error) { + toast({ + title: t('chat:sandbox_html_preview_failed'), + description: getErrText(error), + status: 'error' + }); + } finally { + setGeneratingLink(false); + } + }; + + const renderFileContent = () => { + if (!activeFile) return null; + + // 二进制文件预览 (图片/音频/视频) + if (activeFile.isBinary) { + const { language, content, name } = activeFile; + + if (content.startsWith('blob:') && language === 'image') { + return ( +
+ + + +
+ ); + } + + if (content.startsWith('blob:') && language === 'audio') { + return ( +
+ +
+ ); + } + + if (content.startsWith('blob:') && language === 'video') { + return ( +
+ +
+ ); + } + + // 无渲染器的二进制文件(如 PDF) + return t('chat:sandbox_binary_file_no_preview'); + } + + // 文本文件预览模式 + if (viewMode === 'preview') { + const { language, content } = activeFile; + if (language === 'markdown') { + return ( + + + + ); + } + if (language === 'svg') { + const svgUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(content)}`; + return ( +
+ + + +
+ ); + } + } + + // 文本文件源码模式 (Monaco Editor) + return ( + { + editorRef.current = editor; + + // 保存快捷键 Ctrl/Cmd + S + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + saveFile(); + }); + + // 全部保存快捷键 Ctrl/Cmd + Shift + S + editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS, + () => { + openedFilesRef.current?.forEach((file) => { + if (file.isDirty) { + saveFile(file.path); + } + }); + } + ); + }} + onChange={(value) => { + // 防止循环更新 + if (isUpdatingRef.current) return; + + // 更新当前文件内容 + if (activeFilePath && value !== undefined) { + setOpenedFiles((prev) => + prev.map((f) => + f.path === activeFilePath ? { ...f, content: value, isDirty: true } : f + ) + ); + } + }} + /> + ); + }; + + return ( + + + + {activeFile?.name || ''} + + + {/* HTML Preview Icon */} + {activeFile?.language === 'html' && ( + } + aria-label={'Preview'} + isLoading={generatingLink} + onClick={handleHtmlPreview} + variant="whiteBase" + /> + )} + {/* Source/Preview Toggle Switch */} + {getSupportsPreviewToggle(activeFile?.language) && ( + setViewMode(v as 'source' | 'preview')} + py="1" + px="2" + fontSize="xs" + /> + )} + {activeFilePath && ( + } + aria-label="Download" + onClick={downloadCurrentFile} + isLoading={downloadingFile} + variant="whiteBase" + /> + )} + {activeFile?.isDirty && ( + } + aria-label="Save" + onClick={() => saveFile()} + isLoading={saving} + variant="whiteBase" + /> + )} + + + + + {renderFileContent()} + + + ); +}; + +export default EditorContent; diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTabs.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTabs.tsx new file mode 100644 index 0000000000..26fbf44b27 --- /dev/null +++ b/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTabs.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { getIconByFilename } from '../utils'; + +export type OpenedFile = { + path: string; + name: string; + content: string; + language: string; + isBinary: boolean; + isDirty: boolean; +}; + +type Props = { + openedFiles: OpenedFile[]; + activeFilePath: string; + setActiveFilePath: (path: string) => void; + closeFile: (path: string, e?: React.MouseEvent) => void; +}; + +const FileTabs = ({ openedFiles, activeFilePath, setActiveFilePath, closeFile }: Props) => { + return ( + + + {openedFiles.map((file) => { + const active = activeFilePath === file.path; + return ( + setActiveFilePath(file.path)} + maxW="150px" + flexShrink={0} + position="relative" + boxShadow={'1.5'} + _hover={{ + bg: active ? 'white' : 'myGray.50' + }} + > + + + {file.name} + + {file.isDirty && } + closeFile(file.path, e)} + /> + + ); + })} + + + ); +}; + +export default FileTabs; diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTree.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTree.tsx new file mode 100644 index 0000000000..899828aad1 --- /dev/null +++ b/projects/app/src/pageComponents/chat/SandboxEditor/components/FileTree.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { + Box, + VStack, + Flex, + Input, + InputGroup, + InputLeftElement, + Button, + Text, + Spinner +} from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'next-i18next'; +import { getIconByFilename } from '../utils'; + +export type FileItem = { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; +}; + +export type TreeNode = FileItem & { + children?: TreeNode[]; + level: number; + loaded?: boolean; +}; + +type Props = { + filteredTree: TreeNode[]; + searchQuery: string; + setSearchQuery: (query: string) => void; + expandedDirs: Set; + loadingDirs: Set; + activeFilePath: string; + openFile: (path: string) => void; + toggleDirectory: (node: TreeNode) => void; + downloadingWorkspace: boolean; + downloadWorkspace: () => void; +}; + +const FileTree = ({ + filteredTree, + searchQuery, + setSearchQuery, + expandedDirs, + loadingDirs, + activeFilePath, + openFile, + toggleDirectory, + downloadingWorkspace, + downloadWorkspace +}: Props) => { + const { t } = useTranslation(); + + const renderTreeNode = (node: TreeNode): React.ReactNode => { + const isExpanded = expandedDirs.has(node.path); + const isLoading = loadingDirs.has(node.path); + const isActive = activeFilePath === node.path; + + const shouldShowArrow = + node.type === 'directory' && (!node.loaded || (node.children && node.children.length > 0)); + + return ( + + { + if (node.type === 'file') { + openFile(node.path); + } else { + toggleDirectory(node); + } + }} + align="center" + fontSize="12px" + color={isActive ? 'myGray.600' : 'myGray.600'} + > + + {shouldShowArrow ? ( + isLoading ? ( + + ) : ( + + ) + ) : null} + + + + {node.name} + + + {shouldShowArrow && isExpanded && node.children && node.children.map(renderTreeNode)} + + ); + }; + + return ( + + + + + + + setSearchQuery(e.target.value)} + bg="white" + fontSize="12px" + h="32px" + borderRadius="6px" + borderColor="myGray.200" + _placeholder={{ color: 'myGray.500' }} + /> + + + + + + {filteredTree.map(renderTreeNode)} + + + + + + ); +}; + +export default FileTree; diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/modal.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/modal.tsx index 8a38ff0993..3838396c73 100644 --- a/projects/app/src/pageComponents/chat/SandboxEditor/modal.tsx +++ b/projects/app/src/pageComponents/chat/SandboxEditor/modal.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { Props as EditorProps } from './Editor'; import SandboxEditor from './Editor'; import { useTranslation } from 'next-i18next'; -import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; type Props = EditorProps & { onClose: () => void; diff --git a/projects/app/src/pageComponents/chat/SandboxEditor/utils.tsx b/projects/app/src/pageComponents/chat/SandboxEditor/utils.tsx index a112dc4a1c..94f4fe7b2b 100644 --- a/projects/app/src/pageComponents/chat/SandboxEditor/utils.tsx +++ b/projects/app/src/pageComponents/chat/SandboxEditor/utils.tsx @@ -45,55 +45,123 @@ export const getIconByFilename = (filename: string): IconNameType => { return 'core/app/sandbox/default'; }; -// 获取文件语言 -export const getLanguageByExtension = (ext?: string): string => { - const langMap: Record = { - py: 'python', - js: 'javascript', - ts: 'typescript', - jsx: 'javascript', - tsx: 'typescript', - json: 'json', - jsonc: 'json', - json5: 'json', - md: 'markdown', - markdown: 'markdown', - html: 'html', - htm: 'html', - css: 'css', - scss: 'scss', - sass: 'sass', - less: 'less', - sh: 'shell', - bash: 'shell', - zsh: 'shell', - fish: 'shell', - yml: 'yaml', - yaml: 'yaml', - xml: 'xml', - sql: 'sql', - go: 'go', - rs: 'rust', - java: 'java', - c: 'c', - cpp: 'cpp', - cc: 'cpp', - cxx: 'cpp', - h: 'c', - hpp: 'cpp', - hxx: 'cpp', - cs: 'csharp', - php: 'php', - rb: 'ruby', - swift: 'swift', - kt: 'kotlin', - scala: 'scala', - lua: 'lua', - r: 'r', - toml: 'toml', - ini: 'ini', - conf: 'plaintext', - config: 'plaintext' - }; - return langMap[ext?.toLowerCase() || ''] || 'plaintext'; +const extensionToLang: Record = { + python: ['py'], + javascript: ['js', 'jsx', 'mjs', 'cjs'], + typescript: ['ts', 'tsx'], + json: ['json', 'jsonc', 'json5'], + markdown: ['md', 'markdown'], + html: ['html', 'htm'], + css: ['css'], + scss: ['scss'], + sass: ['sass'], + less: ['less'], + shell: ['sh', 'bash', 'zsh', 'fish'], + yaml: ['yml', 'yaml'], + xml: ['xml'], + sql: ['sql'], + go: ['go'], + rust: ['rs'], + java: ['java'], + c: ['c', 'h'], + cpp: ['cpp', 'cc', 'cxx', 'hpp', 'hxx'], + csharp: ['cs'], + php: ['php'], + ruby: ['rb'], + swift: ['swift'], + kotlin: ['kt'], + scala: ['scala'], + lua: ['lua'], + r: ['r'], + toml: ['toml'], + ini: ['ini'], + plaintext: ['conf', 'config', 'txt', 'log'], + image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'], + svg: ['svg'], + pdf: ['pdf'], + audio: ['mp3', 'wav', 'm4a', 'flac', 'ogg'], + video: ['avi', 'mp4', 'webm', 'mov', 'm4v'] +}; + +const langMap = Object.entries(extensionToLang).reduce( + (acc, [lang, extensions]) => { + extensions.forEach((ext) => { + acc[ext] = lang; + }); + return acc; + }, + {} as Record +); + +// 获取文件语言 +export const getLanguageByFileName = (fileName: string): string => { + const ext = fileName.split('.').at(-1)?.toLowerCase(); + return langMap[ext || ''] ?? 'plaintext'; +}; + +/** + * 判断语言是否属于二进制 + */ +export const getIsBinaryByLanguage = (language: string) => { + return ['image', 'audio', 'video'].includes(language); +}; + +/** + * 支持源码/预览切换的语言列表 + */ +const previewableLanguages = ['markdown', 'svg']; + +export const getSupportsPreviewToggle = (language?: string) => { + return !!language && previewableLanguages.includes(language); +}; + +// Update tree node +export const updateTreeNode = < + T extends { + type: 'file' | 'directory'; + name: string; + path: string; + children?: T[]; + loaded?: boolean; + } +>( + tree: T[], + targetPath: string, + children: T[], + loaded: boolean = false +): T[] => { + return tree.map((node) => { + if (node.path === targetPath) { + return { ...node, children, loaded } as T; + } + if (node.children) { + return { ...node, children: updateTreeNode(node.children, targetPath, children, loaded) }; + } + return node; + }); +}; + +// Filter tree +export const filterTree = < + T extends { type: 'file' | 'directory'; name: string; path: string; children?: T[] } +>( + nodes: T[], + query: string +): T[] => { + if (!query) return nodes; + + return nodes + .map((node) => { + if (node.type === 'file' && node.name.toLowerCase().includes(query.toLowerCase())) { + return node; + } + if (node.children) { + const filteredChildren = filterTree(node.children, query); + if (filteredChildren.length > 0) { + return { ...node, children: filteredChildren } as T; + } + } + return null; + }) + .filter((node): node is T => node !== null); }; diff --git a/projects/app/src/pages/api/core/ai/sandbox/download.ts b/projects/app/src/pages/api/core/ai/sandbox/download.ts index e2656cb4f4..017e06699f 100644 --- a/projects/app/src/pages/api/core/ai/sandbox/download.ts +++ b/projects/app/src/pages/api/core/ai/sandbox/download.ts @@ -2,23 +2,18 @@ import type { NextApiResponse } from 'next'; import { NextAPI } from '@/service/middleware/entry'; import { type ApiRequestProps } from '@fastgpt/service/type/next'; import { authChatCrud } from '@/service/support/permission/auth/chat'; -import { getSandboxClient, type SandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; import archiver from 'archiver'; -import { z } from 'zod'; -import { OutLinkChatAuthSchema } from '@fastgpt/global/support/permission/chat'; - -const DownloadBodySchema = z.object({ - appId: z.string(), - chatId: z.string(), - path: z.string().default('.').describe('要下载的路径(文件或目录)'), - outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') -}); +import { SandboxDownloadBodySchema } from '@fastgpt/global/openapi/core/ai/sandbox/api'; +import { + isSandboxPathDirectory, + getSandboxFileContent, + addDirectoryToArchive +} from '@/service/core/sandbox/fileService'; async function handler(req: ApiRequestProps, res: NextApiResponse): Promise { - const body = DownloadBodySchema.parse(req.body); - const { appId, chatId, path, outLinkAuthData } = body; + const { appId, chatId, path, outLinkAuthData } = SandboxDownloadBodySchema.parse(req.body); - // 鉴权 const { uid } = await authChatCrud({ req, authToken: true, @@ -28,80 +23,39 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise { throw err; }); - archive.pipe(res); - - // 递归添加文件到 ZIP await addDirectoryToArchive(sandbox, archive, path, ''); - await archive.finalize(); } else { - // 下载单个文件 - const results = await sandbox.provider.readFiles([path]); - const result = results[0]; + const { content, fileName } = await getSandboxFileContent(sandbox, path, false); + const encodedFileName = encodeURIComponent(fileName); - if (result.error) { - return Promise.reject('Failed to read file'); - } - - const fileName = path.split('/').pop() || 'file'; res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - res.send(Buffer.from(result.content)); - } -} - -// 递归添加目录到 archive -async function addDirectoryToArchive( - sandbox: SandboxClient, - archive: archiver.Archiver, - dirPath: string, - archivePath: string -): Promise { - const entries = await sandbox.provider.listDirectory(dirPath); - - for (const entry of entries) { - const entryArchivePath = archivePath ? `${archivePath}/${entry.name}` : entry.name; - - if (entry.isDirectory) { - // 递归处理子目录 - await addDirectoryToArchive(sandbox, archive, entry.path, entryArchivePath); - } else { - // 添加文件 - const results = await sandbox.provider.readFiles([entry.path]); - const result = results[0]; - - if (!result.error) { - archive.append(Buffer.from(result.content), { name: entryArchivePath }); - } - } + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}` + ); + res.send(content); } } diff --git a/projects/app/src/pages/api/core/ai/sandbox/file.ts b/projects/app/src/pages/api/core/ai/sandbox/file.ts deleted file mode 100644 index 6b43cab23f..0000000000 --- a/projects/app/src/pages/api/core/ai/sandbox/file.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { NextApiResponse } from 'next'; -import { NextAPI } from '@/service/middleware/entry'; -import { type ApiRequestProps } from '@fastgpt/service/type/next'; -import { authChatCrud } from '@/service/support/permission/auth/chat'; -import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; -import { - SandboxFileOperationBodySchema, - type SandboxFileOperationResponse -} from '@fastgpt/global/openapi/core/ai/sandbox/api'; - -async function handler( - req: ApiRequestProps, - res: NextApiResponse -): Promise { - // 解析请求体 - const body = SandboxFileOperationBodySchema.parse(req.body); - const { appId, chatId, action, outLinkAuthData } = body; - - // 统一鉴权 - const { uid } = await authChatCrud({ - req, - authToken: true, - authApiKey: true, - appId, - chatId, - ...outLinkAuthData - }); - - // 创建沙盒实例 - const sandbox = await getSandboxClient({ - appId, - userId: uid, - chatId - }); - - try { - await sandbox.ensureAvailable(); - - // 根据 action 分类执行 - switch (action) { - case 'list': { - const entries = await sandbox.provider.listDirectory(body.path); - - const files = entries.map((entry) => ({ - name: entry.name, - path: entry.path, - type: entry.isDirectory ? ('directory' as const) : ('file' as const), - size: entry.isFile ? entry.size : undefined - })); - - return { action: 'list', files }; - } - - case 'read': { - const results = await sandbox.provider.readFiles([body.path]); - const result = results[0]; - - if (result.error) { - return Promise.reject(result.error); - } - - // 尝试将 Uint8Array 转换为 UTF-8 字符串 - try { - const decoder = new TextDecoder('utf-8', { fatal: true }); - const content = decoder.decode(result.content); - return { action: 'read', content }; - } catch (error) { - // 非 UTF-8 内容,返回特殊标记 - return { action: 'read', content: '[Binary File - Cannot Display]' }; - } - } - - case 'write': { - const results = await sandbox.provider.writeFiles([ - { - path: body.path, - data: body.content - } - ]); - const result = results[0]; - - if (result.error) { - return Promise.reject(result.error); - } - - return { action: 'write', success: true }; - } - - default: - return Promise.reject('Invalid action'); - } - } catch (error: any) { - if (error?.toJSON) { - const err = error.toJSON(); - return Promise.reject(`[${err.name}] ${err.message}`); - } - return Promise.reject(error); - } -} - -export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/ai/sandbox/getHtmlPreviewLink.ts b/projects/app/src/pages/api/core/ai/sandbox/getHtmlPreviewLink.ts new file mode 100644 index 0000000000..503b108bdf --- /dev/null +++ b/projects/app/src/pages/api/core/ai/sandbox/getHtmlPreviewLink.ts @@ -0,0 +1,71 @@ +import type { NextApiResponse } from 'next'; +import { jsonRes } from '@fastgpt/service/common/response'; +import { NextAPI } from '@/service/middleware/entry'; +import { type ApiRequestProps } from '@fastgpt/service/type/next'; +import { authChatCrud } from '@/service/support/permission/auth/chat'; +import { SandboxGetHtmlPreviewLinkBodySchema } from '@fastgpt/global/openapi/core/ai/sandbox/api'; +import { S3PrivateBucket } from '@fastgpt/service/common/s3/buckets/private'; +import { getFileS3Key } from '@fastgpt/service/common/s3/utils'; +import { addMinutes } from 'date-fns'; +import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import { getSandboxFileContent } from '@/service/core/sandbox/fileService'; + +// 在 中注入 CSP,禁止外部脚本加载,仅允许 inline(沙箱预览场景) +function injectCspMetaTag(html: string): string { + const cspMeta = + ""; + + if (/]*>/i.test(html)) { + return html.replace(/(]*>)/i, `$1${cspMeta}`); + } + // 没有 标签时,直接前置 + return cspMeta + html; +} + +async function handler(req: ApiRequestProps, res: NextApiResponse): Promise { + const { appId, chatId, filePath, outLinkAuthData } = SandboxGetHtmlPreviewLinkBodySchema.parse( + req.body + ); + + // 1. 鉴权 + const { teamId, uid } = await authChatCrud({ + req, + authToken: true, + authApiKey: true, + appId, + chatId, + ...outLinkAuthData + }); + + // 2. 从沙箱读取实际文件内容,避免客户端传入任意 HTML + const sandbox = await getSandboxClient({ appId, userId: uid, chatId }); + await sandbox.ensureAvailable(); + + const { content, contentType } = await getSandboxFileContent(sandbox, filePath, true); + + if (!contentType.startsWith('text/html')) { + return jsonRes(res, { code: 400, message: 'File is not an HTML file' }); + } + + // 3. 注入 CSP meta tag 后上传到 S3 + const safeHtml = injectCspMetaTag(content.toString('utf-8')); + const bucket = new S3PrivateBucket(); + const { fileKey } = getFileS3Key.temp({ teamId, filename: 'preview.html' }); + const expiredTime = addMinutes(new Date(), 30); + + const { + accessUrl: { url } + } = await bucket.uploadFileByBody({ + key: fileKey, + body: Buffer.from(safeHtml, 'utf-8'), + filename: 'preview.html', + contentType: 'text/html; charset=utf-8', + expiredTime + }); + + return jsonRes(res, { + data: url + }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/ai/sandbox/list.ts b/projects/app/src/pages/api/core/ai/sandbox/list.ts new file mode 100644 index 0000000000..518519131b --- /dev/null +++ b/projects/app/src/pages/api/core/ai/sandbox/list.ts @@ -0,0 +1,34 @@ +import type { NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import { type ApiRequestProps } from '@fastgpt/service/type/next'; +import { authChatCrud } from '@/service/support/permission/auth/chat'; +import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import { + SandboxListBodySchema, + type SandboxListResponse +} from '@fastgpt/global/openapi/core/ai/sandbox/api'; +import { listSandboxDirectory } from '@/service/core/sandbox/fileService'; + +async function handler( + req: ApiRequestProps, + res: NextApiResponse +): Promise { + const { appId, chatId, path, outLinkAuthData } = SandboxListBodySchema.parse(req.body); + + const { uid } = await authChatCrud({ + req, + authToken: true, + authApiKey: true, + appId, + chatId, + ...outLinkAuthData + }); + + const sandbox = await getSandboxClient({ appId, userId: uid, chatId }); + await sandbox.ensureAvailable(); + + const files = await listSandboxDirectory(sandbox, path); + return { files }; +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/ai/sandbox/read.ts b/projects/app/src/pages/api/core/ai/sandbox/read.ts new file mode 100644 index 0000000000..44f2a1e528 --- /dev/null +++ b/projects/app/src/pages/api/core/ai/sandbox/read.ts @@ -0,0 +1,30 @@ +import type { NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import { type ApiRequestProps } from '@fastgpt/service/type/next'; +import { authChatCrud } from '@/service/support/permission/auth/chat'; +import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import { SandboxReadBodySchema } from '@fastgpt/global/openapi/core/ai/sandbox/api'; +import { getSandboxFileContent } from '@/service/core/sandbox/fileService'; + +async function handler(req: ApiRequestProps, res: NextApiResponse): Promise { + const { appId, chatId, path, outLinkAuthData } = SandboxReadBodySchema.parse(req.body); + + const { uid } = await authChatCrud({ + req, + authToken: true, + authApiKey: true, + appId, + chatId, + ...outLinkAuthData + }); + + const sandbox = await getSandboxClient({ appId, userId: uid, chatId }); + await sandbox.ensureAvailable(); + + const { content, contentType } = await getSandboxFileContent(sandbox, path, true); + + res.setHeader('Content-Type', contentType); + res.send(content); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/ai/sandbox/write.ts b/projects/app/src/pages/api/core/ai/sandbox/write.ts new file mode 100644 index 0000000000..adeec05fa2 --- /dev/null +++ b/projects/app/src/pages/api/core/ai/sandbox/write.ts @@ -0,0 +1,34 @@ +import type { NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import { type ApiRequestProps } from '@fastgpt/service/type/next'; +import { authChatCrud } from '@/service/support/permission/auth/chat'; +import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import { + SandboxWriteBodySchema, + type SandboxWriteResponse +} from '@fastgpt/global/openapi/core/ai/sandbox/api'; +import { writeSandboxFile } from '@/service/core/sandbox/fileService'; + +async function handler( + req: ApiRequestProps, + res: NextApiResponse +): Promise { + const { appId, chatId, path, content, outLinkAuthData } = SandboxWriteBodySchema.parse(req.body); + + const { uid } = await authChatCrud({ + req, + authToken: true, + authApiKey: true, + appId, + chatId, + ...outLinkAuthData + }); + + const sandbox = await getSandboxClient({ appId, userId: uid, chatId }); + await sandbox.ensureAvailable(); + + await writeSandboxFile(sandbox, path, content); + return { success: true }; +} + +export default NextAPI(handler); diff --git a/projects/app/src/service/core/sandbox/fileService.ts b/projects/app/src/service/core/sandbox/fileService.ts new file mode 100644 index 0000000000..e2b8f33f4d --- /dev/null +++ b/projects/app/src/service/core/sandbox/fileService.ts @@ -0,0 +1,105 @@ +import { type SandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import type archiver from 'archiver'; +import mime from 'mime'; + +export type SandboxFileEntry = { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; +}; + +export type SandboxFileContent = { + content: Buffer; + contentType: string; + fileName: string; +}; + +export async function listSandboxDirectory( + sandbox: SandboxClient, + path: string +): Promise { + const entries = await sandbox.provider.listDirectory(path); + return entries.map((entry) => ({ + name: entry.name, + path: entry.path, + type: entry.isDirectory ? ('directory' as const) : ('file' as const), + size: entry.isFile ? entry.size : undefined + })); +} + +export async function writeSandboxFile( + sandbox: SandboxClient, + path: string, + content: string +): Promise { + const results = await sandbox.provider.writeFiles([{ path, data: content }]); + const result = results[0]; + if (result.error) { + return Promise.reject(result.error); + } +} + +export async function isSandboxPathDirectory( + sandbox: SandboxClient, + path: string +): Promise { + const fileInfoMap = await sandbox.provider.getFileInfo([path]); + const fileInfo = fileInfoMap.get(path); + return fileInfo?.isDirectory ?? (path === '.' || path === '' || path.endsWith('/')); +} + +export async function getSandboxFileContent( + sandbox: SandboxClient, + path: string, + preview?: boolean +): Promise { + const results = await sandbox.provider.readFiles([path]); + const result = results[0]; + + if (result.error) { + return Promise.reject(new Error(`Failed to read file: ${result.error.message}`)); + } + + const fileName = path.split('/').pop() || 'file'; + // 注意:preview 模式下 contentType 由文件路径决定,可能返回 text/html / image/svg+xml 等危险类型。 + // 若未来有任何代码让浏览器直接导航到 download 端点(iframe / window.open 等),需确保这类内容不被同源渲染,否则会造成存储型 XSS。 + const contentType = preview + ? mime.getType(path) ?? 'application/octet-stream' + : 'application/octet-stream'; + + return { + content: Buffer.from(result.content), + contentType, + fileName + }; +} + +const MAX_ARCHIVE_DEPTH = 20; + +export async function addDirectoryToArchive( + sandbox: SandboxClient, + archive: archiver.Archiver, + dirPath: string, + archivePath: string, + depth: number = 0 +): Promise { + if (depth > MAX_ARCHIVE_DEPTH) return; + + const entries = await sandbox.provider.listDirectory(dirPath); + + for (const entry of entries) { + const entryArchivePath = archivePath ? `${archivePath}/${entry.name}` : entry.name; + + if (entry.isDirectory) { + await addDirectoryToArchive(sandbox, archive, entry.path, entryArchivePath, depth + 1); + } else { + const results = await sandbox.provider.readFiles([entry.path]); + const result = results[0]; + + if (!result.error) { + archive.append(Buffer.from(result.content), { name: entryArchivePath }); + } + } + } +} diff --git a/projects/app/test/service/core/sandbox/fileService.test.ts b/projects/app/test/service/core/sandbox/fileService.test.ts new file mode 100644 index 0000000000..2158f528dc --- /dev/null +++ b/projects/app/test/service/core/sandbox/fileService.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + listSandboxDirectory, + writeSandboxFile, + isSandboxPathDirectory, + getSandboxFileContent, + addDirectoryToArchive, + type SandboxFileEntry +} from '@/service/core/sandbox/fileService'; +import type { SandboxClient } from '@fastgpt/service/core/ai/sandbox/controller'; +import type { + DirectoryEntry, + FileInfo, + FileReadResult, + FileWriteResult +} from '@fastgpt-sdk/sandbox-adapter'; + +// ─── helpers ─────────────────────────────────────────────────────────────── + +function makeProvider( + overrides: Partial = {} +): SandboxClient['provider'] { + return { + listDirectory: vi.fn(), + writeFiles: vi.fn(), + readFiles: vi.fn(), + getFileInfo: vi.fn(), + ensureRunning: vi.fn(), + execute: vi.fn(), + delete: vi.fn(), + stop: vi.fn(), + provider: 'mock', + ...overrides + } as unknown as SandboxClient['provider']; +} + +function makeSandbox(providerOverrides: Partial = {}): SandboxClient { + return { provider: makeProvider(providerOverrides) } as unknown as SandboxClient; +} + +function makeDirectoryEntry( + name: string, + opts: { isDirectory?: boolean; size?: number; path?: string } = {} +): DirectoryEntry { + const isDirectory = opts.isDirectory ?? false; + return { + name, + path: opts.path ?? `/workspace/${name}`, + isDirectory, + isFile: !isDirectory, + size: opts.size + }; +} + +function makeReadResult(path: string, content: string, error: Error | null = null): FileReadResult { + return { + path, + content: new TextEncoder().encode(content), + error + }; +} + +function makeWriteResult(path: string, error: Error | null = null): FileWriteResult { + return { path, bytesWritten: error ? 0 : 10, error }; +} + +function makeFileInfoMap(path: string, info: Partial): Map { + return new Map([[path, { path, ...info }]]); +} + +// ─── listSandboxDirectory ────────────────────────────────────────────────── + +describe('listSandboxDirectory', () => { + it('空目录返回空数组', async () => { + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue([]) }); + const result = await listSandboxDirectory(sandbox, '/workspace'); + expect(result).toEqual([]); + }); + + it('正确映射文件条目', async () => { + const entries = [makeDirectoryEntry('index.ts', { size: 200 })]; + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue(entries) }); + const result = await listSandboxDirectory(sandbox, '/workspace'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'index.ts', + path: '/workspace/index.ts', + type: 'file', + size: 200 + }); + }); + + it('正确映射目录条目,size 为 undefined', async () => { + const entries = [makeDirectoryEntry('src', { isDirectory: true })]; + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue(entries) }); + const result = await listSandboxDirectory(sandbox, '/workspace'); + + expect(result[0]).toMatchObject({ + name: 'src', + type: 'directory', + size: undefined + }); + }); + + it('混合条目正确映射', async () => { + const entries = [ + makeDirectoryEntry('src', { isDirectory: true }), + makeDirectoryEntry('main.py', { size: 512 }) + ]; + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue(entries) }); + const result = await listSandboxDirectory(sandbox, '/workspace'); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('directory'); + expect(result[1].type).toBe('file'); + expect(result[1].size).toBe(512); + }); + + it('传递正确的 path 参数给 provider', async () => { + const listDirectory = vi.fn().mockResolvedValue([]); + const sandbox = makeSandbox({ listDirectory }); + await listSandboxDirectory(sandbox, '/some/path'); + expect(listDirectory).toHaveBeenCalledWith('/some/path'); + }); +}); + +// ─── writeSandboxFile ────────────────────────────────────────────────────── + +describe('writeSandboxFile', () => { + it('写入成功时正常返回', async () => { + const writeFiles = vi.fn().mockResolvedValue([makeWriteResult('/file.txt')]); + const sandbox = makeSandbox({ writeFiles }); + await expect(writeSandboxFile(sandbox, '/file.txt', 'hello')).resolves.toBeUndefined(); + }); + + it('传递正确的 path 和 content 给 provider', async () => { + const writeFiles = vi.fn().mockResolvedValue([makeWriteResult('/out.py')]); + const sandbox = makeSandbox({ writeFiles }); + await writeSandboxFile(sandbox, '/out.py', 'print("hi")'); + expect(writeFiles).toHaveBeenCalledWith([{ path: '/out.py', data: 'print("hi")' }]); + }); + + it('写入失败时 reject error', async () => { + const err = new Error('disk full'); + const writeFiles = vi.fn().mockResolvedValue([makeWriteResult('/file.txt', err)]); + const sandbox = makeSandbox({ writeFiles }); + await expect(writeSandboxFile(sandbox, '/file.txt', 'data')).rejects.toThrow('disk full'); + }); + + it('写入空字符串不会报错', async () => { + const writeFiles = vi.fn().mockResolvedValue([makeWriteResult('/empty.txt')]); + const sandbox = makeSandbox({ writeFiles }); + await expect(writeSandboxFile(sandbox, '/empty.txt', '')).resolves.toBeUndefined(); + }); +}); + +// ─── isSandboxPathDirectory ──────────────────────────────────────────────── + +describe('isSandboxPathDirectory', () => { + it('getFileInfo 返回 isDirectory:true', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(makeFileInfoMap('/src', { isDirectory: true })) + }); + expect(await isSandboxPathDirectory(sandbox, '/src')).toBe(true); + }); + + it('getFileInfo 返回 isDirectory:false', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(makeFileInfoMap('/main.py', { isDirectory: false })) + }); + expect(await isSandboxPathDirectory(sandbox, '/main.py')).toBe(false); + }); + + it('fileInfo 不存在时,path 为 "." 返回 true', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(new Map()) + }); + expect(await isSandboxPathDirectory(sandbox, '.')).toBe(true); + }); + + it('fileInfo 不存在时,path 为 "" 返回 true', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(new Map()) + }); + expect(await isSandboxPathDirectory(sandbox, '')).toBe(true); + }); + + it('fileInfo 不存在时,path 以 "/" 结尾返回 true', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(new Map()) + }); + expect(await isSandboxPathDirectory(sandbox, '/workspace/')).toBe(true); + }); + + it('fileInfo 不存在时,普通文件路径返回 false', async () => { + const sandbox = makeSandbox({ + getFileInfo: vi.fn().mockResolvedValue(new Map()) + }); + expect(await isSandboxPathDirectory(sandbox, '/main.py')).toBe(false); + }); + + it('传递正确的 path 数组给 getFileInfo', async () => { + const getFileInfo = vi.fn().mockResolvedValue(new Map()); + const sandbox = makeSandbox({ getFileInfo }); + await isSandboxPathDirectory(sandbox, '/some/path'); + expect(getFileInfo).toHaveBeenCalledWith(['/some/path']); + }); +}); + +// ─── getSandboxFileContent ───────────────────────────────────────────────── + +describe('getSandboxFileContent', () => { + it('preview=false 时 contentType 为 application/octet-stream', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/main.py', 'print(1)')]) + }); + const result = await getSandboxFileContent(sandbox, '/main.py', false); + expect(result.contentType).toBe('application/octet-stream'); + }); + + it('preview=undefined 时 contentType 为 application/octet-stream', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/main.py', 'code')]) + }); + const result = await getSandboxFileContent(sandbox, '/main.py'); + expect(result.contentType).toBe('application/octet-stream'); + }); + + it('preview=true 且可识别扩展名时返回正确 contentType', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/index.html', '')]) + }); + const result = await getSandboxFileContent(sandbox, '/index.html', true); + expect(result.contentType).toBe('text/html'); + }); + + it('preview=true 且扩展名无法识别时回退 application/octet-stream', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/foo.unknown123', 'data')]) + }); + const result = await getSandboxFileContent(sandbox, '/foo.unknown123', true); + expect(result.contentType).toBe('application/octet-stream'); + }); + + it('读取失败时 reject', async () => { + const sandbox = makeSandbox({ + readFiles: vi + .fn() + .mockResolvedValue([makeReadResult('/file.txt', '', new Error('not found'))]) + }); + await expect(getSandboxFileContent(sandbox, '/file.txt')).rejects.toThrow( + 'Failed to read file: not found' + ); + }); + + it('正确提取 fileName(路径最后一段)', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/a/b/script.py', 'code')]) + }); + const result = await getSandboxFileContent(sandbox, '/a/b/script.py'); + expect(result.fileName).toBe('script.py'); + }); + + it('路径不含 "/" 时 fileName 等于 path 本身', async () => { + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('readme.md', 'hi')]) + }); + const result = await getSandboxFileContent(sandbox, 'readme.md'); + expect(result.fileName).toBe('readme.md'); + }); + + it('content 正确转换为 Buffer', async () => { + const text = 'hello world'; + const sandbox = makeSandbox({ + readFiles: vi.fn().mockResolvedValue([makeReadResult('/f.txt', text)]) + }); + const result = await getSandboxFileContent(sandbox, '/f.txt'); + expect(result.content).toEqual(Buffer.from(text)); + }); + + it('传递正确的 path 数组给 readFiles', async () => { + const readFiles = vi.fn().mockResolvedValue([makeReadResult('/x.ts', '')]); + const sandbox = makeSandbox({ readFiles }); + await getSandboxFileContent(sandbox, '/x.ts'); + expect(readFiles).toHaveBeenCalledWith(['/x.ts']); + }); +}); + +// ─── addDirectoryToArchive ───────────────────────────────────────────────── + +describe('addDirectoryToArchive', () => { + function makeArchive() { + return { append: vi.fn() } as unknown as import('archiver').Archiver; + } + + it('空目录不调用 append', async () => { + const archive = makeArchive(); + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue([]) }); + await addDirectoryToArchive(sandbox, archive, '/workspace', ''); + expect(archive.append).not.toHaveBeenCalled(); + }); + + it('顶层文件(archivePath 为空)使用 entry.name 作为归档路径', async () => { + const archive = makeArchive(); + const sandbox = makeSandbox({ + listDirectory: vi.fn().mockResolvedValue([makeDirectoryEntry('main.py', { size: 10 })]), + readFiles: vi.fn().mockResolvedValue([makeReadResult('/workspace/main.py', 'code')]) + }); + await addDirectoryToArchive(sandbox, archive, '/workspace', ''); + expect(archive.append).toHaveBeenCalledWith(expect.any(Buffer), { name: 'main.py' }); + }); + + it('嵌套文件时归档路径包含前缀', async () => { + const archive = makeArchive(); + const sandbox = makeSandbox({ + listDirectory: vi.fn().mockResolvedValue([makeDirectoryEntry('utils.ts', { size: 50 })]), + readFiles: vi.fn().mockResolvedValue([makeReadResult('/workspace/src/utils.ts', 'export')]) + }); + await addDirectoryToArchive(sandbox, archive, '/workspace/src', 'src'); + expect(archive.append).toHaveBeenCalledWith(expect.any(Buffer), { name: 'src/utils.ts' }); + }); + + it('递归处理子目录', async () => { + const archive = makeArchive(); + const listDirectory = vi + .fn() + .mockResolvedValueOnce([ + makeDirectoryEntry('src', { isDirectory: true, path: '/workspace/src' }) + ]) + .mockResolvedValueOnce([makeDirectoryEntry('index.ts', { path: '/workspace/src/index.ts' })]); + const readFiles = vi + .fn() + .mockResolvedValue([makeReadResult('/workspace/src/index.ts', 'code')]); + const sandbox = makeSandbox({ listDirectory, readFiles }); + await addDirectoryToArchive(sandbox, archive, '/workspace', ''); + expect(archive.append).toHaveBeenCalledWith(expect.any(Buffer), { name: 'src/index.ts' }); + }); + + it('读取失败的文件被跳过,不调用 append', async () => { + const archive = makeArchive(); + const entries = [makeDirectoryEntry('broken.py', { size: 100 })]; + const sandbox = makeSandbox({ + listDirectory: vi.fn().mockResolvedValue(entries), + readFiles: vi + .fn() + .mockResolvedValue([makeReadResult('/workspace/broken.py', '', new Error('read error'))]) + }); + await addDirectoryToArchive(sandbox, archive, '/workspace', ''); + expect(archive.append).not.toHaveBeenCalled(); + }); + + it('混合场景:成功文件和失败文件', async () => { + const archive = makeArchive(); + const entries = [ + makeDirectoryEntry('ok.py', { path: '/workspace/ok.py' }), + makeDirectoryEntry('bad.py', { path: '/workspace/bad.py' }) + ]; + const readFiles = vi + .fn() + .mockResolvedValueOnce([makeReadResult('/workspace/ok.py', 'print(1)')]) + .mockResolvedValueOnce([makeReadResult('/workspace/bad.py', '', new Error('fail'))]); + const sandbox = makeSandbox({ listDirectory: vi.fn().mockResolvedValue(entries), readFiles }); + await addDirectoryToArchive(sandbox, archive, '/workspace', ''); + expect(archive.append).toHaveBeenCalledTimes(1); + expect(archive.append).toHaveBeenCalledWith(expect.any(Buffer), { name: 'ok.py' }); + }); + + it('深层嵌套目录递归正确', async () => { + const archive = makeArchive(); + const listDirectory = vi + .fn() + .mockResolvedValueOnce([makeDirectoryEntry('a', { isDirectory: true, path: '/w/a' })]) + .mockResolvedValueOnce([makeDirectoryEntry('b', { isDirectory: true, path: '/w/a/b' })]) + .mockResolvedValueOnce([makeDirectoryEntry('c.txt', { path: '/w/a/b/c.txt' })]); + const readFiles = vi.fn().mockResolvedValue([makeReadResult('/w/a/b/c.txt', 'deep')]); + const sandbox = makeSandbox({ listDirectory, readFiles }); + await addDirectoryToArchive(sandbox, archive, '/w', ''); + expect(archive.append).toHaveBeenCalledWith(expect.any(Buffer), { name: 'a/b/c.txt' }); + }); + + it('超过最大深度限制时停止递归', async () => { + const archive = makeArchive(); + const listDirectory = vi + .fn() + .mockResolvedValue([makeDirectoryEntry('sub', { isDirectory: true, path: '/w/sub' })]); + const sandbox = makeSandbox({ listDirectory }); + // depth=21 超过 MAX_ARCHIVE_DEPTH(20),应直接返回不做任何操作 + await addDirectoryToArchive(sandbox, archive, '/w', '', 21); + expect(listDirectory).not.toHaveBeenCalled(); + expect(archive.append).not.toHaveBeenCalled(); + }); +});