Commit 741ad4e2 by Finley Ge Committed by GitHub

feat(app): filter unavailable tools in system tool selector (#7138)

- Add `status` field to system tool schemas and definitions.
- Implement `isSelectableToolStatus` to restrict tools displayed in the
  creation/selection list to those with 'Normal' status.
- Update `getSystemToolTemplates` API to filter root tools and toolset
  children based on their status.
- Add unit tests to verify tool filtering logic.
parent ffa1037a
...@@ -68,6 +68,7 @@ export type SystemToolListItemType = z.infer<typeof SystemToolListItemSchema>; ...@@ -68,6 +68,7 @@ export type SystemToolListItemType = z.infer<typeof SystemToolListItemSchema>;
export const SystemToolChildDetailSchema = z.object({ export const SystemToolChildDetailSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
status: PluginStatusSchema.meta({ description: '工具的状态' }),
description: z.string().optional(), description: z.string().optional(),
toolDescription: z.string().optional(), toolDescription: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
......
...@@ -237,6 +237,7 @@ export const NodeTemplateListItemTypeSchema = z.object({ ...@@ -237,6 +237,7 @@ export const NodeTemplateListItemTypeSchema = z.object({
currentCost: NumSchema.optional(), // 当前积分消耗 currentCost: NumSchema.optional(), // 当前积分消耗
systemKeyCost: NumSchema.optional(), // 系统密钥费用,统一为数字 systemKeyCost: NumSchema.optional(), // 系统密钥费用,统一为数字
hasTokenFee: BoolSchema.optional(), hasTokenFee: BoolSchema.optional(),
status: PluginStatusSchema.optional(),
instructions: z.string().optional(), // 使用说明 instructions: z.string().optional(), // 使用说明
courseUrl: z.string().optional(), courseUrl: z.string().optional(),
readmeUrl: z.string().optional(), readmeUrl: z.string().optional(),
......
...@@ -51,7 +51,14 @@ type SystemToolRuntimeType = { ...@@ -51,7 +51,14 @@ type SystemToolRuntimeType = {
type SystemToolDisplayChildType = Pick< type SystemToolDisplayChildType = Pick<
SystemToolChildDetailType, SystemToolChildDetailType,
'id' | 'name' | 'description' | 'toolDescription' | 'icon' | 'currentCost' | 'systemKeyCost' | 'id'
| 'name'
| 'status'
| 'description'
| 'toolDescription'
| 'icon'
| 'currentCost'
| 'systemKeyCost'
>; >;
type SystemToolDisplayInfoType = Pick< type SystemToolDisplayInfoType = Pick<
...@@ -292,6 +299,7 @@ export class SystemToolRepo { ...@@ -292,6 +299,7 @@ export class SystemToolRepo {
return { return {
id: item.id, id: item.id,
name: parseI18nString(item.name, lang), name: parseI18nString(item.name, lang),
status: dbChild?.status ?? PluginStatusEnum.Normal,
description: parseI18nString(item.description, lang), description: parseI18nString(item.description, lang),
systemKeyCost: dbChild?.systemKeyCost ?? 0, systemKeyCost: dbChild?.systemKeyCost ?? 0,
currentCost: dbChild?.currentCost ?? 0, currentCost: dbChild?.currentCost ?? 0,
...@@ -460,6 +468,7 @@ export class SystemToolRepo { ...@@ -460,6 +468,7 @@ export class SystemToolRepo {
return { return {
id: item.id, id: item.id,
name: parseI18nString(item.name, lang), name: parseI18nString(item.name, lang),
status: childConfig?.status ?? PluginStatusEnum.Normal,
description: parseI18nString(item.description, lang), description: parseI18nString(item.description, lang),
toolDescription: childConfig?.customConfig?.toolDescription ?? item.toolDescription, toolDescription: childConfig?.customConfig?.toolDescription ?? item.toolDescription,
icon: childIcon, icon: childIcon,
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
type GetSystemToolTemplatesBodyType type GetSystemToolTemplatesBodyType
} from '@fastgpt/global/openapi/core/app/tool/api'; } from '@fastgpt/global/openapi/core/app/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
export type GetSystemPluginTemplatesBody = GetSystemToolTemplatesBodyType; export type GetSystemPluginTemplatesBody = GetSystemToolTemplatesBodyType;
...@@ -42,24 +43,30 @@ export async function handler( ...@@ -42,24 +43,30 @@ export async function handler(
lang, lang,
source: 'system' source: 'system'
}); });
if (!isSelectableToolStatus(parent.status)) {
return GetSystemToolTemplatesResponseSchema.parse([]);
}
const childTemplates = const childTemplates =
parent.children?.map((child) => ({ parent.children
...parent, ?.filter((child) => isSelectableToolStatus(child.status))
templateType: FlowNodeTemplateTypeEnum.tools, .map((child) => ({
// templateType: tool.isToolSet ...parent,
// ? FlowNodeTemplateTypeEnum.tools templateType: FlowNodeTemplateTypeEnum.tools,
// : FlowNodeTemplateTypeEnum.other, // templateType: tool.isToolSet
flowNodeType: FlowNodeTypeEnum.tool, // ? FlowNodeTemplateTypeEnum.tools
name: child.name, // : FlowNodeTemplateTypeEnum.other,
intro: child.description, flowNodeType: FlowNodeTypeEnum.tool,
toolDescription: child.toolDescription, name: child.name,
id: `${parentId}/${child.id}`, intro: child.description,
avatar: child.icon ?? parent.avatar, toolDescription: child.toolDescription,
currentCost: child.currentCost, id: `${parentId}/${child.id}`,
systemKeyCost: child.systemKeyCost, avatar: child.icon ?? parent.avatar,
hasTokenFee: parent.hasTokenFee currentCost: child.currentCost,
})) ?? []; systemKeyCost: child.systemKeyCost,
hasTokenFee: parent.hasTokenFee,
status: child.status
})) ?? [];
return GetSystemToolTemplatesResponseSchema.parse( return GetSystemToolTemplatesResponseSchema.parse(
filterTemplatesBySearchKey(childTemplates, searchRegex) filterTemplatesBySearchKey(childTemplates, searchRegex)
...@@ -74,6 +81,7 @@ export async function handler( ...@@ -74,6 +81,7 @@ export async function handler(
const templates = tools const templates = tools
.filter((item) => { .filter((item) => {
if (!isSelectableToolStatus(item.status)) return false;
if (isRoot) return true; if (isRoot) return true;
if (item.hideTags && item.hideTags.some((tag) => userTags.includes(tag))) return false; if (item.hideTags && item.hideTags.some((tag) => userTags.includes(tag))) return false;
return true; return true;
...@@ -101,6 +109,13 @@ function getSearchRegex(searchKey?: string) { ...@@ -101,6 +109,13 @@ function getSearchRegex(searchKey?: string) {
return new RegExp(replaceRegChars(trimmedSearchKey), 'i'); return new RegExp(replaceRegChars(trimmedSearchKey), 'i');
} }
/**
* Agent 工具选择列表只展示仍可新增配置的工具;旧应用中已选中的下线工具由节点/表单卡片继续显示状态。
*/
function isSelectableToolStatus(status?: PluginStatusType) {
return status === undefined || status === PluginStatusEnum.Normal;
}
function filterTemplatesBySearchKey<T extends NodeTemplateListItemType>( function filterTemplatesBySearchKey<T extends NodeTemplateListItemType>(
templates: T[], templates: T[],
searchRegex?: RegExp searchRegex?: RegExp
......
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
authCert: vi.fn(), authCert: vi.fn(),
...@@ -59,6 +60,7 @@ describe('get system tool templates handler', () => { ...@@ -59,6 +60,7 @@ describe('get system tool templates handler', () => {
intro: 'Forecast lookup', intro: 'Forecast lookup',
toolDescription: 'Get weather', toolDescription: 'Get weather',
isToolSet: false, isToolSet: false,
status: PluginStatusEnum.Normal,
tags: ['life'] tags: ['life']
}, },
{ {
...@@ -67,6 +69,7 @@ describe('get system tool templates handler', () => { ...@@ -67,6 +69,7 @@ describe('get system tool templates handler', () => {
intro: 'Calculator', intro: 'Calculator',
toolDescription: 'Compute numbers', toolDescription: 'Compute numbers',
isToolSet: false, isToolSet: false,
status: PluginStatusEnum.Normal,
tags: ['calc'] tags: ['calc']
}, },
{ {
...@@ -75,6 +78,7 @@ describe('get system tool templates handler', () => { ...@@ -75,6 +78,7 @@ describe('get system tool templates handler', () => {
intro: 'Forecast lookup', intro: 'Forecast lookup',
toolDescription: 'Get weather', toolDescription: 'Get weather',
isToolSet: false, isToolSet: false,
status: PluginStatusEnum.Normal,
tags: ['life'], tags: ['life'],
hideTags: ['hidden-user'] hideTags: ['hidden-user']
} }
...@@ -101,10 +105,12 @@ describe('get system tool templates handler', () => { ...@@ -101,10 +105,12 @@ describe('get system tool templates handler', () => {
name: 'Toolset', name: 'Toolset',
intro: 'Parent intro', intro: 'Parent intro',
avatar: 'parent-icon', avatar: 'parent-icon',
status: PluginStatusEnum.Normal,
children: [ children: [
{ {
id: 'plus', id: 'plus',
name: 'A+B Tool', name: 'A+B Tool',
status: PluginStatusEnum.Normal,
description: 'Exact plus', description: 'Exact plus',
toolDescription: 'Use literal plus', toolDescription: 'Use literal plus',
currentCost: 2, currentCost: 2,
...@@ -113,6 +119,7 @@ describe('get system tool templates handler', () => { ...@@ -113,6 +119,7 @@ describe('get system tool templates handler', () => {
{ {
id: 'regex-like', id: 'regex-like',
name: 'AxxB Tool', name: 'AxxB Tool',
status: PluginStatusEnum.Normal,
description: 'Would match an unescaped regex', description: 'Would match an unescaped regex',
toolDescription: 'No literal plus', toolDescription: 'No literal plus',
currentCost: 3, currentCost: 3,
...@@ -142,4 +149,118 @@ describe('get system tool templates handler', () => { ...@@ -142,4 +149,118 @@ describe('get system tool templates handler', () => {
}); });
expect(mocks.getSystemToolDetail).not.toHaveBeenCalled(); expect(mocks.getSystemToolDetail).not.toHaveBeenCalled();
}); });
it('filters soon offline and offline tools from root system tool candidates', async () => {
mocks.getSystemToolList.mockResolvedValue([
{
id: 'normal-tool',
name: 'Normal Tool',
intro: '',
toolDescription: '',
isToolSet: false,
status: PluginStatusEnum.Normal,
tags: []
},
{
id: 'soon-offline-tool',
name: 'Soon Offline Tool',
intro: '',
toolDescription: '',
isToolSet: false,
status: PluginStatusEnum.SoonOffline,
tags: []
},
{
id: 'offline-tool',
name: 'Offline Tool',
intro: '',
toolDescription: '',
isToolSet: false,
status: PluginStatusEnum.Offline,
tags: []
}
]);
const result = await handler({
body: {}
} as ApiRequestProps<GetSystemPluginTemplatesBody>);
expect(result.map((item) => item.id)).toEqual(['normal-tool']);
});
it('filters unavailable toolset children from system tool candidates', async () => {
mocks.getSystemToolDisplayInfo.mockResolvedValue({
id: 'toolset',
name: 'Toolset',
intro: 'Parent intro',
avatar: 'parent-icon',
status: PluginStatusEnum.Normal,
children: [
{
id: 'normal-child',
name: 'Normal Child',
status: PluginStatusEnum.Normal,
description: '',
currentCost: 1,
systemKeyCost: 0
},
{
id: 'soon-offline-child',
name: 'Soon Offline Child',
status: PluginStatusEnum.SoonOffline,
description: '',
currentCost: 1,
systemKeyCost: 0
},
{
id: 'offline-child',
name: 'Offline Child',
status: PluginStatusEnum.Offline,
description: '',
currentCost: 1,
systemKeyCost: 0
}
],
hasTokenFee: false
});
const result = await handler({
body: {
parentId: 'toolset'
}
} as ApiRequestProps<GetSystemPluginTemplatesBody>);
expect(result.map((item) => item.id)).toEqual(['toolset/normal-child']);
expect(mocks.getSystemToolDetail).not.toHaveBeenCalled();
});
it('returns no children when parent toolset is unavailable', async () => {
mocks.getSystemToolDisplayInfo.mockResolvedValue({
id: 'toolset',
name: 'Toolset',
intro: 'Parent intro',
avatar: 'parent-icon',
status: PluginStatusEnum.Offline,
children: [
{
id: 'normal-child',
name: 'Normal Child',
status: PluginStatusEnum.Normal,
description: '',
currentCost: 1,
systemKeyCost: 0
}
],
hasTokenFee: false
});
const result = await handler({
body: {
parentId: 'toolset'
}
} as ApiRequestProps<GetSystemPluginTemplatesBody>);
expect(result).toEqual([]);
expect(mocks.getSystemToolDetail).not.toHaveBeenCalled();
});
}); });
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