Commit ff1a1a10 by DigHuang Committed by GitHub

feat(sandbox): support dynamic WS limits and multipart upload (#7205)

* feat(sandbox): support dynamic WS limits and multipart upload

* feat(sandbox-adapter): implement readFileStream for OpenSandbox and SealosDevbox
parent d68b7cec
...@@ -34,6 +34,44 @@ export const SandboxDownloadResponseSchema = z ...@@ -34,6 +34,44 @@ export const SandboxDownloadResponseSchema = z
.meta({ format: 'binary', description: '文件流或 ZIP 包' }); .meta({ format: 'binary', description: '文件流或 ZIP 包' });
/** /**
* 上传文件到沙盒工作区 - multipart/form-data 文档结构。
*/
export const SandboxUploadMultipartSchema = z.object({
file: z.any().meta({
format: 'binary',
description: '上传文件,multipart/form-data 的 file 字段'
}),
data: createOutLinkChatTargetInputSchema({
...SandboxBaseShape,
path: z.string().meta({
example: 'src/main.py',
description: '目标文件路径,相对于沙盒工作区根目录'
})
}).meta({
description: '上传参数,JSON 序列化后传入 multipart/form-data 的 data 字段'
})
});
export const SandboxUploadBodySchema = withSandboxTarget({
path: z.string().meta({
example: 'src/main.py',
description: '目标文件路径,相对于沙盒工作区根目录'
})
});
export const SandboxUploadResponseSchema = z.object({
path: z.string().meta({
example: 'src/main.py',
description: '上传成功后的目标文件路径'
}),
bytesWritten: z.number().int().nonnegative().meta({
example: 1024,
description: '写入字节数'
})
});
export type SandboxUploadBody = z.input<typeof SandboxUploadBodySchema>;
export type SandboxUploadRuntimeBody = z.output<typeof SandboxUploadBodySchema>;
export type SandboxUploadResponse = z.infer<typeof SandboxUploadResponseSchema>;
/**
* 检查沙盒是否存在 * 检查沙盒是否存在
*/ */
export const SandboxCheckExistBodyRawSchema = createOutLinkChatTargetInputSchema(SandboxBaseShape); export const SandboxCheckExistBodyRawSchema = createOutLinkChatTargetInputSchema(SandboxBaseShape);
......
...@@ -3,6 +3,8 @@ import { DevApiTagsMap } from '../../../tag'; ...@@ -3,6 +3,8 @@ import { DevApiTagsMap } from '../../../tag';
import { import {
SandboxDownloadBodyRawSchema, SandboxDownloadBodyRawSchema,
SandboxDownloadResponseSchema, SandboxDownloadResponseSchema,
SandboxUploadMultipartSchema,
SandboxUploadResponseSchema,
SandboxCheckExistBodyRawSchema, SandboxCheckExistBodyRawSchema,
SandboxCheckExistResponseSchema, SandboxCheckExistResponseSchema,
SandboxGetTicketBodyRawSchema, SandboxGetTicketBodyRawSchema,
...@@ -36,6 +38,35 @@ export const SandboxPath: OpenAPIPath = { ...@@ -36,6 +38,35 @@ export const SandboxPath: OpenAPIPath = {
} }
}, },
'/core/ai/sandbox/upload': {
post: {
summary: '上传文件到沙盒',
description:
'通过 multipart/form-data 上传文件,并写入指定沙盒工作区路径。`file` 字段为二进制文件,`data` 字段为 JSON 序列化的上传参数对象',
tags: [DevApiTagsMap.sandbox],
requestBody: {
content: {
'multipart/form-data': {
schema: SandboxUploadMultipartSchema,
encoding: {
data: { contentType: 'application/json' }
}
}
}
},
responses: {
200: {
description: '上传结果',
content: {
'application/json': {
schema: SandboxUploadResponseSchema
}
}
}
}
}
},
'/core/ai/sandbox/getHtmlPreviewLink': { '/core/ai/sandbox/getHtmlPreviewLink': {
post: { post: {
summary: '获取 HTML 文件预览链接', summary: '获取 HTML 文件预览链接',
......
...@@ -121,7 +121,11 @@ export function getSandboxAdapterConfig({ ...@@ -121,7 +121,11 @@ export function getSandboxAdapterConfig({
sessionId, sessionId,
workDirectory: profile.workDirectory, workDirectory: profile.workDirectory,
ideAgentBindAddr: serviceEnv.IDE_AGENT_BIND_ADDR, ideAgentBindAddr: serviceEnv.IDE_AGENT_BIND_ADDR,
ideAgentMaxFileBytes: getAgentSandboxMaxFileBytes() ideAgentMaxFileBytes: getAgentSandboxMaxFileBytes(),
ideAgentWsLimits: {
maxMessageBytes: serviceEnv.AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES,
maxFrameBytes: serviceEnv.AGENT_SANDBOX_WS_MAX_FRAME_BYTES
}
}) })
: undefined; : undefined;
......
...@@ -57,18 +57,25 @@ export function buildBaseSandboxRuntimeEnv({ ...@@ -57,18 +57,25 @@ export function buildBaseSandboxRuntimeEnv({
sessionId, sessionId,
workDirectory, workDirectory,
ideAgentBindAddr, ideAgentBindAddr,
ideAgentMaxFileBytes ideAgentMaxFileBytes,
ideAgentWsLimits
}: { }: {
sessionId: string; sessionId: string;
workDirectory: string; workDirectory: string;
ideAgentBindAddr: string; ideAgentBindAddr: string;
ideAgentMaxFileBytes: number; ideAgentMaxFileBytes: number;
ideAgentWsLimits: {
maxMessageBytes: number;
maxFrameBytes: number;
};
}): Record<string, string> { }): Record<string, string> {
return { return {
FASTGPT_SESSION_ID: sessionId, FASTGPT_SESSION_ID: sessionId,
FASTGPT_WORKDIR: workDirectory, FASTGPT_WORKDIR: workDirectory,
IDE_AGENT_ENABLED: 'true', IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: ideAgentBindAddr, IDE_AGENT_BIND_ADDR: ideAgentBindAddr,
FASTGPT_IDE_MAX_FILE_BYTES: String(ideAgentMaxFileBytes) FASTGPT_IDE_MAX_FILE_BYTES: String(ideAgentMaxFileBytes),
FASTGPT_IDE_WS_MAX_MESSAGE_BYTES: String(ideAgentWsLimits.maxMessageBytes),
FASTGPT_IDE_WS_MAX_FRAME_BYTES: String(ideAgentWsLimits.maxFrameBytes)
}; };
} }
...@@ -102,6 +102,18 @@ export const serviceEnv = createEnv({ ...@@ -102,6 +102,18 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS: IntSchema.min(1).max(600).default(30).meta({ AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS: IntSchema.min(1).max(600).default(30).meta({
description: 'Agent sandbox entrypoint 执行超时时间(秒)' description: 'Agent sandbox entrypoint 执行超时时间(秒)'
}), }),
AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES: IntSchema.min(1)
.default(64 * 1024 * 1024)
.meta({
description:
'Agent sandbox WebSocket 单消息上限(字节),由 FastGPT app 统一下发给 proxy/IDE Agent'
}),
AGENT_SANDBOX_WS_MAX_FRAME_BYTES: IntSchema.min(1)
.default(16 * 1024 * 1024)
.meta({
description:
'Agent sandbox WebSocket 单帧上限(字节),由 FastGPT app 统一下发给 proxy/IDE Agent'
}),
AGENT_SANDBOX_NPM_REGISTRY: z.string().optional(), AGENT_SANDBOX_NPM_REGISTRY: z.string().optional(),
AGENT_SANDBOX_PYPI_INDEX_URL: z.string().optional(), AGENT_SANDBOX_PYPI_INDEX_URL: z.string().optional(),
......
...@@ -13,6 +13,8 @@ const originalEnv = { ...@@ -13,6 +13,8 @@ const originalEnv = {
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO, AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG, AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_DISK_MB: process.env.AGENT_SANDBOX_DISK_MB, AGENT_SANDBOX_DISK_MB: process.env.AGENT_SANDBOX_DISK_MB,
AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES: process.env.AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES,
AGENT_SANDBOX_WS_MAX_FRAME_BYTES: process.env.AGENT_SANDBOX_WS_MAX_FRAME_BYTES,
AGENT_SANDBOX_PROXY_SECRET: process.env.AGENT_SANDBOX_PROXY_SECRET AGENT_SANDBOX_PROXY_SECRET: process.env.AGENT_SANDBOX_PROXY_SECRET
}; };
...@@ -63,6 +65,11 @@ describe('sandbox provider config', () => { ...@@ -63,6 +65,11 @@ describe('sandbox provider config', () => {
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
); );
vi.stubEnv('AGENT_SANDBOX_DISK_MB', originalEnv.AGENT_SANDBOX_DISK_MB); vi.stubEnv('AGENT_SANDBOX_DISK_MB', originalEnv.AGENT_SANDBOX_DISK_MB);
vi.stubEnv(
'AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES',
originalEnv.AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES
);
vi.stubEnv('AGENT_SANDBOX_WS_MAX_FRAME_BYTES', originalEnv.AGENT_SANDBOX_WS_MAX_FRAME_BYTES);
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', originalEnv.AGENT_SANDBOX_PROXY_SECRET); vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', originalEnv.AGENT_SANDBOX_PROXY_SECRET);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
...@@ -135,6 +142,8 @@ describe('sandbox provider config', () => { ...@@ -135,6 +142,8 @@ describe('sandbox provider config', () => {
vi.stubEnv('AGENT_SANDBOX_SEALOS_BASEURL', 'https://devbox.example.com'); vi.stubEnv('AGENT_SANDBOX_SEALOS_BASEURL', 'https://devbox.example.com');
vi.stubEnv('AGENT_SANDBOX_SEALOS_TOKEN', 'sealos-token'); vi.stubEnv('AGENT_SANDBOX_SEALOS_TOKEN', 'sealos-token');
vi.stubEnv('AGENT_SANDBOX_SEALOS_WORK_DIRECTORY', '/home/devbox/workspace'); vi.stubEnv('AGENT_SANDBOX_SEALOS_WORK_DIRECTORY', '/home/devbox/workspace');
vi.stubEnv('AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES', '67108864');
vi.stubEnv('AGENT_SANDBOX_WS_MAX_FRAME_BYTES', '16777216');
const { getSandboxAdapterConfig } = await loadSandboxConfigModule(); const { getSandboxAdapterConfig } = await loadSandboxConfigModule();
...@@ -162,7 +171,9 @@ describe('sandbox provider config', () => { ...@@ -162,7 +171,9 @@ describe('sandbox provider config', () => {
FASTGPT_WORKDIR: '/home/devbox/workspace', FASTGPT_WORKDIR: '/home/devbox/workspace',
IDE_AGENT_ENABLED: 'true', IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: '0.0.0.0:1318', IDE_AGENT_BIND_ADDR: '0.0.0.0:1318',
FASTGPT_IDE_MAX_FILE_BYTES: '536870912' FASTGPT_IDE_MAX_FILE_BYTES: '536870912',
FASTGPT_IDE_WS_MAX_MESSAGE_BYTES: '67108864',
FASTGPT_IDE_WS_MAX_FRAME_BYTES: '16777216'
} }
}); });
......
...@@ -91,7 +91,11 @@ describe('sandbox runtime profile', () => { ...@@ -91,7 +91,11 @@ describe('sandbox runtime profile', () => {
sessionId: 'session-1', sessionId: 'session-1',
workDirectory: runtimeProfile.workDirectory, workDirectory: runtimeProfile.workDirectory,
ideAgentBindAddr: '0.0.0.0:1318', ideAgentBindAddr: '0.0.0.0:1318',
ideAgentMaxFileBytes: 10 * 1024 * 1024 ideAgentMaxFileBytes: 10 * 1024 * 1024,
ideAgentWsLimits: {
maxMessageBytes: 64 * 1024 * 1024,
maxFrameBytes: 16 * 1024 * 1024
}
}), }),
metadata: { teamId: 'team-1' } metadata: { teamId: 'team-1' }
}) })
...@@ -101,7 +105,9 @@ describe('sandbox runtime profile', () => { ...@@ -101,7 +105,9 @@ describe('sandbox runtime profile', () => {
FASTGPT_WORKDIR: '/custom/devbox/workspace', FASTGPT_WORKDIR: '/custom/devbox/workspace',
IDE_AGENT_ENABLED: 'true', IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: '0.0.0.0:1318', IDE_AGENT_BIND_ADDR: '0.0.0.0:1318',
FASTGPT_IDE_MAX_FILE_BYTES: '10485760' FASTGPT_IDE_MAX_FILE_BYTES: '10485760',
FASTGPT_IDE_WS_MAX_MESSAGE_BYTES: '67108864',
FASTGPT_IDE_WS_MAX_FRAME_BYTES: '16777216'
}, },
metadata: { metadata: {
teamId: 'team-1' teamId: 'team-1'
......
...@@ -5,7 +5,9 @@ import type { ...@@ -5,7 +5,9 @@ import type {
SandboxGetTicketBody, SandboxGetTicketBody,
SandboxGetTicketResponse, SandboxGetTicketResponse,
SandboxGetHtmlPreviewLinkBody, SandboxGetHtmlPreviewLinkBody,
SandboxGetHtmlPreviewLinkResponse SandboxGetHtmlPreviewLinkResponse,
SandboxUploadBody,
SandboxUploadResponse
} from '@fastgpt/global/openapi/core/ai/sandbox/api'; } from '@fastgpt/global/openapi/core/ai/sandbox/api';
import { parseContentDispositionFilename } from '@fastgpt/global/common/file/tools'; import { parseContentDispositionFilename } from '@fastgpt/global/common/file/tools';
import { POST } from '@/web/common/api/request'; import { POST } from '@/web/common/api/request';
...@@ -25,6 +27,7 @@ type SandboxDownloadClientBody = SandboxClientBody<SandboxDownloadBody>; ...@@ -25,6 +27,7 @@ type SandboxDownloadClientBody = SandboxClientBody<SandboxDownloadBody>;
type SandboxCheckExistClientBody = SandboxClientBody<SandboxCheckExistBody>; type SandboxCheckExistClientBody = SandboxClientBody<SandboxCheckExistBody>;
type SandboxGetTicketClientBody = SandboxClientBody<SandboxGetTicketBody>; type SandboxGetTicketClientBody = SandboxClientBody<SandboxGetTicketBody>;
type SandboxGetHtmlPreviewLinkClientBody = SandboxClientBody<SandboxGetHtmlPreviewLinkBody>; type SandboxGetHtmlPreviewLinkClientBody = SandboxClientBody<SandboxGetHtmlPreviewLinkBody>;
type SandboxUploadClientBody = SandboxClientBody<SandboxUploadBody>;
/** /**
* share 模式下后端 schema 要求只传 outLinkAuthData,真实 appId 由鉴权解析。 * share 模式下后端 schema 要求只传 outLinkAuthData,真实 appId 由鉴权解析。
...@@ -63,13 +66,16 @@ export const getSandboxProxyWsUrl = ({ ...@@ -63,13 +66,16 @@ export const getSandboxProxyWsUrl = ({
/** /**
* 下载文件或目录(强制下载) * 下载文件或目录(强制下载)
*/ */
export const downloadSandbox = async (data: SandboxDownloadClientBody) => { const fetchSandboxDownloadResponse = (data: SandboxDownloadClientBody) =>
const response = await fetch('/api/core/ai/sandbox/download', { fetch('/api/core/ai/sandbox/download', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizeSandboxRequest(data)) body: JSON.stringify(normalizeSandboxRequest(data))
}); });
export const downloadSandbox = async (data: SandboxDownloadClientBody) => {
const response = await fetchSandboxDownloadResponse(data);
if (!response.ok) { if (!response.ok) {
throw new Error('Download failed'); throw new Error('Download failed');
} }
...@@ -91,6 +97,19 @@ export const downloadSandbox = async (data: SandboxDownloadClientBody) => { ...@@ -91,6 +97,19 @@ export const downloadSandbox = async (data: SandboxDownloadClientBody) => {
}; };
/** /**
* 读取沙盒文件原始字节,供大文件绕过 ide-agent WebSocket JSON RPC 读取。
*/
export const readSandboxFile = async (data: SandboxDownloadClientBody) => {
const response = await fetchSandboxDownloadResponse(data);
if (!response.ok) {
throw new Error('Read failed');
}
return new Uint8Array(await response.arrayBuffer());
};
/**
* 检查沙盒是否存在 * 检查沙盒是否存在
*/ */
export const checkSandboxExist = async (data: SandboxCheckExistClientBody) => export const checkSandboxExist = async (data: SandboxCheckExistClientBody) =>
...@@ -107,3 +126,19 @@ export const getHtmlPreviewLink = (data: SandboxGetHtmlPreviewLinkClientBody) => ...@@ -107,3 +126,19 @@ export const getHtmlPreviewLink = (data: SandboxGetHtmlPreviewLinkClientBody) =>
export const getSandboxTicket = async (data: SandboxGetTicketClientBody) => export const getSandboxTicket = async (data: SandboxGetTicketClientBody) =>
POST<SandboxGetTicketResponse>('/core/ai/sandbox/getTicket', normalizeSandboxRequest(data)); POST<SandboxGetTicketResponse>('/core/ai/sandbox/getTicket', normalizeSandboxRequest(data));
/**
* 通过主站 API 复用 sandbox provider 文件上传能力,避免走 ide-agent WebSocket base64。
*/
export const uploadSandboxFile = async ({
file,
...data
}: SandboxUploadClientBody & { file: File }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('data', JSON.stringify(normalizeSandboxRequest(data)));
return POST<SandboxUploadResponse>('/core/ai/sandbox/upload', formData, {
timeout: 10 * 60 * 1000
});
};
...@@ -87,6 +87,9 @@ const EditorContent = ({ ...@@ -87,6 +87,9 @@ const EditorContent = ({
const renderFileContent = () => { const renderFileContent = () => {
if (!activeFile) return null; if (!activeFile) return null;
const editorReadOnly = !canWrite || !!activeFile.readOnly;
if (activeFile.isLoading) return null;
// 非媒体文件 UTF-8 解码失败 → 走兜底(如 xlsx/zip 等真二进制) // 非媒体文件 UTF-8 解码失败 → 走兜底(如 xlsx/zip 等真二进制)
if (activeFile.isUnknown) { if (activeFile.isUnknown) {
...@@ -190,7 +193,7 @@ const EditorContent = ({ ...@@ -190,7 +193,7 @@ const EditorContent = ({
fontFamily: "'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace", fontFamily: "'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace",
tabSize: 2, tabSize: 2,
wordWrap: 'on', wordWrap: 'on',
readOnly: !canWrite, readOnly: editorReadOnly,
smoothScrolling: true, smoothScrolling: true,
cursorBlinking: 'smooth', cursorBlinking: 'smooth',
renderLineHighlight: 'line', renderLineHighlight: 'line',
...@@ -204,7 +207,7 @@ const EditorContent = ({ ...@@ -204,7 +207,7 @@ const EditorContent = ({
// 保存快捷键 Ctrl/Cmd + S // 保存快捷键 Ctrl/Cmd + S
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
if (!canWrite) return; if (editorReadOnly) return;
saveFile(); saveFile();
}); });
...@@ -212,7 +215,7 @@ const EditorContent = ({ ...@@ -212,7 +215,7 @@ const EditorContent = ({
editor.addCommand( editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS, monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS,
() => { () => {
if (!canWrite) return; if (editorReadOnly) return;
openedFilesRef.current?.forEach((file) => { openedFilesRef.current?.forEach((file) => {
if (file.isDirty) { if (file.isDirty) {
saveFile(file.path); saveFile(file.path);
...@@ -222,7 +225,7 @@ const EditorContent = ({ ...@@ -222,7 +225,7 @@ const EditorContent = ({
); );
}} }}
onChange={(value) => { onChange={(value) => {
if (!canWrite) return; if (editorReadOnly) return;
// 更新当前文件内容 // 更新当前文件内容
if (activeFilePath && value !== undefined && value !== activeFile?.content) { if (activeFilePath && value !== undefined && value !== activeFile?.content) {
setOpenedFiles((prev) => setOpenedFiles((prev) =>
...@@ -246,7 +249,9 @@ const EditorContent = ({ ...@@ -246,7 +249,9 @@ const EditorContent = ({
</Box> </Box>
{activeFile && ( {activeFile && (
<Flex alignItems={'center'} h={'20px'}> <Flex alignItems={'center'} h={'20px'}>
{activeFile.isDirty ? ( {activeFile.isLoading ? (
<MyIcon name={'common/loading'} w={'12px'} color={'myGray.500'} />
) : activeFile.isDirty ? (
<Flex alignItems={'center'} gap={1} color={'myGray.500'} fontSize={'xs'}> <Flex alignItems={'center'} gap={1} color={'myGray.500'} fontSize={'xs'}>
<MyIcon name={'common/loading'} w={'12px'} /> <MyIcon name={'common/loading'} w={'12px'} />
<Box>{t('common:core.app.saving')}</Box> <Box>{t('common:core.app.saving')}</Box>
......
...@@ -10,9 +10,12 @@ export type OpenedFile = { ...@@ -10,9 +10,12 @@ export type OpenedFile = {
language: string; language: string;
isBinary: boolean; isBinary: boolean;
isDirty: boolean; isDirty: boolean;
isLoading?: boolean;
// 通过 HTTP stream read 打开的文件,避免编辑保存时再次走 ide-agent WebSocket。
readOnly?: boolean;
// 非媒体文件 UTF-8 解码失败时为 true,前端走「无法预览」兜底 // 非媒体文件 UTF-8 解码失败时为 true,前端走「无法预览」兜底
isUnknown?: boolean; isUnknown?: boolean;
mtime?: number; etag?: string;
}; };
type Props = { type Props = {
...@@ -98,7 +101,7 @@ const FileTabs = ({ openedFiles, activeFilePath, setActiveFilePath, closeFile }: ...@@ -98,7 +101,7 @@ const FileTabs = ({ openedFiles, activeFilePath, setActiveFilePath, closeFile }:
userSelect={'none'} userSelect={'none'}
> >
<MyIcon <MyIcon
name={getIconByFilename(file.name)} name={file.isLoading ? 'common/loading' : getIconByFilename(file.name)}
fill="none" fill="none"
w="16px" w="16px"
h="16px" h="16px"
......
...@@ -76,6 +76,7 @@ export type FileItem = { ...@@ -76,6 +76,7 @@ export type FileItem = {
path: string; path: string;
type: 'file' | 'directory'; type: 'file' | 'directory';
size?: number; size?: number;
mtime?: number;
}; };
export type TreeNode = FileItem & { export type TreeNode = FileItem & {
...@@ -178,12 +179,14 @@ const DroppableRootBox = ({ ...@@ -178,12 +179,14 @@ const DroppableRootBox = ({
children, children,
activeNode, activeNode,
realOverDestPath, realOverDestPath,
onContextMenu onContextMenu,
onPointerDown
}: { }: {
children: React.ReactNode; children: React.ReactNode;
activeNode: TreeNode | null; activeNode: TreeNode | null;
realOverDestPath: string | null; realOverDestPath: string | null;
onContextMenu: (e: React.MouseEvent) => void; onContextMenu: (e: React.MouseEvent) => void;
onPointerDown: (e: React.PointerEvent) => void;
}) => { }) => {
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
id: '.' id: '.'
...@@ -210,6 +213,7 @@ const DroppableRootBox = ({ ...@@ -210,6 +213,7 @@ const DroppableRootBox = ({
} }
borderRadius="6px" borderRadius="6px"
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onPointerDown={onPointerDown}
> >
{children} {children}
</Box> </Box>
...@@ -259,10 +263,11 @@ const FileTree = ({ ...@@ -259,10 +263,11 @@ const FileTree = ({
const [activeNode, setActiveNode] = useState<TreeNode | null>(null); const [activeNode, setActiveNode] = useState<TreeNode | null>(null);
const [activeOverPath, setActiveOverPath] = useState<string | null>(null); const [activeOverPath, setActiveOverPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const effectiveSelectedPaths = const effectiveSelectedPaths = (() => {
enableMultiSelect && selectedPath && !selectedPaths.has(selectedPath) if (!enableMultiSelect) return selectedPaths;
? new Set([selectedPath]) if (!selectedPath) return new Set<string>();
: selectedPaths; return selectedPaths.has(selectedPath) ? selectedPaths : new Set([selectedPath]);
})();
const getOperationSelectedPaths = (basePath: string) => { const getOperationSelectedPaths = (basePath: string) => {
if (enableMultiSelect && effectiveSelectedPaths.has(basePath)) { if (enableMultiSelect && effectiveSelectedPaths.has(basePath)) {
...@@ -337,8 +342,23 @@ const FileTree = ({ ...@@ -337,8 +342,23 @@ const FileTree = ({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const treeRootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleOutsidePointerDown = (e: PointerEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (treeRootRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setSelectedPath('');
setSelectedPaths(new Set());
};
window.addEventListener('pointerdown', handleOutsidePointerDown, true);
return () => window.removeEventListener('pointerdown', handleOutsidePointerDown, true);
}, [setSelectedPath]);
// 点击任意地方关闭右键菜单(支持捕获阶段并过滤菜单内部点击,防止阻止冒泡导致菜单无法关闭) // 点击任意地方关闭右键菜单(支持捕获阶段并过滤菜单内部点击,防止阻止冒泡导致菜单无法关闭)
useEffect(() => { useEffect(() => {
if (!contextMenu) return; if (!contextMenu) return;
...@@ -566,6 +586,14 @@ const FileTree = ({ ...@@ -566,6 +586,14 @@ const FileTree = ({
}); });
}; };
const handleTreeBlankPointerDown = (e: React.PointerEvent) => {
const target = e.target as HTMLElement | null;
if (!target || target.closest('[data-file-tree-node="true"]')) return;
setSelectedPath('');
setSelectedPaths(new Set());
};
const handleCtxCreateFile = async () => { const handleCtxCreateFile = async () => {
if (!contextMenu) return; if (!contextMenu) return;
const node = contextMenu.node; const node = contextMenu.node;
...@@ -789,6 +817,7 @@ const FileTree = ({ ...@@ -789,6 +817,7 @@ const FileTree = ({
return ( return (
<Box <Box
ref={treeRootRef}
flex="1" flex="1"
w="100%" w="100%"
h="full" h="full"
...@@ -957,6 +986,7 @@ const FileTree = ({ ...@@ -957,6 +986,7 @@ const FileTree = ({
activeNode={activeNode} activeNode={activeNode}
realOverDestPath={realOverDestPath} realOverDestPath={realOverDestPath}
onContextMenu={handleBlankContextMenu} onContextMenu={handleBlankContextMenu}
onPointerDown={handleTreeBlankPointerDown}
> >
{isLoading ? ( {isLoading ? (
<FileTreeSkeleton /> <FileTreeSkeleton />
......
...@@ -98,6 +98,7 @@ const FileTreeNode = ({ ...@@ -98,6 +98,7 @@ const FileTreeNode = ({
setDragRef(el); setDragRef(el);
setDropRef(el); setDropRef(el);
}} }}
data-file-tree-node="true"
pl={`${node.level * 16 + 4}px`} pl={`${node.level * 16 + 4}px`}
pr={2} pr={2}
h="28px" h="28px"
......
...@@ -3,7 +3,14 @@ import SandboxEditorModal from '@/pageComponents/chat/SandboxEditor/modal'; ...@@ -3,7 +3,14 @@ import SandboxEditorModal from '@/pageComponents/chat/SandboxEditor/modal';
import type { IconButtonProps } from '@chakra-ui/react'; import type { IconButtonProps } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { checkSandboxExist, downloadSandbox, getSandboxProxyWsUrl, getSandboxTicket } from './api'; import {
checkSandboxExist,
downloadSandbox,
getSandboxProxyWsUrl,
getSandboxTicket,
readSandboxFile,
uploadSandboxFile
} from './api';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
...@@ -42,6 +49,8 @@ const INITIAL_TREE_MAX_DEPTH = 20; ...@@ -42,6 +49,8 @@ const INITIAL_TREE_MAX_DEPTH = 20;
const RPC_TIMEOUT_MS = 30000; const RPC_TIMEOUT_MS = 30000;
const DEFAULT_MAX_FILE_SIZE_MB = 10; const DEFAULT_MAX_FILE_SIZE_MB = 10;
const INVALID_PATH_SEGMENT_CHARS = /[\/\\\u0000]/; const INVALID_PATH_SEGMENT_CHARS = /[\/\\\u0000]/;
const RPC_READ_FILE_HTTP_MIN_BYTES = 5 * 1024 * 1024;
const SANDBOX_RPC_FILE_TOO_LARGE_CODE = -32004;
type RefreshWorkspaceOptions = { type RefreshWorkspaceOptions = {
preserveExpandedDirs?: boolean; preserveExpandedDirs?: boolean;
...@@ -56,11 +65,22 @@ type SandboxDirEntry = { ...@@ -56,11 +65,22 @@ type SandboxDirEntry = {
type SandboxReadFileResponse = { type SandboxReadFileResponse = {
content: string; content: string;
mtime?: number; etag?: string;
}; };
type SandboxWriteFileResponse = { type SandboxWriteFileResponse = {
mtime?: number; etag?: string;
};
type LoadFileResult = {
content: string;
isUnknown: boolean;
etag?: string;
readOnly?: boolean;
};
type SandboxRpcError = Error & {
rpcCode?: number;
}; };
type SandboxReadDirRecursiveResponse = { type SandboxReadDirRecursiveResponse = {
...@@ -77,6 +97,22 @@ const encodeBase64 = (content: string) => { ...@@ -77,6 +97,22 @@ const encodeBase64 = (content: string) => {
return window.btoa(binary); return window.btoa(binary);
}; };
const isReadFileStreamFallbackError = (error: unknown) => {
if (!error || typeof error !== 'object') return false;
const structuredError = error as Partial<SandboxRpcError>;
return structuredError.rpcCode === SANDBOX_RPC_FILE_TOO_LARGE_CODE;
};
const shouldReadFileByHttp = (fileSize?: number) =>
typeof fileSize === 'number' && fileSize > RPC_READ_FILE_HTTP_MIN_BYTES;
const createSandboxRpcError = (message: string, rpcCode?: number): SandboxRpcError =>
Object.assign(new Error(message), {
...(typeof rpcCode === 'number' ? { rpcCode } : {})
});
const isValidPathSegment = (name: string) => const isValidPathSegment = (name: string) =>
!!name && name !== '.' && name !== '..' && !INVALID_PATH_SEGMENT_CHARS.test(name); !!name && name !== '.' && name !== '..' && !INVALID_PATH_SEGMENT_CHARS.test(name);
...@@ -358,11 +394,13 @@ export const useSandboxFileStore = ({ ...@@ -358,11 +394,13 @@ export const useSandboxFileStore = ({
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const openedFilesRef = useLatest(openedFiles); const openedFilesRef = useLatest(openedFiles);
const fileTreeRef = useLatest(fileTree);
const expandedDirsRef = useLatest(expandedDirs); const expandedDirsRef = useLatest(expandedDirs);
const onErrorRef = useLatest(onError); const onErrorRef = useLatest(onError);
const loadingFilePathsRef = useRef<Set<string>>(new Set()); const loadingFilePathsRef = useRef<Set<string>>(new Set());
const isRefreshingWorkspaceRef = useRef(false); const isRefreshingWorkspaceRef = useRef(false);
const hasPendingWorkspaceRefreshRef = useRef(false); const hasPendingWorkspaceRefreshRef = useRef(false);
const saveFileQueuesRef = useRef<Map<string, Promise<{ etag?: string } | undefined>>>(new Map());
// ==================== WebSocket 长连接及 JSON-RPC 2.0 ==================== // ==================== WebSocket 长连接及 JSON-RPC 2.0 ====================
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
...@@ -463,28 +501,64 @@ export const useSandboxFileStore = ({ ...@@ -463,28 +501,64 @@ export const useSandboxFileStore = ({
// 读取文件内容 - 根据 language 决定解码策略 // 读取文件内容 - 根据 language 决定解码策略
const loadFile = useCallback( const loadFile = useCallback(
async ( async (filePath: string, language: string): Promise<LoadFileResult> => {
filePath: string,
language: string
): Promise<{ content: string; isUnknown: boolean; mtime?: number }> => {
const isBinary = getIsBinaryByLanguage(language); const isBinary = getIsBinaryByLanguage(language);
const res = await rpcCall<SandboxReadFileResponse>('fs/read_file', { path: filePath }); const decodeFileBytes = (rawBytes: Uint8Array, etag?: string): LoadFileResult => {
const rawBytes = Uint8Array.from(window.atob(res.content), (c) => c.charCodeAt(0));
if (!isBinary) { if (!isBinary) {
try { try {
const content = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes); const content = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes);
return { content, isUnknown: false, mtime: res.mtime }; return { content, isUnknown: false, etag };
} catch { } catch {
return { content: '', isUnknown: true, mtime: res.mtime }; return { content: '', isUnknown: true, etag };
} }
} else { }
const mimeType = getMimeTypeByFileName(filePath.split('/').pop() || ''); const mimeType = getMimeTypeByFileName(filePath.split('/').pop() || '');
const blob = new Blob([rawBytes], { type: mimeType }); const blobBytes = rawBytes.buffer.slice(
return { content: URL.createObjectURL(blob), isUnknown: false, mtime: res.mtime }; rawBytes.byteOffset,
rawBytes.byteOffset + rawBytes.byteLength
) as ArrayBuffer;
const blob = new Blob([blobBytes], { type: mimeType });
return { content: URL.createObjectURL(blob), isUnknown: false, etag };
};
const getKnownFileSize = () => {
const fileNode = findNodeByPath(fileTreeRef.current ?? [], filePath);
return fileNode?.type === 'file' ? fileNode.size : undefined;
};
const readFileByHttp = async (): Promise<LoadFileResult> => {
const rawBytes = await readSandboxFile({
...sandboxTarget,
chatId,
outLinkAuthData,
path: filePath
});
const result = decodeFileBytes(rawBytes);
return {
...result,
readOnly: !isBinary && !result.isUnknown
};
};
if (shouldReadFileByHttp(getKnownFileSize())) {
return readFileByHttp();
}
try {
const res = await rpcCall<SandboxReadFileResponse>('fs/read_file', { path: filePath });
const rawBytes = Uint8Array.from(window.atob(res.content), (c) => c.charCodeAt(0));
return decodeFileBytes(rawBytes, res.etag);
} catch (error) {
// 只有 IDE Agent 明确返回文件过大错误时,才从 RPC 降级为 HTTP 读取。
if (!isReadFileStreamFallbackError(error)) {
throw error;
}
return readFileByHttp();
} }
}, },
[rpcCall] [chatId, fileTreeRef, outLinkAuthData, rpcCall, sandboxTarget]
); );
const refreshWorkspace = useCallback( const refreshWorkspace = useCallback(
...@@ -531,28 +605,33 @@ export const useSandboxFileStore = ({ ...@@ -531,28 +605,33 @@ export const useSandboxFileStore = ({
if (!hasPendingWorkspaceRefreshRef.current) { if (!hasPendingWorkspaceRefreshRef.current) {
const filesToReload = const filesToReload =
openedFilesRef.current openedFilesRef.current
?.filter((f) => !f.isDirty && !f.isBinary && !f.isUnknown) ?.filter((f) => !f.isDirty && !f.isLoading && !f.isBinary && !f.isUnknown)
.map((f) => ({ .map((f) => ({
path: f.path, path: f.path,
language: f.language, language: f.language,
content: f.content, content: f.content,
mtime: f.mtime etag: f.etag
})) || []; })) || [];
if (filesToReload.length > 0) { if (filesToReload.length > 0) {
const reloadResults = await Promise.allSettled( const reloadResults = await Promise.allSettled(
filesToReload.map(async (f) => { filesToReload.map(async (f) => {
const { content, isUnknown, mtime } = await loadFile(f.path, f.language); const { content, isUnknown, etag, readOnly } = await loadFile(f.path, f.language);
return { content, isUnknown, mtime }; return { content, isUnknown, etag, readOnly };
}) })
); );
const updates = new Map< const updates = new Map<
string, string,
{ content: string; isUnknown: boolean; mtime?: number } {
content: string;
isUnknown: boolean;
etag?: string;
readOnly?: boolean;
}
>(); >();
const snapshots = new Map( const snapshots = new Map(
filesToReload.map((f) => [f.path, { content: f.content, mtime: f.mtime }]) filesToReload.map((f) => [f.path, { content: f.content, etag: f.etag }])
); );
reloadResults.forEach((result, idx) => { reloadResults.forEach((result, idx) => {
const f = filesToReload[idx]; const f = filesToReload[idx];
...@@ -573,13 +652,14 @@ export const useSandboxFileStore = ({ ...@@ -573,13 +652,14 @@ export const useSandboxFileStore = ({
snapshot && snapshot &&
!item.isDirty && !item.isDirty &&
item.content === snapshot.content && item.content === snapshot.content &&
item.mtime === snapshot.mtime item.etag === snapshot.etag
) { ) {
return { return {
...item, ...item,
content: update.content, content: update.content,
isUnknown: update.isUnknown, isUnknown: update.isUnknown,
mtime: update.mtime, etag: update.etag,
readOnly: update.readOnly,
isDirty: false isDirty: false
}; };
} }
...@@ -707,7 +787,12 @@ export const useSandboxFileStore = ({ ...@@ -707,7 +787,12 @@ export const useSandboxFileStore = ({
if (pending) { if (pending) {
pendingRpcRequests.delete(rpcId); pendingRpcRequests.delete(rpcId);
if (data.error) { if (data.error) {
pending.reject(new Error(data.error.message || 'Sandbox RPC failed')); pending.reject(
createSandboxRpcError(
data.error.message || 'Sandbox RPC failed',
data.error.code
)
);
} else { } else {
pending.resolve(data.result); pending.resolve(data.result);
} }
...@@ -730,12 +815,13 @@ export const useSandboxFileStore = ({ ...@@ -730,12 +815,13 @@ export const useSandboxFileStore = ({
wsRef.current = null; wsRef.current = null;
setIsWsConnected(false); setIsWsConnected(false);
stoppedConnectErrorRef.current = null; stoppedConnectErrorRef.current = null;
rejectConnectRef.current?.(new Error(closeMessage)); const closeError = new Error(closeMessage);
rejectConnectRef.current?.(closeError);
resetConnectPromise(); resetConnectPromise();
// 拒绝并清空当前所有积压的 pending RPC 请求,防止网络瞬断或代理重启时前端 Promise 永久 Pending 卡死 // 拒绝并清空当前所有积压的 pending RPC 请求,防止网络瞬断或代理重启时前端 Promise 永久 Pending 卡死
pendingRpcRequests.forEach((req) => { pendingRpcRequests.forEach((req) => {
req.reject(new Error(closeMessage)); req.reject(closeError);
}); });
pendingRpcRequests.clear(); pendingRpcRequests.clear();
...@@ -879,36 +965,96 @@ export const useSandboxFileStore = ({ ...@@ -879,36 +965,96 @@ export const useSandboxFileStore = ({
// refreshWorkspace 已提升至上层长连接前以供事件驱动调用 // refreshWorkspace 已提升至上层长连接前以供事件驱动调用
// 保存指定或当前文件 /**
const saveFile = useCallback( * 保存打开文件的一次内容快照。
async (filePath?: string) => { * 保存期间若用户继续编辑,只推进 etag,不清理 dirty,避免下一轮保存继续携带旧 old_etag。
if (!canWrite) return; */
const saveOpenedFile = useCallback(
const targetPath = filePath || activeFilePath; async (
if (!targetPath) return; targetPath: string,
previousSavedEtag?: string
): Promise<{ etag?: string } | undefined> => {
const targetFile = openedFilesRef.current?.find((f) => f.path === targetPath); const targetFile = openedFilesRef.current?.find((f) => f.path === targetPath);
if (!targetFile || targetFile.isBinary || targetFile.isUnknown) return; if (
!targetFile ||
targetFile.isLoading ||
targetFile.isBinary ||
targetFile.isUnknown ||
targetFile.readOnly
) {
return;
}
try {
const savedContent = targetFile.content; const savedContent = targetFile.content;
const savedMtime = targetFile.mtime; const snapshotEtag = targetFile.etag;
const oldEtag = previousSavedEtag ?? snapshotEtag;
const b64 = encodeBase64(savedContent); const b64 = encodeBase64(savedContent);
const res = await rpcCall<SandboxWriteFileResponse>('fs/write_file', { const res = await rpcCall<SandboxWriteFileResponse>('fs/write_file', {
path: targetPath, path: targetPath,
content: b64, content: b64,
old_mtime: savedMtime old_etag: oldEtag
}); });
const newMtime = res.mtime; const newEtag = res.etag;
// 保存成功后只清理同一份内容,避免保存期间继续编辑被误标为已保存。
setOpenedFiles((prev) => setOpenedFiles((prev) =>
prev.map((f) => prev.map((f) => {
f.path === targetPath && f.content === savedContent && f.mtime === savedMtime if (f.path !== targetPath) return f;
? { ...f, isDirty: false, mtime: newMtime }
: f const isSameSavedContent = f.content === savedContent;
) const shouldRefreshEtag =
isSameSavedContent || f.etag === snapshotEtag || f.etag === oldEtag;
if (!shouldRefreshEtag) return f;
// 保存期间继续编辑时,不能清理 dirty,但必须推进 etag,避免后续保存带旧 old_etag。
if (!isSameSavedContent) {
return { ...f, etag: newEtag };
}
return { ...f, isDirty: false, etag: newEtag };
})
);
return { etag: newEtag ?? oldEtag };
},
[openedFilesRef, rpcCall]
);
/**
* 同一路径的写入必须串行化。
* 自动保存和快捷键保存可能重叠触发,后续任务需要复用前一次写入返回的新 etag。
*/
const enqueueSaveFile = useCallback(
(targetPath: string) => {
const previousTask = saveFileQueuesRef.current.get(targetPath);
const currentTask = (async () => {
const previousResult = await previousTask?.catch(() => undefined);
return saveOpenedFile(targetPath, previousResult?.etag);
})();
saveFileQueuesRef.current.set(targetPath, currentTask);
void currentTask
.finally(() => {
if (saveFileQueuesRef.current.get(targetPath) === currentTask) {
saveFileQueuesRef.current.delete(targetPath);
}
})
.catch(() => undefined);
return currentTask;
},
[saveOpenedFile]
); );
// 保存指定或当前文件
const saveFile = useCallback(
async (filePath?: string) => {
if (!canWrite) return;
const targetPath = filePath || activeFilePath;
if (!targetPath) return;
try {
await enqueueSaveFile(targetPath);
} catch (err) { } catch (err) {
console.error('Failed to save file:', err); console.error('Failed to save file:', err);
toast({ toast({
...@@ -918,7 +1064,7 @@ export const useSandboxFileStore = ({ ...@@ -918,7 +1064,7 @@ export const useSandboxFileStore = ({
}); });
} }
}, },
[activeFilePath, canWrite, openedFilesRef, rpcCall, toast, t] [activeFilePath, canWrite, enqueueSaveFile, toast, t]
); );
// 批量全部保存方法 // 批量全部保存方法
...@@ -926,33 +1072,12 @@ export const useSandboxFileStore = ({ ...@@ -926,33 +1072,12 @@ export const useSandboxFileStore = ({
if (!canWrite) return; if (!canWrite) return;
const dirtyFiles = const dirtyFiles =
openedFilesRef.current?.filter((f) => f.isDirty && !f.isBinary && !f.isUnknown) || []; openedFilesRef.current?.filter(
(f) => f.isDirty && !f.isLoading && !f.isBinary && !f.isUnknown && !f.readOnly
) || [];
if (dirtyFiles.length === 0) return; if (dirtyFiles.length === 0) return;
const results = await Promise.allSettled( const results = await Promise.allSettled(dirtyFiles.map((df) => enqueueSaveFile(df.path)));
dirtyFiles.map(async (df) => {
const currentFile = openedFilesRef.current?.find((f) => f.path === df.path);
if (!currentFile || currentFile.isBinary || currentFile.isUnknown) return;
const savedContent = currentFile.content;
const savedMtime = currentFile.mtime;
const b64 = encodeBase64(savedContent);
const res = await rpcCall<SandboxWriteFileResponse>('fs/write_file', {
path: df.path,
content: b64,
old_mtime: savedMtime
});
const newMtime = res.mtime;
setOpenedFiles((prev) =>
prev.map((f) =>
f.path === df.path && f.content === savedContent && f.mtime === savedMtime
? { ...f, isDirty: false, mtime: newMtime }
: f
)
);
})
);
const failedResult = results.find((result) => result.status === 'rejected'); const failedResult = results.find((result) => result.status === 'rejected');
if (failedResult?.status === 'rejected') { if (failedResult?.status === 'rejected') {
...@@ -964,11 +1089,13 @@ export const useSandboxFileStore = ({ ...@@ -964,11 +1089,13 @@ export const useSandboxFileStore = ({
}); });
throw failedResult.reason; throw failedResult.reason;
} }
}, [canWrite, openedFilesRef, rpcCall, toast, t]); }, [canWrite, enqueueSaveFile, openedFilesRef, toast, t]);
// 500ms 防抖自动保存脏文件 // 500ms 防抖自动保存脏文件
useEffect(() => { useEffect(() => {
const dirtyFiles = openedFiles.filter((f) => f.isDirty && !f.isBinary && !f.isUnknown); const dirtyFiles = openedFiles.filter(
(f) => f.isDirty && !f.isLoading && !f.isBinary && !f.isUnknown && !f.readOnly
);
if (dirtyFiles.length === 0) return; if (dirtyFiles.length === 0) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
...@@ -994,11 +1121,9 @@ export const useSandboxFileStore = ({ ...@@ -994,11 +1121,9 @@ export const useSandboxFileStore = ({
// 打开文件 // 打开文件
const openFile = async (filePath: string) => { const openFile = async (filePath: string) => {
if (!filePath) return; if (!filePath) return;
// 检查是否已打开或正在加载
const existingFile = openedFiles.find((f) => f.path === filePath);
const isAlreadyLoading = loadingFilePathsRef.current.has(filePath);
if (existingFile || isAlreadyLoading) { const existingFile = openedFiles.find((f) => f.path === filePath);
if (existingFile) {
setActiveFilePath(filePath); setActiveFilePath(filePath);
setSelectedPath(filePath); setSelectedPath(filePath);
return; return;
...@@ -1007,7 +1132,6 @@ export const useSandboxFileStore = ({ ...@@ -1007,7 +1132,6 @@ export const useSandboxFileStore = ({
const fileName = filePath.split('/').pop() || ''; const fileName = filePath.split('/').pop() || '';
const language = getLanguageByFileName(fileName); const language = getLanguageByFileName(fileName);
const isBinary = getIsBinaryByLanguage(language); const isBinary = getIsBinaryByLanguage(language);
// 先乐观推送临时 Tab // 先乐观推送临时 Tab
const tempFile: OpenedFile = { const tempFile: OpenedFile = {
path: filePath, path: filePath,
...@@ -1015,16 +1139,22 @@ export const useSandboxFileStore = ({ ...@@ -1015,16 +1139,22 @@ export const useSandboxFileStore = ({
content: '', content: '',
language, language,
isBinary, isBinary,
isLoading: true,
isDirty: false isDirty: false
}; };
setOpenedFiles((prev) => [...prev, tempFile]); const isAlreadyLoading = loadingFilePathsRef.current.has(filePath);
setOpenedFiles((prev) =>
prev.some((file) => file.path === filePath) ? prev : [...prev, tempFile]
);
setActiveFilePath(filePath); setActiveFilePath(filePath);
setSelectedPath(filePath); setSelectedPath(filePath);
if (isAlreadyLoading) return;
try { try {
loadingFilePathsRef.current.add(filePath); loadingFilePathsRef.current.add(filePath);
const { content, isUnknown, mtime } = await loadFile(filePath, language); const { content, isUnknown, etag, readOnly } = await loadFile(filePath, language);
// 加载成功后更新 Tab 状态 // 加载成功后更新 Tab 状态
setOpenedFiles((prev) => setOpenedFiles((prev) =>
...@@ -1034,7 +1164,9 @@ export const useSandboxFileStore = ({ ...@@ -1034,7 +1164,9 @@ export const useSandboxFileStore = ({
...f, ...f,
content, content,
isUnknown, isUnknown,
mtime isLoading: false,
readOnly,
etag
} }
: f : f
) )
...@@ -1187,6 +1319,7 @@ export const useSandboxFileStore = ({ ...@@ -1187,6 +1319,7 @@ export const useSandboxFileStore = ({
content: '', content: '',
language, language,
isBinary, isBinary,
isLoading: true,
isDirty: false isDirty: false
}; };
setOpenedFiles((prev) => [...prev, tempFile]); setOpenedFiles((prev) => [...prev, tempFile]);
...@@ -1197,7 +1330,13 @@ export const useSandboxFileStore = ({ ...@@ -1197,7 +1330,13 @@ export const useSandboxFileStore = ({
// 3. 异步发送请求,若失败则回滚状态 // 3. 异步发送请求,若失败则回滚状态
try { try {
if (type === 'file') { if (type === 'file') {
await rpcCall('fs/write_file', { path: fullPath, content: encodeBase64('') }); const res = await rpcCall<SandboxWriteFileResponse>('fs/write_file', {
path: fullPath,
content: encodeBase64('')
});
setOpenedFiles((prev) =>
prev.map((f) => (f.path === fullPath ? { ...f, isLoading: false, etag: res.etag } : f))
);
} else { } else {
await rpcCall('fs/mkdir', { path: fullPath }); await rpcCall('fs/mkdir', { path: fullPath });
} }
...@@ -1400,17 +1539,7 @@ export const useSandboxFileStore = ({ ...@@ -1400,17 +1539,7 @@ export const useSandboxFileStore = ({
// 上传文件 // 上传文件
const onUploadFiles = useCallback( const onUploadFiles = useCallback(
async (files: FileList, targetDirPath: string) => { async (files: FileList, targetDirPath: string) => {
let targetLevel = 0; const uploadTasks: { path: string; file: File }[] = [];
if (targetDirPath !== '.') {
const parentNode = findNodeByPath(fileTree, targetDirPath);
if (parentNode) {
targetLevel = parentNode.level + 1;
} else {
targetLevel = targetDirPath.split('/').length;
}
}
const uploadTasks: { path: string; content: string; newNode: TreeNode }[] = [];
const pendingPaths = new Set<string>(); const pendingPaths = new Set<string>();
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
...@@ -1437,16 +1566,6 @@ export const useSandboxFileStore = ({ ...@@ -1437,16 +1566,6 @@ export const useSandboxFileStore = ({
}); });
return; return;
} }
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const resultStr = reader.result as string;
const base64 = resultStr ? resultStr.split(',')[1] : '';
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const path = targetDirPath === '.' ? file.name : `${targetDirPath}/${file.name}`; const path = targetDirPath === '.' ? file.name : `${targetDirPath}/${file.name}`;
if (pendingPaths.has(path) || findNodeByPath(fileTree, path)) { if (pendingPaths.has(path) || findNodeByPath(fileTree, path)) {
toast({ toast({
...@@ -1457,27 +1576,17 @@ export const useSandboxFileStore = ({ ...@@ -1457,27 +1576,17 @@ export const useSandboxFileStore = ({
return; return;
} }
pendingPaths.add(path); pendingPaths.add(path);
const newNode: TreeNode = { uploadTasks.push({ path, file });
name: file.name,
path,
type: 'file',
level: targetLevel
};
uploadTasks.push({ path, content, newNode });
} }
// 乐观更新 UI
const addedPaths: string[] = [];
uploadTasks.forEach((task) => {
setFileTree((prevTree) => addTreeNode(prevTree, targetDirPath, task.newNode));
addedPaths.push(task.path);
});
try { try {
for (const task of uploadTasks) { for (const task of uploadTasks) {
await rpcCall('fs/write_file', { await uploadSandboxFile({
...sandboxTarget,
chatId,
outLinkAuthData,
path: task.path, path: task.path,
content: task.content file: task.file
}); });
} }
} catch (error) { } catch (error) {
...@@ -1487,14 +1596,9 @@ export const useSandboxFileStore = ({ ...@@ -1487,14 +1596,9 @@ export const useSandboxFileStore = ({
description: getErrText(error), description: getErrText(error),
status: 'error' status: 'error'
}); });
// 回滚
addedPaths.forEach((path) => {
setFileTree((prevTree) => deleteTreeNode(prevTree, path));
});
await refreshWorkspace({ preserveExpandedDirs: true });
} }
}, },
[fileTree, maxFileBytes, maxFileSizeMB, rpcCall, refreshWorkspace, toast, t] [chatId, fileTree, maxFileBytes, maxFileSizeMB, outLinkAuthData, sandboxTarget, toast, t]
); );
// 展开折叠目录 // 展开折叠目录
......
import type { NextApiResponse } from 'next'; import type { NextApiResponse } from 'next';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
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 { import {
...@@ -14,7 +16,7 @@ import { SandboxDownloadBodySchema } from '@fastgpt/global/openapi/core/ai/sandb ...@@ -14,7 +16,7 @@ import { SandboxDownloadBodySchema } from '@fastgpt/global/openapi/core/ai/sandb
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { import {
isSandboxPathDirectory, isSandboxPathDirectory,
getSandboxFileContent, resolveSandboxWorkspacePath,
addDirectoryToArchive addDirectoryToArchive
} from '@fastgpt/service/core/ai/sandbox/interface/file'; } from '@fastgpt/service/core/ai/sandbox/interface/file';
...@@ -59,6 +61,38 @@ export const writeDirectoryArchiveResponse = async ({ ...@@ -59,6 +61,38 @@ export const writeDirectoryArchiveResponse = async ({
} }
}; };
const writeFileStreamResponse = async ({
sandbox,
res,
path
}: {
sandbox: SandboxClient;
res: NextApiResponse;
path: string;
}) => {
const providerPath = resolveSandboxWorkspacePath(path);
const fileInfoMap = await sandbox.provider.getFileInfo([providerPath]).catch(() => undefined);
const fileInfo = fileInfoMap?.get(providerPath);
if (fileInfo?.isDirectory) {
return Promise.reject('Cannot read a directory as a file');
}
const fileName = providerPath.split('/').pop() || 'file';
const encodedFileName = encodeURIComponent(fileName);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`
);
if (typeof fileInfo?.size === 'number') {
res.setHeader('Content-Length', String(fileInfo.size));
}
await pipeline(Readable.from(sandbox.provider.readFileStream(providerPath)), res);
};
async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> { async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void> {
const { sourceType, sourceId, chatId, path, outLinkAuthData } = parseApiInput({ const { sourceType, sourceId, chatId, path, outLinkAuthData } = parseApiInput({
req, req,
...@@ -110,15 +144,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void ...@@ -110,15 +144,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse): Promise<void
path path
}); });
} else { } else {
const { content, fileName } = await getSandboxFileContent(sandbox, path, false); await writeFileStreamResponse({ sandbox, res, path });
const encodedFileName = encodeURIComponent(fileName);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`
);
res.send(content);
} }
} }
......
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import {
authSandboxSession,
buildSandboxClientQueryFromChatSource
} from '@/service/core/sandbox/auth';
import { multer } from '@fastgpt/service/common/file/multer';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
SandboxUploadBodySchema,
SandboxUploadResponseSchema,
type SandboxUploadResponse
} from '@fastgpt/global/openapi/core/ai/sandbox/api';
import { getAgentSandboxMaxFileBytes } from '@fastgpt/service/core/ai/sandbox/interface/config';
import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/interface/runtime';
import { resolveSandboxWorkspacePath } from '@fastgpt/service/core/ai/sandbox/interface/file';
import { Readable } from 'node:stream';
async function handler(req: ApiRequestProps): Promise<SandboxUploadResponse> {
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
return Promise.reject('Content-Type must be multipart/form-data');
}
const filepaths: string[] = [];
try {
const maxFileBytes = getAgentSandboxMaxFileBytes();
const form = await multer.resolveFormData({
request: req,
maxFileSize: Math.ceil(maxFileBytes / 1024 / 1024)
});
if (form.fileMetadata.path) {
filepaths.push(form.fileMetadata.path);
}
const { sourceType, sourceId, chatId, path, outLinkAuthData } = parseApiInput({
req: { body: form.data },
bodySchema: SandboxUploadBodySchema
}).body;
if (form.fileMetadata.size > maxFileBytes) {
return Promise.reject(
`File is too large (${form.fileMetadata.size} bytes > ${maxFileBytes} bytes)`
);
}
const {
uid,
sourceType: resolvedSourceType,
sourceId: resolvedSourceId
} = await authSandboxSession({
req,
sourceType,
sourceId,
chatId,
outLinkAuthData,
per: WritePermissionVal
});
const sandbox = await getSandboxClient(
buildSandboxClientQueryFromChatSource({
sourceType: resolvedSourceType,
sourceId: resolvedSourceId,
userId: uid,
chatId
}),
{
failedArchivePolicy: 'clearAndContinue'
}
);
const providerPath = resolveSandboxWorkspacePath(path);
const [writeResult] = await sandbox.provider.writeFiles([
{
path: providerPath,
data: Readable.toWeb(form.getReadStream()) as ReadableStream<Uint8Array>
}
]);
if (!writeResult || writeResult.error) {
return Promise.reject(
`Failed to upload file: ${writeResult?.error?.message || 'unknown error'}`
);
}
return SandboxUploadResponseSchema.parse({
path,
bytesWritten: writeResult.bytesWritten || form.fileMetadata.size
});
} finally {
multer.clearDiskTempFiles(filepaths);
}
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: false
}
};
...@@ -8,6 +8,7 @@ import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; ...@@ -8,6 +8,7 @@ import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { z } from 'zod'; import { z } from 'zod';
import { authAgentSandboxProxy } from '@/service/core/sandbox/auth'; import { authAgentSandboxProxy } from '@/service/core/sandbox/auth';
import { IntSchema } from '@fastgpt/global/common/zod';
import { import {
SandboxChannelSchema, SandboxChannelSchema,
SandboxTicketPermissionSchema SandboxTicketPermissionSchema
...@@ -23,6 +24,16 @@ const VerifyTicketQuerySchema = z.object({ ...@@ -23,6 +24,16 @@ const VerifyTicketQuerySchema = z.object({
ticket: z.string() ticket: z.string()
}); });
const SandboxVerifyTicketResponseSchema = z.object({
sandbox_url: z.string().min(1),
agent_token: z.string(),
ws_limits: z.object({
max_message_bytes: IntSchema.min(1),
max_frame_bytes: IntSchema.min(1)
})
});
type SandboxVerifyTicketResponse = z.infer<typeof SandboxVerifyTicketResponseSchema>;
const BaseSandboxTicketClaimsSchema = z.object({ const BaseSandboxTicketClaimsSchema = z.object({
userId: z.string(), userId: z.string(),
chatId: z.string(), chatId: z.string(),
...@@ -73,7 +84,7 @@ async function readIdeAgentPassword(sandbox: SandboxClient) { ...@@ -73,7 +84,7 @@ async function readIdeAgentPassword(sandbox: SandboxClient) {
/** /**
* 校验 proxy ticket,并返回 IDE Agent 的代理连接地址和一次性 agent 口令。 * 校验 proxy ticket,并返回 IDE Agent 的代理连接地址和一次性 agent 口令。
*/ */
async function handler(req: ApiRequestProps) { async function handler(req: ApiRequestProps): Promise<SandboxVerifyTicketResponse> {
const secret = authAgentSandboxProxy(req); const secret = authAgentSandboxProxy(req);
const { ticket } = parseApiInput({ const { ticket } = parseApiInput({
...@@ -102,10 +113,14 @@ async function handler(req: ApiRequestProps) { ...@@ -102,10 +113,14 @@ async function handler(req: ApiRequestProps) {
const endpoint = await sandbox.provider.getEndpoint(getIdeAgentPort()); const endpoint = await sandbox.provider.getEndpoint(getIdeAgentPort());
return { return SandboxVerifyTicketResponseSchema.parse({
sandbox_url: endpoint.url, sandbox_url: endpoint.url,
agent_token: agentPassword agent_token: agentPassword,
}; ws_limits: {
max_message_bytes: serviceEnv.AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES,
max_frame_bytes: serviceEnv.AGENT_SANDBOX_WS_MAX_FRAME_BYTES
}
});
} }
export default NextAPI(handler); export default NextAPI(handler);
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextApiResponse } from 'next';
import type { SandboxClient } from '@fastgpt/service/core/ai/sandbox/interface/runtime';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
addDirectoryToArchive: vi.fn() addDirectoryToArchive: vi.fn()
...@@ -21,7 +19,6 @@ vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', () => ({ ...@@ -21,7 +19,6 @@ vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/interface/file', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/interface/file', () => ({
addDirectoryToArchive: mocks.addDirectoryToArchive, addDirectoryToArchive: mocks.addDirectoryToArchive,
getSandboxFileContent: vi.fn(),
isSandboxPathDirectory: vi.fn() isSandboxPathDirectory: vi.fn()
})); }));
......
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Readable } from 'node:stream';
const mocks = vi.hoisted(() => ({
authSandboxSession: vi.fn(),
buildSandboxClientQueryFromChatSource: vi.fn(),
clearDiskTempFiles: vi.fn(),
getAgentSandboxMaxFileBytes: vi.fn(),
getReadStream: vi.fn(),
getSandboxClient: vi.fn(),
resolveFormData: vi.fn(),
writeFiles: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: vi.fn((handler) => handler)
}));
vi.mock('@/service/core/sandbox/auth', () => ({
authSandboxSession: mocks.authSandboxSession,
buildSandboxClientQueryFromChatSource: mocks.buildSandboxClientQueryFromChatSource
}));
vi.mock('@fastgpt/service/common/file/multer', () => ({
multer: {
resolveFormData: mocks.resolveFormData,
clearDiskTempFiles: mocks.clearDiskTempFiles
}
}));
vi.mock('@fastgpt/service/core/ai/sandbox/interface/config', () => ({
getAgentSandboxMaxFileBytes: mocks.getAgentSandboxMaxFileBytes
}));
vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', () => ({
getSandboxClient: mocks.getSandboxClient
}));
import handler from '@/pages/api/core/ai/sandbox/upload';
const createReq = () =>
({
headers: {
'content-type': 'multipart/form-data; boundary=test'
}
}) as any;
describe('sandbox upload API', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getAgentSandboxMaxFileBytes.mockReturnValue(10 * 1024 * 1024);
mocks.getReadStream.mockReturnValue(Readable.from([new Uint8Array([1, 2, 3])]));
mocks.resolveFormData.mockResolvedValue({
data: {
appId: '507f1f77bcf86cd799439011',
chatId: 'chat-1',
path: 'uploads/a.txt'
},
fileMetadata: {
path: '/tmp/upload-a.txt',
size: 3
},
getReadStream: mocks.getReadStream
});
mocks.authSandboxSession.mockResolvedValue({
uid: 'user-1',
sourceType: ChatSourceTypeEnum.app,
sourceId: '507f1f77bcf86cd799439011'
});
mocks.buildSandboxClientQueryFromChatSource.mockReturnValue({ sandboxId: 'sandbox-1' });
mocks.writeFiles.mockResolvedValue([{ bytesWritten: 3, error: null }]);
mocks.getSandboxClient.mockResolvedValue({ provider: { writeFiles: mocks.writeFiles } });
});
it('uploads multipart file through sandbox provider after write auth', async () => {
const req = createReq();
await expect(handler(req)).resolves.toEqual({
path: 'uploads/a.txt',
bytesWritten: 3
});
expect(mocks.resolveFormData).toHaveBeenCalledWith({
request: req,
maxFileSize: 10
});
expect(mocks.authSandboxSession).toHaveBeenCalledWith({
req,
sourceType: ChatSourceTypeEnum.app,
sourceId: '507f1f77bcf86cd799439011',
chatId: 'chat-1',
outLinkAuthData: undefined,
per: WritePermissionVal
});
expect(mocks.buildSandboxClientQueryFromChatSource).toHaveBeenCalledWith({
sourceType: ChatSourceTypeEnum.app,
sourceId: '507f1f77bcf86cd799439011',
userId: 'user-1',
chatId: 'chat-1'
});
expect(mocks.getSandboxClient).toHaveBeenCalledWith(
{ sandboxId: 'sandbox-1' },
{ failedArchivePolicy: 'clearAndContinue' }
);
expect(mocks.writeFiles).toHaveBeenCalledTimes(1);
const [[writeEntry]] = mocks.writeFiles.mock.calls[0];
expect(writeEntry.path).toBe('/workspace/uploads/a.txt');
expect(writeEntry.data).toBeInstanceOf(ReadableStream);
expect(mocks.clearDiskTempFiles).toHaveBeenCalledWith(['/tmp/upload-a.txt']);
});
});
...@@ -755,6 +755,10 @@ export class OpenSandboxAdapter extends BaseSandboxAdapter { ...@@ -755,6 +755,10 @@ export class OpenSandboxAdapter extends BaseSandboxAdapter {
return results; return results;
} }
override readFileStream(path: string): AsyncIterable<Uint8Array> {
return this.sandbox.files.readBytesStream(this.normalizePath(path));
}
// ==================== Command Execution ==================== // ==================== Command Execution ====================
async execute(command: string, options?: ExecuteOptions): Promise<ExecuteResult> { async execute(command: string, options?: ExecuteOptions): Promise<ExecuteResult> {
const maxBytes = options?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const maxBytes = options?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
......
...@@ -145,14 +145,20 @@ export class DevboxApi { ...@@ -145,14 +145,20 @@ export class DevboxApi {
}); });
} }
/** GET /api/v1/devbox/{name}/files/download */ private buildDownloadFileUrl(name: string, params: DownloadFileParams): string {
async downloadFile(name: string, params: DownloadFileParams): Promise<ArrayBuffer> {
const queryParams: Record<string, string> = { path: params.path }; const queryParams: Record<string, string> = { path: params.path };
if (params.filename) queryParams.filename = params.filename; if (params.filename) queryParams.filename = params.filename;
if (params.timeoutSeconds != null) queryParams.timeoutSeconds = String(params.timeoutSeconds); if (params.timeoutSeconds != null) queryParams.timeoutSeconds = String(params.timeoutSeconds);
if (params.container) queryParams.container = params.container; if (params.container) queryParams.container = params.container;
const url = this.url(`/api/v1/devbox/${name}/files/download`, queryParams); return this.url(`/api/v1/devbox/${name}/files/download`, queryParams);
}
private async fetchDownloadFileResponse(
name: string,
params: DownloadFileParams
): Promise<Response> {
const url = this.buildDownloadFileUrl(name, params);
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` } headers: { Authorization: `Bearer ${this.token}` }
}); });
...@@ -165,6 +171,44 @@ export class DevboxApi { ...@@ -165,6 +171,44 @@ export class DevboxApi {
url url
); );
} }
return res;
}
/** GET /api/v1/devbox/{name}/files/download */
async downloadFile(name: string, params: DownloadFileParams): Promise<ArrayBuffer> {
const res = await this.fetchDownloadFileResponse(name, params);
return res.arrayBuffer(); return res.arrayBuffer();
} }
/**
* GET /api/v1/devbox/{name}/files/download as the native HTTP response stream.
*
* This intentionally bypasses `arrayBuffer()` so callers can pipe large files without buffering
* the full payload in Node memory.
*/
downloadFileStream(name: string, params: DownloadFileParams): AsyncIterable<Uint8Array> {
return this.readDownloadFileStream(name, params);
}
private async *readDownloadFileStream(
name: string,
params: DownloadFileParams
): AsyncIterable<Uint8Array> {
const res = await this.fetchDownloadFileResponse(name, params);
const body = res.body;
if (!body) return;
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
if (value) {
yield value;
}
}
} finally {
reader.releaseLock();
}
}
} }
...@@ -382,6 +382,12 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter { ...@@ -382,6 +382,12 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
return results; return results;
} }
override readFileStream(path: string): AsyncIterable<Uint8Array> {
return this.api.downloadFileStream(this._id, {
path: this.normalizePath(path)
});
}
override async writeFileStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void> { override async writeFileStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void> {
const normalizedPath = this.normalizePath(path); const normalizedPath = this.normalizePath(path);
const results = await this.writeFiles([ const results = await this.writeFiles([
......
...@@ -582,6 +582,31 @@ describe('OpenSandboxAdapter', () => { ...@@ -582,6 +582,31 @@ describe('OpenSandboxAdapter', () => {
}); });
}); });
describe('readFileStream', () => {
it('should stream file through OpenSandbox native readBytesStream', async () => {
const adapter = makeAdapter();
async function* streamChunks() {
yield new TextEncoder().encode('native ');
yield new TextEncoder().encode('stream');
}
const mockReadBytesStream = vi.fn(() => streamChunks());
(adapter as any).sandbox = {
files: {
readBytesStream: mockReadBytesStream
}
};
const received: Uint8Array[] = [];
for await (const chunk of adapter.readFileStream('test.txt')) {
received.push(chunk);
}
expect(new TextDecoder().decode(Buffer.concat(received))).toBe('native stream');
expect(mockReadBytesStream).toHaveBeenCalledWith('/workspace/test.txt');
});
});
describe('writeFiles', () => { describe('writeFiles', () => {
it('should slice Uint8Array with byte offset and pass clean ArrayBuffer to SDK', async () => { it('should slice Uint8Array with byte offset and pass clean ArrayBuffer to SDK', async () => {
const adapter = makeAdapter(); const adapter = makeAdapter();
......
...@@ -579,4 +579,42 @@ describe('SealosDevboxAdapter', () => { ...@@ -579,4 +579,42 @@ describe('SealosDevboxAdapter', () => {
}) })
); );
}); });
it('should stream file through SealosDevbox native download response body', async () => {
const chunks = [new TextEncoder().encode('native '), new TextEncoder().encode('stream')];
const arrayBuffer = vi.fn();
const fetchMock = vi.fn(async () => ({
ok: true,
body: new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
}
}),
arrayBuffer
}));
vi.stubGlobal('fetch', fetchMock);
const adapter = new SealosDevboxAdapter(CONFIG, {
workingDir: '/home/devbox/workspace'
});
const received: Uint8Array[] = [];
for await (const chunk of adapter.readFileStream('test.txt')) {
received.push(chunk);
}
expect(new TextDecoder().decode(Buffer.concat(received))).toBe('native stream');
expect(arrayBuffer).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
'https://devbox-server.example.com/api/v1/devbox/devbox-1/files/download?path=%2Fhome%2Fdevbox%2Fworkspace%2Ftest.txt',
expect.objectContaining({
headers: {
Authorization: 'Bearer test-token'
}
})
);
});
}); });
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