diff --git a/.claude/design/core/ai/sandbox/get-file-url.md b/.claude/design/core/ai/sandbox/get-file-url.md new file mode 100644 index 0000000000..8d8f812b51 --- /dev/null +++ b/.claude/design/core/ai/sandbox/get-file-url.md @@ -0,0 +1,376 @@ +# 虚拟机系统工具:获取文件链接 (sandbox_get_file_url) + +## 一、需求概述 + +为虚拟机(Agent Sandbox)新增一个系统级工具 `sandbox_get_file_url`: + +1. Agent 调用该工具,传入虚拟机内的文件路径 +2. 系统从虚拟机读取文件内容 +3. 将文件上传到 S3(使用对话的 Chat Bucket 实例) +4. 返回 2 小时有效期的签名访问 URL + +**工具返回格式**: +```json +{ + "url": "https://xxx.s3.amazonaws.com/...", + "expired": "2 hours", + "filename": "output.csv" +} +``` + +--- + +## 二、涉及文件 + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `packages/global/core/ai/sandbox/constants.ts` | 修改 | 新增工具定义、Schema、更新系统提示词 | +| `packages/service/core/ai/sandbox/toolCall.ts` | 新增 | 沙盒工具统一执行层 `callSandboxTool()`,两条链路共享 | +| `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` | 修改 | 统一拦截所有 sandbox 工具,复用 `callSandboxTool` | +| `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` | 修改 | 新增 `dispatchSandboxGetFileUrl()`,复用 `callSandboxTool` | +| `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | 修改 | 新增工具拦截逻辑(Agent 模式) | +| `packages/service/common/s3/buckets/base.ts` | 修改 | `uploadFileByBody` 新增 `filename`、`expiredTime` 参数 | +| `packages/service/common/s3/sources/chat/index.ts` | 修改 | `uploadChatFile` 透传 `filename`、`expiredTime` | +| `packages/service/common/s3/type.ts` | 修改 | `UploadFileByBodySchema` 新增 `filename`、`expiredTime` 字段 | +| `packages/service/common/s3/sources/chat/type.ts` | 修改 | `UploadChatFileSchema` 新增 `expiredTime` 字段 | +| `projects/app/src/pages/api/system/file/[jwt].ts` | 修改 | 下载接口支持 `Content-Disposition` 响应头 | + +> **架构说明**:新增 `callSandboxTool` 作为纯执行层,不绑定业务响应格式。两条调用链路(普通工作流 / Agent 模式)均复用该层,消除重复逻辑。 + +> **URL 生成**:文件上传到 S3 后,通过 `jwtSignS3ObjectKey(key, expiredAt)` 生成 JWT 签名的内网访问 URL(有效期 2 小时),不依赖 S3 签名 URL。 + +--- + +## 三、详细设计 + +### 3.1 工具定义(constants.ts) + +新增常量与 Schema: + +```typescript +export const SANDBOX_READ_FILE_TOOL_NAME: I18nStringType = { + 'zh-CN': '虚拟机/获取文件链接', + 'zh-Hant': '虛擬機/獲取文件鏈接', + en: 'Sandbox/Get File URL' +}; +export const SANDBOX_GET_FILE_URL_TOOL_NAME = 'sandbox_get_file_url'; + +// 参数为 paths 数组,支持同时获取多个文件 +export const SandboxGetFileUrlToolSchema = z.object({ + paths: z.array(z.string()) +}); + +export const SANDBOX_GET_FILE_URL_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: SANDBOX_GET_FILE_URL_TOOL_NAME, + description: '从虚拟机读取指定文件,上传至云存储,返回 2 小时有效期的访问链接', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + items: { + type: 'string', + description: '文件访问路径,例如: output.csv' + }, + description: '文件访问路径,例如: ["output.csv", "output.txt"]' + } + }, + required: ['paths'] + } + } +}; + +// 更新 SANDBOX_TOOLS(追加新工具) +export const SANDBOX_TOOLS: ChatCompletionTool[] = [SANDBOX_SHELL_TOOL, SANDBOX_GET_FILE_URL_TOOL]; +``` + +更新系统提示词(`SANDBOX_SYSTEM_PROMPT`),追加工具说明: +``` +- 若需要将生成的文件分享给用户,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 工具获取文件的临时访问链接(有效期 2 小时) +``` + +--- + +### 3.2 沙盒工具统一执行层(toolCall.ts) + +新增 `packages/service/core/ai/sandbox/toolCall.ts`,作为所有沙盒工具的纯执行层,不绑定业务响应格式: + +```typescript +type SandboxToolCallParams = { + toolName: string; + rawArgs: string; + appId: string; + userId: string; + chatId: string; +}; + +export type SandboxToolCallResult = { + input: Record; + response: string; + durationSeconds: number; +}; + +export const callSandboxTool = async (params: SandboxToolCallParams): Promise +``` + +**`sandbox_get_file_url` 执行逻辑**: +1. 解析 `paths` 数组参数 +2. 用 `Promise.all` 并发处理每个文件路径: + - 通过 `instance.provider.readFileStream(filePath)` 流式读取文件内容 + - 聚合 chunks 为 `Buffer` + - 调用 `chatBucket.uploadChatFile({ ..., expiredTime: addHours(now, 2) })` 上传,TTL 设为 2 小时 + - 用 `jwtSignS3ObjectKey(key, addHours(now, 2))` 生成 JWT 签名访问 URL +3. 返回 `Array<{ fileUrl: string, filename: string }>` + +**工具返回格式**(JSON 序列化后作为 response): +```json +[ + { "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "output.csv" }, + { "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "report.txt" } +] +``` + +> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是通过 `jwtSignS3ObjectKey` 生成 JWT 签名的内部访问 URL,由 `/api/system/file/[jwt]` 接口代理下载。 + +--- + +### 3.3 S3 上传(uploadFileByBody 扩展) + +扩展 `packages/service/common/s3/type.ts` 的 `UploadFileByBodySchema`,新增 `filename`(必填)和 `expiredTime`(可选)字段: + +```typescript +export const UploadFileByBodySchema = z.object({ + buffer: z.instanceof(Buffer), + contentType: z.string().optional(), + key: z.string().nonempty(), + filename: z.string().nonempty(), // 新增,用于 Content-Disposition + expiredTime: z.date().optional() // 新增,不传则默认 now + 1h +}); +``` + +`uploadFileByBody` 使用 `expiredTime` 写入 MongoS3TTL,并将 `filename` 写入对象 metadata: + +```typescript +metadata: { + contentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`, + originFilename: encodeURIComponent(filename), + uploadTime: new Date().toISOString() +} +``` + +`uploadChatFile` 同步透传 `filename` 和 `expiredTime` 到底层方法。 + +--- + +### 3.4 封装方法(sub/sandbox/index.ts) + +`dispatchSandboxGetFileUrl` 直接复用 `callSandboxTool`,与 `dispatchSandboxShell` 模式一致: + +```typescript +export const dispatchSandboxGetFileUrl = async ({ + filePath, // 保持参数名,内部转为 paths: [filePath] + appId, + userId, + chatId, + lang +}: SandboxDispatchParams & { filePath: string }): Promise => { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: SANDBOX_GET_FILE_URL_TOOL_NAME, + rawArgs: JSON.stringify({ paths: [filePath] }), + appId, + userId, + chatId + }); + + return { + response, + usages: [], + nodeResponse: buildNodeResponse({ toolId: SANDBOX_GET_FILE_URL_TOOL_NAME, input, response, durationSeconds, lang }) + }; +}; +``` + +--- + +### 3.5 工具拦截逻辑(两条链路) + +两条链路均通过 `callSandboxTool` 统一处理,不再各自内联逻辑。 + +#### 3.5.1 普通工作流:toolCall.ts + +`handleToolResponse` 中合并 `SANDBOX_TOOL_NAME` 和 `SANDBOX_GET_FILE_URL_TOOL_NAME` 到同一拦截块: + +```typescript +if ( + call.function?.name === SANDBOX_TOOL_NAME || + call.function?.name === SANDBOX_GET_FILE_URL_TOOL_NAME +) { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: call.function.name, + rawArgs: call.function.arguments ?? '', + appId: String(workflowProps.runningAppInfo.id), + userId: String(workflowProps.uid), + chatId: workflowProps.chatId + }); + + const flowResponse = getSandboxToolWorkflowResponse({ + name: tool.name, + logo: SANDBOX_ICON, + toolId: call.function.name, + input, + response, + durationSeconds + }); + + return { response, flowResponse }; +} +``` + +#### 3.5.2 Agent 模式:agent/master/call.ts + +紧跟 `SANDBOX_TOOL_NAME` 拦截块之后,添加对 `SANDBOX_GET_FILE_URL_TOOL_NAME` 的处理,复用 `dispatchSandboxGetFileUrl`: + +```typescript +if (toolId === SANDBOX_GET_FILE_URL_TOOL_NAME) { + const toolParams = SandboxGetFileUrlToolSchema.safeParse(parseJsonArgs(call.function.arguments)); + if (!toolParams.success) return { response: toolParams.error.message, usages: [] }; + + const result = await dispatchSandboxGetFileUrl({ + filePath: toolParams.data.paths[0], // Agent 模式单文件路径 + appId: runningAppInfo.id, + userId: props.uid, + chatId, + lang: props.lang + }); + + childrenResponses.push(result.nodeResponse); + return { response: result.response, usages: result.usages }; +} +``` + +--- + +## 四、执行流程 + +### 4.1 普通工作流链路(toolCall.ts) + +```mermaid +sequenceDiagram + participant Tool as ToolCall 节点 + participant Handler as toolCall.ts 拦截 + participant CallLayer as callSandboxTool() + participant SandboxProvider as provider.readFileStream() + participant S3 as S3ChatSource.uploadChatFile() + participant DB as MongoS3TTL + + Tool->>Handler: 调用 sandbox_get_file_url({ paths }) + Handler->>CallLayer: callSandboxTool({ toolName, rawArgs, ... }) + loop 每个 path(并发) + CallLayer->>SandboxProvider: readFileStream(filePath) + SandboxProvider-->>CallLayer: AsyncIterable + CallLayer->>CallLayer: Buffer.concat(chunks) + CallLayer->>S3: uploadChatFile({ filename, buffer, expiredTime: +2h }) + S3->>DB: MongoS3TTL.create(expiredTime = now + 2h) + S3-->>CallLayer: { key } + CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) → fileUrl + end + CallLayer-->>Handler: { input, response: JSON([{fileUrl, filename}...]), durationSeconds } + Handler-->>Tool: response + flowResponse +``` + +### 4.2 Agent 模式链路(agent/master/call.ts) + +```mermaid +sequenceDiagram + participant Agent as Agent Master Call + participant Handler as master/call.ts 拦截 + participant Dispatch as dispatchSandboxGetFileUrl() + participant CallLayer as callSandboxTool() + participant SandboxProvider as provider.readFileStream() + participant S3 as S3ChatSource.uploadChatFile() + + Agent->>Handler: 调用 sandbox_get_file_url({ paths }) + Handler->>Dispatch: dispatchSandboxGetFileUrl({ filePath, appId, ... }) + Dispatch->>CallLayer: callSandboxTool({ toolName, rawArgs: {paths:[filePath]}, ... }) + CallLayer->>SandboxProvider: readFileStream(filePath) + SandboxProvider-->>CallLayer: stream chunks + CallLayer->>S3: uploadChatFile({ expiredTime: +2h }) + S3-->>CallLayer: { key } + CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) + CallLayer-->>Dispatch: { input, response, durationSeconds } + Dispatch-->>Handler: { response, nodeResponse } + Handler-->>Agent: { response, usages } +``` + +--- + +## 五、错误处理 + +| 错误场景 | 处理方式 | +|----------|----------| +| 文件不存在 | `readFileStream` 抛出异常,`callSandboxTool` 捕获后返回错误字符串,Agent 收到后可重试 | +| 沙盒不可用 | `getSandboxClient` 失败,返回错误信息 | +| S3 上传失败 | 捕获异常,返回错误信息 | +| 文件过大 | ⚠️ **当前未限制**,文件内容先全部读入内存再上传(见六、待优化) | + +--- + +## 六、待优化 + +### 6.1 内存占用问题(当前实现缺陷) + +当前实现将沙盒文件全量读入内存后再上传 S3: + +```typescript +// 当前:全量内存聚合 +const chunks: Uint8Array[] = []; +for await (const chunk of stream) { + chunks.push(chunk); +} +const fileBuffer = Buffer.concat(chunks); // 大文件 OOM 风险 +``` + +**问题**:大文件会导致内存暴涨,Tool Call 场景下 AI 可以任意触发,存在 DoS 风险。 + +**优化方向一:文件大小限制** + +在聚合 chunks 过程中累计字节数,超过阈值立即中止: + +```typescript +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +let totalSize = 0; +for await (const chunk of stream) { + totalSize += chunk.byteLength; + if (totalSize > MAX_FILE_SIZE) { + throw new Error(`File too large (> ${MAX_FILE_SIZE / 1024 / 1024}MB)`); + } + chunks.push(chunk); +} +``` + +**优化方向二:直接流式上传 S3(推荐)** + +绕过内存聚合,将 `readFileStream` 返回的 `AsyncIterable` 直接作为 S3 上传 body: + +```typescript +const stream = instance.provider.readFileStream(filePath); +// 需要 S3 client 支持传入 AsyncIterable/Stream 作为 body +await this.client.uploadObject({ key, body: stream, contentType: '...' }); +``` + +可行性取决于底层 S3 client(`@aws-sdk/client-s3` 的 `PutObjectCommand` 支持传入 `Readable` / `ReadableStream`)。需评估 `MinIO/custom` client 的支持情况后实施。 + +--- + +## 七、TODO + +- [x] `packages/global/core/ai/sandbox/constants.ts`:新增 `SANDBOX_READ_FILE_TOOL_NAME`、`SANDBOX_GET_FILE_URL_TOOL_NAME`、`SANDBOX_GET_FILE_URL_TOOL`、`SandboxGetFileUrlToolSchema`(参数为 `paths: string[]`),更新 `SANDBOX_TOOLS` 和 `SANDBOX_SYSTEM_PROMPT` +- [x] `packages/service/core/ai/sandbox/toolCall.ts`:新增 `callSandboxTool()` 统一执行层,支持 `sandbox_shell` 和 `sandbox_get_file_url` +- [x] `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts`:重构为复用 `callSandboxTool`,新增 `dispatchSandboxGetFileUrl()` +- [x] `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`:合并拦截逻辑,复用 `callSandboxTool` +- [x] `packages/service/core/workflow/dispatch/ai/agent/master/call.ts`:新增 `SANDBOX_GET_FILE_URL_TOOL_NAME` 拦截逻辑 +- [x] `packages/service/common/s3/type.ts`、`buckets/base.ts`、`sources/chat/index.ts`、`sources/chat/type.ts`:扩展 `filename`、`expiredTime` 参数 +- [x] `projects/app/src/pages/api/system/file/[jwt].ts`:支持 `Content-Disposition` 下载头 +- [ ] 大文件限制或流式上传优化(见六、待优化) diff --git a/.claude/design/wechat-file-support.md b/.claude/design/wechat-file-support.md index d4e121b762..751e8e1b90 100644 --- a/.claude/design/wechat-file-support.md +++ b/.claude/design/wechat-file-support.md @@ -53,7 +53,7 @@ type UserChatItemValueItemType = { ## 三、S3 存储方案 -### 核心选择:`S3ChatSource.uploadChatFileByBuffer()` +### 核心选择:`S3ChatSource.uploadChatFile()` | 字段 | 值 | |------|----| @@ -66,9 +66,9 @@ type UserChatItemValueItemType = { ``` getS3ChatSource() - └── uploadChatFileByBuffer({ appId, chatId, uId, filename, buffer, contentType }) + └── uploadChatFile({ appId, chatId, uId, filename, buffer, contentType }) └── getFileS3Key.chat(...) → key = "chat/{appId}/{uId}/{chatId}/{filename}" - └── uploadFileByBuffer({ key, buffer, contentType }) + └── uploadFileByBody({ key, buffer, contentType }) ├── MongoS3TTL.create({ expiredTime: +1h }) // 文件 1 小时后自动清理 ├── client.uploadObject(...) └── returns { key, accessUrl } // accessUrl 预签名 2h @@ -327,7 +327,7 @@ for (const msg of msgs) { ### 5.3 `wechat/fileHandler.ts` — 新建文件处理模块 -图片和文件**统一存入 S3 私有桶**(`chat` source),通过 `S3ChatSource.uploadChatFileByBuffer()` 写入, +图片和文件**统一存入 S3 私有桶**(`chat` source),通过 `S3ChatSource.uploadChatFile()` 写入, 上传完成后用 `createGetChatFileURL()` 生成预签名 URL 传给 workflow。 ```typescript @@ -366,7 +366,7 @@ export async function downloadAndStoreMedia(params: { // 上传到 S3,返回 { key, accessUrl } // accessUrl 是 createExternalUrl({ key, expiredHours: 2 }) 的预签名 URL - const { key, accessUrl } = await chatSource.uploadChatFileByBuffer({ + const { key, accessUrl } = await chatSource.uploadChatFile({ appId, chatId, uId, diff --git a/document/content/docs/self-host/upgrading/4-14/41410.mdx b/document/content/docs/self-host/upgrading/4-14/41410.mdx index 59f23721ab..40d6b98619 100644 --- a/document/content/docs/self-host/upgrading/4-14/41410.mdx +++ b/document/content/docs/self-host/upgrading/4-14/41410.mdx @@ -11,10 +11,11 @@ description: 'FastGPT V4.14.10 更新说明' ## 🚀 新增内容 1. 增加 OpenSandbox docker 部署方案及适配,并支持通过挂载 volumn 进行数据持久化。 -2. 飞书发布渠道,支持流输出。 -3. 目录最大上限,可通过环境变量配置。 -4. rerank 模型上限配置,避免超出单条 document 上限导致 rerank 失败。 -5. 增加 LLM 梯度计量计费模式,同时统一计费推送方式。 +2. 新增沙盒读取文件链接工具,可以直接让 AI 返回文件的访问链接。 +3. 飞书发布渠道,支持流输出。 +4. 目录最大上限,可通过环境变量配置。 +5. rerank 模型上限配置,避免超出单条 document 上限导致 rerank 失败。 +6. 增加 LLM 梯度计量计费模式,同时统一计费推送方式。 ## ⚙️ 优化 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 132492bfc3..d491202068 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -147,8 +147,8 @@ "document/content/docs/openapi/share.mdx": "2026-02-12T18:45:30+08:00", "document/content/docs/self-host/config/json.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/config/json.mdx": "2026-03-03T17:39:47+08:00", - "document/content/docs/self-host/config/model/intro.en.mdx": "2026-03-24T23:37:00+08:00", - "document/content/docs/self-host/config/model/intro.mdx": "2026-03-24T23:37:00+08:00", + "document/content/docs/self-host/config/model/intro.en.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/self-host/config/model/intro.mdx": "2026-03-30T10:05:42+08:00", "document/content/docs/self-host/config/model/minimax.en.mdx": "2026-03-19T09:32:57-05:00", "document/content/docs/self-host/config/model/minimax.mdx": "2026-03-19T09:32:57-05:00", "document/content/docs/self-host/config/model/siliconCloud.en.mdx": "2026-03-19T14:09:03+08:00", @@ -171,8 +171,8 @@ "document/content/docs/self-host/custom-models/mineru.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/custom-models/ollama.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/custom-models/ollama.mdx": "2026-03-03T17:39:47+08:00", - "document/content/docs/self-host/custom-models/xinference.en.mdx": "2026-03-24T23:37:00+08:00", - "document/content/docs/self-host/custom-models/xinference.mdx": "2026-03-24T23:37:00+08:00", + "document/content/docs/self-host/custom-models/xinference.en.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/self-host/custom-models/xinference.mdx": "2026-03-30T10:05:42+08:00", "document/content/docs/self-host/deploy/docker.en.mdx": "2026-03-19T14:09:03+08:00", "document/content/docs/self-host/deploy/docker.mdx": "2026-03-19T14:09:03+08:00", "document/content/docs/self-host/deploy/sealos.en.mdx": "2026-03-03T17:39:47+08:00", @@ -220,7 +220,7 @@ "document/content/docs/self-host/upgrading/4-14/4140.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00", - "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-28T17:10:23+08:00", + "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-30T10:05:42+08:00", "document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00", @@ -387,14 +387,14 @@ "document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/english_essay_correction_bot.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/feishu_webhook.en.mdx": "2026-03-28T17:14:28+08:00", - "document/content/docs/use-cases/app-cases/feishu_webhook.mdx": "2026-03-28T17:14:28+08:00", + "document/content/docs/use-cases/app-cases/feishu_webhook.en.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/use-cases/app-cases/feishu_webhook.mdx": "2026-03-30T10:05:42+08:00", "document/content/docs/use-cases/app-cases/fixingEvidence.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/fixingEvidence.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/google_search.en.mdx": "2026-03-28T17:14:28+08:00", - "document/content/docs/use-cases/app-cases/google_search.mdx": "2026-03-28T17:14:28+08:00", - "document/content/docs/use-cases/app-cases/lab_appointment.en.mdx": "2026-03-28T17:10:23+08:00", - "document/content/docs/use-cases/app-cases/lab_appointment.mdx": "2026-03-28T17:10:23+08:00", + "document/content/docs/use-cases/app-cases/google_search.en.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/use-cases/app-cases/google_search.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/use-cases/app-cases/lab_appointment.en.mdx": "2026-03-30T10:05:42+08:00", + "document/content/docs/use-cases/app-cases/lab_appointment.mdx": "2026-03-30T10:05:42+08:00", "document/content/docs/use-cases/app-cases/multi_turn_translation_bot.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/multi_turn_translation_bot.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/use-cases/app-cases/submit_application_template.en.mdx": "2026-03-03T17:39:47+08:00", diff --git a/packages/global/core/ai/sandbox/constants.ts b/packages/global/core/ai/sandbox/constants.ts index 30163c002c..081fa66692 100644 --- a/packages/global/core/ai/sandbox/constants.ts +++ b/packages/global/core/ai/sandbox/constants.ts @@ -26,20 +26,15 @@ export const SANDBOX_NAME: I18nStringType = { }; export const SANDBOX_ICON = 'core/app/sandbox/sandbox' as const; export const SANDBOX_TOOL_NAME = 'sandbox_shell'; -export const SANDBOX_TOOL_DESCRIPTION = - '在独立 Linux 环境中执行 shell 命令,支持文件操作、代码运行、包安装等'; - -// ---- 系统提示词(useAgentSandbox=true 时追加) ---- -export const SANDBOX_SYSTEM_PROMPT = `你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 ${SANDBOX_TOOL_NAME} 工具执行命令: -- 预装:bash / python3 / node / bun / git / curl -- 可自行安装软件包(apt / pip / npm) -- 生成的文件内容都保存在当前目录下即可`; - +export const SandboxShellToolSchema = z.object({ + command: z.string(), + timeout: z.number().optional() +}); export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { type: 'function', function: { name: SANDBOX_TOOL_NAME, - description: SANDBOX_TOOL_DESCRIPTION, + description: '在独立 Linux 虚拟机环境中执行 shell 命令,支持文件操作、代码运行、包安装等', parameters: { type: 'object', properties: { @@ -47,7 +42,7 @@ export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { timeout: { type: 'number', description: '超时秒数', - max: 300, + max: 600, min: 1 } }, @@ -56,10 +51,41 @@ export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { } }; -export const SANDBOX_TOOLS: ChatCompletionTool[] = [SANDBOX_SHELL_TOOL]; - -// Zod Schema 用于参数验证 -export const SandboxShellToolSchema = z.object({ - command: z.string(), - timeout: z.number().optional() +export const SANDBOX_READ_FILE_TOOL_NAME: I18nStringType = { + 'zh-CN': '虚拟机/获取文件链接', + 'zh-Hant': '虛擬機/獲取文件鏈接', + en: 'Sandbox/Get File URL' +}; +export const SANDBOX_GET_FILE_URL_TOOL_NAME = 'sandbox_get_file_url'; +export const SandboxGetFileUrlToolSchema = z.object({ + paths: z.array(z.string()) }); +export const SANDBOX_GET_FILE_URL_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: SANDBOX_GET_FILE_URL_TOOL_NAME, + description: '从虚拟机读取指定文件,上传至云存储,返回 2 小时有效期的访问链接', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + items: { + type: 'string', + description: '文件访问路径,例如: output.csv' + }, + description: '文件访问路径,例如: ["output.csv", "output.txt"]' + } + }, + required: ['paths'] + } + } +}; + +export const SANDBOX_TOOLS: ChatCompletionTool[] = [SANDBOX_SHELL_TOOL, SANDBOX_GET_FILE_URL_TOOL]; + +export const SANDBOX_SYSTEM_PROMPT = `你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 ${SANDBOX_TOOL_NAME} 工具执行命令: +- 预装:bash / python3 / node / bun / git / curl +- 可自行安装软件包(apt / pip / npm) +- 生成的文件内容都保存在当前目录下即可 +- 若需要将生成的文件分享给用户,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 工具获取文件的临时访问链接(有效期 2 小时)`; diff --git a/packages/global/core/workflow/node/agent/constants.ts b/packages/global/core/workflow/node/agent/constants.ts index 04943ff81f..2e51292956 100644 --- a/packages/global/core/workflow/node/agent/constants.ts +++ b/packages/global/core/workflow/node/agent/constants.ts @@ -1,8 +1,9 @@ import { - SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL, SANDBOX_ICON, SANDBOX_NAME, - SANDBOX_TOOL_DESCRIPTION + SANDBOX_READ_FILE_TOOL_NAME, + SANDBOX_SHELL_TOOL } from '../../../ai/sandbox/constants'; import type { I18nStringType } from '../../../../common/i18n/type'; @@ -12,7 +13,8 @@ export enum SubAppIds { model = 'model_agent', fileRead = 'file_read', datasetSearch = 'dataset_search', - sandboxTool = 'sandbox_shell' + sandboxTool = 'sandbox_shell', + sandboxGetFileUrl = 'sandbox_get_file_url' } export const systemSubInfo: Record< @@ -22,7 +24,12 @@ export const systemSubInfo: Record< [SubAppIds.sandboxTool]: { name: SANDBOX_NAME, avatar: SANDBOX_ICON, - toolDescription: SANDBOX_TOOL_DESCRIPTION + toolDescription: SANDBOX_SHELL_TOOL.function.description! + }, + [SubAppIds.sandboxGetFileUrl]: { + name: SANDBOX_READ_FILE_TOOL_NAME, + avatar: SANDBOX_ICON, + toolDescription: SANDBOX_GET_FILE_URL_TOOL.function.description! }, [SubAppIds.plan]: { name: { diff --git a/packages/service/common/s3/buckets/base.ts b/packages/service/common/s3/buckets/base.ts index 3c88551313..8e1a767df8 100644 --- a/packages/service/common/s3/buckets/base.ts +++ b/packages/service/common/s3/buckets/base.ts @@ -19,7 +19,7 @@ import { MongoS3TTL } from '../schema'; import { addHours, addMinutes, differenceInSeconds } from 'date-fns'; import { getLogger, LogCategories } from '../../logger'; import { addS3DelJob } from '../mq'; -import { type UploadFileByBufferParams, UploadFileByBufferSchema } from '../type'; +import { type UploadFileByBufferParams, UploadFileByBodySchema } from '../type'; import type { createStorage } from '@fastgpt-sdk/storage'; import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; @@ -196,19 +196,30 @@ export class S3BaseBucket { return await this.client.generatePresignedGetUrl({ key, expiredSeconds: expires }); } - async uploadFileByBuffer(params: UploadFileByBufferParams) { - const { key, buffer, contentType } = UploadFileByBufferSchema.parse(params); + async uploadFileByBody(params: UploadFileByBufferParams) { + const { + key, + body, + filename, + contentType, + expiredTime = addHours(new Date(), 1) + } = UploadFileByBodySchema.parse(params); await MongoS3TTL.create({ minioKey: key, bucketName: this.bucketName, - expiredTime: addHours(new Date(), 1) + expiredTime }); await this.client.uploadObject({ key, - body: buffer, - contentType: contentType || 'application/octet-stream' + body, + contentType: contentType || 'application/octet-stream', + metadata: { + contentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`, + originFilename: encodeURIComponent(filename), + uploadTime: new Date().toISOString() + } }); return { diff --git a/packages/service/common/s3/sources/chat/index.ts b/packages/service/common/s3/sources/chat/index.ts index 1dc9aee48a..9c5d1befc2 100644 --- a/packages/service/common/s3/sources/chat/index.ts +++ b/packages/service/common/s3/sources/chat/index.ts @@ -87,8 +87,8 @@ export class S3ChatSource extends S3PrivateBucket { return key; } - async uploadChatFileByBuffer(params: UploadFileParams) { - const { appId, chatId, uId, filename, buffer, contentType } = + async uploadChatFile(params: UploadFileParams) { + const { appId, chatId, uId, filename, body, contentType, expiredTime } = UploadChatFileSchema.parse(params); const { fileKey } = getFileS3Key.chat({ appId, @@ -97,10 +97,12 @@ export class S3ChatSource extends S3PrivateBucket { filename }); - return this.uploadFileByBuffer({ + return this.uploadFileByBody({ key: fileKey, - buffer, - contentType + filename, + body, + contentType, + expiredTime }); } diff --git a/packages/service/common/s3/sources/chat/type.ts b/packages/service/common/s3/sources/chat/type.ts index 3e77bf27f9..379655a8bf 100644 --- a/packages/service/common/s3/sources/chat/type.ts +++ b/packages/service/common/s3/sources/chat/type.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo'; +import { UploadFileByBodySchema } from '../../type'; export const ChatFileUploadSchema = z.object({ appId: ObjectIdSchema, @@ -22,9 +23,10 @@ export const UploadChatFileSchema = z.object({ appId: ObjectIdSchema, chatId: z.string().nonempty(), uId: z.string().nonempty(), - filename: z.string().nonempty(), - buffer: z.instanceof(Buffer), - contentType: z.string().optional() + filename: UploadFileByBodySchema.shape.filename, + body: UploadFileByBodySchema.shape.body, + contentType: UploadFileByBodySchema.shape.contentType, + expiredTime: UploadFileByBodySchema.shape.expiredTime }); export type UploadFileParams = z.infer; diff --git a/packages/service/common/s3/type.ts b/packages/service/common/s3/type.ts index b1abd43fa9..2a1f500226 100644 --- a/packages/service/common/s3/type.ts +++ b/packages/service/common/s3/type.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { Mimes } from './constants'; import type { S3BaseBucket } from './buckets/base'; +import { Readable } from 'node:stream'; export const S3MetadataSchema = z.object({ filename: z.string(), @@ -62,12 +63,14 @@ export const UploadImage2S3BucketParamsSchema = z.object({ }); export type UploadImage2S3BucketParams = z.infer; -export const UploadFileByBufferSchema = z.object({ - buffer: z.instanceof(Buffer), +export const UploadFileByBodySchema = z.object({ + body: z.union([z.instanceof(Buffer), z.string(), z.instanceof(Readable)]), contentType: z.string().optional(), - key: z.string().nonempty() + key: z.string().nonempty(), + filename: z.string().nonempty(), + expiredTime: z.date().optional() }); -export type UploadFileByBufferParams = z.infer; +export type UploadFileByBufferParams = z.infer; declare global { var s3BucketMap: { diff --git a/packages/service/core/ai/sandbox/schema.ts b/packages/service/core/ai/sandbox/schema.ts index 32dfbb3f1a..f84099fb94 100644 --- a/packages/service/core/ai/sandbox/schema.ts +++ b/packages/service/core/ai/sandbox/schema.ts @@ -54,7 +54,7 @@ const SandboxInstanceSchema = new Schema({ }); SandboxInstanceSchema.index( - { appId: 1, userId: 1, chatId: 1 }, + { provider: 1, appId: 1, userId: 1, chatId: 1 }, { unique: true, partialFilterExpression: { diff --git a/packages/service/core/ai/sandbox/toolCall.ts b/packages/service/core/ai/sandbox/toolCall.ts new file mode 100644 index 0000000000..ae3b65d6c1 --- /dev/null +++ b/packages/service/core/ai/sandbox/toolCall.ts @@ -0,0 +1,133 @@ +import { + SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL_NAME, + SandboxShellToolSchema, + SandboxGetFileUrlToolSchema +} from '@fastgpt/global/core/ai/sandbox/constants'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { parseJsonArgs } from '../utils'; +import { getSandboxClient } from './controller'; +import { getS3ChatSource } from '../../../common/s3/sources/chat'; +import path from 'path'; +import { jwtSignS3ObjectKey } from '../../../common/s3/utils'; +import { addHours } from 'date-fns'; +import { Readable } from 'stream'; +import { getLogger } from '@fastgpt-sdk/otel'; +import { LogCategories } from '../../../common/logger'; + +type SandboxToolCallParams = { + toolName: string; + rawArgs: string; + appId: string; + userId: string; + chatId: string; +}; + +export type SandboxToolCallResult = { + input: Record; + response: string; + durationSeconds: number; +}; + +/** + * 纯沙盒工具执行层。 + * 只负责调用沙盒、上传 S3 等底层操作,返回统一的执行结果,不绑定任何业务响应格式。 + */ +export const callSandboxTool = async ({ + toolName, + rawArgs, + appId, + userId, + chatId +}: SandboxToolCallParams): Promise => { + const startTime = Date.now(); + const getDuration = () => +((Date.now() - startTime) / 1000).toFixed(2); + + if (toolName === SANDBOX_TOOL_NAME) { + const parsed = SandboxShellToolSchema.safeParse(parseJsonArgs(rawArgs)); + if (!parsed.success) { + return { input: {}, response: parsed.error.message, durationSeconds: getDuration() }; + } + const { command, timeout } = parsed.data; + + try { + const instance = await getSandboxClient({ appId, userId, chatId }); + const result = await instance.exec(command, timeout); + + return { + input: { command, timeout }, + response: JSON.stringify({ + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode + }), + durationSeconds: getDuration() + }; + } catch (error: any) { + getLogger(LogCategories.MODULE.AI.AGENT).error('[Sandbox Shell] Execution failed', { error }); + return { + input: { command, timeout }, + response: getErrText(error), + durationSeconds: getDuration() + }; + } + } + + if (toolName === SANDBOX_GET_FILE_URL_TOOL_NAME) { + const parsed = SandboxGetFileUrlToolSchema.safeParse(parseJsonArgs(rawArgs)); + if (!parsed.success) { + return { input: {}, response: parsed.error.message, durationSeconds: getDuration() }; + } + + const { paths } = parsed.data; + + try { + const instance = await getSandboxClient({ appId, userId, chatId }); + + const result = await Promise.all( + paths.map(async (url) => { + const filename = path.basename(url); + const stream = instance.provider.readFileStream(url); + const readable = Readable.from(stream); // AsyncIterable → Readable + + const chatBucket = getS3ChatSource(); + const expiredTime = addHours(new Date(), 2); + const { key } = await chatBucket.uploadChatFile({ + appId, + chatId, + uId: userId, + filename, + body: readable, + expiredTime: expiredTime + }); + const fileUrl = jwtSignS3ObjectKey(key, expiredTime); + + return { + fileUrl, + filename + }; + }) + ); + + return { + input: { paths }, + response: JSON.stringify(result), + durationSeconds: getDuration() + }; + } catch (error) { + getLogger(LogCategories.MODULE.AI.AGENT).error('[Sandbox Get File URL] failed', { error }); + + return { + input: { paths }, + response: `Get file URL error: ${getErrText(error)}`, + durationSeconds: getDuration() + }; + } + } + + return { + input: {}, + response: `Unknown sandbox tool: ${toolName}`, + durationSeconds: getDuration() + }; +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts index fcc8d64ffb..f842e26e6d 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts @@ -14,7 +14,6 @@ import { dispatchTool } from '../sub/tool'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { DatasetSearchToolSchema } from '../sub/dataset/utils'; import { dispatchAgentDatasetSearch } from '../sub/dataset'; -import { dispatchSandboxShell } from '../sub/sandbox'; import type { DispatchAgentModuleProps } from '..'; import { getLLMModel } from '../../../../../ai/model'; import { getStepCallQuery, getStepDependon } from './dependon'; @@ -33,8 +32,11 @@ import { dispatchApp, dispatchPlugin } from '../sub/app'; import { getLogger, LogCategories } from '../../../../../../common/logger'; import { SandboxShellToolSchema, - SANDBOX_TOOL_NAME + SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL_NAME, + SandboxGetFileUrlToolSchema } from '@fastgpt/global/core/ai/sandbox/constants'; +import { dispatchSandboxShell, dispatchSandboxGetFileUrl } from '../sub/sandbox'; type Response = { stepResponse?: { @@ -406,6 +408,32 @@ export const masterCall = async ({ usages: result.usages }; } + if (toolId === SANDBOX_GET_FILE_URL_TOOL_NAME) { + const toolParams = SandboxGetFileUrlToolSchema.safeParse( + parseJsonArgs(call.function.arguments) + ); + if (!toolParams.success) { + return { + response: toolParams.error.message, + usages: [] + }; + } + + const result = await dispatchSandboxGetFileUrl({ + paths: toolParams.data.paths, + appId: runningAppInfo.id, + userId: props.uid, + chatId, + lang: props.lang + }); + + childrenResponses.push(result.nodeResponse); + + return { + response: result.response, + usages: result.usages + }; + } if (toolId === SubAppIds.plan) { try { const toolArgs = await PlanAgentParamsSchema.safeParseAsync( diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts index 8708ec875d..552689b3e6 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts @@ -1,27 +1,58 @@ -import { getSandboxClient } from '../../../../../../ai/sandbox/controller'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; -import { getLogger, LogCategories } from '../../../../../../../common/logger'; import { SANDBOX_ICON, SANDBOX_NAME, - SANDBOX_TOOL_NAME + SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/constants'; -import { getErrText } from '@fastgpt/global/common/error/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { callSandboxTool } from '../../../../../../ai/sandbox/toolCall'; -type SandboxShellParams = { - command: string; - timeout?: number; +type SandboxDispatchParams = { appId: string; userId: string; chatId: string; lang?: localeType; }; +type SandboxDispatchResult = { + response: string; + usages: ChatNodeUsageType[]; + nodeResponse: ChatHistoryItemResType; +}; + +const buildNodeResponse = ({ + toolId, + input, + response, + durationSeconds, + lang +}: { + toolId: string; + input: Record; + response: string; + durationSeconds: number; + lang?: localeType; +}): ChatHistoryItemResType => { + const nodeId = getNanoid(6); + return { + nodeId, + id: nodeId, + moduleType: FlowNodeTypeEnum.tool, + moduleName: parseI18nString(SANDBOX_NAME, lang), + moduleLogo: SANDBOX_ICON, + toolId, + toolInput: input, + toolRes: response, + totalPoints: 0, + runningTime: durationSeconds + }; +}; + export const dispatchSandboxShell = async ({ command, timeout, @@ -29,75 +60,57 @@ export const dispatchSandboxShell = async ({ userId, chatId, lang -}: SandboxShellParams): Promise<{ - response: string; - usages: ChatNodeUsageType[]; - nodeResponse: ChatHistoryItemResType; -}> => { - const startTime = Date.now(); - const nodeId = getNanoid(6); - const moduleName = parseI18nString(SANDBOX_NAME, lang); +}: SandboxDispatchParams & { + command: string; + timeout?: number; +}): Promise => { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: SANDBOX_TOOL_NAME, + rawArgs: JSON.stringify({ command, timeout }), + appId, + userId, + chatId + }); - try { - const sandboxInstance = await getSandboxClient({ - appId, - userId, - chatId - }); - - const result = await sandboxInstance.exec(command, timeout); - const response = JSON.stringify({ - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode - }); - - getLogger(LogCategories.MODULE.AI.AGENT).info('[Sandbox Shell] Command executed', { - command, - exitCode: result.exitCode, - stdoutLength: result.stdout?.length || 0, - stderrLength: result.stderr?.length || 0 - }); - - return { + return { + response, + usages: [], + nodeResponse: buildNodeResponse({ + toolId: SANDBOX_TOOL_NAME, + input, response, - usages: [], - nodeResponse: { - nodeId, - id: nodeId, - moduleType: FlowNodeTypeEnum.tool, - moduleName, - moduleLogo: SANDBOX_ICON, - toolId: SANDBOX_TOOL_NAME, - toolInput: { command, timeout }, - toolRes: response, - totalPoints: 0, - runningTime: +((Date.now() - startTime) / 1000).toFixed(2) - } - }; - } catch (error) { - getLogger(LogCategories.MODULE.AI.AGENT).error('[Sandbox Shell] Execution failed', { error }); - - const errorResponse = JSON.stringify({ - stdout: '', - stderr: getErrText(error), - exitCode: -1 - }); - - return { - response: errorResponse, - usages: [], - nodeResponse: { - nodeId, - id: nodeId, - moduleType: FlowNodeTypeEnum.tool, - moduleName, - moduleLogo: SANDBOX_ICON, - toolInput: { command, timeout }, - toolRes: errorResponse, - totalPoints: 0, - runningTime: +((Date.now() - startTime) / 1000).toFixed(2) - } - }; - } + durationSeconds, + lang + }) + }; +}; + +export const dispatchSandboxGetFileUrl = async ({ + paths, + appId, + userId, + chatId, + lang +}: SandboxDispatchParams & { + paths: string[]; +}): Promise => { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: SANDBOX_GET_FILE_URL_TOOL_NAME, + rawArgs: JSON.stringify({ paths }), + appId, + userId, + chatId + }); + + return { + response, + usages: [], + nodeResponse: buildNodeResponse({ + toolId: SANDBOX_GET_FILE_URL_TOOL_NAME, + input, + response, + durationSeconds, + lang + }) + }; }; diff --git a/packages/service/core/workflow/dispatch/ai/tool/constants.ts b/packages/service/core/workflow/dispatch/ai/tool/constants.ts index 247008eed8..03b583f8e9 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/constants.ts +++ b/packages/service/core/workflow/dispatch/ai/tool/constants.ts @@ -20,12 +20,14 @@ Image:{{imgCount}} export const getSandboxToolWorkflowResponse = ({ name, logo, + toolId = SANDBOX_TOOL_NAME, input, response, durationSeconds }: { name: string; logo: string; + toolId?: string; input: Record; response: string; durationSeconds: number; @@ -36,7 +38,7 @@ export const getSandboxToolWorkflowResponse = ({ moduleName: name, moduleType: FlowNodeTypeEnum.tool, moduleLogo: logo, - toolId: SANDBOX_TOOL_NAME, + toolId, toolInput: input, toolRes: response, totalPoints: 0, diff --git a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts index 7ffe2db30d..e3742a3d2e 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts @@ -18,16 +18,16 @@ import { runAgentCall } from '../../../../ai/llm/agentCall'; import type { ToolCallChildrenInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema'; import { - SANDBOX_SHELL_TOOL, - SandboxShellToolSchema, SANDBOX_SYSTEM_PROMPT, SANDBOX_ICON, - SANDBOX_NAME, - SANDBOX_TOOL_NAME + SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL_NAME, + SANDBOX_TOOLS } from '@fastgpt/global/core/ai/sandbox/constants'; -import { getSandboxClient } from '../../../../ai/sandbox/controller'; import { getSandboxToolWorkflowResponse } from './constants'; -import { getErrText } from '@fastgpt/global/common/error/utils'; +import { callSandboxTool } from '../../../../ai/sandbox/toolCall'; +import { systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; type ResponseType = { requestIds: string[]; @@ -121,7 +121,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise m.role === 'system'); @@ -135,10 +135,11 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise { - if (name === SANDBOX_TOOL_NAME) { + const systemTool = systemSubInfo[name]; + if (systemTool) { return { - name: SANDBOX_NAME[workflowProps.lang || 'zh-CN'] || SANDBOX_TOOL_NAME, - avatar: SANDBOX_ICON + name: parseI18nString(systemTool.name, workflowProps.lang), + avatar: systemTool.avatar }; } @@ -240,7 +241,6 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise { const tool = getToolInfo(call.function?.name); - const startTime = Date.now(); const { response, @@ -250,41 +250,29 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise { - // 拦截 sandbox_shell 调用 - if (call.function?.name === SANDBOX_TOOL_NAME) { - try { - const params = SandboxShellToolSchema.parse(parseJsonArgs(call.function.arguments)); + // 拦截 sandbox 工具调用 + if ( + call.function?.name === SANDBOX_TOOL_NAME || + call.function?.name === SANDBOX_GET_FILE_URL_TOOL_NAME + ) { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: call.function.name, + rawArgs: call.function.arguments ?? '', + appId: String(workflowProps.runningAppInfo.id), + userId: String(workflowProps.uid), + chatId: workflowProps.chatId + }); - const instance = await getSandboxClient({ - appId: String(workflowProps.runningAppInfo.id), - userId: String(workflowProps.uid), - chatId: workflowProps.chatId - }); - const result = await instance.exec(params.command, params.timeout); + const flowResponse = getSandboxToolWorkflowResponse({ + name: tool.name, + logo: SANDBOX_ICON, + toolId: call.function.name, + input, + response, + durationSeconds + }); - const stringToolResponse = JSON.stringify({ - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode - }); - - const flowResponse = getSandboxToolWorkflowResponse({ - name: tool.name, - logo: SANDBOX_ICON, - input: params, - response: stringToolResponse, - durationSeconds: +((Date.now() - startTime) / 1000).toFixed(2) - }); - - return { - response: stringToolResponse, - flowResponse - }; - } catch (error) { - return { - response: `Sandbox execution error: ${getErrText(error)}` - }; - } + return { response, flowResponse }; } else { const toolNode = tool?.rawData; diff --git a/packages/service/package.json b/packages/service/package.json index b20793ee35..db15d31cba 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.2", - "@fastgpt-sdk/sandbox-adapter": "^0.0.33", + "@fastgpt-sdk/sandbox-adapter": "^0.0.34", "@fastgpt-sdk/otel": "catalog:", "@fastgpt-sdk/storage": "catalog:", "@fastgpt/global": "workspace:*", diff --git a/packages/web/components/v2/common/MyModal/index.tsx b/packages/web/components/v2/common/MyModal/index.tsx index 453ba88675..2ce0933dbd 100644 --- a/packages/web/components/v2/common/MyModal/index.tsx +++ b/packages/web/components/v2/common/MyModal/index.tsx @@ -43,7 +43,7 @@ const MyModal = ({ showCloseButton = true, contentPx = '8', contentPy = '8', - headerPx, + headerPx = 0, ...props }: MyModalProps) => { const { isPc } = useSystem(); @@ -99,7 +99,7 @@ const MyModal = ({ fontWeight={'500'} mb={6} py={0} - px={headerPx ?? contentPx} + px={headerPx} gap={3} > {iconSrc && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17b6583710..22ea9139f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,8 +250,8 @@ importers: specifier: 'catalog:' version: 0.1.0 '@fastgpt-sdk/sandbox-adapter': - specifier: ^0.0.33 - version: 0.0.33 + specifier: ^0.0.34 + version: 0.0.34 '@fastgpt-sdk/storage': specifier: 'catalog:' version: 0.6.15(@opentelemetry/api@1.9.0)(@types/node@24.0.13)(jiti@2.6.0)(lightningcss@1.30.1)(proxy-agent@6.5.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) @@ -2735,8 +2735,8 @@ packages: '@fastgpt-sdk/plugin@0.3.8': resolution: {integrity: sha512-GjKrXMHxeF5UMkYGXawrUpzZjVRw3DICNYODeYwsUVOy+/ltu5zuwsqLkuuGQ7Arp/SBCmYRjG/MHmeNp4xxfw==} - '@fastgpt-sdk/sandbox-adapter@0.0.33': - resolution: {integrity: sha512-XsfMn5rdxFMTyNuQjp97vJcQBeJIphZ+qtpVxcy4pABSk7B90Xf8nYCZ09OLTfcbql+flQ4P3C9HoDIi64ZMkA==} + '@fastgpt-sdk/sandbox-adapter@0.0.34': + resolution: {integrity: sha512-YXCwycqs2yByOPUMMjm2tf0BYUJfLR9D4bvHDv6xIbfKT5btT+hR1pujW5nVawXJDefYNsDfLy8dQ+IkMd21xQ==} engines: {node: '>=18'} '@fastgpt-sdk/storage@0.6.15': @@ -8801,8 +8801,8 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.2: @@ -13745,7 +13745,7 @@ snapshots: '@fortaine/fetch-event-source': 3.0.6 zod: 4.1.12 - '@fastgpt-sdk/sandbox-adapter@0.0.33': + '@fastgpt-sdk/sandbox-adapter@0.0.34': dependencies: '@e2b/code-interpreter': 2.4.0 @@ -19329,7 +19329,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 @@ -21125,7 +21125,7 @@ snapshots: mimic-response@4.0.0: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 diff --git a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx index c4ca3f878f..6aae58ddf1 100644 --- a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx +++ b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx @@ -9,6 +9,7 @@ import Icon from '@fastgpt/web/components/common/Icon'; import { useSendCode } from '@/web/support/user/hooks/useSendCode'; import { useUserStore } from '@/web/support/user/useUserStore'; import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; type FormType = { contact: string; @@ -61,7 +62,7 @@ const UpdateContactModal = ({ } ); - const { SendCodeBox } = useSendCode({ type: 'bindNotification' }); + const { SendCodeBox } = useSendCode({ type: UserAuthTypeEnum.bindNotification }); const placeholder = feConfigs?.bind_notification_method ?.map((item) => { diff --git a/projects/app/src/pages/api/system/file/[jwt].ts b/projects/app/src/pages/api/system/file/[jwt].ts index bfcaa4aaa9..e9e55538f5 100644 --- a/projects/app/src/pages/api/system/file/[jwt].ts +++ b/projects/app/src/pages/api/system/file/[jwt].ts @@ -40,12 +40,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - if (metadata) { + if (metadata?.contentType) { res.setHeader('Content-Type', metadata.contentType); } if (metadata?.contentLength) { res.setHeader('Content-Length', metadata.contentLength); } + if (metadata?.filename) { + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(metadata.filename)}"` + ); + } res.setHeader('Cache-Control', 'public, max-age=31536000'); stream.pipe(res);