Commit 3df89088 by DigHuang Committed by GitHub

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>
parent 85244870
...@@ -55,4 +55,4 @@ ...@@ -55,4 +55,4 @@
"node": ">=20", "node": ">=20",
"pnpm": "9.x" "pnpm": "9.x"
} }
} }
\ No newline at end of file
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { OutLinkChatAuthSchema } from '../../../../support/permission/chat';
import { z } from 'zod'; import { z } from 'zod';
/** const SandboxBaseSchema = z.object({
* 文件操作 - 统一请求体 appId: z.string(),
*/ chatId: z.string(),
export const SandboxFileOperationBodySchema = z.union([ outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
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<typeof SandboxFileOperationBodySchema>;
/** /**
* 文件项 * 列出目录 - 请求/响应
*/ */
export const SandboxListBodySchema = SandboxBaseSchema.extend({
path: z.string().default('.').describe('目录路径')
});
export type SandboxListBody = z.infer<typeof SandboxListBodySchema>;
export const SandboxFileItemSchema = z.object({ export const SandboxFileItemSchema = z.object({
name: z.string().describe('文件名'), name: z.string().describe('文件名'),
path: z.string().describe('完整路径'), path: z.string().describe('完整路径'),
type: z.enum(['file', 'directory']).describe('文件类型'), type: z.enum(['file', 'directory']).describe('文件类型'),
size: z.number().optional().describe('文件大小(字节数)') size: z.number().optional().describe('文件大小(字节数)')
}); });
export type SandboxFileItem = z.infer<typeof SandboxFileItemSchema>; export type SandboxFileItem = z.infer<typeof SandboxFileItemSchema>;
export const SandboxListResponseSchema = z.object({
files: z.array(SandboxFileItemSchema)
});
export type SandboxListResponse = z.infer<typeof SandboxListResponseSchema>;
/** /**
* 文件操作 - 响应体 * 写入文件 - 请求/响应
*/ */
export const SandboxFileOperationResponseSchema = z.union([ export const SandboxWriteBodySchema = SandboxBaseSchema.extend({
z.object({ path: z.string().describe('文件路径'),
action: z.literal('list'), content: z.string().describe('文件内容')
files: z.array(SandboxFileItemSchema) });
}), export type SandboxWriteBody = z.infer<typeof SandboxWriteBodySchema>;
z.object({
action: z.literal('read'),
content: z.string().describe('文件内容')
}),
z.object({
action: z.literal('write'),
success: z.boolean()
})
]);
export type SandboxFileOperationResponse = z.infer<typeof SandboxFileOperationResponseSchema>; export const SandboxWriteResponseSchema = z.object({
success: z.boolean()
});
export type SandboxWriteResponse = z.infer<typeof SandboxWriteResponseSchema>;
/** /**
* 检查沙盒是否存在 * 读取文件内容 - 请求体(响应为原始文件流)
*/ */
export const SandboxCheckExistBodySchema = z.object({ export const SandboxReadBodySchema = SandboxBaseSchema.extend({
appId: z.string(), path: z.string().describe('文件路径')
chatId: z.string(), });
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') export type SandboxReadBody = z.infer<typeof SandboxReadBodySchema>;
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<typeof SandboxDownloadBodySchema>;
export const SandboxDownloadResponseSchema = z
.string()
.openapi({ format: 'binary', description: '文件流或 ZIP 包' });
/**
* 检查沙盒是否存在
*/
export const SandboxCheckExistBodySchema = SandboxBaseSchema;
export const SandboxCheckExistResponseSchema = z.object({ export const SandboxCheckExistResponseSchema = z.object({
exists: z.boolean().describe('沙盒是否存在') exists: z.boolean().describe('沙盒是否存在')
}); });
export type SandboxCheckExistBody = z.infer<typeof SandboxCheckExistBodySchema>; export type SandboxCheckExistBody = z.infer<typeof SandboxCheckExistBodySchema>;
export type SandboxCheckExistResponse = z.infer<typeof SandboxCheckExistResponseSchema>; export type SandboxCheckExistResponse = z.infer<typeof SandboxCheckExistResponseSchema>;
/**
* 获取 HTML 预览链接 - 请求/响应
*/
export const SandboxGetHtmlPreviewLinkBodySchema = SandboxBaseSchema.extend({
filePath: z.string().describe('文件路径')
});
export const SandboxGetHtmlPreviewLinkResponseSchema = z.string().describe('HTML 预览链接');
export type SandboxGetHtmlPreviewLinkBody = z.infer<typeof SandboxGetHtmlPreviewLinkBodySchema>;
export type SandboxGetHtmlPreviewLinkResponse = z.infer<
typeof SandboxGetHtmlPreviewLinkResponseSchema
>;
import type { OpenAPIPath } from '../../../type'; import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag'; import { TagsMap } from '../../../tag';
import { import {
SandboxFileOperationBodySchema, SandboxListBodySchema,
SandboxFileOperationResponseSchema, SandboxListResponseSchema,
SandboxWriteBodySchema,
SandboxWriteResponseSchema,
SandboxReadBodySchema,
SandboxReadResponseSchema,
SandboxDownloadBodySchema,
SandboxDownloadResponseSchema,
SandboxCheckExistBodySchema, SandboxCheckExistBodySchema,
SandboxCheckExistResponseSchema SandboxCheckExistResponseSchema,
SandboxGetHtmlPreviewLinkBodySchema,
SandboxGetHtmlPreviewLinkResponseSchema
} from './api'; } from './api';
export const SandboxPath: OpenAPIPath = { export const SandboxPath: OpenAPIPath = {
'/core/ai/sandbox/file': { '/core/ai/sandbox/list': {
post: { post: {
summary: '沙盒文件操作', summary: '列出沙盒目录',
description: '统一文件操作接口,支持列出目录(list)、读取文件(read)、写入文件(write)', description: '列出指定目录下的文件和子目录',
tags: [TagsMap.sandbox], tags: [TagsMap.sandbox],
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': {
schema: SandboxFileOperationBodySchema schema: SandboxListBodySchema
} }
} }
}, },
responses: { responses: {
200: { 200: {
description: '操作成功', description: '目录内容',
content: { content: {
'application/json': { '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 = { ...@@ -36,26 +93,45 @@ export const SandboxPath: OpenAPIPath = {
'/core/ai/sandbox/download': { '/core/ai/sandbox/download': {
post: { post: {
summary: '下载沙盒文件或目录', summary: '下载沙盒文件或目录',
description: '将指定路径的文件或目录打包为 zip 并下载', description: '下载指定路径的文件,或将目录打包为 ZIP 下载',
tags: [TagsMap.sandbox], tags: [TagsMap.sandbox],
requestBody: { requestBody: {
content: { content: {
'application/json': { 'application/json': {
schema: SandboxCheckExistBodySchema.extend({ schema: SandboxDownloadBodySchema
path: SandboxFileOperationBodySchema.options[0].shape.path
})
} }
} }
}, },
responses: { responses: {
200: { 200: {
description: '返回 zip 文件流',
content: { content: {
'application/octet-stream': { 'application/octet-stream': {
schema: { schema: SandboxDownloadResponseSchema
type: 'string', }
format: 'binary' }
} }
}
}
},
'/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
} }
} }
} }
......
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "catalog:", "lodash": "catalog:",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mime": "^4.1.0", "mime": "catalog:",
"minio": "catalog:", "minio": "catalog:",
"mongoose": "^8.10.1", "mongoose": "^8.10.1",
"multer": "2.1.0", "multer": "2.1.0",
......
...@@ -40,6 +40,7 @@ export const iconPaths = { ...@@ -40,6 +40,7 @@ export const iconPaths = {
'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'), 'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'),
'common/download': () => import('./icons/common/download.svg'), 'common/download': () => import('./icons/common/download.svg'),
'common/downloadLine': () => import('./icons/common/downloadLine.svg'), 'common/downloadLine': () => import('./icons/common/downloadLine.svg'),
'common/htmlPreview': () => import('./icons/common/htmlPreview.svg'),
'common/edit': () => import('./icons/common/edit.svg'), 'common/edit': () => import('./icons/common/edit.svg'),
'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'), 'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'),
'common/ellipsis': () => import('./icons/common/ellipsis.svg'), 'common/ellipsis': () => import('./icons/common/ellipsis.svg'),
......
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.33333 0C11.3834 0 14.6667 3.28325 14.6667 7.33333C14.6667 11.3834 11.3834 14.6667 7.33333 14.6667C3.28325 14.6667 0 11.3834 0 7.33333C0 3.28325 3.28325 0 7.33333 0ZM7.33333 13.3333C7.40944 13.3333 7.91401 13.2046 8.47653 11.967C8.7269 11.4162 8.93866 10.7391 9.08902 9.96879H5.57764C5.72801 10.7391 5.93977 11.4162 6.19013 11.967C6.75265 13.2046 7.25723 13.3333 7.33333 13.3333ZM5.39053 8.63546C5.35326 8.21779 5.33333 7.78249 5.33333 7.33333C5.33333 6.87631 5.35397 6.43363 5.3925 6.00927H9.27416C9.3127 6.43363 9.33333 6.87631 9.33333 7.33333C9.33333 7.78249 9.3134 8.21779 9.27614 8.63546H5.39053ZM10.4449 9.96879C10.2417 11.1292 9.90826 12.1473 9.48396 12.9364C10.9024 12.3916 12.0616 11.3236 12.7251 9.96879H10.4449ZM13.1916 8.63546H10.6143C10.6487 8.21286 10.6667 7.77771 10.6667 7.33333C10.6667 6.88126 10.6481 6.43874 10.6125 6.00927H13.1868C13.2827 6.43524 13.3333 6.87837 13.3333 7.33333C13.3333 7.78049 13.2844 8.21621 13.1916 8.63546ZM4.05239 8.63546H1.47502C1.38225 8.21621 1.33333 7.78049 1.33333 7.33333C1.33333 6.87837 1.38397 6.43524 1.47992 6.00927H4.05419C4.01859 6.43874 4 6.88126 4 7.33333C4 7.77771 4.01797 8.21286 4.05239 8.63546ZM1.94162 9.96879H4.22173C4.42494 11.1292 4.7584 12.1473 5.18271 12.9364C3.76423 12.3916 2.60511 11.3236 1.94162 9.96879ZM5.58195 4.67593H9.08472C8.93463 3.91473 8.72452 3.24519 8.47653 2.69962C7.91401 1.46208 7.40944 1.33333 7.33333 1.33333C7.25723 1.33333 6.75265 1.46208 6.19013 2.69962C5.94215 3.24519 5.73204 3.91473 5.58195 4.67593ZM10.4411 4.67593H12.7142C12.049 3.3315 10.8948 2.27213 9.48396 1.73028C9.90559 2.51435 10.2375 3.5246 10.4411 4.67593ZM5.18271 1.73028C4.76108 2.51435 4.42915 3.5246 4.2256 4.67593H1.95242C2.61764 3.3315 3.77189 2.27213 5.18271 1.73028Z" fill="currentColor"/>
</svg>
\ No newline at end of file
...@@ -105,7 +105,10 @@ ...@@ -105,7 +105,10 @@
"sandbox_entry_tooltip": "View Sandbox files", "sandbox_entry_tooltip": "View Sandbox files",
"sandbox_files": "Sandbox files", "sandbox_files": "Sandbox files",
"sandbox_no_file": "There are no files in the sandbox yet", "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_search_files": "Search files",
"sandbox_select_file_edit": "Select a file to edit", "sandbox_select_file_edit": "Select a file to edit",
"sandox.files": "Sandbox files", "sandox.files": "Sandbox files",
......
...@@ -105,10 +105,13 @@ ...@@ -105,10 +105,13 @@
"sandbox_entry_tooltip": "查看虚拟机文件", "sandbox_entry_tooltip": "查看虚拟机文件",
"sandbox_files": "虚拟机文件", "sandbox_files": "虚拟机文件",
"sandbox_no_file": "虚拟机里还没有文件", "sandbox_no_file": "虚拟机里还没有文件",
"sandbox_not_utf_file_tip": "无法预览该文件,请直接下载查看", "sandbox_binary_file_no_preview": "无法预览该文件,请直接下载查看",
"sandbox_search_files": "搜索文件", "sandbox_search_files": "搜索文件",
"sandbox_select_file_edit": "选择一个文件进行编辑", "sandbox_select_file_edit": "选择一个文件进行编辑",
"sandox.files": "虚拟机文件", "sandox.files": "虚拟机文件",
"sandbox_source": "原文",
"sandbox_preview": "预览",
"sandbox_html_preview_failed": "预览链接生成失败",
"search_results": "搜索结果", "search_results": "搜索结果",
"select": "选择", "select": "选择",
"select_file": "上传文件", "select_file": "上传文件",
......
...@@ -100,9 +100,12 @@ ...@@ -100,9 +100,12 @@
"response_rerank_tokens": "重排模型 Tokens", "response_rerank_tokens": "重排模型 Tokens",
"response_search_results": "搜索結果({{len}})", "response_search_results": "搜索結果({{len}})",
"sandbox_entry_tooltip": "查看虛擬機器文件", "sandbox_entry_tooltip": "查看虛擬機器文件",
"sandbox_source": "原文",
"sandbox_preview": "預覽",
"sandbox_html_preview_failed": "預覽連結產生失敗",
"sandbox_files": "虛擬機器文件", "sandbox_files": "虛擬機器文件",
"sandbox_no_file": "虛擬機器裡還沒有文件", "sandbox_no_file": "虛擬機器裡還沒有文件",
"sandbox_not_utf_file_tip": "無法預覽該文件,請直接下載查看", "sandbox_binary_file_no_preview": "無法預覽該文件,請直接下載查看",
"sandbox_search_files": "搜尋文件", "sandbox_search_files": "搜尋文件",
"sandbox_select_file_edit": "選擇一個文件進行編輯", "sandbox_select_file_edit": "選擇一個文件進行編輯",
"sandox.files": "虛擬機器文件", "sandox.files": "虛擬機器文件",
......
...@@ -84,6 +84,9 @@ catalogs: ...@@ -84,6 +84,9 @@ catalogs:
lodash: lodash:
specifier: 4.17.23 specifier: 4.17.23
version: 4.17.23 version: 4.17.23
mime:
specifier: ^4.1.0
version: 4.1.0
minio: minio:
specifier: 8.0.7 specifier: 8.0.7
version: 8.0.7 version: 8.0.7
...@@ -352,7 +355,7 @@ importers: ...@@ -352,7 +355,7 @@ importers:
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.11.0 version: 1.11.0
mime: mime:
specifier: ^4.1.0 specifier: 'catalog:'
version: 4.1.0 version: 4.1.0
minio: minio:
specifier: 'catalog:' specifier: 'catalog:'
...@@ -740,6 +743,9 @@ importers: ...@@ -740,6 +743,9 @@ importers:
mermaid: mermaid:
specifier: ^10.9.4 specifier: ^10.9.4
version: 10.9.4 version: 10.9.4
mime:
specifier: 'catalog:'
version: 4.1.0
minio: minio:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.0.7 version: 8.0.7
...@@ -5345,6 +5351,7 @@ packages: ...@@ -5345,6 +5351,7 @@ packages:
'@xmldom/xmldom@0.8.10': '@xmldom/xmldom@0.8.10':
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@zag-js/dom-query@0.31.1': '@zag-js/dom-query@0.31.1':
resolution: {integrity: sha512-oiuohEXAXhBxpzzNm9k2VHGEOLC1SXlXSbRPcfBZ9so5NRQUA++zCE7cyQJqGLTZR0t3itFLlZqDbYEXRrefwg==} resolution: {integrity: sha512-oiuohEXAXhBxpzzNm9k2VHGEOLC1SXlXSbRPcfBZ9so5NRQUA++zCE7cyQJqGLTZR0t3itFLlZqDbYEXRrefwg==}
......
...@@ -40,6 +40,7 @@ catalog: ...@@ -40,6 +40,7 @@ catalog:
js-yaml: ^4.1.1 js-yaml: ^4.1.1
json5: ^2.2.3 json5: ^2.2.3
lodash: 4.17.23 lodash: 4.17.23
mime: ^4.1.0
minio: 8.0.7 minio: 8.0.7
next: 16.2.1 next: 16.2.1
next-i18next: 15.4.2 next-i18next: 15.4.2
......
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
"lodash": "catalog:", "lodash": "catalog:",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mermaid": "^10.9.4", "mermaid": "^10.9.4",
"mime": "catalog:",
"minio": "catalog:", "minio": "catalog:",
"nanoid": "^5.1.3", "nanoid": "^5.1.3",
"next": "catalog:", "next": "catalog:",
......
...@@ -47,8 +47,9 @@ const Logs = () => { ...@@ -47,8 +47,9 @@ const Logs = () => {
borderColor={'myGray.200'} borderColor={'myGray.200'}
alignItems={'center'} alignItems={'center'}
> >
<Flex flex={'1 0 0'} gap={2}> <Flex flex={'1 0 0'} gap={2} overflowX={'auto'}>
<Flex <Flex
flexShrink={0}
px={2} px={2}
py={2} py={2}
cursor={'pointer'} cursor={'pointer'}
...@@ -57,6 +58,8 @@ const Logs = () => { ...@@ -57,6 +58,8 @@ const Logs = () => {
borderRadius={'8px'} borderRadius={'8px'}
bg={viewMode === 'chart' ? 'myGray.05' : 'transparent'} bg={viewMode === 'chart' ? 'myGray.05' : 'transparent'}
_hover={{ bg: 'myGray.05' }} _hover={{ bg: 'myGray.05' }}
alignItems={'center'}
whiteSpace={'nowrap'}
> >
<MyIcon name={'core/app/logsLight'} w={4} /> <MyIcon name={'core/app/logsLight'} w={4} />
<Box ml={2} mr={0.5}> <Box ml={2} mr={0.5}>
...@@ -65,6 +68,7 @@ const Logs = () => { ...@@ -65,6 +68,7 @@ const Logs = () => {
<ProTag /> <ProTag />
</Flex> </Flex>
<Flex <Flex
flexShrink={0}
px={2} px={2}
py={2} py={2}
cursor={'pointer'} cursor={'pointer'}
...@@ -74,6 +78,8 @@ const Logs = () => { ...@@ -74,6 +78,8 @@ const Logs = () => {
borderRadius={'8px'} borderRadius={'8px'}
bg={viewMode === 'table' ? 'myGray.05' : 'transparent'} bg={viewMode === 'table' ? 'myGray.05' : 'transparent'}
_hover={{ bg: 'myGray.05' }} _hover={{ bg: 'myGray.05' }}
alignItems={'center'}
whiteSpace={'nowrap'}
> >
<MyIcon name={'core/app/logsLight'} w={4} /> <MyIcon name={'core/app/logsLight'} w={4} />
{t('app:log_detail')} {t('app:log_detail')}
......
import type { import type {
SandboxFileOperationBody, SandboxListBody,
SandboxFileOperationResponse SandboxListResponse,
SandboxWriteBody,
SandboxWriteResponse,
SandboxReadBody,
SandboxDownloadBody,
SandboxCheckExistBody,
SandboxCheckExistResponse,
SandboxGetHtmlPreviewLinkBody,
SandboxGetHtmlPreviewLinkResponse
} from '@fastgpt/global/openapi/core/ai/sandbox/api'; } from '@fastgpt/global/openapi/core/ai/sandbox/api';
import { POST } from '@/web/common/api/request'; import { POST } from '@/web/common/api/request';
/** /**
* 列出目录文件 * 列出目录文件
*/ */
export const listSandboxFiles = async ( export const listSandboxFiles = async (data: SandboxListBody) =>
data: Omit<Extract<SandboxFileOperationBody, { action: 'list' }>, 'action'> POST<SandboxListResponse>('/core/ai/sandbox/list', data);
) =>
POST<Extract<SandboxFileOperationResponse, { action: 'list' }>>('/core/ai/sandbox/file', {
...data,
action: 'list' as const
});
/** /**
* 读取文件内容 * 写入文件内容
*/ */
export const readSandboxFile = async ( export const writeSandboxFile = async (data: SandboxWriteBody) =>
data: Omit<Extract<SandboxFileOperationBody, { action: 'read' }>, 'action'> POST<SandboxWriteResponse>('/core/ai/sandbox/write', data);
) =>
POST<Extract<SandboxFileOperationResponse, { action: 'read' }>>(
'/core/ai/sandbox/file',
{
...data,
action: 'read' as const
},
{
maxQuantity: 1
}
);
/** /**
* 写入文件内容 * 读取文件内容(内联预览)
*/ */
export const writeSandboxFile = async ( export const getSandboxFile = async (data: SandboxReadBody) => {
data: Omit<Extract<SandboxFileOperationBody, { action: 'write' }>, 'action'> const response = await fetch('/api/core/ai/sandbox/read', {
) => method: 'POST',
POST<Extract<SandboxFileOperationResponse, { action: 'write' }>>('/core/ai/sandbox/file', { headers: { 'Content-Type': 'application/json' },
...data, body: JSON.stringify(data)
action: 'write' as const
}); });
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: { export const downloadSandbox = async (data: SandboxDownloadBody) => {
appId: string;
chatId: string;
path?: string;
outLinkAuthData?: any;
}) => {
const response = await fetch('/api/core/ai/sandbox/download', { const response = await fetch('/api/core/ai/sandbox/download', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...@@ -67,23 +61,25 @@ export const downloadSandbox = async (data: { ...@@ -67,23 +61,25 @@ export const downloadSandbox = async (data: {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
// 从响应头获取文件名 const contentDisposition = response.headers.get('Content-Disposition') || '';
const contentDisposition = response.headers.get('Content-Disposition'); const match = contentDisposition.match(/filename="?([^";]+)"?/i);
const fileNameMatch = contentDisposition?.match(/filename="(.+)"/); const fileName = match ? decodeURIComponent(match[1]) : `download-${Date.now()}.zip`;
const fileName = fileNameMatch ? fileNameMatch[1] : `download-${Date.now()}.zip`;
a.download = fileName; a.download = fileName;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); a.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
/** /**
* 检查沙盒是否存在 * 检查沙盒是否存在
*/ */
export const checkSandboxExist = async (data: { export const checkSandboxExist = async (data: SandboxCheckExistBody) =>
appId: string; POST<SandboxCheckExistResponse>('/core/ai/sandbox/checkExist', data);
chatId: string;
outLinkAuthData?: any; /**
}) => POST<{ exists: boolean }>('/core/ai/sandbox/checkExist', data); * 获取 HTML 预览链接 (S3 托管)
*/
export const getHtmlPreviewLink = (data: SandboxGetHtmlPreviewLinkBody) =>
POST<SandboxGetHtmlPreviewLinkResponse>('/core/ai/sandbox/getHtmlPreviewLink', data);
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<NonNullable<Parameters<typeof Editor>[0]['onMount']>>[0];
type Props = {
activeFile: OpenedFile | undefined;
activeFilePath: string;
saving: boolean;
downloadingFile: boolean;
downloadCurrentFile: () => void;
saveFile: (path?: string) => void;
setOpenedFiles: React.Dispatch<React.SetStateAction<OpenedFile[]>>;
openedFiles: OpenedFile[];
editorRef: React.MutableRefObject<EditorInstance | undefined>;
isUpdatingRef: React.MutableRefObject<boolean>;
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 (
<Center h="full" bg="myGray.50" borderRadius="md" p={4}>
<Box position="relative" maxW="100%" maxH="100%">
<MyPhotoView src={content} alt={name} maxW="100%" maxH="100%" objectFit="contain" />
</Box>
</Center>
);
}
if (content.startsWith('blob:') && language === 'audio') {
return (
<Center h="full" bg="myGray.50" borderRadius="md">
<audio controls src={content}>
Your browser does not support the audio element.
</audio>
</Center>
);
}
if (content.startsWith('blob:') && language === 'video') {
return (
<Center h="full" bg="myGray.50" borderRadius="md" p={4}>
<video controls src={content} style={{ maxWidth: '100%', maxHeight: '100%' }}>
Your browser does not support the video element.
</video>
</Center>
);
}
// 无渲染器的二进制文件(如 PDF)
return t('chat:sandbox_binary_file_no_preview');
}
// 文本文件预览模式
if (viewMode === 'preview') {
const { language, content } = activeFile;
if (language === 'markdown') {
return (
<Box h="full" overflow="auto" bg="white">
<Markdown source={content} />
</Box>
);
}
if (language === 'svg') {
const svgUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(content)}`;
return (
<Center h="full" bg="myGray.50" borderRadius="md" p={4}>
<Box position="relative" maxW="100%" maxH="100%">
<MyPhotoView
src={svgUri}
alt={activeFile.name}
maxW="100%"
maxH="100%"
objectFit="contain"
/>
</Box>
</Center>
);
}
}
// 文本文件源码模式 (Monaco Editor)
return (
<Editor
height="100%"
language={activeFile.language || 'plaintext'}
value={activeFile.content || ''}
theme="vs-light"
options={{
minimap: { enabled: false },
overviewRulerLanes: 0,
overviewRulerBorder: false,
fontSize: 13,
lineNumbers: 'on',
lineNumbersMinChars: 4,
scrollBeyondLastLine: false,
automaticLayout: true,
lineHeight: 20,
fontFamily: "'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace",
tabSize: 2,
wordWrap: 'on',
smoothScrolling: true,
cursorBlinking: 'smooth',
renderLineHighlight: 'line',
scrollbar: {
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10
}
}}
onMount={(editor, monaco) => {
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 (
<Flex
flex={'1 0 0'}
m={3}
p={3}
border="base"
flexDirection="column"
borderRadius={'md'}
bg={'white'}
>
<Flex
align="center"
justify="space-between"
borderBottom={'sm'}
mt={'-3px'}
pb={'9px'}
mb={3}
>
<Box fontSize="20px" fontWeight="500" color="black">
{activeFile?.name || ''}
</Box>
<Flex align="center" gap={2}>
{/* HTML Preview Icon */}
{activeFile?.language === 'html' && (
<IconButton
size="sm"
icon={<MyIcon name="common/htmlPreview" w="16px" />}
aria-label={'Preview'}
isLoading={generatingLink}
onClick={handleHtmlPreview}
variant="whiteBase"
/>
)}
{/* Source/Preview Toggle Switch */}
{getSupportsPreviewToggle(activeFile?.language) && (
<FillRowTabs
list={[
{ label: t('chat:sandbox_source'), value: 'source' },
{ label: t('chat:sandbox_preview'), value: 'preview' }
]}
value={viewMode}
onChange={(v) => setViewMode(v as 'source' | 'preview')}
py="1"
px="2"
fontSize="xs"
/>
)}
{activeFilePath && (
<IconButton
size="sm"
icon={<MyIcon name="common/downloadLine" w="16px" />}
aria-label="Download"
onClick={downloadCurrentFile}
isLoading={downloadingFile}
variant="whiteBase"
/>
)}
{activeFile?.isDirty && (
<IconButton
size="sm"
icon={<MyIcon name="save" w="16px" />}
aria-label="Save"
onClick={() => saveFile()}
isLoading={saving}
variant="whiteBase"
/>
)}
</Flex>
</Flex>
<Box flex={1} borderColor="myGray.200" overflow="hidden">
{renderFileContent()}
</Box>
</Flex>
);
};
export default EditorContent;
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 (
<Box
flexShrink={0}
p={1}
bg="myGray.50"
borderRadius="md"
border="sm"
m={3}
mb={0}
overflowX="auto"
overflowY="hidden"
flexWrap="nowrap"
css={{
'&::-webkit-scrollbar': {
height: '6px'
},
'&::-webkit-scrollbar-thumb': {
background: '#E2E8F0',
borderRadius: '3px'
},
'&::-webkit-scrollbar-track': {
background: 'transparent'
}
}}
>
<Flex gap={2} alignItems={'center'}>
{openedFiles.map((file) => {
const active = activeFilePath === file.path;
return (
<Flex
key={file.path}
px={3}
py={1}
h={'22px'}
bg={active ? 'white' : 'myGray.25'}
borderRadius="4px"
align="center"
gap={1}
fontSize="12px"
cursor="pointer"
onClick={() => setActiveFilePath(file.path)}
maxW="150px"
flexShrink={0}
position="relative"
boxShadow={'1.5'}
_hover={{
bg: active ? 'white' : 'myGray.50'
}}
>
<MyIcon name={getIconByFilename(file.name)} w="16px" color="myGray.600" />
<Text
flex={1}
noOfLines={1}
fontWeight={active ? '500' : '400'}
color={active ? 'primary.700' : 'myGray.500'}
>
{file.name}
</Text>
{file.isDirty && <Box w="6px" h="6px" borderRadius="50%" bg="yellow.600" />}
<MyIcon
name="common/closeLight"
w="16px"
color="myGray.500"
_hover={{
color: 'primary.500'
}}
onClick={(e) => closeFile(file.path, e)}
/>
</Flex>
);
})}
</Flex>
</Box>
);
};
export default FileTabs;
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<string>;
loadingDirs: Set<string>;
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 (
<React.Fragment key={node.path}>
<Flex
pl={`${node.level * 16 + 4}px`}
pr={2}
py="6px"
cursor="pointer"
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
bg={isActive ? 'rgba(17, 24, 36, 0.05)' : 'transparent'}
borderRadius="4px"
onClick={() => {
if (node.type === 'file') {
openFile(node.path);
} else {
toggleDirectory(node);
}
}}
align="center"
fontSize="12px"
color={isActive ? 'myGray.600' : 'myGray.600'}
>
<Flex justify="center" align="center" w="16px" h="16px">
{shouldShowArrow ? (
isLoading ? (
<Spinner size="xs" color="primary.400" w="12px" h="12px" />
) : (
<MyIcon
name={isExpanded ? 'core/chat/chevronDown' : 'core/chat/chevronRight'}
w="16px"
color="myGray.500"
/>
)
) : null}
</Flex>
<MyIcon
mr={1}
ml={1}
name={node.type === 'directory' ? 'common/folderFill' : getIconByFilename(node.name)}
w="16px"
color={node.type === 'directory' ? '#EF7623' : 'myGray.600'}
/>
<Text
flex={1}
minW={0}
noOfLines={1}
overflow="hidden"
textOverflow="ellipsis"
fontWeight={isActive ? '600' : '400'}
letterSpacing="0.5px"
>
{node.name}
</Text>
</Flex>
{shouldShowArrow && isExpanded && node.children && node.children.map(renderTreeNode)}
</React.Fragment>
);
};
return (
<Box
flex="0 0 224px"
w={0}
borderRight="1px solid"
borderColor="myGray.200"
bg="myGray.25"
display="flex"
flexDirection="column"
>
<Box p={3}>
<InputGroup size="sm">
<InputLeftElement h="32px">
<MyIcon name="common/searchLight" w="16px" color="myGray.500" />
</InputLeftElement>
<Input
placeholder={t('chat:sandbox_search_files')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg="white"
fontSize="12px"
h="32px"
borderRadius="6px"
borderColor="myGray.200"
_placeholder={{ color: 'myGray.500' }}
/>
</InputGroup>
</Box>
<Box flex={1} overflowY="auto" overflowX="hidden" px={2}>
<VStack align="stretch" spacing="0" pb={2}>
{filteredTree.map(renderTreeNode)}
</VStack>
</Box>
<Button
m={2}
variant={'unstyled'}
bg={'myGray.150'}
color={'primary.700'}
fontSize={'12px'}
fontWeight={'500'}
leftIcon={<MyIcon name="common/downloadLine" w="16px" />}
isLoading={downloadingWorkspace}
onClick={downloadWorkspace}
display={'flex'}
alignItems={'center'}
_disabled={{
color: 'primary.700',
bg: 'myGray.150',
cursor: 'not-allowed',
_hover: {
color: 'primary.700',
bg: 'myGray.150'
}
}}
>
{t('chat:download_all_files')}
</Button>
</Box>
);
};
export default FileTree;
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { Props as EditorProps } from './Editor'; import type { Props as EditorProps } from './Editor';
import SandboxEditor from './Editor'; import SandboxEditor from './Editor';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
type Props = EditorProps & { type Props = EditorProps & {
onClose: () => void; onClose: () => void;
......
...@@ -45,55 +45,123 @@ export const getIconByFilename = (filename: string): IconNameType => { ...@@ -45,55 +45,123 @@ export const getIconByFilename = (filename: string): IconNameType => {
return 'core/app/sandbox/default'; return 'core/app/sandbox/default';
}; };
const extensionToLang: Record<string, string[]> = {
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<string, string>
);
// 获取文件语言 // 获取文件语言
export const getLanguageByExtension = (ext?: string): string => { export const getLanguageByFileName = (fileName: string): string => {
const langMap: Record<string, string> = { const ext = fileName.split('.').at(-1)?.toLowerCase();
py: 'python', return langMap[ext || ''] ?? 'plaintext';
js: 'javascript', };
ts: 'typescript',
jsx: 'javascript', /**
tsx: 'typescript', * 判断语言是否属于二进制
json: 'json', */
jsonc: 'json', export const getIsBinaryByLanguage = (language: string) => {
json5: 'json', return ['image', 'audio', 'video'].includes(language);
md: 'markdown', };
markdown: 'markdown',
html: 'html', /**
htm: 'html', * 支持源码/预览切换的语言列表
css: 'css', */
scss: 'scss', const previewableLanguages = ['markdown', 'svg'];
sass: 'sass',
less: 'less', export const getSupportsPreviewToggle = (language?: string) => {
sh: 'shell', return !!language && previewableLanguages.includes(language);
bash: 'shell', };
zsh: 'shell',
fish: 'shell', // Update tree node
yml: 'yaml', export const updateTreeNode = <
yaml: 'yaml', T extends {
xml: 'xml', type: 'file' | 'directory';
sql: 'sql', name: string;
go: 'go', path: string;
rs: 'rust', children?: T[];
java: 'java', loaded?: boolean;
c: 'c', }
cpp: 'cpp', >(
cc: 'cpp', tree: T[],
cxx: 'cpp', targetPath: string,
h: 'c', children: T[],
hpp: 'cpp', loaded: boolean = false
hxx: 'cpp', ): T[] => {
cs: 'csharp', return tree.map((node) => {
php: 'php', if (node.path === targetPath) {
rb: 'ruby', return { ...node, children, loaded } as T;
swift: 'swift', }
kt: 'kotlin', if (node.children) {
scala: 'scala', return { ...node, children: updateTreeNode(node.children, targetPath, children, loaded) };
lua: 'lua', }
r: 'r', return node;
toml: 'toml', });
ini: 'ini', };
conf: 'plaintext',
config: 'plaintext' // Filter tree
}; export const filterTree = <
return langMap[ext?.toLowerCase() || ''] || 'plaintext'; 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);
}; };
...@@ -2,23 +2,18 @@ import type { NextApiResponse } from 'next'; ...@@ -2,23 +2,18 @@ import type { NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next'; import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { authChatCrud } from '@/service/support/permission/auth/chat'; 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 archiver from 'archiver';
import { z } from 'zod'; import { SandboxDownloadBodySchema } from '@fastgpt/global/openapi/core/ai/sandbox/api';
import { OutLinkChatAuthSchema } from '@fastgpt/global/support/permission/chat'; import {
isSandboxPathDirectory,
const DownloadBodySchema = z.object({ getSandboxFileContent,
appId: z.string(), addDirectoryToArchive
chatId: z.string(), } from '@/service/core/sandbox/fileService';
path: z.string().default('.').describe('要下载的路径(文件或目录)'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
});
async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> { async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> {
const body = DownloadBodySchema.parse(req.body); const { appId, chatId, path, outLinkAuthData } = SandboxDownloadBodySchema.parse(req.body);
const { appId, chatId, path, outLinkAuthData } = body;
// 鉴权
const { uid } = await authChatCrud({ const { uid } = await authChatCrud({
req, req,
authToken: true, authToken: true,
...@@ -28,80 +23,39 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void ...@@ -28,80 +23,39 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void
...outLinkAuthData ...outLinkAuthData
}); });
// 创建沙盒实例 const sandbox = await getSandboxClient({ appId, userId: uid, chatId });
const sandbox = await getSandboxClient({
appId,
userId: uid,
chatId
});
await sandbox.ensureAvailable(); await sandbox.ensureAvailable();
// 通过 getFileInfo 准确判断路径是文件还是目录 const isDirectory = await isSandboxPathDirectory(sandbox, path);
const fileInfoMap = await sandbox.provider.getFileInfo([path]);
const fileInfo = fileInfoMap.get(path);
const isDirectory = fileInfo?.isDirectory ?? path.endsWith('/');
if (isDirectory) { if (isDirectory) {
// 下载目录为 ZIP const isRoot = path === '.' || path === '' || path === '/';
const fileName = path.split('/').filter(Boolean).pop() || 'workspace'; const rawFileName = isRoot ? 'workspace' : path.split('/').filter(Boolean).pop() || 'workspace';
res.setHeader('Content-Type', 'application/zip'); const fileName = encodeURIComponent(`${rawFileName}-${Date.now()}.zip`);
res.setHeader('Content-Disposition', `attachment; filename="${fileName}-${Date.now()}.zip"`);
const archive = archiver('zip', { res.setHeader('Content-Type', 'application/zip');
zlib: { level: 9 } res.setHeader(
}); 'Content-Disposition',
`attachment; filename="${fileName}"; filename*=UTF-8''${fileName}`
);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err) => { archive.on('error', (err) => {
throw err; throw err;
}); });
archive.pipe(res); archive.pipe(res);
// 递归添加文件到 ZIP
await addDirectoryToArchive(sandbox, archive, path, ''); await addDirectoryToArchive(sandbox, archive, path, '');
await archive.finalize(); await archive.finalize();
} else { } else {
// 下载单个文件 const { content, fileName } = await getSandboxFileContent(sandbox, path, false);
const results = await sandbox.provider.readFiles([path]); const encodedFileName = encodeURIComponent(fileName);
const result = results[0];
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-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); res.setHeader(
res.send(Buffer.from(result.content)); 'Content-Disposition',
} `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`
} );
res.send(content);
// 递归添加目录到 archive
async function addDirectoryToArchive(
sandbox: SandboxClient,
archive: archiver.Archiver,
dirPath: string,
archivePath: string
): Promise<void> {
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 });
}
}
} }
} }
......
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<SandboxFileOperationResponse>
): Promise<SandboxFileOperationResponse> {
// 解析请求体
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);
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';
// 在 <head> 中注入 CSP,禁止外部脚本加载,仅允许 inline(沙箱预览场景)
function injectCspMetaTag(html: string): string {
const cspMeta =
"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self' data: blob:; script-src 'none'; style-src 'unsafe-inline' 'self' data:;\">";
if (/<head[^>]*>/i.test(html)) {
return html.replace(/(<head[^>]*>)/i, `$1${cspMeta}`);
}
// 没有 <head> 标签时,直接前置
return cspMeta + html;
}
async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> {
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);
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<SandboxListResponse>
): Promise<SandboxListResponse> {
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);
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<void> {
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);
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<SandboxWriteResponse>
): Promise<SandboxWriteResponse> {
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);
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<SandboxFileEntry[]> {
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<void> {
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<boolean> {
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<SandboxFileContent> {
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<void> {
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 });
}
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment