Install any skill in seconds. Free to start, no credit card required.
Get Started Free →用于开发 FastGPT 工作流中的交互响应。详细说明了交互节点的架构、开发流程和需要修改的文件。
.claude/skills/microck-workflow-interactive-dev/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 110% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 161% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 565% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 175% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 219% | 0% |
FastGPT 工作流支持多种交互节点类型,允许在工作流执行过程中暂停并等待用户输入。本指南详细说明了如何开发新的交互节点。
当前系统支持以下交互节点类型:
交互节点的类型定义位于 packages/global/core/workflow/template/system/interactive/type.d.ts
typescript// 基础交互结构 type InteractiveBasicType = { entryNodeIds: string[]; // 入口节点ID列表 memoryEdges: RuntimeEdgeItemType[]; // 需要记忆的边 nodeOutputs: NodeOutputItemType[]; // 节点输出 skipNodeQueue?: Array; // 跳过的节点队列 usageId?: string; // 用量记录ID }; // 具体交互节点类型 type YourInteractiveNode = InteractiveNodeType & { type: 'yourNodeType'; params: { // 节点特定参数 }; };
交互节点在工作流执行中的特殊处理(位于 packages/service/core/workflow/dispatch/index.ts:1012-1019):
typescript// 部分交互节点不会自动重置 isEntry 标志(因为需要根据 isEntry 字段来判断是首次进入还是流程进入) runtimeNodes.forEach((item) => { if ( item.flowNodeType !== FlowNodeTypeEnum.userSelect && item.flowNodeType !== FlowNodeTypeEnum.formInput && item.flowNodeType !== FlowNodeTypeEnum.agent ) { item.isEntry = false; } });
文件: packages/global/core/workflow/template/system/interactive/type.d.ts
typescriptexport type YourInputItemType = { // 定义输入项的结构 key: string; label: string; value: any; // ... 其他字段 }; type YourInteractiveNode = InteractiveNodeType & { type: 'yourNodeType'; params: { description: string; yourInputField: YourInputItemType[]; submitted?: boolean; // 可选:是否已提交 }; }; // 添加到联合类型 export type InteractiveNodeResponseType = | UserSelectInteractive | UserInputInteractive | YourInteractiveNode // 新增 | ChildrenInteractive | LoopInteractive | PaymentPauseInteractive;
文件: packages/global/core/workflow/node/constant.ts
如果不需要添加新的节点类型,则不需要修改这个文件。
typescriptexport enum FlowNodeTypeEnum { // ... 现有类型 yourNodeType = 'yourNodeType', // 新增节点类型 }
文件: packages/global/core/workflow/template/system/interactive/yourNode.ts
typescriptimport { i18nT } from '../../../../../../web/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; import { FlowNodeInputTypeEnum, FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '../../../node/constant'; import { type FlowNodeTemplateType } from '../../../type/node'; export const YourNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.yourNodeType, templateType: FlowNodeTemplateTypeEnum.interactive, flowNodeType: FlowNodeTypeEnum.yourNodeType, showSourceHandle: true, // 是否显示源连接点 showTargetHandle: true, // 是否显示目标连接点 avatar: 'core/workflow/template/yourNode', name: i18nT('app:workflow.your_node'), intro: i18nT('app:workflow.your_node_tip'), isTool: true, // 标记为工具节点 inputs: [ { key: NodeInputKeyEnum.description, renderTypeList: [FlowNodeInputTypeEnum.textarea], valueType: WorkflowIOValueTypeEnum.string, label: i18nT('app:workflow.node_description'), placeholder: i18nT('app:workflow.your_node_placeholder') }, { key: NodeInputKeyEnum.yourInputField, renderTypeList: [FlowNodeInputTypeEnum.custom], valueType: WorkflowIOValueTypeEnum.any, label: '', value: [] // 默认值 } ], outputs: [ { id: NodeOutputKeyEnum.yourResult, key: NodeOutputKeyEnum.yourResult, required: true, label: i18nT('workflow:your_result'), valueType: WorkflowIOValueTypeEnum.object, type: FlowNodeOutputTypeEnum.static } ] };
文件: packages/service/core/workflow/dispatch/interactive/yourNode.ts
typescriptimport { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { DispatchNodeResultType, ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { YourInputItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.description]: string; [NodeInputKeyEnum.yourInputField]: YourInputItemType[]; }>; type YourNodeResponse = DispatchNodeResultType<{ [NodeOutputKeyEnum.yourResult]?: Record<string, any>; }>; export const dispatchYourNode = async (props: Props): Promise<YourNodeResponse> => { const { histories, node, params: { description, yourInputField }, query, lastInteractive } = props; const { isEntry } = node; // 第一阶段:非入口节点或不是对应的交互类型,返回交互请求 if (!isEntry || lastInteractive?.type !== 'yourNodeType') { return { [DispatchNodeResponseKeyEnum.interactive]: { type: 'yourNodeType', params: { description, yourInputField } } }; } // 第二阶段:处理用户提交的数据 node.isEntry = false; // 重要:重置入口标志 const { text } = chatValue2RuntimePrompt(query); const userInputVal = (() => { try { return JSON.parse(text); // 根据实际格式解析 } catch (error) { return {}; } })(); return { data: { [NodeOutputKeyEnum.yourResult]: userInputVal }, // 移除当前交互的历史记录(最后2条) [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), [DispatchNodeResponseKeyEnum.toolResponses]: userInputVal, [DispatchNodeResponseKeyEnum.nodeResponse]: { yourResult: userInputVal } }; };
文件: packages/service/core/workflow/dispatch/constants.ts
typescriptimport { dispatchYourNode } from './interactive/yourNode'; export const callbackMap: Record<FlowNodeTypeEnum, any> = { // ... 现有节点 [FlowNodeTypeEnum.yourNodeType]: dispatchYourNode, };
文件: projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx
typescriptexport const YourNodeComponent = React.memo(function YourNodeComponent({ interactiveParams: { description, yourInputField, submitted }, defaultValues = {}, SubmitButton }: { interactiveParams: YourInteractiveNode['params']; defaultValues?: Record<string, any>; SubmitButton: (e: { onSubmit: UseFormHandleSubmit<Record<string, any>> }) => React.JSX.Element; }) { const { handleSubmit, control } = useForm({ defaultValues }); return ( <Box> <DescriptionBox description={description} /> <Flex flexDirection={'column'} gap={3}> {yourInputField.map((input) => ( <Box key={input.key}> {/* 渲染你的输入组件 */} <Controller control={control} name={input.key} render={({ field: { onChange, value } }) => ( <YourInputComponent value={value} onChange={onChange} isDisabled={submitted} /> )} /> </Box> ))} </Flex> {!submitted && ( <Flex justifyContent={'flex-end'} mt={4}> <SubmitButton onSubmit={handleSubmit} /> </Flex> )} </Box> ); });
文件: projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeYourNode.tsx
typescriptimport React, { useMemo } from 'react'; import { type NodeProps } from 'reactflow'; import { Box, Button } from '@chakra-ui/react'; import NodeCard from './render/NodeCard'; import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d'; import Container from '../components/Container'; import RenderInput from './render/RenderInput'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { useTranslation } from 'next-i18next'; import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d'; import { useContextSelector } from 'use-context-selector'; import IOTitle from '../components/IOTitle'; import RenderOutput from './render/RenderOutput'; import { WorkflowActionsContext } from '../../context/workflowActionsContext'; const NodeYourNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => { const { t } = useTranslation(); const { nodeId, inputs, outputs } = data; const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const CustomComponent = useMemo( () => ({ [NodeInputKeyEnum.yourInputField]: (v: FlowNodeInputItemType) => { // 自定义渲染逻辑 return ( <Box> {/* 你的自定义UI */} </Box> ); } }), [nodeId, onChangeNode, t] ); return ( <NodeCard minW={'400px'} selected={selected} {...data}> <Container> <RenderInput nodeId={nodeId} flowInputList={inputs} CustomComponent={CustomComponent} /> </Container> <Container> <IOTitle text={t('common:Output')} /> <RenderOutput nodeId={nodeId} flowOutputList={outputs} /> </Container> </NodeCard> ); }; export default React.memo(NodeYourNode);
需要在节点注册表中添加你的节点组件(具体位置根据项目配置而定)。
文件: packages/web/i18n/zh-CN/app.json 和其他语言文件
json{ "workflow": { "your_node": "你的节点名称", "your_node_tip": "节点功能说明", "your_node_placeholder": "提示文本" } }
文件: FastGPT/packages/service/core/chat/saveChat.ts
修改 updateInteractiveChat 方法,支持新的交互
文件: FastGPT/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts 文件: FastGPT/packages/global/core/workflow/runtime/utils.ts
调整setInteractiveResultToHistories, getInteractiveByHistories 和 getLastInteractiveValue方法。
交互节点需要保持 isEntry 标志在工作流恢复时有效:
typescript// 在 packages/service/core/workflow/dispatch/index.ts 中 // 确保你的节点类型被添加到白名单 if ( item.flowNodeType !== FlowNodeTypeEnum.userSelect && item.flowNodeType !== FlowNodeTypeEnum.formInput && item.flowNodeType !== FlowNodeTypeEnum.yourNodeType // 新增 ) { item.isEntry = false; }
交互节点有两个执行阶段:
interactive 响应,暂停工作流typescript// 第一阶段 if (!isEntry || lastInteractive?.type !== 'yourNodeType') { return { [DispatchNodeResponseKeyEnum.interactive]: { type: 'yourNodeType', params: { /* ... */ } } }; } // 第二阶段 node.isEntry = false; // 重要!重置标志 // 处理用户输入...
交互节点需要正确处理历史记录:
typescriptreturn { // 移除交互对话的历史记录(用户问题 + 系统响应) [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), // ... 其他返回值 };
交互节点触发时,系统会保存 skipNodeQueue 以便恢复时跳过已处理的节点。
如果节点需要在工具调用中使用,设置 isTool: true。
开发完成后,请测试以下场景:
可以参考以下现有实现:
userSelect 节点packages/global/core/workflow/template/system/interactive/type.d.ts:48-55packages/service/core/workflow/dispatch/interactive/userSelect.tsprojects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx:29-63formInput 节点packages/global/core/workflow/template/system/interactive/type.d.ts:57-82packages/service/core/workflow/dispatch/interactive/formInput.tsprojects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx:65-126A: 这是正常的。第一次返回交互请求,第二次处理用户输入。确保在第二次执行时设置 node.isEntry = false。
A: 检查你的节点类型是否在 isEntry 白名单中(dispatch/index.ts:1013-1018)。
A: 检查 chatValue2RuntimePrompt 的返回值,根据你的数据格式进行解析。
A: 每个交互节点都会暂停工作流,用户完成后会自动继续到下一个节点。
开发新交互节点需要修改/创建以下文件:
packages/global/core/workflow/template/system/interactive/type.d.ts - 类型定义packages/global/core/workflow/node/constant.ts - 节点枚举packages/global/core/workflow/template/system/interactive/yourNode.ts - 节点模板packages/service/core/workflow/dispatch/interactive/yourNode.ts - 执行逻辑packages/service/core/workflow/dispatch/constants.ts - 回调注册packages/service/core/workflow/dispatch/index.ts - isEntry 白名单projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx - 聊天交互组件projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeYourNode.tsx - 工作流编辑器组件packages/web/i18n/zh-CN/app.json - 中文翻译packages/web/i18n/en/app.json - 英文翻译packages/web/i18n/zh-Hant/app.json - 繁体中文翻译如果需要新的输入输出键,在以下文件中定义:
文件: packages/global/core/workflow/constants.ts
typescriptexport enum NodeInputKeyEnum { // ... 现有键 yourInputKey = 'yourInputKey', } export enum NodeOutputKeyEnum { // ... 现有键 yourOutputKey = 'yourOutputKey', }
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 24,687 | 22,599 | -8% | 1 | 1 | 0% | 4,631 | 9,707 | +110% | 0 | 0 | — |
case-02 | fail→pass | 18,197 | 17,618 | -3% | 1 | 1 | 0% | 2,865 | 7,473 | +161% | 0 | 0 | — |
case-03 | fail→pass | 35,423 | 14,421 | -59% | 1 | 1 | 0% | 1,120 | 7,443 | +565% | 0 | 0 | — |
case-04 | fail→pass | 13,630 | 7,859 | -42% | 1 | 1 | 0% | 2,121 | 5,838 | +175% | 0 | 0 | — |
case-05 | fail→pass | 12,047 | 8,843 | -27% | 1 | 1 | 0% | 1,866 | 5,954 | +219% | 0 | 0 | — |
case-06 | fail→pass | 10,931 | 5,219 | -52% | 1 | 1 | 0% | 1,650 | 5,257 | +219% | 0 | 0 | — |
case-07 | fail→pass | 16,263 | 10,224 | -37% | 1 | 1 | 0% | 2,391 | 6,186 | +159% | 0 | 0 | — |
case-08 | fail→fail | 14,091 | 11,064 | -21% | 1 | 1 | 0% | 2,063 | 6,263 | +204% | 0 | 0 | — |
case-09 | fail→pass | 11,425 | 7,771 | -32% | 1 | 1 | 0% | 1,818 | 5,658 | +211% | 0 | 0 | — |
case-10 | fail→pass | 10,420 | 2,933 | -72% | 1 | 1 | 0% | 1,615 | 4,920 | +205% | 0 | 0 | — |
case-11 | fail→pass | 12,218 | 9,568 | -22% | 1 | 1 | 0% | 2,046 | 6,158 | +201% | 0 | 0 | — |
case-12 | fail→pass | 13,594 | 7,963 | -41% | 1 | 1 | 0% | 2,271 | 5,759 | +154% | 0 | 0 | — |
case-13 | fail→pass | 10,833 | 2,877 | -73% | 1 | 1 | 0% | 1,563 | 4,824 | +209% | 0 | 0 | — |
case-14 | fail→pass | 13,370 | 4,151 | -69% | 1 | 1 | 0% | 2,176 | 5,101 | +134% | 0 | 0 | — |
case-15 | fail→fail | 8,741 | 4,533 | -48% | 1 | 1 | 0% | 1,481 | 5,224 | +253% | 0 | 0 | — |
case-16 | fail→pass | 16,936 | 9,043 | -47% | 1 | 1 | 0% | 2,891 | 6,144 | +113% | 0 | 0 | — |
case-17 | fail→pass | 13,721 | 8,460 | -38% | 1 | 1 | 0% | 2,144 | 5,898 | +175% | 0 | 0 | — |
case-18 | pass→pass | 10,166 | 3,899 | -62% | 1 | 1 | 0% | 1,687 | 5,177 | +207% | 0 | 0 | — |
case-19 | pass→pass | 6,566 | 3,150 | -52% | 1 | 1 | 0% | 911 | 4,968 | +445% | 0 | 0 | — |
case-20 | pass→fail | 11,906 | 12,361 | +4% | 1 | 1 | 0% | 1,725 | 6,293 | +265% | 0 | 0 | — |
case-21 | pass→pass | 14,863 | 4,382 | -71% | 1 | 1 | 0% | 2,303 | 5,272 | +129% | 0 | 0 | — |
case-22 | pass→pass | 12,192 | 13,038 | +7% | 1 | 1 | 0% | 1,849 | 6,965 | +277% | 0 | 0 | — |
case-23 | pass→pass | 13,247 | 17,070 | +29% | 1 | 1 | 0% | 2,199 | 7,367 | +235% | 0 | 0 | — |
case-24 | pass→pass | 11,677 | 8,051 | -31% | 1 | 1 | 0% | 1,899 | 5,645 | +197% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted, and 23 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +58 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.