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 b6d84e2888..e014eed667 100644 --- a/document/content/docs/self-host/upgrading/4-14/41410.mdx +++ b/document/content/docs/self-host/upgrading/4-14/41410.mdx @@ -9,12 +9,13 @@ description: 'FastGPT V4.14.10 更新说明' 以下针对的是 `docker compose` 部署方案的配置调整,使用`sealos`的商业版用户,可私信支持人员,提供在线的沙盒服务方案。 -打开[最新 yml 部署文件](https://github.com/labring/FastGPT/blob/main/deploy/docker/cn/docker-compose.pg.yml),调整以下内容: +参考[最新 yml 部署文件](https://github.com/labring/FastGPT/blob/main/deploy/docker/cn/docker-compose.pg.yml),调整本地 yml 文件,加入以下内容: 1. 在文件顶部增加 `x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken'` 变量配置。 -2. 增加 3 组 services: `opensandbox-server`,`volume-manager`,`agent-sandbox-image` -3. 增加 `configs`, 文件底部可找到该内容,直接复制添加。 -4. 修改 `fastgpt-app`/`fastgpt-pro` 环境变量, 增加以下变量: +2. 增加 5 组 services: `opensandbox-server`,`opensandbox-agent-sandbox-image`,`opensandbox-execd-image`,`opensandbox-egress-image`,`fastgpt-volume-manager` +3. 调整 `networks`,可参考最新的 yml 完全修改。 +4. 增加 `configs`配置, 文件底部可找到该内容,直接复制添加。 +5. 修改 `fastgpt-app`/`fastgpt-pro` 环境变量, 增加以下变量: ```bash # ==================== Agent sandbox 配置 ==================== @@ -25,6 +26,7 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY: AGENT_SANDBOX_OPENSANDBOX_RUNTIME: docker AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: v0.1 +AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: true # Volume 持久化配置(opensandbox provider 下可选) AGENT_SANDBOX_ENABLE_VOLUME: true AGENT_SANDBOX_VOLUME_MANAGER_URL: http://volume-manager:3000 @@ -39,7 +41,7 @@ AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token ### 3. 更新镜像 tag -- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.10.2 +- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.10.4 - 更新 fastpgt-pro(商业版) 镜像 tag: v4.14.10 - 更新 code-sandbox 镜像 tag: v4.14.10 - 更新 fastgpt-plugin 镜像 tag: v0.5.6 diff --git a/document/content/docs/self-host/upgrading/4-14/41411.mdx b/document/content/docs/self-host/upgrading/4-14/41411.mdx index b06fec8e18..c19b448e4c 100644 --- a/document/content/docs/self-host/upgrading/4-14/41411.mdx +++ b/document/content/docs/self-host/upgrading/4-14/41411.mdx @@ -39,4 +39,4 @@ description: 'FastGPT V4.14.11 更新说明' 8. 工作流代码运行节点,AI 生成代码后,会讲输出值的 id 全部替换,优化成相同 key 的 id 不替换。 9. 工作流中,父级节点受到辅助线自动对齐时候,其子节点位置会偏移。 10. 评估列表权限过滤未覆盖继承权限。 -11. MCP 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。 \ No newline at end of file +11. MCP 工具和 Http 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。 \ No newline at end of file diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 1d84d955f7..34e65cb468 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -73,8 +73,8 @@ "document/content/docs/introduction/guide/dashboard/workflow/laf.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/introduction/guide/dashboard/workflow/loop.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/loop.mdx": "2025-09-17T22:29:56+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/parallel_run.en.mdx": "2026-04-17T15:12:11+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/parallel_run.mdx": "2026-04-17T15:12:11+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/parallel_run.en.mdx": "2026-04-17T23:28:43+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/parallel_run.mdx": "2026-04-17T23:28:43+08:00", "document/content/docs/introduction/guide/dashboard/workflow/question_classify.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/question_classify.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/introduction/guide/dashboard/workflow/reply.en.mdx": "2026-02-26T22:14:30+08:00", @@ -155,8 +155,8 @@ "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", "document/content/docs/self-host/config/model/siliconCloud.mdx": "2026-03-19T14:09:03+08:00", - "document/content/docs/self-host/config/object-storage.en.mdx": "2026-04-13T17:52:30+08:00", - "document/content/docs/self-host/config/object-storage.mdx": "2026-04-13T17:52:30+08:00", + "document/content/docs/self-host/config/object-storage.en.mdx": "2026-04-17T23:28:43+08:00", + "document/content/docs/self-host/config/object-storage.mdx": "2026-04-17T23:28:43+08:00", "document/content/docs/self-host/config/signoz.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/config/signoz.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/custom-models/bge-rerank.en.mdx": "2026-03-03T17:39:47+08:00", @@ -175,8 +175,8 @@ "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-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-04-16T15:22:51+08:00", - "document/content/docs/self-host/deploy/docker.mdx": "2026-04-16T15:22:51+08:00", + "document/content/docs/self-host/deploy/docker.en.mdx": "2026-04-17T23:28:43+08:00", + "document/content/docs/self-host/deploy/docker.mdx": "2026-04-17T23:28:43+08:00", "document/content/docs/self-host/deploy/sealos.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/deploy/sealos.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/design/dataset.en.mdx": "2026-03-03T17:39:47+08:00", @@ -223,8 +223,8 @@ "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.en.mdx": "2026-03-31T23:15:29+08:00", - "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-04-08T16:15:25+08:00", - "document/content/docs/self-host/upgrading/4-14/41411.mdx": "2026-04-17T17:46:20+08:00", + "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-04-18T19:42:07+08:00", + "document/content/docs/self-host/upgrading/4-14/41411.mdx": "2026-04-18T19:42:07+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", @@ -385,8 +385,8 @@ "document/content/docs/self-host/upgrading/outdated/499.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00", - "document/content/docs/toc.en.mdx": "2026-04-17T15:12:11+08:00", - "document/content/docs/toc.mdx": "2026-04-17T15:12:11+08:00", + "document/content/docs/toc.en.mdx": "2026-04-17T23:28:43+08:00", + "document/content/docs/toc.mdx": "2026-04-17T23:28:43+08:00", "document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00", "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", diff --git a/packages/global/core/app/tool/httpTool/utils.ts b/packages/global/core/app/tool/httpTool/utils.ts index edc236c378..ff6cb76d93 100644 --- a/packages/global/core/app/tool/httpTool/utils.ts +++ b/packages/global/core/app/tool/httpTool/utils.ts @@ -8,6 +8,7 @@ import { type StoreSecretValueType } from '../../../../common/secret/type'; import { type JsonSchemaPropertiesItemType } from '../../jsonschema'; import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../workflow/constants'; import { i18nT } from '../../../../../web/i18n/utils'; +import type { NodeToolConfigType } from '../../../workflow/type/node'; export const getHTTPToolSetRuntimeNode = ({ name, @@ -89,6 +90,25 @@ export const getHTTPToolRuntimeNode = ({ }; }; +export const parseHttpToolConfig = ( + config: NonNullable +): + | { + toolsetId: string; + toolName: string; + } + | undefined => { + const prefix = `${AppToolSourceEnum.http}-`; + if (!config.toolId.startsWith(prefix)) return undefined; + const [toolsetId, ...rest] = config.toolId.slice(prefix.length).split('/'); + const toolName = rest.join('/'); + if (!toolsetId || !toolName) return undefined; + return { + toolsetId, + toolName + }; +}; + export const pathData2ToolList = async ( pathData: PathDataType[] ): Promise => { diff --git a/packages/global/core/app/tool/mcpTool/utils.ts b/packages/global/core/app/tool/mcpTool/utils.ts index 6f6f7de5e2..77ff4a52b6 100644 --- a/packages/global/core/app/tool/mcpTool/utils.ts +++ b/packages/global/core/app/tool/mcpTool/utils.ts @@ -92,7 +92,8 @@ export const parsetMcpToolConfig = ( | undefined => { const prefix = `${AppToolSourceEnum.mcp}-`; if (!config.toolId.startsWith(prefix)) return undefined; - const [toolsetId, toolName] = config.toolId.slice(prefix.length).split('/'); + const [toolsetId, ...rest] = config.toolId.slice(prefix.length).split('/'); + const toolName = rest.join('/'); if (!toolsetId || !toolName) return undefined; return { toolsetId, diff --git a/packages/service/core/app/tool/httpTool/entity.ts b/packages/service/core/app/tool/httpTool/entity.ts new file mode 100644 index 0000000000..401b4c2f71 --- /dev/null +++ b/packages/service/core/app/tool/httpTool/entity.ts @@ -0,0 +1,14 @@ +import type { AppSchemaType } from '@fastgpt/global/core/app/type'; +import { MongoApp } from '../../schema'; + +export const getHttpToolsets = ({ + teamId, + ids, + field +}: { + teamId: string; + ids: string[]; + field?: Record; +}): Promise => { + return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean(); +}; diff --git a/packages/service/core/app/tool/mcpTool/entity.ts b/packages/service/core/app/tool/mcpTool/entity.ts index 5a1550a4b8..c633327f91 100644 --- a/packages/service/core/app/tool/mcpTool/entity.ts +++ b/packages/service/core/app/tool/mcpTool/entity.ts @@ -1,3 +1,4 @@ +import type { AppSchemaType } from '@fastgpt/global/core/app/type'; import { MongoApp } from '../../schema'; export const getMcpToolsets = ({ @@ -8,6 +9,6 @@ export const getMcpToolsets = ({ teamId: string; ids: string[]; field?: Record; -}) => { +}): Promise => { return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean(); }; diff --git a/packages/service/core/dataset/delete/processor.ts b/packages/service/core/dataset/delete/processor.ts index f4ea87c3da..ef39910739 100644 --- a/packages/service/core/dataset/delete/processor.ts +++ b/packages/service/core/dataset/delete/processor.ts @@ -43,25 +43,32 @@ export const deleteTeamAllDatasets = async (teamId: string) => { teamId, datasetIds: datasets.map((d) => d._id) }); - await MongoDataset.updateMany( - { - teamId - }, - { - $set: { - deleteTime: new Date() + + await mongoSessionRun(async (session) => { + await MongoDataset.updateMany( + { + teamId + }, + { + $set: { + deleteTime: new Date() + } + }, + { + session } - } - ); - await Promise.all( - datasets.map((dataset) => { - if (dataset.parentId) return; - return addDatasetDeleteJob({ - teamId, - datasetId: dataset._id - }); - }) - ); + ); + await Promise.all( + datasets.map((dataset) => { + // 有 parentId 的忽略,只需要删 root 下的即可。 + if (dataset.parentId) return; + return addDatasetDeleteJob({ + teamId, + datasetId: dataset._id + }); + }) + ); + }); }; // 批量删除函数 diff --git a/packages/service/core/workflow/dispatch/utils.ts b/packages/service/core/workflow/dispatch/utils.ts index 9ab1bf003b..8f817b768a 100644 --- a/packages/service/core/workflow/dispatch/utils.ts +++ b/packages/service/core/workflow/dispatch/utils.ts @@ -25,7 +25,10 @@ import { import { getNanoid } from '@fastgpt/global/common/string/tools'; import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils'; -import { getHTTPToolRuntimeNode } from '@fastgpt/global/core/app/tool/httpTool/utils'; +import { + getHTTPToolRuntimeNode, + parseHttpToolConfig +} from '@fastgpt/global/core/app/tool/httpTool/utils'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { MongoApp } from '../../../core/app/schema'; import { getMCPChildren } from '../../../core/app/mcp'; @@ -40,6 +43,7 @@ import { presignVariablesFileUrls } from '../../chat/utils'; import { getSystemTime } from '@fastgpt/global/common/time/timezone'; import { parsetMcpToolConfig } from '@fastgpt/global/core/app/tool/mcpTool/utils'; import { getMcpToolsets } from '../../app/tool/mcpTool/entity'; +import { getHttpToolsets } from '../../app/tool/httpTool/entity'; import { getHTTPToolList } from '../../app/http'; /* get system variable */ @@ -396,7 +400,7 @@ export const rewriteRuntimeWorkFlow = async ({ }) => { /* Toolset 展开 */ // TODO: 待性能优化 - { + const parseToolset = async () => { const toolSetNodes = nodes.filter((node) => node.flowNodeType === FlowNodeTypeEnum.toolSet); if (toolSetNodes.length > 0) { const nodeIdsToRemove = new Set(); @@ -481,10 +485,10 @@ export const rewriteRuntimeWorkFlow = async ({ } } } - } + }; /* MCP tool 获取原始 schema 加入到 jsonschema 字段里 */ - { + const parseMcpTool = async () => { const mcpToolNodes = nodes.filter( (node) => node.flowNodeType === FlowNodeTypeEnum.tool && node.toolConfig?.mcpTool ); @@ -517,7 +521,45 @@ export const rewriteRuntimeWorkFlow = async ({ node.jsonSchema = toolRaw.inputSchema; node.intro = toolRaw.description; }); - } + }; + + /* Http tool 获取原始 schema 加入到 jsonschema 字段里 */ + const parseHttpTool = async () => { + const httpToolNodes = nodes.filter( + (node) => node.flowNodeType === FlowNodeTypeEnum.tool && node.toolConfig?.httpTool + ); + const parseHttpToolConfigs = httpToolNodes + .map((node) => parseHttpToolConfig(node.toolConfig?.httpTool!)) + .filter(Boolean) as { toolsetId: string; toolName: string }[]; + // 批量获取 toolset + const toolsets = await getHttpToolsets({ + teamId, + ids: parseHttpToolConfigs.map((config) => config.toolsetId), + field: { + _id: true, + modules: true + } + }); + const toolsetMap = new Map(); + toolsets.forEach((toolset) => { + toolsetMap.set(String(toolset._id), toolset); + }); + httpToolNodes.forEach((node) => { + const httpTool = node.toolConfig?.httpTool; + if (!httpTool) return; + const parseResult = parseHttpToolConfig(httpTool); + if (!parseResult) return; + const toolset = toolsetMap.get(parseResult.toolsetId); + const toolList = toolset?.modules?.[0].toolConfig?.httpToolSet?.toolList; + if (!toolList) return; + const toolRaw = toolList.find((tool) => tool.name === parseResult.toolName); + if (!toolRaw) return; + node.jsonSchema = toolRaw.requestSchema; + node.intro = toolRaw.description; + }); + }; + + await Promise.all([parseToolset(), parseMcpTool(), parseHttpTool()]); }; export const getNodeErrResponse = ({ diff --git a/projects/app/src/pages/api/core/dataset/delete.ts b/projects/app/src/pages/api/core/dataset/delete.ts index da43039736..065d64b009 100644 --- a/projects/app/src/pages/api/core/dataset/delete.ts +++ b/projects/app/src/pages/api/core/dataset/delete.ts @@ -30,6 +30,10 @@ async function handler(req: ApiRequestProps) { fields: '_id' }); const datasetIds = deleteDatasets.map((d) => d._id); + await deleteDatasetsImmediate({ + teamId, + datasetIds + }); await mongoSessionRun(async (session) => { // 1. Mark as deleted @@ -46,11 +50,6 @@ async function handler(req: ApiRequestProps) { } ); - await deleteDatasetsImmediate({ - teamId, - datasetIds - }); - // 2. Add to delete queue await addDatasetDeleteJob({ teamId, diff --git a/test/cases/global/core/app/tool/httpTool/utils.test.ts b/test/cases/global/core/app/tool/httpTool/utils.test.ts index 5985455945..2d76595d00 100644 --- a/test/cases/global/core/app/tool/httpTool/utils.test.ts +++ b/test/cases/global/core/app/tool/httpTool/utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { getHTTPToolSetRuntimeNode, getHTTPToolRuntimeNode, + parseHttpToolConfig, pathData2ToolList } from '@fastgpt/global/core/app/tool/httpTool/utils'; import { @@ -159,6 +160,72 @@ describe('httpTool utils', () => { }); }); + describe('parseHttpToolConfig', () => { + it('should parse toolsetId and toolName from a valid toolId', () => { + const result = parseHttpToolConfig({ + toolId: 'http-toolset-456/someTool' + }); + + expect(result).toEqual({ toolsetId: 'toolset-456', toolName: 'someTool' }); + }); + + it('should return undefined when toolId does not match http- prefix pattern', () => { + const result = parseHttpToolConfig({ + toolId: 'mcp-foo/bar' + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when toolId has no slash separator', () => { + const result = parseHttpToolConfig({ + toolId: 'http-toolset-no-tool' + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when toolsetId segment is empty in toolId', () => { + const result = parseHttpToolConfig({ + toolId: 'http-/toolName' + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when toolName segment is empty in toolId', () => { + const result = parseHttpToolConfig({ + toolId: 'http-toolset-abc/' + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when toolId is empty string', () => { + const result = parseHttpToolConfig({ + toolId: '' + }); + + expect(result).toBeUndefined(); + }); + + it('should preserve slashes inside tool name', () => { + const result = parseHttpToolConfig({ + toolId: 'http-toolset-abc/namespace/nestedTool' + }); + + expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace/nestedTool' }); + }); + + it('should preserve multiple slashes inside tool name', () => { + const result = parseHttpToolConfig({ + toolId: 'http-toolset-xyz/a/b/c/d' + }); + + expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' }); + }); + }); + describe('pathData2ToolList', () => { it('should convert simple path data to tool list', async () => { const pathData: PathDataType[] = [ diff --git a/test/cases/global/core/app/tool/mcpTool/utils.test.ts b/test/cases/global/core/app/tool/mcpTool/utils.test.ts index 9e2d85fd28..63f038c4dc 100644 --- a/test/cases/global/core/app/tool/mcpTool/utils.test.ts +++ b/test/cases/global/core/app/tool/mcpTool/utils.test.ts @@ -191,12 +191,28 @@ describe('mcpTool utils', () => { expect(result).toBeUndefined(); }); - it('should parse toolsetId correctly when tool name contains additional slashes', () => { + it('should preserve slashes inside tool name', () => { const result = parsetMcpToolConfig({ toolId: 'mcp-toolset-abc/namespace/nestedTool' }); - expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace' }); + expect(result).toEqual({ toolsetId: 'toolset-abc', toolName: 'namespace/nestedTool' }); + }); + + it('should preserve multiple slashes inside tool name', () => { + const result = parsetMcpToolConfig({ + toolId: 'mcp-toolset-xyz/a/b/c/d' + }); + + expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' }); + }); + + it('should return undefined when toolName segment is empty in toolId', () => { + const result = parsetMcpToolConfig({ + toolId: 'mcp-toolset-abc/' + }); + + expect(result).toBeUndefined(); }); }); }); diff --git a/test/cases/service/core/app/tool/httpTool/entity.test.ts b/test/cases/service/core/app/tool/httpTool/entity.test.ts new file mode 100644 index 0000000000..a3fe42dafa --- /dev/null +++ b/test/cases/service/core/app/tool/httpTool/entity.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockMongoAppFind } = vi.hoisted(() => ({ + mockMongoAppFind: vi.fn() +})); + +vi.mock('@fastgpt/service/core/app/schema', () => ({ + MongoApp: { + find: mockMongoAppFind + } +})); + +import { getHttpToolsets } from '@fastgpt/service/core/app/tool/httpTool/entity'; + +const setupFindReturn = (result: any) => { + const leanFn = vi.fn().mockResolvedValue(result); + mockMongoAppFind.mockReturnValue({ lean: leanFn }); + return { leanFn }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getHttpToolsets', () => { + it('should query MongoApp with teamId, ids, and field, and return lean result', async () => { + const docs = [ + { _id: 'id1', modules: [{ toolConfig: { httpToolSet: { toolList: [] } } }] }, + { _id: 'id2', modules: [] } + ]; + const { leanFn } = setupFindReturn(docs); + + const field = { _id: true, modules: true }; + const res = await getHttpToolsets({ + teamId: 'team1', + ids: ['id1', 'id2'], + field + }); + + expect(mockMongoAppFind).toHaveBeenCalledTimes(1); + expect(mockMongoAppFind).toHaveBeenCalledWith( + { teamId: 'team1', _id: { $in: ['id1', 'id2'] } }, + field + ); + expect(leanFn).toHaveBeenCalledTimes(1); + expect(res).toBe(docs); + }); + + it('should pass undefined field when not provided', async () => { + setupFindReturn([]); + + await getHttpToolsets({ teamId: 'team1', ids: ['id1'] }); + + expect(mockMongoAppFind).toHaveBeenCalledWith( + { teamId: 'team1', _id: { $in: ['id1'] } }, + undefined + ); + }); + + it('should handle empty ids array', async () => { + const { leanFn } = setupFindReturn([]); + + const res = await getHttpToolsets({ teamId: 'team1', ids: [] }); + + expect(mockMongoAppFind).toHaveBeenCalledWith({ teamId: 'team1', _id: { $in: [] } }, undefined); + expect(leanFn).toHaveBeenCalledTimes(1); + expect(res).toEqual([]); + }); + + it('should propagate rejection from lean()', async () => { + const leanFn = vi.fn().mockRejectedValue(new Error('db error')); + mockMongoAppFind.mockReturnValue({ lean: leanFn }); + + await expect( + getHttpToolsets({ teamId: 'team1', ids: ['id1'], field: { _id: true } }) + ).rejects.toThrow('db error'); + }); +}); diff --git a/test/cases/service/core/workflow/dispatch/utils.test.ts b/test/cases/service/core/workflow/dispatch/utils.test.ts index 8c41b6e0c3..40bb93fd17 100644 --- a/test/cases/service/core/workflow/dispatch/utils.test.ts +++ b/test/cases/service/core/workflow/dispatch/utils.test.ts @@ -12,8 +12,11 @@ import { formatHttpError, rewriteRuntimeWorkFlow, getNodeErrResponse, - safePoints + safePoints, + getSystemVariables } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { encryptSecret } from '@fastgpt/service/common/secret/aes256gcm'; +import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { responseWrite } from '@fastgpt/service/common/response'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; @@ -51,6 +54,11 @@ vi.mock('@fastgpt/service/core/app/http', () => ({ getHTTPToolList: (...args: any[]) => mockGetHTTPToolList(...args) })); +const mockPresignVariablesFileUrls = vi.fn(); +vi.mock('@fastgpt/service/core/chat/utils', () => ({ + presignVariablesFileUrls: (...args: any[]) => mockPresignVariablesFileUrls(...args) +})); + describe('getWorkflowResponseWrite', () => { const mockRes = () => { const res: any = { closed: false }; @@ -804,6 +812,170 @@ describe('rewriteRuntimeWorkFlow', () => { expect(nodes.find((n) => n.nodeId === 'ts41')).toBeDefined(); expect(edges.filter((e) => e.target === 'ts40' || e.target === 'ts41').length).toBe(2); }); + + // Helper: route MongoApp.find responses by the toolsetId it queries, since + // parseMcpTool and parseHttpTool may both hit MongoApp.find in parallel. + const setupFindByIdMap = (idToDoc: Record) => { + mockMongoAppFind.mockImplementation((query: any) => { + const ids: string[] = query?._id?.$in ?? []; + const docs = ids.map((id) => idToDoc[id]).filter(Boolean); + return { lean: vi.fn().mockResolvedValue(docs) }; + }); + }; + + it('should inject jsonSchema and intro for standalone MCP tool nodes', async () => { + const mcpToolNode = makeNode('mcp1', FlowNodeTypeEnum.tool, { + toolConfig: { + mcpTool: { toolId: 'mcp-toolset-1/toolA' } + } + } as any); + const nodes = [mcpToolNode]; + const edges: RuntimeEdgeItemType[] = []; + + const toolAInputSchema = { + type: 'object', + properties: { x: { type: 'string' } } + }; + setupFindByIdMap({ + 'toolset-1': { + _id: 'toolset-1', + modules: [ + { + toolConfig: { + mcpToolSet: { + toolList: [ + { + name: 'toolA', + description: 'tool A description', + inputSchema: toolAInputSchema + } + ] + } + } + } + ] + } + }); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + + expect(mcpToolNode.jsonSchema).toEqual(toolAInputSchema); + expect(mcpToolNode.intro).toBe('tool A description'); + }); + + it('should inject jsonSchema and intro for standalone HTTP tool nodes', async () => { + const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, { + toolConfig: { + httpTool: { toolId: 'http-toolset-1/toolB' } + } + } as any); + const nodes = [httpToolNode]; + const edges: RuntimeEdgeItemType[] = []; + + const toolBRequestSchema = { + type: 'object', + properties: { y: { type: 'number' } } + }; + setupFindByIdMap({ + 'toolset-1': { + _id: 'toolset-1', + modules: [ + { + toolConfig: { + httpToolSet: { + toolList: [ + { + name: 'toolB', + description: 'tool B description', + requestSchema: toolBRequestSchema + } + ] + } + } + } + ] + } + }); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + + expect(httpToolNode.jsonSchema).toEqual(toolBRequestSchema); + expect(httpToolNode.intro).toBe('tool B description'); + }); + + it('should preserve tool names containing slashes when injecting schema', async () => { + const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, { + toolConfig: { + httpTool: { toolId: 'http-toolset-1/namespace/toolC' } + } + } as any); + const nodes = [httpToolNode]; + const edges: RuntimeEdgeItemType[] = []; + + setupFindByIdMap({ + 'toolset-1': { + _id: 'toolset-1', + modules: [ + { + toolConfig: { + httpToolSet: { + toolList: [ + { + name: 'namespace/toolC', + description: 'nested tool', + requestSchema: { type: 'object' } + } + ] + } + } + } + ] + } + }); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + + expect(httpToolNode.jsonSchema).toEqual({ type: 'object' }); + expect(httpToolNode.intro).toBe('nested tool'); + }); + + it('should skip schema injection when toolset is not found', async () => { + const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, { + toolConfig: { + httpTool: { toolId: 'http-missing/toolX' } + }, + intro: 'original', + jsonSchema: { type: 'original' } + } as any); + const nodes = [httpToolNode]; + const edges: RuntimeEdgeItemType[] = []; + + setupFindByIdMap({}); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + + expect(httpToolNode.jsonSchema).toEqual({ type: 'original' }); + expect(httpToolNode.intro).toBe('original'); + }); + + it('should skip schema injection when toolId prefix does not match', async () => { + const httpToolNode = makeNode('http1', FlowNodeTypeEnum.tool, { + toolConfig: { + httpTool: { toolId: 'mcp-toolset-1/toolA' } + }, + intro: 'original', + jsonSchema: { type: 'original' } + } as any); + const nodes = [httpToolNode]; + const edges: RuntimeEdgeItemType[] = []; + + setupFindByIdMap({}); + + await rewriteRuntimeWorkFlow({ teamId: 'team1', nodes, edges }); + + expect(httpToolNode.jsonSchema).toEqual({ type: 'original' }); + expect(httpToolNode.intro).toBe('original'); + }); }); describe('getNodeErrResponse', () => { @@ -893,3 +1065,306 @@ describe('safePoints', () => { expect(safePoints(undefined)).toBe(0); }); }); + +describe('getSystemVariables', () => { + const baseArgs = { + timezone: 'Asia/Shanghai', + runningAppInfo: { id: 'app-123' } as any, + chatId: 'chat-1', + responseChatItemId: 'rci-1', + histories: [], + uid: 'user-1', + chatConfig: {} as any, + variables: {} + }; + + it('should return only system variables when chatConfig has no variables', async () => { + const result = await getSystemVariables(baseArgs); + expect(result.userId).toBe('user-1'); + expect(result.appId).toBe('app-123'); + expect(result.chatId).toBe('chat-1'); + expect(result.responseChatItemId).toBe('rci-1'); + expect(result.histories).toEqual([]); + expect(typeof result.cTime).toBe('string'); + }); + + it('should return only system variables when chatConfig is undefined', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: undefined as any + }); + expect(result.userId).toBe('user-1'); + expect(result.appId).toBe('app-123'); + expect((result as any).custom).toBeUndefined(); + }); + + it('should coerce runningAppInfo.id to string', async () => { + const result = await getSystemVariables({ + ...baseArgs, + runningAppInfo: { id: 42 } as any + }); + expect(result.appId).toBe('42'); + }); + + it('should default histories to [] when not provided', async () => { + const { histories, ...rest } = baseArgs; + const result = await getSystemVariables(rest as any); + expect(result.histories).toEqual([]); + }); + + it('should preserve provided histories', async () => { + const histories = [{ obj: 'Human', value: [] }] as any; + const result = await getSystemVariables({ + ...baseArgs, + histories + }); + expect(result.histories).toBe(histories); + }); + + it('should decrypt password variable from item.label', async () => { + const encrypted = encryptSecret('plain-value'); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'pwdKey', + label: 'pwdLabel', + type: VariableInputEnum.password, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + pwdLabel: JSON.stringify({ value: '', secret: encrypted }) + } + }); + expect((result as any).pwdKey).toBe('plain-value'); + }); + + it('should decrypt password variable from item.key when label missing', async () => { + const encrypted = encryptSecret('key-value'); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'pwdKey', + label: 'pwdLabel', + type: VariableInputEnum.password, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + pwdKey: { value: '', secret: encrypted } + } + }); + expect((result as any).pwdKey).toBe('key-value'); + }); + + it('should fall back to defaultValue for password variable', async () => { + const encrypted = encryptSecret('default-value'); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'pwdKey', + label: 'pwdLabel', + type: VariableInputEnum.password, + valueType: WorkflowIOValueTypeEnum.string, + defaultValue: { value: '', secret: encrypted } + } + ] + } as any, + variables: {} + }); + expect((result as any).pwdKey).toBe('default-value'); + }); + + it('should map file variables to their url list', async () => { + mockPresignVariablesFileUrls.mockResolvedValueOnce({ + fileKey: [ + { key: 'a', url: 'http://example.com/a' }, + { key: 'b', url: 'http://example.com/b' } + ] + }); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'fileKey', + label: 'fileLabel', + type: VariableInputEnum.file, + valueType: WorkflowIOValueTypeEnum.arrayString + } + ] + } as any, + variables: { + fileKey: [{ key: 'a' }, { key: 'b' }] + } + }); + expect((result as any).fileKey).toEqual(['http://example.com/a', 'http://example.com/b']); + }); + + it('should return undefined for file variable when presign returns no entry', async () => { + mockPresignVariablesFileUrls.mockResolvedValueOnce({}); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'fileKey', + label: 'fileLabel', + type: VariableInputEnum.file, + valueType: WorkflowIOValueTypeEnum.arrayString + } + ] + } as any, + variables: {} + }); + expect((result as any).fileKey).toBeUndefined(); + }); + + it('should use variables[label] when provided (API input)', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'myKey', + label: 'myLabel', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + myLabel: 'from-label' + } + }); + expect((result as any).myKey).toBe('from-label'); + }); + + it('should use variables[key] when label missing (Web input)', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'myKey', + label: 'myLabel', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + myKey: 'from-key' + } + }); + expect((result as any).myKey).toBe('from-key'); + }); + + it('should fall back to defaultValue when neither label nor key provided', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'myKey', + label: 'myLabel', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string, + defaultValue: 'fallback' + } + ] + } as any, + variables: {} + }); + expect((result as any).myKey).toBe('fallback'); + }); + + it('should format value by valueType (number)', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'n', + label: 'nLabel', + type: VariableInputEnum.numberInput, + valueType: WorkflowIOValueTypeEnum.number + } + ] + } as any, + variables: { + nLabel: '42' + } + }); + expect((result as any).n).toBe(42); + }); + + it('should prefer label over key when both present', async () => { + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'dup', + label: 'dupLabel', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + dupLabel: 'label-wins', + dup: 'key-loses' + } + }); + expect((result as any).dup).toBe('label-wins'); + }); + + it('should process multiple variables in the same call', async () => { + mockPresignVariablesFileUrls.mockResolvedValueOnce({ + f: [{ key: 'k', url: 'http://example.com/k' }] + }); + const encrypted = encryptSecret('pwd-plain'); + const result = await getSystemVariables({ + ...baseArgs, + chatConfig: { + variables: [ + { + key: 'a', + label: 'aLabel', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: 'f', + label: 'fLabel', + type: VariableInputEnum.file, + valueType: WorkflowIOValueTypeEnum.arrayString + }, + { + key: 'p', + label: 'pLabel', + type: VariableInputEnum.password, + valueType: WorkflowIOValueTypeEnum.string + } + ] + } as any, + variables: { + aLabel: 'alpha', + f: [{ key: 'k' }], + pLabel: JSON.stringify({ value: '', secret: encrypted }) + } + }); + expect((result as any).a).toBe('alpha'); + expect((result as any).f).toEqual(['http://example.com/k']); + expect((result as any).p).toBe('pwd-plain'); + }); +});