mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-07 01:02:55 +08:00
fix: http tool schema (#6768)
* fix: http tool schema * perf: del dataset * perf: review * add test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -39,4 +39,4 @@ description: 'FastGPT V4.14.11 更新说明'
|
||||
8. 工作流代码运行节点,AI 生成代码后,会讲输出值的 id 全部替换,优化成相同 key 的 id 不替换。
|
||||
9. 工作流中,父级节点受到辅助线自动对齐时候,其子节点位置会偏移。
|
||||
10. 评估列表权限过滤未覆盖继承权限。
|
||||
11. MCP 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。
|
||||
11. MCP 工具和 Http 工具 raw schema 未成功保存,导致工具调用时候,schema 不准确。
|
||||
@@ -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",
|
||||
|
||||
@@ -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<NodeToolConfigType['httpTool']>
|
||||
):
|
||||
| {
|
||||
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<HttpToolConfigType[]> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}): Promise<AppSchemaType[]> => {
|
||||
return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean();
|
||||
};
|
||||
@@ -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<string, boolean>;
|
||||
}) => {
|
||||
}): Promise<AppSchemaType[]> => {
|
||||
return MongoApp.find({ teamId, _id: { $in: ids } }, field).lean();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 批量删除函数
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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<string, (typeof toolsets)[number]>();
|
||||
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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, any>) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user