From 5f196544a34e720df32b7cac2194a438e63a9cb1 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 10 Aug 2026 15:59:50 +0800 Subject: [PATCH 01/11] refactor(workflow): deprecate queryExtension node Signed-off-by: Nixieboluo --- packages/global/core/workflow/template/constants.ts | 6 +++--- .../system/{ => abandoned}/queryExtension.ts | 12 +++++++----- .../dispatch/{tools => abandoned}/queryExternsion.ts | 3 +++ packages/service/core/workflow/dispatch/constants.ts | 7 ++++--- 4 files changed, 17 insertions(+), 11 deletions(-) rename packages/global/core/workflow/template/system/{ => abandoned}/queryExtension.ts (84%) rename packages/service/core/workflow/dispatch/{tools => abandoned}/queryExternsion.ts (97%) diff --git a/packages/global/core/workflow/template/constants.ts b/packages/global/core/workflow/template/constants.ts index bad414213162..f93b99414c1d 100644 --- a/packages/global/core/workflow/template/constants.ts +++ b/packages/global/core/workflow/template/constants.ts @@ -17,7 +17,7 @@ import { AgentNode } from './system/agent'; import { RunAppModule } from './system/abandoned/runApp/index'; import { PluginInputModule } from './system/pluginInput'; import { PluginOutputModule } from './system/pluginOutput'; -import { AiQueryExtension } from './system/queryExtension'; +import { AiQueryExtension } from './system/abandoned/queryExtension'; import { RunAppNode } from './system/runApp'; import { RunPluginModule } from './system/runPlugin'; @@ -55,7 +55,6 @@ const systemNodes: FlowNodeTemplateType[] = [ AgentNode, ReadFilesNode, HttpNode468, - AiQueryExtension, IfElseNode, VariableUpdateNode, CodeNode, @@ -96,5 +95,6 @@ export const moduleTemplatesFlat: FlowNodeTemplateType[] = [ LoopEndNode, LoopRunStartNode, RunToolNode, - RunToolSetNode + RunToolSetNode, + AiQueryExtension ]; diff --git a/packages/global/core/workflow/template/system/queryExtension.ts b/packages/global/core/workflow/template/system/abandoned/queryExtension.ts similarity index 84% rename from packages/global/core/workflow/template/system/queryExtension.ts rename to packages/global/core/workflow/template/system/abandoned/queryExtension.ts index 27f82943a5f1..bdfda9614221 100644 --- a/packages/global/core/workflow/template/system/queryExtension.ts +++ b/packages/global/core/workflow/template/system/abandoned/queryExtension.ts @@ -2,25 +2,27 @@ import { FlowNodeInputTypeEnum, FlowNodeOutputTypeEnum, FlowNodeTypeEnum -} from '../../node/constant'; -import { type FlowNodeTemplateType } from '../../type/node'; +} from '../../../node/constant'; +import { type FlowNodeTemplateType } from '../../../type/node'; import { WorkflowIOValueTypeEnum, NodeInputKeyEnum, NodeOutputKeyEnum, FlowNodeTemplateTypeEnum -} from '../../constants'; +} from '../../../constants'; import { Input_Template_History, Input_Template_UserChatInput, Input_Template_SelectAIModel -} from '../input'; -import { i18nT } from '../../../../common/i18n/utils'; +} from '../../input'; +import { i18nT } from '../../../../../common/i18n/utils'; +import { PluginStatusEnum } from '../../../../plugin/type'; export const AiQueryExtension: FlowNodeTemplateType = { id: FlowNodeTypeEnum.queryExtension, templateType: FlowNodeTemplateTypeEnum.other, flowNodeType: FlowNodeTypeEnum.queryExtension, + status: PluginStatusEnum.SoonOffline, showSourceHandle: true, showTargetHandle: true, avatar: 'core/workflow/template/queryExtension', diff --git a/packages/service/core/workflow/dispatch/tools/queryExternsion.ts b/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts similarity index 97% rename from packages/service/core/workflow/dispatch/tools/queryExternsion.ts rename to packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts index 6ee6b11001bf..e21187171784 100644 --- a/packages/service/core/workflow/dispatch/tools/queryExternsion.ts +++ b/packages/service/core/workflow/dispatch/abandoned/queryExternsion.ts @@ -1,3 +1,5 @@ +/* Abandoned */ + import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; @@ -20,6 +22,7 @@ type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.text]: string; }>; +/** @deprecated 保留用于兼容已保存的问题优化节点。 */ export const dispatchQueryExtension = async ({ histories, node, diff --git a/packages/service/core/workflow/dispatch/constants.ts b/packages/service/core/workflow/dispatch/constants.ts index 936bda35921d..bbdecc5d0d23 100644 --- a/packages/service/core/workflow/dispatch/constants.ts +++ b/packages/service/core/workflow/dispatch/constants.ts @@ -28,7 +28,7 @@ import { dispatchRunTool } from './child/runTool'; import { dispatchAnswer } from './tools/answer'; import { dispatchCustomFeedback } from './tools/customFeedback'; import { dispatchHttp468Request } from './tools/http468'; -import { dispatchQueryExtension } from './tools/queryExternsion'; +import { dispatchQueryExtension } from './abandoned/queryExternsion'; import { dispatchReadFiles } from './tools/readFiles'; import { dispatchIfElse } from './tools/runIfElse'; import { dispatchUpdateVariable } from './tools/runUpdateVar'; @@ -54,7 +54,6 @@ export const callbackMap: Record< [FlowNodeTypeEnum.datasetSearchNode]: dispatchDatasetSearch, [FlowNodeTypeEnum.classifyQuestion]: dispatchClassifyQuestion, [FlowNodeTypeEnum.contentExtract]: dispatchContentExtract, - [FlowNodeTypeEnum.queryExtension]: dispatchQueryExtension, // Tool call [FlowNodeTypeEnum.toolCall]: dispatchRunTools, [FlowNodeTypeEnum.stopTool]: dispatchStopToolCall, @@ -91,5 +90,7 @@ export const callbackMap: Record< /** @deprecated */ [FlowNodeTypeEnum.runApp]: dispatchAppRequest, /** @deprecated 已被 loopRun 替代 */ - [FlowNodeTypeEnum.loop]: dispatchLoop + [FlowNodeTypeEnum.loop]: dispatchLoop, + /** @deprecated 已弃用,保留旧工作流运行兼容 */ + [FlowNodeTypeEnum.queryExtension]: dispatchQueryExtension }; From 4167a6dddecdf5f5970417a2fb406c1c5fb94835 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 10 Aug 2026 16:51:00 +0800 Subject: [PATCH 02/11] refactor(workflow): deprecate custom vars in http node Signed-off-by: Nixieboluo --- .../core/workflow/template/system/http468.ts | 2 +- projects/app/src/web/core/workflow/utils.ts | 82 ++++++++++--------- .../workflow/store2flow.deprecated.test.ts | 27 ++++++ 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/packages/global/core/workflow/template/system/http468.ts b/packages/global/core/workflow/template/system/http468.ts index 7aaef133cb1d..5c6d54307740 100644 --- a/packages/global/core/workflow/template/system/http468.ts +++ b/packages/global/core/workflow/template/system/http468.ts @@ -39,7 +39,7 @@ export const HttpNode468: FlowNodeTemplateType = { showDescription: false, showDefaultValue: true }, - deprecated: false + deprecated: true }, { key: NodeInputKeyEnum.httpMethod, diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index c90d6b5c2c8d..0c193163d30d 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -126,7 +126,7 @@ export const nodeTemplate2FlowNode = ({ }): Node => { const name = t(template.name as any); - // replace item data + // 用持久化节点数据覆盖模板默认值。 const moduleItem: FlowNodeItemType = { ...template, name: formatName?.(name) ?? name, @@ -174,90 +174,92 @@ export const storeNode2FlowNode = ({ isTool?: boolean; t: TFunction; }): Node => { - // init some static data - const template = + // 解析当前节点模板与持久化输入。 + const nodeTemplate = moduleTemplatesFlat.find((template) => template.flowNodeType === storeNode.flowNodeType) || EmptyNode; - const templateInputs = template.inputs.filter( - (input) => !input.canEdit && input.deprecated !== true + const storedInputs = adaptStoreNodeInputs(storeNode); + // 废弃模板输入仅在存量节点已有该字段时,按模板顺序保留。 + const orderedTemplateInputs = nodeTemplate.inputs.filter( + (input) => + (!input.canEdit && input.deprecated !== true) || + (input.deprecated === true && storedInputs.some((item) => item.key === input.key)) ); - const templateOutputs = template.outputs.filter( + const staticTemplateOutputs = nodeTemplate.outputs.filter( (output) => output.type !== FlowNodeOutputTypeEnum.dynamic ); - const dynamicInput = template.inputs.find( + const dynamicInputTemplate = nodeTemplate.inputs.find( (input) => input.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam ); - const adaptedStoreInputs = adaptStoreNodeInputs(storeNode); - // replace item data const nodeItem: FlowNodeItemType = { parentNodeId, - ...template, + ...nodeTemplate, ...storeNode, name: t(storeNode.name as any), intro: storeNode.intro ? t(storeNode.intro as any) : storeNode.intro, - avatar: template.avatar ?? storeNode.avatar, - version: template.version || storeNode.version, - catchError: storeNode.catchError ?? template.catchError, - // template 中的输入必须都有 - inputs: templateInputs - .map((templateInput) => { + avatar: nodeTemplate.avatar ?? storeNode.avatar, + version: nodeTemplate.version || storeNode.version, + catchError: storeNode.catchError ?? nodeTemplate.catchError, + // 按模板顺序恢复当前输入及存量废弃输入。 + inputs: orderedTemplateInputs + .map((inputTemplate) => { const storeInput = - adaptedStoreInputs.find((item) => item.key === templateInput.key) || templateInput; + storedInputs.find((item) => item.key === inputTemplate.key) || inputTemplate; return { ...storeInput, - ...templateInput, - debugLabel: t(templateInput.debugLabel ?? (storeInput.debugLabel as any)), - toolDescription: t(templateInput.toolDescription ?? (storeInput.toolDescription as any)), - selectedType: storeInput.selectedType ?? templateInput.selectedType, - selectedTypeIndex: storeInput.selectedTypeIndex ?? templateInput.selectedTypeIndex, + ...inputTemplate, + debugLabel: t(inputTemplate.debugLabel ?? (storeInput.debugLabel as any)), + toolDescription: t(inputTemplate.toolDescription ?? (storeInput.toolDescription as any)), + selectedType: storeInput.selectedType ?? inputTemplate.selectedType, + selectedTypeIndex: storeInput.selectedTypeIndex ?? inputTemplate.selectedTypeIndex, value: storeInput.value }; }) .concat( - // 合并 store 中有,template 中没有的输入 - adaptedStoreInputs - .filter((item) => !templateInputs.find((input) => input.key === item.key)) + // 追加未按模板顺序恢复的存量输入,例如自定义动态字段。 + storedInputs + .filter((item) => !orderedTemplateInputs.find((input) => input.key === item.key)) .map((item) => { - const templateInput = template.inputs.find((input) => input.key === item.key); + const inputTemplate = nodeTemplate.inputs.find((input) => input.key === item.key); - if (!dynamicInput) { + if (!dynamicInputTemplate) { return { ...item, - deprecated: templateInput?.deprecated + deprecated: inputTemplate?.deprecated }; } return { ...item, - ...getInputComponentProps(dynamicInput), - deprecated: templateInput?.deprecated + ...getInputComponentProps(dynamicInputTemplate), + deprecated: inputTemplate?.deprecated }; }) ), - outputs: templateOutputs - .map((templateOutput) => { + outputs: staticTemplateOutputs + .map((outputTemplate) => { const storeOutput = - storeNode.outputs.find((item) => item.key === templateOutput.key) || templateOutput; + storeNode.outputs.find((item) => item.key === outputTemplate.key) || outputTemplate; return { ...storeOutput, - ...templateOutput, - description: t(templateOutput.description ?? (storeOutput.description as any)), - id: storeOutput.id ?? templateOutput.id, - value: storeOutput.value ?? templateOutput.value + ...outputTemplate, + description: t(outputTemplate.description ?? (storeOutput.description as any)), + id: storeOutput.id ?? outputTemplate.id, + value: storeOutput.value ?? outputTemplate.value }; }) .concat( storeNode.outputs - .filter((item) => !templateOutputs.find((output) => output.key === item.key)) + .filter((item) => !staticTemplateOutputs.find((output) => output.key === item.key)) .map((item) => { - const templateOutput = template.outputs.find((output) => output.key === item.key); + const outputTemplate = nodeTemplate.outputs.find((output) => output.key === item.key); return { ...item, - deprecated: templateOutput?.deprecated + deprecated: outputTemplate?.deprecated }; }) ) diff --git a/projects/app/test/web/core/app/workflow/store2flow.deprecated.test.ts b/projects/app/test/web/core/app/workflow/store2flow.deprecated.test.ts index a83dc648bce6..f070a4d8188e 100644 --- a/projects/app/test/web/core/app/workflow/store2flow.deprecated.test.ts +++ b/projects/app/test/web/core/app/workflow/store2flow.deprecated.test.ts @@ -15,12 +15,22 @@ describe('storeNode2FlowNode with deprecated inputs/outputs', () => { intro: '', version: '1.0', inputs: [ + { + key: 'beforeInput', + label: 'Before Input', + renderTypeList: ['input'] + }, { key: 'deprecatedInput', deprecated: true, label: 'Deprecated Input', renderTypeList: ['input'], selectedTypeIndex: 0 + }, + { + key: 'afterInput', + label: 'After Input', + renderTypeList: ['input'] } ], outputs: [ @@ -49,11 +59,23 @@ describe('storeNode2FlowNode with deprecated inputs/outputs', () => { flowNodeType: 'userInput' as FlowNodeTypeEnum, position: { x: 0, y: 0 }, inputs: [ + { + key: 'beforeInput', + value: 'before', + renderTypeList: ['input'], + label: 'Before Input' + }, { key: 'deprecatedInput', value: 'old value', renderTypeList: ['input'], label: 'Deprecated Input' + }, + { + key: 'afterInput', + value: 'after', + renderTypeList: ['input'], + label: 'After Input' } ], outputs: [ @@ -76,6 +98,11 @@ describe('storeNode2FlowNode with deprecated inputs/outputs', () => { const deprecatedInput = result.data.inputs.find((input) => input.key === 'deprecatedInput'); expect(deprecatedInput).toBeDefined(); expect(deprecatedInput?.deprecated).toBe(true); + expect(result.data.inputs.map((input) => input.key)).toEqual([ + 'beforeInput', + 'deprecatedInput', + 'afterInput' + ]); const deprecatedOutput = result.data.outputs.find( (output) => output.key === 'deprecatedOutput' From 36972be17e1f8115ba0105d84d186127d34777da Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 10 Aug 2026 17:40:50 +0800 Subject: [PATCH 03/11] feat(workflow): show nodes depends on the context Signed-off-by: Nixieboluo --- .../global/core/workflow/template/context.ts | 50 +++++++ .../workflow/template/system/datasetConcat.ts | 4 + .../template/system/interactive/formInput.ts | 2 + .../template/system/interactive/userSelect.ts | 2 + .../template/system/loopRun/loopRun.ts | 5 + .../template/system/loopRun/loopRunBreak.ts | 2 + .../system/parallelRun/parallelRun.ts | 5 + .../core/workflow/template/system/stopTool.ts | 1 + .../workflow/template/system/toolParams.ts | 6 +- packages/global/core/workflow/type/node.ts | 16 +++ .../Flow/NodeTemplatesPopover.tsx | 28 +++- .../NodeTemplates/useNodeTemplates.tsx | 31 ++-- .../core/app/workflow/templateContext.test.ts | 134 ++++++++++++++++++ 13 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 packages/global/core/workflow/template/context.ts create mode 100644 projects/app/test/web/core/app/workflow/templateContext.test.ts diff --git a/packages/global/core/workflow/template/context.ts b/packages/global/core/workflow/template/context.ts new file mode 100644 index 000000000000..413f4333bf9d --- /dev/null +++ b/packages/global/core/workflow/template/context.ts @@ -0,0 +1,50 @@ +import type { FlowNodeTypeEnum } from '../node/constant'; +import type { + FlowNodeTemplateType, + NodeTemplateContext, + NodeTemplateContextPredicate +} from '../type/node'; + +/** + * 模板展示上下文规则:规则字段全部为空时匹配任何上下文。 + */ +export type NodeTemplateContextRule = { + sourceType?: FlowNodeTypeEnum; + handleId?: string; + parentType?: FlowNodeTypeEnum; +}; + +const matchRule = (rule: NodeTemplateContextRule, ctx: NodeTemplateContext): boolean => { + if (rule.sourceType !== undefined && rule.sourceType !== ctx.sourceType) return false; + if (rule.handleId !== undefined && rule.handleId !== ctx.handleId) return false; + if (rule.parentType !== undefined && rule.parentType !== ctx.parentType) return false; + return true; +}; + +/** + * 白名单工厂:上下文非空且匹配任一规则时才展示;ctx 为 null(侧边栏)时不展示。 + */ +export const createShowInContext = ( + rules: NodeTemplateContextRule[] +): NodeTemplateContextPredicate => { + return (ctx) => !!ctx && rules.some((rule) => matchRule(rule, ctx)); +}; + +/** + * 黑名单工厂:匹配任一规则时隐藏;ctx 为 null(侧边栏)时正常展示。 + */ +export const createHideInContext = ( + rules: NodeTemplateContextRule[] +): NodeTemplateContextPredicate => { + return (ctx) => !ctx || !rules.some((rule) => matchRule(rule, ctx)); +}; + +/** + * 模板在给定上下文中是否可见:未声明谓词的模板为顶级节点,处处可见。 + */ +export const isTemplateVisible = ( + template: Pick, + ctx: NodeTemplateContext | null +): boolean => { + return !template.isShowInContext || template.isShowInContext(ctx); +}; diff --git a/packages/global/core/workflow/template/system/datasetConcat.ts b/packages/global/core/workflow/template/system/datasetConcat.ts index f8da609435fe..4b03a8a7f468 100644 --- a/packages/global/core/workflow/template/system/datasetConcat.ts +++ b/packages/global/core/workflow/template/system/datasetConcat.ts @@ -11,6 +11,7 @@ import { NodeOutputKeyEnum, FlowNodeTemplateTypeEnum } from '../../constants'; +import { createHideInContext } from '../context'; import { getNanoid } from '../../../../common/string/tools'; import { type FlowNodeInputItemType } from '../../type/io'; import { i18nT } from '../../../../common/i18n/utils'; @@ -44,6 +45,9 @@ export const DatasetConcatModule: FlowNodeTemplateType = { intro: i18nT('workflow:intro_knowledge_base_search_merge'), showStatus: false, + isShowInContext: createHideInContext([ + { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools } + ]), courseUrl: '/guide/build/workflow/nodes/knowledge_base_search_merge', inputs: [ { diff --git a/packages/global/core/workflow/template/system/interactive/formInput.ts b/packages/global/core/workflow/template/system/interactive/formInput.ts index 7d20d5df6648..f843d6cba55e 100644 --- a/packages/global/core/workflow/template/system/interactive/formInput.ts +++ b/packages/global/core/workflow/template/system/interactive/formInput.ts @@ -10,6 +10,7 @@ import { FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '../../../node/constant'; +import { createHideInContext } from '../../context'; import { type FlowNodeTemplateType } from '../../../type/node'; export const FormInputNode: FlowNodeTemplateType = { @@ -24,6 +25,7 @@ export const FormInputNode: FlowNodeTemplateType = { name: i18nT('app:workflow.form_input'), intro: i18nT(`app:workflow.form_input_tip`), isTool: true, + isShowInContext: createHideInContext([{ parentType: FlowNodeTypeEnum.parallelRun }]), inputs: [ { key: NodeInputKeyEnum.description, diff --git a/packages/global/core/workflow/template/system/interactive/userSelect.ts b/packages/global/core/workflow/template/system/interactive/userSelect.ts index 9f9dec8ef35f..f54f6230e199 100644 --- a/packages/global/core/workflow/template/system/interactive/userSelect.ts +++ b/packages/global/core/workflow/template/system/interactive/userSelect.ts @@ -10,6 +10,7 @@ import { FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '../../../node/constant'; +import { createHideInContext } from '../../context'; import { type FlowNodeTemplateType } from '../../../type/node'; export const UserSelectNode: FlowNodeTemplateType = { @@ -25,6 +26,7 @@ export const UserSelectNode: FlowNodeTemplateType = { name: i18nT('app:workflow.user_select'), intro: i18nT(`app:workflow.user_select_tip`), isTool: true, + isShowInContext: createHideInContext([{ parentType: FlowNodeTypeEnum.parallelRun }]), courseUrl: '/guide/build/workflow/nodes/user-selection', inputs: [ { diff --git a/packages/global/core/workflow/template/system/loopRun/loopRun.ts b/packages/global/core/workflow/template/system/loopRun/loopRun.ts index 86f1b162490d..5fc7e1cade8a 100644 --- a/packages/global/core/workflow/template/system/loopRun/loopRun.ts +++ b/packages/global/core/workflow/template/system/loopRun/loopRun.ts @@ -10,6 +10,7 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; +import { createHideInContext } from '../../context'; import { i18nT } from '../../../../../common/i18n/utils'; import { Input_Template_Children_Node_List, @@ -35,6 +36,10 @@ export const LoopRunNode: FlowNodeTemplateType = { name: i18nT('workflow:loop_run'), intro: i18nT('workflow:intro_loop_run'), showStatus: true, + isShowInContext: createHideInContext([ + { parentType: FlowNodeTypeEnum.loopRun }, + { parentType: FlowNodeTypeEnum.parallelRun } + ]), courseUrl: '/guide/build/workflow/nodes/loop_run', catchError: false, inputs: [ diff --git a/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts index 8ffa0480f5d6..20feb1175246 100644 --- a/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts +++ b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts @@ -1,6 +1,7 @@ import { FlowNodeTypeEnum } from '../../../node/constant'; import { type FlowNodeTemplateType } from '../../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../../constants'; +import { createShowInContext } from '../../context'; import { i18nT } from '../../../../../common/i18n/utils'; export const LoopRunBreakNode: FlowNodeTemplateType = { @@ -15,6 +16,7 @@ export const LoopRunBreakNode: FlowNodeTemplateType = { name: i18nT('workflow:loop_run_break'), intro: i18nT('workflow:loop_run_break_tip'), showStatus: false, + isShowInContext: createShowInContext([{ parentType: FlowNodeTypeEnum.loopRun }]), inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts b/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts index 63b106385021..73ebd9303870 100644 --- a/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts +++ b/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts @@ -10,6 +10,7 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; +import { createHideInContext } from '../../context'; import { i18nT } from '../../../../../common/i18n/utils'; import { Input_Template_Children_Node_List, @@ -30,6 +31,10 @@ export const ParallelRunNode: FlowNodeTemplateType = { name: i18nT('workflow:parallel_run'), intro: i18nT('workflow:intro_parallel_run'), showStatus: true, + isShowInContext: createHideInContext([ + { parentType: FlowNodeTypeEnum.loopRun }, + { parentType: FlowNodeTypeEnum.parallelRun } + ]), courseUrl: '/guide/build/workflow/nodes/parallel_run', inputs: [ { diff --git a/packages/global/core/workflow/template/system/stopTool.ts b/packages/global/core/workflow/template/system/stopTool.ts index 26cda0f8bdd9..2326b9421482 100644 --- a/packages/global/core/workflow/template/system/stopTool.ts +++ b/packages/global/core/workflow/template/system/stopTool.ts @@ -14,6 +14,7 @@ export const StopToolNode: FlowNodeTemplateType = { colorSchema: 'violet', name: i18nT('workflow:tool_call_termination'), intro: i18nT('workflow:intro_tool_call_termination'), + isShowInContext: (ctx) => !!ctx?.isConnectedTool, inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/template/system/toolParams.ts b/packages/global/core/workflow/template/system/toolParams.ts index d1b2c750f241..efc3077f6961 100644 --- a/packages/global/core/workflow/template/system/toolParams.ts +++ b/packages/global/core/workflow/template/system/toolParams.ts @@ -1,6 +1,7 @@ import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; -import { FlowNodeTemplateTypeEnum } from '../../constants'; +import { FlowNodeTemplateTypeEnum, NodeOutputKeyEnum } from '../../constants'; +import { createShowInContext } from '../context'; import { i18nT } from '../../../../common/i18n/utils'; export const ToolParamsNode: FlowNodeTemplateType = { @@ -15,6 +16,9 @@ export const ToolParamsNode: FlowNodeTemplateType = { name: i18nT('workflow:tool_custom_field'), intro: i18nT('workflow:intro_tool_params_config'), isTool: true, + isShowInContext: createShowInContext([ + { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools } + ]), inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index 1138fd109c8f..432230865ad7 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -208,6 +208,9 @@ export const FlowNodeTemplateTypeSchema = FlowNodeCommonTypeSchema.extend({ forbidDelete: BoolSchema.optional(), // forbid delete unique: BoolSchema.optional(), + // 声明式展示控制:声明后在无上下文(侧边栏)或上下文不匹配时不展示 + isShowInContext: z.custom().optional(), + diagram: z.string().optional(), courseUrl: z.string().optional(), readmeUrl: z.string().optional(), @@ -223,6 +226,19 @@ export const FlowNodeTemplateTypeSchema = FlowNodeCommonTypeSchema.extend({ }); export type FlowNodeTemplateType = z.infer; +/** 模板快捷添加的上下文:快捷面板触发时的源节点信息;侧边栏无上下文,传 null。 */ +export type NodeTemplateContext = { + sourceNodeId: string | null; + sourceType: FlowNodeTypeEnum | null; + sourceIsTool: boolean; + /** 源节点是否已被 selectedTools 边挂载到工具调用(工具子流程)。 */ + isConnectedTool: boolean; + handleId: string | null; + /** 源节点所在容器(loopRun/parallelRun 等)的节点类型。 */ + parentType: FlowNodeTypeEnum | null; +}; +export type NodeTemplateContextPredicate = (ctx: NodeTemplateContext | null) => boolean; + // Api response export const NodeTemplateListItemTypeSchema = z.object({ id: z.string(), // 系统节点-系统节点的 id, 系统插件-插件的 id,团队应用的 id diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx index 114ee9896421..698620dba533 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx @@ -1,12 +1,16 @@ import { collectWorkflowStartInputAutoFillPatches } from '@/web/core/workflow/workflowStartAutoFill'; import { Popover, PopoverBody, PopoverContent } from '@chakra-ui/react'; import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { EDGE_TYPE, FlowNodeTypeEnum, isNestedChildSystemNodeType } from '@fastgpt/global/core/workflow/node/constant'; -import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import type { + FlowNodeItemType, + NodeTemplateContext +} from '@fastgpt/global/core/workflow/type/node'; import MyBox from '@fastgpt/web/components/common/MyBox'; import { useMemoizedFn } from 'ahooks'; import React from 'react'; @@ -24,7 +28,7 @@ const NodeTemplatesPopover = () => { const { handleParams, setHandleParams } = useContextSelector(WorkflowModalContext, (v) => v); const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes); - const { edges, setNodes, setEdges, workflowStartNode } = useContextSelector( + const { edges, setNodes, setEdges, workflowStartNode, getNodeById } = useContextSelector( WorkflowBufferDataContext, (v) => v ); @@ -34,6 +38,24 @@ const NodeTemplatesPopover = () => { (v) => v.onRefreshSingleNodeWorkflowCheckIssues ); + const nodeTemplateContext = React.useMemo(() => { + if (!handleParams?.nodeId) return null; + const sourceNode = getNodeById(handleParams.nodeId); + if (!sourceNode) return null; + const parentNode = sourceNode.parentNodeId ? getNodeById(sourceNode.parentNodeId) : undefined; + return { + sourceNodeId: sourceNode.nodeId, + sourceType: sourceNode.flowNodeType, + sourceIsTool: !!sourceNode.isTool, + isConnectedTool: edges.some( + (edge) => + edge.target === sourceNode.nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools + ), + handleId: handleParams.handleId ?? null, + parentType: parentNode?.flowNodeType ?? null + }; + }, [handleParams, edges, getNodeById]); + const { templateType, parentId, @@ -47,7 +69,7 @@ const NodeTemplatesPopover = () => { toolTags, selectedTagIds, setSelectedTagIds - } = useNodeTemplates(); + } = useNodeTemplates(nodeTemplateContext); const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node[] }) => { setNodes((state) => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index 69864b94e086..adc4e4544f5f 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -1,7 +1,10 @@ import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { + NodeTemplateContext, + NodeTemplateListItemType +} from '@fastgpt/global/core/workflow/type/node'; +import { isTemplateVisible } from '@fastgpt/global/core/workflow/template/context'; import { getTeamAppTemplates, getAppToolTemplates } from '@/web/core/app/api/tool'; import { TemplateTypeEnum } from './header'; import { useContextSelector } from 'use-context-selector'; @@ -12,7 +15,7 @@ import { AppContext } from '@/pageComponents/app/detail/context'; import { getPluginToolTags } from '@/web/core/plugin/toolTag/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; -export const useNodeTemplates = () => { +export const useNodeTemplates = (context: NodeTemplateContext | null = null) => { const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic); const [searchKey, setSearchKey] = useState(''); @@ -22,8 +25,10 @@ export const useNodeTemplates = () => { const [parentSource, setParentSource] = useState(); const appId = useContextSelector(AppContext, (v) => v.appDetail._id); - const { basicNodeTemplates, hasToolNode, hasLoopRunNode, getNodeList, nodeAmount } = - useContextSelector(WorkflowBufferDataContext, (v) => v); + const { basicNodeTemplates, getNodeList, nodeAmount } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); const [selectedTagIds, setSelectedTagIds] = useState([]); const { data: toolTags = [] } = useRequest(getPluginToolTags, { @@ -44,19 +49,7 @@ export const useNodeTemplates = () => { return false; } } - // tool stop or tool params - if ( - !hasToolNode && - (item.flowNodeType === FlowNodeTypeEnum.stopTool || - item.flowNodeType === FlowNodeTypeEnum.toolParams) - ) { - return false; - } - // loopRunBreak only shows when a loopRun node exists on the canvas - if (!hasLoopRunNode && item.flowNodeType === FlowNodeTypeEnum.loopRunBreak) { - return false; - } - return true; + return isTemplateVisible(item, context); }) .map((item) => ({ id: item.id, @@ -71,7 +64,7 @@ export const useNodeTemplates = () => { { manual: false, throttleWait: 100, - refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, hasLoopRunNode, templateType] + refreshDeps: [basicNodeTemplates, nodeAmount, templateType, context] } ); diff --git a/projects/app/test/web/core/app/workflow/templateContext.test.ts b/projects/app/test/web/core/app/workflow/templateContext.test.ts new file mode 100644 index 000000000000..eac70846039d --- /dev/null +++ b/projects/app/test/web/core/app/workflow/templateContext.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { + createHideInContext, + createShowInContext, + isTemplateVisible +} from '@fastgpt/global/core/workflow/template/context'; +import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiChat'; +import { DatasetConcatModule } from '@fastgpt/global/core/workflow/template/system/datasetConcat'; +import { LoopRunNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +import { LoopRunBreakNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunBreak'; +import { ParallelRunNode } from '@fastgpt/global/core/workflow/template/system/parallelRun/parallelRun'; +import { StopToolNode } from '@fastgpt/global/core/workflow/template/system/stopTool'; +import { ToolParamsNode } from '@fastgpt/global/core/workflow/template/system/toolParams'; +import { UserSelectNode } from '@fastgpt/global/core/workflow/template/system/interactive/userSelect'; +import type { NodeTemplateContext } from '@fastgpt/global/core/workflow/type/node'; + +const ctx = (patch: Partial): NodeTemplateContext => ({ + sourceNodeId: 'n1', + sourceType: null, + sourceIsTool: false, + isConnectedTool: false, + handleId: null, + parentType: null, + ...patch +}); + +describe('template context', () => { + it('工厂函数:白名单仅在匹配任一规则且上下文非空时可见', () => { + const predicate = createShowInContext([ + { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }, + { parentType: FlowNodeTypeEnum.loopRun } + ]); + + expect(predicate(null)).toBe(false); + expect(predicate(ctx({ sourceType: FlowNodeTypeEnum.toolCall }))).toBe(false); + expect( + predicate( + ctx({ sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }) + ) + ).toBe(true); + expect(predicate(ctx({ parentType: FlowNodeTypeEnum.loopRun }))).toBe(true); + }); + + it('工厂函数:黑名单在匹配任一规则时隐藏,ctx 为 null 时可见', () => { + const predicate = createHideInContext([ + { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools } + ]); + + expect(predicate(null)).toBe(true); + expect(predicate(ctx({ sourceType: FlowNodeTypeEnum.toolCall }))).toBe(true); + expect( + predicate( + ctx({ sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }) + ) + ).toBe(false); + }); + + it('未声明谓词的模板为顶级节点,处处可见', () => { + expect(isTemplateVisible(AiChatModule, null)).toBe(true); + expect(isTemplateVisible(AiChatModule, ctx({ sourceType: FlowNodeTypeEnum.toolCall }))).toBe( + true + ); + }); + + it('toolParams 仅在工具调用底部(selectedTools)可见', () => { + expect(isTemplateVisible(ToolParamsNode, null)).toBe(false); + expect( + isTemplateVisible( + ToolParamsNode, + ctx({ sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }) + ) + ).toBe(true); + expect(isTemplateVisible(ToolParamsNode, ctx({ sourceType: FlowNodeTypeEnum.toolCall }))).toBe( + false + ); + }); + + it('stopTool 仅在已挂载工具节点(工具子流程)可见', () => { + expect(isTemplateVisible(StopToolNode, null)).toBe(false); + expect(isTemplateVisible(StopToolNode, ctx({ isConnectedTool: true }))).toBe(true); + expect(isTemplateVisible(StopToolNode, ctx({ isConnectedTool: false }))).toBe(false); + }); + + it('datasetConcat 不在工具调用底部可见', () => { + expect(isTemplateVisible(DatasetConcatModule, null)).toBe(true); + expect( + isTemplateVisible( + DatasetConcatModule, + ctx({ sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }) + ) + ).toBe(false); + expect( + isTemplateVisible(DatasetConcatModule, ctx({ sourceType: FlowNodeTypeEnum.toolCall })) + ).toBe(true); + }); + + it('loopRunBreak 仅在循环节点内部可见', () => { + expect(isTemplateVisible(LoopRunBreakNode, null)).toBe(false); + expect(isTemplateVisible(LoopRunBreakNode, ctx({ parentType: FlowNodeTypeEnum.loopRun }))).toBe( + true + ); + expect( + isTemplateVisible(LoopRunBreakNode, ctx({ parentType: FlowNodeTypeEnum.parallelRun })) + ).toBe(false); + }); + + it('userSelect 不在批量执行内部可见', () => { + expect(isTemplateVisible(UserSelectNode, null)).toBe(true); + expect( + isTemplateVisible(UserSelectNode, ctx({ parentType: FlowNodeTypeEnum.parallelRun })) + ).toBe(false); + expect(isTemplateVisible(UserSelectNode, ctx({ parentType: FlowNodeTypeEnum.loopRun }))).toBe( + true + ); + }); + + it('loopRun/parallelRun 不在嵌套容器内部可见', () => { + expect(isTemplateVisible(LoopRunNode, null)).toBe(true); + expect(isTemplateVisible(LoopRunNode, ctx({ parentType: FlowNodeTypeEnum.loopRun }))).toBe( + false + ); + expect(isTemplateVisible(LoopRunNode, ctx({ parentType: FlowNodeTypeEnum.parallelRun }))).toBe( + false + ); + expect(isTemplateVisible(ParallelRunNode, ctx({ parentType: FlowNodeTypeEnum.loopRun }))).toBe( + false + ); + expect( + isTemplateVisible(ParallelRunNode, ctx({ parentType: FlowNodeTypeEnum.parallelRun })) + ).toBe(false); + }); +}); From 56dab35b040212a4f29a49033403a7f8b185914a Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Mon, 10 Aug 2026 17:51:04 +0800 Subject: [PATCH 04/11] feat(workflow): disallow connections to nodes not allowed in the context Signed-off-by: Nixieboluo --- .../global/core/workflow/template/context.ts | 33 +++++++++++ .../Flow/NodeTemplatesPopover.tsx | 34 ++++-------- .../nodes/render/Handle/ConnectionHandle.tsx | 22 ++++++++ .../core/app/workflow/templateContext.test.ts | 55 +++++++++++++++++++ 4 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/global/core/workflow/template/context.ts b/packages/global/core/workflow/template/context.ts index 413f4333bf9d..704aaaae454f 100644 --- a/packages/global/core/workflow/template/context.ts +++ b/packages/global/core/workflow/template/context.ts @@ -1,6 +1,8 @@ import type { FlowNodeTypeEnum } from '../node/constant'; +import { NodeOutputKeyEnum } from '../constants'; import type { FlowNodeTemplateType, + FlowNodeItemType, NodeTemplateContext, NodeTemplateContextPredicate } from '../type/node'; @@ -48,3 +50,34 @@ export const isTemplateVisible = ( ): boolean => { return !template.isShowInContext || template.isShowInContext(ctx); }; + +/** + * 由源节点信息构建快捷添加/连线共用的展示上下文;sourceNode 不存在时返回 null。 + */ +export const buildNodeTemplateContext = ({ + sourceNode, + edges, + handleId, + getNodeById +}: { + sourceNode: + | Pick + | undefined; + edges: { target: string; targetHandle?: string | null }[]; + handleId?: string | null; + getNodeById: (nodeId: string | undefined | null) => FlowNodeItemType | undefined; +}): NodeTemplateContext | null => { + if (!sourceNode) return null; + const parentNode = sourceNode.parentNodeId ? getNodeById(sourceNode.parentNodeId) : undefined; + return { + sourceNodeId: sourceNode.nodeId, + sourceType: sourceNode.flowNodeType, + sourceIsTool: !!sourceNode.isTool, + isConnectedTool: edges.some( + (edge) => + edge.target === sourceNode.nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools + ), + handleId: handleId ?? null, + parentType: parentNode?.flowNodeType ?? null + }; +}; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx index 698620dba533..7c5a6d99da00 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx @@ -1,16 +1,13 @@ import { collectWorkflowStartInputAutoFillPatches } from '@/web/core/workflow/workflowStartAutoFill'; import { Popover, PopoverBody, PopoverContent } from '@chakra-ui/react'; import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { EDGE_TYPE, FlowNodeTypeEnum, isNestedChildSystemNodeType } from '@fastgpt/global/core/workflow/node/constant'; -import type { - FlowNodeItemType, - NodeTemplateContext -} from '@fastgpt/global/core/workflow/type/node'; +import { buildNodeTemplateContext } from '@fastgpt/global/core/workflow/template/context'; +import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import MyBox from '@fastgpt/web/components/common/MyBox'; import { useMemoizedFn } from 'ahooks'; import React from 'react'; @@ -38,23 +35,16 @@ const NodeTemplatesPopover = () => { (v) => v.onRefreshSingleNodeWorkflowCheckIssues ); - const nodeTemplateContext = React.useMemo(() => { - if (!handleParams?.nodeId) return null; - const sourceNode = getNodeById(handleParams.nodeId); - if (!sourceNode) return null; - const parentNode = sourceNode.parentNodeId ? getNodeById(sourceNode.parentNodeId) : undefined; - return { - sourceNodeId: sourceNode.nodeId, - sourceType: sourceNode.flowNodeType, - sourceIsTool: !!sourceNode.isTool, - isConnectedTool: edges.some( - (edge) => - edge.target === sourceNode.nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools - ), - handleId: handleParams.handleId ?? null, - parentType: parentNode?.flowNodeType ?? null - }; - }, [handleParams, edges, getNodeById]); + const nodeTemplateContext = React.useMemo( + () => + buildNodeTemplateContext({ + sourceNode: handleParams?.nodeId ? getNodeById(handleParams.nodeId) : undefined, + edges, + handleId: handleParams?.handleId, + getNodeById + }), + [handleParams, edges, getNodeById] + ); const { templateType, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ConnectionHandle.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ConnectionHandle.tsx index 0103b3fc6109..67b1b25380a2 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ConnectionHandle.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/Handle/ConnectionHandle.tsx @@ -3,6 +3,11 @@ import { Position } from 'reactflow'; import { MySourceHandle, MyTargetHandle } from '.'; import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; +import { + buildNodeTemplateContext, + isTemplateVisible +} from '@fastgpt/global/core/workflow/template/context'; import { useContextSelector } from 'use-context-selector'; import { WorkflowBufferDataContext } from '../../../../context/workflowInitContext'; import { WorkflowActionsContext } from '../../../../context/workflowActionsContext'; @@ -132,6 +137,23 @@ export const ConnectionTargetHandle = React.memo(function ConnectionTargetHandle } } + // 目标节点模板在源节点添加上下文不可见时禁止连接(与快捷添加面板同规则) + const sourceNode = connectingEdge ? getNodeById(connectingEdge.nodeId) : undefined; + const targetTemplate = node + ? moduleTemplatesFlat.find((item) => item.id === node.flowNodeType) + : undefined; + if (targetTemplate && sourceNode && connectingEdge) { + const sourceCtx = buildNodeTemplateContext({ + sourceNode, + edges, + handleId: connectingEdge.handleId, + getNodeById + }); + if (sourceCtx && !isTemplateVisible(targetTemplate, sourceCtx)) { + forbidConnect = true; + } + } + const showHandle = (() => { if (forbidConnect) return false; if (!node) return false; diff --git a/projects/app/test/web/core/app/workflow/templateContext.test.ts b/projects/app/test/web/core/app/workflow/templateContext.test.ts index eac70846039d..745a333f31c0 100644 --- a/projects/app/test/web/core/app/workflow/templateContext.test.ts +++ b/projects/app/test/web/core/app/workflow/templateContext.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { + buildNodeTemplateContext, createHideInContext, createShowInContext, isTemplateVisible @@ -27,6 +28,60 @@ const ctx = (patch: Partial): NodeTemplateContext => ({ }); describe('template context', () => { + it('buildNodeTemplateContext:源节点不存在返回 null,字段正确映射', () => { + const node = { + nodeId: 'n1', + flowNodeType: FlowNodeTypeEnum.toolParams, + isTool: true, + parentNodeId: 'loop1' + }; + const loopNode = { nodeId: 'loop1', flowNodeType: FlowNodeTypeEnum.loopRun }; + const edges = [ + { target: 'n1', targetHandle: NodeOutputKeyEnum.selectedTools }, + { target: 'other', targetHandle: 'x' } + ]; + + expect( + buildNodeTemplateContext({ + sourceNode: undefined, + edges, + handleId: 'h', + getNodeById: () => undefined + }) + ).toBeNull(); + + const result = buildNodeTemplateContext({ + sourceNode: node, + edges, + handleId: 'h', + getNodeById: (id) => (id === 'loop1' ? (loopNode as any) : undefined) + }); + expect(result).toEqual({ + sourceNodeId: 'n1', + sourceType: FlowNodeTypeEnum.toolParams, + sourceIsTool: true, + isConnectedTool: true, + handleId: 'h', + parentType: FlowNodeTypeEnum.loopRun + }); + }); + + it('buildNodeTemplateContext:未被工具调用挂载时 isConnectedTool 为 false', () => { + const result = buildNodeTemplateContext({ + sourceNode: { + nodeId: 'n1', + flowNodeType: FlowNodeTypeEnum.aiChat, + isTool: false, + parentNodeId: undefined + }, + edges: [{ target: 'n2', targetHandle: NodeOutputKeyEnum.selectedTools }], + handleId: null, + getNodeById: () => undefined + }); + expect(result?.isConnectedTool).toBe(false); + expect(result?.parentType).toBeNull(); + }); + it('工厂函数:白名单仅在匹配任一规则且上下文非空时可见', () => { const predicate = createShowInContext([ { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools }, From 822397e39b3574d018e85252c308a9719cfc0fb2 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 11 Aug 2026 16:09:59 +0800 Subject: [PATCH 05/11] fix(workflow): tool call params for http/sandbox node Signed-off-by: Nixieboluo --- .../core/workflow/template/system/http468.ts | 1 + .../workflow/template/system/sandbox/index.ts | 2 + packages/global/core/workflow/type/node.ts | 3 ++ .../Flow/nodes/NodeCode/index.tsx | 2 +- .../Flow/nodes/NodeHttp/index.tsx | 2 +- .../templates/DynamicInputs/index.tsx | 3 +- .../RenderInput/templates/Reference.tsx | 44 ++++++++++++++++--- .../nodes/render/RenderToolInput/index.tsx | 21 +++++---- .../context/workflowUtilsContext.tsx | 6 +-- projects/app/src/web/core/workflow/utils.ts | 9 +++- .../context/workflowUtilsContext.test.ts | 30 +++++++++++++ .../test/web/core/app/workflow/utils.test.ts | 35 +++++++++++++++ 12 files changed, 134 insertions(+), 24 deletions(-) diff --git a/packages/global/core/workflow/template/system/http468.ts b/packages/global/core/workflow/template/system/http468.ts index 5c6d54307740..c8f3fdb53919 100644 --- a/packages/global/core/workflow/template/system/http468.ts +++ b/packages/global/core/workflow/template/system/http468.ts @@ -28,6 +28,7 @@ export const HttpNode468: FlowNodeTemplateType = { intro: i18nT('workflow:intro_http_request'), showStatus: true, isTool: true, + hasToolInput: true, catchError: false, courseUrl: '/guide/build/workflow/nodes/http', inputs: [ diff --git a/packages/global/core/workflow/template/system/sandbox/index.ts b/packages/global/core/workflow/template/system/sandbox/index.ts index e99fef68edac..12bf58ec57b0 100644 --- a/packages/global/core/workflow/template/system/sandbox/index.ts +++ b/packages/global/core/workflow/template/system/sandbox/index.ts @@ -27,6 +27,8 @@ export const CodeNode: FlowNodeTemplateType = { name: i18nT('workflow:code_execution'), intro: i18nT('workflow:code_sandbox_intro'), showStatus: true, + isTool: true, + hasToolInput: true, catchError: false, courseUrl: '/guide/build/workflow/nodes/sandbox-v2', inputs: [ diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index 432230865ad7..3fff2f74a6b0 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -203,6 +203,8 @@ export const FlowNodeTemplateTypeSchema = FlowNodeCommonTypeSchema.extend({ // Info isTool: BoolSchema.optional(), // can be connected by tool + /** 是否展示可编辑的工具参数面板,与自定义变量动态输入相互独立。 */ + hasToolInput: BoolSchema.optional(), // Action forbidDelete: BoolSchema.optional(), // forbid delete @@ -252,6 +254,7 @@ export const NodeTemplateListItemTypeSchema = z.object({ name: z.string(), intro: z.string().optional(), // template list intro isTool: BoolSchema.optional(), + hasToolInput: BoolSchema.optional(), authorAvatar: z.string().optional(), author: z.string().optional(), diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx index 158ef58594bd..0f9c0a1d4577 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx @@ -189,7 +189,7 @@ const NodeCode = ({ data, selected }: NodeProps) => { return ( - {isTool && hasDynamicToolInput(inputs) && ( + {isTool && hasDynamicToolInput(data) && ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx index aba3836c382e..c912a1bd9f6b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeHttp/index.tsx @@ -837,7 +837,7 @@ const NodeHttp = ({ data, selected }: NodeProps) => { // console.log(inputs); return ( - {isTool && hasDynamicToolInput(inputs) && ( + {isTool && hasDynamicToolInput(data) && ( <> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx index 781bf6e3596e..ba133fc82dd9 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx @@ -139,7 +139,8 @@ const Reference = ({ nodeId, valueType: WorkflowIOValueTypeEnum.any, // Container nodes (loopRun) need to reference outputs from their sub-workflow. - includeChildren: true + includeChildren: true, + excludeInputKey: inputChildren.key }); const onlBlurLabel = useCallback( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index cc78abb7163e..8d4227fa2fed 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -12,12 +12,12 @@ import type { } from '@fastgpt/global/core/workflow/type/io'; import dynamic from 'next/dynamic'; import { useContextSelector } from 'use-context-selector'; -import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant'; -import { AppContext } from '@/pageComponents/app/detail/context'; import { - WorkflowBufferDataContext, - WorkflowNodeDataContext -} from '../../../../../context/workflowInitContext'; + FlowNodeTypeEnum, + isNestedParentNodeType +} from '@fastgpt/global/core/workflow/node/constant'; +import { AppContext } from '@/pageComponents/app/detail/context'; +import { WorkflowBufferDataContext } from '../../../../../context/workflowInitContext'; import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; @@ -56,12 +56,15 @@ type SelectProps = CommonSelectProps & { export const useReference = ({ nodeId, valueType = WorkflowIOValueTypeEnum.any, - includeChildren + includeChildren, + excludeInputKey }: { nodeId: string; valueType?: WorkflowIOValueTypeEnum; // Include the container's own children as reference sources. includeChildren?: boolean; + /** 代码自定义变量不能引用自身,避免形成直接循环。 */ + excludeInputKey?: string; }) => { const { t } = useTranslation(); const appDetail = useContextSelector(AppContext, (v) => v.appDetail); @@ -112,7 +115,33 @@ export const useReference = ({ }) .filter((item) => item.children.length > 0); - return list; + const currentNode = getNodeById(nodeId); + if (currentNode?.flowNodeType !== FlowNodeTypeEnum.code) return list; + + const toolInputs = currentNode.inputs.filter( + (input) => + input.canEdit === true && input.isToolParam === true && input.key !== excludeInputKey + ); + if (toolInputs.length === 0) return list; + + return [ + ...list, + // References to user-defined tool call params of the node. + { + label: ( + + + {currentNode.name} + + ), + value: currentNode.nodeId, + children: toolInputs.map((input) => ({ + label: t(input.label as any), + value: input.key, + valueType: input.valueType + })) + } + ]; }, [ nodeId, systemConfigNode, @@ -122,6 +151,7 @@ export const useReference = ({ t, valueType, includeChildren, + excludeInputKey, childrenNodeIdListMap ]); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderToolInput/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderToolInput/index.tsx index b7f35a3b7a32..d0e5d722894b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderToolInput/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderToolInput/index.tsx @@ -1,5 +1,7 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { Box, Button, @@ -19,14 +21,21 @@ import { defaultEditFormData } from './EditFieldModal'; import { useContextSelector } from 'use-context-selector'; import IOTitle from '../../../components/IOTitle'; import { SmallAddIcon } from '@chakra-ui/icons'; -import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { WorkflowUtilsContext } from '../../../../context/workflowUtilsContext'; import { WorkflowActionsContext } from '../../../../context/workflowActionsContext'; const EditFieldModal = dynamic(() => import('./EditFieldModal')); -export const hasDynamicToolInput = (inputs: FlowNodeInputItemType[]) => - inputs.some((item) => item.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam); +export const hasDynamicToolInput = ( + source: Pick | FlowNodeInputItemType[] +) => + Array.isArray(source) + ? source.some( + (item) => + item.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam || + (item.canEdit === true && item.isToolParam === true) + ) + : source.hasToolInput === true; const RenderToolInput = ({ nodeId, @@ -43,12 +52,8 @@ const RenderToolInput = ({ [inputs, nodeId, splitToolInputs] ); - const dynamicInput = useMemo(() => hasDynamicToolInput(inputs), [inputs]); - const [editField, setEditField] = useState(); - if (!dynamicInput) return null; - return ( <> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx index d79876b8e455..e304ee532f8d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -34,7 +34,6 @@ import { WorkflowSnapshotContext } from './workflowSnapshotContext'; import { WorkflowActionsContext } from './workflowActionsContext'; import { canInputBeAgentGenerated, - isAgentGeneratedToolInput, normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; @@ -82,10 +81,9 @@ export const splitToolInputsByMode = (inputs: FlowNodeInputItemType[], isTool: b inputs.forEach((item) => { const normalizedInput = normalizeFlowNodeInputType(item, { isTool }); - const isAgentGeneratedInput = - isAgentGeneratedToolInput(normalizedInput) && canInputBeAgentGenerated(normalizedInput); + const isToolParamInput = item.canEdit === true && item.isToolParam === true; - if (isTool && isAgentGeneratedInput && item.canEdit) { + if (isTool && isToolParamInput && canInputBeAgentGenerated(normalizedInput)) { toolInputs.push(item); return; } diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 0c193163d30d..0641e03cd8f0 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -42,7 +42,10 @@ import { workflowSystemVariables } from '../app/utils'; import type { WorkflowDataContextType } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; -import { normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; +import { + normalizeFlowNodeInputType, + normalizeLegacyWorkflowHttpToolInputsDefaultMode +} from '@fastgpt/global/core/app/formEdit/utils'; import { normalizeWorkflowToolInputsDefaultMode } from '@fastgpt/global/core/app/tool/workflowTool/utils'; /* ====== node ======= */ @@ -268,7 +271,9 @@ export const storeNode2FlowNode = ({ const inputsWithLegacyDefaults = nodeItem.flowNodeType === FlowNodeTypeEnum.pluginInput ? normalizeWorkflowToolInputsDefaultMode(nodeItem.inputs) - : nodeItem.inputs; + : nodeItem.flowNodeType === FlowNodeTypeEnum.httpRequest468 + ? normalizeLegacyWorkflowHttpToolInputsDefaultMode(nodeItem.inputs) + : nodeItem.inputs; const allowLegacyToolDescriptionFallback = isTool && (nodeItem.flowNodeType === FlowNodeTypeEnum.pluginModule || diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts index df6b78e261c3..89cd80c121e9 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts @@ -24,6 +24,36 @@ describe('splitToolInputsByMode', () => { expect(result.commonInputs.map((input) => input.key)).toEqual(['url']); }); + it('keeps manually configured editable tool params in tool inputs', () => { + const toolInput = { + key: 'query', + label: 'query', + canEdit: true, + isToolParam: true, + selectedType: FlowNodeInputTypeEnum.input, + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference] + }; + + const result = splitToolInputsByMode([toolInput], true); + + expect(result.toolInputs).toEqual([toolInput]); + expect(result.commonInputs).toEqual([]); + }); + + it('keeps unmarked editable inputs as code custom variables', () => { + const customVariable = { + key: 'codeInput', + label: 'codeInput', + canEdit: true, + renderTypeList: [FlowNodeInputTypeEnum.reference] + }; + + const result = splitToolInputsByMode([customVariable], true); + + expect(result.toolInputs).toEqual([]); + expect(result.commonInputs.map((input) => input.key)).toEqual(['codeInput']); + }); + it('keeps the same input in common inputs when it is not a tool', () => { const input = { key: 'query', diff --git a/projects/app/test/web/core/app/workflow/utils.test.ts b/projects/app/test/web/core/app/workflow/utils.test.ts index 24041508fcf2..d821898b22f2 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -2138,6 +2138,41 @@ describe('storeNode2FlowNode', () => { expect(result.data.outputs).toHaveLength(2); }); + it('restores legacy HTTP dynamic fields as tool parameters', () => { + const storeNode: StoreNodeItemType = { + nodeId: 'http-tool', + flowNodeType: FlowNodeTypeEnum.httpRequest468, + position: { x: 0, y: 0 }, + inputs: [ + { + key: 'query', + label: 'Query', + canEdit: true, + toolDescription: 'Request query', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + valueType: WorkflowIOValueTypeEnum.string + } + ], + outputs: [], + name: 'HTTP tool', + version: '1.0' + }; + + const result = storeNode2FlowNode({ + item: storeNode, + isTool: true, + t: ((key: string) => key) as any + }); + const queryInput = result.data.inputs.find((input) => input.key === 'query'); + + expect(queryInput).toMatchObject({ + canEdit: true, + isToolParam: true, + selectedType: FlowNodeInputTypeEnum.agentGenerated + }); + expect(result.data.hasToolInput).toBe(true); + }); + it('should migrate selectedTypeIndex while restoring canvas nodes', () => { const storeNode: StoreNodeItemType = { nodeId: 'chat-node', From a00857173db54f7df6d7dac52617e96f2c2bc9e2 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 11 Aug 2026 16:54:17 +0800 Subject: [PATCH 06/11] feat(workflow): allow more nodes as tools Signed-off-by: Nixieboluo --- packages/global/core/app/formEdit/utils.ts | 3 +- .../template/system/classifyQuestion/index.ts | 11 +- .../template/system/customFeedback.ts | 5 +- .../workflow/template/system/ifElse/index.ts | 2 + .../template/system/loopRun/loopRun.ts | 4 + .../system/parallelRun/parallelRun.ts | 4 + .../template/system/readFiles/index.tsx | 6 +- .../workflow/template/system/sandbox/index.ts | 3 + .../workflow/template/system/textEditor.ts | 2 + .../core/workflow/template/system/toolCall.ts | 13 +- packages/global/core/workflow/type/io.ts | 7 + .../Flow/nodes/NodeCQNode.tsx | 16 ++- .../Flow/nodes/NodeToolCall.tsx | 19 ++- .../context/workflowUtilsContext.test.ts | 34 +++++ .../app/workflow/toolNodeTemplates.test.ts | 121 ++++++++++++++++++ 15 files changed, 230 insertions(+), 20 deletions(-) create mode 100644 projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts diff --git a/packages/global/core/app/formEdit/utils.ts b/packages/global/core/app/formEdit/utils.ts index 154f4eca7d35..cc2561f70b20 100644 --- a/packages/global/core/app/formEdit/utils.ts +++ b/packages/global/core/app/formEdit/utils.ts @@ -101,11 +101,12 @@ export const isAgentGeneratedToolInput = (input: InputRenderTypeState) => * 服务端 runtime schema 的安全边界:即使持久化数据被篡改,也只允许普通可生成字段进入模型 schema。 */ export const canInputBeAgentGenerated = ( - input: Pick & { + input: Pick & { renderTypeList?: FlowNodeInputItemType['renderTypeList']; } ) => { if (input.key === NodeInputKeyEnum.systemInputConfig) return false; + if (input.allowAgentGenerated === false) return false; if (!Array.isArray(input.renderTypeList)) return false; return !input.renderTypeList.some((type) => agentGeneratedDenyRenderTypes.has(type)); }; diff --git a/packages/global/core/workflow/template/system/classifyQuestion/index.ts b/packages/global/core/workflow/template/system/classifyQuestion/index.ts index b38797e645e3..95508e48c5fe 100644 --- a/packages/global/core/workflow/template/system/classifyQuestion/index.ts +++ b/packages/global/core/workflow/template/system/classifyQuestion/index.ts @@ -30,6 +30,7 @@ export const ClassifyQuestionModule: FlowNodeTemplateType = { name: i18nT('workflow:question_classification'), intro: i18nT('workflow:intro_question_classification'), showStatus: true, + isTool: true, version: '4.9.2', courseUrl: '/guide/build/workflow/nodes/question_classify', inputs: [ @@ -38,10 +39,14 @@ export const ClassifyQuestionModule: FlowNodeTemplateType = { ...Input_Template_System_Prompt, label: i18nT('common:core.module.input.label.Background'), description: i18nT('common:core.module.input.description.Background'), - placeholder: i18nT('common:core.module.input.placeholder.Classify background') + placeholder: i18nT('common:core.module.input.placeholder.Classify background'), + toolDescription: i18nT('common:core.module.input.label.Background') }, - Input_Template_History, - Input_Template_UserChatInput, + { + ...Input_Template_History, + toolDescription: i18nT('common:core.module.input.label.chat history') + }, + { ...Input_Template_UserChatInput, isToolParam: true }, { key: NodeInputKeyEnum.agents, renderTypeList: [FlowNodeInputTypeEnum.custom], diff --git a/packages/global/core/workflow/template/system/customFeedback.ts b/packages/global/core/workflow/template/system/customFeedback.ts index 141b6019311f..106735fdde77 100644 --- a/packages/global/core/workflow/template/system/customFeedback.ts +++ b/packages/global/core/workflow/template/system/customFeedback.ts @@ -18,6 +18,7 @@ export const CustomFeedbackNode: FlowNodeTemplateType = { colorSchema: 'yellowGreen', name: i18nT('workflow:custom_feedback'), intro: i18nT('workflow:intro_custom_feedback'), + isTool: true, courseUrl: '/guide/build/workflow/nodes/custom_feedback', inputs: [ { @@ -25,7 +26,9 @@ export const CustomFeedbackNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.string, required: true, - label: i18nT('workflow:feedback_text') + label: i18nT('workflow:feedback_text'), + toolDescription: i18nT('workflow:feedback_text'), + isToolParam: false } ], outputs: [] diff --git a/packages/global/core/workflow/template/system/ifElse/index.ts b/packages/global/core/workflow/template/system/ifElse/index.ts index 380d744a4162..29dee9be8ede 100644 --- a/packages/global/core/workflow/template/system/ifElse/index.ts +++ b/packages/global/core/workflow/template/system/ifElse/index.ts @@ -24,6 +24,7 @@ export const IfElseNode: FlowNodeTemplateType = { name: i18nT('workflow:condition_checker'), intro: i18nT('workflow:execute_different_branches_based_on_conditions'), showStatus: true, + isTool: true, courseUrl: '/guide/build/workflow/nodes/tfswitch', inputs: [ { @@ -31,6 +32,7 @@ export const IfElseNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.hidden], valueType: WorkflowIOValueTypeEnum.any, label: '', + allowAgentGenerated: false, value: [ { condition: 'AND', // AND, OR diff --git a/packages/global/core/workflow/template/system/loopRun/loopRun.ts b/packages/global/core/workflow/template/system/loopRun/loopRun.ts index 5fc7e1cade8a..576eae49e7df 100644 --- a/packages/global/core/workflow/template/system/loopRun/loopRun.ts +++ b/packages/global/core/workflow/template/system/loopRun/loopRun.ts @@ -36,6 +36,7 @@ export const LoopRunNode: FlowNodeTemplateType = { name: i18nT('workflow:loop_run'), intro: i18nT('workflow:intro_loop_run'), showStatus: true, + isTool: true, isShowInContext: createHideInContext([ { parentType: FlowNodeTypeEnum.loopRun }, { parentType: FlowNodeTypeEnum.parallelRun } @@ -48,6 +49,7 @@ export const LoopRunNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.select], valueType: WorkflowIOValueTypeEnum.string, required: true, + allowAgentGenerated: false, label: i18nT('workflow:loop_run_mode'), description: i18nT('workflow:loop_run_mode_tip'), list: [ @@ -71,6 +73,7 @@ export const LoopRunNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.arrayAny, required: true, + allowAgentGenerated: false, label: i18nT('workflow:loop_run_input_array'), value: [] }, @@ -81,6 +84,7 @@ export const LoopRunNode: FlowNodeTemplateType = { label: i18nT('workflow:loop_custom_outputs'), description: i18nT('workflow:loop_custom_outputs_tip'), required: false, + allowAgentGenerated: false, customInputConfig: { selectValueTypeList: Object.values(WorkflowIOValueTypeEnum), showDescription: false, diff --git a/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts b/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts index 73ebd9303870..f247c86539a7 100644 --- a/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts +++ b/packages/global/core/workflow/template/system/parallelRun/parallelRun.ts @@ -31,6 +31,7 @@ export const ParallelRunNode: FlowNodeTemplateType = { name: i18nT('workflow:parallel_run'), intro: i18nT('workflow:intro_parallel_run'), showStatus: true, + isTool: true, isShowInContext: createHideInContext([ { parentType: FlowNodeTypeEnum.loopRun }, { parentType: FlowNodeTypeEnum.parallelRun } @@ -42,6 +43,7 @@ export const ParallelRunNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.arrayAny, required: true, + allowAgentGenerated: false, label: i18nT('workflow:loop_input_array'), value: [] }, @@ -50,6 +52,7 @@ export const ParallelRunNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.numberInput], valueType: WorkflowIOValueTypeEnum.number, required: true, + allowAgentGenerated: false, label: i18nT('workflow:parallel_run_max_concurrency'), description: i18nT('workflow:parallel_run_max_concurrency_tip'), min: 1, @@ -60,6 +63,7 @@ export const ParallelRunNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.numberInput], valueType: WorkflowIOValueTypeEnum.number, required: true, + allowAgentGenerated: false, label: i18nT('workflow:parallel_run_max_retry_times'), description: i18nT('workflow:parallel_run_max_retry_times_tip'), min: 0, diff --git a/packages/global/core/workflow/template/system/readFiles/index.tsx b/packages/global/core/workflow/template/system/readFiles/index.tsx index cae00327dd10..c1d301e287b6 100644 --- a/packages/global/core/workflow/template/system/readFiles/index.tsx +++ b/packages/global/core/workflow/template/system/readFiles/index.tsx @@ -26,7 +26,7 @@ export const ReadFilesNode: FlowNodeTemplateType = { intro: i18nT('app:workflow.read_files_tip'), showStatus: true, version: '4.9.2', - isTool: false, + isTool: true, courseUrl: '/guide/build/general/fileInput', inputs: [ { @@ -35,7 +35,9 @@ export const ReadFilesNode: FlowNodeTemplateType = { valueType: WorkflowIOValueTypeEnum.arrayString, label: i18nT('app:workflow.file_url'), required: true, - value: [] + value: [], + toolDescription: i18nT('app:workflow.file_url'), + isToolParam: true } ], outputs: [ diff --git a/packages/global/core/workflow/template/system/sandbox/index.ts b/packages/global/core/workflow/template/system/sandbox/index.ts index 12bf58ec57b0..22f229e8d066 100644 --- a/packages/global/core/workflow/template/system/sandbox/index.ts +++ b/packages/global/core/workflow/template/system/sandbox/index.ts @@ -35,6 +35,7 @@ export const CodeNode: FlowNodeTemplateType = { { ...Input_Template_DynamicInput, description: i18nT('workflow:these_variables_will_be_input_parameters_for_code_execution'), + allowAgentGenerated: false, customInputConfig: { selectValueTypeList: Object.values(WorkflowIOValueTypeEnum), showDescription: false, @@ -45,6 +46,7 @@ export const CodeNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.string, canEdit: true, + allowAgentGenerated: false, key: 'data1', label: 'data1', customInputConfig: { @@ -58,6 +60,7 @@ export const CodeNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.reference], valueType: WorkflowIOValueTypeEnum.string, canEdit: true, + allowAgentGenerated: false, key: 'data2', label: 'data2', customInputConfig: { diff --git a/packages/global/core/workflow/template/system/textEditor.ts b/packages/global/core/workflow/template/system/textEditor.ts index d78dba6cd69a..1a059ec35235 100644 --- a/packages/global/core/workflow/template/system/textEditor.ts +++ b/packages/global/core/workflow/template/system/textEditor.ts @@ -23,6 +23,7 @@ export const TextEditorNode: FlowNodeTemplateType = { colorSchema: 'orange', name: i18nT('workflow:text_concatenation'), intro: i18nT('workflow:intro_text_concatenation'), + isTool: true, courseUrl: '/guide/build/workflow/nodes/text_editor', inputs: [ { @@ -30,6 +31,7 @@ export const TextEditorNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.textarea], valueType: WorkflowIOValueTypeEnum.string, required: true, + allowAgentGenerated: false, label: i18nT('workflow:concatenation_text'), placeholder: i18nT('workflow:input_variable_list') } diff --git a/packages/global/core/workflow/template/system/toolCall.ts b/packages/global/core/workflow/template/system/toolCall.ts index 1620e4a5931a..b81dae208a4c 100644 --- a/packages/global/core/workflow/template/system/toolCall.ts +++ b/packages/global/core/workflow/template/system/toolCall.ts @@ -33,6 +33,7 @@ export const ToolCallNode: FlowNodeTemplateType = { name: i18nT('workflow:template.agent'), intro: i18nT('workflow:template.agent_intro'), showStatus: true, + isTool: true, catchError: false, courseUrl: '/guide/build/workflow/nodes/tool', version: '4.9.2', @@ -141,11 +142,15 @@ export const ToolCallNode: FlowNodeTemplateType = { ...Input_Template_System_Prompt, label: i18nT('common:core.ai.Prompt'), description: systemPromptTip, - placeholder: chatNodeSystemPromptTip + placeholder: chatNodeSystemPromptTip, + toolDescription: i18nT('common:core.ai.Prompt') }, - Input_Template_History, - Input_Template_File_Link, - Input_Template_UserChatInput + { + ...Input_Template_History, + toolDescription: i18nT('common:core.module.input.label.chat history') + }, + { ...Input_Template_File_Link, toolDescription: i18nT('app:workflow.user_file_input') }, + { ...Input_Template_UserChatInput, isToolParam: true } ], outputs: [ { diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 773223993273..e5c0d97d69c0 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -285,9 +285,16 @@ export const FlowNodeInputItemTypeSchema = InputComponentPropsTypeSchema.extend( toolDescription: z.string().optional().meta({ description: '作为工具调用参数时的语义说明' }), + /** + * 指默认不作为工具调用的参数(由用户填写),但是仍可由用户选择让 AI 生成。 + * @see allowAgentGenerated + */ isToolParam: BoolSchema.optional().meta({ description: '该输入默认是否作为工具调用参数' }), + allowAgentGenerated: BoolSchema.optional().meta({ + description: '该输入作为工具参数时是否允许 AI 生成,默认允许' + }), customJsonSchema: z.record(z.string(), z.any()).optional().meta({ description: '工具参数自定义 JSON Schema 的 property 定义' }), diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCQNode.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCQNode.tsx index d3f1c3dc21e2..f884f49c496e 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCQNode.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCQNode.tsx @@ -16,12 +16,19 @@ import { MySourceHandle } from './render/Handle'; import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { useContextSelector } from 'use-context-selector'; import { WorkflowActionsContext } from '../../context/workflowActionsContext'; +import { WorkflowUtilsContext } from '../../context/workflowUtilsContext'; +import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; const NodeCQNode = ({ data, selected }: NodeProps) => { const { t } = useTranslation(); const { nodeId, inputs } = data; const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onDelEdge = useContextSelector(WorkflowActionsContext, (v) => v.onDelEdge); + const splitToolInputs = useContextSelector(WorkflowUtilsContext, (ctx) => ctx.splitToolInputs); + const { isTool, commonInputs } = useMemoEnhance( + () => splitToolInputs(inputs, nodeId), + [inputs, nodeId, splitToolInputs] + ); const CustomComponent = useMemo( () => ({ @@ -134,11 +141,16 @@ const NodeCQNode = ({ data, selected }: NodeProps) => { return ( - + ); - }, [CustomComponent, data, inputs, nodeId, selected]); + }, [CustomComponent, commonInputs, data, isTool, nodeId, selected]); return Render; }; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeToolCall.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeToolCall.tsx index 439f83eaf136..8dd2c6e2df93 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeToolCall.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeToolCall.tsx @@ -28,11 +28,16 @@ const NodeToolCall = ({ data, selected }: NodeProps) => { const { nodeId, inputs, outputs, catchError } = data; const { toast } = useToast(); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + const splitToolInputs = useContextSelector(WorkflowUtilsContext, (ctx) => ctx.splitToolInputs); const splitOutput = useContextSelector(WorkflowUtilsContext, (ctx) => ctx.splitOutput); const { feConfigs } = useSystemStore(); const { teamPlanStatus } = useUserStore(); const enableSandbox = !teamPlanStatus?.standard || !!teamPlanStatus?.standard?.enableSandbox; const showSandbox = feConfigs.show_agent_sandbox; + const { isTool, commonInputs } = useMemoEnhance( + () => splitToolInputs(inputs, nodeId), + [inputs, nodeId, splitToolInputs] + ); const { successOutputs, errorOutputs } = useMemoEnhance( () => splitOutput(outputs), [outputs, splitOutput] @@ -46,12 +51,12 @@ const NodeToolCall = ({ data, selected }: NodeProps) => { [inputs] ); const { beforeSandboxInputs, afterSandboxInputs } = React.useMemo(() => { - const visibleInputs = inputs.filter( + const visibleInputs = commonInputs.filter( (input) => input.key !== NodeInputKeyEnum.useAgentSandbox && input.key !== NodeInputKeyEnum.sandboxEntrypoint ); - const sandboxIndex = inputs.findIndex( + const sandboxIndex = commonInputs.findIndex( (input) => input.key === NodeInputKeyEnum.useAgentSandbox ); if (sandboxIndex < 0) { @@ -63,13 +68,13 @@ const NodeToolCall = ({ data, selected }: NodeProps) => { return { beforeSandboxInputs: visibleInputs.filter( - (input) => inputs.findIndex((item) => item.key === input.key) < sandboxIndex + (input) => commonInputs.findIndex((item) => item.key === input.key) < sandboxIndex ), afterSandboxInputs: visibleInputs.filter( - (input) => inputs.findIndex((item) => item.key === input.key) > sandboxIndex + (input) => commonInputs.findIndex((item) => item.key === input.key) > sandboxIndex ) }; - }, [inputs]); + }, [commonInputs]); const onChangeSandbox = React.useCallback( (checked: boolean) => { if (!sandboxInput) return; @@ -107,7 +112,7 @@ const NodeToolCall = ({ data, selected }: NodeProps) => { - + ) => { }); }} /> - + diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts index 89cd80c121e9..5b3c3d01c169 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import { splitToolInputsByMode } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { ClassifyQuestionModule } from '@fastgpt/global/core/workflow/template/system/classifyQuestion'; +import { ToolCallNode } from '@fastgpt/global/core/workflow/template/system/toolCall'; describe('splitToolInputsByMode', () => { it('keeps Agent-generated editable inputs out of common inputs', () => { @@ -69,4 +72,35 @@ describe('splitToolInputsByMode', () => { expect(result.toolInputs).toEqual([]); expect(result.commonInputs).toHaveLength(1); }); + + it('adds AI-generated mode to fixed inputs when connected as a tool', () => { + const classifyInputs = splitToolInputsByMode(ClassifyQuestionModule.inputs, true).commonInputs; + const toolCallInputs = splitToolInputsByMode(ToolCallNode.inputs, true).commonInputs; + + [ + ...classifyInputs.filter((input) => + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.userChatInput + ].includes(input.key as NodeInputKeyEnum) + ), + ...toolCallInputs.filter((input) => + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.fileUrlList, + NodeInputKeyEnum.userChatInput + ].includes(input.key as NodeInputKeyEnum) + ) + ].forEach((input) => { + expect(input.renderTypeList).toContain(FlowNodeInputTypeEnum.agentGenerated); + }); + expect( + classifyInputs.find((input) => input.key === NodeInputKeyEnum.userChatInput)?.selectedType + ).toBe(FlowNodeInputTypeEnum.agentGenerated); + expect( + toolCallInputs.find((input) => input.key === NodeInputKeyEnum.userChatInput)?.selectedType + ).toBe(FlowNodeInputTypeEnum.agentGenerated); + }); }); diff --git a/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts b/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts new file mode 100644 index 000000000000..2186e453fe65 --- /dev/null +++ b/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { + FlowNodeInputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; +import { ClassifyQuestionModule } from '@fastgpt/global/core/workflow/template/system/classifyQuestion'; +import { CustomFeedbackNode } from '@fastgpt/global/core/workflow/template/system/customFeedback'; +import { CodeNode } from '@fastgpt/global/core/workflow/template/system/sandbox'; +import { ReadFilesNode } from '@fastgpt/global/core/workflow/template/system/readFiles'; +import { TextEditorNode } from '@fastgpt/global/core/workflow/template/system/textEditor'; +import { ToolCallNode } from '@fastgpt/global/core/workflow/template/system/toolCall'; +import { IfElseNode } from '@fastgpt/global/core/workflow/template/system/ifElse'; +import { ParallelRunNode } from '@fastgpt/global/core/workflow/template/system/parallelRun/parallelRun'; +import { LoopRunNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +import { HttpNode468 } from '@fastgpt/global/core/workflow/template/system/http468'; + +describe('workflow tool node templates', () => { + it('marks supported nodes as tool-connectable', () => { + expect( + [ + ClassifyQuestionModule, + ToolCallNode, + TextEditorNode, + ReadFilesNode, + IfElseNode, + ParallelRunNode, + LoopRunNode, + CustomFeedbackNode, + CodeNode + ].every((template) => template.isTool === true) + ).toBe(true); + }); + + it('keeps requested AI-generated defaults explicit', () => { + expect( + ClassifyQuestionModule.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput) + ).toMatchObject({ isToolParam: true }); + expect( + ReadFilesNode.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList) + ).toMatchObject({ isToolParam: true }); + expect( + ToolCallNode.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput) + ).toMatchObject({ isToolParam: true }); + expect( + CustomFeedbackNode.inputs.find((input) => input.key === NodeInputKeyEnum.textareaInput) + ).toMatchObject({ isToolParam: false }); + }); + + it('allows AI-generated mode for every requested tool input', () => { + const requestedInputs = [ + ...ClassifyQuestionModule.inputs.filter((input) => + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.userChatInput + ].includes(input.key as NodeInputKeyEnum) + ), + ...ToolCallNode.inputs.filter((input) => + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.fileUrlList, + NodeInputKeyEnum.userChatInput + ].includes(input.key as NodeInputKeyEnum) + ), + ...ReadFilesNode.inputs.filter((input) => input.key === NodeInputKeyEnum.fileUrlList), + ...CustomFeedbackNode.inputs.filter((input) => input.key === NodeInputKeyEnum.textareaInput) + ]; + + requestedInputs.forEach((input) => { + expect(normalizeFlowNodeInputType(input, { isTool: true }).renderTypeList).toContain( + FlowNodeInputTypeEnum.agentGenerated + ); + }); + + expect( + normalizeFlowNodeInputType( + ClassifyQuestionModule.inputs.find( + (input) => input.key === NodeInputKeyEnum.userChatInput + )!, + { isTool: true } + ).selectedType + ).toBe(FlowNodeInputTypeEnum.agentGenerated); + expect( + normalizeFlowNodeInputType( + ReadFilesNode.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)!, + { isTool: true } + ).selectedType + ).toBe(FlowNodeInputTypeEnum.agentGenerated); + expect( + normalizeFlowNodeInputType( + CustomFeedbackNode.inputs.find((input) => input.key === NodeInputKeyEnum.textareaInput)!, + { isTool: true } + ).selectedType + ).not.toBe(FlowNodeInputTypeEnum.agentGenerated); + expect( + normalizeFlowNodeInputType(TextEditorNode.inputs[0], { isTool: true }).renderTypeList + ).not.toContain(FlowNodeInputTypeEnum.agentGenerated); + }); + + it('keeps code node custom variables separate from tool params', () => { + expect(CodeNode.flowNodeType).toBe(FlowNodeTypeEnum.code); + expect(CodeNode.hasToolInput).toBe(true); + expect(HttpNode468.hasToolInput).toBe(true); + expect( + CodeNode.inputs.find((input) => input.key === NodeInputKeyEnum.addInputParam) + ).not.toHaveProperty('isToolParam'); + }); + + it('does not expose AI-generated mode for control and code nodes', () => { + [IfElseNode, ParallelRunNode, LoopRunNode, CodeNode].forEach((template) => { + template.inputs.forEach((input) => { + expect(normalizeFlowNodeInputType(input, { isTool: true }).renderTypeList).not.toContain( + FlowNodeInputTypeEnum.agentGenerated + ); + }); + }); + }); +}); From 2960c2a347ee06c318375073888ddc86383b9cb1 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 11 Aug 2026 17:43:15 +0800 Subject: [PATCH 07/11] fix: schema type Signed-off-by: Nixieboluo --- packages/global/openapi/core/app/tool/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/global/openapi/core/app/tool/api.ts b/packages/global/openapi/core/app/tool/api.ts index b6d4648ccb00..f9a24bbf580c 100644 --- a/packages/global/openapi/core/app/tool/api.ts +++ b/packages/global/openapi/core/app/tool/api.ts @@ -16,6 +16,8 @@ const ToolNodeTemplateListItemSchema = NodeTemplateListItemTypeSchema.extend({ }).catchall(z.any()); const ToolPreviewNodeResponseSchema = FlowNodeTemplateTypeSchema.omit({ + // Runtime-only predicate; functions cannot be represented in OpenAPI or JSON. + isShowInContext: true, inputs: true, outputs: true }).extend({ From 1bc96c7ae24aa140c94a1ab25f5e0c8932d72e8f Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 12 Aug 2026 11:18:54 +0800 Subject: [PATCH 08/11] docs(workflow): queryExtension deprecation note Signed-off-by: Nixieboluo --- .../workflow/nodes/coreferenceResolution.en.mdx | 2 ++ .../build/workflow/nodes/coreferenceResolution.mdx | 2 ++ document/data/doc-last-modified.json | 14 ++++++++------ .../template/system/abandoned/queryExtension.ts | 1 + 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/document/content/guide/build/workflow/nodes/coreferenceResolution.en.mdx b/document/content/guide/build/workflow/nodes/coreferenceResolution.en.mdx index b9ec30bb6b0e..3c7bfc4c702e 100644 --- a/document/content/guide/build/workflow/nodes/coreferenceResolution.en.mdx +++ b/document/content/guide/build/workflow/nodes/coreferenceResolution.en.mdx @@ -3,6 +3,8 @@ title: Query Enhancement description: FastGPT Query Enhancement node overview and usage --- +> **Deprecated**: The Query Enhancement node can no longer be added to new workflows. Existing nodes remain runnable for compatibility. + ## Characteristics - Can be added multiple times diff --git a/document/content/guide/build/workflow/nodes/coreferenceResolution.mdx b/document/content/guide/build/workflow/nodes/coreferenceResolution.mdx index 06efa695e88a..93c2d9028adc 100644 --- a/document/content/guide/build/workflow/nodes/coreferenceResolution.mdx +++ b/document/content/guide/build/workflow/nodes/coreferenceResolution.mdx @@ -3,6 +3,8 @@ title: 问题优化 description: 问题优化模块介绍和使用 --- +> **已弃用**:问题优化节点已停止新增。已存在于画布中的节点仍可继续运行。 + ## 特点 - 可重复添加 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 616c73cae907..7e1c76f4badc 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -127,8 +127,8 @@ "content/guide/dataset/websync.mdx": "2026-05-07T15:06:40+08:00", "content/guide/getting-started/index.en.mdx": "2026-07-17T19:17:14+08:00", "content/guide/getting-started/index.mdx": "2026-07-17T19:17:14+08:00", - "content/guide/getting-started/quick-start.en.mdx": "2026-07-01T17:20:32+08:00", - "content/guide/getting-started/quick-start.mdx": "2026-07-01T17:20:32+08:00", + "content/guide/getting-started/quick-start.en.mdx": "2026-08-11T10:31:10+08:00", + "content/guide/getting-started/quick-start.mdx": "2026-08-11T10:31:10+08:00", "content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/index.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/faq.en.mdx": "2026-07-17T19:17:14+08:00", @@ -303,6 +303,8 @@ "content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/4-14/4149.mdx": "2026-07-25T00:27:20+08:00", + "content/self-host/upgrading/4-14/41930.en.mdx": "2026-08-10T16:25:21+08:00", + "content/self-host/upgrading/4-14/41930.mdx": "2026-08-10T16:25:21+08:00", "content/self-host/upgrading/4-15/41500.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-15/41500.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-15/41501.en.mdx": "2026-07-25T00:27:20+08:00", @@ -335,8 +337,8 @@ "content/self-host/upgrading/4-15/4157.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", - "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-07T18:40:57+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-07T18:40:57+08:00", + "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T10:31:10+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T10:31:10+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", @@ -477,6 +479,6 @@ "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", - "content/toc.en.mdx": "2026-08-07T18:40:57+08:00", - "content/toc.mdx": "2026-08-07T18:40:57+08:00" + "content/toc.en.mdx": "2026-08-10T16:25:21+08:00", + "content/toc.mdx": "2026-08-10T16:25:21+08:00" } \ No newline at end of file diff --git a/packages/global/core/workflow/template/system/abandoned/queryExtension.ts b/packages/global/core/workflow/template/system/abandoned/queryExtension.ts index bdfda9614221..70369d04891a 100644 --- a/packages/global/core/workflow/template/system/abandoned/queryExtension.ts +++ b/packages/global/core/workflow/template/system/abandoned/queryExtension.ts @@ -18,6 +18,7 @@ import { import { i18nT } from '../../../../../common/i18n/utils'; import { PluginStatusEnum } from '../../../../plugin/type'; +/** @deprecated Retained for existing workflow compatibility; hidden from new-node templates. */ export const AiQueryExtension: FlowNodeTemplateType = { id: FlowNodeTypeEnum.queryExtension, templateType: FlowNodeTemplateTypeEnum.other, From ac63bd95d710f8a8beabbd8d13b97b54a5ebe2d8 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 12 Aug 2026 11:40:32 +0800 Subject: [PATCH 09/11] feat(workflow): sidebar node panel filtering Signed-off-by: Nixieboluo --- .../global/core/workflow/template/context.ts | 29 ++++++---- .../template/system/loopRun/loopRunBreak.ts | 4 +- .../core/workflow/template/system/stopTool.ts | 2 +- .../workflow/template/system/toolParams.ts | 10 ++-- packages/global/core/workflow/type/node.ts | 8 ++- .../Flow/NodeTemplatesModal.tsx | 21 +++++++- .../Flow/NodeTemplatesPopover.tsx | 53 ++++++++----------- .../NodeTemplates/useNodeTemplates.tsx | 16 ++++-- .../core/app/workflow/templateContext.test.ts | 21 +++++++- 9 files changed, 110 insertions(+), 54 deletions(-) diff --git a/packages/global/core/workflow/template/context.ts b/packages/global/core/workflow/template/context.ts index 704aaaae454f..1637ef133e8c 100644 --- a/packages/global/core/workflow/template/context.ts +++ b/packages/global/core/workflow/template/context.ts @@ -24,7 +24,7 @@ const matchRule = (rule: NodeTemplateContextRule, ctx: NodeTemplateContext): boo }; /** - * 白名单工厂:上下文非空且匹配任一规则时才展示;ctx 为 null(侧边栏)时不展示。 + * 白名单工厂:上下文存在且匹配任一规则时展示。 */ export const createShowInContext = ( rules: NodeTemplateContextRule[] @@ -33,7 +33,7 @@ export const createShowInContext = ( }; /** - * 黑名单工厂:匹配任一规则时隐藏;ctx 为 null(侧边栏)时正常展示。 + * 黑名单工厂:匹配任一规则时隐藏;无上下文时展示。 */ export const createHideInContext = ( rules: NodeTemplateContextRule[] @@ -58,7 +58,10 @@ export const buildNodeTemplateContext = ({ sourceNode, edges, handleId, - getNodeById + getNodeById, + isSidebar = false, + hasToolNode = false, + hasLoopRunNode = false }: { sourceNode: | Pick @@ -66,18 +69,24 @@ export const buildNodeTemplateContext = ({ edges: { target: string; targetHandle?: string | null }[]; handleId?: string | null; getNodeById: (nodeId: string | undefined | null) => FlowNodeItemType | undefined; + isSidebar?: boolean; + hasToolNode?: boolean; + hasLoopRunNode?: boolean; }): NodeTemplateContext | null => { - if (!sourceNode) return null; - const parentNode = sourceNode.parentNodeId ? getNodeById(sourceNode.parentNodeId) : undefined; + if (!sourceNode && !isSidebar) return null; + const parentNode = sourceNode?.parentNodeId ? getNodeById(sourceNode.parentNodeId) : undefined; return { - sourceNodeId: sourceNode.nodeId, - sourceType: sourceNode.flowNodeType, - sourceIsTool: !!sourceNode.isTool, + isSidebar, + sourceNodeId: sourceNode?.nodeId ?? null, + sourceType: sourceNode?.flowNodeType ?? null, + sourceIsTool: !!sourceNode?.isTool, isConnectedTool: edges.some( (edge) => - edge.target === sourceNode.nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools + edge.target === sourceNode?.nodeId && edge.targetHandle === NodeOutputKeyEnum.selectedTools ), handleId: handleId ?? null, - parentType: parentNode?.flowNodeType ?? null + parentType: parentNode?.flowNodeType ?? null, + hasToolNode, + hasLoopRunNode }; }; diff --git a/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts index 20feb1175246..5424c2ee44a5 100644 --- a/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts +++ b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts @@ -1,7 +1,6 @@ import { FlowNodeTypeEnum } from '../../../node/constant'; import { type FlowNodeTemplateType } from '../../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../../constants'; -import { createShowInContext } from '../../context'; import { i18nT } from '../../../../../common/i18n/utils'; export const LoopRunBreakNode: FlowNodeTemplateType = { @@ -16,7 +15,8 @@ export const LoopRunBreakNode: FlowNodeTemplateType = { name: i18nT('workflow:loop_run_break'), intro: i18nT('workflow:loop_run_break_tip'), showStatus: false, - isShowInContext: createShowInContext([{ parentType: FlowNodeTypeEnum.loopRun }]), + isShowInContext: (ctx) => + !!ctx && (ctx.isSidebar ? ctx.hasLoopRunNode : ctx.parentType === FlowNodeTypeEnum.loopRun), inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/template/system/stopTool.ts b/packages/global/core/workflow/template/system/stopTool.ts index 2326b9421482..ce02f510da26 100644 --- a/packages/global/core/workflow/template/system/stopTool.ts +++ b/packages/global/core/workflow/template/system/stopTool.ts @@ -14,7 +14,7 @@ export const StopToolNode: FlowNodeTemplateType = { colorSchema: 'violet', name: i18nT('workflow:tool_call_termination'), intro: i18nT('workflow:intro_tool_call_termination'), - isShowInContext: (ctx) => !!ctx?.isConnectedTool, + isShowInContext: (ctx) => !!ctx && (ctx.isSidebar ? ctx.hasToolNode : !!ctx.isConnectedTool), inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/template/system/toolParams.ts b/packages/global/core/workflow/template/system/toolParams.ts index efc3077f6961..4f19cfe22024 100644 --- a/packages/global/core/workflow/template/system/toolParams.ts +++ b/packages/global/core/workflow/template/system/toolParams.ts @@ -16,9 +16,13 @@ export const ToolParamsNode: FlowNodeTemplateType = { name: i18nT('workflow:tool_custom_field'), intro: i18nT('workflow:intro_tool_params_config'), isTool: true, - isShowInContext: createShowInContext([ - { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools } - ]), + isShowInContext: (ctx) => + !!ctx && + (ctx.isSidebar + ? ctx.hasToolNode + : createShowInContext([ + { sourceType: FlowNodeTypeEnum.toolCall, handleId: NodeOutputKeyEnum.selectedTools } + ])(ctx)), inputs: [], outputs: [] }; diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts index 3fff2f74a6b0..c96a83372440 100644 --- a/packages/global/core/workflow/type/node.ts +++ b/packages/global/core/workflow/type/node.ts @@ -228,8 +228,10 @@ export const FlowNodeTemplateTypeSchema = FlowNodeCommonTypeSchema.extend({ }); export type FlowNodeTemplateType = z.infer; -/** 模板快捷添加的上下文:快捷面板触发时的源节点信息;侧边栏无上下文,传 null。 */ +/** 模板快捷添加和侧边栏过滤使用的画布上下文。 */ export type NodeTemplateContext = { + /** 是否来自画布左侧节点侧边栏,而不是快捷添加/连线上下文。 */ + isSidebar: boolean; sourceNodeId: string | null; sourceType: FlowNodeTypeEnum | null; sourceIsTool: boolean; @@ -238,6 +240,10 @@ export type NodeTemplateContext = { handleId: string | null; /** 源节点所在容器(loopRun/parallelRun 等)的节点类型。 */ parentType: FlowNodeTypeEnum | null; + /** 画布上是否已有工具调用节点。 */ + hasToolNode: boolean; + /** 画布上是否已有循环执行节点。 */ + hasLoopRunNode: boolean; }; export type NodeTemplateContextPredicate = (ctx: NodeTemplateContext | null) => boolean; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx index 8da92770c4f7..14a2147c7258 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx @@ -3,6 +3,7 @@ import { type Node } from 'reactflow'; import NodeTemplateListHeader from './components/NodeTemplates/header'; import NodeTemplateList from './components/NodeTemplates/list'; import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates'; +import { buildNodeTemplateContext } from '@fastgpt/global/core/workflow/template/context'; import { useMemoizedFn } from 'ahooks'; import React from 'react'; import { useContextSelector } from 'use-context-selector'; @@ -18,7 +19,10 @@ type ModuleTemplateListProps = { export const sliderWidth = 460; const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { - const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes); + const { setNodes, edges, getNodeById, hasToolNode, hasLoopRunNode } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); const onRefreshSingleNodeWorkflowCheckIssues = useContextSelector( WorkflowActionsContext, (v) => v.onRefreshSingleNodeWorkflowCheckIssues @@ -37,7 +41,20 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { selectedTagIds, setSelectedTagIds, toolTags - } = useNodeTemplates(); + } = useNodeTemplates( + React.useMemo( + () => + buildNodeTemplateContext({ + sourceNode: undefined, + edges, + getNodeById, + isSidebar: true, + hasToolNode, + hasLoopRunNode + }), + [edges, getNodeById, hasToolNode, hasLoopRunNode] + ) + ); const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node[] }) => { setNodes((state) => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx index 7c5a6d99da00..1aad23cb402a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx @@ -25,10 +25,8 @@ const NodeTemplatesPopover = () => { const { handleParams, setHandleParams } = useContextSelector(WorkflowModalContext, (v) => v); const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes); - const { edges, setNodes, setEdges, workflowStartNode, getNodeById } = useContextSelector( - WorkflowBufferDataContext, - (v) => v - ); + const { edges, setNodes, setEdges, workflowStartNode, getNodeById, hasToolNode, hasLoopRunNode } = + useContextSelector(WorkflowBufferDataContext, (v) => v); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onRefreshSingleNodeWorkflowCheckIssues = useContextSelector( WorkflowActionsContext, @@ -41,9 +39,11 @@ const NodeTemplatesPopover = () => { sourceNode: handleParams?.nodeId ? getNodeById(handleParams.nodeId) : undefined, edges, handleId: handleParams?.handleId, - getNodeById + getNodeById, + hasToolNode, + hasLoopRunNode }), - [handleParams, edges, getNodeById] + [handleParams, edges, getNodeById, hasToolNode, hasLoopRunNode] ); const { @@ -62,6 +62,18 @@ const NodeTemplatesPopover = () => { } = useNodeTemplates(nodeTemplateContext); const onAddNode = useMemoizedFn(async ({ newNodes }: { newNodes: Node[] }) => { + const isToolHandle = handleParams?.handleId === 'selectedTools'; + const validNewNodes = newNodes.filter((node) => { + if (!isToolHandle && node.data.flowNodeType === FlowNodeTypeEnum.toolSet) return false; + if (isToolHandle && !node.data.isTool) return false; + return true; + }); + + if (validNewNodes.length === 0) { + setHandleParams(null); + return; + } + setNodes((state) => { const newState = state .map((node) => ({ @@ -69,33 +81,14 @@ const NodeTemplatesPopover = () => { selected: false })) // @ts-ignore - .concat(newNodes); + .concat(validNewNodes); return newState; }); if (!handleParams) return; - const isToolHandle = handleParams?.handleId === 'selectedTools'; - const newEdges = newNodes - .filter((node) => { - // Exclude nodes that don't meet the conditions - // 1. Tool set nodes must be connected through tool handle - if (!isToolHandle && node.data.flowNodeType === FlowNodeTypeEnum.toolSet) { - return false; - } - - // 2. Exclude loop start and end nodes - if (isNestedChildSystemNodeType(node.data.flowNodeType)) { - return false; - } - - // 3. Tool handle can only connect to tool nodes - if (isToolHandle && !node.data.isTool) { - return false; - } - - return true; - }) + const newEdges = validNewNodes + .filter((node) => !isNestedChildSystemNodeType(node.data.flowNodeType)) .map((node) => ({ id: getNanoid(), source: handleParams.nodeId as string, @@ -112,7 +105,7 @@ const NodeTemplatesPopover = () => { if (workflowStartNode) { const patches = collectWorkflowStartInputAutoFillPatches({ - nodes: nodes.concat(newNodes), + nodes: nodes.concat(validNewNodes), edges: edges.concat(newEdges), workflowStartNode }); @@ -125,7 +118,7 @@ const NodeTemplatesPopover = () => { setHandleParams(null); setTimeout(() => { - newNodes.forEach((node) => { + validNewNodes.forEach((node) => { onRefreshSingleNodeWorkflowCheckIssues(node.data.nodeId); }); }, 0); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index adc4e4544f5f..0f229374ec04 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -14,6 +14,8 @@ import { useDebounceEffect } from 'ahooks'; import { AppContext } from '@/pageComponents/app/detail/context'; import { getPluginToolTags } from '@/web/core/plugin/toolTag/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; export const useNodeTemplates = (context: NodeTemplateContext | null = null) => { const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic); @@ -40,6 +42,7 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => if (templateType === TemplateTypeEnum.basic) { return basicNodeTemplates .filter((item) => { + if (item.flowNodeType === FlowNodeTypeEnum.queryExtension) return false; // unique node filter if (item.unique) { const nodeExist = getNodeList().some( @@ -57,7 +60,8 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => templateType: item.templateType, avatar: item.avatar, name: item.name, - intro: item.intro + intro: item.intro, + isTool: item.isTool })); } }, @@ -186,10 +190,14 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => const templates = useMemo(() => { if (templateType === TemplateTypeEnum.basic) { - return basicNodes || []; + return (basicNodes || []).filter((item) => + context?.handleId === NodeOutputKeyEnum.selectedTools ? item.isTool === true : true + ); } - return teamAndSystemTools || []; - }, [basicNodes, teamAndSystemTools, templateType]); + return (teamAndSystemTools || []).filter((item) => + context?.handleId === NodeOutputKeyEnum.selectedTools ? item.isTool === true : true + ); + }, [basicNodes, teamAndSystemTools, templateType, context?.handleId]); return { templateType, diff --git a/projects/app/test/web/core/app/workflow/templateContext.test.ts b/projects/app/test/web/core/app/workflow/templateContext.test.ts index 745a333f31c0..efd641be4549 100644 --- a/projects/app/test/web/core/app/workflow/templateContext.test.ts +++ b/projects/app/test/web/core/app/workflow/templateContext.test.ts @@ -18,12 +18,15 @@ import { UserSelectNode } from '@fastgpt/global/core/workflow/template/system/in import type { NodeTemplateContext } from '@fastgpt/global/core/workflow/type/node'; const ctx = (patch: Partial): NodeTemplateContext => ({ + isSidebar: false, sourceNodeId: 'n1', sourceType: null, sourceIsTool: false, isConnectedTool: false, handleId: null, parentType: null, + hasToolNode: false, + hasLoopRunNode: false, ...patch }); @@ -57,12 +60,15 @@ describe('template context', () => { getNodeById: (id) => (id === 'loop1' ? (loopNode as any) : undefined) }); expect(result).toEqual({ + isSidebar: false, sourceNodeId: 'n1', sourceType: FlowNodeTypeEnum.toolParams, sourceIsTool: true, isConnectedTool: true, handleId: 'h', - parentType: FlowNodeTypeEnum.loopRun + parentType: FlowNodeTypeEnum.loopRun, + hasToolNode: false, + hasLoopRunNode: false }); }); @@ -132,6 +138,19 @@ describe('template context', () => { ); }); + it('侧边栏按画布状态显示工具参数、工具终止和循环终止', () => { + expect(isTemplateVisible(ToolParamsNode, ctx({ isSidebar: true }))).toBe(false); + expect(isTemplateVisible(ToolParamsNode, ctx({ isSidebar: true, hasToolNode: true }))).toBe( + true + ); + expect(isTemplateVisible(StopToolNode, ctx({ isSidebar: true }))).toBe(false); + expect(isTemplateVisible(StopToolNode, ctx({ isSidebar: true, hasToolNode: true }))).toBe(true); + expect(isTemplateVisible(LoopRunBreakNode, ctx({ isSidebar: true }))).toBe(false); + expect( + isTemplateVisible(LoopRunBreakNode, ctx({ isSidebar: true, hasLoopRunNode: true })) + ).toBe(true); + }); + it('stopTool 仅在已挂载工具节点(工具子流程)可见', () => { expect(isTemplateVisible(StopToolNode, null)).toBe(false); expect(isTemplateVisible(StopToolNode, ctx({ isConnectedTool: true }))).toBe(true); From bcb39eb462b2598abcd32d2165038d7cf4379902 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 12 Aug 2026 12:02:42 +0800 Subject: [PATCH 10/11] refactor(workflow): control tool param precisely Signed-off-by: Nixieboluo --- packages/global/core/app/formEdit/utils.ts | 40 +++++++------- packages/global/core/app/jsonschema.ts | 4 +- packages/global/core/app/tool/runtime.ts | 3 +- .../core/app/tool/workflowTool/utils.ts | 3 +- .../template/system/classifyQuestion/index.ts | 10 ++-- .../template/system/customFeedback.ts | 2 +- .../workflow/template/system/ifElse/index.ts | 1 - .../template/system/loopRun/loopRun.ts | 3 -- .../system/parallelRun/parallelRun.ts | 3 -- .../template/system/readFiles/index.tsx | 3 +- .../workflow/template/system/sandbox/index.ts | 3 -- .../workflow/template/system/textEditor.ts | 1 - .../core/workflow/template/system/toolCall.ts | 17 ++++-- packages/global/core/workflow/type/io.ts | 10 ++-- packages/global/core/workflow/utils.ts | 3 +- .../global/test/core/app/tool/runtime.test.ts | 52 +++++++++++++++++-- .../detail/Edit/component/ConfigToolModal.tsx | 2 +- .../context/workflowUtilsContext.tsx | 2 +- .../context/workflowUtilsContext.test.ts | 39 +++++++------- .../app/workflow/toolNodeTemplates.test.ts | 36 +++++++------ 20 files changed, 148 insertions(+), 89 deletions(-) diff --git a/packages/global/core/app/formEdit/utils.ts b/packages/global/core/app/formEdit/utils.ts index cc2561f70b20..1a8b566216e5 100644 --- a/packages/global/core/app/formEdit/utils.ts +++ b/packages/global/core/app/formEdit/utils.ts @@ -57,13 +57,19 @@ type InputRenderTypeState = { }; type SavedToolInputTypeState = InputRenderTypeState & - Pick, 'isToolParam' | 'toolDescription'>; + Pick, 'isToolParam' | 'defaultAgentGenerated' | 'toolDescription'>; type ToolInputTypeState = InputRenderTypeState & Pick & Pick< Partial, - 'isToolParam' | 'toolDescription' | 'list' | 'enums' | 'enum' | 'valueType' + | 'isToolParam' + | 'defaultAgentGenerated' + | 'toolDescription' + | 'list' + | 'enums' + | 'enum' + | 'valueType' >; type ToolInputDefaultModeOptions = { @@ -101,12 +107,11 @@ export const isAgentGeneratedToolInput = (input: InputRenderTypeState) => * 服务端 runtime schema 的安全边界:即使持久化数据被篡改,也只允许普通可生成字段进入模型 schema。 */ export const canInputBeAgentGenerated = ( - input: Pick & { + input: Pick & { renderTypeList?: FlowNodeInputItemType['renderTypeList']; } ) => { if (input.key === NodeInputKeyEnum.systemInputConfig) return false; - if (input.allowAgentGenerated === false) return false; if (!Array.isArray(input.renderTypeList)) return false; return !input.renderTypeList.some((type) => agentGeneratedDenyRenderTypes.has(type)); }; @@ -132,7 +137,8 @@ export const normalizeLegacyWorkflowHttpToolInputsDefaultMode = ( @@ -161,17 +167,15 @@ export const normalizeFlowNodeInputType = ( : inputRenderTypeList[input.selectedTypeIndex]; const hasExplicitSelectedType = input.selectedType !== undefined; const recommendsAgentGenerated = - input.isToolParam === true || - (input.isToolParam !== false && isTool && input.key === NodeInputKeyEnum.userChatInput) || - (allowLegacyToolDescriptionFallback && - input.isToolParam === undefined && - !!input.toolDescription); + input.defaultAgentGenerated === true || + (allowLegacyToolDescriptionFallback && input.isToolParam === true && !!input.toolDescription); const isLegacyDefaultSelection = !hasExplicitSelectedType && input.selectedTypeIndex === 0 && (isTool || deferDefaultSelection) && recommendsAgentGenerated; - const supportsAgentGenerated = canInputBeAgentGenerated(input); + const supportsAgentGenerated = + input.isToolParam === true && input.canEdit !== true && canInputBeAgentGenerated(input); const canUseAgentGenerated = isTool && supportsAgentGenerated; const renderTypeList = Array.from( new Set([ @@ -323,7 +327,7 @@ export const getToolInputDisplayRenderTypeList = ({ input: FlowNodeInputItemType; showAgentGenerated: boolean; }) => { - if (!(showAgentGenerated && canInputBeAgentGenerated(input))) { + if (!(showAgentGenerated && input.isToolParam === true && canInputBeAgentGenerated(input))) { return Array.from( new Set(input.renderTypeList.filter((type) => type !== FlowNodeInputTypeEnum.agentGenerated)) ); @@ -483,13 +487,13 @@ export const getSavedToolInputSelectedType = ({ }; /** - * 删除工具定义携带的默认输入方式,避免把它当成用户在配置页的最终选择持久化。 + * 删除工具定义携带的默认 AI 生成方式,避免把它当成用户在配置页的最终选择持久化。 */ export const stripToolInputDefaultMode = ( input: T -): Omit => { +): Omit => { const inputWithoutDefaultMode = { ...input }; - delete inputWithoutDefaultMode.isToolParam; + delete inputWithoutDefaultMode.defaultAgentGenerated; return inputWithoutDefaultMode; }; @@ -539,7 +543,7 @@ export const filterToolConfiguredParams = ({ /** * 工具首次加入工作流/Agent 时,将默认输入方式固化为 selectedType。 - * isToolParam 是插件/schema 声明的默认输入方式;toolDescription 只作为模型参数描述。 + * isToolParam 声明输入是否属于工具参数;defaultAgentGenerated 只决定首次默认来源。 */ export const initToolInputTypeByDefaultMode = ( input: T, diff --git a/packages/global/core/app/jsonschema.ts b/packages/global/core/app/jsonschema.ts index d76fd87d6f46..ec024d1326b0 100644 --- a/packages/global/core/app/jsonschema.ts +++ b/packages/global/core/app/jsonschema.ts @@ -159,7 +159,7 @@ export const JsonSchemaPropertiesItemSchema = z // 自定义扩展(FastGPT 专用) 'x-tool-description': z.string().optional(), // 工具描述 toolDescription: z.string().optional(), // 工具描述 for System Tool - isToolParam: z.boolean().optional(), // 是否默认作为工具调用参数 + isToolParam: z.boolean().optional(), // 是否作为工具调用参数 isSecret: z.boolean().optional(), // System Tool [JsonSchemaNodeInputMetadataKey]: z.any().optional(), [JsonSchemaNodeOutputMetadataKey]: z.any().optional() @@ -857,7 +857,7 @@ export const nodeInputs2JsonSchema = ({ }; }; -const modelSchemaIgnoredKeys = new Set(['title', 'default']); +const modelSchemaIgnoredKeys = new Set(['title', 'default', 'isToolParam']); const schemaDataKeywords = new Set(['const', 'enum', 'examples']); /** 生成模型 schema 副本时移除不会参与工具调用协议的展示与默认值 annotation。 */ diff --git a/packages/global/core/app/tool/runtime.ts b/packages/global/core/app/tool/runtime.ts index 7b1c04da663c..21bf199d5a71 100644 --- a/packages/global/core/app/tool/runtime.ts +++ b/packages/global/core/app/tool/runtime.ts @@ -48,7 +48,8 @@ const createToolInputDefinitions = ({ jsonSchema?: JSONSchemaInputType; }): ToolInputDefinition[] => inputs.map((input) => { - const canAgentGenerate = canInputBeAgentGenerated(input); + const canAgentGenerate = + input.isToolParam === true && input.canEdit !== true && canInputBeAgentGenerated(input); // reference 在工作流执行前已解析为固定值,不受 Agent 配置页手动控件范围限制。 const canUseFixedBinding = canInputBeManuallyConfigured({ renderTypeList: input.renderTypeList ?? [] }) || diff --git a/packages/global/core/app/tool/workflowTool/utils.ts b/packages/global/core/app/tool/workflowTool/utils.ts index f3be3f86237b..c3a88f898f69 100644 --- a/packages/global/core/app/tool/workflowTool/utils.ts +++ b/packages/global/core/app/tool/workflowTool/utils.ts @@ -36,7 +36,8 @@ export const normalizeWorkflowToolInputDefaultMode = { + it('uses ToolCallNode isToolParam defaults for its model schema', () => { + const toolInputs = ToolCallNode.inputs + .filter((input) => + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.fileUrlList, + NodeInputKeyEnum.userChatInput + ].includes(input.key as NodeInputKeyEnum) + ) + .map((input) => normalizeFlowNodeInputType(input, { isTool: true })); + const compiled = compileToolRuntime({ + toolId: 'agent', + name: 'Agent', + inputs: toolInputs + }); + + expect(compiled.agentGeneratedKeys).toEqual([ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.fileUrlList, + NodeInputKeyEnum.userChatInput + ]); + + const manualInputs = toolInputs.map((input) => ({ + ...input, + selectedType: input.renderTypeList.find( + (type) => type !== FlowNodeInputTypeEnum.agentGenerated + ) + })); + expect( + compileToolRuntime({ toolId: 'agent', name: 'Agent', inputs: manualInputs }) + .agentGeneratedKeys + ).toEqual([]); + }); + it('separates model parameters from configured values and defaults', () => { const compiled = compileToolRuntime({ toolId: 'search', @@ -21,6 +59,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.string, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, required: true }, { @@ -29,6 +68,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.number, renderTypeList: [FlowNodeInputTypeEnum.numberInput], selectedType: FlowNodeInputTypeEnum.numberInput, + isToolParam: true, defaultValue: 5, required: true }, @@ -76,6 +116,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.string, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference], selectedType: FlowNodeInputTypeEnum.reference, + isToolParam: true, value: ['workflowStart', 'userChatInput'] }, { @@ -83,7 +124,8 @@ describe('compileToolRuntime', () => { label: 'var_ref2', valueType: WorkflowIOValueTypeEnum.string, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference], - selectedType: FlowNodeInputTypeEnum.agentGenerated + selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true } ] }); @@ -117,6 +159,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.any, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], selectedType: FlowNodeInputTypeEnum.input, + isToolParam: true, value: 'persisted-manual-value' } ] @@ -135,7 +178,8 @@ describe('compileToolRuntime', () => { key: 'filter', valueType: WorkflowIOValueTypeEnum.object, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], - selectedType: FlowNodeInputTypeEnum.agentGenerated + selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true } ], jsonSchema: { @@ -183,6 +227,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.string, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, defaultValue: 'fallback' }, { @@ -190,6 +235,7 @@ describe('compileToolRuntime', () => { valueType: WorkflowIOValueTypeEnum.number, renderTypeList: [FlowNodeInputTypeEnum.numberInput], selectedType: FlowNodeInputTypeEnum.numberInput, + isToolParam: true, defaultValue: 5 } ] diff --git a/projects/app/src/pageComponents/app/detail/Edit/component/ConfigToolModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/component/ConfigToolModal.tsx index 1c26096aef72..c066b11c03f7 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/component/ConfigToolModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/component/ConfigToolModal.tsx @@ -542,7 +542,7 @@ const ConfigInputRow = ({ control: Control>; }) => { const { t } = useSafeTranslation(); - const canAgentGenerated = canInputBeAgentGenerated(input); + const canAgentGenerated = input.isToolParam === true && canInputBeAgentGenerated(input); const canManuallyConfigure = canInputBeManuallyConfigured(input); const developerInputType = canManuallyConfigure ? getToolInputManualRenderType(input) : undefined; const selectableRenderTypeList = canAgentGenerated diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx index 01abbc2e3984..e3caea15deab 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.tsx @@ -84,7 +84,7 @@ export const splitToolInputsByMode = (inputs: FlowNodeInputItemType[], isTool: b const normalizedInput = normalizeFlowNodeInputType(item, { isTool }); const isToolParamInput = item.canEdit === true && item.isToolParam === true; - if (isTool && isToolParamInput && canInputBeAgentGenerated(normalizedInput)) { + if (isTool && isToolParamInput) { toolInputs.push(item); return; } diff --git a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts index 5b3c3d01c169..9c067b799dfd 100644 --- a/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts +++ b/projects/app/test/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { splitToolInputsByMode } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowUtilsContext'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { ClassifyQuestionModule } from '@fastgpt/global/core/workflow/template/system/classifyQuestion'; import { ToolCallNode } from '@fastgpt/global/core/workflow/template/system/toolCall'; describe('splitToolInputsByMode', () => { @@ -43,6 +42,21 @@ describe('splitToolInputsByMode', () => { expect(result.commonInputs).toEqual([]); }); + it('does not expose AI mode for an input that is not a tool parameter', () => { + const input = { + key: 'query', + label: 'query', + renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference] + }; + + const result = splitToolInputsByMode([input], true); + + expect(result.commonInputs[0].renderTypeList).not.toContain( + FlowNodeInputTypeEnum.agentGenerated + ); + expect(result.commonInputs[0].selectedType).toBe(FlowNodeInputTypeEnum.input); + }); + it('keeps unmarked editable inputs as code custom variables', () => { const customVariable = { key: 'codeInput', @@ -73,19 +87,11 @@ describe('splitToolInputsByMode', () => { expect(result.commonInputs).toHaveLength(1); }); - it('adds AI-generated mode to fixed inputs when connected as a tool', () => { - const classifyInputs = splitToolInputsByMode(ClassifyQuestionModule.inputs, true).commonInputs; + it('keeps fixed tool inputs in the node and defaults the marked inputs to AI generation', () => { const toolCallInputs = splitToolInputsByMode(ToolCallNode.inputs, true).commonInputs; - [ - ...classifyInputs.filter((input) => - [ - NodeInputKeyEnum.aiSystemPrompt, - NodeInputKeyEnum.history, - NodeInputKeyEnum.userChatInput - ].includes(input.key as NodeInputKeyEnum) - ), - ...toolCallInputs.filter((input) => + toolCallInputs + .filter((input) => [ NodeInputKeyEnum.aiSystemPrompt, NodeInputKeyEnum.history, @@ -93,12 +99,9 @@ describe('splitToolInputsByMode', () => { NodeInputKeyEnum.userChatInput ].includes(input.key as NodeInputKeyEnum) ) - ].forEach((input) => { - expect(input.renderTypeList).toContain(FlowNodeInputTypeEnum.agentGenerated); - }); - expect( - classifyInputs.find((input) => input.key === NodeInputKeyEnum.userChatInput)?.selectedType - ).toBe(FlowNodeInputTypeEnum.agentGenerated); + .forEach((input) => { + expect(input.renderTypeList).toContain(FlowNodeInputTypeEnum.agentGenerated); + }); expect( toolCallInputs.find((input) => input.key === NodeInputKeyEnum.userChatInput)?.selectedType ).toBe(FlowNodeInputTypeEnum.agentGenerated); diff --git a/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts b/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts index 2186e453fe65..7d504e944a6d 100644 --- a/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts +++ b/projects/app/test/web/core/app/workflow/toolNodeTemplates.test.ts @@ -34,29 +34,34 @@ describe('workflow tool node templates', () => { }); it('keeps requested AI-generated defaults explicit', () => { - expect( - ClassifyQuestionModule.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput) - ).toMatchObject({ isToolParam: true }); + [ + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.history, + NodeInputKeyEnum.userChatInput + ].forEach((key) => { + expect(ClassifyQuestionModule.inputs.find((input) => input.key === key)).toMatchObject({ + isToolParam: true, + defaultAgentGenerated: true + }); + }); expect( ReadFilesNode.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList) - ).toMatchObject({ isToolParam: true }); + ).toMatchObject({ isToolParam: true, defaultAgentGenerated: true }); expect( ToolCallNode.inputs.find((input) => input.key === NodeInputKeyEnum.userChatInput) - ).toMatchObject({ isToolParam: true }); + ).toMatchObject({ isToolParam: true, defaultAgentGenerated: true }); expect( - CustomFeedbackNode.inputs.find((input) => input.key === NodeInputKeyEnum.textareaInput) - ).toMatchObject({ isToolParam: false }); + ReadFilesNode.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList) + ).toMatchObject({ isToolParam: true, defaultAgentGenerated: true }); + const customFeedbackInput = CustomFeedbackNode.inputs.find( + (input) => input.key === NodeInputKeyEnum.textareaInput + ); + expect(customFeedbackInput).toMatchObject({ isToolParam: true }); + expect(customFeedbackInput).not.toHaveProperty('defaultAgentGenerated'); }); it('allows AI-generated mode for every requested tool input', () => { const requestedInputs = [ - ...ClassifyQuestionModule.inputs.filter((input) => - [ - NodeInputKeyEnum.aiSystemPrompt, - NodeInputKeyEnum.history, - NodeInputKeyEnum.userChatInput - ].includes(input.key as NodeInputKeyEnum) - ), ...ToolCallNode.inputs.filter((input) => [ NodeInputKeyEnum.aiSystemPrompt, @@ -65,8 +70,7 @@ describe('workflow tool node templates', () => { NodeInputKeyEnum.userChatInput ].includes(input.key as NodeInputKeyEnum) ), - ...ReadFilesNode.inputs.filter((input) => input.key === NodeInputKeyEnum.fileUrlList), - ...CustomFeedbackNode.inputs.filter((input) => input.key === NodeInputKeyEnum.textareaInput) + ...ReadFilesNode.inputs.filter((input) => input.key === NodeInputKeyEnum.fileUrlList) ]; requestedInputs.forEach((input) => { From 3e788ad854be1e8118982863552a3cb6306b8af8 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Wed, 12 Aug 2026 12:11:06 +0800 Subject: [PATCH 11/11] test(workflow): update tool input expectations --- .../test/core/app/formEdit/utils.test.ts | 55 +++++++++++-------- .../global/test/core/app/jsonschema.test.ts | 2 +- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/global/test/core/app/formEdit/utils.test.ts b/packages/global/test/core/app/formEdit/utils.test.ts index 0912c087ca49..91a465bb5983 100644 --- a/packages/global/test/core/app/formEdit/utils.test.ts +++ b/packages/global/test/core/app/formEdit/utils.test.ts @@ -212,6 +212,7 @@ describe('checkNeedsUserConfiguration', () => { createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, toolDescription: 'Tool description' }) ] @@ -462,6 +463,7 @@ describe('checkNeedsUserConfiguration', () => { key: 'input3', renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, toolDescription: 'Has description' }) ] @@ -489,6 +491,7 @@ describe('getToolConfigStatus', () => { createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, toolDescription: 'Tool description' }) ] @@ -854,6 +857,7 @@ describe('getToolConfigStatus', () => { createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input], selectedType: FlowNodeInputTypeEnum.agentGenerated, + isToolParam: true, required: true, toolDescription: 'Has description', value: '' @@ -976,7 +980,6 @@ describe('agent generated tool input helpers', () => { ); expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.numberInput ]); @@ -984,7 +987,7 @@ describe('agent generated tool input helpers', () => { expect(input).not.toHaveProperty('selectedTypeIndex'); }); - it('should add agentGenerated to supported tool inputs and apply isToolParam default', () => { + it('should retain the manual default when no agent-generated default is configured', () => { const input = normalizeFlowNodeInputType( createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.numberInput, FlowNodeInputTypeEnum.reference], @@ -999,7 +1002,7 @@ describe('agent generated tool input helpers', () => { FlowNodeInputTypeEnum.numberInput, FlowNodeInputTypeEnum.reference ]); - expect(input.selectedType).toBe(FlowNodeInputTypeEnum.agentGenerated); + expect(input.selectedType).toBe(FlowNodeInputTypeEnum.numberInput); expect(input).not.toHaveProperty('selectedTypeIndex'); }); @@ -1035,6 +1038,7 @@ describe('agent generated tool input helpers', () => { it('should only use toolDescription when the caller enables legacy fallback', () => { const legacyInput = createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], + isToolParam: true, toolDescription: 'Legacy model description' }); @@ -1078,10 +1082,7 @@ describe('agent generated tool input helpers', () => { { isTool: true } ); - expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, - FlowNodeInputTypeEnum.reference - ]); + expect(input.renderTypeList).toEqual([FlowNodeInputTypeEnum.reference]); expect(input.selectedType).toBe(FlowNodeInputTypeEnum.reference); }); @@ -1094,10 +1095,7 @@ describe('agent generated tool input helpers', () => { }) ); - expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, - FlowNodeInputTypeEnum.reference - ]); + expect(input.renderTypeList).toEqual([FlowNodeInputTypeEnum.reference]); expect(input.selectedType).toBe(FlowNodeInputTypeEnum.agentGenerated); expect(isAgentGeneratedToolInput(input)).toBe(true); }); @@ -1122,7 +1120,8 @@ describe('agent generated tool input helpers', () => { createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], toolDescription: 'Prompt to model', - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }) ); @@ -1142,7 +1141,8 @@ describe('agent generated tool input helpers', () => { renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference], selectedType: FlowNodeInputTypeEnum.input, selectedTypeIndex: 0, - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }), { forceDefaultMode: true } ); @@ -1152,11 +1152,12 @@ describe('agent generated tool input helpers', () => { expect(isAgentGeneratedToolInput(input)).toBe(true); }); - it('should remove isToolParam from a persisted tool input', () => { - const input = createMockInput({ isToolParam: true }); + it('should remove only the default mode from a persisted tool input', () => { + const input = createMockInput({ isToolParam: true, defaultAgentGenerated: true }); const persistedInput = stripToolInputDefaultMode(input); - expect(persistedInput).not.toHaveProperty('isToolParam'); + expect(persistedInput).not.toHaveProperty('defaultAgentGenerated'); + expect(persistedInput.isToolParam).toBe(true); expect(input.isToolParam).toBe(true); }); @@ -1169,7 +1170,6 @@ describe('agent generated tool input helpers', () => { ); expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference ]); @@ -1186,7 +1186,6 @@ describe('agent generated tool input helpers', () => { ); expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference ]); @@ -1199,7 +1198,8 @@ describe('agent generated tool input helpers', () => { key: NodeInputKeyEnum.userChatInput, renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea], toolDescription: 'User question', - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }) ); @@ -1218,7 +1218,8 @@ describe('agent generated tool input helpers', () => { key: NodeInputKeyEnum.userChatInput, renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea], toolDescription: 'User question', - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }), { allowUserChatInputAgentGenerated: true } ); @@ -1244,7 +1245,6 @@ describe('agent generated tool input helpers', () => { ); expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea ]); @@ -1284,7 +1284,6 @@ describe('agent generated tool input helpers', () => { ); expect(input.renderTypeList).toEqual([ - FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea ]); @@ -1472,6 +1471,7 @@ describe('agent generated tool input helpers', () => { const manualType = getToolInputManualRenderType( createMockInput({ valueType: WorkflowIOValueTypeEnum.string, + isToolParam: true, renderTypeList: [ FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference, @@ -1536,6 +1536,7 @@ describe('agent generated tool input helpers', () => { const renderTypeList = getToolInputDisplayRenderTypeList({ input: createMockInput({ valueType: WorkflowIOValueTypeEnum.string, + isToolParam: true, renderTypeList: [ FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.agentGenerated, @@ -1556,6 +1557,7 @@ describe('agent generated tool input helpers', () => { it('should keep reference available for reference-only workflow tool inputs', () => { const renderTypeList = getToolInputDisplayRenderTypeList({ input: createMockInput({ + isToolParam: true, renderTypeList: [FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.reference] }), showAgentGenerated: true @@ -1580,6 +1582,7 @@ describe('agent generated tool input helpers', () => { const renderTypeList = getToolInputDisplayRenderTypeList({ input: createMockInput({ valueType, + isToolParam: true, renderTypeList: [ FlowNodeInputTypeEnum.agentGenerated, FlowNodeInputTypeEnum.input, @@ -1602,6 +1605,7 @@ describe('agent generated tool input helpers', () => { const renderTypeList = getToolInputDisplayRenderTypeList({ input: createMockInput({ valueType: WorkflowIOValueTypeEnum.arrayString, + isToolParam: true, list: [{ label: 'A', value: 'a' }], renderTypeList: [ FlowNodeInputTypeEnum.agentGenerated, @@ -1627,7 +1631,8 @@ describe('agent generated tool input helpers', () => { renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.agentGenerated], selectedTypeIndex: 0, toolDescription: 'Prompt to model', - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }) ); @@ -1647,6 +1652,7 @@ describe('agent generated tool input helpers', () => { renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea], selectedTypeIndex: 0, isToolParam: true, + defaultAgentGenerated: true, required: true }), { allowUserChatInputAgentGenerated: true } @@ -1677,7 +1683,8 @@ describe('agent generated tool input helpers', () => { createMockInput({ renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.agentGenerated], toolDescription: 'Prompt to model', - isToolParam: true + isToolParam: true, + defaultAgentGenerated: true }) ); diff --git a/packages/global/test/core/app/jsonschema.test.ts b/packages/global/test/core/app/jsonschema.test.ts index f8dfdbf8b546..e3615dba62db 100644 --- a/packages/global/test/core/app/jsonschema.test.ts +++ b/packages/global/test/core/app/jsonschema.test.ts @@ -922,7 +922,7 @@ describe('nodeInputs2JsonSchema', () => { expect(result.properties).toEqual({ query: { type: 'string' }, - schemaOnly: { type: 'string', isToolParam: true } + schemaOnly: { type: 'string' } }); expect(result.required).toEqual(['query', 'schemaOnly']); });