Commit 602bdf85 by YeYuheng Committed by GitHub

fix: optimize agent v2 tool labels (#7096)

* fix: optimize agent v2 tool labels

* perf: code

* fix: toolset name

* test: update unified agent prompt expectation

* fix: stabilize agent tool mentions

---------

Co-authored-by: archer <545436317@qq.com>
parent cba10ce2
---
name: pr-change-analysis
description: 手动触发的 FastGPT PR 或本地分支变更梳理技能。仅当用户显式调用 $pr-change-analysis 时使用;用于 reviewer 分析一个 GitHub PR 或当前本地分支相对 upstream/main 的需求变更、影响范围、代码质量与代码风格,不用于自动审查触发。
---
# PR / 分支变更梳理
面向代码 reviewer,先从真实 diff 还原需求与实现边界,再判断是否有超出需求的改动、过度复杂的整理、测试与模块化风险、以及与相近代码不一致的风格。
## 基本原则
- 默认输出中文。
- 以 checked-out 代码和 diff 为准,不只复述 PR 标题或描述。
- 优先回答“这次到底改了什么、为什么有用、影响到哪里、哪里不像同一个需求应该带上的改动”。
- 不默认提交 review、批准、请求修改或推送代码;用户明确要求后才执行 GitHub 写操作。
- 不修改工作区代码;如为了 review 需要切分实验或临时命令,只做只读检查。
- 保留用户已有改动。切换 PR 前必须检查 `git status --short`,遇到未提交改动时先说明风险并让用户决定,不要自行 stash、reset 或 checkout 覆盖。
## 入口判断
1. 如果用户给了 PR URL 或编号:
-`gh pr view <pr>` 获取 `number,title,body,author,headRefName,baseRefName,files,additions,deletions,commits`
- 按用户要求用 `gh pr checkout <pr>` 切到该 PR 分支。
- `git fetch upstream main`,默认用 `upstream/main...HEAD` 做审查基准。
- 如果 PR 的 `baseRefName` 不是 `main`,在报告里明确说明,并根据上下文决定是否同时补充 `upstream/<baseRefName>...HEAD` 的对比。
2. 如果用户没有给 PR,只说当前分支:
- 确认当前分支名:`git branch --show-current`
- `git fetch upstream main`
- 使用 `git diff upstream/main...HEAD` 和相关统计作为审查基准。
3. 如果当前分支就是 `main` 或 diff 为空:
- 明确说明没有可分析的分支差异,必要时检查是否需要比较其他 base。
## 信息收集
优先用这些命令建立全局视角:
```bash
git status --short
git branch --show-current
git fetch upstream main
git diff --stat upstream/main...HEAD
git diff --name-status upstream/main...HEAD
git log --oneline --decorate upstream/main..HEAD
```
再按文件类型深入:
```bash
git diff upstream/main...HEAD -- <path>
git diff --word-diff=color upstream/main...HEAD -- <path>
rg -n "关键函数|关键类型|关键字段" <相关目录>
```
检查需求来源时优先读取:
- PR 标题、正文、commit message。
- 新增或修改的 API schema、数据库 schema、工作流节点定义、配置默认值、权限判断。
- 与变更文件相邻的旧实现或同类功能实现。
- 测试文件与缺失测试的高风险路径。
## 审查维度
### 1. 需求变更说明
需要用 reviewer 能快速理解的方式说明:
- 本次变更解决什么问题,给用户、管理员、开发者或系统运行带来什么作用。
- 核心行为从什么变成什么。
- 对外接口、数据结构、配置、权限、计费、工作流运行时、前端交互是否变化。
- 关键实现路径:入口在哪里,主要处理逻辑在哪里,最终落到哪些模块。
不要只列文件名。每个关键文件都要说明它承担的行为变化。
### 2. 是否超出需求影响范围
重点找这些信号:
- 与需求无关的格式化、重命名、移动文件、依赖升级、批量整理。
- 修改共享类型、全局工具、公共组件、运行时核心逻辑,但需求只需要局部修复。
- 同一个分支混入多个独立需求或历史清理。
- 行为改动被伪装成重构,导致旧路径兼容性、默认值、继承语义或保存边界改变。
- 删除兼容逻辑、改变错误处理、日志、权限、缓存、并发策略,但 PR 描述未解释。
结论要区分:
- `合理扩散`:为了实现需求必须改的上下游。
- `可疑越界`:看起来和需求无关,需要作者拆分或解释。
- `高风险越界`:可能导致回归,应要求拆出或补测试。
### 3. 代码质量
检查是否:
- 存在重复逻辑、临时硬编码、过长函数、混合多层职责。
- 可以被独立模块化测试,但实际写在 UI、API route 或分支逻辑里导致难测。
- 缺少边界条件、异常路径、空值、旧数据兼容、并发或幂等处理。
- 没有把契约放在正确边界,例如 FastGPT API 入参应使用 `parseApiInput`,共享 schema 应放在 `packages/global/openapi` 或相应共享层。
- 测试只覆盖 happy path,或者新增核心逻辑没有对应单测。
评价质量时给出具体文件和函数,不要泛泛说“需要优化”。
### 4. 代码风格与相近功能一致性
对比相邻实现,而不是只按个人偏好判断:
- API route、service、schema、hook、组件、i18n、错误处理、日志、权限判断是否沿用附近模式。
- 前端交互、Chakra UI 组件组织、状态命名、弹窗/表单/列表布局是否与同模块一致。
- 后端命名、目录分层、模型访问、事务/队列/缓存写法是否与同类代码一致。
- 注释是否解释设计原因,尤其是导出函数、核心业务函数、复杂 helper;避免无意义逐行注释。
- 是否引入与项目习惯不一致的新抽象、新工具或新依赖。
## 深挖方法
对每个核心改动至少做一次“调用链闭环”:
1. 从入口找触发点:页面、API route、worker、workflow dispatcher、service 方法或配置加载。
2. 追到状态/数据落点:数据库、缓存、请求体、响应体、运行时变量、前端 store。
3. 找相近功能:同目录同类 route、同类组件、同类工作流节点、同类模型配置。
4. 对比旧行为:用 `git show upstream/main:<path>``git diff` 确认不是误读。
5. 判断测试:现有测试是否覆盖这条路径;没有测试时说明缺口与风险。
如果发现大规模重构,先识别“纯移动/纯重命名/格式化”和“真实行为改动”,避免把噪音当成需求。
## 输出格式
最终报告优先用以下结构,按风险高低组织,不要写冗长总结报告:
```markdown
## 需求变更
- ...
## 关键实现路径
- `path/to/file.ts`: ...
## 影响范围与越界判断
- 合理扩散: ...
- 可疑越界: ...
- 高风险越界: ...
## 代码质量
- [风险等级] `path/to/file.ts`: 问题、原因、建议。
## 代码风格一致性
- ...
## 测试与验证缺口
- ...
## 需要作者确认的问题
- ...
```
问题项必须尽量带文件路径和行号。没有发现问题时也要明确写“未发现明显越界/质量/风格问题”,并列出仍未覆盖的验证盲区。
interface:
display_name: "PR Change Analysis"
short_description: "梳理 PR 或本地分支的需求、影响范围、质量与风格"
default_prompt: "Use $pr-change-analysis to summarize what this PR or current branch changed and whether the scope, quality, and style fit the requirement."
policy:
allow_implicit_invocation: false
...@@ -85,3 +85,39 @@ export const getToolRawId = (id: string) => { ...@@ -85,3 +85,39 @@ export const getToolRawId = (id: string) => {
// 兼容 toolset // 兼容 toolset
return toolId.split('/')[0]; return toolId.split('/')[0];
}; };
/**
* 拆分 MCP/HTTP 子工具 pluginId,保留 toolName 内部的 `/`。
* pluginId 格式为 appId/toolName;toolName 可能本身以 `/` 开头,例如 appId//test。
*/
export const splitToolsetToolPluginId = (pluginId: string) => {
const [parentId, ...toolNameParts] = pluginId.split('/');
return {
parentId,
toolName: toolNameParts.join('/')
};
};
/**
* 从完整组合工具 ID 中解析 MCP/HTTP 子工具信息。
*/
export const parseToolsetToolId = (id: string) => {
const { pluginId } = splitCombineToolId(id);
return splitToolsetToolPluginId(pluginId);
};
/**
* 生成工具名查找候选。优先使用完整 toolName;旧版 appId/toolsetName/toolName
* 持久化数据在完整名查不到时回退到最后一段。
*/
export const getToolNameCandidates = (toolName?: string) => {
if (!toolName) return [];
const candidates = [toolName];
const lastSegment = toolName.split('/').at(-1);
if (lastSegment && lastSegment !== toolName) {
candidates.push(lastSegment);
}
return candidates;
};
import type { I18nStringType, localeType } from '../../../../common/i18n/type'; import type { I18nStringType, localeType } from '../../../../common/i18n/type';
import { import { AGENT_SANDBOX_TOOLSET_ID, SANDBOX_ICON, SANDBOX_NAME } from '../../../ai/sandbox/tools';
AGENT_SANDBOX_TOOLSET_ID,
SANDBOX_ICON,
SANDBOX_NAME,
sandboxToolMap
} from '../../../ai/sandbox/tools';
import { parseI18nString } from '../../../../common/i18n/utils'; import { parseI18nString } from '../../../../common/i18n/utils';
export enum SubAppIds { export enum SubAppIds {
...@@ -37,31 +32,12 @@ export const systemSubInfo: Record< ...@@ -37,31 +32,12 @@ export const systemSubInfo: Record<
toolDescription: toolDescription:
'搜索知识库获取相关信息,当有相关知识库信息的时候可以使用此工具来对知识库进行检索' '搜索知识库获取相关信息,当有相关知识库信息的时候可以使用此工具来对知识库进行检索'
}, },
[SubAppIds.ask]: {
name: {
'zh-CN': '询问Agent',
'zh-Hant': '詢問Agent',
en: 'AskAgent'
},
avatar: 'core/workflow/template/agent',
toolDescription: '询问用户问题,并返回用户回答。'
},
[SubAppIds.model]: {
name: {
'zh-CN': '模型Agent',
'zh-Hant': '模型Agent',
en: 'ModelAgent'
},
avatar: 'core/workflow/template/agent',
toolDescription: '调用 LLM 模型完成一些通用任务。'
},
[AGENT_SANDBOX_TOOLSET_ID]: { [AGENT_SANDBOX_TOOLSET_ID]: {
name: SANDBOX_NAME, name: SANDBOX_NAME,
avatar: SANDBOX_ICON, avatar: SANDBOX_ICON,
toolDescription: toolDescription:
'提供完整虚拟机能力,包括命令执行、文件读写、文件编辑、文件搜索和文件链接生成。' '提供完整虚拟机能力,包括命令执行、文件读写、文件编辑、文件搜索和文件链接生成。'
}, }
...sandboxToolMap
}; };
export const getSystemToolInfo = (id: string, lang: localeType = 'en') => { export const getSystemToolInfo = (id: string, lang: localeType = 'en') => {
if (id in systemSubInfo) { if (id in systemSubInfo) {
......
...@@ -224,6 +224,17 @@ describe('httpTool utils', () => { ...@@ -224,6 +224,17 @@ describe('httpTool utils', () => {
expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' }); expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' });
}); });
it('should preserve leading slash in tool name', () => {
const result = parseHttpToolConfig({
toolId: 'http-69e20f48dbec7c6ece77556b//test'
});
expect(result).toEqual({
toolsetId: '69e20f48dbec7c6ece77556b',
toolName: '/test'
});
});
}); });
describe('pathData2ToolList', () => { describe('pathData2ToolList', () => {
......
...@@ -207,6 +207,17 @@ describe('mcpTool utils', () => { ...@@ -207,6 +207,17 @@ describe('mcpTool utils', () => {
expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' }); expect(result).toEqual({ toolsetId: 'toolset-xyz', toolName: 'a/b/c/d' });
}); });
it('should preserve leading slash in tool name', () => {
const result = parsetMcpToolConfig({
toolId: 'mcp-69e20f48dbec7c6ece77556b//test'
});
expect(result).toEqual({
toolsetId: '69e20f48dbec7c6ece77556b',
toolName: '/test'
});
});
it('should return undefined when toolName segment is empty in toolId', () => { it('should return undefined when toolName segment is empty in toolId', () => {
const result = parsetMcpToolConfig({ const result = parsetMcpToolConfig({
toolId: 'mcp-toolset-abc/' toolId: 'mcp-toolset-abc/'
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { splitCombineToolId, getToolRawId } from '@fastgpt/global/core/app/tool/utils'; import {
getToolNameCandidates,
getToolRawId,
parseToolsetToolId,
splitCombineToolId,
splitToolsetToolPluginId
} from '@fastgpt/global/core/app/tool/utils';
import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants'; import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants';
describe('splitCombineToolId', () => { describe('splitCombineToolId', () => {
...@@ -147,3 +153,44 @@ describe('getToolRawId', () => { ...@@ -147,3 +153,44 @@ describe('getToolRawId', () => {
expect(result).toBe('oldPlugin'); expect(result).toBe('oldPlugin');
}); });
}); });
describe('splitToolsetToolPluginId', () => {
it('should preserve slashes inside tool name', () => {
const result = splitToolsetToolPluginId('toolset-abc/namespace/nestedTool');
expect(result).toEqual({
parentId: 'toolset-abc',
toolName: 'namespace/nestedTool'
});
});
it('should preserve leading slash in tool name', () => {
const result = splitToolsetToolPluginId('69e20f48dbec7c6ece77556b//test');
expect(result).toEqual({
parentId: '69e20f48dbec7c6ece77556b',
toolName: '/test'
});
});
});
describe('parseToolsetToolId', () => {
it('should parse combined HTTP tool id and preserve leading slash in tool name', () => {
const result = parseToolsetToolId('http-69e20f48dbec7c6ece77556b//test');
expect(result).toEqual({
parentId: '69e20f48dbec7c6ece77556b',
toolName: '/test'
});
});
});
describe('getToolNameCandidates', () => {
it('should prefer full tool name and fallback to last segment for legacy ids', () => {
expect(getToolNameCandidates('toolset/tool')).toEqual(['toolset/tool', 'tool']);
});
it('should preserve leading slash name before fallback', () => {
expect(getToolNameCandidates('/test')).toEqual(['/test', 'test']);
});
});
...@@ -8,10 +8,8 @@ export const getMainAgentSystemPrompt = ({ ...@@ -8,10 +8,8 @@ export const getMainAgentSystemPrompt = ({
}: { }: {
systemPrompt?: string; systemPrompt?: string;
hasRuntimeTools: boolean; hasRuntimeTools: boolean;
}) => `<!-- Main Agent --> }) => `<role>
你是 Master Agent。
<role>
你是 FastGPT Main Agent。
你在一个工具循环中工作:阅读用户目标,调用工具获取信息或执行动作,维护计划状态,并在任务完成后给出最终回答。 你在一个工具循环中工作:阅读用户目标,调用工具获取信息或执行动作,维护计划状态,并在任务完成后给出最终回答。
</role> </role>
......
...@@ -6,7 +6,11 @@ import { AppFolderTypeList, AppTypeEnum } from '@fastgpt/global/core/app/constan ...@@ -6,7 +6,11 @@ import { AppFolderTypeList, AppTypeEnum } from '@fastgpt/global/core/app/constan
import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants'; import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants';
import { getHTTPToolRuntimeNode } from '@fastgpt/global/core/app/tool/httpTool/utils'; import { getHTTPToolRuntimeNode } from '@fastgpt/global/core/app/tool/httpTool/utils';
import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils'; import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils';
import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils'; import {
getToolNameCandidates,
splitCombineToolId,
splitToolsetToolPluginId
} from '@fastgpt/global/core/app/tool/utils';
import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { import {
FlowNodeTypeEnum, FlowNodeTypeEnum,
...@@ -168,7 +172,7 @@ export async function getChildAppPreviewNode({ ...@@ -168,7 +172,7 @@ export async function getChildAppPreviewNode({
} }
// mcp tool // mcp tool
else if (source === AppToolSourceEnum.mcp) { else if (source === AppToolSourceEnum.mcp) {
const [parentId, toolName] = pluginId.split('/'); const { parentId, toolName } = splitToolsetToolPluginId(pluginId);
// 1. get parentApp // 1. get parentApp
const item = await MongoApp.findById(parentId).lean(); const item = await MongoApp.findById(parentId).lean();
if (!item) return Promise.reject(PluginErrEnum.unExist); if (!item) return Promise.reject(PluginErrEnum.unExist);
...@@ -180,12 +184,17 @@ export async function getChildAppPreviewNode({ ...@@ -180,12 +184,17 @@ export async function getChildAppPreviewNode({
}); });
const toolConfig = version.nodes[0].toolConfig?.mcpToolSet; const toolConfig = version.nodes[0].toolConfig?.mcpToolSet;
const tool = await (async () => { const tool = await (async () => {
const matchTool = <T extends { name: string }>(tools: T[]) =>
getToolNameCandidates(toolName)
.map((name) => tools.find((item) => item.name === name))
.find(Boolean);
if (toolConfig?.toolList) { if (toolConfig?.toolList) {
// new mcp toolset // new mcp toolset
return toolConfig.toolList.find((item) => item.name === toolName); return matchTool(toolConfig.toolList);
} }
// old mcp toolset // old mcp toolset
return (await getMCPChildren(item)).find((item) => item.name === toolName); return matchTool(await getMCPChildren(item));
})(); })();
if (!tool) return Promise.reject(PluginErrEnum.unExist); if (!tool) return Promise.reject(PluginErrEnum.unExist);
return { return {
...@@ -215,7 +224,7 @@ export async function getChildAppPreviewNode({ ...@@ -215,7 +224,7 @@ export async function getChildAppPreviewNode({
} }
// http tool // http tool
else if (source === AppToolSourceEnum.http) { else if (source === AppToolSourceEnum.http) {
const [parentId, toolName] = pluginId.split('/'); const { parentId, toolName } = splitToolsetToolPluginId(pluginId);
const item = await MongoApp.findById(parentId).lean(); const item = await MongoApp.findById(parentId).lean();
if (!item) return Promise.reject(PluginErrEnum.unExist); if (!item) return Promise.reject(PluginErrEnum.unExist);
...@@ -227,7 +236,9 @@ export async function getChildAppPreviewNode({ ...@@ -227,7 +236,9 @@ export async function getChildAppPreviewNode({
const toolConfig = version.nodes[0].toolConfig?.httpToolSet; const toolConfig = version.nodes[0].toolConfig?.httpToolSet;
const tool = await (async () => { const tool = await (async () => {
if (toolConfig?.toolList) { if (toolConfig?.toolList) {
return toolConfig.toolList.find((item) => item.name === toolName); return getToolNameCandidates(toolName)
.map((name) => toolConfig.toolList.find((item) => item.name === name))
.find(Boolean);
} }
return undefined; return undefined;
})(); })();
......
import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils';
const toolReferenceReg = /\{\{@([^@{}]+)@\}\}/g;
export type ResolvePromptToolReferenceNameFn = (id: string) => string | undefined;
const getToolReferenceIdCandidates = (id: string) => {
const trimId = id.trim();
const candidates = [trimId];
try {
const { pluginId } = splitCombineToolId(trimId);
const runtimeId = pluginId.replace(/[^a-zA-Z0-9_-]/g, '');
// PromptEditor 保存的是组合工具 ID,runtime catalog 使用清洗后的 pluginId 作为工具 ID。
for (const candidate of [pluginId, runtimeId]) {
if (candidate && !candidates.includes(candidate)) {
candidates.push(candidate);
}
}
} catch {
// 非组合工具 ID 按原始值查询即可。
}
return candidates;
};
/**
* 将 PromptEditor 保存的工具引用 ID 转成人类可读工具名。
* 只依赖 prompt 引用解析器,不关心工具是否可执行,避免和 runtime 工具 catalog 耦合。
*/
export const replaceToolReferenceWithName = ({
text,
resolvePromptToolReferenceName
}: {
text: string;
resolvePromptToolReferenceName: ResolvePromptToolReferenceNameFn;
}) => {
return text.replace(toolReferenceReg, (raw, id: string) => {
const name = getToolReferenceIdCandidates(id)
.map((candidate) => resolvePromptToolReferenceName(candidate))
.find(Boolean);
return name ? `{{${name}}}` : raw;
});
};
/** /**
* workflow 适配层负责把用户配置和已选知识库整理成主 loop 可读的背景信息。 * workflow 适配层负责把用户配置和已选知识库整理成主 loop 可读的背景信息。
* 这里不包含任何 agent 角色或路由规则,避免把规划/路由 prompt 混入主上下文。 * 这里不包含任何 agent 角色或路由规则,避免把规划/路由 prompt 混入主上下文。
*/ */
export const parseUserSystemPrompt = ({ userSystemPrompt }: { userSystemPrompt?: string }) => { export const parseUserSystemPrompt = ({
userSystemPrompt,
resolvePromptToolReferenceName
}: {
userSystemPrompt?: string;
resolvePromptToolReferenceName: ResolvePromptToolReferenceNameFn;
}) => {
if (!userSystemPrompt) { if (!userSystemPrompt) {
return ''; return '';
} }
return `${userSystemPrompt} const readableSystemPrompt = replaceToolReferenceWithName({
text: userSystemPrompt,
resolvePromptToolReferenceName
});
return `${readableSystemPrompt}
请参考用户的任务信息来匹配是否和当前的 <user_background></user_background> 一致,如果一致请优先遵循参考的步骤安排和偏好 请参考用户的任务信息来匹配是否和当前的 <user_background></user_background> 一致,如果一致请优先遵循参考的步骤安排和偏好
如果和 <user_background></user_background> 没有任何关系则忽略参考信息。 如果和 <user_background></user_background> 没有任何关系则忽略参考信息。
......
...@@ -201,27 +201,22 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -201,27 +201,22 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
}); });
// system message 由 getMainAgentSystemPrompt 统一注入;历史里的 system 只作为外部噪音过滤掉。 // system message 由 getMainAgentSystemPrompt 统一注入;历史里的 system 只作为外部噪音过滤掉。
const loopMessages = historiesMessages.filter((message) => message.role !== 'system'); const loopMessages = historiesMessages.filter((message) => message.role !== 'system');
// 用户配置 prompt 和 sandbox prompt 作为 Main Agent 的 system 背景输入。
const formatedSystemPrompt = parseUserSystemPrompt({
userSystemPrompt: [systemPrompt, sandboxClient ? SANDBOX_SYSTEM_PROMPT : '']
.filter(Boolean)
.join('\n\n')
});
// 汇总用户选择工具、内置系统工具、知识库/文件工具和 sandbox tools。 // 汇总用户选择工具、内置系统工具、知识库/文件工具和 sandbox tools。
// completionTools 只描述给模型看,subAppsMap 则供 runtime 执行工具时定位真实实现。 // completionTools 只描述给模型看,subAppsMap 则供 runtime 执行工具时定位真实实现。
const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( const {
{ completionTools: agentCompletionTools,
tools: selectedTools, subAppsMap: agentSubAppsMap,
tmbId: runningAppInfo.tmbId, promptToolReferenceInfoMap
lang, } = await getSubapps({
hasDataset: datasetParams && datasetParams.datasets.length > 0, tools: selectedTools,
hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile, tmbId: runningAppInfo.tmbId,
useAgentSandbox: !!sandboxClient lang,
} hasDataset: datasetParams && datasetParams.datasets.length > 0,
); hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile,
useAgentSandbox: !!sandboxClient
});
console.log('agentSubAppsMap', agentSubAppsMap);
// runtime 运行详情和工具卡需要根据 function name 反查展示名、头像和描述。 // runtime 运行详情和工具卡需要根据 function name 反查展示名、头像和描述。
// 用户工具与系统工具的 id 形态不完全一致,这里统一归一化查询。 // 用户工具与系统工具的 id 形态不完全一致,这里统一归一化查询。
const getSubAppInfo = (id: string) => { const getSubAppInfo = (id: string) => {
...@@ -246,6 +241,22 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -246,6 +241,22 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
const formatId = id.slice(1); const formatId = id.slice(1);
return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId);
}; };
const resolvePromptToolReferenceName = (id: string) => {
const formatId = id.startsWith('t') ? id.slice(1) : id;
return (
promptToolReferenceInfoMap.get(id) ||
promptToolReferenceInfoMap.get(formatId) ||
getSystemToolInfo(id, lang)?.name ||
getSystemToolInfo(formatId, lang)?.name
);
};
// 用户配置 prompt 和 sandbox prompt 作为 Main Agent 的 system 背景输入。
const formatedSystemPrompt = parseUserSystemPrompt({
userSystemPrompt: [systemPrompt, sandboxClient ? SANDBOX_SYSTEM_PROMPT : '']
.filter(Boolean)
.join('\n\n'),
resolvePromptToolReferenceName
});
// 2. 创建 workflow adapter。 // 2. 创建 workflow adapter。
// 通用 agent loop 不感知 workflow;工具执行、SSE、usage、nodeResponse 都通过 runtime 参数回调进来。 // 通用 agent loop 不感知 workflow;工具执行、SSE、usage、nodeResponse 都通过 runtime 参数回调进来。
......
...@@ -145,16 +145,18 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -145,16 +145,18 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const { text: formatUserChatInput } = chatValue2RuntimePrompt(currentUserMessage.value); const { text: formatUserChatInput } = chatValue2RuntimePrompt(currentUserMessage.value);
// 2. 收集 workflow 可用工具。PiAgent 工具执行仍复用现有 workflow 子工具调度器。 // 2. 收集 workflow 可用工具。PiAgent 工具执行仍复用现有 workflow 子工具调度器。
const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( const {
{ completionTools: agentCompletionTools,
tools: selectedTools, subAppsMap: agentSubAppsMap,
tmbId: runningAppInfo.tmbId, promptToolReferenceInfoMap
lang, } = await getSubapps({
hasDataset: datasetParams && datasetParams.datasets.length > 0, tools: selectedTools,
hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile, tmbId: runningAppInfo.tmbId,
useAgentSandbox: !!sandboxClient lang,
} hasDataset: datasetParams && datasetParams.datasets.length > 0,
); hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile,
useAgentSandbox: !!sandboxClient
});
const getSubAppInfo = (id: string) => { const getSubAppInfo = (id: string) => {
const formatId = id.startsWith('t') ? id.slice(1) : id; const formatId = id.startsWith('t') ? id.slice(1) : id;
...@@ -178,12 +180,22 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -178,12 +180,22 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const formatId = id.startsWith('t') ? id.slice(1) : id; const formatId = id.startsWith('t') ? id.slice(1) : id;
return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId);
}; };
const resolvePromptToolReferenceName = (id: string) => {
const formatId = id.startsWith('t') ? id.slice(1) : id;
return (
promptToolReferenceInfoMap.get(id) ||
promptToolReferenceInfoMap.get(formatId) ||
getSystemToolInfo(id, lang)?.name ||
getSystemToolInfo(formatId, lang)?.name
);
};
// 3. 拼接 PiAgent 的 system prompt。这里只补齐 workflow 专属约束和 sandbox prompt。 // 3. 拼接 PiAgent 的 system prompt。这里只补齐 workflow 专属约束和 sandbox prompt。
const formatedSystemPrompt = parseUserSystemPrompt({ const formatedSystemPrompt = parseUserSystemPrompt({
userSystemPrompt: [systemPrompt || '', sandboxClient ? SANDBOX_SYSTEM_PROMPT : ''] userSystemPrompt: [systemPrompt || '', sandboxClient ? SANDBOX_SYSTEM_PROMPT : '']
.filter(Boolean) .filter(Boolean)
.join('\n\n') .join('\n\n'),
resolvePromptToolReferenceName
}); });
// 4. 创建 workflow runtime adapter。它负责主模型 requestId、usage、nodeResponses、SSE 与 request record。 // 4. 创建 workflow runtime adapter。它负责主模型 requestId、usage、nodeResponses、SSE 与 request record。
......
import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type'; import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants'; import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import type { DispatchSubAppResponse } from '../../type'; import type { DispatchSubAppResponse } from '../../type';
import { getToolRawId } from '@fastgpt/global/core/app/tool/utils'; import { getToolNameCandidates, getToolRawId } from '@fastgpt/global/core/app/tool/utils';
import { getSecretValue } from '../../../../../../../common/secret/utils'; import { getSecretValue } from '../../../../../../../common/secret/utils';
import type { import type {
ChatDispatchProps, ChatDispatchProps,
...@@ -192,6 +192,10 @@ export const dispatchTool = async ({ ...@@ -192,6 +192,10 @@ export const dispatchTool = async ({
}; };
} else if (toolConfig?.mcpTool?.toolId) { } else if (toolConfig?.mcpTool?.toolId) {
const { parentId, toolName } = parseToolId(toolConfig.mcpTool.toolId); const { parentId, toolName } = parseToolId(toolConfig.mcpTool.toolId);
if (!parentId || !toolName) {
return Promise.reject(`Invalid MCP tool id: ${toolConfig.mcpTool.toolId}`);
}
const tool = await getAppVersionById({ const tool = await getAppVersionById({
appId: parentId, appId: parentId,
versionId: version versionId: version
...@@ -236,7 +240,9 @@ export const dispatchTool = async ({ ...@@ -236,7 +240,9 @@ export const dispatchTool = async ({
const { headerSecret, baseUrl, toolList, customHeaders } = toolSetData; const { headerSecret, baseUrl, toolList, customHeaders } = toolSetData;
const httpTool = toolList?.find((tool) => tool.name === toolName); const httpTool = getToolNameCandidates(toolName)
.map((name) => toolList?.find((tool) => tool.name === name))
.find(Boolean);
if (!httpTool) { if (!httpTool) {
return Promise.reject(`HTTP tool ${toolName} not found`); return Promise.reject(`HTTP tool ${toolName} not found`);
} }
......
import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type'; import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type';
import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils'; import {
getToolNameCandidates,
splitCombineToolId,
splitToolsetToolPluginId
} from '@fastgpt/global/core/app/tool/utils';
import type { localeType } from '@fastgpt/global/common/i18n/type'; import type { localeType } from '@fastgpt/global/common/i18n/type';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { authAppByTmbId } from '../../../../../../../support/permission/app/auth'; import { authAppByTmbId } from '../../../../../../../support/permission/app/auth';
...@@ -274,11 +278,12 @@ export const getAgentRuntimeTools = async ({ ...@@ -274,11 +278,12 @@ export const getAgentRuntimeTools = async ({
app: AppSchemaType; app: AppSchemaType;
pluginId: string; pluginId: string;
}): Promise<AgentRuntimeNode> => { }): Promise<AgentRuntimeNode> => {
const [, ...toolNameParts] = pluginId.split('/'); const { toolName } = splitToolsetToolPluginId(pluginId);
const toolName = toolNameParts.join('/');
const version = await getVersionNodes({ app }); const version = await getVersionNodes({ app });
const toolList = version.nodes[0]?.toolConfig?.mcpToolSet?.toolList ?? []; const toolList = version.nodes[0]?.toolConfig?.mcpToolSet?.toolList ?? [];
const tool = toolList.find((item) => item.name === toolName); const tool = getToolNameCandidates(toolName)
.map((name) => toolList.find((item) => item.name === name))
.find(Boolean);
if (!tool) return Promise.reject(PluginErrEnum.unExist); if (!tool) return Promise.reject(PluginErrEnum.unExist);
const node = getMCPToolRuntimeNode({ const node = getMCPToolRuntimeNode({
...@@ -308,12 +313,12 @@ export const getAgentRuntimeTools = async ({ ...@@ -308,12 +313,12 @@ export const getAgentRuntimeTools = async ({
app: AppSchemaType; app: AppSchemaType;
pluginId: string; pluginId: string;
}): Promise<AgentRuntimeNode> => { }): Promise<AgentRuntimeNode> => {
const [, ...toolNameParts] = pluginId.split('/'); const { toolName } = splitToolsetToolPluginId(pluginId);
const toolName = toolNameParts.join('/');
const version = await getVersionNodes({ app }); const version = await getVersionNodes({ app });
const tool = version.nodes[0]?.toolConfig?.httpToolSet?.toolList.find( const toolList = version.nodes[0]?.toolConfig?.httpToolSet?.toolList ?? [];
(item) => item.name === toolName const tool = getToolNameCandidates(toolName)
); .map((name) => toolList.find((item) => item.name === name))
.find(Boolean);
if (!tool) return Promise.reject(PluginErrEnum.unExist); if (!tool) return Promise.reject(PluginErrEnum.unExist);
const node = getHTTPToolRuntimeNode({ const node = getHTTPToolRuntimeNode({
...@@ -396,11 +401,7 @@ export const getAgentRuntimeTools = async ({ ...@@ -396,11 +401,7 @@ export const getAgentRuntimeTools = async ({
} }
} }
const description = JSON.stringify({ const description = [name, toolDescription || intro].filter(Boolean).join(': ');
type: flowNodeType,
name: name,
intro: toolDescription || intro
});
// 仅数字开头的工具名需要补前缀,避免破坏 runtime 使用原始 tool id 反查工具。 // 仅数字开头的工具名需要补前缀,避免破坏 runtime 使用原始 tool id 反查工具。
const formatToolId = /^\d/.test(toolId) ? `t${toolId}` : toolId; const formatToolId = /^\d/.test(toolId) ? `t${toolId}` : toolId;
...@@ -522,6 +523,10 @@ export const getAgentRuntimeTools = async ({ ...@@ -522,6 +523,10 @@ export const getAgentRuntimeTools = async ({
})(); })();
// toolset 展开后的子工具统一走 tool 执行;params 仍继承父工具配置。 // toolset 展开后的子工具统一走 tool 执行;params 仍继承父工具配置。
const promptReference = {
id: tool.id,
name: toolNode.name
};
const buildSubApp = (child: RuntimeNodeItemType, id = child.nodeId): SubAppInitType => ({ const buildSubApp = (child: RuntimeNodeItemType, id = child.nodeId): SubAppInitType => ({
type: 'tool', type: 'tool',
id, id,
...@@ -529,6 +534,7 @@ export const getAgentRuntimeTools = async ({ ...@@ -529,6 +534,7 @@ export const getAgentRuntimeTools = async ({
avatar: child.avatar, avatar: child.avatar,
version: child.version, version: child.version,
toolConfig: child.toolConfig, toolConfig: child.toolConfig,
promptReference,
params: tool.config, params: tool.config,
requestSchema: formatSchema({ requestSchema: formatSchema({
toolId: id, toolId: id,
...@@ -604,6 +610,7 @@ export const getAgentRuntimeTools = async ({ ...@@ -604,6 +610,7 @@ export const getAgentRuntimeTools = async ({
avatar: toolNode.avatar, avatar: toolNode.avatar,
version: toolNode.version, version: toolNode.version,
toolConfig: toolNode.toolConfig, toolConfig: toolNode.toolConfig,
promptReference,
params: tool.config, params: tool.config,
requestSchema: formatSchema({ requestSchema: formatSchema({
toolId: cleanedPluginId, toolId: cleanedPluginId,
......
...@@ -13,6 +13,10 @@ export type SubAppInitType = { ...@@ -13,6 +13,10 @@ export type SubAppInitType = {
version?: string; version?: string;
toolConfig?: RuntimeNodeItemType['toolConfig']; toolConfig?: RuntimeNodeItemType['toolConfig'];
requestSchema: ChatCompletionTool; requestSchema: ChatCompletionTool;
promptReference?: {
id: string;
name: string;
};
params: { params: {
[NodeInputKeyEnum.systemInputConfig]?: { [NodeInputKeyEnum.systemInputConfig]?: {
type: SystemToolSecretInputTypeEnum; type: SystemToolSecretInputTypeEnum;
......
...@@ -24,7 +24,8 @@ import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseSt ...@@ -24,7 +24,8 @@ import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseSt
/** /**
* 收集 Agent 节点可用的系统工具和用户选择的子应用工具。 * 收集 Agent 节点可用的系统工具和用户选择的子应用工具。
* 返回给 LLM 的 completionTools 与运行时查找用的 subAppsMap 会在这里保持一致。 * completionTools/subAppsMap 面向 runtime;promptToolReferenceInfoMap 只用于解析 prompt 中的
* {{@toolId@}} 展示名,不参与工具展开和执行。
*/ */
export const getSubapps = async ({ export const getSubapps = async ({
tmbId, tmbId,
...@@ -43,8 +44,11 @@ export const getSubapps = async ({ ...@@ -43,8 +44,11 @@ export const getSubapps = async ({
}): Promise<{ }): Promise<{
completionTools: ChatCompletionTool[]; completionTools: ChatCompletionTool[];
subAppsMap: Map<string, SubAppRuntimeType>; subAppsMap: Map<string, SubAppRuntimeType>;
promptToolReferenceInfoMap: Map<string, string>;
}> => { }> => {
const completionTools: ChatCompletionTool[] = []; const completionTools: ChatCompletionTool[] = [];
const subAppsMap = new Map<string, SubAppRuntimeType>();
const promptToolReferenceInfoMap = new Map<string, string>();
// system tools // system tools
{ {
...@@ -64,8 +68,6 @@ export const getSubapps = async ({ ...@@ -64,8 +68,6 @@ export const getSubapps = async ({
} }
} }
/* User tools */
const subAppsMap = new Map<string, SubAppRuntimeType>();
const formatTools = await getAgentRuntimeTools({ const formatTools = await getAgentRuntimeTools({
tools, tools,
tmbId, tmbId,
...@@ -73,6 +75,9 @@ export const getSubapps = async ({ ...@@ -73,6 +75,9 @@ export const getSubapps = async ({
}); });
formatTools.forEach((tool) => { formatTools.forEach((tool) => {
if (tool.promptReference) {
promptToolReferenceInfoMap.set(tool.promptReference.id, tool.promptReference.name);
}
completionTools.push(tool.requestSchema); completionTools.push(tool.requestSchema);
subAppsMap.set(tool.id, { subAppsMap.set(tool.id, {
type: tool.type, type: tool.type,
...@@ -87,7 +92,8 @@ export const getSubapps = async ({ ...@@ -87,7 +92,8 @@ export const getSubapps = async ({
return { return {
completionTools, completionTools,
subAppsMap subAppsMap,
promptToolReferenceInfoMap
}; };
}; };
......
...@@ -19,7 +19,7 @@ import { getNodeErrResponse } from '../utils'; ...@@ -19,7 +19,7 @@ import { getNodeErrResponse } from '../utils';
import { getAppVersionById } from '../../../../core/app/version/controller'; import { getAppVersionById } from '../../../../core/app/version/controller';
import { runHTTPTool } from '../../../app/http'; import { runHTTPTool } from '../../../app/http';
import { getWorkflowContext } from '../../utils/context'; import { getWorkflowContext } from '../../utils/context';
import { getToolRawId } from '@fastgpt/global/core/app/tool/utils'; import { getToolNameCandidates, getToolRawId } from '@fastgpt/global/core/app/tool/utils';
import { pluginClient } from '../../../../thirdProvider/fastgptPlugin'; import { pluginClient } from '../../../../thirdProvider/fastgptPlugin';
import { SystemToolRepo } from '../../../app/tool/systemTool/systemTool.repo'; import { SystemToolRepo } from '../../../app/tool/systemTool/systemTool.repo';
import { InvokeProcessor } from '../../../../support/invoke/invoke'; import { InvokeProcessor } from '../../../../support/invoke/invoke';
...@@ -168,9 +168,9 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -168,9 +168,9 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
error: res.error, error: res.error,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponse]: res.error [DispatchNodeResponseKeyEnum.toolResponse]: res.error
}; };
} }
const usagePoints = (() => { const usagePoints = (() => {
if ( if (
...@@ -212,6 +212,10 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -212,6 +212,10 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
} else if (toolConfig?.mcpTool?.toolId) { } else if (toolConfig?.mcpTool?.toolId) {
// pluginId: toolSetAppId/toolsetName/toolName // pluginId: toolSetAppId/toolsetName/toolName
const { parentId, toolName } = parseToolId(toolConfig.mcpTool.toolId); const { parentId, toolName } = parseToolId(toolConfig.mcpTool.toolId);
if (!parentId || !toolName) {
throw new Error(`Invalid MCP tool id: ${toolConfig.mcpTool.toolId}`);
}
const tool = await getAppVersionById({ const tool = await getAppVersionById({
appId: parentId, appId: parentId,
versionId: version versionId: version
...@@ -258,7 +262,9 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -258,7 +262,9 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
const { headerSecret, baseUrl, toolList, customHeaders } = toolSetData; const { headerSecret, baseUrl, toolList, customHeaders } = toolSetData;
const httpTool = toolList?.find((tool: HttpToolConfigType) => tool.name === toolName); const httpTool = getToolNameCandidates(toolName)
.map((name) => toolList?.find((tool: HttpToolConfigType) => tool.name === name))
.find(Boolean);
if (!httpTool) { if (!httpTool) {
throw new Error(`HTTP tool ${toolName} not found`); throw new Error(`HTTP tool ${toolName} not found`);
} }
...@@ -358,11 +364,18 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -358,11 +364,18 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
export const parseToolId = (id: string) => { export const parseToolId = (id: string) => {
const formatId = id.split('-').slice(1).join('-'); const formatId = id.split('-').slice(1).join('-');
const [parentId, toolsetNameOrToolName, legacyToolName] = formatId.split('/'); const [parentId, toolsetNameOrToolName, ...restToolNameParts] = formatId.split('/');
if (restToolNameParts.length > 0) {
const toolName = restToolNameParts.join('/');
// 新版格式允许 toolName 以 `/` 开头,此时 ID 会表现为 source-appId//toolName。
if (!toolsetNameOrToolName) {
return { parentId, toolName: `/${toolName}` };
}
if (legacyToolName) {
// 旧版格式: source-appId/toolsetName/toolName // 旧版格式: source-appId/toolsetName/toolName
return { parentId, toolName: legacyToolName }; return { parentId, toolName };
} }
// 新版格式: source-appId/toolName // 新版格式: source-appId/toolName
......
...@@ -129,7 +129,7 @@ describe('runUnifiedAgentLoop', () => { ...@@ -129,7 +129,7 @@ describe('runUnifiedAgentLoop', () => {
expect(result.activePlan).toBeUndefined(); expect(result.activePlan).toBeUndefined();
expect(createLLMResponseMock).toHaveBeenCalledTimes(1); expect(createLLMResponseMock).toHaveBeenCalledTimes(1);
expect(createLLMResponseMock.mock.calls[0][0].body.messages[0].content).toContain( expect(createLLMResponseMock.mock.calls[0][0].body.messages[0].content).toContain(
'你是 FastGPT Main Agent' '你是 Master Agent'
); );
}); });
......
import { describe, expect, it } from 'vitest';
import {
parseUserSystemPrompt,
replaceToolReferenceWithName
} from '@fastgpt/service/core/workflow/dispatch/ai/agent/adapter/prompt';
const resolvePromptToolReferenceName = (id: string) => {
const names: Record<string, string> = {
dataset_search: '知识库检索',
agent_sandbox: '虚拟机',
custom_tool: '自定义工具',
getTime: '获取当前时间',
mcp_appsearchTool: 'MCP 搜索',
http_appcreateOrder: 'HTTP 创建订单',
personal_tool_app: '个人工具',
personal_agent_app: '个人 Agent'
};
return names[id];
};
describe('workflow agent prompt adapter', () => {
it('replaces skill references with readable tool names', () => {
const result = replaceToolReferenceWithName({
text: '优先使用 {{@dataset_search@}} 和 {{@agent_sandbox@}}。',
resolvePromptToolReferenceName
});
expect(result).toBe('优先使用 {{知识库检索}} 和 {{虚拟机}}。');
});
it('keeps unknown skill references unchanged', () => {
const result = replaceToolReferenceWithName({
text: '未知工具 {{@missing_tool@}} 保持原样。',
resolvePromptToolReferenceName
});
expect(result).toBe('未知工具 {{@missing_tool@}} 保持原样。');
});
it.each([
{
label: 'system tool',
referenceId: 'systemTool-getTime',
name: '获取当前时间'
},
{
label: 'MCP child tool',
referenceId: 'mcp-mcp_app/searchTool',
name: 'MCP 搜索'
},
{
label: 'HTTP child tool',
referenceId: 'http-http_app/createOrder',
name: 'HTTP 创建订单'
},
{
label: 'personal tool',
referenceId: 'personal-personal_tool_app',
name: '个人工具'
},
{
label: 'personal agent',
referenceId: 'personal_agent_app',
name: '个人 Agent'
}
])('replaces $label references with readable tool names', ({ referenceId, name }) => {
const result = replaceToolReferenceWithName({
text: `优先使用 {{@${referenceId}@}} 完成任务。`,
resolvePromptToolReferenceName
});
expect(result).toBe(`优先使用 {{${name}}} 完成任务。`);
});
it('formats user system prompt after replacing tool references', () => {
const result = parseUserSystemPrompt({
userSystemPrompt: '参考 {{@custom_tool@}} 完成任务。',
resolvePromptToolReferenceName
});
expect(result).toContain('参考 {{自定义工具}} 完成任务。');
expect(result).toContain('如果背景信息中包含工具引用');
});
});
...@@ -15,7 +15,8 @@ const { ...@@ -15,7 +15,8 @@ const {
axiosGetMock, axiosGetMock,
getAgentSkillInfosMock, getAgentSkillInfosMock,
injectAgentSkillFilesToSandboxMock, injectAgentSkillFilesToSandboxMock,
checkTeamSandboxPermissionMock checkTeamSandboxPermissionMock,
getAgentRuntimeToolsMock
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runUnifiedAgentLoopMock: vi.fn(), runUnifiedAgentLoopMock: vi.fn(),
getSandboxClientMock: vi.fn(), getSandboxClientMock: vi.fn(),
...@@ -24,7 +25,8 @@ const { ...@@ -24,7 +25,8 @@ const {
axiosGetMock: vi.fn(), axiosGetMock: vi.fn(),
getAgentSkillInfosMock: vi.fn(), getAgentSkillInfosMock: vi.fn(),
injectAgentSkillFilesToSandboxMock: vi.fn(), injectAgentSkillFilesToSandboxMock: vi.fn(),
checkTeamSandboxPermissionMock: vi.fn() checkTeamSandboxPermissionMock: vi.fn(),
getAgentRuntimeToolsMock: vi.fn(async () => [])
})); }));
vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => { vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => {
...@@ -36,7 +38,7 @@ vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => { ...@@ -36,7 +38,7 @@ vi.mock('@fastgpt/service/core/ai/llm/agentLoop', async (importOriginal) => {
}); });
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({
getAgentRuntimeTools: vi.fn(async () => []) getAgentRuntimeTools: getAgentRuntimeToolsMock
})); }));
vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => { vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => {
...@@ -252,6 +254,7 @@ describe('dispatchRunAgent user context', () => { ...@@ -252,6 +254,7 @@ describe('dispatchRunAgent user context', () => {
skillMdPath: './skills/Report-skill_1/SKILL.md' skillMdPath: './skills/Report-skill_1/SKILL.md'
} }
]); ]);
getAgentRuntimeToolsMock.mockResolvedValue([]);
runUnifiedAgentLoopMock.mockResolvedValue({ runUnifiedAgentLoopMock.mockResolvedValue({
status: 'done', status: 'done',
answerText: 'ok', answerText: 'ok',
...@@ -297,6 +300,86 @@ describe('dispatchRunAgent user context', () => { ...@@ -297,6 +300,86 @@ describe('dispatchRunAgent user context', () => {
expect(loopInput.messages[1].content).toContain('当前问题'); expect(loopInput.messages[1].content).toContain('当前问题');
}); });
it('replaces system prompt tool references with readable names before starting the loop', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps();
props.params.systemPrompt = '优先使用 {{@dataset_search@}},未知 {{@missing_tool@}} 保留。';
let result: any;
runWithContext(
{
queryUrlTypeMap: {
'/old.pdf': ChatFileTypeEnum.file,
'/current.pdf': ChatFileTypeEnum.file
},
mcpClientMemory: {}
},
() => {
result = dispatchRunAgent(props);
}
);
await result;
const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input;
expect(loopInput.systemPrompt).toContain('优先使用 {{知识库检索}}');
expect(loopInput.systemPrompt).toContain('未知 {{@missing_tool@}} 保留');
expect(loopInput.systemPrompt).not.toContain('{{@dataset_search@}}');
});
it('replaces system toolset prompt references from promptToolReferenceInfoMap', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps();
props.params.systemPrompt = '优先使用 {{@systemTool-system_toolset@}}。';
props.params.agent_selectedTools = [
{
id: 'systemTool-system_toolset',
config: {}
} as any
];
getAgentRuntimeToolsMock.mockResolvedValueOnce([
{
type: 'tool',
id: 'system_toolset_search',
name: '系统工具集搜索',
params: {},
requestSchema: {
type: 'function',
function: {
name: 'system_toolset_search',
description: '',
parameters: {
type: 'object',
properties: {}
}
}
},
promptReference: {
id: 'systemTool-system_toolset',
name: '系统工具集'
}
}
]);
let result: any;
runWithContext(
{
queryUrlTypeMap: {
'/old.pdf': ChatFileTypeEnum.file,
'/current.pdf': ChatFileTypeEnum.file
},
mcpClientMemory: {}
},
() => {
result = dispatchRunAgent(props);
}
);
await result;
const loopInput = runUnifiedAgentLoopMock.mock.calls[0][0].input;
expect(loopInput.systemPrompt).toContain('优先使用 {{系统工具集}}');
expect(loopInput.systemPrompt).not.toContain('{{@systemTool-system_toolset@}}');
});
it('injects sandbox input files before starting the unified agent loop', async () => { it('injects sandbox input files before starting the unified agent loop', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent'); const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps(); const props = createProps();
......
...@@ -23,7 +23,8 @@ const { ...@@ -23,7 +23,8 @@ const {
sandboxWriteFilesMock, sandboxWriteFilesMock,
sandboxClientExecMock, sandboxClientExecMock,
axiosGetMock, axiosGetMock,
checkTeamSandboxPermissionMock checkTeamSandboxPermissionMock,
getAgentRuntimeToolsMock
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
agentPromptMock: vi.fn(), agentPromptMock: vi.fn(),
agentSubscribeMock: vi.fn(), agentSubscribeMock: vi.fn(),
...@@ -39,7 +40,8 @@ const { ...@@ -39,7 +40,8 @@ const {
sandboxWriteFilesMock: vi.fn(), sandboxWriteFilesMock: vi.fn(),
sandboxClientExecMock: vi.fn(), sandboxClientExecMock: vi.fn(),
axiosGetMock: vi.fn(), axiosGetMock: vi.fn(),
checkTeamSandboxPermissionMock: vi.fn() checkTeamSandboxPermissionMock: vi.fn(),
getAgentRuntimeToolsMock: vi.fn(async () => [])
})); }));
vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
...@@ -77,7 +79,7 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter', ...@@ -77,7 +79,7 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter',
})); }));
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({
getAgentRuntimeTools: vi.fn(async () => []) getAgentRuntimeTools: getAgentRuntimeToolsMock
})); }));
vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => { vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => {
...@@ -299,6 +301,7 @@ describe('dispatchPiAgent user context', () => { ...@@ -299,6 +301,7 @@ describe('dispatchPiAgent user context', () => {
skillMdPath: './skills/Report-skill_1/SKILL.md' skillMdPath: './skills/Report-skill_1/SKILL.md'
} }
]); ]);
getAgentRuntimeToolsMock.mockResolvedValue([]);
createPiAgentWorkflowRuntimeMock.mockReturnValue({ createPiAgentWorkflowRuntimeMock.mockReturnValue({
onPayload: vi.fn(), onPayload: vi.fn(),
handleAgentEvent: vi.fn(), handleAgentEvent: vi.fn(),
...@@ -397,6 +400,84 @@ describe('dispatchPiAgent user context', () => { ...@@ -397,6 +400,84 @@ describe('dispatchPiAgent user context', () => {
]); ]);
}); });
it('replaces system prompt tool references before creating pi agent', async () => {
const { dispatchPiAgent } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
const props = createProps();
props.params.systemPrompt = '优先使用 {{@dataset_search@}},未知 {{@missing_tool@}} 保留。';
let resultPromise: Promise<any>;
runWithContext(
{
queryUrlTypeMap: {},
mcpClientMemory: {}
},
() => {
resultPromise = dispatchPiAgent(props);
}
);
await resultPromise!;
expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('优先使用 {{知识库检索}}');
expect(agentConstructorArgs[0].initialState.systemPrompt).toContain(
'未知 {{@missing_tool@}} 保留'
);
expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain('{{@dataset_search@}}');
});
it('replaces system toolset prompt references from promptToolReferenceInfoMap', async () => {
const { dispatchPiAgent } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
const props = createProps();
props.params.systemPrompt = '优先使用 {{@systemTool-system_toolset@}}。';
props.params.agent_selectedTools = [
{
id: 'systemTool-system_toolset',
config: {}
} as any
];
getAgentRuntimeToolsMock.mockResolvedValueOnce([
{
type: 'tool',
id: 'system_toolset_search',
name: '系统工具集搜索',
params: {},
requestSchema: {
type: 'function',
function: {
name: 'system_toolset_search',
description: '',
parameters: {
type: 'object',
properties: {}
}
}
},
promptReference: {
id: 'systemTool-system_toolset',
name: '系统工具集'
}
}
]);
let resultPromise: Promise<any>;
runWithContext(
{
queryUrlTypeMap: {},
mcpClientMemory: {}
},
() => {
resultPromise = dispatchPiAgent(props);
}
);
await resultPromise!;
expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('优先使用 {{系统工具集}}');
expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain(
'{{@systemTool-system_toolset@}}'
);
});
it('injects sandbox input files before calling pi agent prompt', async () => { it('injects sandbox input files before calling pi agent prompt', async () => {
const { dispatchPiAgent } = const { dispatchPiAgent } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent'); await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
......
...@@ -80,6 +80,11 @@ const mcpTool = { ...@@ -80,6 +80,11 @@ const mcpTool = {
inputSchema: mcpInputSchema inputSchema: mcpInputSchema
}; };
const mcpToolWithLeadingSlash = {
...mcpTool,
name: '/test'
};
const httpTool = { const httpTool = {
name: 'create', name: 'create',
description: 'Create record', description: 'Create record',
...@@ -93,6 +98,11 @@ const httpTool = { ...@@ -93,6 +98,11 @@ const httpTool = {
} }
}; };
const httpToolWithLeadingSlash = {
...httpTool,
name: '/test'
};
const createToolsetApp = ({ const createToolsetApp = ({
id, id,
type, type,
...@@ -158,6 +168,17 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -158,6 +168,17 @@ describe('getAgentRuntimeTools schema loading', () => {
} }
} }
}), }),
mcp_slash_app: createToolsetApp({
id: 'mcp_slash_app',
type: AppTypeEnum.mcpToolSet,
toolConfig: {
mcpToolSet: {
url: 'https://mcp.example.com',
headerSecret: {},
toolList: [mcpToolWithLeadingSlash]
}
}
}),
'123_app': createToolsetApp({ '123_app': createToolsetApp({
id: '123_app', id: '123_app',
type: AppTypeEnum.mcpToolSet, type: AppTypeEnum.mcpToolSet,
...@@ -179,6 +200,17 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -179,6 +200,17 @@ describe('getAgentRuntimeTools schema loading', () => {
toolList: [httpTool] toolList: [httpTool]
} }
} }
}),
http_slash_app: createToolsetApp({
id: 'http_slash_app',
type: AppTypeEnum.httpToolSet,
toolConfig: {
httpToolSet: {
baseUrl: 'https://api.example.com',
headerSecret: {},
toolList: [httpToolWithLeadingSlash]
}
}
}) })
}; };
...@@ -190,8 +222,13 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -190,8 +222,13 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools).toHaveLength(1); expect(tools).toHaveLength(1);
expect(tools[0].requestSchema.function.name).toBe('mcp_app0'); expect(tools[0].requestSchema.function.name).toBe('mcp_app0');
expect(tools[0].requestSchema.function.description).toBe('mcp_app name/search: Search docs');
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema); expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search');
expect(tools[0].promptReference).toEqual({
id: 'mcp_app',
name: 'mcp_app name'
});
}); });
it('loads a selected MCP tool with its input schema', async () => { it('loads a selected MCP tool with its input schema', async () => {
...@@ -204,10 +241,23 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -204,10 +241,23 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools[0].id).toBe('mcp_appsearch'); expect(tools[0].id).toBe('mcp_appsearch');
expect(tools[0].name).toBe('search'); expect(tools[0].name).toBe('search');
expect(tools[0].requestSchema.function.name).toBe('mcp_appsearch'); expect(tools[0].requestSchema.function.name).toBe('mcp_appsearch');
expect(tools[0].requestSchema.function.description).toBe('search: Search docs');
expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema); expect(tools[0].requestSchema.function.parameters).toEqual(mcpInputSchema);
expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search'); expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_app/search');
}); });
it('loads a selected MCP tool whose name starts with slash', async () => {
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'mcp-mcp_slash_app//test', config: {} }]
});
expect(tools).toHaveLength(1);
expect(tools[0].id).toBe('mcp_slash_apptest');
expect(tools[0].name).toBe('/test');
expect(tools[0].toolConfig?.mcpTool?.toolId).toBe('mcp-mcp_slash_app//test');
});
it('prefixes tool function name only when the runtime tool id starts with a number', async () => { it('prefixes tool function name only when the runtime tool id starts with a number', async () => {
const tools = await getAgentRuntimeTools({ const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1', tmbId: 'tmb_1',
...@@ -228,9 +278,14 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -228,9 +278,14 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools).toHaveLength(1); expect(tools).toHaveLength(1);
expect(tools[0].requestSchema.function.name).toBe('http_app0'); expect(tools[0].requestSchema.function.name).toBe('http_app0');
expect(tools[0].requestSchema.function.description).toBe('http_app name/create: Create record');
expect(tools[0].requestSchema.function.parameters).toEqual(httpRequestSchema); expect(tools[0].requestSchema.function.parameters).toEqual(httpRequestSchema);
expect(tools[0].requestSchema.function.parameters).not.toEqual(httpInputSchema); expect(tools[0].requestSchema.function.parameters).not.toEqual(httpInputSchema);
expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_app/create'); expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_app/create');
expect(tools[0].promptReference).toEqual({
id: 'http_app',
name: 'http_app name'
});
}); });
it('loads a selected HTTP tool with its request schema', async () => { it('loads a selected HTTP tool with its request schema', async () => {
...@@ -243,11 +298,71 @@ describe('getAgentRuntimeTools schema loading', () => { ...@@ -243,11 +298,71 @@ describe('getAgentRuntimeTools schema loading', () => {
expect(tools[0].id).toBe('http_appcreate'); expect(tools[0].id).toBe('http_appcreate');
expect(tools[0].name).toBe('create'); expect(tools[0].name).toBe('create');
expect(tools[0].requestSchema.function.name).toBe('http_appcreate'); expect(tools[0].requestSchema.function.name).toBe('http_appcreate');
expect(tools[0].requestSchema.function.description).toBe('create: Create record');
expect(tools[0].requestSchema.function.parameters).toEqual(httpRequestSchema); expect(tools[0].requestSchema.function.parameters).toEqual(httpRequestSchema);
expect(tools[0].requestSchema.function.parameters).not.toEqual(httpInputSchema); expect(tools[0].requestSchema.function.parameters).not.toEqual(httpInputSchema);
expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_app/create'); expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_app/create');
}); });
it('loads a selected HTTP tool whose name starts with slash', async () => {
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'http-http_slash_app//test', config: {} }]
});
expect(tools).toHaveLength(1);
expect(tools[0].id).toBe('http_slash_apptest');
expect(tools[0].name).toBe('/test');
expect(tools[0].toolConfig?.httpTool?.toolId).toBe('http-http_slash_app//test');
});
it('loads system toolset children with parent prompt reference name', async () => {
getSystemToolDetailMock.mockResolvedValue({
id: 'systemTool-metaso',
name: '秘塔搜索',
avatar: 'metaso.png',
intro: '搜索工具集',
toolDescription: '搜索工具集',
status: 'active',
source: 'system',
isToolSet: true,
hasSystemSecret: false,
systemSecretStatus: 'none',
currentCost: 0,
systemKeyCost: 0,
hasTokenFee: false,
tags: [],
author: '',
version: '1.0.0',
isLatestVersion: true,
outputs: [],
inputs: [],
children: [
{
id: 'search',
name: '搜索网页',
description: '搜索网页内容',
currentCost: 0,
systemKeyCost: 0,
inputs: [],
outputs: []
}
]
});
const tools = await getAgentRuntimeTools({
tmbId: 'tmb_1',
tools: [{ id: 'systemTool-metaso', config: {} }]
});
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('搜索网页');
expect(tools[0].promptReference).toEqual({
id: 'systemTool-metaso',
name: '秘塔搜索'
});
});
it('loads system tool params from standard JSON schema description', async () => { it('loads system tool params from standard JSON schema description', async () => {
getSystemToolDetailMock.mockResolvedValue({ getSystemToolDetailMock.mockResolvedValue({
id: 'systemTool-gpjj5s', id: 'systemTool-gpjj5s',
......
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants';
import { getSubapps, getExecuteTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/utils'; import { getSubapps, getExecuteTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/utils';
import { readFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils'; import { readFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils';
import { datasetSearchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset/utils'; import { datasetSearchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset/utils';
const { dispatchAgentDatasetSearchMock, dispatchAppMock, dispatchFileReadMock } = vi.hoisted( const {
() => ({ dispatchAgentDatasetSearchMock,
dispatchAgentDatasetSearchMock: vi.fn(), dispatchAppMock,
dispatchAppMock: vi.fn(), dispatchFileReadMock,
dispatchFileReadMock: vi.fn() getAgentRuntimeToolsMock
}) } = vi.hoisted(() => ({
); dispatchAgentDatasetSearchMock: vi.fn(),
dispatchAppMock: vi.fn(),
dispatchFileReadMock: vi.fn(),
getAgentRuntimeToolsMock: vi.fn(async () => [])
}));
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({
dispatchFileRead: dispatchFileReadMock dispatchFileRead: dispatchFileReadMock
})); }));
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/tool/utils', () => ({
getAgentRuntimeTools: vi.fn(async () => []) getAgentRuntimeTools: getAgentRuntimeToolsMock
})); }));
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset', () => ({
...@@ -30,6 +34,11 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/app', () => ({ ...@@ -30,6 +34,11 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/app', () => ({
})); }));
describe('Agent read_files tool protocol', () => { describe('Agent read_files tool protocol', () => {
beforeEach(() => {
vi.clearAllMocks();
getAgentRuntimeToolsMock.mockResolvedValue([]);
});
it('exposes read_files with ids parameter', async () => { it('exposes read_files with ids parameter', async () => {
const { completionTools } = await getSubapps({ const { completionTools } = await getSubapps({
tmbId: 'tmb_1', tmbId: 'tmb_1',
...@@ -141,6 +150,89 @@ describe('Agent read_files tool protocol', () => { ...@@ -141,6 +150,89 @@ describe('Agent read_files tool protocol', () => {
}); });
}); });
it('uses loaded toolset names for prompt references when selected tools only include ids', async () => {
getAgentRuntimeToolsMock.mockResolvedValue([
{
type: 'tool',
id: 'metaso0',
name: '搜索网页',
params: {},
requestSchema: {
type: 'function',
function: {
name: 'metaso0',
description: '',
parameters: {
type: 'object',
properties: {}
}
}
},
promptReference: {
id: 'systemTool-metaso',
name: '秘塔搜索'
}
},
{
type: 'tool',
id: '697342badc35c2fc3f90ac3a0',
name: 'HTTP 搜索',
params: {},
requestSchema: {
type: 'function',
function: {
name: 'httpTool0',
description: '',
parameters: {
type: 'object',
properties: {}
}
}
},
promptReference: {
id: '697342badc35c2fc3f90ac3a',
name: 'HTTP 工具集'
}
},
{
type: 'tool',
id: '69e20f48dbec7c6ece77556b0',
name: 'MCP 搜索',
params: {},
requestSchema: {
type: 'function',
function: {
name: 'mcpTool0',
description: '',
parameters: {
type: 'object',
properties: {}
}
}
},
promptReference: {
id: '69e20f48dbec7c6ece77556b',
name: 'MCP 工具集'
}
}
]);
const { promptToolReferenceInfoMap } = await getSubapps({
tmbId: 'tmb_1',
tools: [
{ id: 'systemTool-metaso', config: {} },
{ id: '697342badc35c2fc3f90ac3a', config: {} },
{ id: '69e20f48dbec7c6ece77556b', config: {} }
],
hasFiles: false,
hasDataset: false
});
expect(promptToolReferenceInfoMap.get('systemTool-metaso')).toBe('秘塔搜索');
expect(promptToolReferenceInfoMap.get('697342badc35c2fc3f90ac3a')).toBe('HTTP 工具集');
expect(promptToolReferenceInfoMap.get('69e20f48dbec7c6ece77556b')).toBe('MCP 工具集');
});
it('passes external OpenAI account to dataset search tool', async () => { it('passes external OpenAI account to dataset search tool', async () => {
const userKey = { const userKey = {
key: 'user-key', key: 'user-key',
......
...@@ -45,7 +45,6 @@ describe('parseToolId', () => { ...@@ -45,7 +45,6 @@ describe('parseToolId', () => {
const result = parseToolId('mcp-507f1f77bcf86cd799439011/ignoredToolset/actualTool'); const result = parseToolId('mcp-507f1f77bcf86cd799439011/ignoredToolset/actualTool');
expect(result.parentId).toBe('507f1f77bcf86cd799439011'); expect(result.parentId).toBe('507f1f77bcf86cd799439011');
expect(result.toolName).toBe('actualTool'); expect(result.toolName).toBe('actualTool');
// toolsetName 应该被忽略
}); });
it('should handle toolset names with special characters', () => { it('should handle toolset names with special characters', () => {
...@@ -69,16 +68,15 @@ describe('parseToolId', () => { ...@@ -69,16 +68,15 @@ describe('parseToolId', () => {
}); });
it('should handle tool names with slashes in old format', () => { it('should handle tool names with slashes in old format', () => {
// 注意: split('/') 只会分割成三个部分,所以第三个部分是 'tool'
const result = parseToolId('mcp-507f1f77bcf86cd799439011/toolset/tool/extra'); const result = parseToolId('mcp-507f1f77bcf86cd799439011/toolset/tool/extra');
expect(result.parentId).toBe('507f1f77bcf86cd799439011'); expect(result.parentId).toBe('507f1f77bcf86cd799439011');
// 实际上 split('/') 会得到 ['507f1f77bcf86cd799439011', 'toolset', 'tool/extra'] expect(result.toolName).toBe('tool/extra');
// 但由于解构赋值,legacyToolName 会是 'tool/extra' });
// 等等,让我重新理解代码逻辑...
// formatId.split('/') 会得到 ['507f1f77bcf86cd799439011', 'toolset', 'tool', 'extra'] it('should preserve leading slash in tool name', () => {
// 解构赋值只取前三个: parentId='507f1f77bcf86cd799439011', toolsetNameOrToolName='toolset', legacyToolName='tool' const result = parseToolId('http-69e20f48dbec7c6ece77556b//test');
// 所以 toolName 应该是 'tool',而不是 'tool/extra' expect(result.parentId).toBe('69e20f48dbec7c6ece77556b');
expect(result.toolName).toBe('tool'); expect(result.toolName).toBe('/test');
}); });
it('should handle empty tool name', () => { it('should handle empty tool name', () => {
...@@ -90,7 +88,7 @@ describe('parseToolId', () => { ...@@ -90,7 +88,7 @@ describe('parseToolId', () => {
it('should handle empty toolset and tool name in old format', () => { it('should handle empty toolset and tool name in old format', () => {
const result = parseToolId('mcp-507f1f77bcf86cd799439011//'); const result = parseToolId('mcp-507f1f77bcf86cd799439011//');
expect(result.parentId).toBe('507f1f77bcf86cd799439011'); expect(result.parentId).toBe('507f1f77bcf86cd799439011');
expect(result.toolName).toBe(''); expect(result.toolName).toBe('/');
}); });
}); });
...@@ -115,7 +113,6 @@ describe('parseToolId', () => { ...@@ -115,7 +113,6 @@ describe('parseToolId', () => {
const oldResult = parseToolId(oldId); const oldResult = parseToolId(oldId);
const newResult = parseToolId(newId); const newResult = parseToolId(newId);
// 两种格式应该解析出相同的 parentId 和 toolName
expect(oldResult.parentId).toBe(newResult.parentId); expect(oldResult.parentId).toBe(newResult.parentId);
expect(oldResult.toolName).toBe(newResult.toolName); expect(oldResult.toolName).toBe(newResult.toolName);
}); });
......
...@@ -146,6 +146,7 @@ export default function Editor({ ...@@ -146,6 +146,7 @@ export default function Editor({
const [focus, setFocus] = useState(false); const [focus, setFocus] = useState(false);
const [scrollHeight, setScrollHeight] = useState(0); const [scrollHeight, setScrollHeight] = useState(0);
const editorOutputRef = useRef(value); const editorOutputRef = useRef(value);
const pendingSkillsRef = useRef<Map<string, SkillLabelItemType>>(new Map());
const initialConfig = { const initialConfig = {
namespace: isRichText ? 'richPromptEditor' : 'promptEditor', namespace: isRichText ? 'richPromptEditor' : 'promptEditor',
...@@ -291,8 +292,13 @@ export default function Editor({ ...@@ -291,8 +292,13 @@ export default function Editor({
selectedSkills={selectedSkills} selectedSkills={selectedSkills}
onClickSkill={onClickSkill} onClickSkill={onClickSkill}
onRemoveSkill={onRemoveSkill} onRemoveSkill={onRemoveSkill}
pendingSkillsRef={pendingSkillsRef}
/>
<SkillPickerPlugin
skillOption={skillOption}
isFocus={focus}
pendingSkillsRef={pendingSkillsRef}
/> />
<SkillPickerPlugin skillOption={skillOption} isFocus={focus} />
</> </>
)} )}
......
...@@ -6,6 +6,11 @@ import { useTranslation } from 'next-i18next'; ...@@ -6,6 +6,11 @@ import { useTranslation } from 'next-i18next';
import type { SkillLabelNodeBasicType } from '../node'; import type { SkillLabelNodeBasicType } from '../node';
import { useMemoEnhance } from '../../../../../../../hooks/useMemoEnhance'; import { useMemoEnhance } from '../../../../../../../hooks/useMemoEnhance';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { NodeKey } from 'lexical';
type SkillLabelProps = SkillLabelNodeBasicType & {
nodeKey: NodeKey;
};
export default function SkillLabel({ export default function SkillLabel({
id, id,
...@@ -13,8 +18,9 @@ export default function SkillLabel({ ...@@ -13,8 +18,9 @@ export default function SkillLabel({
icon, icon,
skillType, skillType,
status, status,
onClick onClick,
}: SkillLabelNodeBasicType) { nodeKey
}: SkillLabelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isInvalid = status === 'invalid'; const isInvalid = status === 'invalid';
...@@ -83,7 +89,7 @@ export default function SkillLabel({ ...@@ -83,7 +89,7 @@ export default function SkillLabel({
bg: colors.hoverBg, bg: colors.hoverBg,
borderColor: colors.hoverBorderColor borderColor: colors.hoverBorderColor
}} }}
onClick={() => onClick(id)} onClick={() => onClick(id, nodeKey)}
transform={'translateY(2px)'} transform={'translateY(2px)'}
> >
<MyTooltip shouldWrapChildren={false} label={tipText}> <MyTooltip shouldWrapChildren={false} label={tipText}>
......
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { $createSkillNode, SkillNode } from './node'; import { $createSkillNode, SkillNode } from './node';
import type { TextNode } from 'lexical'; import {
$getNodeByKey,
$getRoot,
$isElementNode,
type LexicalNode,
type NodeKey,
type TextNode
} from 'lexical';
import { getSkillRegexString } from './utils'; import { getSkillRegexString } from './utils';
import { mergeRegister } from '@lexical/utils'; import { mergeRegister } from '@lexical/utils';
import { registerLexicalTextEntity } from '../../utils'; import { registerLexicalTextEntity } from '../../utils';
...@@ -17,16 +24,28 @@ export type SkillLabelItemType = SelectedToolItemType & { ...@@ -17,16 +24,28 @@ export type SkillLabelItemType = SelectedToolItemType & {
function SkillLabelPlugin({ function SkillLabelPlugin({
selectedSkills = [], selectedSkills = [],
onClickSkill, onClickSkill,
onRemoveSkill pendingSkillsRef
}: { }: {
selectedSkills: SkillLabelItemType[]; selectedSkills: SkillLabelItemType[];
onClickSkill: (id: string) => void; onClickSkill: (id: string) => void;
onRemoveSkill: (id: string) => void; onRemoveSkill: (id: string) => void;
pendingSkillsRef: React.MutableRefObject<Map<string, SkillLabelItemType>>;
}) { }) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const selectedSkillsRef = useRef(selectedSkills);
const onClickSkillRef = useRef(onClickSkill);
// Track the mapping of node keys to skill IDs for detecting deletions useEffect(() => {
const previousIdsRef = useRef<Map<string, string>>(new Map()); selectedSkillsRef.current = selectedSkills;
selectedSkills.forEach((skill) => {
pendingSkillsRef.current.delete(skill.id);
});
}, [pendingSkillsRef, selectedSkills]);
useEffect(() => {
onClickSkillRef.current = onClickSkill;
}, [onClickSkill]);
// Check if SkillNode is registered in the editor // Check if SkillNode is registered in the editor
useEffect(() => { useEffect(() => {
...@@ -49,42 +68,77 @@ function SkillLabelPlugin({ ...@@ -49,42 +68,77 @@ function SkillLabelPlugin({
}; };
}, []); }, []);
const visitSkillNodes = useCallback((handler: (node: SkillNode) => void) => {
const visitNode = (node: LexicalNode) => {
if (node instanceof SkillNode) {
handler(node);
return;
}
if ($isElementNode(node)) {
node.getChildren().forEach(visitNode);
}
};
visitNode($getRoot());
}, []);
const removeSkillNode = useCallback(
(id: string, nodeKey?: NodeKey) => {
editor.update(() => {
if (nodeKey) {
const node = $getNodeByKey(nodeKey);
if (node instanceof SkillNode && node.getSkillKey() === id) {
node.remove();
}
return;
}
visitSkillNodes((node) => {
if (node.getSkillKey() === id) {
node.remove();
}
});
});
},
[editor, visitSkillNodes]
);
const handleSkillClick = useCallback(
(id: string, nodeKey?: NodeKey) => {
const tool =
selectedSkillsRef.current.find((item) => item.id === id) ??
pendingSkillsRef.current.get(id);
if (!tool || tool.configStatus === 'invalid') {
removeSkillNode(id, nodeKey);
return;
}
onClickSkillRef.current(id);
},
[pendingSkillsRef, removeSkillNode]
);
// Register text entity transformer to convert {{@skillId@}} text into SkillNode // Register text entity transformer to convert {{@skillId@}} text into SkillNode
useEffect(() => { useEffect(() => {
const createSkillPlugin = (textNode: TextNode): SkillNode => { const createSkillPlugin = (textNode: TextNode): SkillNode => {
const textContent = textNode.getTextContent(); const textContent = textNode.getTextContent();
const skillId = textContent.slice(3, -3); const skillId = textContent.slice(3, -3);
const tool = selectedSkills.find((t) => t.id === skillId); const selectedTool = selectedSkillsRef.current.find((t) => t.id === skillId);
if (selectedTool) {
if (tool) { pendingSkillsRef.current.delete(skillId);
return $createSkillNode({
id: tool.id,
name: tool.name,
icon: tool.avatar,
skillType: tool.flowNodeType,
status: tool.configStatus,
onClick: onClickSkill
});
} }
const tool = selectedTool ?? pendingSkillsRef.current.get(skillId);
return $createSkillNode({ return $createSkillNode({
id: skillId, id: skillId,
name: skillId, name: tool?.name ?? skillId,
icon: undefined, icon: tool?.avatar,
skillType: FlowNodeTypeEnum.tool, skillType: tool?.flowNodeType ?? FlowNodeTypeEnum.tool,
status: 'invalid', status: tool?.configStatus ?? 'invalid',
onClick: (id) => { onClick: handleSkillClick
// Delete the skill node from editor
editor.update(() => {
const nodes = editor.getEditorState()._nodeMap;
nodes.forEach((node) => {
if (node instanceof SkillNode && node.getSkillKey() === id) {
node.remove();
}
});
});
}
}); });
}; };
...@@ -92,93 +146,38 @@ function SkillLabelPlugin({ ...@@ -92,93 +146,38 @@ function SkillLabelPlugin({
...registerLexicalTextEntity(editor, getSkillMatch, SkillNode, createSkillPlugin) ...registerLexicalTextEntity(editor, getSkillMatch, SkillNode, createSkillPlugin)
); );
return unregister; return unregister;
}, [editor, getSkillMatch, onClickSkill, selectedSkills]); }, [editor, getSkillMatch, handleSkillClick, pendingSkillsRef]);
// Update existing SkillNode properties when selectedSkills change // Update existing SkillNode properties when selectedSkills change
// Sync tool name, avatar, status and configure handler for each skill node // Sync tool name, avatar, status and configure handler for each skill node
useEffect(() => { useEffect(() => {
// Wrapped click handler: delete if invalid, otherwise call original onClickSkill
const handleClick = (id: string, status: SkillLabelItemType['configStatus']) => {
if (status === 'invalid') {
// Delete the skill node from editor
editor.update(() => {
const nodes = editor.getEditorState()._nodeMap;
nodes.forEach((node) => {
if (node instanceof SkillNode && node.getSkillKey() === id) {
node.remove();
}
});
});
} else {
// Call original click handler for configuration
onClickSkill(id);
}
};
// Perform all operations in a single editor.update() to avoid node reference issues
// This ensures we work within the same editor state snapshot
editor.update(() => { editor.update(() => {
const nodes = editor.getEditorState()._nodeMap; visitSkillNodes((node) => {
const id = node.getSkillKey();
nodes.forEach((node) => { const selectedTool = selectedSkills.find((t) => t.id === id);
if (node instanceof SkillNode) { if (selectedTool) {
const id = node.getSkillKey(); pendingSkillsRef.current.delete(id);
const tool = selectedSkills.find((t) => t.id === id);
const writableNode = node.getWritable();
if (tool) {
writableNode.__id = tool.id;
writableNode.__name = tool.name;
writableNode.__icon = tool.avatar;
writableNode.__skillType = tool.flowNodeType;
writableNode.__status = tool.configStatus;
writableNode.__onClick = (id) => handleClick(id, tool.configStatus);
} else {
writableNode.__name = id;
writableNode.__icon = undefined;
writableNode.__skillType = FlowNodeTypeEnum.tool;
writableNode.__status = 'invalid';
writableNode.__onClick = (id) => handleClick(id, 'invalid');
}
}
});
});
}, [selectedSkills, editor, onClickSkill]);
// Monitor skill node mutations and notify parent on destruction
useEffect(() => {
const unregister = editor.registerMutationListener(SkillNode, (mutatedNodes) => {
const currentState = editor.getEditorState();
mutatedNodes.forEach((mutation, nodeKey) => {
if (mutation === 'destroyed') {
const removedId = previousIdsRef.current.get(nodeKey);
if (removedId) {
onRemoveSkill(removedId);
}
previousIdsRef.current.delete(nodeKey);
return;
} }
const tool = selectedTool ?? pendingSkillsRef.current.get(id);
const node = currentState._nodeMap.get(nodeKey); const writableNode = node.getWritable();
if (node instanceof SkillNode) {
previousIdsRef.current.set(nodeKey, node.getSkillKey()); if (tool) {
writableNode.__id = tool.id;
writableNode.__name = tool.name;
writableNode.__icon = tool.avatar;
writableNode.__skillType = tool.flowNodeType;
writableNode.__status = tool.configStatus;
writableNode.__onClick = handleSkillClick;
} else {
writableNode.__name = id;
writableNode.__icon = undefined;
writableNode.__skillType = FlowNodeTypeEnum.tool;
writableNode.__status = 'invalid';
writableNode.__onClick = handleSkillClick;
} }
}); });
}); });
}, [selectedSkills, editor, handleSkillClick, pendingSkillsRef, visitSkillNodes]);
// Initialize with current state
editor.getEditorState().read(() => {
const nodes = editor.getEditorState()._nodeMap;
nodes.forEach((node, nodeKey) => {
if (node instanceof SkillNode) {
previousIdsRef.current.set(nodeKey, node.getSkillKey());
}
});
});
return unregister;
}, [editor, onRemoveSkill]);
return null; return null;
} }
......
...@@ -20,7 +20,7 @@ export type SkillLabelNodeBasicType = { ...@@ -20,7 +20,7 @@ export type SkillLabelNodeBasicType = {
icon?: string; icon?: string;
skillType: FlowNodeTypeEnum; skillType: FlowNodeTypeEnum;
status: SkillLabelItemType['configStatus']; status: SkillLabelItemType['configStatus'];
onClick: (id: string) => void; onClick: (id: string, nodeKey?: NodeKey) => void;
}; };
export type SerializedSkillNode = Spread< export type SerializedSkillNode = Spread<
{ {
...@@ -40,10 +40,13 @@ export class SkillNode extends DecoratorNode<JSX.Element> { ...@@ -40,10 +40,13 @@ export class SkillNode extends DecoratorNode<JSX.Element> {
__icon?: string; __icon?: string;
__skillType: FlowNodeTypeEnum; __skillType: FlowNodeTypeEnum;
__status: SkillLabelItemType['configStatus']; __status: SkillLabelItemType['configStatus'];
__onClick: (id: string) => void; __onClick: (id: string, nodeKey?: NodeKey) => void;
constructor({ id, name, icon, skillType, status, onClick }: SkillLabelNodeBasicType) { constructor(
super(); { id, name, icon, skillType, status, onClick }: SkillLabelNodeBasicType,
key?: NodeKey
) {
super(key);
this.__id = id; this.__id = id;
this.__name = name; this.__name = name;
this.__icon = icon; this.__icon = icon;
...@@ -57,14 +60,17 @@ export class SkillNode extends DecoratorNode<JSX.Element> { ...@@ -57,14 +60,17 @@ export class SkillNode extends DecoratorNode<JSX.Element> {
} }
static clone(node: SkillNode): SkillNode { static clone(node: SkillNode): SkillNode {
const newNode = new SkillNode({ const newNode = new SkillNode(
id: node.__id, {
name: node.__name, id: node.__id,
icon: node.__icon, name: node.__name,
skillType: node.__skillType, icon: node.__icon,
status: node.__status, skillType: node.__skillType,
onClick: node.__onClick status: node.__status,
}); onClick: node.__onClick
},
node.__key
);
return newNode; return newNode;
} }
...@@ -148,6 +154,7 @@ export class SkillNode extends DecoratorNode<JSX.Element> { ...@@ -148,6 +154,7 @@ export class SkillNode extends DecoratorNode<JSX.Element> {
skillType={this.__skillType} skillType={this.__skillType}
status={this.__status} status={this.__status}
onClick={this.__onClick} onClick={this.__onClick}
nodeKey={this.getKey()}
/> />
); );
} }
......
import type { FlexProps } from '@chakra-ui/react'; import type { FlexProps } from '@chakra-ui/react';
import { Box, Flex, Textarea, useBoolean } from '@chakra-ui/react'; import { Box, Flex, Textarea, useBoolean } from '@chakra-ui/react';
import React, { useRef, useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { import { type ChatBoxInputFormType } from '../ChatContainer/ChatBox/type';
type ChatBoxInputFormType, import { ChatInputDefaultHeight, textareaMinH } from '../ChatContainer/ChatBox/constants';
type ChatBoxInputType,
type SendPromptFnType
} from '../ChatContainer/ChatBox/type';
import { textareaMinH } from '../ChatContainer/ChatBox/constants';
import { useFieldArray, type UseFormReturn } from 'react-hook-form'; import { useFieldArray, type UseFormReturn } from 'react-hook-form';
import { ChatBoxContext } from '../ChatContainer/ChatBox/Provider';
import dynamic from 'next/dynamic';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowRuntimeContext } from '../ChatContainer/context/workflowRuntimeContext';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { documentFileType } from '@fastgpt/global/common/file/constants'; import { documentFileType } from '@fastgpt/global/common/file/constants';
import FilePreview from '../ChatContainer/components/FilePreview'; import FilePreview from '../ChatContainer/components/FilePreview';
...@@ -91,6 +84,7 @@ const ChatInput = ({ ...@@ -91,6 +84,7 @@ const ChatInput = ({
showSelectVideo || showSelectVideo ||
showSelectAudio || showSelectAudio ||
showSelectCustomFileExtension; showSelectCustomFileExtension;
const isDefaultInputHeight = !inputValue && fileList.length === 0;
/* on send */ /* on send */
const handleSend = useCallback( const handleSend = useCallback(
...@@ -116,9 +110,11 @@ const ChatInput = ({ ...@@ -116,9 +110,11 @@ const ChatInput = ({
<Textarea <Textarea
ref={TextareaDom} ref={TextareaDom}
py={0} py={0}
mx={[2, 4]} mx={0}
px={2} px={0}
border={'none'} border={'none'}
borderRadius={0}
appearance={'none'}
_focusVisible={{ _focusVisible={{
border: 'none' border: 'none'
}} }}
...@@ -127,10 +123,10 @@ const ChatInput = ({ ...@@ -127,10 +123,10 @@ const ChatInput = ({
} }
resize={'none'} resize={'none'}
rows={1} rows={1}
height={[5, 6]} height={textareaMinH}
lineHeight={[5, 6]} lineHeight={textareaMinH}
maxHeight={[24, 32]} maxHeight={[24, 32]}
minH={'50px'} minH={textareaMinH}
mb={0} mb={0}
maxLength={-1} maxLength={-1}
overflowY={'hidden'} overflowY={'hidden'}
...@@ -140,12 +136,14 @@ const ChatInput = ({ ...@@ -140,12 +136,14 @@ const ChatInput = ({
boxShadow={'none !important'} boxShadow={'none !important'}
color={'myGray.900'} color={'myGray.900'}
fontWeight={400} fontWeight={400}
fontSize={'1rem'} fontSize={'16px'}
letterSpacing={'0.5px'} letterSpacing={'0.5px'}
w={'100%'} w={'100%'}
_placeholder={{ _placeholder={{
color: '#707070', color: 'myGray.400',
fontSize: 'sm' fontSize: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit'
}} }}
value={inputValue} value={inputValue}
onChange={(e) => { onChange={(e) => {
...@@ -167,26 +165,28 @@ const ChatInput = ({ ...@@ -167,26 +165,28 @@ const ChatInput = ({
onKeyDown={(e) => { onKeyDown={(e) => {
// enter send.(pc or iframe && enter and unPress shift) // enter send.(pc or iframe && enter and unPress shift)
const isEnter = e.key === 'Enter'; const isEnter = e.key === 'Enter';
if (isEnter && TextareaDom.current && (e.ctrlKey || e.altKey)) { const textarea = e.currentTarget;
if (isEnter && (e.ctrlKey || e.altKey)) {
// Add a new line // Add a new line
const index = TextareaDom.current.selectionStart; const index = textarea.selectionStart;
const val = TextareaDom.current.value; const val = textarea.value;
TextareaDom.current.value = `${val.slice(0, index)}\n${val.slice(index)}`; textarea.value = `${val.slice(0, index)}\n${val.slice(index)}`;
TextareaDom.current.selectionStart = index + 1; textarea.selectionStart = index + 1;
TextareaDom.current.selectionEnd = index + 1; textarea.selectionEnd = index + 1;
TextareaDom.current.style.height = textareaMinH; textarea.style.height = textareaMinH;
TextareaDom.current.style.height = `${TextareaDom.current.scrollHeight}px`; textarea.style.height = `${textarea.scrollHeight}px`;
return; return;
} }
// Select all content // Select all content
// @ts-ignore if (e.key === 'a' && e.ctrlKey) {
e.key === 'a' && e.ctrlKey && e.target?.select(); textarea.select();
}
if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) { if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) {
handleSend(); handleSend(textarea.value);
e.preventDefault(); e.preventDefault();
} }
}} }}
...@@ -230,8 +230,8 @@ const ChatInput = ({ ...@@ -230,8 +230,8 @@ const ChatInput = ({
const RenderButtonGroup = useMemo(() => { const RenderButtonGroup = useMemo(() => {
const iconSize = { const iconSize = {
w: isPc ? '20px' : '16px', w: '20px',
h: isPc ? '20px' : '16px' h: '20px'
}; };
return ( return (
...@@ -240,23 +240,22 @@ const ChatInput = ({ ...@@ -240,23 +240,22 @@ const ChatInput = ({
justifyContent={'space-between'} justifyContent={'space-between'}
w={'100%'} w={'100%'}
mt={0} mt={0}
pr={[3, 4]} h={9}
pl={[3, 4]}
h={[8, 9]}
gap={[0, 1]} gap={[0, 1]}
> >
<Box flex={1} /> <Flex alignItems={'center'} gap={2} flex={'1 1 0'} minW={0} w={0} />
{/* Right button group */}
{/* 右侧按钮组 */}
<Flex alignItems={'center'} gap={[0, 1]}> <Flex alignItems={'center'} gap={[0, 1]}>
{/* Attachment Group */} {/* Attachment Group */}
<Flex alignItems={'center'} h={[8, 9]}> <Flex alignItems={'center'} h={9}>
{/* file selector button */} {/* file selector button */}
{canUploadFile && ( {canUploadFile && (
<Flex <Flex
alignItems={'center'} alignItems={'center'}
justifyContent={'center'} justifyContent={'center'}
w={[8, 9]} w={9}
h={[8, 9]} h={9}
p={[1, 2]} p={[1, 2]}
borderRadius={'sm'} borderRadius={'sm'}
cursor={'pointer'} cursor={'pointer'}
...@@ -267,7 +266,7 @@ const ChatInput = ({ ...@@ -267,7 +266,7 @@ const ChatInput = ({
}} }}
> >
<MyTooltip label={selectFileLabel}> <MyTooltip label={selectFileLabel}>
<MyIcon name={selectFileIcon as any} {...iconSize} color={'#707070'} /> <MyIcon name={selectFileIcon as any} {...iconSize} color={'myGray.500'} />
</MyTooltip> </MyTooltip>
<File onSelect={(files) => onSelectFile({ files })} /> <File onSelect={(files) => onSelectFile({ files })} />
</Flex> </Flex>
...@@ -282,12 +281,12 @@ const ChatInput = ({ ...@@ -282,12 +281,12 @@ const ChatInput = ({
)} )}
{/* Send Button Container */} {/* Send Button Container */}
<Flex alignItems={'center'} w={[8, 9]} h={[8, 9]} borderRadius={'lg'}> <Flex alignItems={'center'} w={9} h={9} borderRadius={'lg'}>
<Flex <Flex
alignItems={'center'} alignItems={'center'}
justifyContent={'center'} justifyContent={'center'}
w={[7, 9]} w={9}
h={[7, 9]} h={9}
p={[1, 2]} p={[1, 2]}
bg={ bg={
isChatting ? 'primary.50' : canSendMessage ? 'primary.500' : 'rgba(17, 24, 36, 0.1)' isChatting ? 'primary.50' : canSendMessage ? 'primary.500' : 'rgba(17, 24, 36, 0.1)'
...@@ -315,7 +314,6 @@ const ChatInput = ({ ...@@ -315,7 +314,6 @@ const ChatInput = ({
</Flex> </Flex>
); );
}, [ }, [
isPc,
canUploadFile, canUploadFile,
selectFileLabel, selectFileLabel,
selectFileIcon, selectFileIcon,
...@@ -331,11 +329,16 @@ const ChatInput = ({ ...@@ -331,11 +329,16 @@ const ChatInput = ({
const activeStyles: FlexProps = { const activeStyles: FlexProps = {
boxShadow: '0px 5px 20px -4px rgba(19, 51, 107, 0.13)', boxShadow: '0px 5px 20px -4px rgba(19, 51, 107, 0.13)',
border: '0.5px solid rgba(0, 0, 0, 0.24)' border: '1px solid',
borderColor: 'myGray.250'
}; };
return ( return (
<Box <Box
w={'100%'}
maxW={['100%', '780px']}
mx={'auto'}
pb={['calc(16px + env(safe-area-inset-bottom))', 4]}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
...@@ -364,9 +367,10 @@ const ChatInput = ({ ...@@ -364,9 +367,10 @@ const ChatInput = ({
{/* Real Chat Input */} {/* Real Chat Input */}
<Flex <Flex
direction={'column'} direction={'column'}
minH={['96px', '120px']} h={isDefaultInputHeight ? ChatInputDefaultHeight : undefined}
pt={fileList.length > 0 ? '0' : [3, 4]} minH={ChatInputDefaultHeight}
pb={3} p={4}
mb={0}
position={'relative'} position={'relative'}
borderRadius={['xl', 'xxl']} borderRadius={['xl', 'xxl']}
bg={'white'} bg={'white'}
...@@ -375,15 +379,16 @@ const ChatInput = ({ ...@@ -375,15 +379,16 @@ const ChatInput = ({
? activeStyles ? activeStyles
: { : {
_hover: activeStyles, _hover: activeStyles,
border: '0.5px solid rgba(0, 0, 0, 0.18)', border: '1px solid',
borderColor: 'myGray.200',
boxShadow: `0px 5px 16px -4px rgba(19, 51, 107, 0.08)` boxShadow: `0px 5px 16px -4px rgba(19, 51, 107, 0.08)`
})} })}
onClick={() => TextareaDom?.current?.focus()} onClick={() => TextareaDom?.current?.focus()}
> >
<Box flex={1}> <Box flex={1}>
{/* file preview */} {/* file preview */}
<Box px={[2, 3]}> <Box>
<FilePreview fileList={fileList} removeFiles={removeFiles} /> <FilePreview fileList={fileList} removeFiles={removeFiles} pt={0} />
</Box> </Box>
{RenderTextarea} {RenderTextarea}
...@@ -391,7 +396,7 @@ const ChatInput = ({ ...@@ -391,7 +396,7 @@ const ChatInput = ({
<Box>{RenderButtonGroup}</Box> <Box>{RenderButtonGroup}</Box>
</Flex> </Flex>
<ComplianceTip type={'chat'} /> <ComplianceTip type={'chat'} pt={4} pb={0} />
</Box> </Box>
); );
}; };
......
...@@ -26,7 +26,7 @@ import { ...@@ -26,7 +26,7 @@ import {
type HelperBotChatItemSiteType type HelperBotChatItemSiteType
} from '@fastgpt/global/core/chat/helperBot/type'; } from '@fastgpt/global/core/chat/helperBot/type';
import type { onSendMessageParamsType } from './type'; import type { onSendMessageParamsType } from './type';
import { textareaMinH } from '../ChatContainer/ChatBox/constants'; import { ChatInputWrapperStyle, textareaMinH } from '../ChatContainer/ChatBox/constants';
import { streamFetch } from '@/web/common/api/fetch'; import { streamFetch } from '@/web/common/api/fetch';
import type { generatingMessageProps } from '../ChatContainer/type'; import type { generatingMessageProps } from '../ChatContainer/type';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
...@@ -408,12 +408,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -408,12 +408,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
</Box> </Box>
))} ))}
</ScrollData> </ScrollData>
<Box <Box {...ChatInputWrapperStyle}>
px={[3, 5]}
m={['0 auto 10px', '10px auto']}
w={'100%'}
maxW={['auto', 'min(820px, 100%)']}
>
<ChatInput <ChatInput
TextareaDom={TextareaDom} TextareaDom={TextareaDom}
chatId={chatId} chatId={chatId}
......
...@@ -71,12 +71,12 @@ const EditForm = ({ ...@@ -71,12 +71,12 @@ const EditForm = ({
onDeleteTool: (id) => { onDeleteTool: (id) => {
setAppForm((state) => ({ setAppForm((state) => ({
...state, ...state,
selectedTools: state.selectedTools?.filter((item) => item.id !== id) || [] selectedTools: state.selectedTools?.filter((item) => item.pluginId !== id) || []
})); }));
}, },
onUpdateOrAddTool: (tool) => { onUpdateOrAddTool: (tool) => {
setAppForm((state) => { setAppForm((state) => {
const index = state.selectedTools.findIndex((item) => item.id === tool.id); const index = state.selectedTools.findIndex((item) => item.pluginId === tool.pluginId);
if (index === -1) { if (index === -1) {
return { return {
...@@ -87,7 +87,8 @@ const EditForm = ({ ...@@ -87,7 +87,8 @@ const EditForm = ({
return { return {
...state, ...state,
selectedTools: selectedTools:
state.selectedTools?.map((item) => (item.id === tool.id ? tool : item)) || [] state.selectedTools?.map((item) => (item.pluginId === tool.pluginId ? tool : item)) ||
[]
}; };
} }
}); });
...@@ -143,14 +144,14 @@ const EditForm = ({ ...@@ -143,14 +144,14 @@ const EditForm = ({
return { return {
...option, ...option,
onClick: async (toolId: string) => { onClick: async (toolId: string) => {
const skillId = await option.onClick?.(toolId); const result = await option.onClick?.(toolId);
// AgentV2 提示词 @虚拟机 时,同步打开下方虚拟机开关。 // AgentV2 提示词 @虚拟机 时,同步打开下方虚拟机开关。
if (skillId === AGENT_SANDBOX_TOOLSET_ID && !appForm.aiSettings.useAgentSandbox) { if (result?.id === AGENT_SANDBOX_TOOLSET_ID && !appForm.aiSettings.useAgentSandbox) {
onChangeAgentSandbox(true); onChangeAgentSandbox(true);
} }
return skillId; return result;
} }
}; };
} }
...@@ -415,7 +416,8 @@ const EditForm = ({ ...@@ -415,7 +416,8 @@ const EditForm = ({
setAppForm((state) => ({ setAppForm((state) => ({
...state, ...state,
selectedTools: selectedTools:
state.selectedTools?.map((item) => (item.id === e.id ? e : item)) || [] state.selectedTools?.map((item) => (item.pluginId === e.pluginId ? e : item)) ||
[]
})); }));
}} }}
onRemoveTool={(id) => { onRemoveTool={(id) => {
......
...@@ -32,6 +32,7 @@ import { useLatest } from 'ahooks'; ...@@ -32,6 +32,7 @@ import { useLatest } from 'ahooks';
import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools'; import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools';
import type { SkillClickResult } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin';
const ConfigToolModal = dynamic(() => import('../../component/ConfigToolModal')); const ConfigToolModal = dynamic(() => import('../../component/ConfigToolModal'));
...@@ -45,10 +46,19 @@ const isSubApp = (flowNodeType: FlowNodeTypeEnum) => { ...@@ -45,10 +46,19 @@ const isSubApp = (flowNodeType: FlowNodeTypeEnum) => {
return subAppTypeMap[flowNodeType]; return subAppTypeMap[flowNodeType];
}; };
const toSkillLabelItem = (
tool: SelectedToolItemType,
configStatus: SkillLabelItemType['configStatus']
): SkillLabelItemType => ({
...tool,
id: tool.pluginId!,
name: tool.name,
configStatus
});
export const useSkillManager = ({ export const useSkillManager = ({
selectedTools, selectedTools,
onUpdateOrAddTool, onUpdateOrAddTool,
onDeleteTool,
canUploadFile, canUploadFile,
hasSelectedDataset, hasSelectedDataset,
useAgentSandbox useAgentSandbox
...@@ -100,6 +110,17 @@ export const useSkillManager = ({ ...@@ -100,6 +110,17 @@ export const useSkillManager = ({
}); });
} }
const readFilesInfo = systemSubInfo[SubAppIds.readFiles];
if (readFilesInfo) {
apiTools.unshift({
id: SubAppIds.readFiles,
label: parseI18nString(readFilesInfo.name, i18n.language),
icon: readFilesInfo.avatar,
description: readFilesInfo.toolDescription,
canClick: true
});
}
const sandboxToolInfo = systemSubInfo[AGENT_SANDBOX_TOOLSET_ID]; const sandboxToolInfo = systemSubInfo[AGENT_SANDBOX_TOOLSET_ID];
if (sandboxToolInfo) { if (sandboxToolInfo) {
apiTools.unshift({ apiTools.unshift({
...@@ -176,17 +197,57 @@ export const useSkillManager = ({ ...@@ -176,17 +197,57 @@ export const useSkillManager = ({
const lastSelectedTools = useLatest(selectedTools); const lastSelectedTools = useLatest(selectedTools);
const onAddAppOrTool = useCallback( const onAddAppOrTool = useCallback(
async (toolId: string) => { async (toolId: string): Promise<SkillClickResult | undefined> => {
console.log('Add tool', toolId);
// Check tool exists, if exists, not update/add tool // Check tool exists, if exists, not update/add tool
const existsTool = lastSelectedTools.current?.find((tool) => tool.pluginId === toolId); const existsTool = lastSelectedTools.current?.find((tool) => tool.pluginId === toolId);
if (existsTool) { if (existsTool) {
return existsTool.pluginId; const skill = toSkillLabelItem(existsTool, existsTool.configStatus || 'waitingForConfig');
return {
id: skill.id,
skill
};
} }
// Check if it's a sub agent tool // Check if it's a sub agent tool
if (toolId in systemSubInfo) { if (toolId in systemSubInfo) {
return toolId; const subToolInfo = systemSubInfo[toolId as keyof typeof systemSubInfo];
if (!subToolInfo) return;
const configStatus: SkillLabelItemType['configStatus'] = (() => {
if (toolId === SubAppIds.datasetSearch) {
return hasSelectedDataset ? 'configured' : 'invalid';
}
if (toolId === SubAppIds.readFiles) {
return canUploadFile ? 'configured' : 'invalid';
}
if (toolId === AGENT_SANDBOX_TOOLSET_ID) {
return useAgentSandbox ? 'noConfig' : 'invalid';
}
return 'noConfig';
})();
const skill: SkillLabelItemType = {
id: toolId,
pluginId: toolId,
name: parseI18nString(subToolInfo.name, i18n.language),
avatar: subToolInfo.avatar,
intro: subToolInfo.toolDescription,
flowNodeType: FlowNodeTypeEnum.tool,
templateType: FlowNodeTemplateTypeEnum.tools,
inputs: [],
outputs: [],
configStatus
};
return {
id: skill.id,
skill
};
} }
const toolTemplate = await getToolPreviewNode({ appId: toolId, versionId: '' }); const toolTemplate = await getToolPreviewNode({ appId: toolId, versionId: '' });
...@@ -207,15 +268,29 @@ export const useSkillManager = ({ ...@@ -207,15 +268,29 @@ export const useSkillManager = ({
...toolTemplate, ...toolTemplate,
id: toolTemplate.pluginId! id: toolTemplate.pluginId!
}; };
const configStatus = getToolConfigStatus({ tool }).status;
const skill = toSkillLabelItem(tool, configStatus);
onUpdateOrAddTool({ onUpdateOrAddTool({
...tool, ...tool,
configStatus: getToolConfigStatus({ tool }).status configStatus
}); });
return tool.id; return {
id: skill.id,
skill
};
}, },
[canUploadFile, lastSelectedTools, onUpdateOrAddTool, t, toast] [
canUploadFile,
hasSelectedDataset,
i18n.language,
lastSelectedTools,
onUpdateOrAddTool,
t,
toast,
useAgentSandbox
]
); );
/* ===== Skill option ===== */ /* ===== Skill option ===== */
...@@ -305,6 +380,22 @@ export const useSkillManager = ({ ...@@ -305,6 +380,22 @@ export const useSkillManager = ({
}); });
} }
const readFilesInfo = systemSubInfo[SubAppIds.readFiles];
if (readFilesInfo) {
tools.push({
id: SubAppIds.readFiles,
pluginId: SubAppIds.readFiles,
name: parseI18nString(readFilesInfo.name, i18n.language),
avatar: readFilesInfo.avatar,
intro: readFilesInfo.toolDescription,
flowNodeType: FlowNodeTypeEnum.tool,
templateType: FlowNodeTemplateTypeEnum.tools,
inputs: [],
outputs: [],
configStatus: canUploadFile ? 'configured' : 'invalid'
});
}
// Merge sandbox tool // Merge sandbox tool
const sandboxToolInfo = systemSubInfo[AGENT_SANDBOX_TOOLSET_ID]; const sandboxToolInfo = systemSubInfo[AGENT_SANDBOX_TOOLSET_ID];
if (sandboxToolInfo) { if (sandboxToolInfo) {
...@@ -328,7 +419,7 @@ export const useSkillManager = ({ ...@@ -328,7 +419,7 @@ export const useSkillManager = ({
const [configTool, setConfigTool] = useState<SelectedToolItemType>(); const [configTool, setConfigTool] = useState<SelectedToolItemType>();
const onClickSkill = useCallback( const onClickSkill = useCallback(
(id: string) => { (id: string) => {
const tool = selectedTools.find((tool) => tool.id === id); const tool = selectedTools.find((tool) => tool.pluginId === id);
if (!tool) return; if (!tool) return;
if (isSubApp(tool.flowNodeType)) { if (isSubApp(tool.flowNodeType)) {
...@@ -338,19 +429,11 @@ export const useSkillManager = ({ ...@@ -338,19 +429,11 @@ export const useSkillManager = ({
} }
setConfigTool(tool); setConfigTool(tool);
} else {
console.log('onClickSkill', tool);
} }
}, },
[selectedTools] [selectedTools]
); );
const onRemoveSkill = useCallback( const onRemoveSkill = useCallback(() => {}, []);
(id: string) => {
console.log('onRemoveSkill', id);
onDeleteTool(id);
},
[onDeleteTool]
);
const SkillModal = useCallback(() => { const SkillModal = useCallback(() => {
return ( return (
......
...@@ -289,7 +289,7 @@ const RenderList = React.memo(function RenderList({ ...@@ -289,7 +289,7 @@ const RenderList = React.memo(function RenderList({
const topTool = topAgentSelectedTools.find((tool) => tool.pluginId === res.pluginId); const topTool = topAgentSelectedTools.find((tool) => tool.pluginId === res.pluginId);
if (topTool) { if (topTool) {
res.inputs.forEach((input) => { res.inputs.forEach((input) => {
const topInput = topTool.inputs.find((input) => input.key === input.key); const topInput = topTool.inputs.find((topInput) => topInput.key === input.key);
if (topInput) { if (topInput) {
input.value = topInput.value; input.value = topInput.value;
} }
...@@ -452,7 +452,7 @@ const RenderList = React.memo(function RenderList({ ...@@ -452,7 +452,7 @@ const RenderList = React.memo(function RenderList({
return ( return (
<Flex position="relative" direction="column" h="100%"> <Flex position="relative" direction="column" h="100%">
<Box overflowY="auto" mb={8} w={'full'}> <Box overflowY="auto" mb={8} w={'full'}>
<PluginListRender /> {PluginListRender()}
</Box> </Box>
{type === TemplateTypeEnum.systemTools && ( {type === TemplateTypeEnum.systemTools && (
<Flex <Flex
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment