From 1ed746c2361733bf582aa02b1ac3562d855b6d26 Mon Sep 17 00:00:00 2001
From: xxyyh <2289112474@qq.com>
Date: Mon, 20 Jul 2026 17:56:31 +0800
Subject: [PATCH 01/18] feat(workflow): add workflow core and local CLI
---
...00\345\217\221\346\226\207\346\241\243.md" | 1745 +++++++++++++++++
...76\350\256\241\346\226\207\346\241\243.md" | 1614 +++++++++++++++
packages/global/core/workflow/constants.ts | 2 +-
packages/global/core/workflow/type/io.ts | 9 +
packages/global/core/workflow/type/node.ts | 8 +-
packages/global/openapi/core/workflow/node.ts | 9 +-
packages/web/i18n/en/workflow.json | 8 +-
packages/web/i18n/zh-CN/workflow.json | 8 +-
packages/web/i18n/zh-Hant/workflow.json | 8 +-
packages/workflow-cli/package.json | 37 +
packages/workflow-cli/src/cli.ts | 4 +
packages/workflow-cli/src/commands/config.ts | 50 +
.../workflow-cli/src/commands/container.ts | 15 +
.../workflow-cli/src/commands/document.ts | 152 ++
packages/workflow-cli/src/commands/edge.ts | 72 +
packages/workflow-cli/src/commands/helpers.ts | 123 ++
packages/workflow-cli/src/commands/input.ts | 149 ++
packages/workflow-cli/src/commands/meta.ts | 25 +
packages/workflow-cli/src/commands/node.ts | 162 ++
packages/workflow-cli/src/commands/output.ts | 52 +
.../workflow-cli/src/commands/template.ts | 46 +
packages/workflow-cli/src/commands/tool.ts | 54 +
.../workflow-cli/src/commands/validate.ts | 24 +
.../workflow-cli/src/commands/variable.ts | 247 +++
packages/workflow-cli/src/error.ts | 11 +
packages/workflow-cli/src/help.ts | 19 +
packages/workflow-cli/src/i18n.ts | 29 +
packages/workflow-cli/src/index.ts | 9 +
packages/workflow-cli/src/io/workflowFile.ts | 59 +
packages/workflow-cli/src/output/render.ts | 76 +
packages/workflow-cli/src/parser.ts | 124 ++
packages/workflow-cli/src/registry.ts | 954 +++++++++
packages/workflow-cli/src/run.ts | 84 +
packages/workflow-cli/src/type.ts | 41 +
packages/workflow-cli/test/bin-smoke.mjs | 79 +
packages/workflow-cli/test/e2e.test.ts | 1164 +++++++++++
.../workflow-cli/test/io/workflowFile.test.ts | 40 +
packages/workflow-cli/test/output.test.ts | 44 +
packages/workflow-cli/test/parser.test.ts | 52 +
packages/workflow-cli/test/registry.test.ts | 579 ++++++
packages/workflow-cli/tsconfig.json | 10 +
packages/workflow-cli/tsdown.config.ts | 14 +
packages/workflow-cli/vitest.config.ts | 20 +
packages/workflow-core/package.json | 34 +
packages/workflow-core/src/binding/service.ts | 53 +
packages/workflow-core/src/binding/type.ts | 11 +
packages/workflow-core/src/code/io.ts | 278 +++
packages/workflow-core/src/command/apply.ts | 453 +++++
packages/workflow-core/src/command/type.ts | 222 +++
packages/workflow-core/src/config/service.ts | 275 +++
packages/workflow-core/src/domain/checksum.ts | 42 +
.../workflow-core/src/domain/diagnostic.ts | 32 +
packages/workflow-core/src/domain/document.ts | 33 +
packages/workflow-core/src/edge/compiler.ts | 165 ++
packages/workflow-core/src/edge/parser.ts | 51 +
packages/workflow-core/src/edge/service.ts | 214 ++
packages/workflow-core/src/edge/type.ts | 27 +
packages/workflow-core/src/index.ts | 30 +
packages/workflow-core/src/io/service.ts | 348 ++++
packages/workflow-core/src/nesting/service.ts | 166 ++
packages/workflow-core/src/node/add.ts | 155 ++
packages/workflow-core/src/node/service.ts | 189 ++
packages/workflow-core/src/public.ts | 13 +
packages/workflow-core/src/reference/codec.ts | 80 +
.../workflow-core/src/reference/service.ts | 364 ++++
packages/workflow-core/src/reference/type.ts | 24 +
packages/workflow-core/src/store/compile.ts | 12 +
packages/workflow-core/src/store/decompile.ts | 30 +
.../src/template/automationMeta.ts | 277 +++
.../workflow-core/src/template/builtin.ts | 92 +
.../src/template/defaultValue.ts | 70 +
.../workflow-core/src/template/descriptor.ts | 133 ++
.../workflow-core/src/template/instantiate.ts | 160 ++
packages/workflow-core/src/template/type.ts | 75 +
.../workflow-core/src/template/valueSchema.ts | 83 +
.../workflow-core/src/validation/index.ts | 562 ++++++
.../test/binding/service.test.ts | 119 ++
packages/workflow-core/test/code/io.test.ts | 67 +
.../test/command/additionalBuiltins.test.ts | 185 ++
.../workflow-core/test/command/apply.test.ts | 143 ++
.../workflow-core/test/command/codeIo.test.ts | 168 ++
.../workflow-core/test/command/pr2.test.ts | 225 +++
.../workflow-core/test/command/pr3.test.ts | 272 +++
.../test/command/systemConfig.test.ts | 104 +
.../workflow-core/test/config/service.test.ts | 125 ++
.../workflow-core/test/edge/compiler.test.ts | 166 ++
.../workflow-core/test/edge/parser.test.ts | 53 +
.../basic-ai/expected-diagnostics.json | 1 +
.../fixtures/basic-ai/store-workflow.json | 248 +++
.../test/fixtures/basic-ai/workflow.json | 254 +++
.../basic-static/expected-diagnostics.json | 1 +
.../fixtures/basic-static/store-workflow.json | 101 +
.../test/fixtures/basic-static/workflow.json | 111 ++
.../branching/expected-diagnostics.json | 1 +
.../fixtures/branching/store-workflow.json | 143 ++
.../test/fixtures/branching/workflow.json | 159 ++
.../common-linear/expected-diagnostics.json | 1 +
.../common-linear/store-workflow.json | 860 ++++++++
.../test/fixtures/common-linear/workflow.json | 893 +++++++++
.../expected-diagnostics.json | 1 +
.../dynamic-io-catch/store-workflow.json | 255 +++
.../fixtures/dynamic-io-catch/workflow.json | 265 +++
.../nested-loop/expected-diagnostics.json | 1 +
.../fixtures/nested-loop/store-workflow.json | 229 +++
.../test/fixtures/nested-loop/workflow.json | 239 +++
packages/workflow-core/test/fixtures/pr3.ts | 195 ++
.../tool-call-tools/expected-diagnostics.json | 1 +
.../tool-call-tools/store-workflow.json | 265 +++
.../fixtures/tool-call-tools/workflow.json | 275 +++
.../test/reference/codec.test.ts | 70 +
.../test/reference/service.test.ts | 104 +
.../test/store/roundtrip.test.ts | 90 +
.../test/template/defaultValue.test.ts | 80 +
.../test/template/template.test.ts | 292 +++
.../test/validation/validation.test.ts | 124 ++
packages/workflow-core/tsconfig.json | 7 +
packages/workflow-core/tsdown.config.ts | 11 +
packages/workflow-core/vitest.config.ts | 19 +
pnpm-lock.yaml | 56 +
projects/app/package.json | 1 +
.../Flow/NodeTemplatesPopover.tsx | 18 +-
.../Flow/hooks/useDebug.tsx | 15 +-
.../Flow/hooks/useWorkflow.tsx | 65 +-
.../Flow/nodes/NodeCode/Copilot.tsx | 45 +-
.../Flow/nodes/NodeCode/parser.ts | 22 +-
.../Flow/nodes/NodeIfElse/index.tsx | 15 +-
.../WorkflowComponents/adapters/command.ts | 85 +
.../WorkflowComponents/adapters/document.ts | 22 +
.../WorkflowComponents/adapters/validation.ts | 129 ++
.../context/workflowUtilsContext.tsx | 14 +-
.../app/detail/WorkflowComponents/utils.ts | 4 +-
.../pages/api/core/workflow/optimizeCode.ts | 1 +
.../Flow/nodes/NodeCode/parser.test.ts | 72 +
.../Flow/nodes/NodeIfElse/index.test.ts | 110 ++
.../adapters/document.test.ts | 44 +
.../workflowCorePr1Equivalence.test.ts | 86 +
.../workflow/workflowCorePr2Adapter.test.ts | 92 +
.../workflow/workflowCorePr3Adapter.test.ts | 109 +
138 files changed, 20645 insertions(+), 67 deletions(-)
create mode 100644 ".agents/design/core/workflow/workflow-cli-builder-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md"
create mode 100644 ".agents/design/core/workflow/workflow-cli-builder-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md"
create mode 100644 packages/workflow-cli/package.json
create mode 100644 packages/workflow-cli/src/cli.ts
create mode 100644 packages/workflow-cli/src/commands/config.ts
create mode 100644 packages/workflow-cli/src/commands/container.ts
create mode 100644 packages/workflow-cli/src/commands/document.ts
create mode 100644 packages/workflow-cli/src/commands/edge.ts
create mode 100644 packages/workflow-cli/src/commands/helpers.ts
create mode 100644 packages/workflow-cli/src/commands/input.ts
create mode 100644 packages/workflow-cli/src/commands/meta.ts
create mode 100644 packages/workflow-cli/src/commands/node.ts
create mode 100644 packages/workflow-cli/src/commands/output.ts
create mode 100644 packages/workflow-cli/src/commands/template.ts
create mode 100644 packages/workflow-cli/src/commands/tool.ts
create mode 100644 packages/workflow-cli/src/commands/validate.ts
create mode 100644 packages/workflow-cli/src/commands/variable.ts
create mode 100644 packages/workflow-cli/src/error.ts
create mode 100644 packages/workflow-cli/src/help.ts
create mode 100644 packages/workflow-cli/src/i18n.ts
create mode 100644 packages/workflow-cli/src/index.ts
create mode 100644 packages/workflow-cli/src/io/workflowFile.ts
create mode 100644 packages/workflow-cli/src/output/render.ts
create mode 100644 packages/workflow-cli/src/parser.ts
create mode 100644 packages/workflow-cli/src/registry.ts
create mode 100644 packages/workflow-cli/src/run.ts
create mode 100644 packages/workflow-cli/src/type.ts
create mode 100644 packages/workflow-cli/test/bin-smoke.mjs
create mode 100644 packages/workflow-cli/test/e2e.test.ts
create mode 100644 packages/workflow-cli/test/io/workflowFile.test.ts
create mode 100644 packages/workflow-cli/test/output.test.ts
create mode 100644 packages/workflow-cli/test/parser.test.ts
create mode 100644 packages/workflow-cli/test/registry.test.ts
create mode 100644 packages/workflow-cli/tsconfig.json
create mode 100644 packages/workflow-cli/tsdown.config.ts
create mode 100644 packages/workflow-cli/vitest.config.ts
create mode 100644 packages/workflow-core/package.json
create mode 100644 packages/workflow-core/src/binding/service.ts
create mode 100644 packages/workflow-core/src/binding/type.ts
create mode 100644 packages/workflow-core/src/code/io.ts
create mode 100644 packages/workflow-core/src/command/apply.ts
create mode 100644 packages/workflow-core/src/command/type.ts
create mode 100644 packages/workflow-core/src/config/service.ts
create mode 100644 packages/workflow-core/src/domain/checksum.ts
create mode 100644 packages/workflow-core/src/domain/diagnostic.ts
create mode 100644 packages/workflow-core/src/domain/document.ts
create mode 100644 packages/workflow-core/src/edge/compiler.ts
create mode 100644 packages/workflow-core/src/edge/parser.ts
create mode 100644 packages/workflow-core/src/edge/service.ts
create mode 100644 packages/workflow-core/src/edge/type.ts
create mode 100644 packages/workflow-core/src/index.ts
create mode 100644 packages/workflow-core/src/io/service.ts
create mode 100644 packages/workflow-core/src/nesting/service.ts
create mode 100644 packages/workflow-core/src/node/add.ts
create mode 100644 packages/workflow-core/src/node/service.ts
create mode 100644 packages/workflow-core/src/public.ts
create mode 100644 packages/workflow-core/src/reference/codec.ts
create mode 100644 packages/workflow-core/src/reference/service.ts
create mode 100644 packages/workflow-core/src/reference/type.ts
create mode 100644 packages/workflow-core/src/store/compile.ts
create mode 100644 packages/workflow-core/src/store/decompile.ts
create mode 100644 packages/workflow-core/src/template/automationMeta.ts
create mode 100644 packages/workflow-core/src/template/builtin.ts
create mode 100644 packages/workflow-core/src/template/defaultValue.ts
create mode 100644 packages/workflow-core/src/template/descriptor.ts
create mode 100644 packages/workflow-core/src/template/instantiate.ts
create mode 100644 packages/workflow-core/src/template/type.ts
create mode 100644 packages/workflow-core/src/template/valueSchema.ts
create mode 100644 packages/workflow-core/src/validation/index.ts
create mode 100644 packages/workflow-core/test/binding/service.test.ts
create mode 100644 packages/workflow-core/test/code/io.test.ts
create mode 100644 packages/workflow-core/test/command/additionalBuiltins.test.ts
create mode 100644 packages/workflow-core/test/command/apply.test.ts
create mode 100644 packages/workflow-core/test/command/codeIo.test.ts
create mode 100644 packages/workflow-core/test/command/pr2.test.ts
create mode 100644 packages/workflow-core/test/command/pr3.test.ts
create mode 100644 packages/workflow-core/test/command/systemConfig.test.ts
create mode 100644 packages/workflow-core/test/config/service.test.ts
create mode 100644 packages/workflow-core/test/edge/compiler.test.ts
create mode 100644 packages/workflow-core/test/edge/parser.test.ts
create mode 100644 packages/workflow-core/test/fixtures/basic-ai/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/basic-ai/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/basic-ai/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/basic-static/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/basic-static/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/basic-static/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/branching/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/branching/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/branching/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/common-linear/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/common-linear/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/common-linear/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/dynamic-io-catch/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/dynamic-io-catch/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/dynamic-io-catch/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/nested-loop/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/nested-loop/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/nested-loop/workflow.json
create mode 100644 packages/workflow-core/test/fixtures/pr3.ts
create mode 100644 packages/workflow-core/test/fixtures/tool-call-tools/expected-diagnostics.json
create mode 100644 packages/workflow-core/test/fixtures/tool-call-tools/store-workflow.json
create mode 100644 packages/workflow-core/test/fixtures/tool-call-tools/workflow.json
create mode 100644 packages/workflow-core/test/reference/codec.test.ts
create mode 100644 packages/workflow-core/test/reference/service.test.ts
create mode 100644 packages/workflow-core/test/store/roundtrip.test.ts
create mode 100644 packages/workflow-core/test/template/defaultValue.test.ts
create mode 100644 packages/workflow-core/test/template/template.test.ts
create mode 100644 packages/workflow-core/test/validation/validation.test.ts
create mode 100644 packages/workflow-core/tsconfig.json
create mode 100644 packages/workflow-core/tsdown.config.ts
create mode 100644 packages/workflow-core/vitest.config.ts
create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/command.ts
create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/document.ts
create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/validation.ts
create mode 100644 projects/app/test/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/parser.test.ts
create mode 100644 projects/app/test/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/index.test.ts
create mode 100644 projects/app/test/pageComponents/app/detail/WorkflowComponents/adapters/document.test.ts
create mode 100644 projects/app/test/web/core/app/workflow/workflowCorePr1Equivalence.test.ts
create mode 100644 projects/app/test/web/core/app/workflow/workflowCorePr2Adapter.test.ts
create mode 100644 projects/app/test/web/core/app/workflow/workflowCorePr3Adapter.test.ts
diff --git "a/.agents/design/core/workflow/workflow-cli-builder-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/.agents/design/core/workflow/workflow-cli-builder-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md"
new file mode 100644
index 000000000000..d7f144a027ce
--- /dev/null
+++ "b/.agents/design/core/workflow/workflow-cli-builder-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md"
@@ -0,0 +1,1745 @@
+# Workflow CLI Builder 功能开发文档
+
+## 0. 文档标识
+
+- 文档状态:方案评审稿
+- 修订日期:2026-07-20
+- 关联需求文档:`workflow-cli-builder-需求设计文档.md`
+- 实施原则:先共享领域核心,再接 Web 和 CLI,最后接远端
+- 文档范围:模块设计、实施任务、测试与发布方案
+- Agent 接入范围:PR1 到 PR4 保证具备 Shell 能力的 Agent 可调用 CLI;PR5 在 Workflow 编辑器内注入单一内置 `workflow-builder` Skill,不实现通用 MCP Adapter
+
+## 1. 开发目标和硬约束
+
+### 1.1 开发目标
+
+构建一个不依赖 React、ReactFlow、Next.js 和浏览器 API 的工作流领域核心,让以下两个入口执行同一套规则:
+
+```text
+Web command adapter ─┐
+ ├─> @fastgpt/workflow-core ─> WorkflowDocument ─> StoreWorkflow
+CLI command handler ─┘
+```
+
+第一阶段完成本地闭环:
+
+```text
+load/import
+ -> resolve template
+ -> apply WorkflowCommand
+ -> validate
+ -> build StoreWorkflow
+ -> atomic write
+```
+
+### 1.2 硬约束
+
+1. `packages/global/core` 只继续提供已有共享类型、常量和基础工具,不承载 editor 业务实现。
+2. `packages/workflow-core` 必须保持 browser-safe,不引用 `fs`、React、ReactFlow、Next.js 或服务端 SDK。
+3. 文件 IO、终端交互、HTTP profile 和进程退出码位于 `packages/workflow-cli`。
+4. 后端权限与数据写入继续位于现有 service/API 层。
+5. API 入参修改必须使用 `parseApiInput` 和 OpenAPI schema。
+6. Web 迁移必须通过 adapter,不能把 ReactFlow 类型扩散到 workflow-core。
+7. 每条 mutation 都通过统一 command dispatcher,禁止 CLI command 直接修改数组。
+8. 本地 JSON 约束、Confirm 和远端版本冲突都要由代码重新计算,不能信任文件内布尔值。
+9. CLI/Agent 参数语义通过独立 Template Descriptor 暴露,不向运行时节点 Schema 混入 CLI 专用字段。
+10. CLI 本体不新增通用 Skill、MCP Server 或 MCP Adapter;PR5 的 `workflow-builder` 仅是产品层内置 Skill,不进入 WorkflowDocument、StoreWorkflow 或 CLI 领域 Schema。
+
+### 1.3 Core 抽取与最小 CLI 落地总览
+
+#### 1.3.1 抽取目标
+
+PR1 采用纵向切片,不一次性搬迁 FastGPT Web 的全部工作流逻辑。交付目标是抽取能够构建基础线性工作流的最小 Core,同时打通 CLI 输入、Document 修改、StoreWorkflow 编译和本地文件写入的完整闭环。
+
+`@fastgpt/workflow-core` 是 browser-safe TypeScript package,不启动进程、不监听端口。Web 和 CLI 将动作适配为相同的 `WorkflowCommand`,Core 只根据输入文档和显式依赖返回新文档与诊断。
+
+```text
+Current WorkflowDocument + WorkflowCommand + Dependencies
+ -> structuredClone
+ -> dispatch command
+ -> command invariant validation
+ -> WorkflowCommandResult
+```
+
+Core 不直接等于 ReactFlow。节点数据复用 `StoreNodeItemType`,执行边使用稳定的语义端口;ReactFlow 的 Node/Edge 外层、selected、zIndex、viewport、节点尺寸和临时 debug 状态继续由 Web Adapter 管理。
+
+#### 1.3.2 PR1 数据流
+
+```mermaid
+flowchart LR
+ subgraph Caller["调用入口"]
+ Human["用户 / 脚本"]
+ Agent["Agent"]
+ Web["FastGPT Web"]
+ end
+
+ subgraph Adapter["接入层"]
+ CLI["workflow-cli
flags / stdin / IO"]
+ WebAdapter["Web Adapter
PR2/PR3 逐步接入"]
+ end
+
+ Protocol["WorkflowCommand / ChangeSet
统一修改协议"]
+ Core["@fastgpt/workflow-core
模板 + 节点 + 边 + 引用 + 校验"]
+ Document["WorkflowDocument
唯一规范状态"]
+ File["workflow.json
本地文件"]
+ Store["StoreWorkflow
FastGPT 格式"]
+ Service["FastGPT Service / AppVersion
PR6/PR7"]
+
+ Human --> CLI
+ Agent -->|"stdin ChangeSet"| CLI
+ Web --> WebAdapter
+ CLI --> Protocol
+ WebAdapter --> Protocol
+ Protocol --> Core --> Document
+ Document -->|"serialize"| File
+ Document -->|"compile"| Store
+ Store --> Service
+
+ classDef canonical fill:#fff2cc,stroke:#bf9000,stroke-width:2px;
+ classDef core fill:#e2f0d9,stroke:#548235;
+ classDef later fill:#f2f2f2,stroke:#7f7f7f,stroke-dasharray:5 5;
+ class Document canonical;
+ class Core core;
+ class Service later;
+```
+
+状态转换单独表达,避免把 UI 适配、文件持久化和服务端编译混在调用图中:
+
+```mermaid
+flowchart LR
+ ReactFlow["ReactFlow State
Document + UI 状态"]
+ Document["WorkflowDocument
唯一规范状态"]
+ File["workflow.json
Document 的 JSON 表达"]
+ Store["StoreWorkflow
FastGPT 存储格式"]
+
+ ReactFlow <-->|"Web Adapter"| Document
+ File <-->|"parse / serialize"| Document
+ Document <-->|"compile / decompile"| Store
+
+ classDef canonical fill:#fff2cc,stroke:#bf9000,stroke-width:2px;
+ class Document canonical;
+```
+
+三种转换的实现边界:
+
+| 转换 | 所属模块 | PR1 要求 |
+| --- | --- | --- |
+| `workflow.json <-> WorkflowDocument` | CLI codec + Core Schema/normalize | 稳定序列化、schemaVersion、错误不覆盖原文件 |
+| `WorkflowDocument <-> StoreWorkflow` | Core store compiler/decompiler | 普通节点和 next/target 边语义往返 |
+| `ReactFlow State <-> WorkflowDocument` | Web Adapter | PR1 先用 fixture 固化当前行为,PR2/PR3 按动作接入 |
+
+#### 1.3.3 PR1 首批节点
+
+PR1 只支持能够证明基础工作流构建链路的四类内置节点:
+
+| 节点 | Core 实现重点 | 可执行的最小场景 |
+| --- | --- | --- |
+| SystemConfig | 默认存在、唯一且不可删除/复制;配置读取 `chatConfig` | 提供变量和工作流开关的统一 Web 编辑入口 |
+| WorkflowStart | 唯一性、系统输出、默认 userChatInput/userFiles 引用 | 提供工作流入口 |
+| AI Chat | 完整模板实例化、模型和提示词参数、用户输入引用 | Start -> AI Chat |
+| Text Editor | 固定文本和 VariableRef 输入 | Start -> Text Editor |
+| Assigned Answer | 固定值/引用作为回答、普通 target 端口 | Text Editor -> Assigned Answer |
+
+模板实例化必须读取现有 FastGPT 原始模板,保留 inputs、outputs、toolConfig、pluginData、catchError 等运行字段。CLI/Agent 参数说明通过 Descriptor 和 Automation Metadata 暴露,但这些元数据不得进入节点、`workflow.json` 或 StoreWorkflow。
+
+#### 1.3.4 PR1 Core 操作面
+
+| 分类 | PR1 操作 | 主要输出 |
+| --- | --- | --- |
+| Document | create、parse、normalize、serialize、基础 checksum | `WorkflowDocument` |
+| Template | builtin list/show、descriptor normalize、instantiate | 完整 `StoreNodeItemType` |
+| Node | list、show、add、add-after | 新节点及必要执行边 |
+| Input | set literal、set VariableRef | 更新后的节点 input value |
+| Edge | parse next/target、compile/decompile 普通边 | `WorkflowExecutionEdge` / `StoreEdgeItemType` |
+| Validation | Schema、节点 ID、必填输入、普通边、引用、Start 可达性 | `WorkflowDiagnostic[]` |
+| Store | compile、decompile、semantic round-trip | `StoreWorkflow` |
+
+`node add --after` 必须只生成一个 `AddNodeCommand`,通过 `connectFrom` 在 Core 内原子完成“创建节点 + 连接边”。执行失败时不返回新节点,也不写入文件。
+
+#### 1.3.5 PR1 CLI Demo
+
+PR1 CLI 开放以下命令:
+
+```text
+init
+build
+template list
+template show
+node list
+node show
+node add
+input set
+input ref
+validate
+```
+
+最小验收流程:
+
+```bash
+fastgpt-workflow init --dir ./demo
+fastgpt-workflow node add --dir ./demo --node ai --template builtin:ai-chat --after start@next
+fastgpt-workflow input ref --dir ./demo --node ai --key userChatInput --from start.userChatInput
+fastgpt-workflow input set --dir ./demo --node ai --key systemPrompt --value "You are a helpful assistant"
+fastgpt-workflow validate --dir ./demo
+fastgpt-workflow build --dir ./demo --output ./demo/workflow.generated.json
+```
+
+验收条件:
+
+1. `workflow.json` 可以稳定 parse/serialize,失败不产生半写入文件。
+2. 生成的 StoreWorkflow 可被当前 FastGPT Web 正确读取。
+3. Start、AI Chat、输入引用和普通执行边保持完整。
+4. `StoreWorkflow -> WorkflowDocument -> StoreWorkflow` 规范化后语义等价。
+5. 人工 CLI flags 和直接调用同一 Command 得到相同 Document。
+6. `--dry-run` 零写入,`--format json` 的 stdout 只包含结构化结果。
+
+#### 1.3.6 后续增量操作
+
+| 阶段 | 新增节点和操作 | Core 演进重点 |
+| --- | --- | --- |
+| PR2 | 知识库搜索、问题优化、内容提取、HTTP、代码、调用应用;node update/remove/clone;edge connect/disconnect/reconnect;input unset;App/ChatConfig/global variables | 完整线性工作流和共享 Validator |
+| PR3 | 条件分支、catch、工具调用、动态 IO、循环和容器节点;insert、复杂 reconnect、attach/detach tool、父子移动 | 复杂图语义、副作用和 Web action 迁移 |
+| PR4 | 不新增节点;ChangeSet、plan/apply、checksum、Confirm、CI | 自动化事务和本地 CLI Beta |
+| PR5 | Workflow ChatBox、独立 Builder Handler、App Sandbox、内置 Skill、CLI 调用、ChangeSet 预览/应用 | 端到端 Workflow 辅助生成 Demo |
+| PR6 | 远端 Team App、System Tool、Tool 模板 Provider;pull/versions/preview | 远端只读和反编译 |
+| PR7 | draft save、publish、run、debug | 权限、发布校验和乐观并发 |
+
+所有后续操作继续扩展 `WorkflowCommandSchema` 和同一个 dispatcher。禁止在 CLI Handler、Web Context 或远端 Client 中形成第二套节点、边和引用修改逻辑。
+
+#### 1.3.7 开发顺序
+
+PR1 固定按以下顺序实施:
+
+1. 从当前 Web 导出并脱敏 `basic-ai`、`basic-static` fixture,建立 Characterization Tests。
+2. 创建 `workflow-core` 和 `workflow-cli` package,锁定依赖方向。
+3. 定义 PR1 范围内的 Document、Diagnostic、ExecutionPortRef、VariableRef、Descriptor 和 Command Schema。
+4. 实现普通 edge compiler/decompiler 和 StoreWorkflow round-trip。
+5. 实现四类内置模板的 Provider、Descriptor 与完整节点实例化。
+6. 实现 AddNode、SetInputValue、SetInputReference 和 dispatcher。
+7. 实现最小 Validator、文件 codec、原子 IO 和 CLI Registry。
+8. 完成 CLI 端到端、golden、失败不写盘、JSON contract 和确定性构建测试。
+
+## 2. 总体模块设计
+
+### 2.1 新增 package
+
+```text
+packages/
+├── workflow-core/
+│ ├── package.json
+│ ├── tsconfig.json
+│ ├── tsdown.config.ts
+│ ├── src/
+│ │ ├── domain/
+│ │ ├── command/
+│ │ ├── template/
+│ │ ├── edge/
+│ │ ├── reference/
+│ │ ├── nesting/
+│ │ ├── validation/
+│ │ ├── store/
+│ │ └── index.ts
+│ └── test/
+└── workflow-cli/
+ ├── package.json
+ ├── tsconfig.json
+ ├── tsdown.config.ts
+ ├── src/
+ │ ├── cli.ts
+ │ ├── registry.ts
+ │ ├── context.ts
+ │ ├── options/
+ │ ├── commands/
+ │ ├── io/
+ │ ├── remote/
+ │ ├── output/
+ │ └── index.ts
+ └── test/
+```
+
+两个目录都匹配 `pnpm-workspace.yaml` 的 `packages/*`,无需新增 workspace 路径。
+
+建议包名:
+
+- `@fastgpt/workflow-core`
+- `@fastgpt/workflow-cli`
+
+### 2.2 依赖方向
+
+```mermaid
+flowchart LR
+ Global["@fastgpt/global
types/constants"] --> Core["@fastgpt/workflow-core
pure domain"]
+ Core --> CLI["@fastgpt/workflow-cli
IO/HTTP/terminal"]
+ Core --> Web["projects/app
ReactFlow adapter"]
+ Service["@fastgpt/service"] --> API["Next API"]
+ Global --> Service
+ CLI --> API
+```
+
+禁止依赖:
+
+- `workflow-core -> workflow-cli`
+- `workflow-core -> projects/app`
+- `workflow-core -> packages/service`
+- `packages/global -> workflow-core`
+
+## 3. workflow-core 文件设计
+
+### 3.1 文件清单
+
+| 文件 | 职责 | 关键导出 |
+| --- | --- | --- |
+| `src/domain/document.ts` | Document schema/type | `WorkflowDocumentSchema`、`WorkflowDocument` |
+| `src/domain/diagnostic.ts` | 统一诊断 | `WorkflowDiagnostic`、`DiagnosticCode` |
+| `src/domain/checksum.ts` | 规范化和 checksum | `normalizeWorkflowDocument()`、`getWorkflowChecksum()` |
+| `src/edge/type.ts` | 语义执行端口和边 | `ExecutionSourcePortRef`、`WorkflowExecutionEdge` |
+| `src/edge/parser.ts` | CLI 语义字符串解析 | `parseExecutionPortRef()` |
+| `src/edge/compiler.ts` | 语义边与 StoreEdge 互转 | `compileExecutionEdge()`、`decompileStoreEdge()` |
+| `src/reference/type.ts` | 数据引用 | `VariableRefSchema` |
+| `src/reference/codec.ts` | Document/Store 数据引用编解码 | output key/id 与文本占位符双向转换 |
+| `src/reference/service.ts` | set/unset/available | `setInputReference()`、`getAvailableVariables()` |
+| `src/template/type.ts` | 模板引用和 provider 接口 | `NodeTemplateRef`、`WorkflowTemplateProvider` |
+| `src/template/builtin.ts` | 内置模板 provider | `builtinTemplateProvider` |
+| `src/template/descriptor.ts` | 机器可读模板参数契约 | `NodeTemplateDescriptor`、`NodeParameterDescriptor` |
+| `src/template/normalize.ts` | 现有模板到 Descriptor 的归一化 | `normalizeNodeTemplateDescriptor()` |
+| `src/template/automationMeta.ts` | CLI/Agent 补充元数据 | `NodeTemplateAutomationMeta`、`getAutomationMeta()` |
+| `src/template/instantiate.ts` | 完整节点实例化 | `instantiateNodeFromTemplate()` |
+| `src/nesting/rules.ts` | 容器规则 | `checkCanMoveIntoParent()` |
+| `src/nesting/service.ts` | 父子关系更新 | `moveNodeToParent()`、`removeParentCascade()` |
+| `src/command/type.ts` | Command/ChangeSet schema | `WorkflowCommandSchema`、`WorkflowChangeSetSchema` |
+| `src/command/apply.ts` | 单命令 dispatcher | `applyWorkflowCommand()` |
+| `src/command/applyChangeSet.ts` | 批量原子执行 | `applyWorkflowChangeSet()` |
+| `src/command/node.ts` | node commands | `addNode()`、`removeNode()`、`cloneNode()` |
+| `src/command/edge.ts` | edge commands | `connectEdge()`、`insertNodeOnEdge()` |
+| `src/command/input.ts` | input/output commands | `setInputValue()`、`removeOutput()` |
+| `src/command/config.ts` | App 元数据、ChatConfig、全局变量 | `updateAppMeta()`、`updateChatConfig()`、`addGlobalVariable()` |
+| `src/validation/schema.ts` | schema 诊断 | `validateWorkflowSchema()` |
+| `src/validation/document.ts` | 节点和父子规则 | `validateWorkflowDocument()` |
+| `src/validation/graph.ts` | 图和端口规则 | `validateWorkflowGraph()` |
+| `src/validation/reference.ts` | 引用规则 | `validateWorkflowReferences()` |
+| `src/validation/index.ts` | 聚合校验 | `validateWorkflow()` |
+| `src/store/compile.ts` | Document -> Store | `compileStoreWorkflow()` |
+| `src/store/decompile.ts` | Store -> Document | `decompileStoreWorkflow()` |
+| `src/index.ts` | browser-safe 统一导出 | 上述公共 API |
+
+### 3.2 领域类型
+
+```ts
+import {
+ StoreEdgeItemTypeSchema,
+ StoreNodeItemTypeSchema
+} from '@fastgpt/global/core/workflow/type';
+
+export const WorkflowDocumentSchema = z.object({
+ schemaVersion: z.literal('fastgpt-workflow/v1'),
+ app: z.object({
+ appId: z.string().optional(),
+ name: z.string().optional(),
+ intro: z.string().optional(),
+ appType: z.string().optional(),
+ baseVersionId: z.string().optional()
+ }),
+ nodes: z.array(StoreNodeItemTypeSchema),
+ executionEdges: z.array(WorkflowExecutionEdgeSchema),
+ chatConfig: AppChatConfigSchema
+});
+```
+
+注意:示例表示结构方向,实际 import 应以仓库现有类型导出位置为准,不为 CLI 复制 StoreNode/ChatConfig schema。
+
+### 3.3 Template Descriptor
+
+#### 3.3.1 类型定义
+
+`FlowNodeInputItemTypeSchema` 同时服务于模板、ReactFlow 节点和 StoreNode。CLI/Agent 专用字段不能直接加到这个共享输入结构,否则会进入 Web 节点状态或 StoreWorkflow。
+
+workflow-core 定义独立 Descriptor:
+
+```ts
+export type NodeTemplateDescriptor = {
+ template: NodeTemplateRef;
+ name: string;
+ intro?: string;
+ flowNodeType: string;
+ inputs: NodeParameterDescriptor[];
+ outputs: NodeOutputDescriptor[];
+ constraints: {
+ unique: boolean;
+ isTool: boolean;
+ allowedParents?: string[];
+ };
+};
+
+export type NodeOutputDescriptor = {
+ id: string;
+ key: string;
+ label: string;
+ description?: string;
+ valueType?: string;
+ required: boolean;
+ executable: boolean;
+};
+
+export type NodeParameterDescriptor = {
+ key: string;
+ label: string;
+ description: string;
+ valueType?: string;
+ required: boolean;
+ defaultValue?: unknown;
+ defaultPolicy: 'template' | 'userRequired' | 'remoteValidated';
+ resourceKind?: 'dataset' | 'model' | 'app' | 'tool' | 'secret';
+ bindingRequired: boolean;
+ configurable: boolean;
+ inputModes: Array<'literal' | 'reference' | 'secret'>;
+ enum?: Array<{
+ label?: string;
+ value: string;
+ description?: string;
+ }>;
+ constraints?: {
+ min?: number;
+ max?: number;
+ minLength?: number;
+ maxLength?: number;
+ valueSchema?: Record;
+ };
+ examples?: unknown[];
+};
+
+export type NodeTemplateAutomationMeta = {
+ inputs?: Record<
+ string,
+ {
+ configurable?: boolean;
+ agentHint?: string;
+ valueSchema?: Record;
+ examples?: unknown[];
+ defaultPolicy?: 'template' | 'userRequired' | 'remoteValidated';
+ resourceKind?: 'dataset' | 'model' | 'app' | 'tool' | 'secret';
+ bindingRequired?: boolean;
+ }
+ >;
+};
+
+export type ResolvedWorkflowTemplate = {
+ template: FlowNodeTemplateType;
+ automationMeta?: NodeTemplateAutomationMeta;
+ validatedInputDefaults?: Record;
+};
+```
+
+#### 3.3.2 归一化逻辑
+
+```ts
+export const normalizeNodeTemplateDescriptor = ({
+ template,
+ templateRef,
+ automationMeta,
+ locale
+}: NormalizeNodeTemplateDescriptorParams): NodeTemplateDescriptor => ({
+ template: templateRef,
+ name: resolveLocale(template.name, locale),
+ intro: resolveLocale(template.intro, locale),
+ flowNodeType: template.flowNodeType,
+ inputs: template.inputs
+ .filter((input) => input.deprecated !== true)
+ .map((input) => {
+ const meta = automationMeta?.inputs?.[input.key];
+ return {
+ key: input.key,
+ label: resolveLocale(input.label, locale),
+ description: resolveLocale(
+ meta?.agentHint ?? input.toolDescription ?? input.description ?? input.label,
+ locale
+ ),
+ valueType: input.valueType,
+ required: input.required ?? false,
+ defaultValue: input.defaultValue ?? input.value,
+ defaultPolicy: meta?.defaultPolicy ?? 'template',
+ resourceKind: meta?.resourceKind,
+ bindingRequired: meta?.bindingRequired ?? false,
+ configurable: meta?.configurable ?? input.canEdit !== false,
+ inputModes: renderTypesToInputModes(input.renderTypeList),
+ enum: input.list,
+ constraints: {
+ min: input.min,
+ max: input.max,
+ minLength: input.minLength,
+ maxLength: input.maxLength,
+ valueSchema: meta?.valueSchema
+ },
+ examples: meta?.examples
+ };
+ }),
+ outputs: template.outputs
+ .filter((output) => output.deprecated !== true)
+ .map((output) => ({
+ id: output.id,
+ key: output.key,
+ label: resolveLocale(output.label, locale),
+ description: output.description
+ ? resolveLocale(output.description, locale)
+ : undefined,
+ valueType: output.valueType,
+ required: output.required ?? false,
+ executable: output.type === FlowNodeOutputTypeEnum.source
+ })),
+ constraints: {
+ unique: template.unique === true,
+ isTool: template.isTool === true
+ }
+});
+```
+
+实现要求:
+
+- 普通字段从现有模板实时归一化,不在 CLI 维护第二份 `label/description/valueType`。
+- `NodeTemplateAutomationMeta` 只补充现有模板无法表达的 Agent/CLI 信息。
+
+#### 3.3.3 输入初始值解析
+
+新增 `packages/workflow-core/src/template/defaultValue.ts`,集中实现值来源优先级;禁止在 builtin template、CLI handler 或 Agent 示例中分散判断资源字段。
+
+```ts
+const hasOwn = (value: object, key: PropertyKey) =>
+ Object.prototype.hasOwnProperty.call(value, key);
+
+export const resolveInitialInputValue = ({
+ input,
+ meta,
+ validatedRemoteDefault
+}: ResolveInitialInputValueParams) => {
+ if (validatedRemoteDefault?.provided === true) {
+ return structuredClone(validatedRemoteDefault.value);
+ }
+
+ if ((meta?.defaultPolicy ?? 'template') === 'template') {
+ return structuredClone(input.defaultValue ?? input.value);
+ }
+
+ return getResourceSafeEmptyValue({
+ valueType: input.valueType,
+ resourceKind: meta?.resourceKind
+ });
+};
+```
+
+`getResourceSafeEmptyValue()` 规则:
+
+| 输入 | 空值 |
+| --- | --- |
+| `resourceKind=dataset` / `selectDataset` | `[]` |
+| `resourceKind=model/app/secret` | `undefined` |
+| `resourceKind=tool` | 不实例化资源节点,不创建 selectedTools edge |
+| 非资源数组且模板无默认值 | `[]` |
+| 其他字段且模板无默认值 | `undefined` |
+
+用户显式值不在该 helper 内解析。`node.add.inputOverrides` 在模板实例化后通过 `hasOwn(inputOverrides, inputKey)` 应用,确保 `[]`、`''`、`false` 和 `0` 都能覆盖远端值及模板默认值。
+
+Start 默认引用作为实例化的后置步骤执行:仅当 `hasConfiguredValue(input.value) === false`、输入支持 reference 且共享 `areWorkflowValueTypesCompatible()` 认可源输出类型时补充。普通单引用仍要求类型匹配;对明确的聚合引用,`arrayString` 可接收 `string` 元素或 `arrayString` 输出。实例化、`input ref` 和 Validator 必须复用同一函数,禁止各自维护兼容表。
+- `renderTypeList` 转成 `literal/reference/secret`,不把 React 组件名称暴露给 Agent。
+- `description` 只负责语义说明;类型、范围和复杂结构必须使用结构化字段。
+- `valueSchema` 用于 custom、object、array 等复杂输入;缺失时返回 warning,不允许 Agent 靠猜测提交复杂值。
+- secret/input config 只输出参数约束,不输出实际值。
+- Descriptor 不传入 `nodeTemplate2FlowNode()`,不写入 WorkflowDocument、`workflow.json`、StoreNode 或 StoreWorkflow。
+
+#### 3.3.3 Provider 返回契约
+
+```ts
+export type ResolvedWorkflowTemplate = {
+ template: FlowNodeTemplateType;
+ automationMeta?: NodeTemplateAutomationMeta;
+};
+
+export type TemplateResolveContext = {
+ locale: string;
+ appId?: string;
+};
+
+export interface WorkflowTemplateProvider {
+ resolve(
+ ref: NodeTemplateRef,
+ context: TemplateResolveContext
+ ): Promise;
+}
+```
+
+Provider 行为:
+
+- builtin provider 返回内置模板和本地补充 metadata。
+- Web remote provider 复用 `getClientToolPreviewNode`,将 preview template 包装为 resolved result;不改变 Web 节点实例化结果。
+- CLI remote provider 通过 profile 获取完整 preview 和可用参数 Schema。
+- 测试 provider 返回固定模板和固定 metadata,不依赖网络。
+- `template show` 调用 `resolve -> normalizeNodeTemplateDescriptor`。
+- `node add` 只使用 `resolved.template` 实例化,不能把 `automationMeta` 展开到节点。
+
+### 3.4 语义执行端口
+
+```ts
+export const ExecutionSourcePortRefSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('next'), nodeId: z.string() }),
+ z.object({ kind: z.literal('branch'), nodeId: z.string(), branchKey: z.string() }),
+ z.object({ kind: z.literal('sourceOutput'), nodeId: z.string(), outputKey: z.string() }),
+ z.object({ kind: z.literal('catch'), nodeId: z.string() }),
+ z.object({ kind: z.literal('selectedTools'), nodeId: z.string() })
+]);
+
+export const ExecutionTargetPortRefSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('target'), nodeId: z.string() }),
+ z.object({ kind: z.literal('selectedTools'), nodeId: z.string() })
+]);
+```
+
+编译规则:
+
+```ts
+const compileSourceHandle = (port: ExecutionSourcePortRef, document: WorkflowDocument) => {
+ switch (port.kind) {
+ case 'next':
+ return getHandleId(port.nodeId, 'source', Position.Right);
+ case 'branch':
+ return getHandleId(port.nodeId, 'source', port.branchKey);
+ case 'sourceOutput':
+ assertSourceOutput(document, port.nodeId, port.outputKey);
+ return getHandleId(port.nodeId, 'source', port.outputKey);
+ case 'catch':
+ return getHandleId(port.nodeId, 'source_catch', Position.Right);
+ case 'selectedTools':
+ return NodeOutputKeyEnum.selectedTools;
+ }
+};
+```
+
+`Position.Right/Left` 不应让 workflow-core 引入 ReactFlow。实现时在 core 内定义稳定字符串常量 `right/left`,或让 `getHandleId` 接受字符串;上述伪代码只展示与当前 Web handle 的对应关系。
+
+### 3.5 StoreEdge 反编译
+
+`decompileStoreEdge()` 按以下顺序识别:
+
+1. 两端均为 `selectedTools`:工具边。
+2. source 为 `${nodeId}-source_catch-right`:catch 边。
+3. source 为 `${nodeId}-source-right`:普通 next 边。
+4. source key 命中节点中 `type=source` 的 output:sourceOutput 边。
+5. source key 命中 ifElse/userSelect/classify 等分支配置:branch 边。
+6. target 必须能规范化为 `${targetId}-target-left` 或工具目标。
+
+未知 handle 不得被静默删除。返回阻断式诊断:
+
+```ts
+{
+ code: 'WORKFLOW_EDGE_HANDLE_UNSUPPORTED',
+ severity: 'error',
+ edge: storeEdge
+}
+```
+
+只有完成新 handle 类型映射后,import 才能继续。
+
+### 3.6 Template 实例化
+
+实例化函数:
+
+```ts
+export const instantiateNodeFromTemplate = async ({
+ document,
+ templateRef,
+ nodeId,
+ position,
+ parentNodeId,
+ provider,
+ locale
+}: InstantiateNodeParams): Promise => {
+ // 1. resolve ResolvedWorkflowTemplate,只取 template 进入节点实例化
+ // 2. 检查 unique/tool/nesting 限制
+ // 3. 生成完整 StoreNode
+ // 4. 按 workflowStart 补 userChatInput/userFiles 默认引用
+ // 5. 创建容器所需系统子节点
+ // 6. 返回主节点、附带节点和 warnings
+};
+```
+
+实际实现链路调整为:
+
+```text
+provider.resolve(templateRef)
+-> template + automationMeta + validatedInputDefaults(PR6)
+-> resolveInitialInputValue() 逐输入解析
+-> StoreNodeItemTypeSchema.parse()
+-> 仅为空且类型兼容时补 Start 引用
+-> addNodeFromTemplate() 创建系统子节点
+-> applyWorkflowCommand() 最后应用 inputOverrides
+```
+
+文件级改动:
+
+| 文件 | 职责与关键改动 |
+| --- | --- |
+| `packages/workflow-core/src/template/type.ts` | 增加 `defaultPolicy/resourceKind/bindingRequired/validatedInputDefaults` 类型,不进入运行时节点 |
+| `packages/workflow-core/src/template/automationMeta.ts` | 声明 dataset/model/app/tool/secret 等资源输入及默认值策略 |
+| `packages/workflow-core/src/template/defaultValue.ts` | 实现唯一的输入初始值解析器和安全空值映射 |
+| `packages/workflow-core/src/template/instantiate.ts` | 消费 Provider 元数据,按优先级解析输入,通过共享类型函数限制 Start 默认引用 |
+| `packages/workflow-core/src/command/apply.ts` | 保证 `inputOverrides` 按“是否提供”覆盖,不使用 truthy/nullish 判断 |
+| `packages/workflow-core/src/reference/service.ts` | 实现单引用/聚合引用共享类型兼容规则 |
+| `packages/workflow-core/src/binding/type.ts` | 定义 `missing/unverified` 绑定结果,不包含资源实际值 |
+| `packages/workflow-core/src/binding/service.ts` | 收集待绑定项并转换为非阻断 warning |
+| `packages/workflow-core/src/validation/index.ts` | 只输出本地可确定的结构诊断,外部绑定缺失不转换为结构 error |
+| `packages/workflow-cli/src/commands/document.ts` | `build/inspect` 合并 Binding Collector 结果,构建不合成资源值 |
+| `packages/workflow-cli/src/commands/validate.ts` | 返回结构 `valid`、绑定清单和 `executable` 提示,不接收校验 mode |
+
+不得只复制 `flowNodeType/name`。必须保留 template 的 inputs、outputs、toolConfig、pluginData、catchError 等现有字段。
+
+### 3.7 Command Dispatcher
+
+```ts
+export const applyWorkflowCommand = async ({
+ document,
+ command,
+ dependencies
+}: ApplyWorkflowCommandParams): Promise => {
+ const nextDocument = structuredClone(document);
+ const result = await dispatchCommand(nextDocument, command, dependencies);
+ const diagnostics = validateCommandInvariants(result.document);
+
+ if (diagnostics.some((item) => item.severity === 'error')) {
+ throw new WorkflowCommandError(diagnostics);
+ }
+
+ return {
+ ...result,
+ checksum: getWorkflowChecksum(result.document)
+ };
+};
+```
+
+约束:
+
+- 输入 document 不得原地修改。
+- 单命令失败不返回半成品。
+- ChangeSet 在内存中依次执行,全部成功后才交给 IO 层写盘。
+- command error 与完整 workflow validation error 分开。
+
+#### 3.7.1 WorkflowCommand 与 WorkflowChangeSet 边界
+
+三类对象必须保持职责分离:
+
+| 对象 | 职责 | 不负责 |
+| --- | --- | --- |
+| `WorkflowCommand` | 描述一个原子领域修改,是 Web 和 workflow-core 的最小 mutation 单元 | 保存完整状态、处理文件和网络 |
+| `WorkflowChangeSet` | 封装一个或多个 WorkflowCommand,是 Agent、脚本和 CI 的版本化事务协议 | 重新实现节点、边、引用和校验规则 |
+| `WorkflowDocument` | 保存当前完整工作流,是唯一规范状态 | 描述本次修改意图 |
+
+调用边界固定为:
+
+```text
+FastGPT Web action -> WorkflowCommand -> applyWorkflowCommand()
+人工 CLI flags -> WorkflowCommand -> applyWorkflowCommand()
+Agent stdin -> WorkflowChangeSet -> applyWorkflowChangeSet() -> WorkflowCommand[]
+```
+
+`WorkflowChangeSet` 不是所有调用端必须使用的领域输入。Web 的拖拽、改单个参数和连一条边直接生成 WorkflowCommand;人工 CLI flags 是单条 WorkflowCommand 的人类友好适配器。Agent 的所有 mutation 无论只有一条还是多条,都必须通过 stdin 提交 WorkflowChangeSet,不做“简单/复杂”分类。
+
+`applyWorkflowChangeSet()` 只负责编排,必须逐条调用 `applyWorkflowCommand()`,不能复制 dispatcher 或领域规则:
+
+```ts
+export const applyWorkflowChangeSet = async ({
+ document,
+ changeSet,
+ dependencies
+}: ApplyWorkflowChangeSetParams): Promise => {
+ let nextDocument = structuredClone(document);
+ const changes: WorkflowChangeSummary[] = [];
+ const warnings: WorkflowDiagnostic[] = [];
+
+ for (const command of changeSet.commands) {
+ const result = await applyWorkflowCommand({
+ document: nextDocument,
+ command,
+ dependencies
+ });
+
+ nextDocument = result.document;
+ changes.push(...result.changes);
+ warnings.push(...result.warnings);
+ }
+
+ return {
+ document: nextDocument,
+ changes,
+ warnings,
+ checksum: getWorkflowChecksum(nextDocument)
+ };
+};
+```
+
+执行期间只更新内存中的 nextDocument。任一 Command 失败,整个 ChangeSet 失败,IO 层不得写入中间状态。Command invariant 每条执行后检查;需要完整图才能判断的 graph/reference/publish validation 在 ChangeSet 全部执行后统一检查。
+
+### 3.8 AddNodeCommand
+
+```ts
+type AddNodeCommand = {
+ type: 'node.add';
+ nodeId: string;
+ template: NodeTemplateRef;
+ name?: string;
+ position?: { x: number; y: number };
+ parentNodeId?: string;
+ connectFrom?: ExecutionSourcePortRef;
+ inputOverrides?: Record;
+};
+```
+
+执行顺序固定:
+
+1. 检查 nodeId 唯一。
+2. resolve + instantiate 完整模板。
+3. 应用 input overrides。
+4. 确认或继承 parentNodeId。
+5. 创建系统子节点并维护 childrenNodeIdList。
+6. 若有 connectFrom,连接到新节点 target。
+7. 检查边、父子和 unique node invariant。
+8. 一次性返回 document。
+
+这正是用户要求的“添加一个节点,并把节点的边连上”的原子动作。
+
+### 3.9 节点和 IO 副作用
+
+共享 command 必须实现:
+
+| 操作 | 必要副作用 |
+| --- | --- |
+| 删除节点 | 删除所有入边、出边、变量引用诊断;父节点默认级联子节点 |
+| 删除父节点 | 删除 childrenNodeIdList 内子节点及相关边 |
+| 移入容器 | 更新 child.parentNodeId 和 parent.childrenNodeIdList;按 Web 语义清边 |
+| 移出容器 | 同步移除 parent.childrenNodeIdList |
+| 替换动态 output | 删除旧 output handle 发出的执行边 |
+| 删除动态 output | 删除该 output handle 发出的执行边;保留引用错误供 validator 报告 |
+| 重置节点模板 | key 仍存在的 input value 按前端现状保留 |
+| 条件 loopRun 删除 break | 若最后一个 break 被移除则拒绝操作 |
+| 删除 tool node | 删除来自 toolCall 的 `selectedTools` 工具边 |
+
+变量引用不建议在删除输出时静默清除。静默清除会丢失用户意图;应保留引用并由 validator 精确报告,除非命令显式传入清理策略。
+
+### 3.10 InsertNodeCommand
+
+```ts
+type InsertNodeCommand = {
+ type: 'node.insert';
+ edge: WorkflowExecutionEdge;
+ nodeId: string;
+ template: NodeTemplateRef;
+};
+```
+
+算法:
+
+1. 精确找到旧 edge。
+2. 校验旧 edge 唯一存在。
+3. 创建节点但暂不提交。
+4. 删除旧 edge。
+5. 创建 `oldSource -> newNode@target`。
+6. 创建 `newNode@next -> oldTarget`。
+7. 若新模板没有普通 next 端口则拒绝插入。
+8. 任一步失败回到原 document。
+
+工具边、catch 边和分支边是否支持 insert 由显式规则决定,PR1 只支持普通 next -> target。
+
+### 3.11 引用和可用变量
+
+`getAvailableVariables(document, targetNodeId)` 不能简单返回所有其他节点输出。实现应复用或下沉当前 `getNodeAllSource` 的语义:
+
+- 从目标节点逆向遍历可达上游。
+- 加入 workflowStart 和系统变量。
+- 识别嵌套父级可见变量。
+- 排除下游、不可达和作用域外输出。
+- 输出 valueType,供 `input ref` 做类型兼容检查。
+
+```ts
+type AvailableVariable = {
+ ref: VariableRef;
+ name: string;
+ valueType: WorkflowValueType;
+ source: 'node' | 'system' | 'parent';
+};
+```
+
+#### 3.11.1 outputKey / outputId 编解码边界
+
+Core 内存在两个不可混用的输出标识命名空间:
+
+- CLI、WorkflowCommand、WorkflowDocument 和 `workflow.json` 使用稳定的 `output.key`。
+- StoreWorkflow、FastGPT Web 引用选择器和 Runtime 使用节点内的 `output.id`。
+- execution edge 的 source output 是执行端口语义,继续使用 `output.key`,不参与数据引用编解码。
+
+`compileStoreWorkflow()` 必须递归处理节点 input value,将结构化引用
+`[nodeId, outputKey]`、聚合引用和 `{{$nodeId.outputKey$}}` 文本占位符转换为 `output.id`。
+`decompileStoreWorkflow()` 执行完全相反的转换。`VARIABLE_NODE_ID` 全局变量引用保持 variable key 不变。
+编解码必须覆盖 `variable-update.updateList`、if/else 条件等嵌套配置,不能只处理
+`selectedTypeIndex === reference` 的顶层输入。
+
+Store -> Document -> Store 必须保持 StoreWorkflow 引用语义等价;Document -> Store 的产物必须能被当前
+Web 引用选择器识别。不得要求 CLI 用户读取或手工填写模板生成的随机 output id。
+
+## 4. Validator 下沉设计
+
+### 4.1 校验边界
+
+共享校验器接收 `WorkflowDocument` 并返回结构化诊断,不依赖 ReactFlow 类型。`projects/app/src/web/core/workflow/utils.ts` 的 `checkWorkflowNodeAndConnection()` 通过 Web adapter 调用共享校验器,并继续向页面返回错误 nodeId。
+
+### 4.2 目标结构
+
+```text
+validateWorkflow(document)
+ ├── validateWorkflowSchema
+ ├── validateWorkflowDocument
+ ├── validateWorkflowGraph
+ └── validateWorkflowReferences
+
+collectWorkflowBindings(document)
+ ├── missing
+ └── unverified
+
+assertWorkflowExecutable(document, resolvedBindings) // PR6/PR7
+ ├── validateWorkflow
+ ├── resolveResourceBindings
+ └── validateRuntimeAndPublishCapabilities
+```
+
+```ts
+type WorkflowBindingRequirement = {
+ nodeId: string;
+ inputKey: string;
+ defaultPolicy: 'userRequired' | 'remoteValidated';
+ resourceKind?: WorkflowResourceKind;
+ status: 'missing' | 'unverified';
+};
+```
+
+`collectWorkflowBindings()` 读取 Automation Metadata,但不更改 Validator 诊断级别:
+
+- 空值且 `input.required=true` 或 `bindingRequired=true`:返回 `missing`。
+- 非空且 `defaultPolicy=remoteValidated`:本地只返回 `unverified`,不声称资源可用。
+- 可选且空的外部输入不返回绑定项,避免关闭的 rerank、未使用 Secret 等字段产生噪声。
+- 返回对象不包含 value,避免 Dataset 快照、URL 或 Secret 进入日志和 CLI envelope。
+
+CLI `validate/build` 只被 `validateWorkflow()` 的 error 阻断,Binding Collector 输出稳定 warning。PR6 Resolver 和 PR7 `assertWorkflowExecutable()` 在需要调试、运行或发布时检查存在性、当前团队读取权限和运行能力;这是独立业务操作,不是 Validator mode。
+
+返回:
+
+```ts
+type WorkflowValidationResult = {
+ valid: boolean;
+ diagnostics: WorkflowDiagnostic[];
+};
+```
+
+### 4.3 Web adapter
+
+```ts
+const checkWorkflowNodeAndConnection = ({ nodes, edges }) => {
+ const document = reactFlowToWorkflowDocument({ nodes, edges, chatConfig });
+ const result = validateWorkflow(document);
+
+ if (result.valid) return;
+ return unique(
+ result.diagnostics
+ .map((item) => item.nodeId)
+ .filter(Boolean)
+ );
+};
+```
+
+Web 继续负责:
+
+- `onUpdateNodeError`
+- `fitView`
+- toast 文案和 i18n
+- sandbox UI 开关提示
+
+Core 只返回诊断事实,不发 toast。
+
+### 4.4 校验迁移顺序
+
+1. 先为当前校验行为补 characterization tests。
+2. 将纯 Store/Document 规则迁入 workflow-core。
+3. Web adapter 对同一 fixture 比较当前 Web 行为和共享校验结果。
+4. 结果等价后,Web 切换到新 validator。
+5. sandbox、套餐能力等环境规则通过 context/provider 注入。
+
+## 5. 本地文件与自动化门禁实现
+
+### 5.1 Workflow File Codec
+
+初期不实现分片 Manifest。workflow-core 只定义 `WorkflowDocumentSchema` 和规范化函数,不读取文件;workflow-cli 负责把单文件 `workflow.json` 解析为 `WorkflowDocument`:
+
+```ts
+parseWorkflowDocument(input: unknown): WorkflowDocument;
+serializeWorkflowDocument(document: WorkflowDocument): string;
+```
+
+`serializeWorkflowDocument()` 只负责稳定 JSON 序列化,不执行节点、边、权限或确认规则。
+
+### 5.2 Workflow File IO
+
+workflow-cli 负责单文件 IO:
+
+```ts
+readWorkflowFile(dir: string): Promise;
+writeWorkflowFileAtomic(dir: string, document: WorkflowDocument): Promise;
+```
+
+写入规则:
+
+1. 目标文件固定为工作目录下的 `workflow.json`。
+2. 写入前先完成 Schema 和当前场景要求的领域校验,失败不得修改原文件。
+3. JSON 使用稳定字段顺序、固定缩进和末尾换行。
+4. 在同目录写入临时文件,完成必要 fsync 后原子 rename 为 `workflow.json`。
+5. rename 失败时保留原文件并清理临时文件;不得出现半写入状态。
+6. 两个进程并发写入的冲突控制在实现 PR 中通过 checksum/baseChecksum 验证,不引入目录事务协议。
+
+分片 Manifest 仅是后期可选 IO Codec。只有真实实验证明单文件在大型 Git diff、多人冲突或 Agent 上下文方面构成瓶颈时才单独设计,不进入当前 package 目录、命令注册、TODO 和测试矩阵。
+
+### 5.3 规范化与 checksum
+
+checksum 输入:
+
+- schemaVersion
+- app 绑定字段,排除易变展示字段时必须明确
+- 按 nodeId 排序的完整节点
+- 按语义端口排序的 executionEdges
+- 规范化 chatConfig
+
+不纳入 checksum:
+
+- 文件路径
+- JSON 缩进
+- `workflow.generated.json`
+- plan 的 approvedAt 等审计元数据
+
+推荐流程:稳定 JSON canonicalize 后计算 SHA-256。
+
+### 5.4 ChangeSet plan
+
+```ts
+type WorkflowPlan = {
+ schemaVersion: 'fastgpt-workflow-plan/v1';
+ baseChecksum: string;
+ targetChecksum: string;
+ changeSet: WorkflowChangeSet;
+ changes: WorkflowChangeSummary[];
+ diagnostics: WorkflowDiagnostic[];
+};
+```
+
+`apply` 时重新执行 ChangeSet 并重新计算 targetChecksum,不直接信任 plan 中的目标文档。
+
+Agent 默认不生成 ChangeSet 或 Plan 文件。`changeset plan --input -` 从 stdin 读取 `WorkflowChangeSetSchema`,JSON 结果通过 stdout 返回;用户确认后,`changeset apply --plan -` 从 stdin 读取原始 WorkflowPlan。Agent 在两次调用之间保留结构化对象,Apply 成功后只持久化 `workflow.json`。
+
+文件只是 Git/CI/跨人审批的可选载体,不是 Agent mutation 的前置条件。stdin 和文件输入必须进入同一个 parser 和 Schema;不得维护两套 ChangeSet 业务逻辑。
+
+## 6. workflow-cli 实现
+
+### 6.1 CLI 入口
+
+`packages/workflow-cli/package.json`:
+
+```json
+{
+ "name": "@fastgpt/workflow-cli",
+ "type": "module",
+ "bin": {
+ "fastgpt-workflow": "dist/cli.js"
+ }
+}
+```
+
+命令示例统一使用 `fastgpt-workflow` 作为实际 bin;文档中的 `workflow` 是可读简称,正式帮助文本只保留一个名称,避免双命令漂移。
+
+CLI 框架选型应优先复用仓库已有依赖;若仓库没有成熟 CLI parser,再在实现 PR 中比较 `commander`、`yargs` 或轻量自解析。框架不影响领域契约。
+
+### 6.2 CLI 契约实现
+
+所有命令由单一 Command Registry 注册,parser、`--help`、测试和命令可用性检查都消费该注册表,禁止分别维护命令列表:
+
+```ts
+type CliCommandDefinition = {
+ path: readonly string[];
+ introducedIn: 'PR1' | 'PR2' | 'PR3' | 'PR4' | 'PR5' | 'PR6' | 'PR7';
+ kind: 'query' | 'localMutation' | 'artifact' | 'remoteQuery' | 'remoteMutation';
+ inputSchema: ZodType;
+ supportsDryRun: boolean;
+ confirm: 'none' | 'checksum';
+ handler: (input: TInput, context: CliContext) => Promise;
+};
+```
+
+实现约束:
+
+- 全局 parser 只注册 `--dir`、`--format`、`--locale`、`--no-color`、`--quiet` 和基础 help/version。
+- `--dry-run`、`--profile`、`--output`、`--confirm` 只注册到适用命令,避免无效参数被静默忽略。
+- 资源标识统一使用显式选项;不得同时支持位置参数和 option 两套语法。
+- `--value`、`--value-json`、`--value-file`、`--value-env` 由互斥 Zod union 校验;`input ref` 只接受 `--from`。
+- 配置解析优先级固定为 CLI 参数、环境变量、profile、内置默认值,并在 handler 执行前生成只读 `CliContext`。
+- stdout renderer 使用 `--format`;只有 artifact command 使用 `--output`。JSON renderer 不读取 TTY 状态,也不输出 ANSI。
+- 当前发行阶段未开放的命令不注册到 parser 和 help;不得注册后返回 TODO 或空结果。
+- Command Registry 的 path、option、introducedIn、dry-run 和 confirm 属性需要 snapshot test。
+- CLI 公共命令或 JSON envelope 的破坏性修改必须提升 CLI major version;WorkflowDocument 格式变化必须提升 schemaVersion 并提供明确迁移指引。
+- 查询命令继续使用显式 CLI options;Agent 的 mutation 入口固定为 `changeset plan --input -` 和 `changeset apply --plan -`。
+- Agent 即使只修改一个参数,也提交只包含一条 WorkflowCommand 的 ChangeSet;禁止增加简单/复杂参数分类器。
+- 人工 mutation flags 只生成一条 WorkflowCommand,不能形成第二套 mutation 实现;需要批量原子操作时同样调用 applyWorkflowChangeSet。
+- stdin 只承载版本化 JSON,不接受自然语言或待猜测的字符串格式;解析失败按参数/schema 错误返回退出码 2。
+
+### 6.2.1 全局变量参数模型
+
+`variable add/update` 直接复用 `VariableItemTypeSchema`,CLI 参数拆为两个正交维度:
+
+- `--type` 对应 `VariableInputEnum`,控制输入组件或变量作用域;`external` 在 CLI 边界归一化为存储值 `custom`。
+- `--value-type` 对应 `WorkflowIOValueTypeEnum`,控制变量值的数据结构;不得增加与 `--type` 重复表达作用域的 `--source`。
+
+兼容规则:显式 `--type` 的优先级最高;未传时沿用现有 `valueType -> type` 推断。`variable update --value-type` 只在当前 `type` 等于旧 `valueType` 的推断结果时计算新 `type`,否则保留当前显式类型,避免把 `internal/custom` 意外改回普通输入框。
+
+类型专属字段由 `VariableItemTypeSchema.partial().omit(coreFields).strict()` 校验。`--config-json/--config-file` 提供完整配置入口,常用快捷参数覆盖 JSON 同名字段;`coreFields` 包括 `key`、`label`、`description`、`type`、`valueType`、`required` 和 `defaultValue`,只能由各自专用参数修改。最终仍组装单条 `variable.add` 或 `variable.update` WorkflowCommand,不允许 handler 绕过 Core 直接写 `chatConfig.variables`。
+
+### 6.3 Command Handler 边界
+
+每个 handler 只做五件事:
+
+1. parse args。
+2. load document/profile。
+3. 组装 WorkflowCommand 或调用 query service。
+4. 调用 workflow-core。
+5. 输出并按规则原子写入。
+
+禁止在 handler 内复制节点模板、handle、嵌套或校验规则。
+
+### 6.4 命令文件映射
+
+| CLI | 文件 | Core 调用 |
+| --- | --- | --- |
+| `init/import/build/inspect/diff` | `src/commands/document.ts` | create/decompile/compile/diff |
+| `template list/show` | `src/commands/template.ts` | template provider + `normalizeNodeTemplateDescriptor()` |
+| `node list/show/add/update/remove/clone/move` | `src/commands/node.ts` | query/applyWorkflowCommand |
+| `edge list/connect/disconnect/reconnect` | `src/commands/edge.ts` | query/applyWorkflowCommand |
+| `node insert` | `src/commands/insert.ts` | applyWorkflowCommand |
+| `input show/set/ref/unset/available`、`output list/add/remove` | `src/commands/input.ts` | descriptor lookup + command/reference service |
+| `meta/config/variable` 的 list/show/get/set/add/update/remove | `src/commands/config.ts` | config commands |
+| `tool attach/detach/list` | `src/commands/tool.ts` | toolCall 专用 command/edge query |
+| `container children` | `src/commands/container.ts` | nesting query |
+| `validate` | `src/commands/validate.ts` | validate |
+| `changeset plan/apply` | `src/commands/changeSet.ts` | applyWorkflowChangeSet |
+| `profile list/show/add/update/remove/test` | `src/commands/profile.ts` | CLI-only profile store |
+| `remote pull/diff/versions/meta push/save/publish` | `src/commands/remote.ts` | remote client + core build |
+| `debug start/run` | `src/commands/run.ts` | remote client |
+
+### 6.4.1 `template show` 实现流程
+
+```ts
+const showTemplate = async ({ ref, locale, format }) => {
+ const resolved = await templateProvider.resolve(ref, { locale });
+ const descriptor = normalizeNodeTemplateDescriptor({
+ template: resolved.template,
+ templateRef: ref,
+ automationMeta: resolved.automationMeta,
+ locale
+ });
+
+ return printResult(descriptor, format);
+};
+```
+
+`template show --format json` 必须输出稳定的 `NodeTemplateDescriptor`。它只读取模板和补充 metadata,不创建节点、不写 `workflow.json`、不调用保存 API。
+
+### 6.4.2 `input set/ref` 参数校验
+
+```ts
+const validateInputMutation = ({ node, inputKey, value, mode }) => {
+ const descriptor = getNodeParameterDescriptor(node, inputKey);
+
+ assert(descriptor, 'WORKFLOW_INPUT_NOT_FOUND');
+ assert(descriptor.configurable, 'WORKFLOW_INPUT_NOT_CONFIGURABLE');
+ assert(descriptor.inputModes.includes(mode), 'WORKFLOW_INPUT_MODE_NOT_ALLOWED');
+ assertValueType(value, descriptor.valueType, descriptor.constraints?.valueSchema);
+};
+```
+
+资源型输入额外执行:
+
+- PR6 前允许保存用户显式输入,但标记为 unverified;不能凭格式合法把它当作真实资源。
+- Agent 未从用户输入或远端 Provider 获得资源值时必须调用 `input unset` 或保留安全空值,禁止从 `examples/label/description` 生成占位 ID。
+- PR6 后由 `resourceResolver` 校验存在性和读取权限;通用 `input set` 不直接访问网络。
+- model 等 `valueType=string` 的资源必须依赖 `resourceKind` 判断,不能只按 valueType 推断。
+
+参数描述的来源顺序:
+
+1. 节点实例自身的 input 元数据。
+2. 对应 templateRef 的 Automation Metadata。
+3. 远端模板版本的 preview/schema。
+
+如果复杂参数没有 `valueSchema`,命令返回 warning 或阻断错误,不让 CLI/Agent 靠自然语言猜测对象结构。Descriptor 元数据不写入节点实例。
+
+### 6.5 本地 mutation 流程
+
+```ts
+const runMutation = async ({ dir, command, dryRun, format }) => {
+ const document = await readWorkflowFile(dir);
+ const result = await applyWorkflowCommand({ document, command, dependencies });
+
+ printResult(result, format);
+
+ if (!dryRun) {
+ await writeWorkflowFileAtomic(dir, result.document);
+ }
+};
+```
+
+构建门禁:
+
+```ts
+const buildDocument = async ({ document }: BuildParams) => {
+ const diagnostics = validateWorkflow(document);
+ assertNoValidationErrors(diagnostics);
+
+ const bindings = collectWorkflowBindings(document);
+ return {
+ workflow: compileStoreWorkflow(document),
+ bindings,
+ warnings: getWorkflowBindingDiagnostics(bindings)
+ };
+};
+```
+
+结构校验失败时不写 StoreWorkflow;只存在待绑定项时正常构建并输出 warning,节点中的资源字段仍保持安全空值。build 阶段不填充、猜测或替换资源,也不等价于可运行或可发布。
+
+打印错误后不得写盘。JSON 输出写 stdout,日志和交互提示写 stderr,便于脚本稳定解析。
+
+### 6.6 输出和错误
+
+内部异常统一转换:
+
+| 错误类 | 退出码 |
+| --- | --- |
+| `CliArgumentError`、Zod schema error | 2 |
+| `WorkflowCommandError` | 3 |
+| `WorkflowValidationError` | 4 |
+| `RemoteAuthError`、`RemotePermissionError` | 5 |
+| `RemoteVersionConflictError` | 6 |
+| `RemoteTransportError` | 7 |
+
+不得根据英文 message 判断退出码,必须根据错误类型或稳定 error code。
+
+## 7. Web 前端接入
+
+### 7.1 Adapter 文件
+
+建议新增:
+
+```text
+projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/
+├── document.ts
+├── command.ts
+├── templateProvider.ts
+└── validation.ts
+```
+
+职责:
+
+- `document.ts`:ReactFlow Node/Edge 与 WorkflowDocument 转换。
+- `command.ts`:把 UI action 转成 WorkflowCommand,应用后 setNodes/setEdges。
+- `templateProvider.ts`:复用 `getClientToolPreviewNode`;Web 节点创建只消费 raw template,不消费 Automation Metadata。
+- `validation.ts`:把 diagnostics 转为 node error、fitView 和 toast。
+
+### 7.2 需要逐步迁移的调用点
+
+| 当前文件 | 当前逻辑 | 迁移目标 |
+| --- | --- | --- |
+| `workflowActionsContext.tsx` | input/output 变更与清边 | 调用 node/input command |
+| `Flow/hooks/useWorkflow.tsx` | add/delete/connect/insert/nesting | 调用 command dispatcher |
+| `NodeTemplates/list.tsx` | resolve template/default refs/system child | 调用 instantiateNodeFromTemplate |
+| `NodeTemplatesPopover.tsx` | 新增后自动连边 | 发出单个 AddNodeCommand.connectFrom |
+| `workflowUtilsContext.tsx` | 转 Store + UI 校验 | 调用 shared validator |
+| `projects/app/src/web/core/workflow/utils.ts` | ReactFlow 校验 | 保留 adapter,移除重复领域规则 |
+
+### 7.3 迁移策略
+
+不要一次性重写全部 Context。按命令垂直迁移:
+
+1. 普通 edge connect/disconnect。
+2. node add + connectFrom。
+3. node remove cascade。
+4. input value/reference。
+5. output side effect。
+6. nested move。
+7. validator。
+
+每接入一个动作,先通过 Web 行为回归测试,再删除对应的重复领域逻辑。
+
+### 7.4 PR5 Workflow 辅助生成 Demo
+
+PR5 在 Workflow 编辑器内增加独立辅助生成模块。前端可以复用 `ChatBox`、`ChatItemContextProvider`、`ChatRecordContextProvider` 和 `ChatAIModelSelector`,但使用独立 Workflow Builder 容器和 API client,不改造 Skill Preview。
+
+```text
+projects/app/src/pageComponents/app/detail/WorkflowComponents/WorkflowBuilder/
+├── index.tsx
+├── ChatPanel.tsx
+├── WorkflowPlanCard.tsx
+└── api.ts
+```
+
+Pro 拥有 Workflow Builder 的产品编排、Sandbox 上下文和内置 Skill;FastGPT 主仓保留共享 Schema、Workflow Core/Web Adapter 与 UI。
+
+```text
+pro/admin/src/pages/api/core/workflow/builder/chat.ts
+pro/admin/src/service/core/ai/workflowBuilder/
+├── handler.ts
+├── runtime.ts
+├── sandbox.ts
+├── schema.ts
+└── index.ts
+pro/admin/src/service/core/ai/skill/builtin/workflow-builder/SKILL.md
+```
+
+`handleWorkflowBuilderChat` 是独立 Handler。它只模仿 `handleSkillDebugChat` 的顺序,直接调用已有底层能力,不抽取 Skill 辅助生成的公共业务 Runner:
+
+1. 用 `authApp` 验证当前成员对 App 的写权限并执行聊天频控。
+2. 将前端当前轮 `messages` 转换为 ChatItem,通过 `getChatItems` 恢复同 `appId + chatId` 的历史和 memories。
+3. 调用 `preChatRound`,构造 `WorkflowStart -> Agent` 最小 Runtime,并通过 `dispatchWorkFlow` 进入现有 Agent Loop。
+4. 按 `sourceType=app`、`sourceId=appId`、`userId`、`chatId` 创建或恢复 App Sandbox,不使用 `skillEdit` 或 `chatAgentHelper` source。
+5. Sandbox prepare action 每轮写入前端当前 `WorkflowDocument`,注入与服务端版本一致的 `fastgpt-workflow` 构建产物,并同步内置 `workflow-builder` Skill。
+6. Skill 必须要求 Agent 先查询 Descriptor,只使用 `--format json` 的 CLI 命令,不直接编辑 `workflow.json` 或 StoreWorkflow。
+7. Agent 产出 ChangeSet 后,Handler 重新验证 Schema、`baseChecksum`、graph 和 reference,禁止直接信任 Sandbox 输出。
+8. 通过现有 Workflow SSE 输出文本、plan 和 diagnostics,并使用 `finalizeChatRound` / `updateInteractiveChat` 持久化普通聊天历史。
+9. 前端确认时对当前画布重新计算 checksum;匹配后通过 Web Adapter/Core 应用 ChangeSet,不匹配则只作废当前 plan。
+
+请求 Schema 位于共享 OpenAPI 目录,只包含当前轮消息和当前画布事实:
+
+```ts
+type WorkflowBuilderChatBody = {
+ appId: string;
+ chatId: string;
+ responseChatItemId?: string;
+ messages: ChatCompletionMessageParam[];
+ model?: string;
+ workflowContext: {
+ document: WorkflowDocument;
+ checksum: string;
+ };
+};
+```
+
+不向前端开放 `systemPrompt` 和 `mode`;不单独传输完整历史、修改记录或节点选中上下文。PR5 只支持当前 CLI 已暴露的内置节点和本地静态校验,不自动保存、发布、调试或调用 PR6/PR7 远端命令。
+
+## 8. 远端 API 实现
+
+该部分分为 PR6 的远端只读能力和 PR7 的远端写入与运行,不应混入本地 CLI MVP 或 PR5 Workflow 辅助生成 Demo。
+
+### 8.1 鉴权改造
+
+需要逐个审计并增加 API Key 支持的现有入口:
+
+| 能力 | 当前文件 | 改造 |
+| --- | --- | --- |
+| pull 详情 | `projects/app/src/pages/api/core/app/detail.ts` | `authApp` 增加 `authApiKey: true`,保持完整图要求写权限 |
+| push 应用资料 | `projects/app/src/pages/api/core/app/update.ts` | 增加 API Key,继续按字段执行已有权限和父目录规则 |
+| template preview | `projects/app/src/pages/api/core/app/tool/getPreviewNode.ts` | 增加 API Key,保持团队资源权限 |
+| draft save/publish | `projects/app/src/pages/api/core/app/version/publish.ts` | 增加 API Key、baseVersionId |
+| debug | `projects/app/src/pages/api/core/workflow/debug.ts` | 增加 API Key,保持 app/resource 权限 |
+
+不能只改 `parseHeaderCert`。每个 endpoint 必须明确 opt in,并补授权测试。
+
+PR6 Template Provider 的资源默认值契约:
+
+```ts
+type ValidatedInputDefault = {
+ provided: true;
+ value: unknown;
+ resourceKind: 'dataset' | 'model' | 'app' | 'tool';
+};
+```
+
+- Provider 必须在当前 profile 身份下确认资源存在且具有读取权限,才放入 `validatedInputDefaults`。
+- API 返回完整节点运行所需快照,例如 dataset 不能只返回 `_id`,还需返回当前模板要求的 name/avatar/vectorModel 等字段。
+- 找不到、无权限、已删除或网络无法确认时不返回默认值,由实例化层落到安全空值并产生资源绑定诊断。
+- `validatedInputDefaults` 只参与本次实例化,不写入 Automation Metadata;节点只保存现有 StoreNode 所需值。
+- Secret 永远不进入 `validatedInputDefaults`,Provider 不读取、不返回、不记录模板中的 secret 默认值。
+
+#### 8.1.1 只读资源解析 API
+
+新增批量只读解析入口,避免 CLI 分别依赖 Dataset、App、Model 和 Tool 的 Web 页面接口:
+
+```text
+POST /api/core/workflow/resource/resolve
+```
+
+```ts
+const WorkflowResourceResolveBodySchema = z.object({
+ resources: z
+ .array(
+ z.object({
+ requestKey: z.string().min(1),
+ kind: z.enum(['dataset', 'model', 'app', 'tool']),
+ resourceKey: z.string().min(1)
+ })
+ )
+ .max(100)
+});
+
+const WorkflowResourceResolveResponseSchema = z.object({
+ items: z.array(
+ z.object({
+ requestKey: z.string(),
+ kind: z.enum(['dataset', 'model', 'app', 'tool']),
+ status: z.enum(['available', 'unavailable']),
+ value: z.unknown().optional()
+ })
+ )
+});
+```
+
+`unavailable` 统一表示不存在、无权限或已删除,避免向调用方泄露资源是否真实存在。`value` 仅在 available 时返回节点当前 Store schema 所需的脱敏快照;响应 schema 不允许 secret/token/header credential 字段。
+
+文件落点:
+
+| 文件 | 改动 |
+| --- | --- |
+| `packages/global/openapi/core/workflow/resource.ts` | 定义带 Route/Method/Description/Tags 和字段 meta 的请求、响应 Zod Schema,导出类型并注册 OpenAPI |
+| `projects/app/src/pages/api/core/workflow/resource/resolve.ts` | 使用 `parseApiInput` 校验 body,启用 API Key 只读鉴权,返回前执行 ResponseSchema.parse |
+| `projects/app/src/service/core/workflow/resourceResolver.ts` | 按 kind 调用现有 Dataset/App/Model/Tool 权限服务,归一化 available/unavailable 和脱敏快照 |
+| `packages/workflow-cli/src/remote/resourceResolver.ts` | 批量调用 API,转换为 Core `WorkflowResourceResolver`,不缓存 secret 或权限结论 |
+
+该接口只读且不绑定、不保存资源;profile/API Key 不进入请求 body。请求日志只记录 kind、数量和结果数量,不记录资源快照、用户 Prompt 或任何凭证。
+
+### 8.2 乐观并发
+
+OpenAPI body 增加:
+
+```ts
+baseVersionId: ObjectIdSchema.optional()
+```
+
+服务端事务逻辑:
+
+1. 开启 Mongo session。
+2. 查询当前 App 的 `pluginData.nodeVersion`。
+3. 若请求带 baseVersionId 且不一致,抛 `WORKFLOW_VERSION_CONFLICT`,HTTP 409。
+4. 创建 version history。
+5. 使用同时匹配 `_id` 和旧 versionId 的条件更新 App。
+6. `matchedCount === 0` 时同样返回冲突。
+
+仅在事务外先查询一次不够,会有 TOCTOU 覆盖窗口。
+
+### 8.3 保存和发布
+
+- draft save:使用 `isPublish: false`、`autoSave: false` 创建可追踪版本;允许 graph validation error,但请求 schema、权限和节点格式适配仍必须通过。CLI v1 不调用 Web 后台自动保存分支。
+- publish:CLI 本地先 validate,服务端仍重新执行发布必要校验。
+- 服务端不能信任 CLI 的 `validated: true` 或 checksum。
+- 发布继续检查 Agent Skill 读取权限和资源引用。
+- debug tool 发布限制应下沉成可复用 publish validator,Web 和 CLI 都展示同一诊断。
+
+### 8.4 运行与调试
+
+- `run` 可复用现有 OpenAI-compatible chat 接口时,不额外发明运行 API。
+- `debug` 需要保持当前 entryNode、runtimeNodes、runtimeEdges、variables、usageId 的逐步状态契约。
+- CLI debug v1 可以只输出 JSON step 结果,不实现 TUI。
+
+## 9. 测试设计
+
+### 9.1 workflow-core 单元测试
+
+| 测试文件 | 覆盖 |
+| --- | --- |
+| `test/edge/parser.test.ts` | `@next/@target/@branch/@output/@catch/@tools` |
+| `test/edge/compiler.test.ts` | semantic edge 与 StoreEdge 双向转换 |
+| `test/template/descriptor.test.ts` | 现有模板字段归一化、locale、inputModes、valueSchema、secret 脱敏 |
+| `test/template/automationMeta.test.ts` | 补充 metadata 与模板 key 对齐、configurable 和 examples |
+| `test/template/defaultValue.test.ts` | 显式值、PR6 已验证值、模板安全默认值、资源安全空值的优先级和显式空值 |
+| `test/template/instantiate.test.ts` | 完整模板、默认引用不覆盖已有值、引用类型兼容、unique、系统子节点 |
+| `test/command/addNode.test.ts` | add、add-after 原子性、重复 ID |
+| `test/command/removeNode.test.ts` | 边清理、父节点级联、forbidDelete |
+| `test/command/input.test.ts` | 固定值、VariableRef、动态 IO、清边 |
+| `test/command/insert.test.ts` | 替边、回滚、不支持端口 |
+| `test/nesting/service.test.ts` | 父子列表、非法嵌套、条件 break |
+| `test/validation/graph.test.ts` | 连通性、工具边、分支、sourceOutput |
+| `test/validation/reference.test.ts` | 上游、作用域、输出存在和类型 |
+| `test/binding/service.test.ts` | 必填绑定为空、未验证资源、可选 Secret 和诊断脱敏 |
+| `test/io/workflowFile.test.ts` | parse/serialize round-trip、Schema 错误和原子写入 |
+| `test/store/roundtrip.test.ts` | Store -> Document -> Store 语义等价 |
+| `test/template/runtime-isolation.test.ts` | Descriptor 不进入 ReactFlow Node、StoreNode、`workflow.json` 和 StoreWorkflow |
+
+### 9.2 workflow-cli 契约测试
+
+| 测试文件 | 覆盖 |
+| --- | --- |
+| `test/registry.snapshot.test.ts` | 所有命令 path、首次开放 PR、kind、dry-run 和 Confirm 元数据 |
+| `test/help.snapshot.test.ts` | 当前发行阶段只展示已开放命令,全局和命令级 option 不漂移 |
+| `test/options/value.test.ts` | value/value-json/value-file/value-env 互斥、类型解析和 secret 脱敏 |
+| `test/options/reference.test.ts` | TemplateRef、ExecutionPortRef、VariableRef、position 语法 |
+| `test/output/json.test.ts` | JSON envelope、schemaVersion、stdout 纯 JSON、无 ANSI |
+| `test/output/text.test.ts` | locale、quiet、no-color 和 stderr 分流 |
+| `test/exitCode.test.ts` | 参数、领域、校验、权限、冲突和网络错误映射 |
+| `test/dryRun.test.ts` | 本地与远端 mutation dry-run 零写入 |
+| `test/compatibility.test.ts` | 已发布命令 option 和 JSON envelope 的向后兼容 |
+| `test/stdinChangeSet.test.ts` | 单命令/多命令 ChangeSet、stdin 解析、零过程文件和原子失败 |
+| `test/mutationEquivalence.test.ts` | 人工 flags、Web Command 与单命令 ChangeSet 产生相同 Document |
+| `test/e2e.test.ts` | 待绑定资源下 validate/build 成功、warning 稳定、构建不合成资源值 |
+
+Command Registry 测试必须校验需求文档完整命令目录中的每个 command path 都有唯一注册项。未到首次开放 PR 的命令不进入当前 help snapshot,但必须在对应 PR 合并时同时增加 registry、handler、测试和帮助快照。
+
+### 9.3 Characterization tests
+
+在迁移 Web 逻辑前,先冻结以下当前行为:
+
+- `onChangeNode` 删除/替换 output 后清理 source edge。
+- `node add` 默认关联 workflowStart 输入。
+- 从 handle 添加节点后自动连边。
+- 删除父节点级联删除 children。
+- move into parent 清边并维护 childrenNodeIdList。
+- 条件 loopRun 至少保留一个 loopRunBreak。
+- save draft 不强制完整校验,publish/run 强制校验。
+- `template show --format json` 返回稳定 Descriptor,且不产生任何工作流写入。
+- `input set/ref` 根据 Descriptor 的 type、inputMode、configurable 和 valueSchema 进行校验。
+- 模板安全默认值保留,资源默认值在 PR6 验证前保持空;显式 `[]/''/false/0` 不被覆盖。
+- Start 默认引用只补空输入且类型兼容;知识库搜索的聚合输入允许 `string/arrayString -> arrayString`,普通单引用仍拒绝该转换。
+
+### 9.4 Golden fixtures
+
+从真实 FastGPT 导出并脱敏保存:
+
+```text
+packages/workflow-core/test/fixtures/
+├── basic-ai/
+├── branching/
+├── tool-call-tools/
+├── nested-loop/
+└── dynamic-io-catch/
+```
+
+每个 fixture 包含:
+
+- `store-workflow.json`
+- `workflow.json`
+- `expected-diagnostics.json`
+- 必要时的 template snapshot
+
+Round-trip 比较使用规范化后的语义对象,不比较 JSON 字段顺序和 ReactFlow 展示位置微调。
+
+### 9.5 Web/CLI 等价测试
+
+PR1 不接入 Web Adapter,也不要求复刻当前 Web 校验器的短路顺序或内部副作用。PR1 的 Characterization Test 将当前 Web 结果投影为 `valid/invalid + blockingNodeIds`,再与 workflow-core 在 `basic-ai`、`basic-static` 及对应失败样本上的规范化投影比较。完整 WorkflowCommand、StoreWorkflow 和 diagnostics 等价从 PR2 的 Web Validation Adapter 开始,并在 PR3 随复杂图语义补齐。
+
+对同一初始 fixture 和同一 WorkflowCommand:
+
+1. CLI 直接调用 workflow-core。
+2. Web adapter 转 Document 后调用 workflow-core。
+3. 两边编译 StoreWorkflow。
+4. 比较规范化 StoreWorkflow 和 diagnostics。
+
+这组测试是防止双实现漂移的核心验收,不可省略。
+
+### 9.6 Workflow 辅助生成测试
+
+- Workflow Builder 前端只发送当前轮 message、model、WorkflowDocument 和 checksum。
+- Handler 按 `appId + chatId` 恢复历史 ChatItem、memories、active plan 和 ask 交互。
+- 显式 model 与默认 model 路径均进入现有 Agent Loop,usage 只计费一次。
+- 不同 `chatId` 使用独立 App Sandbox,同一会话可恢复并继续使用原 Sandbox。
+- prepare action 每轮写入当前 WorkflowDocument,CLI 产物版本与服务端匹配,内置 Skill 不写入用户工作区。
+- Agent 不直接编辑 `workflow.json`,生成的 ChangeSet 必须经 workflow-core 二次校验。
+- base checksum 匹配时可应用到当前画布;人工修改导致不匹配时保留画布并只作废当前 plan。
+- 刷新页面后恢复聊天、生成状态和待确认 plan;Skill 辅助生成现有 API、历史和 Sandbox 测试保持不变。
+
+### 9.7 远端契约测试
+
+- API Key 可/不可访问各 endpoint。
+- `projects/app/test/api/core/workflow/resource/resolve.test.ts` 覆盖 available、格式非法、批量上限、无权限/不存在统一 unavailable、跨团队、响应无 Secret。
+- `packages/global/openapi/core/workflow/resource.test.ts` 覆盖请求和响应 Schema、OpenAPI 注册及 kind-specific resourceKey。
+- `packages/workflow-cli/test/remote/resourceResolver.test.ts` 覆盖批量映射、网络失败、部分 unavailable 和不缓存权限结论。
+- 只读成员无法拉取完整图。
+- 无 Agent Skill 权限不能 publish。
+- baseVersionId 正常更新。
+- 两个并发请求只有一个成功,另一个 409。
+- draft save 未完成图成功,publish 同图失败。
+- PR6 Provider 仅返回当前 profile 可读资源;无权限、删除、跨团队和网络无法确认时不返回 validated default。
+- PR7 debug/run/publish 对 PR6 曾验证但随后删除或撤权的资源重新阻断。
+
+资源初始值和 Binding Collector 核心函数要求优先达到 100% 行/分支覆盖,最低不得低于 90%;远端 Resolver 只 mock 鉴权和网络边界,本地优先级、空值映射、绑定脱敏、diagnostics 和 build 必须真实执行。
+
+## 10. 可观测性和审计
+
+CLI 的结构化日志字段:
+
+```ts
+type CliAuditEvent = {
+ command: string;
+ appId?: string;
+ profile?: string;
+ baseChecksum?: string;
+ targetChecksum?: string;
+ baseVersionId?: string;
+ changedNodeIds?: string[];
+ changedEdgeCount?: number;
+ durationMs: number;
+ result: 'success' | 'rejected' | 'conflict' | 'failed';
+};
+```
+
+禁止记录:
+
+- API Key、session token。
+- 完整用户输入和 prompt。
+- 节点 secret、header、credential。
+- 未脱敏的远端响应体。
+
+FastGPT 服务端已有 audit log 继续作为远端写入审计源;CLI 日志不替代服务端审计。
+
+## 11. 质量门禁
+
+每个 PR 至少执行:
+
+```bash
+pnpm --filter @fastgpt/workflow-core test
+pnpm --filter @fastgpt/workflow-core build
+pnpm --filter @fastgpt/workflow-cli test
+pnpm --filter @fastgpt/workflow-cli build
+pnpm --filter @fastgpt/workflow-cli test:bin
+```
+
+涉及 Web adapter 时增加相关 app 单测和类型检查;涉及 API 时增加 OpenAPI、路由和权限测试。最终合并前再执行仓库要求的全量检查。
+
+PR5 额外要求 Workflow Builder Handler、Sandbox prepare action、聊天恢复、usage 单次上报、checksum 过期和 Web apply 的定向测试;Pro 内置 Skill 注入测试不得依赖用户工作区或外部网络。
+
+i18n 要求:
+
+- core diagnostic 使用稳定 code 和参数,不内置面向用户的中文/英文长文案。
+- Template Descriptor 的 label/description/placeholder 按 CLI `--locale` 解析;JSON 不输出未解析的 i18n key。
+- Web 使用现有 i18n 展示诊断。
+- CLI text renderer 根据 locale 翻译;JSON 始终输出稳定 code。
+- 新增文案同步仓库现有的英文、简体中文和繁体中文资源。
+
+## 12. 发布和回滚
+
+### 12.1 发布顺序
+
+1. PR1 合并最小可用 Demo CLI,仅用于内部验证,不发布正式 CLI。
+2. PR2 增加常用线性节点和基础编辑,继续作为内部 alpha。
+3. PR3 开放复杂图语义,Web 按动作迁移到 shared commands。
+4. PR4 完成 ChangeSet、Confirm、checksum 和 CI 后发布本地 CLI Beta。
+5. PR5 在 Workflow 编辑器开放内部辅助生成 Demo,只支持 ChangeSet 预览和画布应用。
+6. PR6 完成 API Key 只读契约后开放 remote read beta。
+7. PR7 完成权限、并发和发布测试后开放 remote write beta。
+
+### 12.2 回滚边界
+
+- workflow-core 是纯函数 package,可按依赖版本回滚。
+- Web adapter 每个动作独立接入,可按 feature flag 临时切回当前 Web 实现。
+- Workflow Builder 入口通过 feature flag 独立关闭,回滚不影响 Skill 辅助生成、手工画布编辑或已发布 CLI。
+- WorkflowDocument schemaVersion 不允许静默降级;不兼容时给出明确错误和迁移指引。
+- 远端 baseVersionId 字段为 optional,旧 Web 客户端保持兼容。
+- API Key opt in 出现权限问题时可单 endpoint 关闭,不影响 session Web。
+
+## 13. Reviewer 阅读顺序
+
+1. `domain/document.ts`:是否只有一个规范状态。
+2. `edge/type.ts` 和 `edge/compiler.ts`:执行边是否与 VariableRef 分开。
+3. `template/defaultValue.ts`、`template/instantiate.ts`:是否按显式值、PR6 已验证值、模板安全默认值、安全空值解析,且 Start 引用不覆盖已有值。
+4. `command/apply.ts`:是否纯函数、原子、无半成品。
+5. node/input/nesting command:是否覆盖前端副作用。
+6. validation/binding:Web 和 CLI 是否共享单一结构规则,Binding Collector 是否不修改结构诊断级别且不泄露资源值。
+7. workflow file IO:是否只是解析和序列化,不执行权限或确认。
+8. CLI handlers:是否没有复制领域逻辑,结构失败是否零写入,待绑定时是否保持资源字段为空。
+9. Web adapter:是否没有把 ReactFlow 放入 core。
+10. Workflow Builder:是否保持独立 Handler,只复用底层 Chat/Agent/Sandbox 能力,并在服务端二次校验 ChangeSet。
+11. API/远端 Provider:是否逐 endpoint 鉴权、只返回可读资源默认值,并在运行/发布时重新检查资源与版本。
+12. golden 和 adapter 等价测试:是否证明没有行为漂移。
+
+## 14. 实施 TODO
+
+当前 TODO 交付 workflow-core、workflow-cli、Web Adapter、PR5 Workflow 辅助生成 Demo 和必要的 FastGPT API 改造。Agent 通过 Shell 使用 CLI;PR5 只注入单一内置 `workflow-builder` Skill,通用 MCP Adapter 不在 PR1 到 PR7 范围内。PR 是增量开发与审核单元,不等于发布单元;PR1 只提供内部技术 Demo,PR4 完成后才发布本地 CLI Beta。
+
+共享校验能力集中在 PR2 完成:一次性抽取 FastGPT Web 现有工作流规则,建立 Web 与 CLI 共用的 Validator 和新旧结果等价测试。PR1 只保留最小 Demo 所需的结构检查;PR3 以后只为新增图语义或远端场景补充规则,不再重复建设校验框架。
+
+### PR1:最小可用 Demo CLI
+
+- [x] T1 创建 `packages/workflow-core`、`packages/workflow-cli` package 及构建测试配置。
+- [x] T2 用 `basic-ai` 固化 Web 当前 WorkflowStart、AI Chat、必填输入、普通边、基础变量引用和 Start 可达性行为。
+- [x] T3 定义最小 WorkflowDocument、Diagnostic、ExecutionPortRef、VariableRef、Command 和 Descriptor。
+- [x] T4 实现普通 semantic edge parser/compiler/decompiler 和 StoreWorkflow compile/decompile。
+- [x] T5 实现 builtin template provider、Automation Metadata、Descriptor 归一化和完整节点实例化。
+- [x] T6 支持 WorkflowStart、AI Chat、Text Editor、Assigned Answer 四种基础模板。
+- [x] T7 实现 `node add --after`、`input set/ref` 和基础 WorkflowCommand dispatcher。
+- [x] T8 实现 PR1 Demo 所需的最小 Store/Document/Graph/Reference 结构检查,不在本 PR 迁移完整 Web Validator。
+- [x] T9 实现 `workflow.json` Schema codec、schemaVersion 和单文件原子 IO。
+- [x] T10 实现 `init/build/template list/template show/node list/node show/node add/input set/input ref/validate`、`--dry-run`、JSON 输出和基础退出码。
+- [x] T10.1 统一默认工作流初始化:`init` 创建 SystemConfig + WorkflowStart,`import` 对旧工作流补齐 SystemConfig,且不覆盖 `chatConfig`。
+- [x] T11 增加 `basic-ai`、`basic-static` CLI 端到端和 golden round-trip 测试。
+- [x] T12 增加 Command Registry/help/JSON snapshot、value option 互斥、Descriptor runtime isolation、命令失败不写盘、确定性构建、构建后真实 bin 冒烟和 PR1 新旧校验结果等价测试。
+
+### PR2:常用线性工作流
+
+- [x] T13 实现 node update/remove/clone 和普通 edge connect/disconnect/reconnect。
+- [x] T14 实现 import/inspect、App 元数据、ChatConfig、全局变量和 available variables。
+- [x] T14.1 为全局变量补齐 `--type`、`external -> custom` 别名、类型专属配置和更新兼容规则,覆盖 Web 变量类型模型。
+- [x] T15 接入知识库搜索、问题优化、内容提取、HTTP、代码和调用应用等常用线性节点中可独立落地的部分。
+- [x] T16 完善 `input set/ref/unset`、常用复杂参数 `valueSchema` 和基础删除副作用。
+- [x] T17 集中抽取 FastGPT Web 现有工作流校验为共享 Validator,覆盖节点必填参数、输入输出、边合法性、变量引用、Start 可达性和删除残留关系。
+- [x] T18 建立常用线性工作流 fixtures 和 CLI 端到端测试。
+- [x] T19 建立 Web Validation Adapter,对同一 fixture 比较新旧校验结果;等价后让 Web 与 CLI 共同调用共享 Validator,但不一次性迁移全部 Web mutation action。
+
+### PR3:复杂图语义
+
+- [x] T20 实现 branch/sourceOutput/catch/tool edge。
+- [x] T21 实现 add-after 扩展、insert 和复杂 reconnect。
+- [x] T22 实现动态 input/output 和清边副作用。
+- [x] T23 实现 nesting rules、父子同步和系统子节点。
+- [x] T24 迁移分支、catch、工具边、动态 IO、父子关系和循环校验,并增加 branching、tool-call-tools、nested-loop、dynamic-io-catch fixtures。
+- [x] T25 逐动作迁移 Web editor 到 shared commands。
+- [x] T26 完成复杂图 Web/CLI StoreWorkflow、阻断结果、warning 和 diagnostics 等价测试。
+- [x] T26.1 修复数据引用的 outputKey/outputId 编解码,覆盖结构化引用、文本占位符、嵌套配置和 Store 往返回归测试。
+
+### PR4:自动化与门禁
+
+- [ ] T27 完善 WorkflowDocument schemaVersion 兼容策略和 `workflow.json` 迁移指引。
+- [ ] T28 实现基于规范化 WorkflowDocument 的 canonical checksum。
+- [ ] T29 实现 stdin ChangeSet plan/apply、单命令/多命令统一编排和 baseChecksum。
+- [ ] T30 实现 TTY/non-TTY Confirm gate。
+- [x] T31 固化模板输入值来源优先级、资源安全空值、Start 引用不覆盖、共享引用类型兼容规则、单一结构校验和独立 Binding Collector;CLI validate/build 输出稳定 warning 且不合成资源值。
+- [ ] T32 增加 workflow file/ChangeSet/Confirm/CI、审计字段和失败零写入测试,并补齐本地 CLI Beta 端到端验收。
+- [ ] T33 完成本地 CLI Beta 的安装、升级、回滚和端到端验收。
+
+### PR5:Workflow 辅助生成 Demo
+
+- [ ] T34 定义 `WorkflowBuilderChatBodySchema` 和 Workflow Builder SSE/ChangeSet 输出契约,只接收当前轮 messages、model、WorkflowDocument 和 checksum。
+- [ ] T35 在 Pro 中实现独立 `handleWorkflowBuilderChat`、Runtime 构造和 API route,复用底层 Chat/Workflow 能力但不抽取或修改 Skill Handler。
+- [ ] T36 实现 App Sandbox prepare action,每轮写入当前 `workflow.json`,注入版本匹配的 CLI 构建产物,并确保不同 chatId 的 Sandbox 隔离。
+- [ ] T37 实现 Pro 内置 `workflow-builder` Skill,约束 Agent 先查询 Descriptor、只调用 JSON CLI、不直接编辑 Document/StoreWorkflow。
+- [ ] T38 恢复普通 Chat 历史、memories、plan/ask、模型选择、SSE、停止和 usage,并通过现有 Chat round 流程持久化。
+- [ ] T39 在 Handler 中对 Sandbox 输出的 ChangeSet 执行 Schema、base checksum、graph 和 reference 二次校验,禁止未确认的远端保存/发布。
+- [ ] T40 在 Workflow 编辑器接入独立 ChatBox、模型选择、历史恢复和最小 WorkflowPlanCard,不实现节点选中上下文和复杂 diff UI。
+- [ ] T41 用 Web Adapter/Core 将已确认 ChangeSet 应用到当前画布,覆盖 checksum 过期、人工修改保留、刷新恢复、计费单次上报和 Skill 路径零回归。
+
+### PR6:远端只读能力
+
+- [ ] T42 设计 profile 和密钥读取,不在配置文件明文保存 key。
+- [ ] T43 为 detail、preview、versions 和 dataset/model/app/tool 资源解析增加 API Key 只读契约与权限测试,所有响应禁止包含 Secret 值。
+- [ ] T44 实现 team app/system tool/remote tool provider 和只读 Resource Resolver,返回远端参数 Schema/Descriptor;仅把当前 profile 已鉴权且具有读取权限的非 Secret 资源写入 `validatedInputDefaults`。
+- [ ] T45 实现 remote pull、versions 和 template preview。
+- [ ] T46 增加远端模板版本、读取权限、资源可见性、未授权/已删除资源默认值回退为空、pull 本地冲突和 StoreWorkflow 反编译校验测试。
+- [ ] T47 完成 remote read beta 验收。
+
+### PR7:远端写入与运行
+
+- [ ] T48 为 update、publish 和 debug 增加 API Key 写入契约与权限测试。
+- [ ] T49 为 publish schema 增加 baseVersionId。
+- [ ] T50 在事务内实现版本比较和 409 冲突。
+- [ ] T51 实现 remote meta push、draft save 和 publish。
+- [ ] T52 实现 run 和 JSON step debug。
+- [ ] T53 完成 runtime/publish Validator,重新验证 PR6 已解析资源,增加并发、权限撤销、资源删除、跨团队复制、资源引用、draft/publish 门禁差异的服务端端到端测试。
+- [ ] T54 完成 remote write beta 的发布与回滚验收。
+
+## 15. 开发开始前的阻断条件
+
+以下三项没有确认前,不进入源代码实现:
+
+1. 接受 `WorkflowDocument` 为唯一 CLI 规范状态,初期直接持久化为单文件 `workflow.json`;分片 Manifest 不进入 PR1 到 PR7。
+2. 接受执行边使用 `@next/@target/...`,变量引用使用 `node.output`,两者彻底分开。
+3. 接受按 PR1 到 PR7 增量落地,PR1 只作为内部技术 Demo,PR4 后发布本地 CLI Beta,PR5 接入 Workflow 辅助生成 Demo,PR6/PR7 再开放远端能力。
+
+确认后,实际开发从 T1 开始,并按 TODO 逐项更新状态。
diff --git "a/.agents/design/core/workflow/workflow-cli-builder-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/.agents/design/core/workflow/workflow-cli-builder-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md"
new file mode 100644
index 000000000000..9eb9d0d884f5
--- /dev/null
+++ "b/.agents/design/core/workflow/workflow-cli-builder-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -0,0 +1,1614 @@
+# Workflow CLI Builder 需求设计文档
+
+## 0. 文档标识
+
+- 文档状态:方案评审稿
+- 修订日期:2026-07-20
+- 目标仓库:FastGPT
+- 目标对象:FastGPT Workflow 的本地构建、自动化修改、Web 辅助生成、远端保存、发布与调试
+- 关联开发文档:`workflow-cli-builder-功能开发文档.md`
+- 文档范围:需求边界、CLI 规范与总体架构
+
+## 1. 结论先行
+
+CLI 不能被设计成一套脱离 FastGPT 前端的 JSON 拼装器。它必须是 FastGPT 工作流编辑领域能力的第二个调用端:
+
+```text
+FastGPT Web Editor ─┐
+ ├─> Shared Headless Workflow Core ─> WorkflowDocument ─> StoreWorkflow
+FastGPT Workflow CLI ┘
+```
+
+本方案锁定以下决策:
+
+1. Web 与 CLI 共享同一套节点、执行边、变量引用、嵌套、删除副作用和校验规则。
+2. ReactFlow 只负责画布交互,不作为共享领域模型。
+3. 执行边与变量引用是两种不同关系,必须使用不同类型和命令。
+4. `WorkflowDocument` 是 CLI 内存中的唯一规范状态,初期直接序列化为单文件 `workflow.json`。
+5. `WorkflowChangeSet` 只描述对 `WorkflowDocument` 的命令集合,不是第二份工作流状态。
+6. `StoreWorkflow` 是编译结果和 FastGPT 现有保存、发布、运行接口的输入。
+7. `workflow.json` 只记录状态,约束由共享代码和校验命令执行;分片 Manifest 仅作为后期实验指标证明有必要时的可选优化。
+8. 本地编辑命令默认原子写入,统一支持 `--dry-run`;远端发布和 AI 生成的批量变更需要确认门禁。
+9. 第一阶段只交付本地核心闭环,远端鉴权和并发控制完成后再开放远端写入。
+10. 新建和导入工作流必须包含唯一且不可删除的系统配置节点;变量和开关仍以 `chatConfig` 为唯一事实源,系统配置节点只提供 Web 编辑入口。
+11. PR5 在 Workflow 编辑器内提供独立的辅助生成 Demo:复用现有 Chat、Agent Loop、Sandbox、Skill 和计费基础设施,但不抽象或改写 Skill 辅助生成的业务 Handler。
+
+## 2. 目标与设计约束
+
+### 2.1 产品目标
+
+用户能够用 CLI 自动化完成一个 FastGPT 工作流从零到可运行的全过程,包括:
+
+- 选择节点模板并创建完整节点。
+- 给节点设置固定值、变量引用和节点专用配置。
+- 建立普通执行边、分支边、异常边和工具边。
+- 将节点插入已有边,或在创建节点时直接接到现有节点后。
+- 创建和维护循环、批处理等嵌套结构。
+- 删除、克隆、移动节点,并正确处理关联边和子节点。
+- 校验节点配置、图连通性、引用有效性和运行前置条件。
+- 将本地草稿保存为可审查、可版本控制的 `workflow.json`。
+- 拉取、保存、发布、调试和运行远端 FastGPT 应用。
+- 让 AI 只生成可审查的 ChangeSet,再由确定性代码应用。
+
+### 2.2 设计约束
+
+FastGPT Web 已经具备完整的画布操作。CLI 需要将分布在 React Context、ReactFlow hook、节点模板 UI、校验工具和 API 中的领域规则收敛为共享能力,并满足以下约束:
+
+- 节点创建必须包含模板默认输入和远端模板完整数据。
+- 变量引用与执行边使用独立的数据结构和命令。
+- 删除输出、节点或父容器时执行确定性的清理副作用。
+- 嵌套关系遵循与 Web 相同的领域规则。
+- CLI 与 Web 使用同一套运行、发布校验规则。
+- 远端命令只使用已明确支持并经过权限测试的 API Key 契约。
+
+第一阶段先建立可被 Web 和 CLI 共同调用的工作流领域核心,再实现命令行入口。
+
+## 3. FastGPT 现状事实基线
+
+以下事实来自当前代码,后续实现不得用推测替代。
+
+| 领域 | 当前代码 | 已确认行为 | 对 CLI 的要求 |
+| --- | --- | --- | --- |
+| 编辑器初始化 | `projects/app/src/pageComponents/app/detail/Workflow/index.tsx` | 由 `appDetail.modules` 和 `appDetail.edges` 初始化 ReactFlow | CLI 使用存储态,不依赖 ReactFlow Node/Edge |
+| 编辑器能力分层 | `WorkflowComponents/context/index.tsx` | Init、Actions、Utils、Debug、Persistence 等 Context 叠加 | 共享领域规则由 workflow-core 承载 |
+| 节点修改 | `workflowActionsContext.tsx` | 支持属性、输入、输出增删改;删除或替换输出会清边 | CLI 节点命令必须产生相同副作用 |
+| 图操作 | `Flow/hooks/useWorkflow.tsx` | 连接、删除、复制、插入、嵌套均有额外规则 | CLI 不能只对数组做 CRUD |
+| 嵌套节点 | `useWorkflow.tsx`、`useNestedNode.ts` | 维护 `parentNodeId` 和 `childrenNodeIdList`;移入容器会清边 | 嵌套命令必须原子更新父子关系和边 |
+| 节点模板 | `Flow/components/NodeTemplates/useNodeTemplates.tsx` | 模板包含内置节点、团队应用、系统工具 | 模板引用必须区分来源,不能只用 `flowNodeType` |
+| 模板实例化 | `NodeTemplates/list.tsx` | 远端模板先取完整 preview;内置模板补默认输入引用和本地化文案 | CLI 应复用模板提供器和实例化规则 |
+| 普通执行边 | `ConnectionHandle.tsx`、`getHandleId()` | 常规 source/target handle 使用节点 ID、方向和类型生成 | CLI 接收语义端口,内部编译 handle |
+| 输出执行边 | `RenderOutput/Label.tsx` | 仅 `output.type === source` 的输出产生执行 handle | 普通变量输出不能被当作执行端口 |
+| 工具边 | `NodeCard.tsx`、`ToolHandle.tsx`、`NodeTemplatesPopover.tsx` | toolCall 节点提供 source,工具节点提供 target;两端 handle 均为 `selectedTools` | 工具调用连边是专用执行边,不能泛化成任意 Agent 连边 |
+| 异常边 | `CatchError.tsx` | source 类型为 `source_catch` | CLI 要有 `catch` 端口语义 |
+| 变量引用 | 工作流 input/reference 逻辑 | 引用值是 `[nodeId, outputId]`,存储在节点输入中 | 使用 `input ref`,不创建 StoreEdge |
+| 新增后连边 | `NodeTemplatesPopover.tsx` | 从 handle 新增节点后,会自动添加一条边 | CLI 的 `node add --after` 必须是原子操作 |
+| 新增默认引用 | `NodeTemplates/list.tsx` | 常见输入会默认引用 workflowStart 的用户文本或文件 | CLI 与 Web 应生成相同默认值 |
+| 删除父节点 | `useWorkflow.tsx` | 同时删除子节点及所有关联边 | CLI `node remove` 默认级联 |
+| 条件循环 | `useWorkflow.tsx`、校验函数 | 条件 loopRun 至少保留一个 loopRunBreak | CLI 在修改和校验阶段都要阻止非法状态 |
+| 校验 | `projects/app/src/web/core/workflow/utils.ts` | `checkWorkflowNodeAndConnection` 同时检查节点和连通性,但输入是 ReactFlow 类型 | 使用 Store/Document 级共享校验器 |
+| 草稿保存 | `Header.tsx`、`SaveButton.tsx` | 保存到云端不强制执行完整校验,允许保存未完成草稿 | CLI 必须区分 draft save 与 publish |
+| 运行和发布 | `Header.tsx` | Run 和 Save and publish 会先调用检查 | CLI run/publish 必须经过阻断式校验 |
+| 发布权限 | `version/publish.ts` | 要求写权限;发布时检查 Agent Skill 读取权限 | CLI 不得绕过资源权限 |
+| 只读详情 | `api/core/app/detail.ts` | 只有读权限、没有写权限时返回空 nodes/edges | CLI pull 工作流图实际需要写权限 |
+| API 鉴权 | detail、preview、publish、debug API | 当前主要开启 `authToken: true`,未统一开启 `authApiKey` | 远端 CLI 上线前必须补 API Key 契约或明确只支持 session token |
+| 包边界 | `.agents/code/syntax.md` | `packages/global/core` 主要承载类型和常量 | 共享领域实现位于独立 package |
+
+### 3.1 当前校验层级与复用边界
+
+当前 FastGPT 没有统一的工作流 Validator。校验职责分散在以下三层:
+
+| 当前层级 | 当前职责 | 代表代码 | CLI 设计处理 |
+| --- | --- | --- | --- |
+| Web 前端层 | 节点配置、必填输入、变量引用、特殊节点规则、执行边和图连通性 | `projects/app/src/web/core/workflow/utils.ts` 的 `checkWorkflowNodeAndConnection()` | 复用规则本身,将纯规则下沉到 workflow-core;CLI 不直接依赖 ReactFlow 函数 |
+| API Schema 层 | 请求字段、枚举、ObjectId、nodes/edges/chatConfig 基本结构 | OpenAPI/Zod Schema 和 `parseApiInput` | 保留 API 边界校验,不能替代工作流领域校验 |
+| Service 层 | App 权限、资源引用、Agent Skill 读取权限、发布限制和版本写入 | `authApp`、`beforeUpdateAppFormat`、publish controller | 继续留在服务端;CLI 本地校验结果不能替代服务端权限和发布校验 |
+
+当前主要图校验调用链:
+
+```text
+ReactFlow Nodes/Edges
+ -> checkWorkflowNodeAndConnection()
+ -> 节点标红 / toast
+ -> uiWorkflow2StoreWorkflow()
+ -> FastGPT API
+```
+
+`checkWorkflowNodeAndConnection()` 不能被 CLI 直接 import,原因是它接收 ReactFlow Node/Edge、读取 sourceHandle/targetHandle、只返回首个错误节点,并在部分分支中修改 input value。CLI 建设不重新发明校验准则,而是通过 Characterization tests 固化现有行为,再把判断规则迁移为不依赖 UI、文件系统和网络的纯 Validator:
+
+```text
+FastGPT Web -> Web Adapter -------┐
+ v
+ WorkflowDocument
+ |
+CLI workflow.json -> File Codec --┘
+ v
+ Shared Workflow Validator
+ |
+ WorkflowDiagnostic[]
+
+FastGPT API / Service -> Schema、权限、资源、运行和发布校验
+```
+
+目标拆分:
+
+- `validateWorkflowSchema()`:Document 与字段结构。
+- `validateWorkflowDocument()`:节点、模板实例、必填配置和动态 key。
+- `validateWorkflowGraph()`:执行端口、重复边、连通性、分支、工具边和循环规则。
+- `validateWorkflowReferences()`:变量存在性、类型、上游可达性和父级作用域。
+- `validateWorkflowRuntime()`:模型、sandbox 和外部资源能力,需要运行环境上下文。
+- `validateWorkflowPublish()`:调试工具、Agent Skill 和远端资源权限,最终由服务端执行。
+
+校验策略不能对所有命令一刀切:
+
+| 场景 | 阻断规则 | 非阻断诊断 |
+| --- | --- | --- |
+| 普通本地 mutation | Schema、ID、端口、父子一致性、本次参数类型和引用格式 | 整体连通性、其他节点尚未补齐的业务参数 |
+| `build` / ChangeSet apply | schema、document、graph、reference | runtime、publish |
+| `remote save --draft` | schema、document、服务端请求结构和权限 | graph、reference、runtime、publish |
+| `remote publish` / `run` | schema、document、graph、reference、runtime、publish | 仅明确标记为 warning 的诊断 |
+
+每条 mutation 在内存中生成 `Next WorkflowDocument` 后执行对应策略。存在阻断错误时,不写 `workflow.json`、不调用远端写接口;Validator 只返回完整 `WorkflowDiagnostic[]`,不得修改 Document。Web 将诊断转换为节点高亮和 toast,CLI 将同一诊断渲染为 text 或 JSON。
+
+### 3.2 本次 Binding 职责拆分影响域
+
+| 维度 | 是否命中 | 证据 | 结论 |
+| --- | --- | --- | --- |
+| API | No | 本地 `validate/build` 不调用远端 Resolver | Not Applicable,PR6 再设计鉴权接口 |
+| Data | No | Binding 不写入 WorkflowDocument、StoreWorkflow 或数据库 | Not Applicable,无迁移 |
+| Frontend | No | 本次只改 workflow-core 和 workflow-cli | Not Applicable,Web 行为不切换 |
+| Logging | No | Binding 只进入 CLI result/warnings,且不包含实际值 | Not Applicable,不新增日志通道 |
+| Packaging | Yes | 新增 `packages/workflow-core/src/binding/*` 并从 package 入口导出 | 保持 browser-safe,不引入 IO/服务端依赖 |
+| Testing | Yes | Core Binding/reference/validation 与 CLI e2e 都需回归 | 真实运行 CLI,不 mock 本地领域逻辑 |
+| DocI18n | No | 未增加用户可见文案,诊断使用稳定 code | Not Applicable,无 i18n key 变更 |
+
+## 4. 范围和分档
+
+### 4.1 档位 A:共享领域核心和本地 CLI 闭环
+
+必须优先完成:
+
+- `WorkflowDocument`、语义执行端口、变量引用、命令和结果模型。
+- StoreWorkflow 的导入、编译与语义等价 round-trip。
+- 内置模板实例化。
+- 内置模板参数的机器可读 Descriptor 和 `template show --format json`。
+- 节点新增、修改、删除。
+- 普通执行边连接、断开。
+- 输入固定值和变量引用。
+- 共用的 Store/Document 级校验器。
+- 本地文件读写、原子保存、`--dry-run` 和 JSON 输出。
+
+### 4.2 档位 B:完整图编辑语义
+
+- 分支边、输出 source 边、catch 边、工具边。
+- `node add --after`、`node insert`、reconnect。
+- 动态输入输出及清边副作用。
+- clone、批量删除、嵌套移动。
+- loop、loopRun、batch 等父子节点自动创建与约束。
+- 团队应用、插件、MCP/HTTP 工具等远端模板提供器。
+- custom/object 参数的 JSON Schema、示例和远端模板 Descriptor。
+
+### 4.3 档位 C:ChangeSet 和 Confirm 门禁
+
+- ChangeSet diff、plan、apply。
+- AI 生成 ChangeSet,不直接生成最终 StoreWorkflow。
+- 对 base checksum 和 target checksum 做确认门禁。
+- 确认后内容发生变化时自动使确认失效。
+
+### 4.4 档位 D:Workflow 辅助生成 Demo
+
+- Workflow 编辑器内的独立聊天入口。
+- 复用普通聊天的历史恢复、模型选择、SSE、停止、计费和 Agent Loop。
+- 使用 App 归属 Sandbox,注入当前 `workflow.json`、固定版本 CLI 和内置 `workflow-builder` Skill。
+- Agent 只生成可预览、可确认的 `WorkflowChangeSet`,由 Web Adapter/Core 应用到当前画布。
+- PR5 不自动保存、发布或调试,不支持节点选中上下文、文件上传或复杂 diff 编辑。
+
+### 4.5 档位 E:远端生命周期
+
+- profile/login/whoami。
+- pull、draft save、publish、versions。
+- debug step、run。
+- API Key 鉴权、写权限、资源权限。
+- `baseVersionId` 乐观并发控制和 409 冲突。
+
+### 4.6 后期可选优化:分片 Manifest
+
+分片 Manifest 不进入当前 PR1 到 PR7 的开发、测试和验收范围。初期统一使用单文件 `workflow.json`,先通过真实工作流实验验证以下问题是否实际存在:
+
+- 大型工作流导致 Git diff 难以审核。
+- 多人并行编辑单文件产生高频冲突。
+- Agent 读取完整文件造成不可接受的上下文开销。
+- 单文件原子写入或恢复不能满足实际可靠性要求。
+
+只有实验数据证明上述问题达到需要优化的程度,才新增可选的 Manifest Codec。该 Codec 只负责 `WorkflowDocument` 的分片序列化和组装,不改变 Command、Validator、StoreWorkflow Compiler 和远端 API 契约。
+
+### 4.7 档位 F:团队与 CI
+
+- 非交互模式和稳定退出码。
+- 结构化审计日志。
+- CI validate/build/diff/publish。
+- 策略配置,例如禁止调试工具发布、允许节点类型白名单。
+
+### 4.8 第一版明确不做
+
+- 不在 CLI 内实现可视化画布。
+- 不把 ReactFlow viewport、选择态、toast、modal、节点宽高放进共享核心。
+- 不在 v1 中创建、删除 FastGPT App;先针对本地文档和已有 App。
+- 不在 v1 中自动布局复杂图;节点位置先由模板默认、显式参数或简单确定性偏移产生。
+- CLI 本体不实现通用 Skill 或 MCP Adapter;PR5 只在产品层注入单一内置 `workflow-builder` Skill,Agent 仍通过 Shell 调用 CLI 并读取结构化输出。
+- 不允许用户在常规命令中手写 `sourceHandle` 和 `targetHandle`。
+- 不让大模型直接写数据库格式并绕过确定性命令。
+
+## 5. CLI 整体架构与实现逻辑
+
+### 5.1 架构目标
+
+整体架构采用“一个共享领域核心、两个直接调用端、两个持久化落点”:
+
+- 一个共享领域核心:`@fastgpt/workflow-core`,集中执行节点、执行边、变量引用、嵌套、副作用和校验规则。
+- 两个直接调用端:FastGPT Web Adapter 和 `fastgpt-workflow` CLI。
+- 两个持久化落点:本地单文件 `workflow.json` 和正在运行的 FastGPT 服务。
+- Agent 位于 CLI 上游;PR1 到 PR4 保证具备 Shell 能力的 Agent 可直接调用 CLI,PR5 再将同一能力接入 FastGPT Workflow 编辑器。
+
+核心原则:
+
+1. Web、CLI 和 Agent 最终都执行同一套 WorkflowCommand。
+2. CLI 只做命令解析、流程编排、文件 IO、HTTP 和结果输出。
+3. workflow-core 只做确定性领域计算,不依赖 React、文件系统和网络。
+4. 本地工作流与服务端工作流通过 StoreWorkflow 编译结果对接。
+5. 所有高风险变更先 plan、validate,再 confirm 和 apply。
+
+### 5.2 整体大架构
+
+```mermaid
+flowchart TB
+ subgraph Interaction["交互层"]
+ User["用户 / 自动化脚本"]
+ Agent["Agent 自然语言交互"]
+ Web["FastGPT Web 编辑器"]
+ end
+
+ subgraph Access["接入与编排层"]
+ CLI["fastgpt-workflow CLI"]
+ WebAdapter["Web Workflow Adapter"]
+ end
+
+ subgraph Domain["共享领域层"]
+ Core["@fastgpt/workflow-core"]
+ end
+
+ subgraph State["状态与编译层"]
+ Document["WorkflowDocument"]
+ WorkflowFile["workflow.json"]
+ Store["StoreWorkflow"]
+ end
+
+ subgraph Server["FastGPT 服务端"]
+ APIClient["FastGPT API Client"]
+ API["FastGPT HTTP API"]
+ Service["FastGPT Service"]
+ DB["MongoDB / App Version"]
+ end
+
+ User --> CLI
+ Agent --> CLI
+ Web --> WebAdapter
+ CLI --> Core
+ WebAdapter --> Core
+ Core --> Document
+ Document --> WorkflowFile
+ Document --> Store
+ Store --> APIClient --> API --> Service --> DB
+```
+
+Agent 不形成第二套工作流引擎。具备 Shell 能力的 Agent 直接调用 CLI,并读取稳定 JSON 输出;PR5 的内置 `workflow-builder` Skill 只规定 Agent 如何使用 CLI,实际修改仍由 CLI 和 workflow-core 完成。通用 MCP Adapter 不属于当前开发范围。
+
+### 5.3 详细架构设计
+
+```mermaid
+flowchart LR
+ Agent["Agent / 用户"] --> CLI["CLI Command Router"]
+ Web["FastGPT Web"] --> WebAdapter["Web Adapter"]
+
+ subgraph Core["共享 Workflow Core"]
+ Template["Template System
模板发现、参数描述、节点实例化"]
+ Command["WorkflowCommand"]
+ Mutation["Graph Mutation Engine
节点、边、参数、引用、嵌套"]
+ NextDocument["Next WorkflowDocument"]
+ Validator["Validator"]
+ Compiler["StoreWorkflow Compiler"]
+ end
+
+ subgraph Local["本地工作流"]
+ WorkflowFile["workflow.json"]
+ FileAdapter["Workflow File IO
Schema 解析、原子写入"]
+ Document["WorkflowDocument"]
+ end
+
+ subgraph RemoteSide["远端 FastGPT"]
+ Remote["Remote Client"]
+ Server["FastGPT API / Service"]
+ end
+
+ Output["Text / JSON Output"]
+
+ CLI --> Template
+ Template -->|"template show"| Output
+ CLI --> Command
+ WebAdapter --> Command
+ Template -->|"模板与参数契约"| Command
+
+ WorkflowFile --> FileAdapter --> Document --> Command
+ Command --> Mutation --> NextDocument --> Validator
+
+ Validator -->|"查看结果"| Output
+ Validator -->|"local apply"| FileAdapter
+ Validator -->|"remote apply"| Compiler --> Remote --> Server
+```
+
+精简后的详细架构只保留两条关键链路:
+
+- 模板查询链路:`CLI -> Template System -> 参数描述 -> Text / JSON 输出`。
+- 工作流修改链路:`workflow.json -> WorkflowDocument -> WorkflowCommand -> Graph Mutation Engine -> Validator`;校验通过后,根据命令选择 `local apply` 原子写回 `workflow.json`,或 `remote apply` 编译为 StoreWorkflow 并提交 FastGPT 服务。
+
+Automation Metadata 只进入 Descriptor 归一化和参数校验,不进入节点实例化、WorkflowDocument、`workflow.json` 或 StoreWorkflow。
+
+### 5.4 组件职责
+
+| 组件 | 位置 | 核心职责 | 明确不负责 |
+| --- | --- | --- | --- |
+| Workflow CLI | `packages/workflow-cli` | 参数解析、命令编排、本地 IO、远端 HTTP、输出和退出码 | 节点与图规则 |
+| Workflow Core | `packages/workflow-core` | 模板实例化、Command、图操作、引用、嵌套、校验、编译 | ReactFlow、fs、HTTP、终端交互 |
+| Web Adapter | `projects/app/.../WorkflowComponents/adapters` | ReactFlow 与 WorkflowDocument 转换、UI action 适配、诊断展示 | 重复实现领域规则 |
+| Template Provider | core 接口 + Web/CLI 实现 | 提供内置模板、团队应用、系统工具的完整模板和可选自动化元数据 | 直接修改工作流 |
+| Template Descriptor | `packages/workflow-core/src/template` | 将现有模板字段与 Automation Metadata 归一化为机器可读参数契约 | 修改 Web 模板或进入 StoreNode |
+| Workflow File IO | `packages/workflow-cli/src/io` | `workflow.json` 的 Schema 解析、确定性序列化和单文件原子写入 | 判断节点是否合法 |
+| Remote Client | `packages/workflow-cli/src/remote` | profile、鉴权、pull、save、publish、debug、run | 直接读写数据库 |
+| FastGPT API | 现有 Next API 与 Service | 权限、资源校验、版本写入和运行调试 | 信任客户端已完成校验 |
+
+### 5.5 分层调用规则
+
+依赖方向固定为:
+
+```text
+Agent -> Workflow CLI -> Workflow Core -> @fastgpt/global
+FastGPT Web -> Web Adapter -> Workflow Core -> @fastgpt/global
+Workflow CLI -> FastGPT API -> Service -> Database
+```
+
+禁止出现:
+
+- workflow-core 引用 workflow-cli。
+- workflow-core 引用 projects/app、ReactFlow 或 packages/service。
+- CLI command handler 直接拼 `sourceHandle/targetHandle`。
+- Agent 绕过 CLI 直接写 StoreWorkflow 或 MongoDB。
+- FastGPT API 信任客户端传入的 `validated: true`、confirm 状态或权限结论。
+- CLI 专用 Automation Metadata 写入 `FlowNodeInputItemTypeSchema`、WorkflowDocument、`workflow.json` 或 StoreWorkflow。
+
+### 5.6 数据流与对象关系
+
+工作流状态、变更指令和编译结果必须分开:
+
+```text
+WorkflowChangeSet
+ -> apply
+WorkflowDocument
+ -> serialize / parse -> workflow.json
+ -> build -> StoreWorkflow
+ -> save / publish / run -> FastGPT Service
+```
+
+- `WorkflowChangeSet` 回答“要执行哪些动作”。
+- `WorkflowDocument` 回答“当前完整工作流是什么”。
+- `workflow.json` 回答“工作流如何在本地持久化”。
+- `StoreWorkflow` 回答“如何提交给 FastGPT”。
+
+任何时候都只能有一个规范工作流状态,即 `WorkflowDocument`。`workflow.json` 是它的单文件序列化结果,StoreWorkflow 是它的编译结果。
+
+### 5.7 单条命令执行流程
+
+以创建节点并自动连边为例:
+
+```bash
+fastgpt-workflow node add \
+ --dir ./flow \
+ --template builtin:ai-chat \
+ --node answer \
+ --after start@next
+```
+
+内部执行顺序:
+
+1. CLI parser 校验参数并确定工作目录。
+2. Workflow File IO 读取并解析 `workflow.json` 为 `WorkflowDocument`。
+3. CLI 将参数转换为 `AddNodeCommand`,其中包含 templateRef、nodeId 和 connectFrom。
+4. workflow-core 通过 Template Provider 解析完整节点模板。
+5. command dispatcher 创建完整节点、默认输入引用和系统子节点。
+6. dispatcher 创建 `start@next -> answer@target` 语义执行边。
+7. validator 检查节点 ID、模板、端口、父子关系和图约束。
+8. workflow-core 返回新 Document、变更摘要、warning 和 checksum。
+9. `--dry-run` 只输出结果;普通模式通过临时文件和原子 rename 写回 `workflow.json`。
+10. 若执行 `remote save/publish`,CLI 编译 StoreWorkflow 并调用 FastGPT API。
+
+步骤 4 到步骤 7 任一失败,Document 不落盘,远端 API 也不会被调用。
+
+### 5.8 本地构建模式
+
+本地模式不要求 FastGPT 服务运行:
+
+```text
+workflow.json -> WorkflowDocument -> Command -> Validate -> workflow.json / StoreWorkflow JSON
+```
+
+适用场景:
+
+- 在代码仓库中维护工作流。
+- 使用 Git review 工作流变化。
+- CI 执行 validate/build。
+- 批量生成可导入的 workflow JSON。
+- Agent 在沙箱中先生成并审查工作流,不直接影响服务端。
+
+本地 mutation 默认原子写入,所有 mutation 支持 `--dry-run`。
+
+### 5.9 FastGPT 服务端模式
+
+服务端模式通过 HTTP API 操作一个正在运行的 FastGPT 实例。该实例可以位于公网、内网,也可以是 `localhost`。
+
+```text
+WorkflowDocument -> Build StoreWorkflow -> FastGPT API -> App Version
+```
+
+规则:
+
+- CLI 不直接连接 MongoDB。
+- pull、save、publish 和 debug 都经过 FastGPT 权限系统。
+- draft save 与 publish 使用不同校验门禁。
+- `baseVersionId` 用于阻止覆盖其他客户端的新版本。
+- 服务端重新执行 schema、权限、资源和发布校验。
+- 当前 v1 面向已有 App;创建和删除 App 不属于 v1。
+
+### 5.10 Web 共用逻辑
+
+FastGPT Web 不调用 CLI 进程,而是通过 Web Adapter 直接调用 workflow-core:
+
+```text
+ReactFlow action -> WorkflowCommand -> Workflow Core -> WorkflowDocument -> ReactFlow state
+```
+
+Web Adapter 负责坐标、选择态、viewport、toast、modal 和错误高亮。节点创建、连边、引用、嵌套和校验规则由 workflow-core 负责。
+
+同一初始 Document 和同一 WorkflowCommand,Web 与 CLI 必须生成语义相同的 StoreWorkflow。
+
+### 5.11 Agent 接入逻辑
+
+Agent 不作为另一套工作流引擎。PR1 到 PR4 只保证具备 Shell 能力的 Agent 直接调用 `fastgpt-workflow`,并读取稳定 JSON 输出;PR5 将这条路径接入 Workflow 编辑器,但不改变 WorkflowCommand、ChangeSet 和 Core 的事实边界。通用 MCP Adapter 不进入当前验收范围。
+
+Agent 的标准执行循环:
+
+1. 查询模板及其输入输出约束。
+2. 根据用户需求生成 `WorkflowChangeSet`。
+3. 调用 `changeset plan` 获取变更摘要和诊断。
+4. 根据诊断调整 ChangeSet,直到静态校验通过。
+5. 向用户展示 plan。
+6. 获得确认后执行 apply。
+7. 根据用户要求停留在本地、保存草稿或发布到 FastGPT。
+
+Agent 只能提出和调用结构化动作。checksum、权限、发布校验和并发冲突必须由 CLI/Core/API 独立执行。
+
+### 5.12 PR5 Workflow 辅助生成接入
+
+PR5 是独立产品模块,只模仿 Skill 辅助生成的运行顺序,不抽取、改造或共用 `handleSkillDebugChat` 的业务 Handler。它可以直接复用现有底层函数和组件,包括 `ChatBox`、`getChatItems`、`preChatRound`、`dispatchWorkFlow`、Agent Loop、Sandbox tools、usage 和 `finalizeChatRound`。
+
+```text
+Workflow ChatBox
+ -> independent Workflow Builder API/Handler
+ -> restore normal chat histories and Agent memories
+ -> build WorkflowStart -> Agent runtime
+ -> prepare App sandbox
+ -> write current workflow.json
+ -> inject version-matched fastgpt-workflow CLI
+ -> inject builtin workflow-builder Skill
+ -> Agent Loop calls CLI
+ -> server revalidates WorkflowChangeSet
+ -> SSE returns plan/diagnostics
+ -> Web Adapter/Core applies confirmed ChangeSet to current canvas
+```
+
+PR5 请求只携带当前轮消息、模型和画布事实;历史对话、Agent memories、计费和生成状态继续按普通 Chat 机制恢复:
+
+```ts
+type WorkflowBuilderChatBody = {
+ appId: string;
+ chatId: string;
+ responseChatItemId?: string;
+ messages: ChatCompletionMessageParam[];
+ model?: string;
+ workflowContext: {
+ document: WorkflowDocument;
+ checksum: string;
+ };
+};
+```
+
+PR5 不传入 `mode`、不单独建模“修改记录”、不传节点选中上下文。Sandbox 只修改当前 Document 的副本;未经用户确认时不得更新画布或调用保存/发布 API。应用时必须重新比较 `baseChecksum`,用户人工修改导致过期时只作废当前 plan,不限制之后继续使用辅助生成。
+
+### 5.13 Workflow Core 抽取与阶段交付
+
+`@fastgpt/workflow-core` 是 Web 和 CLI 共同依赖的 TypeScript 领域包,不是独立部署的服务。它围绕 `WorkflowDocument` 提供确定性的模板实例化、节点编辑、执行边编辑、变量引用、嵌套、副作用、校验和 StoreWorkflow 转换能力。
+
+Core 的状态关系固定为:
+
+```text
+workflow.json
+ <-> parse / serialize
+WorkflowDocument
+ <-> compile / decompile
+StoreWorkflow
+
+ReactFlow State
+ <-> Web Adapter
+WorkflowDocument
+```
+
+其中:
+
+- `workflow.json` 是 `WorkflowDocument` 的单文件持久化表达,不是第二套领域模型。
+- `StoreWorkflow` 是 FastGPT 当前保存、发布和运行所需的编译结果。
+- ReactFlow State 是 `WorkflowDocument` 加上 Web 画布运行状态;selected、viewport、节点测量尺寸、toast 和 debug 展示信息不得进入 Core。
+- Core 复用 `StoreNodeItemType` 保存完整节点,以语义执行端口代替面向用户暴露的 ReactFlow handle。
+
+PR1 只抽取能够构建基础线性工作流的最小 Core 纵向切片,并交付本地 CLI Demo,不宣称完成全部 Workflow Core。首批节点固定为:
+
+| 节点 | 用途 | PR1 必要能力 |
+| --- | --- | --- |
+| WorkflowStart | 工作流入口,提供用户问题和文件等系统输出 | 初始化、默认引用、Start 可达性校验 |
+| AI Chat | 调用模型生成结果 | 模板实例化、模型/提示词参数、用户输入引用 |
+| Text Editor | 提供静态文本或拼接文本 | 固定值输入、变量引用 |
+| Assigned Answer | 将固定内容或上游结果作为最终回答 | 固定值/引用输入、普通执行边 |
+
+PR1 必须打通以下完整链路:
+
+```text
+CLI flags
+ -> WorkflowCommand
+ -> applyWorkflowCommand()
+ -> WorkflowDocument
+ -> validate
+ -> compileStoreWorkflow()
+ -> workflow.generated.json
+```
+
+PR1 的 Core 操作范围:
+
+- 创建和解析 `WorkflowDocument`,规范化并计算基础 checksum。
+- 解析内置模板并实例化完整 `StoreNodeItemType`。
+- 添加节点,并通过 `connectFrom` 原子地连接普通 `next -> target` 执行边。
+- 设置 input 固定值、设置 VariableRef、查询节点和模板。
+- 编译和反编译普通 StoreEdge,完成 StoreWorkflow 语义往返。
+- 执行最小 Schema、节点、图、引用和 Start 可达性校验。
+- 返回结构化 changes、warnings、diagnostics 和稳定退出码。
+
+后续能力按增量 PR 进入同一 Core,不创建第二套编辑引擎:
+
+| 阶段 | Core 新增操作 |
+| --- | --- |
+| PR2 | node update/remove/clone、edge connect/disconnect/reconnect、input unset、App 元数据、ChatConfig、全局变量、完整线性图 Validator |
+| PR3 | branch/sourceOutput/catch/tool edge、insert、动态 input/output、嵌套和循环、系统子节点、Web action 逐步迁移 |
+| PR4 | ChangeSet plan/apply、canonical checksum、Confirm、并发写入保护、CI 契约 |
+| PR5 | Workflow 编辑器 ChatBox、独立 Builder Handler、App Sandbox、内置 Skill、CLI 调用、ChangeSet 预览/应用 |
+| PR6 | 远端模板 Provider、StoreWorkflow pull/decompile、版本查询 |
+| PR7 | draft save、publish、run/debug 前校验和远端版本冲突处理 |
+
+PR1 的验收结果必须是:仅通过 CLI 即可创建 Start -> AI Chat 或 Start -> Text Editor -> Assigned Answer 基础工作流,生成可被当前 FastGPT 识别的 StoreWorkflow;命令失败不写盘,Store -> Document -> Store 语义等价。
+
+## 6. 核心对象模型
+
+### 6.1 WorkflowDocument
+
+`WorkflowDocument` 是 CLI 和共享核心使用的唯一规范状态。
+
+```ts
+type WorkflowDocument = {
+ schemaVersion: 'fastgpt-workflow/v1';
+ app: {
+ appId?: string;
+ name?: string;
+ intro?: string;
+ appType?: string;
+ baseVersionId?: string;
+ };
+ nodes: StoreNodeItemType[];
+ executionEdges: WorkflowExecutionEdge[];
+ chatConfig: AppChatConfigType;
+};
+```
+
+约束:
+
+- `nodes` 使用 FastGPT 完整存储节点,不发明精简节点格式。
+- `executionEdges` 使用语义执行端口,不在用户格式中暴露 ReactFlow handle。
+- 变量引用保留在节点 input value 中,不进入 `executionEdges`。
+- `app` 只保存本地绑定与并发基线,不替代 FastGPT App 数据。
+- 编译器将该文档转换为现有 `{ nodes, edges, chatConfig }`。
+
+### 6.2 StoreWorkflow
+
+`StoreWorkflow` 是现有 FastGPT 保存、发布、运行链路所需的编译结果:
+
+```ts
+type StoreWorkflow = {
+ nodes: StoreNodeItemType[];
+ edges: StoreEdgeItemType[];
+ chatConfig: AppChatConfigType;
+};
+```
+
+它不是主要手工编辑格式。`fastgpt-workflow build` 负责生成它,`fastgpt-workflow import` 负责把现有 StoreWorkflow 反编译为 `WorkflowDocument`。
+
+### 6.3 NodeTemplateRef
+
+模板来源必须显式区分:
+
+```ts
+type NodeTemplateRef =
+ | { kind: 'builtin'; templateId: string }
+ | { kind: 'teamApp'; appId: string; versionId?: string }
+ | { kind: 'systemTool'; toolId: string; versionId?: string }
+ | { kind: 'tool'; toolId: string; parentId?: string; versionId?: string };
+```
+
+模板实例化必须输出完整 `StoreNodeItemType`,并执行与 Web 一致的默认输入引用、节点名、本地化文案、父节点和系统子节点处理。
+
+#### 6.3.1 模板输入初始值优先级
+
+节点输入初始值必须由统一解析器决定,禁止各模板、CLI handler 或 Agent 自行猜测。值来源优先级固定为:
+
+1. 用户显式提供的值。
+2. PR6 远端 Template Provider 在当前 profile 下完成鉴权、存在性和读取权限验证后返回的值。
+3. 原始模板中与部署环境、团队和用户无关的安全默认值。
+4. 与 `valueType` 匹配的安全空值。
+
+规则:
+
+- 用户显式提供 `[]`、`''`、`false` 或 `0` 也视为明确输入,不得使用空值合并运算覆盖。
+- PR6 前不存在可信远端值,第二优先级必须跳过;本地 CLI 不得把示例值、名称或自然语言推断结果当成资源 ID。
+- `dataset/model/app/tool/secret` 等资源型输入必须由 Automation Metadata 声明;未验证资源不得从模板默认值写入节点。
+- 资源输入的安全空值分别为:`selectDataset -> []`,其他单值资源和 secret 保持 `undefined`,工具资源不创建工具边。
+- Secret 永远使用 `userRequired`,即使到 PR6 也不得通过 Template Provider 返回或写入默认值。
+- HTTP URL 等用户环境参数不是远端资源 ID,但没有真实输入时同样保持为空,不得写入 `example.com` 等演示地址。
+- Start 默认引用属于确定性图推导,不参与上述四级值来源竞争;只在输入仍为空、目标允许 reference 且源输出类型兼容时补充,且不得覆盖模板值或用户值。
+- `defaultValue`、模板 `input.value` 和已验证远端值只作为实例化输入;来源元数据不得进入 WorkflowDocument、StoreWorkflow 或数据库。
+
+该优先级解决“创建节点时写入什么”,不证明远端资源在运行时持续可用。PR6 负责读取时验证,PR7 的 debug/run/publish 和服务端仍必须重新验证资源存在性与权限。
+
+### 6.4 NodeTemplateDescriptor
+
+现有 FastGPT 模板已经包含 `key`、`label`、`description`、`toolDescription`、`valueType`、`required`、`defaultValue`、`renderTypeList`、`list`、`min/max` 等参数信息。CLI 不复制这些字段,而是将它们归一化为稳定的机器可读契约:
+
+```ts
+type NodeTemplateDescriptor = {
+ template: NodeTemplateRef;
+ name: string;
+ intro?: string;
+ flowNodeType: string;
+ inputs: NodeParameterDescriptor[];
+ outputs: NodeOutputDescriptor[];
+ constraints: {
+ unique: boolean;
+ isTool: boolean;
+ allowedParents?: string[];
+ };
+};
+
+type NodeOutputDescriptor = {
+ id: string;
+ key: string;
+ label: string;
+ description?: string;
+ valueType?: string;
+ required: boolean;
+ executable: boolean;
+};
+
+type NodeParameterDescriptor = {
+ key: string;
+ label: string;
+ description: string;
+ valueType?: string;
+ required: boolean;
+ defaultValue?: unknown;
+ defaultPolicy: 'template' | 'userRequired' | 'remoteValidated';
+ resourceKind?: 'dataset' | 'model' | 'app' | 'tool' | 'secret';
+ bindingRequired: boolean;
+ configurable: boolean;
+ inputModes: Array<'literal' | 'reference' | 'secret'>;
+ enum?: Array<{
+ label?: string;
+ value: string;
+ description?: string;
+ }>;
+ constraints?: {
+ min?: number;
+ max?: number;
+ minLength?: number;
+ maxLength?: number;
+ valueSchema?: Record;
+ };
+ examples?: unknown[];
+};
+```
+
+归一化规则:
+
+- `description` 优先使用 `toolDescription`,其次使用 `description`,最后回退到 `label`。
+- `inputModes` 由 `renderTypeList` 转换,不要求 Agent 理解 Web 组件枚举。
+- `required/defaultValue/enum/min/max` 直接来自现有模板结构字段。
+- hidden 不直接等于不可配置;是否由系统维护通过 Automation Metadata 显式声明。
+- `description` 只解释含义,类型、范围和复杂结构必须使用结构化字段表达。
+- i18n 文案按照 `--locale` 解析后输出,不把翻译 key 交给 Agent。
+- secret 参数只输出约束和说明,不输出当前值或默认密钥。
+
+现有模板缺少的复杂参数信息使用独立补充层:
+
+```ts
+type NodeTemplateAutomationMeta = {
+ inputs?: Record<
+ string,
+ {
+ configurable?: boolean;
+ agentHint?: string;
+ valueSchema?: Record;
+ examples?: unknown[];
+ defaultPolicy?: 'template' | 'userRequired' | 'remoteValidated';
+ resourceKind?: 'dataset' | 'model' | 'app' | 'tool' | 'secret';
+ bindingRequired?: boolean;
+ }
+ >;
+};
+```
+
+隔离要求:
+
+- 不向 `FlowNodeInputItemTypeSchema` 添加 CLI/Agent 专用字段。
+- 不为了 Agent 批量修改现有模板的 `label`、`description`、`required`、`valueType`、`renderTypeList` 和默认值。
+- Automation Metadata 只参与 `normalizeNodeTemplateDescriptor()`,不得被 `nodeTemplate2FlowNode()` 展开到节点实例。
+- Automation Metadata 不进入 WorkflowDocument、`workflow.json`、StoreWorkflow 和数据库。
+- Web 可继续使用原始 `FlowNodeTemplateType`;Descriptor 层不改变 Web 渲染和保存结果。
+- 未声明 `defaultPolicy` 的历史 Automation Metadata 按 `template` 解释;资源型输入必须显式声明,避免通过 valueType 为 `string` 的模型、应用等字段漏检。
+- `defaultPolicy` 只描述初始值来源,不承担必填语义;当原始模板的 `required` 不能表达运行前必须配置时,使用 `bindingRequired` 显式补充。
+
+### 6.5 ExecutionPortRef
+
+执行端口只描述控制流:
+
+```ts
+type ExecutionSourcePortRef =
+ | { kind: 'next'; nodeId: string }
+ | { kind: 'branch'; nodeId: string; branchKey: string }
+ | { kind: 'sourceOutput'; nodeId: string; outputKey: string }
+ | { kind: 'catch'; nodeId: string }
+ | { kind: 'selectedTools'; nodeId: string };
+
+type ExecutionTargetPortRef =
+ | { kind: 'target'; nodeId: string }
+ | { kind: 'selectedTools'; nodeId: string };
+
+type WorkflowExecutionEdge = {
+ source: ExecutionSourcePortRef;
+ target: ExecutionTargetPortRef;
+};
+```
+
+CLI 文本格式:
+
+| 语义 | 文本格式 | 编译结果 |
+| --- | --- | --- |
+| 普通后继 | `start@next` | `${startId}-source-right` |
+| 普通目标 | `ai@target` | `${aiId}-target-left` |
+| 分支 | `if1@branch:else` | `getHandleId(if1, 'source', 'else')` |
+| source 输出 | `node@output:success` | `getHandleId(node, 'source', 'success')` |
+| 异常 | `node@catch` | `getHandleId(node, 'source_catch', 'right')` |
+| 工具 | `agent@tools -> tool@tools` | 两端 handle 均为 `selectedTools` |
+
+规则:
+
+- `sourceOutput` 只允许引用 `output.type === source` 的输出。
+- 普通输出变量不能用 `edge connect`。
+- 普通执行边不能连接工具目标,工具边也不能连接普通目标。
+- 自连接、重复边和不存在的节点/端口必须被拒绝。
+
+### 6.6 VariableRef
+
+变量引用描述数据流:
+
+```ts
+type VariableRef = {
+ nodeId: string;
+ outputKey: string;
+};
+```
+
+CLI 文本格式为 `nodeId.outputKey`。例如:
+
+```bash
+fastgpt-workflow input ref --node ai --key text --from start.userChatInput
+```
+
+该命令修改 `WorkflowDocument` 中 `ai` 节点的 input value,保存为语义引用
+`[startNodeId, userChatInput]`,不会创建执行边。`WorkflowDocument` 和 `workflow.json` 始终使用稳定的
+`output.key`;编译 StoreWorkflow 时必须把结构化引用和 `{{$nodeId.outputKey$}}` 文本引用转换为
+FastGPT Web/Runtime 使用的 `output.id`,反编译时再转换回 `output.key`。全局变量引用
+`[VARIABLE_NODE_ID, variableKey]` 不参与转换,执行边的 source output 也继续使用 `output.key`。
+
+### 6.7 WorkflowCommand 与 WorkflowChangeSet
+
+```ts
+type WorkflowChangeSet = {
+ schemaVersion: 'fastgpt-workflow-changeset/v1';
+ baseChecksum: string;
+ commands: WorkflowCommand[];
+};
+
+type WorkflowCommand =
+ | UpdateAppMetaCommand
+ | AddNodeCommand
+ | UpdateNodeCommand
+ | RemoveNodeCommand
+ | MoveNodeCommand
+ | CloneNodeCommand
+ | ConnectExecutionEdgeCommand
+ | DisconnectExecutionEdgeCommand
+ | ReconnectExecutionEdgeCommand
+ | InsertNodeCommand
+ | SetInputValueCommand
+ | SetInputReferenceCommand
+ | UnsetInputCommand
+ | AttachToolCommand
+ | DetachToolCommand
+ | AddGlobalVariableCommand
+ | UpdateGlobalVariableCommand
+ | RemoveGlobalVariableCommand
+ | UpdateChatConfigCommand;
+```
+
+所有命令由同一个 dispatcher 执行,并返回:
+
+```ts
+type WorkflowCommandResult = {
+ document: WorkflowDocument;
+ changes: WorkflowChangeSummary[];
+ warnings: WorkflowDiagnostic[];
+ checksum: string;
+};
+```
+
+### 6.8 本地工作流文件
+
+初期使用单文件 `workflow.json` 直接保存 `WorkflowDocument`:
+
+```text
+workflow.json
+ <-> parse / serialize
+WorkflowDocument
+ -> build
+StoreWorkflow JSON
+```
+
+本地文件规则:
+
+- `workflow.json` 是本地唯一 source of truth,不额外保存第二份 Document 状态。
+- 写入前执行对应校验;存在阻断错误时不得覆盖原文件。
+- JSON 使用稳定字段顺序、固定缩进和末尾换行,便于 Git diff 和 checksum。
+- 写入临时文件并在同一文件系统内原子 rename,失败时保留原文件。
+- `workflow.generated.json` 是可选 build 输出,不是手工维护的 source of truth。
+
+文件格式不负责决定节点、端口、嵌套或发布是否合法,也不保存可被手工修改绕过的“已确认”状态,上述约束全部由代码执行。
+
+分片 Manifest 是后期可选 IO Codec,不进入初期命令面。若实验数据证明单文件在大型工作流 Git diff、并发冲突或 Agent 上下文方面存在实际瓶颈,可在不改变 `WorkflowDocument` 的前提下增加:
+
+```text
+WorkflowDocument <-> Optional Manifest Codec <-> workflow.manifest/
+```
+
+## 7. 详细功能需求
+
+| 需求编号 | 功能域 | 目标 |
+| --- | --- | --- |
+| FR-01 | 文档、配置与本地文件 | 创建、导入、配置、单文件持久化和编译工作流 |
+| FR-02 | 节点模板 | 查询并解析完整节点模板 |
+| FR-03 | 节点操作 | 新增、修改、克隆、移动、删除和插入节点 |
+| FR-04 | 执行边 | 管理普通、分支、异常和输出执行边 |
+| FR-05 | 工具连接 | 管理 toolCall 与工具节点之间的专用边 |
+| FR-06 | 输入、输出和变量引用 | 管理固定值、数据引用和动态 IO |
+| FR-07 | 嵌套容器 | 管理 loop、loopRun、batch、parallel 父子结构 |
+| FR-08 | 校验和诊断 | 提供 schema、graph、reference、runtime、publish 校验 |
+| FR-09 | ChangeSet 与 Confirm | 提供可审查、可确认、原子应用的批量修改 |
+| FR-10 | FastGPT 服务端生命周期 | pull、save、publish、versions、debug 和 run |
+| FR-11 | Workflow 辅助生成 | 通过 Chat + Agent Loop + Sandbox + Skill + CLI 生成并应用可审查 ChangeSet |
+
+### 7.0 CLI 公共契约
+
+CLI 公共契约一次性定义 PR1 到 PR7 的最终命令面。PR5 不新增 CLI 命令,只在产品层调用 PR4 稳定的 ChangeSet 契约。后续 PR 只按阶段开放命令,不改变已经发布的命令语法;未实现命令不得出现在正式帮助文本中,也不得返回伪成功结果。
+
+#### 7.0.1 命令语法
+
+统一语法:
+
+```text
+fastgpt-workflow [global options] [command options]
+```
+
+规则:
+
+- 正式 bin 只有 `fastgpt-workflow`,不提供 `workflow` 等别名。
+- 资源 ID 和引用统一使用显式选项,例如 `--node ai`、`--template builtin:ai-chat`、`--app APP_ID`,不混用位置参数。
+- 查询动作统一使用 `list/show/inspect/validate/diff`;创建和修改使用 `add/set/update/remove`;不提供语义模糊的 `edit`、`sync` 或通用 `exec`。
+- 创建命令遇到重复 ID 必须失败,更新命令遇到不存在 ID 必须失败;不提供隐式 upsert。
+- `--help` 和 `--version` 属于所有阶段的基础能力,帮助文本只展示当前版本已开放的命令。
+
+#### 7.0.2 公共选项
+
+| 选项 | 适用范围 | 默认值 | 语义 |
+| --- | --- | --- | --- |
+| `--dir ` | 本地工作流命令 | 当前目录 | 包含 `workflow.json` 的工作目录 |
+| `--format text\|json` | 所有命令 | `text` | stdout 的展示格式 |
+| `--locale ` | 需要人类文案的命令 | 系统 locale | Descriptor 和 text renderer 的语言 |
+| `--no-color` | text 输出 | 非 TTY 自动关闭 | 禁止 ANSI 颜色 |
+| `--quiet` | 所有命令 | `false` | 隐藏非必要进度信息,不隐藏结果或错误 |
+| `--dry-run` | mutation 命令 | `false` | 完整计算但不执行本地或远端写入 |
+| `--profile ` | 远端命令 | 无 | 选择 FastGPT 服务配置 |
+| `--output ` | 产生文件的命令 | 命令定义 | 产物文件路径,不用于控制终端格式 |
+| `--confirm ` | 受门禁保护的命令 | 无 | 确认目标 checksum |
+
+`--format` 与 `--output` 必须严格分离:前者控制 stdout,后者指定文件产物。JSON 模式推荐同时使用 `--no-color`,但 JSON 输出无论是否传该参数都不得包含 ANSI 字符。
+
+配置优先级固定为:命令行参数 > 环境变量 > profile > 内置默认值。CLI 不根据 TTY 自动切换 `--format`,Agent 和脚本必须显式使用 `--format json`。
+
+#### 7.0.3 值、引用和资源语法
+
+输入值参数互斥:
+
+| 参数 | 用途 |
+| --- | --- |
+| `--value ` | string、number、boolean 等标量,由 Descriptor 决定解析类型 |
+| `--value-json ` | object、array、custom 等结构化值 |
+| `--value-file ` | 从 UTF-8 文件读取;`-` 表示 stdin |
+| `--value-env ` | 从环境变量读取 secret,避免进入 shell history |
+| `--from ` | 设置 VariableRef,仅用于 `input ref` |
+
+引用格式:
+
+```text
+模板引用:builtin:ai-chat | teamApp:APP_ID | systemTool:TOOL_ID | plugin:PLUGIN_ID
+执行端口:start@next | route@branch:yes | request@catch | caller@tools
+变量引用:start.userChatInput | search.datasetQuote
+坐标:600,200
+边引用:--from start@next --to ai@target
+```
+
+CLI 不猜测字符串是否为 JSON、文件或变量引用,也不允许通过普通 `--value` 写入 secret。
+
+#### 7.0.4 完整命令目录
+
+| 领域 | 最终命令 | 类型 | 首次开放 |
+| --- | --- | --- | --- |
+| 基础 | `--help`、`--version` | 查询 | PR1 |
+| 文档 | `init`、`build` | 本地 mutation / 产物 | PR1 |
+| 文档 | `import`、`inspect` | 本地 mutation / 查询 | PR2 |
+| 文档 | `diff` | 查询 | PR4 |
+| 元数据 | `meta show`、`meta set` | 查询 / 本地 mutation | PR2 |
+| ChatConfig | `config list`、`config get`、`config set`、`config unset` | 查询 / 本地 mutation | PR2 |
+| 全局变量 | `variable list`、`variable add`、`variable update`、`variable remove` | 查询 / 本地 mutation | PR2 |
+| 模板 | `template list`、`template show` | 查询 | PR1;远端来源 PR6 |
+| 节点 | `node list`、`node show`、`node add` | 查询 / 本地 mutation | PR1 |
+| 节点 | `node update`、`node remove`、`node clone`、`node move` | 本地 mutation | PR2 |
+| 节点 | `node insert` | 本地 mutation | PR3 |
+| 执行边 | `edge list`、`edge connect`、`edge disconnect`、`edge reconnect` | 查询 / 本地 mutation | PR2;复杂端口 PR3 |
+| 输入 | `input show`、`input set`、`input ref`、`input unset`、`input available` | 查询 / 本地 mutation | set/ref PR1;其余 PR2 |
+| 动态输出 | `output list`、`output add`、`output remove` | 查询 / 本地 mutation | PR3 |
+| 工具连接 | `tool list`、`tool attach`、`tool detach` | 查询 / 本地 mutation | PR3 |
+| 嵌套 | `container children`、`node move --parent/--root` | 查询 / 本地 mutation | PR3 |
+| 校验 | `validate` | 查询 | PR1;完整层级 PR3/PR7 |
+| ChangeSet | `changeset plan`、`changeset apply` | 产物 / 本地 mutation + Confirm | PR4 |
+| Profile | `profile list`、`profile show`、`profile add`、`profile update`、`profile remove`、`profile test` | 本地配置 | PR6 |
+| 远端只读 | `remote pull`、`remote diff`、`remote versions` | 远端查询 / 本地 mutation | PR6 |
+| 远端写入 | `remote meta push`、`remote save`、`remote publish` | 远端 mutation | PR7 |
+| 调试运行 | `debug start`、`run` | 远端执行 | PR7 |
+
+`input available` 是查询指定节点输入可引用变量的唯一命令,不再提供重复的 `available-vars` 别名。
+
+#### 7.0.5 命令参数契约
+
+文档与配置:
+
+| 命令 | 必填参数 | 可选参数与关键约束 |
+| --- | --- | --- |
+| `init` | 无 | `--dir`、`--name`;默认创建系统配置节点和 WorkflowStart;目标目录已有 `workflow.json` 时失败 |
+| `import` | `--input ` | `--dir`;缺少系统配置节点时补齐但不覆盖 `chatConfig`;导入未知 handle 或不支持 schema 时阻断 |
+| `inspect` | 无 | `--dir`;只输出节点、边、引用、配置和诊断摘要 |
+| `diff` | `--input ` | `--dir`;输出语义差异,不比较 JSON 排序和展示坐标微调 |
+| `build` | `--output ` | `--dir`;先执行 schema/document/graph/reference 校验 |
+| `meta show` | 无 | `--dir` |
+| `meta set` | 至少一个 `--name/--intro` | `--dir`、`--dry-run`;只修改本地绑定元数据 |
+| `config list` | 无 | `--dir` |
+| `config get` | `--path ` | `--dir` |
+| `config set` | `--path` 与一个 value 参数 | `--dir`、`--dry-run`;path 必须在 ChatConfig allowlist |
+| `config unset` | `--path ` | `--dir`、`--dry-run` |
+| `variable list` | 无 | `--dir` |
+| `variable add` | `--key`、`--value-type` | `--type`、`--description`、`--required`、类型配置、value 参数、`--dry-run` |
+| `variable update` | `--key` 与至少一个更新字段 | `--type`、`--value-type`、类型配置;不允许修改为重复 key |
+| `variable remove` | `--key` | `--dry-run`;仍被引用时阻断并返回引用位置 |
+
+模板、节点与图:
+
+| 命令 | 必填参数 | 可选参数与关键约束 |
+| --- | --- | --- |
+| `template list` | 无 | `--source builtin\|team\|system\|plugin`、远端来源要求 `--profile` |
+| `template show` | `--template ` | `--version`、`--locale`、远端模板要求 `--profile` |
+| `node list` | 无 | `--dir`、`--type`、`--parent` |
+| `node show` | `--node ` | `--dir`;输出节点、Descriptor、执行端口和引用摘要 |
+| `node add` | `--template`、`--node` | `--name`、`--position`、`--after`;`--parent` 从 PR3 开放 |
+| `node update` | `--node` 与至少一个更新字段 | `--name`、`--position`、`--dry-run`;参数值修改应走 input 命令 |
+| `node remove` | `--node` | `--dry-run`;执行边、引用和子节点副作用必须进入变更摘要 |
+| `node clone` | `--node`、`--id` | `--offset`、`--position`、`--dry-run`;不复制不可复用 secret |
+| `node move` | `--node` | `--position` 可与 `--parent` 或 `--root` 组合;`--parent` 与 `--root` 互斥 |
+| `node insert` | `--from`、`--to`、`--template`、`--id` | `--position`、`--dry-run`;删除旧边并创建两条新边必须原子完成 |
+| `edge list` | 无 | `--dir`、`--node`、`--kind` |
+| `edge connect` | `--from `、`--to ` | `--dry-run`;重复边失败 |
+| `edge disconnect` | `--from`、`--to` | `--dry-run`;不存在边失败 |
+| `edge reconnect` | `--from`、`--old-to`、`--to` | `--dry-run`;断开和重连必须原子完成 |
+| `input show` | `--node`、`--key` | `--dir`;secret 只显示是否已配置 |
+| `input set` | `--node`、`--key` 与一个 value 参数 | `--dry-run`;value 参数互斥并按 Descriptor 校验 |
+| `input ref` | `--node`、`--key`、`--from ` | `--dry-run`;校验上游可达性、作用域和类型 |
+| `input unset` | `--node`、`--key` | `--dry-run`;required 或系统维护参数不可清除 |
+| `input available` | `--node`、`--key` | `--dir`;只返回该输入可合法引用的变量 |
+| `output list` | `--node` | `--dir` |
+| `output add` | `--node`、`--key`、`--value-type` | `--label`、`--description`、`--dry-run`;仅动态 IO 节点可用 |
+| `output remove` | `--node`、`--key` | `--dry-run`;同步清理关联执行边并报告变量引用 |
+| `tool list` | `--tool-call ` | `--dir` |
+| `tool attach` | `--tool-call` 与 `--template` 或 `--tool-node` | 两种工具来源互斥;创建节点和工具边必须原子完成 |
+| `tool detach` | `--tool-call`、`--tool-node` | `--dry-run`;默认只断开,不删除已存在工具节点 |
+| `container children` | `--node ` | `--dir`;返回系统子节点和普通子节点 |
+| `validate` | 无 | `--level schema\|document\|graph\|reference\|runtime\|publish`;远端层级要求 `--profile` |
+
+自动化与远端:
+
+| 命令 | 必填参数 | 可选参数与关键约束 |
+| --- | --- | --- |
+| `changeset plan` | `--file `、`--output ` | `--dir`;输出 base/target checksum、changes 和 diagnostics |
+| `changeset apply` | `--plan ` | `--dir`、`--dry-run`、`--confirm`;非 TTY 写入必须提供 target checksum |
+| `profile list` | 无 | 只显示名称、base URL 和 credential 来源,不显示密钥 |
+| `profile show` | `--name` | secret 只显示来源和配置状态 |
+| `profile add` | `--name`、`--base-url` 与 credential 来源 | credential 使用 `--api-key-env`,不接受明文 `--api-key` |
+| `profile update` | `--name` 与至少一个更新字段 | 不把解析后的密钥写入配置文件 |
+| `profile remove` | `--name` | profile 被当前命令使用时失败 |
+| `profile test` | `--name` | 只测试连通性和身份,不读取完整工作流 |
+| `remote pull` | `--app`、`--profile` | `--dir`、`--dry-run`;非空本地目录存在未同步变更时拒绝覆盖 |
+| `remote diff` | `--app`、`--profile` | `--dir`;比较本地 Document 与远端版本,不写入两端 |
+| `remote versions` | `--app`、`--profile` | `--limit`、`--cursor` |
+| `remote meta push` | `--app`、`--profile`、`--confirm` | `--dir`、`--dry-run`;只同步允许的应用资料字段 |
+| `remote save` | `--app`、`--profile`、`--draft` | `--dir`、`--dry-run`;携带 baseVersionId,允许 graph warning |
+| `remote publish` | `--app`、`--profile`、`--confirm` | `--dir`、`--version-name`、`--dry-run`;publish error 必须阻断 |
+| `debug start` | `--app`、`--entry`、`--profile` | `--input/--input-json/--input-file`;输出 JSON step 事件 |
+| `run` | `--app`、`--profile` 与一个 input 参数 | `--input/--input-json/--input-file` 互斥;执行完整运行校验 |
+
+#### 7.0.6 查询、写入和 Confirm
+
+- query 命令不得修改 Document、`workflow.json`、profile 或远端 App。
+- 本地 mutation 默认写入,统一支持 `--dry-run`,并以单条命令为原子事务。
+- 远端 mutation 统一支持 `--dry-run`;dry-run 不得调用任何写接口。
+- `changeset apply`、`remote meta push` 和 `remote publish` 受 checksum Confirm 保护。
+- `remote save --draft` 不要求 Confirm,但必须携带 `baseVersionId` 并显示 graph warnings。
+- 非 TTY 环境不允许等待输入;需要确认时必须显式传 `--confirm`。
+- 不提供通用 `--force`、`--yes` 或跳过权限/校验的参数。
+
+所有 mutation 必须转换为 WorkflowCommand 或明确的 CLI/remote mutation service,不允许 command handler 直接修改数组、JSON 或数据库。远端操作在客户端校验后,服务端仍重新执行权限和数据校验。
+
+### 7.1 FR-01:文档、配置与本地文件
+
+```bash
+fastgpt-workflow init --dir ./flow --name "客服助手"
+fastgpt-workflow import --input workflow.json --dir ./flow
+fastgpt-workflow inspect --dir ./flow
+fastgpt-workflow meta set --dir ./flow --name "客服助手" --intro "处理售后咨询"
+fastgpt-workflow config set --dir ./flow --path welcomeText --value "你好"
+fastgpt-workflow variable add --dir ./flow --key customerId --value-type string --required
+fastgpt-workflow variable add --dir ./flow --key quizResults --type internal --value-type arrayObject --value-json '[]'
+fastgpt-workflow variable add --dir ./flow --key theme --type select --value-type string --options-json '[{"label":"数学","value":"math"}]'
+fastgpt-workflow variable update --dir ./flow --key customerId --description "客户编号"
+fastgpt-workflow variable remove --dir ./flow --key customerId
+fastgpt-workflow build --dir ./flow --output workflow.generated.json
+```
+
+`import -> build` 必须保证语义等价,不要求 JSON 字段顺序完全一致。
+
+`config set` 只能修改 AppChatConfig schema 中列入 allowlist 的路径,不能实现成任意 JSON Pointer。全局变量命令维护 `chatConfig.variables`,并执行 key 唯一、valueType、required/defaultValue 等现有 schema 约束。
+
+全局变量使用两个正交维度,不增加第三个 `source` 参数:
+
+- `--type` 表示交互或作用域类型,取值与 `VariableInputEnum` 一致;CLI 额外接受 `external` 友好别名,落盘统一保存为现有 `custom`。
+- `--value-type` 表示数据结构,如 `string`、`number`、`object`、`arrayObject`。普通输入变量的两个参数可能看起来相同或相关,但语义不能合并。
+- 显式 `--type` 优先;省略时保留历史自动推断。更新 `--value-type` 时,只有旧变量仍是自动推断类型才重新推断,显式的 `internal/custom` 等类型必须保留。
+- `custom`、`internal` 和 `switch` 固定为非必填;CLI 不接受它们与 `--required` 的冲突组合。
+- 类型专属配置使用 `--config-json` 或 `--config-file`,两者互斥;`--options-json`、`--min`、`--max`、`--max-length`、`--time-granularity` 是高频快捷参数,并覆盖 JSON 配置中的同名字段。类型配置不得覆盖 `key/type/valueType/required/defaultValue` 等核心字段。
+
+`meta set` 只更新本地 App 绑定信息;`build` 只输出 StoreWorkflow,不把 name/intro 混入工作流 payload。远端同步元数据必须使用独立命令,避免一次 publish 意外覆盖应用资料。
+
+### 7.2 FR-02:节点模板
+
+```bash
+fastgpt-workflow template list --source builtin
+fastgpt-workflow template list --source team --profile prod
+fastgpt-workflow template show --template builtin:ai-chat --locale zh-CN --format json
+fastgpt-workflow template show --template teamApp:APP_ID --version VERSION_ID --format json
+```
+
+`template show` 必须输出 `NodeTemplateDescriptor`,包括输入、输出、动态字段、允许的执行端口、是否唯一节点、是否工具节点和嵌套限制。
+
+实现要求:
+
+- 普通参数从现有 FlowNodeTemplate inputs/outputs 归一化,不在 CLI 中维护第二份说明。
+- 内置模板可以离线生成 Descriptor。
+- 团队应用、插件和工具先通过 Template Provider 获取完整 preview,再生成 Descriptor。
+- custom/object/array 参数必须提供 `valueSchema` 或返回 `WORKFLOW_TEMPLATE_PARAMETER_SCHEMA_MISSING` warning,禁止让 Agent猜测结构。
+- `configurable: false` 的参数不得通过 `input set/ref/unset` 修改。
+- Descriptor 中不得包含 input 当前 secret、credential、Authorization 或其他敏感值。
+- 同一模板、版本和 locale 的 JSON 输出必须确定性稳定。
+
+示例输出:
+
+```json
+{
+ "template": {
+ "kind": "builtin",
+ "templateId": "ai-chat"
+ },
+ "inputs": [
+ {
+ "key": "systemPrompt",
+ "label": "系统提示词",
+ "description": "定义 AI 的角色、回答规则和限制",
+ "valueType": "string",
+ "required": false,
+ "configurable": true,
+ "inputModes": ["literal", "reference"],
+ "constraints": {
+ "maxLength": 100000
+ },
+ "examples": ["你是一个售后客服助手"]
+ }
+ ]
+}
+```
+
+### 7.3 FR-03:节点操作
+
+```bash
+fastgpt-workflow node add --template builtin:ai-chat --node ai --position 600,200
+fastgpt-workflow node add --template builtin:ai-chat --node ai --after start@next
+fastgpt-workflow node add --template builtin:if-else --node route --after start@next
+fastgpt-workflow node update --node ai --name "回答用户"
+fastgpt-workflow node clone --node ai --id ai_copy --offset 320,0
+fastgpt-workflow node move --node child --position 900,300
+fastgpt-workflow node move --node child --parent loop1 --position 120,180
+fastgpt-workflow node move --node child --root
+fastgpt-workflow node remove --node ai
+```
+
+`node add --after start` 的领域含义不是两个松散动作,而是一个原子命令:
+
+1. 解析模板。
+2. 创建完整节点。
+3. 补齐默认输入引用。
+4. 校验父节点和嵌套限制。
+5. 添加 `start@next -> newNode@target` 执行边。
+6. 任一步失败则整个命令不落盘。
+
+### 7.4 FR-04:执行边
+
+```bash
+fastgpt-workflow edge connect --from start@next --to ai@target
+fastgpt-workflow edge connect --from route@branch:yes --to success@target
+fastgpt-workflow edge connect --from request@catch --to fallback@target
+fastgpt-workflow edge reconnect --from start@next --old-to ai@target --to route@target
+fastgpt-workflow edge disconnect --from start@next --to ai@target
+fastgpt-workflow node insert --from start@next --to ai@target --template builtin:if-else --id route
+```
+
+`node insert` 必须原子完成:删除旧边、创建节点、建立前后两条边。失败时恢复原图。
+
+### 7.5 FR-05:工具连接
+
+```bash
+fastgpt-workflow tool attach --tool-call caller1 --template systemTool:webSearch
+fastgpt-workflow tool attach --tool-call caller1 --tool-node search1
+fastgpt-workflow tool detach --tool-call caller1 --tool-node search1
+fastgpt-workflow tool list --tool-call caller1
+```
+
+工具命令内部维护 `toolCall@tools -> toolNode@tools` 专用边,用户不接触内部 handle。Agent V2 的技能选择若存储在 `selectedTools` input 中,应走 input/专用 Agent Skill 命令,不能伪装成执行边。
+
+### 7.6 FR-06:输入、变量引用和动态 IO
+
+```bash
+fastgpt-workflow input set --node ai --key systemPrompt --value "你是客服助手"
+fastgpt-workflow input set --node code --key timeout --value-json 30
+fastgpt-workflow input ref --node ai --key text --from start.userChatInput
+fastgpt-workflow input unset --node ai --key text
+fastgpt-workflow input available --node ai
+fastgpt-workflow output add --node code --key score --value-type number
+fastgpt-workflow output remove --node code --key score
+```
+
+规则:
+
+- `input available` 复用 FastGPT 的上游可用变量语义,不能仅列出所有节点输出。
+- `input set/ref/unset` 根据当前节点的 `NodeParameterDescriptor` 校验参数,不只检查 key 是否存在。
+- `input set --value` 只接受与 `valueType` 兼容的标量;对象、数组和复杂配置使用 `--json`。
+- `input ref` 只允许 Descriptor 声明 `reference` inputMode 的参数,并检查来源输出类型。
+- `configurable: false`、deprecated 和系统维护参数禁止修改。
+- custom/object 参数按照 `constraints.valueSchema` 校验;缺少 Schema 时不得静默接受 Agent 猜测的数据。
+- 引用必须来自当前节点可达的上游节点、系统变量或合法父级作用域。
+- 动态 input/output key 在同一节点内唯一。
+- 删除或替换 `source` 类型输出时,必须同步删除从该 output handle 发出的执行边。
+- 普通数据输出即使被引用,也不产生执行边。
+
+### 7.7 FR-07:嵌套和容器
+
+```bash
+fastgpt-workflow container children --node loop1
+fastgpt-workflow node move --node worker --parent loop1
+fastgpt-workflow node move --node worker --root
+```
+
+共享核心必须覆盖前端现有规则:
+
+- workflowStart、loop/loopRun/parallel、插件输入输出、系统配置等节点不能被任意放进容器。
+- interactive 节点不能放入 parallel。
+- loopRunBreak 只能位于 loopRun。
+- 节点移入或移出父容器时按前端语义清理不满足新作用域约束的边。
+- 同时维护 child 的 `parentNodeId` 和 parent 的 `childrenNodeIdList`。
+- 新建 loopRun 等容器时自动创建系统子节点。
+- 删除容器时默认级联删除子节点和所有关联边。
+
+### 7.8 FR-08:校验和诊断
+
+```bash
+fastgpt-workflow validate --dir ./flow
+fastgpt-workflow validate --dir ./flow --level schema
+fastgpt-workflow validate --dir ./flow --level graph
+fastgpt-workflow validate --dir ./flow --level runtime --profile prod
+fastgpt-workflow input available --node ai --key userChatInput --dir ./flow
+```
+
+诊断必须包含稳定 code、severity、nodeId/edge、path 和 message。例如:
+
+```json
+{
+ "code": "WORKFLOW_EDGE_SOURCE_OUTPUT_NOT_EXECUTABLE",
+ "severity": "error",
+ "nodeId": "code1",
+ "path": "executionEdges[2].source",
+ "message": "Output score is a data output and cannot be used as an execution port"
+}
+```
+
+校验层级:
+
+1. `schema`:文件结构、枚举、必填字段。
+2. `document`:节点 ID、模板实例、动态 key、父子一致性。
+3. `graph`:端口、重复边、连通性、起点可达、工具边和循环规则。
+4. `reference`:变量存在、类型与作用域。
+5. `runtime`:sandbox、模型、外部资源和运行环境能力。
+6. `publish`:调试工具、Agent Skill 权限、远端资源权限。
+
+校验层级只回答“工作流结构是否合法”,资源绑定和可执行性是独立职责,不通过 `draft/strict` 模式改变同一条诊断的阻断级别:
+
+- `validateWorkflow(document)` 只检查 schema/document/graph/reference 等本地可确定规则,且保持单一签名。
+- `collectWorkflowBindings(document)` 收集 `missing/unverified` 绑定;未绑定资源不是结构错误,以 `WORKFLOW_BINDING_REQUIRED` 或 `WORKFLOW_BINDING_UNVERIFIED` warning 返回。
+- 本地 `validate` 和 `build` 只被结构 error 阻断;`build` 保留资源字段空值并输出待绑定清单,不声称产物已可执行。
+- `defaultPolicy=userRequired|remoteValidated` 的外部绑定不使用通用 `WORKFLOW_REQUIRED_INPUT_MISSING` 报错;是否需要绑定由模板 `required` 或 Metadata `bindingRequired` 决定。
+- PR6 Resolver 负责当前 profile 下的存在性和读取权限验证;PR7 的 debug/run/publish 通过 `assertWorkflowExecutable()` 重新解析并阻断未绑定、已删除或无权限资源。
+
+绑定诊断必须至少包含 `nodeId`、`inputKey`、`defaultPolicy` 和可用时的 `resourceKind`,且不得包含实际资源值或 Secret;禁止把 Mongoose `CastError` 等内部异常直接展示给用户。
+
+### 7.9 FR-09:ChangeSet 与 Confirm
+
+```bash
+fastgpt-workflow changeset plan --file changeset.json --dir ./flow --output plan.json
+fastgpt-workflow changeset apply --plan plan.json --dir ./flow --dry-run
+fastgpt-workflow changeset apply --plan plan.json --dir ./flow --confirm TARGET_CHECKSUM
+```
+
+门禁规则:
+
+- plan 记录 `baseChecksum`、命令列表、变更摘要、诊断和 `targetChecksum`。
+- 当前文档 checksum 与 `baseChecksum` 不一致时拒绝 apply。
+- 非交互环境 apply 高风险计划时必须传 `--confirm targetChecksum`。
+- plan 或目标文档发生任何变化后,旧 checksum 自动失效。
+- Confirm 是代码校验,不是在 `workflow.json` 中写一个 `confirmed: true`。
+
+### 7.10 FR-10:FastGPT 服务端生命周期
+
+```bash
+fastgpt-workflow profile add --name prod --base-url https://fastgpt.example.com --api-key-env FASTGPT_API_KEY
+fastgpt-workflow remote pull --app APP_ID --profile prod --dir ./flow
+fastgpt-workflow remote meta push --app APP_ID --profile prod --dir ./flow --confirm CHECKSUM
+fastgpt-workflow remote save --app APP_ID --profile prod --dir ./flow --draft
+fastgpt-workflow remote publish --app APP_ID --profile prod --dir ./flow --version-name v1 --confirm CHECKSUM
+fastgpt-workflow remote versions --app APP_ID --profile prod
+fastgpt-workflow debug start --app APP_ID --entry NODE_ID --profile prod
+fastgpt-workflow run --app APP_ID --input "你好" --profile prod
+```
+
+生命周期语义:
+
+- `remote pull`:要求能读取完整图;按当前权限行为应要求写权限。
+- PR6 远端 Template Provider 只可返回当前 profile 已鉴权、存在且可读的资源默认值;未授权、已删除或无法确认的值按未绑定处理。
+- `remote meta push`:只同步本地 name/intro 等允许字段,不修改 workflow graph。
+- `remote save --draft`:发送 `isPublish: false`、`autoSave: false`,创建一条可追踪版本;允许保存未完成草稿,但仍执行 schema 校验并输出 graph warning。Web 的后台 `autoSave: true` 语义不进入 CLI v1。
+- `remote publish`:阻断所有 publish error,并校验 debug tool 与资源权限。
+- `run`:执行完整运行前校验。
+- `debug`:保留 FastGPT 当前逐节点 runtimeNodes/runtimeEdges/variables 语义。
+- `pull/save/publish/debug` 在 API Key 契约完成前不得标记为正式可用。
+
+### 7.11 FR-11:Workflow 辅助生成
+
+- Workflow 编辑器提供独立聊天入口,复用普通 Chat 历史、模型选择、SSE、停止、计费和 Agent memories。
+- Workflow Builder 使用独立 Handler,不抽取或修改 Skill 辅助生成的 Handler;仅复用现有底层 Chat、Workflow Dispatch、Agent Loop 和 Sandbox 能力。
+- 每轮以前端当前 `WorkflowDocument + checksum` 为事实输入,历史由后端根据 `appId + chatId` 恢复,不建立独立修改记录模型。
+- Agent 只能通过内置 `workflow-builder` Skill 调用 `fastgpt-workflow` CLI;服务端必须对 ChangeSet 二次校验。
+- 前端只在 checksum 仍匹配时通过 Web Adapter/Core 应用已确认 ChangeSet;过期 plan 不得覆盖人工修改。
+- PR5 不支持节点选中上下文、自动保存/发布/调试或远端资源模板解析。
+
+## 8. 共享规则与 UI 边界
+
+### 8.1 必须进入共享核心
+
+- 节点模板实例化和默认 input 引用。
+- NodeTemplateDescriptor 归一化、Automation Metadata 合并和参数校验。
+- 执行端口解析与 StoreEdge 编译。
+- VariableRef 设置和有效性检查。
+- 动态 input/output 唯一性和清边副作用。
+- add/remove/clone/insert/reconnect。
+- 父子节点、childrenNodeIdList 和嵌套约束。
+- 工具边、catch 边、分支边。
+- App 元数据、ChatConfig 和全局变量的 schema 化修改。
+- 节点配置、引用、图连通性和发布前静态校验。
+- WorkflowDocument、`workflow.json`、ChangeSet、checksum。
+
+### 8.2 继续留在 Web
+
+- ReactFlow Node/Edge 与 Store/Document 的 UI adapter。
+- 鼠标拖拽、框选、快捷键、viewport、fitView。
+- toast、modal、错误节点高亮。
+- 节点尺寸、吸附、画布坐标交互。
+- 前端本地化显示和模板面板筛选 UI。
+- 原始 FlowNodeTemplate 的展示字段和 ReactFlow 节点持久化结构。
+
+Web 调用共享核心后,负责把 `WorkflowCommandResult.document` 转回 ReactFlow 状态并展示 `warnings/diagnostics`。
+
+Descriptor 接入不得改变同一模板创建出的 ReactFlow Node、StoreNode 和 StoreWorkflow。Web 是否未来展示 `examples/valueSchema` 属于独立需求,不是 CLI 参数发现能力的前置条件。
+
+## 9. 写入、输出和自动化契约
+
+### 9.1 本地写入
+
+- 所有本地 mutation 命令默认写入。
+- 所有本地 mutation 命令统一支持 `--dry-run`。
+- `workflow.json` 使用同目录临时文件加原子 rename;失败不得留下半写入文件或覆盖原文件。
+- 不提供通用 `--write`,避免与默认写入语义冲突。
+
+### 9.2 远端写入
+
+- 命令名必须明确包含 `remote save` 或 `remote publish`。
+- publish 在 TTY 中展示摘要并确认;非 TTY 要求 checksum。
+- 远端更新携带 `baseVersionId`;版本变化返回冲突,不自动覆盖。
+- `--force` 不进入 v1,冲突应先 pull/diff/rebase。
+
+### 9.3 结构化输出
+
+所有命令支持 `--format text|json`。JSON 至少包含:
+
+```ts
+type CliResult = {
+ schemaVersion: 'fastgpt-workflow-cli-result/v1';
+ ok: boolean;
+ command: string;
+ changed: boolean;
+ checksum?: string;
+ result?: unknown;
+ changes?: WorkflowChangeSummary[];
+ warnings?: WorkflowDiagnostic[];
+ errors?: WorkflowDiagnostic[];
+};
+```
+
+稳定退出码:
+
+| 退出码 | 含义 |
+| --- | --- |
+| 0 | 成功 |
+| 2 | 参数或 schema 错误 |
+| 3 | 领域命令冲突或非法操作 |
+| 4 | 工作流校验失败 |
+| 5 | 鉴权或权限失败 |
+| 6 | 远端版本冲突 |
+| 7 | 远端服务或网络错误 |
+
+## 10. 远端 API 前置改造
+
+远端 CLI 不是简单调用现有接口即可完成,至少需要:
+
+1. 为详情、应用资料更新、模板 preview、发布、debug 等 CLI 所需接口明确开启并测试 `authApiKey`。
+2. 继续复用 `authApp`、写权限和资源权限,不新增旁路鉴权。
+3. 详情接口的 CLI 文档明确“拉取完整图需要写权限”。
+4. 发布 body 增加可选 `baseVersionId`,服务端在事务内比较当前版本。
+5. 冲突返回稳定 409 error code,CLI 映射为退出码 6。
+6. OpenAPI schema、路由文档和客户端类型同步更新。
+7. 所有 API 入参继续使用 `parseApiInput`。
+
+在这些改造完成前,本设计只承诺本地 CLI。
+
+## 11. 交付顺序
+
+PR 是可审核、可回滚的增量开发单元,不等于发布单元。每个 PR 都必须保留此前行为并形成可独立演示的新增闭环;PR1 只作为内部技术 Demo,完成 PR4 后再发布本地 CLI Beta。
+
+共享校验能力集中在 PR2 完成:一次性把 FastGPT Web 现有工作流规则抽取为 Web 与 CLI 共用的 Validator,并建立新旧结果等价测试。PR1 只保留保证最小 Demo 可生成、可导出的结构检查;PR3 以后只为新增图语义或远端场景补充规则,不再重复建设校验框架。
+
+### PR1:最小可用 Demo CLI
+
+- 建立 `packages/workflow-core` 和 `packages/workflow-cli` 最小结构。
+- 定义基础 Document、Command、ExecutionPortRef、VariableRef、Descriptor 和 Diagnostic。
+- 实现 workflowStart、AI Chat、Text Editor、Assigned Answer 的模板实例化。
+- 实现 `init/build`、`template list/show`、`node list/show/add --after`、`input set/ref` 和 `validate`。
+- 实现 `workflow.json` Schema 解析、单文件原子写入、JSON 输出和基础退出码。
+- 用 Characterization tests 记录 PR2 需要复用的 Web 当前行为;PR1 只实现 Start、AI Chat、普通边和基础引用的最小结构检查。
+- 建立 `basic-ai` 和 `basic-static` 端到端测试。
+- 验收结果:CLI 可以真实构建并导出 `Start -> AI Chat`,但不作为正式用户版本发布。
+
+### PR2:常用线性工作流
+
+- 补齐普通节点 update/remove/clone 和普通边 connect/disconnect/reconnect。
+- 增加 App 配置、全局变量、`input available` 和基础 inspect/import。
+- 覆盖知识库搜索、问题优化、内容提取、HTTP、代码、调用应用等常用线性节点中可独立落地的部分。
+- 资源依赖节点只生成结构和安全默认值;用户未显式提供真实资源时保持空值,不得使用示例 ID 伪造可运行状态。
+- 集中抽取 FastGPT Web 现有工作流校验为共享 Validator,覆盖节点必填参数、输入输出、边合法性、变量引用、Start 可达性和删除残留关系;补充复杂参数 `valueSchema`、fixtures,并完成 Web/CLI diagnostics 等价测试。
+- 验收结果:CLI 可以构建具有实际业务价值的线性知识库问答和数据处理流程。
+
+### PR3:复杂图语义
+
+- 分支、source output、catch 和 tool edge。
+- insert、复杂 reconnect、动态 IO 及删除副作用。
+- loop、loopRun、batch、parallel 等嵌套容器和系统子节点。
+- 同步迁移分支、catch、工具边、动态 IO、父子关系和循环规则;逐动作迁移 Web editor 到 shared commands,并补齐复杂流程等价测试。
+- 验收结果:CLI 覆盖 FastGPT Web 编辑器的主要工作流图语义。
+
+### PR4:自动化与门禁
+
+- 完整 WorkflowDocument schemaVersion、单文件兼容策略和 canonical checksum。
+- ChangeSet diff/plan/apply、TTY/non-TTY Confirm。
+- 固化 local mutation、build、ChangeSet apply 的校验策略,以及 JSON envelope、错误 code、退出码、审计和 CI 用法。
+- 固化模板输入初始值优先级、资源型输入安全空值、单一结构校验和独立 Binding Collector;PR6 前本地构建产物不得被标记为远端可执行。
+- 验收结果:Agent 可以通过 Shell 安全执行计划、确认和批量修改;完成后发布本地 CLI Beta。
+
+### PR5:Workflow 辅助生成 Demo
+
+- 在 Workflow 编辑器中提供独立可收起 ChatBox,复用模型选择、历史恢复、SSE、停止和计费基础设施。
+- 在 Pro 中实现独立 Workflow Builder API、Handler、Runtime 和 Sandbox prepare action;只模仿 Skill 辅助生成顺序,不抽取或修改 Skill Handler。
+- 按 `sourceType=app`、`sourceId=appId`、`userId`、`chatId` 归属 Sandbox,注入当前 `WorkflowDocument`、与服务端匹配的 CLI 产物和内置 `workflow-builder` Skill。
+- 恢复普通 Chat 历史和 Agent memories,不建立独立修改记录模型;前端不传 `mode` 和节点选中上下文。
+- Agent 通过 CLI 生成 ChangeSet,服务端使用 workflow-core 重新校验,前端展示 plan/diagnostics 并在用户确认后通过 Web Adapter/Core 应用。
+- checksum 不匹配时只作废当前 plan,不覆盖人工修改;PR5 不自动保存、发布或运行工作流。
+- 验收结果:用户可在 Workflow 编辑器中通过多轮对话生成可审查 ChangeSet,并安全应用到当前画布。
+
+### PR6:远端只读能力
+
+- profile、密钥读取和 API Key 只读鉴权契约。
+- 远端 template preview、pull 和 versions。
+- 团队应用、系统工具和远端工具 Template Provider。
+- 数据集、模型、应用和工具的只读 Resource Resolver;返回存在性、当前团队可见性、读取权限和节点所需资源快照,不返回 Secret 值。
+- 远端 Provider 返回已鉴权且已验证读取权限的资源值,并作为模板输入值来源的第二优先级;失败时保持未绑定,不回退到虚构资源。
+- 增加远端模板版本、读取权限、资源可见性和 pull 反编译校验。
+- 验收结果:CLI 可以安全读取已有 FastGPT App 和远端模板,不执行远端写入。
+
+### PR7:远端写入与运行
+
+- remote meta push、draft save、publish、debug 和 run。
+- `baseVersionId`、事务内版本比较和 HTTP 409 冲突。
+- 完成 runtime/publish 校验,并在服务端二次执行权限、资源、版本和发布校验及端到端测试。
+- 对 PR6 已验证资源再次检查存在性和权限,处理绑定后删除、跨团队复制和权限撤销,不信任本地 validated 状态。
+- 验收结果:CLI 可以安全操作本地或云端 FastGPT 的完整远端生命周期。
+
+## 12. 验收标准
+
+### 12.1 本地核心
+
+以下条目是 PR1 到 PR3 的累计验收标准。PR1 只验收首批四类节点、普通 `next -> target` 执行边、基础引用、最小结构检查和 StoreWorkflow 语义往返;删除副作用、完整 Web Adapter 等价和真实工作流 import 分别在 PR2/PR3 按对应 TODO 验收。
+
+- 能从空文档创建 start、AI 节点并通过 `--after` 原子连边。
+- 新建工作流默认包含唯一的系统配置节点和 WorkflowStart;系统配置节点不可删除、复制或重复添加。
+- 变量与欢迎语、问题引导、文件选择、语音、定时触发、自动执行等开关统一存储在 `chatConfig`,并通过系统配置节点在 Web 中编辑。
+- 能从内置模板生成稳定的 NodeTemplateDescriptor,Agent 无需读取 React 组件即可理解参数。
+- `input set/ref` 按 Descriptor 的类型、inputMode、可配置性和 valueSchema 校验。
+- Automation Metadata 不出现在 ReactFlow Node、StoreNode、`workflow.json` 或 StoreWorkflow 中。
+- 同一模板创建出的 Web 节点和 StoreWorkflow 在接入 Descriptor 前后语义一致。
+- 用户显式值覆盖远端值和模板默认值;显式空值不得被覆盖。
+- 环境无关的模板默认值被保留,资源型模板默认值在 PR6 验证前保持安全空值。
+- Start 默认引用只补充空输入且必须类型兼容,不得覆盖已有值。
+- 本地 `validate/build` 允许资源待绑定并返回稳定 warning;普通结构错误仍阻断,且任何构建都不得合成资源值。
+- fixtures、示例和 Agent 生成流程不得包含虚构 dataset/app/tool ID、模型名、HTTP URL 或 secret。
+- 能明确区分 `start@next -> ai@target` 与 `start.userChatInput` 引用。
+- 删除输出、节点和容器时,副作用与 Web 一致。
+- 同一命令输入在 Web adapter 与 CLI 中生成语义相同的 StoreWorkflow。
+- 现有真实工作流 import/build 后通过语义等价测试。
+
+### 12.2 本地文件和 ChangeSet
+
+PR1 只验收 `workflow.json` 的确定性 round-trip、失败不覆盖原文件和基础 checksum。canonical checksum、ChangeSet base 校验与 Confirm 从 PR4 开始验收。
+
+- `workflow.json` parse/serialize round-trip 结果确定性稳定。
+- `workflow.json` 中的规范状态变化会改变 checksum,缩进和字段顺序变化不会改变 checksum。
+- ChangeSet base 不匹配时拒绝应用。
+- Confirm 不能被简单修改 `workflow.json` 字段绕过。
+
+### 12.3 远端
+
+- API Key 对 pull、preview、save、publish、debug 有明确测试结果。
+- 无写权限不能拉取完整图或写入。
+- 两个客户端基于同一版本更新时,后提交者收到 409 冲突。
+- draft save 可以保存未完成图;publish 和 run 会被完整校验阻断。
+
+### 12.4 测试样本
+
+至少覆盖五类真实导出工作流:
+
+1. start -> AI 的普通流程。
+2. ifElse/userSelect 的分支流程。
+3. agent/toolCall 与工具边。
+4. loop/loopRun/batch 的嵌套流程。
+5. 动态输入输出、变量引用和 catch edge。
+
+## 13. 风险与控制
+
+| 风险 | 影响 | 控制措施 |
+| --- | --- | --- |
+| Web 与 CLI 继续各写一套规则 | 行为漂移 | 共享 command/validator,做 adapter 等价测试 |
+| CLI 参数说明直接写入共享 Input Schema | Web/存储结构变化 | 使用独立 Automation Metadata,并在实例化前隔离 |
+| 模板参数描述不完整 | Agent 猜测参数导致坏工作流 | Descriptor 质量门禁;复杂参数要求 valueSchema 或 warning |
+| Descriptor 与模板发生漂移 | CLI 校验错误 | 普通字段从模板实时归一化,补充元数据只描述缺失信息并做 key 对齐测试 |
+| Agent 或示例编造远端资源 | 本地校验通过、导入后运行失败或泄露内部异常 | 固定值来源优先级;PR6 前资源留空;Binding Collector 输出待绑定项;禁止 fixture 使用占位资源 |
+| PR6 验证后的资源被删除或撤权 | debug/publish 时使用过期资源 | PR7 服务端重新验证存在性、团队边界和权限,不信任本地 validated 状态 |
+| 动态模板版本变化 | 构建不可复现 | `workflow.json` 保存完整节点快照;远端模板解析结果进入变更摘要和审计日志 |
+| 语义端口覆盖不完整 | 导入丢边 | golden round-trip;未知 handle 导入时阻断并报告,不静默丢弃 |
+| 本地 JSON 被当作校验器 | 可绕过 | 所有门禁在代码中重新计算 |
+| 远端覆盖他人修改 | 数据丢失 | baseVersionId 事务内比较,无 `--force` |
+| CLI 保存与发布语义混淆 | 未完成图被发布 | 分离 draft save 和 publish 命令 |
+| MVP 范围过宽 | 无法形成闭环 | PR1 只做内部技术 Demo,PR2/PR3 增量补齐操作面,PR4 后再发布本地 CLI Beta |
+
+## 14. MECE 核查
+
+### 14.1 操作面完整性
+
+- 状态载体:WorkflowDocument、`workflow.json`、StoreWorkflow。
+- 构建来源:模板、导入、ChangeSet。
+- 参数发现:Template Descriptor、Automation Metadata、机器可读 Schema。
+- 输入初始化:用户显式值、PR6 已验证值、模板安全默认值、资源安全空值。
+- 图操作:节点、执行边、变量引用、工具、嵌套。
+- 生命周期:校验、build、draft save、publish、debug、run。
+- 自动化:dry-run、JSON、退出码、checksum、并发控制。
+
+### 14.2 边界互斥性
+
+- 执行边不承担变量引用。
+- `workflow.json` 不承担约束执行。
+- ChangeSet 不承担完整状态存储。
+- ReactFlow 不承担共享领域模型。
+- Automation Metadata 不承担运行时节点状态,也不进入 StoreWorkflow。
+- 模板值来源优先级只负责实例化;PR6 资源读取验证与 PR7 运行/发布复验职责互不替代。
+- 本地 build 只表示结构可编译,不等于资源已解析、可调试或可发布。
+- draft save 不等于 publish。
+- 本地 checksum 不替代远端 baseVersionId。
+
+### 14.3 最终评审结论
+
+该方案可以作为实现基线,但必须严格按 PR1 到 PR7 增量落地。PR1 要以最小可运行 CLI 证明从模板、Command、Document、Validator、`workflow.json` 到 StoreWorkflow 的完整链路;PR5 只在 PR4 契约上增加独立的 Workflow 辅助生成产品入口,不得引入第二套修改规则。后续 PR 只能扩展能力,不能破坏已固化的基础契约和端到端测试。分片 Manifest 不进入初期开发范围,只有真实实验数据证明单文件方案存在瓶颈后再单独立项。
diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts
index 2642692ff5a6..e4e94064c158 100644
--- a/packages/global/core/workflow/constants.ts
+++ b/packages/global/core/workflow/constants.ts
@@ -372,7 +372,7 @@ export enum VariableInputEnum {
internal = 'internal'
}
-type VariableConfigType = {
+export type VariableConfigType = {
icon: string;
label: string;
value: VariableInputEnum;
diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts
index 5e6244999959..2399750a0843 100644
--- a/packages/global/core/workflow/type/io.ts
+++ b/packages/global/core/workflow/type/io.ts
@@ -379,6 +379,15 @@ export const FlowNodeOutputItemTypeSchema = z.object({
});
export type FlowNodeOutputItemType = z.infer;
+/**
+ * 工作流持久化输出结构。`invalidCondition` 只用于 Web 模板的可用性计算,不能进入
+ * StoreWorkflow 或 WorkflowDocument。
+ */
+export const StoreNodeOutputItemTypeSchema = FlowNodeOutputItemTypeSchema.omit({
+ invalidCondition: true
+});
+export type StoreNodeOutputItemType = z.infer;
+
/* Reference */
export const ReferenceItemValueTypeSchema = z.tuple([z.string(), z.string().optional()]);
export type ReferenceItemValueType = z.infer;
diff --git a/packages/global/core/workflow/type/node.ts b/packages/global/core/workflow/type/node.ts
index 1138fd109c8f..7f988feae673 100644
--- a/packages/global/core/workflow/type/node.ts
+++ b/packages/global/core/workflow/type/node.ts
@@ -1,5 +1,9 @@
import { FlowNodeTypeEnum, NodeColorSchemaEnum } from '../node/constant';
-import { FlowNodeInputItemTypeSchema, FlowNodeOutputItemTypeSchema } from './io';
+import {
+ FlowNodeInputItemTypeSchema,
+ FlowNodeOutputItemTypeSchema,
+ StoreNodeOutputItemTypeSchema
+} from './io';
import { HttpToolConfigTypeSchema } from '../../app/tool/httpTool/type';
import { McpToolConfigSchema } from '../../app/tool/mcpTool/type';
import { ParentIdSchema } from '../../../common/parentFolder/type';
@@ -303,6 +307,8 @@ export type FlowNodeItemType = z.infer;
// store node type
export const StoreNodeItemTypeSchema = FlowNodeCommonTypeSchema.extend({
nodeId: z.string(),
+ outputs: z.array(StoreNodeOutputItemTypeSchema),
+ isFolded: BoolSchema.optional(),
position: z
.object({
x: NumSchema,
diff --git a/packages/global/openapi/core/workflow/node.ts b/packages/global/openapi/core/workflow/node.ts
index 5013c4632a62..a65f14d84c41 100644
--- a/packages/global/openapi/core/workflow/node.ts
+++ b/packages/global/openapi/core/workflow/node.ts
@@ -1,7 +1,7 @@
import z from 'zod';
import {
FlowNodeInputItemTypeSchema,
- FlowNodeOutputItemTypeSchema
+ StoreNodeOutputItemTypeSchema
} from '../../../core/workflow/type/io';
import {
NodeToolConfigTypeSchema,
@@ -58,12 +58,7 @@ export const OpenAPIFlowNodeInputItemTypeSchema = FlowNodeInputItemTypeSchema.om
description: '工作流节点输入配置'
});
-// `invalidCondition` in FlowNodeOutputItemTypeSchema is a Zod function schema used only
-// by the editor to validate outputs; function schemas cannot be represented in JSON
-// Schema, so we strip it before exposing via OpenAPI.
-export const OpenAPIFlowNodeOutputItemTypeSchema = FlowNodeOutputItemTypeSchema.omit({
- invalidCondition: true
-}).meta({
+export const OpenAPIFlowNodeOutputItemTypeSchema = StoreNodeOutputItemTypeSchema.meta({
description: '工作流节点输出配置'
});
diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json
index 9e35096f537e..fd568e5d95d5 100644
--- a/packages/web/i18n/en/workflow.json
+++ b/packages/web/i18n/en/workflow.json
@@ -30,6 +30,11 @@
"classification_result": "Classification Result",
"click_to_change_reference": "Click to switch input mode",
"click_to_change_value": "Click to switch Variable citation mode",
+ "cli.input.ai_model": "Model used by this AI node",
+ "cli.input.answer": "Fixed answer text or an upstream output reference",
+ "cli.input.system_prompt": "Instructions that define the assistant's role and response rules",
+ "cli.input.text_editor": "Static text or text assembled from upstream variables",
+ "cli.input.user_question": "User question supplied by the workflow start node",
"code.Reset template": "Reset Template",
"code.Reset template confirm": "Confirm reset code template? This will reset all inputs and outputs to template values. Please save your current code.",
"code.Switch language confirm": "Switching the language will reset the code, will it continue?",
@@ -273,5 +278,6 @@
"workflow_local_draft_auth_expired_notice": "Your login has expired. The current edits have been automatically saved locally. Do not close this page. After signing in again in this tab, your workflow edits can be restored.",
"workflow_local_draft_auth_expired_title": "Automatically saved",
"workflow_local_draft_relogin": "Sign in again",
- "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits."
+ "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits.",
+ "connection_invalid": "This connection violates workflow rules"
}
diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json
index 11b779db6c9d..6220dbc2d85f 100644
--- a/packages/web/i18n/zh-CN/workflow.json
+++ b/packages/web/i18n/zh-CN/workflow.json
@@ -30,6 +30,11 @@
"classification_result": "分类结果",
"click_to_change_reference": "点击切换输入模式",
"click_to_change_value": "点击切换变量引用模式",
+ "cli.input.ai_model": "该 AI 节点使用的模型",
+ "cli.input.answer": "固定回答文本或上游输出引用",
+ "cli.input.system_prompt": "定义助手角色和回答规则的指令",
+ "cli.input.text_editor": "静态文本或由上游变量拼接的文本",
+ "cli.input.user_question": "由工作流开始节点提供的用户问题",
"code.Reset template": "还原模板",
"code.Reset template confirm": "确认还原代码模板?将会重置所有输入和输出至模板值,请注意保存当前代码。",
"code.Switch language confirm": "切换语言将重置代码,是否继续?",
@@ -273,5 +278,6 @@
"workflow_local_draft_auth_expired_notice": "登录已过期,当前编辑内容已自动保存至本地。请不要关闭页面,在此标签页重新登录后,即可恢复编排内容。",
"workflow_local_draft_auth_expired_title": "已自动保存",
"workflow_local_draft_relogin": "重新登录",
- "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。"
+ "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。",
+ "connection_invalid": "该连接不符合工作流规则"
}
diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json
index 957d7bf55d41..88614df2a410 100644
--- a/packages/web/i18n/zh-Hant/workflow.json
+++ b/packages/web/i18n/zh-Hant/workflow.json
@@ -30,6 +30,11 @@
"classification_result": "分類結果",
"click_to_change_reference": "點擊切換輸入模式",
"click_to_change_value": "點擊切換變量引用模式",
+ "cli.input.ai_model": "此 AI 節點使用的模型",
+ "cli.input.answer": "固定回答文字或上游輸出引用",
+ "cli.input.system_prompt": "定義助手角色和回答規則的指令",
+ "cli.input.text_editor": "靜態文字或由上游變數組合的文字",
+ "cli.input.user_question": "由工作流程開始節點提供的使用者問題",
"code.Reset template": "重設範本",
"code.Reset template confirm": "確定要重設程式碼範本嗎?這將會把所有輸入和輸出重設為範本值。請儲存您目前的程式碼。",
"code.Switch language confirm": "切換語言將重設代碼,是否繼續?",
@@ -273,5 +278,6 @@
"workflow_local_draft_auth_expired_notice": "登入已過期,當前編輯內容已自動儲存至本機。請不要關閉頁面,在此分頁重新登入後,即可恢復編排內容。",
"workflow_local_draft_auth_expired_title": "已自動儲存",
"workflow_local_draft_relogin": "重新登入",
- "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。"
+ "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。",
+ "connection_invalid": "此連線不符合工作流程規則"
}
diff --git a/packages/workflow-cli/package.json b/packages/workflow-cli/package.json
new file mode 100644
index 000000000000..8e3585f55a2f
--- /dev/null
+++ b/packages/workflow-cli/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@fastgpt/workflow-cli",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "bin": {
+ "fastgpt-workflow": "./dist/cli.js"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "sideEffects": false,
+ "scripts": {
+ "build": "tsdown",
+ "test": "vitest run --config ./vitest.config.ts",
+ "test:bin": "pnpm --filter @fastgpt/workflow-core build && pnpm build && node test/bin-smoke.mjs",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@fastgpt/web": "workspace:*",
+ "@fastgpt/workflow-core": "workspace:*",
+ "zod": "catalog:"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "tsdown": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:",
+ "@vitest/coverage-v8": "catalog:"
+ }
+}
diff --git a/packages/workflow-cli/src/cli.ts b/packages/workflow-cli/src/cli.ts
new file mode 100644
index 000000000000..bc54c28245c2
--- /dev/null
+++ b/packages/workflow-cli/src/cli.ts
@@ -0,0 +1,4 @@
+#!/usr/bin/env node
+import { runCli } from './run';
+
+process.exitCode = await runCli({ argv: process.argv.slice(2) });
diff --git a/packages/workflow-cli/src/commands/config.ts b/packages/workflow-cli/src/commands/config.ts
new file mode 100644
index 000000000000..656d484e854f
--- /dev/null
+++ b/packages/workflow-cli/src/commands/config.ts
@@ -0,0 +1,50 @@
+import { CHAT_CONFIG_PATHS, getChatConfigValue } from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { readInputValue, requireString, runMutation } from './helpers';
+
+export const listConfig = async (
+ _input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ return {
+ changed: false,
+ result: CHAT_CONFIG_PATHS.map((path) => ({ path, value: getChatConfigValue(document, path) }))
+ };
+};
+
+export const getConfig = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const path = requireString(input, 'path');
+ return {
+ changed: false,
+ result: { path, value: getChatConfigValue(await readWorkflowFile(context.dir), path) }
+ };
+};
+
+export const setConfig = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'config.set',
+ path: requireString(input, 'path'),
+ value: await readInputValue({ input, context })
+ }
+ });
+
+export const unsetConfig = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: { type: 'config.unset', path: requireString(input, 'path') }
+ });
diff --git a/packages/workflow-cli/src/commands/container.ts b/packages/workflow-cli/src/commands/container.ts
new file mode 100644
index 000000000000..31f6e55092fd
--- /dev/null
+++ b/packages/workflow-cli/src/commands/container.ts
@@ -0,0 +1,15 @@
+import { listContainerChildren } from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { requireString } from './helpers';
+
+export const listChildren = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ return {
+ changed: false,
+ result: listContainerChildren(document, requireString(input, 'node'))
+ };
+};
diff --git a/packages/workflow-cli/src/commands/document.ts b/packages/workflow-cli/src/commands/document.ts
new file mode 100644
index 000000000000..4fee8f036124
--- /dev/null
+++ b/packages/workflow-cli/src/commands/document.ts
@@ -0,0 +1,152 @@
+import {
+ builtinTemplateProvider,
+ collectWorkflowBindings,
+ compileStoreWorkflow,
+ createDefaultWorkflowDocument,
+ decompileStoreWorkflow,
+ ensureSystemConfigNode,
+ getWorkflowBindingDiagnostics,
+ getWorkflowChecksum,
+ validateWorkflow
+} from '@fastgpt/workflow-core';
+import { access, readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { CliArgumentError } from '../error';
+import { createTranslator } from '../i18n';
+import {
+ getWorkflowFilePath,
+ readWorkflowFile,
+ writeJsonFileAtomic,
+ writeWorkflowFileAtomic
+} from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { assertNoValidationErrors, requireString } from './helpers';
+
+export const initDocument = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const filePath = getWorkflowFilePath(context.dir);
+ const exists = await access(filePath).then(
+ () => true,
+ () => false
+ );
+ if (exists) throw new CliArgumentError('workflow.json already exists', { filePath });
+
+ const result = await createDefaultWorkflowDocument({
+ app: typeof input.name === 'string' ? { name: input.name } : {},
+ dependencies: {
+ templateProvider: builtinTemplateProvider,
+ locale: context.locale,
+ translate: createTranslator(context.locale)
+ }
+ });
+ if (input.dryRun !== true) await writeWorkflowFileAtomic(context.dir, result.document);
+ return {
+ changed: true,
+ checksum: getWorkflowChecksum(result.document),
+ changes: result.nodeIds.map((nodeId) => ({ type: 'node.add', nodeId })),
+ result: { dryRun: input.dryRun === true },
+ warnings: result.warnings
+ };
+};
+
+export const buildDocument = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const output = requireString(input, 'output');
+ const document = await readWorkflowFile(context.dir);
+ const diagnostics = validateWorkflow(document);
+ assertNoValidationErrors(diagnostics);
+ const bindings = collectWorkflowBindings(document);
+ const bindingDiagnostics = getWorkflowBindingDiagnostics(bindings);
+ const workflow = compileStoreWorkflow(document);
+ const outputPath = resolve(context.cwd, output);
+ await writeJsonFileAtomic(outputPath, workflow);
+ return {
+ changed: false,
+ result: { output: outputPath, workflow, diagnostics, bindings },
+ warnings: bindingDiagnostics
+ };
+};
+
+export const importDocument = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const inputPath = resolve(context.cwd, requireString(input, 'input'));
+ let raw: unknown;
+ try {
+ raw = JSON.parse(await readFile(inputPath, 'utf8'));
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ throw new CliArgumentError('Import file must contain valid JSON', { inputPath });
+ }
+ throw error;
+ }
+
+ const currentApp = await readWorkflowFile(context.dir).then(
+ (document) => document.app,
+ () => ({})
+ );
+ const document = decompileStoreWorkflow({ workflow: raw as never, app: currentApp });
+ const systemConfigResult = await ensureSystemConfigNode({
+ document,
+ dependencies: {
+ templateProvider: builtinTemplateProvider,
+ locale: context.locale,
+ translate: createTranslator(context.locale)
+ }
+ });
+ if (input.dryRun !== true) await writeWorkflowFileAtomic(context.dir, document);
+ return {
+ changed: true,
+ checksum: getWorkflowChecksum(document),
+ changes: systemConfigResult.nodeIds.map((nodeId) => ({ type: 'node.add', nodeId })),
+ result: { input: inputPath, dryRun: input.dryRun === true, document },
+ warnings: systemConfigResult.warnings
+ };
+};
+
+export const inspectDocument = async (
+ _input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ const diagnostics = validateWorkflow(document);
+ const bindings = collectWorkflowBindings(document);
+ const bindingDiagnostics = getWorkflowBindingDiagnostics(bindings);
+ const allDiagnostics = [...diagnostics, ...bindingDiagnostics];
+ const references = document.nodes.flatMap((node) =>
+ node.inputs.flatMap((input) =>
+ Array.isArray(input.value) &&
+ input.value.length === 2 &&
+ typeof input.value[0] === 'string' &&
+ typeof input.value[1] === 'string'
+ ? [{ nodeId: node.nodeId, inputKey: input.key, from: input.value }]
+ : []
+ )
+ );
+ return {
+ changed: false,
+ result: {
+ app: document.app,
+ nodes: document.nodes.map((node) => ({
+ nodeId: node.nodeId,
+ name: node.name,
+ flowNodeType: node.flowNodeType
+ })),
+ edges: document.executionEdges,
+ references,
+ bindings,
+ chatConfig: document.chatConfig,
+ diagnostics: {
+ errorCount: allDiagnostics.filter((item) => item.severity === 'error').length,
+ warningCount: allDiagnostics.filter((item) => item.severity === 'warning').length,
+ items: allDiagnostics
+ }
+ },
+ warnings: bindingDiagnostics
+ };
+};
diff --git a/packages/workflow-cli/src/commands/edge.ts b/packages/workflow-cli/src/commands/edge.ts
new file mode 100644
index 000000000000..4182d9ad3216
--- /dev/null
+++ b/packages/workflow-cli/src/commands/edge.ts
@@ -0,0 +1,72 @@
+import {
+ parseExecutionSourcePortRef,
+ parseExecutionTargetPortRef,
+ type WorkflowExecutionEdge
+} from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { requireString, runMutation } from './helpers';
+
+const parseEdge = (from: string, to: string): WorkflowExecutionEdge => ({
+ source: parseExecutionSourcePortRef(from),
+ target: parseExecutionTargetPortRef(to)
+});
+
+export const listEdges = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ return {
+ changed: false,
+ result: document.executionEdges.filter(
+ (edge) =>
+ (input.node === undefined ||
+ edge.source.nodeId === input.node ||
+ edge.target.nodeId === input.node) &&
+ (input.kind === undefined || edge.source.kind === input.kind)
+ )
+ };
+};
+
+export const connectEdge = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'edge.connect',
+ edge: parseEdge(requireString(input, 'from'), requireString(input, 'to'))
+ }
+ });
+
+export const disconnectEdge = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'edge.disconnect',
+ edge: parseEdge(requireString(input, 'from'), requireString(input, 'to'))
+ }
+ });
+
+export const reconnectEdge = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const from = requireString(input, 'from');
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'edge.reconnect',
+ oldEdge: parseEdge(from, requireString(input, 'oldTo')),
+ newEdge: parseEdge(from, requireString(input, 'to'))
+ }
+ });
+};
diff --git a/packages/workflow-cli/src/commands/helpers.ts b/packages/workflow-cli/src/commands/helpers.ts
new file mode 100644
index 000000000000..0d3e584ee51a
--- /dev/null
+++ b/packages/workflow-cli/src/commands/helpers.ts
@@ -0,0 +1,123 @@
+import {
+ WorkflowValidationError,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ type WorkflowCommand
+} from '@fastgpt/workflow-core';
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { CliArgumentError } from '../error';
+import { createTranslator } from '../i18n';
+import { readWorkflowFile, writeWorkflowFileAtomic } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+
+export const requireString = (input: Record, key: string) => {
+ const value = input[key];
+ if (typeof value !== 'string' || !value) {
+ throw new CliArgumentError(`Missing --${key}`, { option: key });
+ }
+ return value;
+};
+
+export const runMutation = async ({
+ command,
+ input,
+ context
+}: {
+ command: WorkflowCommand;
+ input: Record;
+ context: CliContext;
+}): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ const result = await applyWorkflowCommand({
+ document,
+ command,
+ dependencies: {
+ templateProvider: builtinTemplateProvider,
+ locale: context.locale,
+ translate: createTranslator(context.locale)
+ }
+ });
+ if (input.dryRun !== true) {
+ await writeWorkflowFileAtomic(context.dir, result.document);
+ }
+ return {
+ changed: true,
+ checksum: result.checksum,
+ changes: result.changes,
+ result: { dryRun: input.dryRun === true },
+ warnings: result.warnings
+ };
+};
+
+export const assertNoValidationErrors = (
+ diagnostics: ReturnType
+) => {
+ if (diagnostics.some((item) => item.severity === 'error')) {
+ throw new WorkflowValidationError(diagnostics);
+ }
+};
+
+export const readInputValue = async ({
+ input,
+ context,
+ valueType
+}: {
+ input: Record;
+ context: CliContext;
+ valueType?: string;
+}) => {
+ const valueOptions = ['value', 'valueJson', 'valueFile', 'valueEnv'].filter(
+ (key) => input[key] !== undefined
+ );
+ if (valueOptions.length !== 1) {
+ throw new CliArgumentError('Exactly one value option is required', { valueOptions });
+ }
+
+ if (typeof input.valueJson === 'string') {
+ try {
+ return JSON.parse(input.valueJson);
+ } catch {
+ throw new CliArgumentError('--value-json must contain valid JSON');
+ }
+ }
+ const rawValue = (() => {
+ if (typeof input.value === 'string') return input.value;
+ if (typeof input.valueEnv === 'string') {
+ const value = context.env[input.valueEnv];
+ if (value === undefined) {
+ throw new CliArgumentError('Environment variable is not defined', {
+ name: input.valueEnv
+ });
+ }
+ return value;
+ }
+ return undefined;
+ })();
+ const fileValue =
+ typeof input.valueFile === 'string'
+ ? input.valueFile === '-'
+ ? await context.readStdin()
+ : await readFile(resolve(context.cwd, input.valueFile), 'utf8')
+ : undefined;
+ const value = rawValue ?? fileValue;
+
+ if (valueType === 'number') {
+ const numberValue = Number(value);
+ if (!Number.isFinite(numberValue)) throw new CliArgumentError('Value must be a number');
+ return numberValue;
+ }
+ if (valueType === 'boolean') {
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ throw new CliArgumentError('Value must be true or false');
+ }
+ if (valueType?.startsWith('array') || valueType === 'object') {
+ try {
+ return JSON.parse(value ?? '');
+ } catch {
+ throw new CliArgumentError('Structured input must contain valid JSON');
+ }
+ }
+ return value;
+};
diff --git a/packages/workflow-cli/src/commands/input.ts b/packages/workflow-cli/src/commands/input.ts
new file mode 100644
index 000000000000..a88374e811a3
--- /dev/null
+++ b/packages/workflow-cli/src/commands/input.ts
@@ -0,0 +1,149 @@
+import type { WorkflowIOValueTypeEnum } from '@fastgpt/workflow-core';
+import {
+ FlowNodeInputTypeEnum,
+ getAvailableInputReferences,
+ parseVariableRef
+} from '@fastgpt/workflow-core';
+import { CliArgumentError } from '../error';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { readInputValue, requireString, runMutation } from './helpers';
+
+export const listInputs = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const nodeId = requireString(input, 'node');
+ const document = await readWorkflowFile(context.dir);
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) throw new CliArgumentError('Node not found', { nodeId });
+ return { changed: false, result: node.inputs };
+};
+
+export const addInput = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const key = requireString(input, 'key');
+ const mode = requireString(input, 'mode');
+ const renderTypeList =
+ mode === 'reference'
+ ? [FlowNodeInputTypeEnum.reference]
+ : mode === 'both'
+ ? [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference]
+ : [FlowNodeInputTypeEnum.input];
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'input.add',
+ nodeId: requireString(input, 'node'),
+ input: {
+ key,
+ label: typeof input.label === 'string' ? input.label : key,
+ description: typeof input.description === 'string' ? input.description : undefined,
+ valueType: requireString(input, 'valueType') as WorkflowIOValueTypeEnum,
+ renderTypeList,
+ required: input.required === true,
+ canEdit: true
+ }
+ }
+ });
+};
+
+export const removeInput = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'input.remove',
+ nodeId: requireString(input, 'node'),
+ inputKey: requireString(input, 'key')
+ }
+ });
+
+export const setInput = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const nodeId = requireString(input, 'node');
+ const inputKey = requireString(input, 'key');
+ const document = await readWorkflowFile(context.dir);
+ const nodeInput = document.nodes
+ .find((node) => node.nodeId === nodeId)
+ ?.inputs.find((item) => item.key === inputKey);
+ const value = await readInputValue({ input, context, valueType: nodeInput?.valueType });
+ return runMutation({
+ input,
+ context,
+ command: { type: 'input.set', nodeId, inputKey, value }
+ });
+};
+
+export const refInput = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'input.ref',
+ nodeId: requireString(input, 'node'),
+ inputKey: requireString(input, 'key'),
+ ref: parseVariableRef(requireString(input, 'from'))
+ }
+ });
+
+const findInput = async (input: Record, context: CliContext) => {
+ const nodeId = requireString(input, 'node');
+ const inputKey = requireString(input, 'key');
+ const document = await readWorkflowFile(context.dir);
+ const nodeInput = document.nodes
+ .find((node) => node.nodeId === nodeId)
+ ?.inputs.find((item) => item.key === inputKey);
+ if (!nodeInput) throw new CliArgumentError('Input not found', { nodeId, inputKey });
+ return { document, nodeId, inputKey, nodeInput };
+};
+
+export const showInput = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const { nodeInput } = await findInput(input, context);
+ const isSecret = nodeInput.renderTypeList.includes(FlowNodeInputTypeEnum.password);
+ return {
+ changed: false,
+ result: isSecret
+ ? { ...nodeInput, value: undefined, configured: nodeInput.value !== undefined }
+ : nodeInput
+ };
+};
+
+export const unsetInputValue = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'input.unset',
+ nodeId: requireString(input, 'node'),
+ inputKey: requireString(input, 'key')
+ }
+ });
+
+export const listAvailableInputReferences = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const { document, nodeId, inputKey } = await findInput(input, context);
+ return {
+ changed: false,
+ result: getAvailableInputReferences({ document, nodeId, inputKey })
+ };
+};
diff --git a/packages/workflow-cli/src/commands/meta.ts b/packages/workflow-cli/src/commands/meta.ts
new file mode 100644
index 000000000000..c3c50955ce95
--- /dev/null
+++ b/packages/workflow-cli/src/commands/meta.ts
@@ -0,0 +1,25 @@
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { runMutation } from './helpers';
+
+export const showMeta = async (
+ _input: Record,
+ context: CliContext
+): Promise => ({
+ changed: false,
+ result: (await readWorkflowFile(context.dir)).app
+});
+
+export const setMeta = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'meta.update',
+ name: typeof input.name === 'string' ? input.name : undefined,
+ intro: typeof input.intro === 'string' ? input.intro : undefined
+ }
+ });
diff --git a/packages/workflow-cli/src/commands/node.ts b/packages/workflow-cli/src/commands/node.ts
new file mode 100644
index 000000000000..a3b157d308a2
--- /dev/null
+++ b/packages/workflow-cli/src/commands/node.ts
@@ -0,0 +1,162 @@
+import {
+ parseExecutionSourcePortRef,
+ parseExecutionTargetPortRef,
+ parseNodeTemplateRef
+} from '@fastgpt/workflow-core';
+import { CliArgumentError } from '../error';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { requireString, runMutation } from './helpers';
+import { findDescriptorForNode } from './template';
+
+export const listNodes = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ return {
+ changed: false,
+ result: document.nodes.filter(
+ (node) =>
+ (input.type === undefined || node.flowNodeType === input.type) &&
+ (input.parent === undefined || node.parentNodeId === input.parent)
+ )
+ };
+};
+
+export const showNode = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const nodeId = requireString(input, 'node');
+ const document = await readWorkflowFile(context.dir);
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) throw new CliArgumentError('Node not found', { nodeId });
+ return {
+ changed: false,
+ result: {
+ node,
+ descriptor: await findDescriptorForNode(node.flowNodeType, context),
+ executionEdges: document.executionEdges.filter(
+ (edge) => edge.source.nodeId === nodeId || edge.target.nodeId === nodeId
+ )
+ }
+ };
+};
+
+export const parsePosition = (value: unknown) => {
+ if (value === undefined) return undefined;
+ if (typeof value !== 'string') throw new CliArgumentError('Position must be x,y');
+ const [x, y, extra] = value.split(',').map(Number);
+ if (extra !== undefined || !Number.isFinite(x) || !Number.isFinite(y)) {
+ throw new CliArgumentError('Position must be x,y');
+ }
+ return { x, y };
+};
+
+export const updateNode = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ let catchError: boolean | undefined;
+ if (input.catchError === true) catchError = true;
+ if (input.noCatchError === true) catchError = false;
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'node.update',
+ nodeId: requireString(input, 'node'),
+ name: typeof input.name === 'string' ? input.name : undefined,
+ position: parsePosition(input.position),
+ catchError
+ }
+ });
+};
+
+export const moveNode = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'node.move',
+ nodeId: requireString(input, 'node'),
+ position: parsePosition(input.position),
+ parentNodeId:
+ typeof input.parent === 'string' ? input.parent : input.root === true ? null : undefined
+ }
+ });
+
+export const cloneNode = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'node.clone',
+ sourceNodeId: requireString(input, 'node'),
+ nodeId: requireString(input, 'id'),
+ position: parsePosition(input.position),
+ offset: parsePosition(input.offset)
+ }
+ });
+
+export const removeNode = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: { type: 'node.remove', nodeId: requireString(input, 'node') }
+ });
+
+export const addNode = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const after = input.after;
+ if (after !== undefined && typeof after !== 'string') {
+ throw new CliArgumentError('--after must be an execution source');
+ }
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'node.add',
+ nodeId: requireString(input, 'node'),
+ template: parseNodeTemplateRef(requireString(input, 'template')),
+ name: typeof input.name === 'string' ? input.name : undefined,
+ position: parsePosition(input.position),
+ parentNodeId: typeof input.parent === 'string' ? input.parent : undefined,
+ connectFrom: after ? parseExecutionSourcePortRef(after) : undefined
+ }
+ });
+};
+
+export const insertNode = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const target = parseExecutionTargetPortRef(requireString(input, 'to'));
+ if (target.kind !== 'target') {
+ throw new CliArgumentError('node insert only supports a normal target port');
+ }
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'node.insert',
+ nodeId: requireString(input, 'id'),
+ template: parseNodeTemplateRef(requireString(input, 'template')),
+ from: parseExecutionSourcePortRef(requireString(input, 'from')),
+ to: target,
+ position: parsePosition(input.position)
+ }
+ });
+};
diff --git a/packages/workflow-cli/src/commands/output.ts b/packages/workflow-cli/src/commands/output.ts
new file mode 100644
index 000000000000..920152e535a6
--- /dev/null
+++ b/packages/workflow-cli/src/commands/output.ts
@@ -0,0 +1,52 @@
+import type { WorkflowIOValueTypeEnum } from '@fastgpt/workflow-core';
+import { FlowNodeOutputTypeEnum } from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { requireString, runMutation } from './helpers';
+
+export const listOutputs = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const nodeId = requireString(input, 'node');
+ const document = await readWorkflowFile(context.dir);
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ return { changed: false, result: node?.outputs ?? [] };
+};
+
+export const addOutput = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const key = requireString(input, 'key');
+ return runMutation({
+ input,
+ context,
+ command: {
+ type: 'output.add',
+ nodeId: requireString(input, 'node'),
+ output: {
+ id: key,
+ key,
+ type: FlowNodeOutputTypeEnum.dynamic,
+ valueType: requireString(input, 'valueType') as WorkflowIOValueTypeEnum,
+ label: typeof input.label === 'string' ? input.label : key,
+ description: typeof input.description === 'string' ? input.description : undefined
+ }
+ }
+ });
+};
+
+export const removeOutput = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'output.remove',
+ nodeId: requireString(input, 'node'),
+ outputKey: requireString(input, 'key')
+ }
+ });
diff --git a/packages/workflow-cli/src/commands/template.ts b/packages/workflow-cli/src/commands/template.ts
new file mode 100644
index 000000000000..3f1ff6b58207
--- /dev/null
+++ b/packages/workflow-cli/src/commands/template.ts
@@ -0,0 +1,46 @@
+import {
+ builtinTemplateProvider,
+ normalizeNodeTemplateDescriptor,
+ parseNodeTemplateRef,
+ type NodeTemplateRef
+} from '@fastgpt/workflow-core';
+import { createTranslator } from '../i18n';
+import type { CliContext, CliResult } from '../type';
+import { requireString } from './helpers';
+
+const resolveDescriptor = async (ref: NodeTemplateRef, context: CliContext) => {
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: context.locale });
+ return normalizeNodeTemplateDescriptor({
+ template: resolved.template,
+ templateRef: ref,
+ automationMeta: resolved.automationMeta,
+ translate: createTranslator(context.locale)
+ });
+};
+
+export const listTemplates = async (
+ _input: Record,
+ context: CliContext
+): Promise => {
+ const refs = await builtinTemplateProvider.list({ locale: context.locale });
+ return {
+ changed: false,
+ result: await Promise.all(refs.map((ref) => resolveDescriptor(ref, context)))
+ };
+};
+
+export const showTemplate = async (
+ input: Record,
+ context: CliContext
+): Promise => ({
+ changed: false,
+ result: await resolveDescriptor(parseNodeTemplateRef(requireString(input, 'template')), context)
+});
+
+export const findDescriptorForNode = async (flowNodeType: string, context: CliContext) => {
+ const refs = await builtinTemplateProvider.list({ locale: context.locale });
+ for (const ref of refs) {
+ const descriptor = await resolveDescriptor(ref, context);
+ if (descriptor.flowNodeType === flowNodeType) return descriptor;
+ }
+};
diff --git a/packages/workflow-cli/src/commands/tool.ts b/packages/workflow-cli/src/commands/tool.ts
new file mode 100644
index 000000000000..1754f0d112f6
--- /dev/null
+++ b/packages/workflow-cli/src/commands/tool.ts
@@ -0,0 +1,54 @@
+import { parseNodeTemplateRef } from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { requireString, runMutation } from './helpers';
+import { parsePosition } from './node';
+
+export const listTools = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const toolCallNodeId = requireString(input, 'toolCall');
+ const document = await readWorkflowFile(context.dir);
+ return {
+ changed: false,
+ result: document.executionEdges
+ .filter(
+ (edge) => edge.source.kind === 'selectedTools' && edge.source.nodeId === toolCallNodeId
+ )
+ .map((edge) => document.nodes.find((node) => node.nodeId === edge.target.nodeId))
+ .filter(Boolean)
+ };
+};
+
+export const attachTool = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'tool.attach',
+ toolCallNodeId: requireString(input, 'toolCall'),
+ toolNodeId: typeof input.toolNode === 'string' ? input.toolNode : undefined,
+ template:
+ typeof input.template === 'string' ? parseNodeTemplateRef(input.template) : undefined,
+ newNodeId: typeof input.id === 'string' ? input.id : undefined,
+ position: parsePosition(input.position)
+ }
+ });
+
+export const detachTool = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: {
+ type: 'tool.detach',
+ toolCallNodeId: requireString(input, 'toolCall'),
+ toolNodeId: requireString(input, 'toolNode')
+ }
+ });
diff --git a/packages/workflow-cli/src/commands/validate.ts b/packages/workflow-cli/src/commands/validate.ts
new file mode 100644
index 000000000000..cb53599e9d73
--- /dev/null
+++ b/packages/workflow-cli/src/commands/validate.ts
@@ -0,0 +1,24 @@
+import {
+ collectWorkflowBindings,
+ getWorkflowBindingDiagnostics,
+ validateWorkflow
+} from '@fastgpt/workflow-core';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { assertNoValidationErrors } from './helpers';
+
+export const validateDocument = async (
+ _input: Record,
+ context: CliContext
+): Promise => {
+ const document = await readWorkflowFile(context.dir);
+ const diagnostics = validateWorkflow(document);
+ assertNoValidationErrors(diagnostics);
+ const bindings = collectWorkflowBindings(document);
+ const bindingDiagnostics = getWorkflowBindingDiagnostics(bindings);
+ return {
+ changed: false,
+ result: { valid: true, executable: bindings.length === 0, diagnostics, bindings },
+ warnings: bindingDiagnostics
+ };
+};
diff --git a/packages/workflow-cli/src/commands/variable.ts b/packages/workflow-cli/src/commands/variable.ts
new file mode 100644
index 000000000000..a6eceb719a18
--- /dev/null
+++ b/packages/workflow-cli/src/commands/variable.ts
@@ -0,0 +1,247 @@
+import {
+ VariableItemTypeSchema,
+ VariableInputEnum,
+ WorkflowIOValueTypeEnum,
+ textInputVariableValueTypes,
+ variableMap,
+ type VariableItemType
+} from '@fastgpt/workflow-core';
+import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { ZodError } from 'zod';
+import { CliArgumentError } from '../error';
+import { readWorkflowFile } from '../io/workflowFile';
+import type { CliContext, CliResult } from '../type';
+import { readInputValue, requireString, runMutation } from './helpers';
+
+const valueOptionKeys = ['value', 'valueJson', 'valueFile', 'valueEnv'] as const;
+const hasValueOption = (input: Record) =>
+ valueOptionKeys.some((key) => input[key] !== undefined);
+
+const VariableConfigSchema = VariableItemTypeSchema.partial()
+ .omit({
+ key: true,
+ label: true,
+ description: true,
+ type: true,
+ valueType: true,
+ required: true,
+ defaultValue: true
+ })
+ .strict();
+
+const normalizeVariableInputType = (type: unknown) => {
+ if (type === 'external') return VariableInputEnum.custom;
+ return typeof type === 'string' ? (type as VariableInputEnum) : undefined;
+};
+
+const getVariableInputType = (valueType: WorkflowIOValueTypeEnum) => {
+ if (valueType === WorkflowIOValueTypeEnum.number) return VariableInputEnum.numberInput;
+ if (valueType === WorkflowIOValueTypeEnum.boolean) return VariableInputEnum.switch;
+ if (valueType === WorkflowIOValueTypeEnum.arrayString) return VariableInputEnum.multipleSelect;
+ return VariableInputEnum.input;
+};
+
+const specialOptionalTypes = new Set([
+ VariableInputEnum.custom,
+ VariableInputEnum.internal,
+ VariableInputEnum.switch
+]);
+
+/** 校验显式交互类型与数据结构,保持 CLI 和 Web 变量编辑器的约束一致。 */
+const assertVariableTypeCompatibility = ({
+ type,
+ valueType
+}: {
+ type: VariableInputEnum;
+ valueType: WorkflowIOValueTypeEnum;
+}) => {
+ if (type === VariableInputEnum.custom || type === VariableInputEnum.internal) return;
+ if (type === VariableInputEnum.input) {
+ if (!textInputVariableValueTypes.includes(valueType)) {
+ throw new CliArgumentError('--type input does not support this --value-type', {
+ type,
+ valueType
+ });
+ }
+ return;
+ }
+
+ const config = (variableMap as Partial)[type];
+ if (config && config.defaultValueType !== valueType) {
+ throw new CliArgumentError('--type and --value-type are incompatible', {
+ type,
+ valueType,
+ expectedValueType: config.defaultValueType
+ });
+ }
+};
+
+const parseJson = (value: string, option: string) => {
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ throw new CliArgumentError(`${option} must contain valid JSON`);
+ }
+};
+
+const parseNumberOption = (input: Record, key: string) => {
+ if (input[key] === undefined) return undefined;
+ const value = Number(input[key]);
+ if (!Number.isFinite(value)) {
+ throw new CliArgumentError(
+ `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} must be a number`
+ );
+ }
+ return value;
+};
+
+/** 读取完整类型配置,并让常用快捷参数覆盖 JSON 配置中的同名字段。 */
+const readVariableConfig = async (
+ input: Record,
+ context: CliContext
+): Promise> => {
+ const rawConfig = await (async () => {
+ if (typeof input.configJson === 'string') return parseJson(input.configJson, '--config-json');
+ if (typeof input.configFile === 'string') {
+ const content = await readFile(resolve(context.cwd, input.configFile), 'utf8');
+ return parseJson(content, '--config-file');
+ }
+ return {};
+ })();
+
+ const maxLength = parseNumberOption(input, 'maxLength');
+ if (maxLength !== undefined && (!Number.isInteger(maxLength) || maxLength < 0)) {
+ throw new CliArgumentError('--max-length must be a non-negative integer');
+ }
+
+ const shortcuts = {
+ list:
+ typeof input.optionsJson === 'string'
+ ? parseJson(input.optionsJson, '--options-json')
+ : undefined,
+ min: parseNumberOption(input, 'min'),
+ max: parseNumberOption(input, 'max'),
+ maxLength,
+ timeGranularity: typeof input.timeGranularity === 'string' ? input.timeGranularity : undefined
+ };
+
+ try {
+ return VariableConfigSchema.parse({
+ ...(rawConfig as Record),
+ ...Object.fromEntries(Object.entries(shortcuts).filter(([, value]) => value !== undefined))
+ });
+ } catch (error) {
+ if (error instanceof ZodError) {
+ throw new CliArgumentError('Variable type config is invalid', { issues: error.issues });
+ }
+ throw error;
+ }
+};
+
+const getRequired = (input: Record) => {
+ if (input.required === true) return true;
+ if (input.optional === true) return false;
+ return undefined;
+};
+
+export const listVariables = async (
+ _input: Record,
+ context: CliContext
+): Promise => ({
+ changed: false,
+ result: (await readWorkflowFile(context.dir)).chatConfig.variables ?? []
+});
+
+export const addVariable = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const key = requireString(input, 'key');
+ const valueType = requireString(input, 'valueType') as WorkflowIOValueTypeEnum;
+ const type = normalizeVariableInputType(input.type) ?? getVariableInputType(valueType);
+ assertVariableTypeCompatibility({ type, valueType });
+ if (input.required === true && specialOptionalTypes.has(type)) {
+ throw new CliArgumentError('--required is not supported for this variable type', { type });
+ }
+ const defaultValue = hasValueOption(input)
+ ? await readInputValue({ input, context, valueType })
+ : undefined;
+ const config = await readVariableConfig(input, context);
+ const variable: VariableItemType = {
+ ...config,
+ key,
+ label: typeof input.label === 'string' ? input.label : key,
+ description: typeof input.description === 'string' ? input.description : '',
+ valueType,
+ type,
+ required: specialOptionalTypes.has(type) ? false : input.required === true,
+ defaultValue
+ };
+ return runMutation({ input, context, command: { type: 'variable.add', variable } });
+};
+
+export const updateVariable = async (
+ input: Record,
+ context: CliContext
+): Promise => {
+ const valueType =
+ typeof input.valueType === 'string' ? (input.valueType as WorkflowIOValueTypeEnum) : undefined;
+ const document = await readWorkflowFile(context.dir);
+ const currentVariable = document.chatConfig.variables?.find(
+ (variable) => variable.key === input.key
+ );
+ const explicitType = normalizeVariableInputType(input.type);
+ const inferredType = (() => {
+ if (!valueType || !currentVariable?.valueType) return undefined;
+ if (currentVariable.type !== getVariableInputType(currentVariable.valueType)) return undefined;
+ return getVariableInputType(valueType);
+ })();
+ const type = explicitType ?? inferredType;
+ const nextType = type ?? currentVariable?.type;
+ const nextValueType = valueType ?? currentVariable?.valueType;
+ if ((explicitType || valueType) && nextType && nextValueType) {
+ assertVariableTypeCompatibility({ type: nextType, valueType: nextValueType });
+ }
+ if (input.required === true && nextType && specialOptionalTypes.has(nextType)) {
+ throw new CliArgumentError('--required is not supported for this variable type', {
+ type: nextType
+ });
+ }
+ const required = (() => {
+ const requested = getRequired(input);
+ if (type && nextType && specialOptionalTypes.has(nextType)) return false;
+ return requested;
+ })();
+ const config = await readVariableConfig(input, context);
+ const patch: Partial = {
+ ...config,
+ key: typeof input.newKey === 'string' ? input.newKey : undefined,
+ label: typeof input.label === 'string' ? input.label : undefined,
+ description: typeof input.description === 'string' ? input.description : undefined,
+ valueType,
+ type,
+ required,
+ defaultValue: hasValueOption(input)
+ ? await readInputValue({ input, context, valueType: nextValueType })
+ : undefined
+ };
+ const cleanPatch = Object.fromEntries(
+ Object.entries(patch).filter(([, value]) => value !== undefined)
+ ) as Partial;
+ return runMutation({
+ input,
+ context,
+ command: { type: 'variable.update', key: requireString(input, 'key'), patch: cleanPatch }
+ });
+};
+
+export const removeVariable = async (
+ input: Record,
+ context: CliContext
+): Promise =>
+ runMutation({
+ input,
+ context,
+ command: { type: 'variable.remove', key: requireString(input, 'key') }
+ });
diff --git a/packages/workflow-cli/src/error.ts b/packages/workflow-cli/src/error.ts
new file mode 100644
index 000000000000..063b4e3a7892
--- /dev/null
+++ b/packages/workflow-cli/src/error.ts
@@ -0,0 +1,11 @@
+export class CliArgumentError extends Error {
+ readonly code = 'CLI_ARGUMENT_INVALID';
+
+ constructor(
+ message: string,
+ readonly params?: Record
+ ) {
+ super(message);
+ this.name = 'CliArgumentError';
+ }
+}
diff --git a/packages/workflow-cli/src/help.ts b/packages/workflow-cli/src/help.ts
new file mode 100644
index 000000000000..0ff1860031c5
--- /dev/null
+++ b/packages/workflow-cli/src/help.ts
@@ -0,0 +1,19 @@
+import { cliCommandRegistry, globalCliOptions, getCommandName } from './registry';
+import type { CliCommandDefinition } from './type';
+
+const formatOptions = (definition?: CliCommandDefinition) =>
+ [...globalCliOptions, ...(definition?.options ?? [])]
+ .filter((option) => !['--help', '--version'].includes(option.name))
+ .map(
+ (option) =>
+ ` ${option.name}${option.value ? ' ' : ''}${option.required ? ' (required)' : ''}\n ${option.description}`
+ )
+ .join('\n');
+
+export const renderHelp = (definition?: CliCommandDefinition) => {
+ if (definition) {
+ return `Usage: fastgpt-workflow ${getCommandName(definition)} [options]\n\nOptions:\n${formatOptions(definition)}`;
+ }
+ const commands = cliCommandRegistry.map((item) => ` ${getCommandName(item)}`).join('\n');
+ return `Usage: fastgpt-workflow [options]\n\nCommands:\n${commands}\n\nGlobal options:\n${formatOptions()}`;
+};
diff --git a/packages/workflow-cli/src/i18n.ts b/packages/workflow-cli/src/i18n.ts
new file mode 100644
index 000000000000..768373c76dbf
--- /dev/null
+++ b/packages/workflow-cli/src/i18n.ts
@@ -0,0 +1,29 @@
+import enApp from '@fastgpt/web/i18n/en/app.json';
+import enCommon from '@fastgpt/web/i18n/en/common.json';
+import enWorkflow from '@fastgpt/web/i18n/en/workflow.json';
+import zhCnApp from '@fastgpt/web/i18n/zh-CN/app.json';
+import zhCnCommon from '@fastgpt/web/i18n/zh-CN/common.json';
+import zhCnWorkflow from '@fastgpt/web/i18n/zh-CN/workflow.json';
+import zhHantApp from '@fastgpt/web/i18n/zh-Hant/app.json';
+import zhHantCommon from '@fastgpt/web/i18n/zh-Hant/common.json';
+import zhHantWorkflow from '@fastgpt/web/i18n/zh-Hant/workflow.json';
+
+type TranslationDictionary = Record;
+
+const resources: Record> = {
+ en: { app: enApp, common: enCommon, workflow: enWorkflow },
+ 'zh-CN': { app: zhCnApp, common: zhCnCommon, workflow: zhCnWorkflow },
+ 'zh-Hant': { app: zhHantApp, common: zhHantCommon, workflow: zhHantWorkflow }
+};
+
+/** 使用现有 Web 资源解析模板 key;未知 locale/key 明确回退英文或原值。 */
+export const createTranslator = (locale: string) => {
+ const localeResources = resources[locale] ?? resources.en;
+ return (value: string) => {
+ const separatorIndex = value.indexOf(':');
+ if (separatorIndex <= 0) return value;
+ const namespace = value.slice(0, separatorIndex);
+ const key = value.slice(separatorIndex + 1);
+ return localeResources[namespace]?.[key] ?? resources.en[namespace]?.[key] ?? value;
+ };
+};
diff --git a/packages/workflow-cli/src/index.ts b/packages/workflow-cli/src/index.ts
new file mode 100644
index 000000000000..569539c074f1
--- /dev/null
+++ b/packages/workflow-cli/src/index.ts
@@ -0,0 +1,9 @@
+export * from './error';
+export * from './help';
+export * from './i18n';
+export * from './io/workflowFile';
+export * from './output/render';
+export * from './parser';
+export * from './registry';
+export * from './run';
+export * from './type';
diff --git a/packages/workflow-cli/src/io/workflowFile.ts b/packages/workflow-cli/src/io/workflowFile.ts
new file mode 100644
index 000000000000..d63a0fc3d1b4
--- /dev/null
+++ b/packages/workflow-cli/src/io/workflowFile.ts
@@ -0,0 +1,59 @@
+import {
+ WorkflowDocumentSchema,
+ normalizeWorkflowDocument,
+ type WorkflowDocument
+} from '@fastgpt/workflow-core';
+import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
+import { basename, dirname, join, resolve } from 'node:path';
+import { CliArgumentError } from '../error';
+
+export const WORKFLOW_FILE_NAME = 'workflow.json';
+
+export const parseWorkflowDocument = (input: unknown): WorkflowDocument =>
+ WorkflowDocumentSchema.parse(input);
+
+export const serializeWorkflowDocument = (document: WorkflowDocument) =>
+ `${JSON.stringify(normalizeWorkflowDocument(WorkflowDocumentSchema.parse(document)), null, 2)}\n`;
+
+export const getWorkflowFilePath = (dir: string) => join(resolve(dir), WORKFLOW_FILE_NAME);
+
+export const readWorkflowFile = async (dir: string): Promise => {
+ const content = await readFile(getWorkflowFilePath(dir), 'utf8');
+ try {
+ return parseWorkflowDocument(JSON.parse(content));
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ throw new CliArgumentError('workflow.json must contain valid JSON');
+ }
+ throw error;
+ }
+};
+
+/** 同目录临时文件 + fsync + rename,失败时不会留下半写入目标文件。 */
+export const writeFileAtomic = async (filePath: string, content: string) => {
+ const absolutePath = resolve(filePath);
+ const directory = dirname(absolutePath);
+ const temporaryPath = join(
+ directory,
+ `.${basename(absolutePath)}.${process.pid}.${Date.now()}.tmp`
+ );
+ await mkdir(directory, { recursive: true });
+
+ const handle = await open(temporaryPath, 'wx');
+ try {
+ await handle.writeFile(content, 'utf8');
+ await handle.sync();
+ await handle.close();
+ await rename(temporaryPath, absolutePath);
+ } catch (error) {
+ await handle.close().catch(() => {});
+ await rm(temporaryPath, { force: true }).catch(() => {});
+ throw error;
+ }
+};
+
+export const writeWorkflowFileAtomic = async (dir: string, document: WorkflowDocument) =>
+ writeFileAtomic(getWorkflowFilePath(dir), serializeWorkflowDocument(document));
+
+export const writeJsonFileAtomic = async (filePath: string, value: unknown) =>
+ writeFileAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
diff --git a/packages/workflow-cli/src/output/render.ts b/packages/workflow-cli/src/output/render.ts
new file mode 100644
index 000000000000..817482922480
--- /dev/null
+++ b/packages/workflow-cli/src/output/render.ts
@@ -0,0 +1,76 @@
+import type { CliFormat, CliResult } from '../type';
+
+export const CLI_OUTPUT_SCHEMA_VERSION = 'fastgpt-workflow-cli-result/v1' as const;
+
+export type CliSuccessEnvelope = {
+ schemaVersion: typeof CLI_OUTPUT_SCHEMA_VERSION;
+ ok: true;
+ command: string;
+ changed: boolean;
+ checksum?: string;
+ result?: unknown;
+ changes?: unknown[];
+ warnings: unknown[];
+};
+
+export type CliErrorEnvelope = {
+ schemaVersion: typeof CLI_OUTPUT_SCHEMA_VERSION;
+ ok: false;
+ command: string;
+ changed: false;
+ errors: Array<{ code: string; diagnostics?: unknown; params?: unknown }>;
+};
+
+export const createSuccessEnvelope = (command: string, result: CliResult): CliSuccessEnvelope => ({
+ schemaVersion: CLI_OUTPUT_SCHEMA_VERSION,
+ ok: true,
+ command,
+ changed: result.changed,
+ checksum: result.checksum,
+ result: result.result,
+ changes: result.changes,
+ warnings: result.warnings ?? []
+});
+
+export const renderSuccess = ({
+ command,
+ result,
+ format
+}: {
+ command: string;
+ result: CliResult;
+ format: CliFormat;
+}) => {
+ if (format === 'json') return JSON.stringify(createSuccessEnvelope(command, result));
+ if (result.result !== undefined) {
+ return typeof result.result === 'string'
+ ? result.result
+ : JSON.stringify(result.result, null, 2);
+ }
+ return result.message ?? 'OK';
+};
+
+export const renderError = ({
+ command,
+ code,
+ diagnostics,
+ params,
+ format
+}: {
+ command: string;
+ code: string;
+ diagnostics?: unknown;
+ params?: unknown;
+ format: CliFormat;
+}) => {
+ const envelope: CliErrorEnvelope = {
+ schemaVersion: CLI_OUTPUT_SCHEMA_VERSION,
+ ok: false,
+ command,
+ changed: false,
+ errors: [{ code, diagnostics, params }]
+ };
+ return format === 'json'
+ ? JSON.stringify(envelope)
+ : `${code}${diagnostics ? `\n${JSON.stringify(diagnostics, null, 2)}` : ''}`;
+};
diff --git a/packages/workflow-cli/src/parser.ts b/packages/workflow-cli/src/parser.ts
new file mode 100644
index 000000000000..143ae879fc5c
--- /dev/null
+++ b/packages/workflow-cli/src/parser.ts
@@ -0,0 +1,124 @@
+import { resolve } from 'node:path';
+import { ZodError } from 'zod';
+import { CliArgumentError } from './error';
+import { cliCommandRegistry, globalCliOptions, getCommandName } from './registry';
+import type { CliCommandDefinition, CliContext, CliOptionDefinition } from './type';
+
+const toInputKey = (optionName: string) =>
+ optionName.slice(2).replace(/-([a-z])/g, (_, character: string) => character.toUpperCase());
+
+const findOption = (name: string, options: readonly CliOptionDefinition[]) =>
+ options.find((option) => option.name === name);
+
+export type ParsedCliCommand = {
+ definition: CliCommandDefinition;
+ input: Record;
+ context: CliContext;
+ help: boolean;
+};
+
+/** Registry 是命令、参数校验和帮助文本的唯一来源。 */
+export const parseCliArgs = ({
+ argv,
+ cwd,
+ env
+}: {
+ argv: string[];
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+}): ParsedCliCommand => {
+ const positional: string[] = [];
+ const rawOptions: Record = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (!token.startsWith('--')) {
+ positional.push(token);
+ continue;
+ }
+ const option = findOption(token, [
+ ...globalCliOptions,
+ ...cliCommandRegistry.flatMap((definition) => definition.options)
+ ]);
+ if (!option) throw new CliArgumentError(`Unknown option: ${token}`);
+ if (option.value) {
+ const value = argv[index + 1];
+ if (value === undefined || value.startsWith('--')) {
+ throw new CliArgumentError(`Option requires a value: ${token}`);
+ }
+ rawOptions[toInputKey(token)] = value;
+ index += 1;
+ } else {
+ rawOptions[toInputKey(token)] = true;
+ }
+ }
+
+ const definition = cliCommandRegistry.find(
+ (item) =>
+ item.path.length === positional.length &&
+ item.path.every((part, index) => positional[index] === part)
+ );
+ if (!definition) {
+ throw new CliArgumentError(
+ positional.length === 0 ? 'Command is required' : `Unknown command: ${positional.join(' ')}`
+ );
+ }
+
+ const allowedCommandOptions = new Set(definition.options.map((item) => toInputKey(item.name)));
+ const globalOptionKeys = new Set(globalCliOptions.map((item) => toInputKey(item.name)));
+ for (const key of Object.keys(rawOptions)) {
+ if (!globalOptionKeys.has(key) && !allowedCommandOptions.has(key)) {
+ throw new CliArgumentError(`Option is not valid for ${getCommandName(definition)}`, {
+ option: key
+ });
+ }
+ }
+
+ const commandInput = Object.fromEntries(
+ Object.entries(rawOptions).filter(([key]) => allowedCommandOptions.has(key))
+ );
+ let input: Record;
+ if (rawOptions.help === true) {
+ input = {};
+ } else {
+ try {
+ input = definition.inputSchema.parse(commandInput) as Record;
+ } catch (error) {
+ if (error instanceof ZodError) {
+ throw new CliArgumentError('Command options are invalid', { issues: error.issues });
+ }
+ throw error;
+ }
+ }
+
+ const format = rawOptions.format ?? env.FASTGPT_WORKFLOW_FORMAT ?? 'text';
+ if (format !== 'text' && format !== 'json') {
+ throw new CliArgumentError('--format must be text or json');
+ }
+ const dirValue = rawOptions.dir ?? env.FASTGPT_WORKFLOW_DIR ?? '.';
+ if (typeof dirValue !== 'string') throw new CliArgumentError('--dir must be a path');
+
+ return {
+ definition,
+ input,
+ help: rawOptions.help === true,
+ context: {
+ cwd,
+ dir: resolve(cwd, dirValue),
+ format,
+ locale:
+ typeof rawOptions.locale === 'string'
+ ? rawOptions.locale
+ : (env.FASTGPT_WORKFLOW_LOCALE ?? 'en'),
+ quiet: rawOptions.quiet === true,
+ color: rawOptions.noColor !== true,
+ env,
+ readStdin: async () => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks).toString('utf8');
+ }
+ }
+ };
+};
diff --git a/packages/workflow-cli/src/registry.ts b/packages/workflow-cli/src/registry.ts
new file mode 100644
index 000000000000..55fb6d942e2b
--- /dev/null
+++ b/packages/workflow-cli/src/registry.ts
@@ -0,0 +1,954 @@
+import z from 'zod';
+import { VariableInputEnum, WorkflowIOValueTypeEnum } from '@fastgpt/workflow-core';
+import { buildDocument, importDocument, initDocument, inspectDocument } from './commands/document';
+import {
+ addInput,
+ listAvailableInputReferences,
+ listInputs,
+ refInput,
+ removeInput,
+ setInput,
+ showInput,
+ unsetInputValue
+} from './commands/input';
+import {
+ addNode,
+ cloneNode,
+ listNodes,
+ moveNode,
+ removeNode,
+ showNode,
+ updateNode,
+ insertNode
+} from './commands/node';
+import { connectEdge, disconnectEdge, listEdges, reconnectEdge } from './commands/edge';
+import { setMeta, showMeta } from './commands/meta';
+import { getConfig, listConfig, setConfig, unsetConfig } from './commands/config';
+import { addVariable, listVariables, removeVariable, updateVariable } from './commands/variable';
+import { listTemplates, showTemplate } from './commands/template';
+import { validateDocument } from './commands/validate';
+import { addOutput, listOutputs, removeOutput } from './commands/output';
+import { attachTool, detachTool, listTools } from './commands/tool';
+import { listChildren } from './commands/container';
+import type { CliCommandDefinition, CliOptionDefinition } from './type';
+
+const option = (
+ name: string,
+ description: string,
+ config: Partial> = {}
+): CliOptionDefinition => ({
+ name,
+ description,
+ value: config.value ?? true,
+ required: config.required
+});
+
+const emptySchema = z.object({}).strict();
+const valueOptionShape = {
+ value: z.string().optional(),
+ valueJson: z.string().optional(),
+ valueFile: z.string().optional(),
+ valueEnv: z.string().optional()
+};
+const valueOptions = [
+ option('--value', 'Scalar value'),
+ option('--value-json', 'JSON value'),
+ option('--value-file', 'Read a UTF-8 value from a file'),
+ option('--value-env', 'Read a value from an environment variable')
+];
+const getValueOptionCount = (value: Record) =>
+ [value.value, value.valueJson, value.valueFile, value.valueEnv].filter(
+ (item) => item !== undefined
+ ).length;
+const variableTypeSchema = z.enum([...Object.values(VariableInputEnum), 'external']);
+const variableConfigOptionShape = {
+ configJson: z.string().min(1).optional(),
+ configFile: z.string().min(1).optional(),
+ optionsJson: z.string().min(1).optional(),
+ min: z.string().min(1).optional(),
+ max: z.string().min(1).optional(),
+ maxLength: z.string().min(1).optional(),
+ timeGranularity: z.enum(['day', 'hour', 'minute', 'second']).optional()
+};
+const variableConfigOptions = [
+ option('--config-json', 'Type-specific variable config as JSON'),
+ option('--config-file', 'Read type-specific variable config from a JSON file'),
+ option('--options-json', 'Select options as a JSON array'),
+ option('--min', 'Minimum numeric value'),
+ option('--max', 'Maximum numeric value'),
+ option('--max-length', 'Maximum text length'),
+ option('--time-granularity', 'Time granularity: day, hour, minute or second')
+];
+const getVariableConfigFileOptionCount = (value: Record) =>
+ [value.configJson, value.configFile].filter((item) => item !== undefined).length;
+export const cliCommandRegistry: CliCommandDefinition[] = [
+ {
+ path: ['init'],
+ introducedIn: 'PR1',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({ name: z.string().min(1).optional(), dryRun: z.literal(true).optional() })
+ .strict(),
+ options: [
+ option('--name', 'Workflow name'),
+ option('--dry-run', 'Return the initialized document without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: initDocument
+ },
+ {
+ path: ['build'],
+ introducedIn: 'PR1',
+ kind: 'artifact',
+ inputSchema: z.object({ output: z.string().min(1) }).strict(),
+ options: [option('--output', 'StoreWorkflow output path', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: buildDocument
+ },
+ {
+ path: ['import'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({ input: z.string().min(1), dryRun: z.literal(true).optional() })
+ .strict(),
+ options: [
+ option('--input', 'StoreWorkflow input path', { required: true }),
+ option('--dry-run', 'Return the imported document without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: importDocument
+ },
+ {
+ path: ['inspect'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: emptySchema,
+ options: [],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: inspectDocument
+ },
+ {
+ path: ['meta', 'show'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: emptySchema,
+ options: [],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: showMeta
+ },
+ {
+ path: ['meta', 'set'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ name: z.string().min(1).optional(),
+ intro: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .refine((value) => value.name !== undefined || value.intro !== undefined),
+ options: [
+ option('--name', 'Application name'),
+ option('--intro', 'Application introduction'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: setMeta
+ },
+ {
+ path: ['config', 'list'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: emptySchema,
+ options: [],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listConfig
+ },
+ {
+ path: ['config', 'get'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: z.object({ path: z.string().min(1) }).strict(),
+ options: [option('--path', 'Allowlisted ChatConfig path', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: getConfig
+ },
+ {
+ path: ['config', 'set'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ path: z.string().min(1),
+ ...valueOptionShape,
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .refine((value) => getValueOptionCount(value) === 1),
+ options: [
+ option('--path', 'Allowlisted ChatConfig path', { required: true }),
+ ...valueOptions,
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: setConfig
+ },
+ {
+ path: ['config', 'unset'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z.object({ path: z.string().min(1), dryRun: z.literal(true).optional() }).strict(),
+ options: [
+ option('--path', 'Allowlisted ChatConfig path', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: unsetConfig
+ },
+ {
+ path: ['variable', 'list'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: emptySchema,
+ options: [],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listVariables
+ },
+ {
+ path: ['variable', 'add'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ key: z.string().min(1),
+ type: variableTypeSchema.optional(),
+ valueType: z.enum(WorkflowIOValueTypeEnum),
+ label: z.string().min(1).optional(),
+ description: z.string().optional(),
+ required: z.literal(true).optional(),
+ ...variableConfigOptionShape,
+ ...valueOptionShape,
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (getValueOptionCount(value) > 1) {
+ context.addIssue({ code: 'custom', message: 'Value options are exclusive' });
+ }
+ if (getVariableConfigFileOptionCount(value) > 1) {
+ context.addIssue({ code: 'custom', message: 'Config options are exclusive' });
+ }
+ }),
+ options: [
+ option('--key', 'Variable key', { required: true }),
+ option('--type', 'Variable input type; external is an alias for custom'),
+ option('--value-type', 'Workflow value type', { required: true }),
+ option('--label', 'Variable label'),
+ option('--description', 'Variable description'),
+ option('--required', 'Mark the variable as required', { value: false }),
+ ...variableConfigOptions,
+ ...valueOptions,
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: addVariable
+ },
+ {
+ path: ['variable', 'update'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ key: z.string().min(1),
+ newKey: z.string().min(1).optional(),
+ type: variableTypeSchema.optional(),
+ valueType: z.enum(WorkflowIOValueTypeEnum).optional(),
+ label: z.string().min(1).optional(),
+ description: z.string().optional(),
+ required: z.literal(true).optional(),
+ optional: z.literal(true).optional(),
+ ...variableConfigOptionShape,
+ ...valueOptionShape,
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (value.required && value.optional) {
+ context.addIssue({ code: 'custom', message: '--required and --optional are exclusive' });
+ }
+ if (getValueOptionCount(value) > 1) {
+ context.addIssue({ code: 'custom', message: 'Value options are exclusive' });
+ }
+ if (getVariableConfigFileOptionCount(value) > 1) {
+ context.addIssue({ code: 'custom', message: 'Config options are exclusive' });
+ }
+ if (
+ [
+ value.newKey,
+ value.type,
+ value.valueType,
+ value.label,
+ value.description,
+ value.required,
+ value.optional,
+ value.configJson,
+ value.configFile,
+ value.optionsJson,
+ value.min,
+ value.max,
+ value.maxLength,
+ value.timeGranularity,
+ value.value,
+ value.valueJson,
+ value.valueFile,
+ value.valueEnv
+ ].every((item) => item === undefined)
+ ) {
+ context.addIssue({ code: 'custom', message: 'At least one update field is required' });
+ }
+ }),
+ options: [
+ option('--key', 'Existing variable key', { required: true }),
+ option('--new-key', 'New variable key'),
+ option('--type', 'Variable input type; external is an alias for custom'),
+ option('--value-type', 'Workflow value type'),
+ option('--label', 'Variable label'),
+ option('--description', 'Variable description'),
+ option('--required', 'Mark the variable as required', { value: false }),
+ option('--optional', 'Mark the variable as optional', { value: false }),
+ ...variableConfigOptions,
+ ...valueOptions,
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: updateVariable
+ },
+ {
+ path: ['variable', 'remove'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z.object({ key: z.string().min(1), dryRun: z.literal(true).optional() }).strict(),
+ options: [
+ option('--key', 'Variable key', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: removeVariable
+ },
+ {
+ path: ['template', 'list'],
+ introducedIn: 'PR1',
+ kind: 'query',
+ inputSchema: z.object({ source: z.literal('builtin').optional() }).strict(),
+ options: [option('--source', 'Template source; PR1 supports builtin only')],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listTemplates
+ },
+ {
+ path: ['template', 'show'],
+ introducedIn: 'PR1',
+ kind: 'query',
+ inputSchema: z.object({ template: z.string().min(1) }).strict(),
+ options: [option('--template', 'Explicit template reference', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: showTemplate
+ },
+ {
+ path: ['node', 'list'],
+ introducedIn: 'PR1',
+ kind: 'query',
+ inputSchema: z.object({ type: z.string().optional(), parent: z.string().optional() }).strict(),
+ options: [
+ option('--type', 'Filter by flow node type'),
+ option('--parent', 'Filter by parent node')
+ ],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listNodes
+ },
+ {
+ path: ['node', 'show'],
+ introducedIn: 'PR1',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1) }).strict(),
+ options: [option('--node', 'Node ID', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: showNode
+ },
+ {
+ path: ['node', 'add'],
+ introducedIn: 'PR1',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ template: z.string().min(1),
+ name: z.string().min(1).optional(),
+ after: z.string().optional(),
+ parent: z.string().min(1).optional(),
+ position: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'New node ID', { required: true }),
+ option('--template', 'Template reference', { required: true }),
+ option('--name', 'Node name'),
+ option('--after', 'Connect from a semantic source port'),
+ option('--parent', 'Create inside a container'),
+ option('--position', 'Canvas position as x,y'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: addNode
+ },
+ {
+ path: ['node', 'update'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ name: z.string().min(1).optional(),
+ position: z.string().optional(),
+ catchError: z.literal(true).optional(),
+ noCatchError: z.literal(true).optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (value.catchError && value.noCatchError) {
+ context.addIssue({ code: 'custom', message: 'Catch error options are exclusive' });
+ }
+ if (
+ value.name === undefined &&
+ value.position === undefined &&
+ value.catchError === undefined &&
+ value.noCatchError === undefined
+ ) {
+ context.addIssue({ code: 'custom', message: 'At least one update is required' });
+ }
+ }),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--name', 'Node name'),
+ option('--position', 'Canvas position as x,y'),
+ option('--catch-error', 'Enable catch execution port', { value: false }),
+ option('--no-catch-error', 'Disable catch execution port', { value: false }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: updateNode
+ },
+ {
+ path: ['node', 'move'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ position: z.string().min(1).optional(),
+ parent: z.string().min(1).optional(),
+ root: z.literal(true).optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (value.parent && value.root) {
+ context.addIssue({ code: 'custom', message: '--parent and --root are exclusive' });
+ }
+ if (!value.position && !value.parent && !value.root) {
+ context.addIssue({ code: 'custom', message: 'A move target is required' });
+ }
+ }),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--position', 'Canvas position as x,y'),
+ option('--parent', 'Move into a container'),
+ option('--root', 'Move to root scope', { value: false }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: moveNode
+ },
+ {
+ path: ['node', 'insert'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ from: z.string().min(1),
+ to: z.string().min(1),
+ template: z.string().min(1),
+ id: z.string().min(1),
+ position: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--from', 'Existing execution source port', { required: true }),
+ option('--to', 'Existing execution target port', { required: true }),
+ option('--template', 'Inserted node template', { required: true }),
+ option('--id', 'Inserted node ID', { required: true }),
+ option('--position', 'Canvas position as x,y'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: insertNode
+ },
+ {
+ path: ['node', 'clone'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ id: z.string().min(1),
+ position: z.string().optional(),
+ offset: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .refine((value) => !(value.position && value.offset)),
+ options: [
+ option('--node', 'Source node ID', { required: true }),
+ option('--id', 'Cloned node ID', { required: true }),
+ option('--position', 'Absolute canvas position as x,y'),
+ option('--offset', 'Offset from source as x,y'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: cloneNode
+ },
+ {
+ path: ['node', 'remove'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z.object({ node: z.string().min(1), dryRun: z.literal(true).optional() }).strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: removeNode
+ },
+ {
+ path: ['edge', 'list'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().optional(), kind: z.string().optional() }).strict(),
+ options: [option('--node', 'Filter by node ID'), option('--kind', 'Filter by source kind')],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listEdges
+ },
+ {
+ path: ['edge', 'connect'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ from: z.string().min(1),
+ to: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--from', 'Execution source port', { required: true }),
+ option('--to', 'Execution target port', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: connectEdge
+ },
+ {
+ path: ['edge', 'disconnect'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ from: z.string().min(1),
+ to: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--from', 'Execution source port', { required: true }),
+ option('--to', 'Execution target port', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: disconnectEdge
+ },
+ {
+ path: ['edge', 'reconnect'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ from: z.string().min(1),
+ oldTo: z.string().min(1),
+ to: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--from', 'Execution source port', { required: true }),
+ option('--old-to', 'Existing execution target port', { required: true }),
+ option('--to', 'Replacement execution target port', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: reconnectEdge
+ },
+ {
+ path: ['input', 'list'],
+ introducedIn: 'PR3',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1) }).strict(),
+ options: [option('--node', 'Node ID', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listInputs
+ },
+ {
+ path: ['input', 'show'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1), key: z.string().min(1) }).strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Input key', { required: true })
+ ],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: showInput
+ },
+ {
+ path: ['input', 'add'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ valueType: z.enum(WorkflowIOValueTypeEnum),
+ mode: z.enum(['literal', 'reference', 'both']),
+ label: z.string().min(1).optional(),
+ description: z.string().optional(),
+ required: z.literal(true).optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'New input key', { required: true }),
+ option('--value-type', 'Workflow value type', { required: true }),
+ option('--mode', 'Input mode: literal, reference or both', { required: true }),
+ option('--label', 'Input label'),
+ option('--description', 'Input description'),
+ option('--required', 'Mark the input as required', { value: false }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: addInput
+ },
+ {
+ path: ['input', 'remove'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Dynamic input key', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: removeInput
+ },
+ {
+ path: ['input', 'set'],
+ introducedIn: 'PR1',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ value: z.string().optional(),
+ valueJson: z.string().optional(),
+ valueFile: z.string().optional(),
+ valueEnv: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ const count = [value.value, value.valueJson, value.valueFile, value.valueEnv].filter(
+ (item) => item !== undefined
+ ).length;
+ if (count !== 1) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Exactly one of --value, --value-json, --value-file or --value-env is required'
+ });
+ }
+ }),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Input key', { required: true }),
+ option('--value', 'Scalar value'),
+ option('--value-json', 'JSON value'),
+ option('--value-file', 'Read a UTF-8 value from a file'),
+ option('--value-env', 'Read a value from an environment variable'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: setInput
+ },
+ {
+ path: ['input', 'ref'],
+ introducedIn: 'PR1',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ from: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Input key', { required: true }),
+ option('--from', 'Variable reference as node.output', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: refInput
+ },
+ {
+ path: ['input', 'unset'],
+ introducedIn: 'PR2',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Input key', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: unsetInputValue
+ },
+ {
+ path: ['input', 'available'],
+ introducedIn: 'PR2',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1), key: z.string().min(1) }).strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Input key', { required: true })
+ ],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listAvailableInputReferences
+ },
+ {
+ path: ['output', 'list'],
+ introducedIn: 'PR3',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1) }).strict(),
+ options: [option('--node', 'Node ID', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listOutputs
+ },
+ {
+ path: ['output', 'add'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ valueType: z.enum(WorkflowIOValueTypeEnum),
+ label: z.string().min(1).optional(),
+ description: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Output key', { required: true }),
+ option('--value-type', 'Workflow value type', { required: true }),
+ option('--label', 'Output label'),
+ option('--description', 'Output description'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: addOutput
+ },
+ {
+ path: ['output', 'remove'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ node: z.string().min(1),
+ key: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--node', 'Node ID', { required: true }),
+ option('--key', 'Output key', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: removeOutput
+ },
+ {
+ path: ['tool', 'list'],
+ introducedIn: 'PR3',
+ kind: 'query',
+ inputSchema: z.object({ toolCall: z.string().min(1) }).strict(),
+ options: [option('--tool-call', 'Tool-call node ID', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listTools
+ },
+ {
+ path: ['tool', 'attach'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ toolCall: z.string().min(1),
+ template: z.string().min(1).optional(),
+ toolNode: z.string().min(1).optional(),
+ id: z.string().min(1).optional(),
+ position: z.string().optional(),
+ dryRun: z.literal(true).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (Boolean(value.template) === Boolean(value.toolNode)) {
+ context.addIssue({ code: 'custom', message: 'Use --template or --tool-node' });
+ }
+ if (value.template && !value.id) {
+ context.addIssue({ code: 'custom', message: '--id is required with --template' });
+ }
+ if (value.toolNode && value.id) {
+ context.addIssue({ code: 'custom', message: '--id only applies to --template' });
+ }
+ }),
+ options: [
+ option('--tool-call', 'Tool-call node ID', { required: true }),
+ option('--template', 'Create tool node from template'),
+ option('--tool-node', 'Attach an existing tool node'),
+ option('--id', 'New tool node ID'),
+ option('--position', 'Canvas position as x,y'),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: attachTool
+ },
+ {
+ path: ['tool', 'detach'],
+ introducedIn: 'PR3',
+ kind: 'localMutation',
+ inputSchema: z
+ .object({
+ toolCall: z.string().min(1),
+ toolNode: z.string().min(1),
+ dryRun: z.literal(true).optional()
+ })
+ .strict(),
+ options: [
+ option('--tool-call', 'Tool-call node ID', { required: true }),
+ option('--tool-node', 'Attached tool node ID', { required: true }),
+ option('--dry-run', 'Return changes without writing', { value: false })
+ ],
+ supportsDryRun: true,
+ confirm: 'none',
+ handler: detachTool
+ },
+ {
+ path: ['container', 'children'],
+ introducedIn: 'PR3',
+ kind: 'query',
+ inputSchema: z.object({ node: z.string().min(1) }).strict(),
+ options: [option('--node', 'Container node ID', { required: true })],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: listChildren
+ },
+ {
+ path: ['validate'],
+ introducedIn: 'PR1',
+ kind: 'query',
+ inputSchema: emptySchema,
+ options: [],
+ supportsDryRun: false,
+ confirm: 'none',
+ handler: validateDocument
+ }
+];
+
+export const globalCliOptions: CliOptionDefinition[] = [
+ option('--dir', 'Workflow directory'),
+ option('--format', 'Output format: text or json'),
+ option('--locale', 'Descriptor locale'),
+ option('--no-color', 'Disable ANSI color', { value: false }),
+ option('--quiet', 'Suppress non-result text', { value: false }),
+ option('--help', 'Show help', { value: false }),
+ option('--version', 'Show version', { value: false })
+];
+
+export const getCommandName = (definition: CliCommandDefinition) => definition.path.join(' ');
diff --git a/packages/workflow-cli/src/run.ts b/packages/workflow-cli/src/run.ts
new file mode 100644
index 000000000000..e50d6a6db573
--- /dev/null
+++ b/packages/workflow-cli/src/run.ts
@@ -0,0 +1,84 @@
+import { WorkflowCommandError, WorkflowValidationError } from '@fastgpt/workflow-core';
+import { ZodError } from 'zod';
+import { CliArgumentError } from './error';
+import { renderHelp } from './help';
+import { renderError, renderSuccess } from './output/render';
+import { parseCliArgs } from './parser';
+
+export const runCli = async ({
+ argv,
+ cwd = process.cwd(),
+ env = process.env,
+ stdin,
+ stdout = (value) => process.stdout.write(`${value}\n`),
+ stderr = (value) => process.stderr.write(`${value}\n`)
+}: {
+ argv: string[];
+ cwd?: string;
+ env?: NodeJS.ProcessEnv;
+ stdin?: () => Promise;
+ stdout?: (value: string) => void;
+ stderr?: (value: string) => void;
+}): Promise => {
+ const formatOptionIndex = argv.lastIndexOf('--format');
+ const requestedFormat = formatOptionIndex >= 0 ? argv[formatOptionIndex + 1] : undefined;
+ let command =
+ argv
+ .filter((item) => !item.startsWith('--'))
+ .slice(0, 2)
+ .join(' ') || 'unknown';
+ let format: 'text' | 'json' =
+ requestedFormat === 'json' ||
+ (requestedFormat === undefined && env.FASTGPT_WORKFLOW_FORMAT === 'json')
+ ? 'json'
+ : 'text';
+ try {
+ if (argv.length === 0 || (argv.length === 1 && argv[0] === '--help')) {
+ stdout(renderHelp());
+ return 0;
+ }
+ if (argv.length === 1 && argv[0] === '--version') {
+ stdout('0.1.0');
+ return 0;
+ }
+
+ const parsed = parseCliArgs({ argv, cwd, env });
+ if (stdin) parsed.context.readStdin = stdin;
+ command = parsed.definition.path.join(' ');
+ format = parsed.context.format;
+ if (parsed.help) {
+ stdout(renderHelp(parsed.definition));
+ return 0;
+ }
+ const result = await parsed.definition.handler(parsed.input, parsed.context);
+ stdout(renderSuccess({ command, result, format }));
+ return 0;
+ } catch (error) {
+ const mapped = (() => {
+ if (error instanceof CliArgumentError || error instanceof ZodError) {
+ return {
+ exitCode: 2,
+ code: error instanceof CliArgumentError ? error.code : 'CLI_ARGUMENT_INVALID',
+ params: error instanceof CliArgumentError ? error.params : error.issues
+ };
+ }
+ if (error instanceof WorkflowCommandError) {
+ return { exitCode: 3, code: error.code, diagnostics: error.diagnostics };
+ }
+ if (error instanceof WorkflowValidationError) {
+ return { exitCode: 4, code: error.code, diagnostics: error.diagnostics };
+ }
+ return { exitCode: 1, code: 'CLI_INTERNAL_ERROR' };
+ })();
+ stderr(
+ renderError({
+ command,
+ code: mapped.code,
+ diagnostics: 'diagnostics' in mapped ? mapped.diagnostics : undefined,
+ params: 'params' in mapped ? mapped.params : undefined,
+ format
+ })
+ );
+ return mapped.exitCode;
+ }
+};
diff --git a/packages/workflow-cli/src/type.ts b/packages/workflow-cli/src/type.ts
new file mode 100644
index 000000000000..667000158301
--- /dev/null
+++ b/packages/workflow-cli/src/type.ts
@@ -0,0 +1,41 @@
+import type { z } from 'zod';
+
+export type CliFormat = 'text' | 'json';
+
+export type CliContext = {
+ cwd: string;
+ dir: string;
+ format: CliFormat;
+ locale: string;
+ quiet: boolean;
+ color: boolean;
+ env: NodeJS.ProcessEnv;
+ readStdin: () => Promise;
+};
+
+export type CliResult = {
+ changed: boolean;
+ checksum?: string;
+ result?: unknown;
+ changes?: unknown[];
+ message?: string;
+ warnings?: unknown[];
+};
+
+export type CliOptionDefinition = {
+ name: string;
+ value: boolean;
+ required?: boolean;
+ description: string;
+};
+
+export type CliCommandDefinition = {
+ path: readonly string[];
+ introducedIn: 'PR1' | 'PR2' | 'PR3';
+ kind: 'query' | 'localMutation' | 'artifact';
+ inputSchema: z.ZodType;
+ options: readonly CliOptionDefinition[];
+ supportsDryRun: boolean;
+ confirm: 'none';
+ handler: (input: Record, context: CliContext) => Promise;
+};
diff --git a/packages/workflow-cli/test/bin-smoke.mjs b/packages/workflow-cli/test/bin-smoke.mjs
new file mode 100644
index 000000000000..c9e4af0dd468
--- /dev/null
+++ b/packages/workflow-cli/test/bin-smoke.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawnSync } from 'node:child_process';
+
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const cliPath = join(packageRoot, 'dist', 'cli.js');
+const workspace = await mkdtemp(join(tmpdir(), 'workflow-cli-bin-'));
+const workflowDir = join(workspace, 'demo');
+
+const invoke = (args, input) => {
+ const result = spawnSync(process.execPath, [cliPath, ...args, '--format', 'json'], {
+ cwd: workspace,
+ env: { ...process.env, NODE_ENV: 'test' },
+ input,
+ encoding: 'utf8'
+ });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ assert.equal(result.stderr, '');
+ return JSON.parse(result.stdout);
+};
+
+try {
+ invoke(['init', '--dir', workflowDir, '--name', 'Bin smoke']);
+ invoke([
+ 'node',
+ 'add',
+ '--dir',
+ workflowDir,
+ '--node',
+ 'ai',
+ '--name',
+ 'CLI AI',
+ '--template',
+ 'builtin:ai-chat',
+ '--after',
+ 'start@next'
+ ]);
+ const prompt = 'Prompt only from stdin';
+ const mutation = invoke(
+ [
+ 'input',
+ 'set',
+ '--dir',
+ workflowDir,
+ '--node',
+ 'ai',
+ '--key',
+ 'systemPrompt',
+ '--value-file',
+ '-'
+ ],
+ prompt
+ );
+ assert.equal(JSON.stringify(mutation).includes(prompt), false);
+ assert.equal(invoke(['validate', '--dir', workflowDir]).result.valid, true);
+
+ const outputPath = join(workspace, 'workflow.generated.json');
+ invoke(['build', '--dir', workflowDir, '--output', outputPath]);
+ const workflow = JSON.parse(await readFile(outputPath, 'utf8'));
+ assert.equal(workflow.nodes.find((node) => node.nodeId === 'ai').name, 'CLI AI');
+ assert.equal(workflow.edges[0].sourceHandle, 'start-source-right');
+
+ const descriptor = invoke([
+ 'template',
+ 'show',
+ '--template',
+ 'builtin:ai-chat',
+ '--locale',
+ 'zh-CN'
+ ]).result;
+ assert.equal(descriptor.name, 'AI 对话');
+ assert.equal(JSON.stringify(descriptor).includes('workflow:cli.input'), false);
+ process.stdout.write('workflow-cli built bin smoke passed\n');
+} finally {
+ await rm(workspace, { recursive: true, force: true });
+}
diff --git a/packages/workflow-cli/test/e2e.test.ts b/packages/workflow-cli/test/e2e.test.ts
new file mode 100644
index 000000000000..1935eddb78a1
--- /dev/null
+++ b/packages/workflow-cli/test/e2e.test.ts
@@ -0,0 +1,1164 @@
+import { runCli } from '../src';
+import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const createHarness = async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'workflow-cli-'));
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+ const invoke = async (
+ argv: string[],
+ env: NodeJS.ProcessEnv = { NODE_ENV: 'test' },
+ stdinValue = ''
+ ) => {
+ stdout.length = 0;
+ stderr.length = 0;
+ const exitCode = await runCli({
+ argv,
+ cwd,
+ env,
+ stdin: async () => stdinValue,
+ stdout: (value) => stdout.push(value),
+ stderr: (value) => stderr.push(value)
+ });
+ return { exitCode, stdout: [...stdout], stderr: [...stderr] };
+ };
+ return { cwd, invoke };
+};
+
+const jsonArgs = (dir: string, args: string[]) => [...args, '--dir', dir, '--format', 'json'];
+
+describe('PR1 through PR3 CLI end to end', () => {
+ it('builds basic-ai with pure JSON stdout and deterministic output', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'basic-ai');
+ expect((await invoke(jsonArgs(dir, ['init', '--name', 'Demo workflow']))).exitCode).toBe(0);
+ const initializedDocument = JSON.parse(await readFile(join(dir, 'workflow.json'), 'utf8'));
+ expect(initializedDocument.nodes).toHaveLength(2);
+ expect(initializedDocument.nodes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ nodeId: 'userGuide', flowNodeType: 'userGuide' }),
+ expect.objectContaining({ nodeId: 'start', flowNodeType: 'workflowStart' })
+ ])
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'ai',
+ '--template',
+ 'builtin:ai-chat',
+ '--name',
+ 'Answer user',
+ '--after',
+ 'start@next'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'systemPrompt',
+ '--value',
+ 'Be concise'
+ ])
+ );
+ const inputShow = await invoke(
+ jsonArgs(dir, ['input', 'show', '--node', 'ai', '--key', 'systemPrompt'])
+ );
+ expect(JSON.parse(inputShow.stdout[0]).result.value).toBe('Be concise');
+ await invoke(jsonArgs(dir, ['input', 'unset', '--node', 'ai', '--key', 'systemPrompt']));
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'systemPrompt',
+ '--value',
+ 'Be concise'
+ ])
+ );
+ const validation = await invoke(jsonArgs(dir, ['validate']));
+ expect(validation.exitCode).toBe(0);
+ expect(JSON.parse(validation.stdout[0])).toMatchObject({
+ schemaVersion: 'fastgpt-workflow-cli-result/v1',
+ ok: true,
+ changed: false,
+ result: { valid: true }
+ });
+
+ const output = join(dir, 'workflow.generated.json');
+ await invoke(jsonArgs(dir, ['build', '--output', output]));
+ const firstBuild = await readFile(output, 'utf8');
+ await invoke(jsonArgs(dir, ['build', '--output', output]));
+ expect(await readFile(output, 'utf8')).toBe(firstBuild);
+ expect(JSON.parse(firstBuild).edges[0]).toEqual({
+ source: 'start',
+ sourceHandle: 'start-source-right',
+ target: 'ai',
+ targetHandle: 'ai-target-left'
+ });
+
+ const templateList = await invoke(jsonArgs(dir, ['template', 'list', '--source', 'builtin']));
+ expect(JSON.parse(templateList.stdout[0]).result).toHaveLength(22);
+ const nodeList = await invoke(jsonArgs(dir, ['node', 'list', '--type', 'chatNode']));
+ expect(JSON.parse(nodeList.stdout[0]).result).toHaveLength(1);
+ const nodeShow = await invoke(jsonArgs(dir, ['node', 'show', '--node', 'ai']));
+ const nodeShowResult = JSON.parse(nodeShow.stdout[0]).result;
+ expect(nodeShowResult.node.name).toBe('Answer user');
+ expect(nodeShowResult.descriptor.template).toEqual({
+ kind: 'builtin',
+ templateId: 'ai-chat'
+ });
+ expect(JSON.parse(await readFile(join(dir, 'workflow.json'), 'utf8')).app.name).toBe(
+ 'Demo workflow'
+ );
+ });
+
+ it('builds basic-static with literal and reference inputs', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'basic-static');
+ await invoke(jsonArgs(dir, ['init']));
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'text',
+ '--template',
+ 'builtin:text-editor',
+ '--after',
+ 'start@next'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'text',
+ '--key',
+ 'system_textareaInput',
+ '--value',
+ 'Static response'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'answer',
+ '--template',
+ 'builtin:assigned-answer',
+ '--after',
+ 'text@next'
+ ])
+ );
+ const refResult = await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'ref',
+ '--node',
+ 'answer',
+ '--key',
+ 'text',
+ '--from',
+ 'text.system_text'
+ ])
+ );
+ expect(refResult.exitCode).toBe(0);
+ expect((await invoke(jsonArgs(dir, ['validate']))).exitCode).toBe(0);
+ });
+
+ it('builds output key references as Store output ids and imports them back as keys', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'code-reference');
+ await invoke(jsonArgs(dir, ['init']));
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'code',
+ '--template',
+ 'builtin:code',
+ '--after',
+ 'start@next'
+ ])
+ );
+ for (const inputKey of ['data1', 'data2']) {
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'ref',
+ '--node',
+ 'code',
+ '--key',
+ inputKey,
+ '--from',
+ 'start.userChatInput'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ }
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'answer',
+ '--template',
+ 'builtin:assigned-answer',
+ '--after',
+ 'code@next'
+ ])
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'ref',
+ '--node',
+ 'answer',
+ '--key',
+ 'text',
+ '--from',
+ 'code.result'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+
+ const document = JSON.parse(await readFile(join(dir, 'workflow.json'), 'utf8'));
+ expect(
+ document.nodes
+ .find((node: { nodeId: string }) => node.nodeId === 'answer')
+ .inputs.find((input: { key: string }) => input.key === 'text').value
+ ).toEqual(['code', 'result']);
+
+ const output = join(cwd, 'code-reference-store.json');
+ expect((await invoke(jsonArgs(dir, ['build', '--output', output]))).exitCode).toBe(0);
+ const store = JSON.parse(await readFile(output, 'utf8'));
+ expect(
+ store.nodes
+ .find((node: { nodeId: string }) => node.nodeId === 'answer')
+ .inputs.find((input: { key: string }) => input.key === 'text').value
+ ).toEqual(['code', 'qLUQfhG0ILRX']);
+
+ const importedDir = join(cwd, 'code-reference-imported');
+ expect((await invoke(jsonArgs(importedDir, ['import', '--input', output]))).exitCode).toBe(0);
+ const importedDocument = JSON.parse(await readFile(join(importedDir, 'workflow.json'), 'utf8'));
+ expect(
+ importedDocument.nodes
+ .find((node: { nodeId: string }) => node.nodeId === 'answer')
+ .inputs.find((input: { key: string }) => input.key === 'text').value
+ ).toEqual(['code', 'result']);
+ });
+
+ it('builds workflows with unresolved resources and reports their bindings', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'resource-bindings');
+ await invoke(jsonArgs(dir, ['init']));
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'search',
+ '--template',
+ 'builtin:dataset-search',
+ '--after',
+ 'start@next'
+ ])
+ );
+
+ const validation = await invoke(jsonArgs(dir, ['validate']));
+ expect(validation.exitCode).toBe(0);
+ expect(JSON.parse(validation.stdout[0])).toMatchObject({
+ ok: true,
+ result: {
+ valid: true,
+ executable: false,
+ bindings: [
+ {
+ nodeId: 'search',
+ inputKey: 'datasets',
+ resourceKind: 'dataset',
+ status: 'missing'
+ }
+ ]
+ },
+ warnings: [
+ {
+ code: 'WORKFLOW_BINDING_REQUIRED',
+ nodeId: 'search',
+ inputKey: 'datasets'
+ }
+ ]
+ });
+
+ const output = join(dir, 'workflow.generated.json');
+ const build = await invoke(jsonArgs(dir, ['build', '--output', output]));
+ expect(build.exitCode).toBe(0);
+ expect(JSON.parse(build.stdout[0]).warnings).toMatchObject([
+ { code: 'WORKFLOW_BINDING_REQUIRED', nodeId: 'search', inputKey: 'datasets' }
+ ]);
+ const workflow = JSON.parse(await readFile(output, 'utf8'));
+ expect(
+ workflow.nodes
+ .find((node: { nodeId: string }) => node.nodeId === 'search')
+ .inputs.find((input: { key: string }) => input.key === 'datasets').value
+ ).toEqual([]);
+ expect(JSON.stringify(workflow)).not.toContain('demo-dataset');
+ });
+
+ it('keeps mutations and template queries write-free in dry-run/query mode', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dryDir = join(cwd, 'dry');
+ expect((await invoke(jsonArgs(dryDir, ['init', '--dry-run']))).exitCode).toBe(0);
+ await expect(access(join(dryDir, 'workflow.json'))).rejects.toThrow();
+
+ const queryDir = join(cwd, 'query');
+ const template = await invoke(
+ jsonArgs(queryDir, ['template', 'show', '--template', 'builtin:ai-chat', '--locale', 'zh-CN'])
+ );
+ const descriptor = JSON.parse(template.stdout[0]).result;
+ expect(descriptor.name).toBe('AI 对话');
+ expect(descriptor.inputs.find((input: { key: string }) => input.key === 'model')).toMatchObject(
+ {
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'model'
+ }
+ );
+ expect(JSON.stringify(descriptor)).not.toContain('workflow:template.ai_chat');
+ expect(JSON.stringify(descriptor)).not.toContain('workflow:cli.input');
+ await expect(access(join(queryDir, 'workflow.json'))).rejects.toThrow();
+
+ const persistedDir = join(cwd, 'persisted');
+ await invoke(jsonArgs(persistedDir, ['init']));
+ const before = await readFile(join(persistedDir, 'workflow.json'), 'utf8');
+ await invoke(
+ jsonArgs(persistedDir, [
+ 'node',
+ 'add',
+ '--node',
+ 'ai',
+ '--template',
+ 'builtin:ai-chat',
+ '--after',
+ 'start@next',
+ '--dry-run'
+ ])
+ );
+ expect(await readFile(join(persistedDir, 'workflow.json'), 'utf8')).toBe(before);
+ });
+
+ it('configures dataset concat dynamic references and builds custom feedback', async () => {
+ const { cwd, invoke } = await createHarness();
+ const concatDir = join(cwd, 'dataset-concat');
+ await invoke(jsonArgs(concatDir, ['init']));
+ await invoke(
+ jsonArgs(concatDir, [
+ 'node',
+ 'add',
+ '--node',
+ 'search',
+ '--template',
+ 'builtin:dataset-search',
+ '--after',
+ 'start@next'
+ ])
+ );
+ await invoke(
+ jsonArgs(concatDir, [
+ 'node',
+ 'add',
+ '--node',
+ 'concat',
+ '--template',
+ 'builtin:dataset-concat',
+ '--after',
+ 'search@next'
+ ])
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(concatDir, [
+ 'input',
+ 'add',
+ '--node',
+ 'concat',
+ '--key',
+ 'quote_1',
+ '--value-type',
+ 'datasetQuote',
+ '--mode',
+ 'reference',
+ '--required'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ expect(
+ (
+ await invoke(
+ jsonArgs(concatDir, [
+ 'input',
+ 'ref',
+ '--node',
+ 'concat',
+ '--key',
+ 'quote_1',
+ '--from',
+ 'search.quoteQA'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ const inputList = await invoke(jsonArgs(concatDir, ['input', 'list', '--node', 'concat']));
+ expect(JSON.parse(inputList.stdout[0]).result).toContainEqual(
+ expect.objectContaining({ key: 'quote_1', value: ['search', 'quoteQA'] })
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(concatDir, ['input', 'remove', '--node', 'concat', '--key', 'quote_1'])
+ )
+ ).exitCode
+ ).toBe(0);
+
+ const feedbackDir = join(cwd, 'custom-feedback');
+ await invoke(jsonArgs(feedbackDir, ['init']));
+ await invoke(
+ jsonArgs(feedbackDir, [
+ 'node',
+ 'add',
+ '--node',
+ 'feedback',
+ '--template',
+ 'builtin:custom-feedback',
+ '--after',
+ 'start@next'
+ ])
+ );
+ await invoke(
+ jsonArgs(feedbackDir, [
+ 'input',
+ 'set',
+ '--node',
+ 'feedback',
+ '--key',
+ 'system_textareaInput',
+ '--value',
+ 'Accurate answer'
+ ])
+ );
+ const output = join(feedbackDir, 'workflow.generated.json');
+ expect((await invoke(jsonArgs(feedbackDir, ['build', '--output', output]))).exitCode).toBe(0);
+ expect(JSON.parse(await readFile(output, 'utf8')).nodes).toContainEqual(
+ expect.objectContaining({ flowNodeType: 'customFeedback' })
+ );
+ });
+
+ it('parses scalar, JSON, file and environment values from one command contract', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'values');
+ await invoke(jsonArgs(dir, ['init']));
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'ai',
+ '--template',
+ 'builtin:ai-chat',
+ '--after',
+ 'start@next'
+ ])
+ );
+ const envResult = await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'maxToken',
+ '--value-env',
+ 'MAX_TOKEN'
+ ]),
+ { NODE_ENV: 'test', MAX_TOKEN: '512' }
+ );
+ expect(envResult.exitCode).toBe(0);
+ expect(envResult.stdout[0]).not.toContain('512');
+ await invoke(
+ jsonArgs(dir, ['input', 'set', '--node', 'ai', '--key', 'temperature', '--value-json', '0.5'])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'isResponseAnswerText',
+ '--value',
+ 'false'
+ ])
+ );
+ const promptPath = join(cwd, 'prompt.txt');
+ await writeFile(promptPath, 'Prompt from file', 'utf8');
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'systemPrompt',
+ '--value-file',
+ promptPath
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, ['input', 'set', '--node', 'ai', '--key', 'systemPrompt', '--value-file', '-']),
+ { NODE_ENV: 'test' },
+ 'Prompt from stdin'
+ );
+ const node = JSON.parse(
+ (await invoke(jsonArgs(dir, ['node', 'show', '--node', 'ai']))).stdout[0]
+ ).result.node;
+ const values = Object.fromEntries(
+ node.inputs.map((input: { key: string; value: unknown }) => [input.key, input.value])
+ );
+ expect(values).toMatchObject({
+ maxToken: 512,
+ temperature: 0.5,
+ isResponseAnswerText: false,
+ systemPrompt: 'Prompt from stdin'
+ });
+ });
+
+ it('maps argument, command and validation failures and never writes partial state', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'failure');
+ await invoke(jsonArgs(dir, ['init']));
+ const before = await readFile(join(dir, 'workflow.json'), 'utf8');
+
+ expect((await invoke(jsonArgs(dir, ['init']))).exitCode).toBe(2);
+ expect((await invoke(jsonArgs(dir, ['node', 'show', '--node', 'missing']))).exitCode).toBe(2);
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'bad-position',
+ '--template',
+ 'builtin:ai-chat',
+ '--position',
+ 'invalid'
+ ])
+ )
+ ).exitCode
+ ).toBe(2);
+
+ const argument = await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'start',
+ '--key',
+ 'userChatInput',
+ '--value-json',
+ '{'
+ ])
+ );
+ expect(argument.exitCode).toBe(2);
+
+ const command = await invoke(
+ jsonArgs(dir, ['node', 'add', '--node', 'broken', '--template', 'builtin:missing'])
+ );
+ expect(command.exitCode).toBe(3);
+ expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before);
+
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'text',
+ '--template',
+ 'builtin:text-editor',
+ '--after',
+ 'start@next'
+ ])
+ );
+ expect((await invoke(jsonArgs(dir, ['validate']))).exitCode).toBe(4);
+ });
+
+ it('renders global/command help, version and text output without executing handlers', async () => {
+ const { invoke } = await createHarness();
+ expect((await invoke(['--help'])).stdout[0]).toContain('Commands:');
+ expect((await invoke(['--version'])).stdout).toEqual(['0.1.0']);
+ const commandHelp = await invoke(['node', 'show', '--help']);
+ expect(commandHelp.exitCode).toBe(0);
+ expect(commandHelp.stdout[0]).toContain('Usage: fastgpt-workflow node show');
+ });
+
+ it('honors JSON output for parser failures from flags or environment', async () => {
+ const { invoke } = await createHarness();
+ const flagFailure = await invoke(['unknown', '--format', 'json']);
+ expect(flagFailure.exitCode).toBe(2);
+ expect(JSON.parse(flagFailure.stderr[0])).toMatchObject({ ok: false, changed: false });
+
+ const envFailure = await invoke(['unknown'], {
+ NODE_ENV: 'test',
+ FASTGPT_WORKFLOW_FORMAT: 'json'
+ });
+ expect(JSON.parse(envFailure.stderr[0])).toMatchObject({ ok: false, changed: false });
+ });
+
+ it('supports the complete PR2 linear workflow command surface and import round-trip', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'pr2-linear');
+ await invoke(jsonArgs(dir, ['init', '--name', 'PR2 workflow']));
+ await invoke(jsonArgs(dir, ['meta', 'set', '--intro', 'Linear workflow fixture']));
+ await invoke(jsonArgs(dir, ['config', 'set', '--path', 'welcomeText', '--value', 'Welcome']));
+ expect(
+ JSON.parse((await invoke(jsonArgs(dir, ['meta', 'show']))).stdout[0]).result
+ ).toMatchObject({
+ name: 'PR2 workflow',
+ intro: 'Linear workflow fixture'
+ });
+ expect(
+ JSON.parse(
+ (await invoke(jsonArgs(dir, ['config', 'get', '--path', 'welcomeText']))).stdout[0]
+ ).result.value
+ ).toBe('Welcome');
+ expect(JSON.parse((await invoke(jsonArgs(dir, ['config', 'list']))).stdout[0]).result).toEqual(
+ expect.arrayContaining([expect.objectContaining({ path: 'welcomeText', value: 'Welcome' })])
+ );
+ await invoke(
+ jsonArgs(dir, ['config', 'set', '--path', 'questionGuide', '--value-json', '{"open":true}'])
+ );
+ await invoke(jsonArgs(dir, ['config', 'unset', '--path', 'questionGuide']));
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'tenantId',
+ '--value-type',
+ 'string',
+ '--description',
+ 'Tenant ID',
+ '--required'
+ ])
+ );
+ expect(
+ JSON.parse((await invoke(jsonArgs(dir, ['variable', 'list']))).stdout[0]).result
+ ).toHaveLength(1);
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'update',
+ '--key',
+ 'tenantId',
+ '--description',
+ 'Current tenant ID'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'text',
+ '--template',
+ 'builtin:text-editor',
+ '--after',
+ 'start@next'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'text',
+ '--key',
+ 'system_textareaInput',
+ '--value',
+ 'Hello'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, ['node', 'clone', '--node', 'text', '--id', 'text-copy', '--offset', '320,0'])
+ );
+ await invoke(
+ jsonArgs(dir, ['edge', 'connect', '--from', 'text@next', '--to', 'text-copy@target'])
+ );
+ await invoke(
+ jsonArgs(dir, ['node', 'add', '--node', 'answer', '--template', 'builtin:assigned-answer'])
+ );
+ await invoke(
+ jsonArgs(dir, ['edge', 'connect', '--from', 'text-copy@next', '--to', 'answer@target'])
+ );
+ expect(
+ JSON.parse((await invoke(jsonArgs(dir, ['edge', 'list']))).stdout[0]).result
+ ).toHaveLength(3);
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'ref',
+ '--node',
+ 'answer',
+ '--key',
+ 'text',
+ '--from',
+ 'text-copy.system_text'
+ ])
+ );
+ await invoke(jsonArgs(dir, ['node', 'update', '--node', 'answer', '--name', 'Final answer']));
+ await invoke(jsonArgs(dir, ['node', 'move', '--node', 'text-copy', '--position', '900,300']));
+
+ const available = await invoke(
+ jsonArgs(dir, ['input', 'available', '--node', 'answer', '--key', 'text'])
+ );
+ expect(JSON.parse(available.stdout[0]).result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ ref: { nodeId: 'text-copy', outputKey: 'system_text' },
+ source: 'node'
+ }),
+ expect.objectContaining({
+ ref: { nodeId: 'VARIABLE_NODE_ID', outputKey: 'tenantId' },
+ source: 'variable'
+ })
+ ])
+ );
+ expect((await invoke(jsonArgs(dir, ['validate']))).exitCode).toBe(0);
+ const inspect = await invoke(jsonArgs(dir, ['inspect']));
+ expect(JSON.parse(inspect.stdout[0]).result.diagnostics.errorCount).toBe(0);
+
+ await invoke(jsonArgs(dir, ['variable', 'remove', '--key', 'tenantId']));
+ const output = join(cwd, 'pr2-store.json');
+ expect((await invoke(jsonArgs(dir, ['build', '--output', output]))).exitCode).toBe(0);
+
+ const importedDir = join(cwd, 'pr2-imported');
+ expect((await invoke(jsonArgs(importedDir, ['import', '--input', output]))).exitCode).toBe(0);
+ const importedOutput = join(cwd, 'pr2-imported-store.json');
+ await invoke(jsonArgs(importedDir, ['build', '--output', importedOutput]));
+ expect(JSON.parse(await readFile(importedOutput, 'utf8'))).toEqual(
+ JSON.parse(await readFile(output, 'utf8'))
+ );
+
+ const invalidStore = JSON.parse(await readFile(output, 'utf8'));
+ invalidStore.edges[0].sourceHandle = 'unknown-handle';
+ const invalidStorePath = join(cwd, 'invalid-store.json');
+ await writeFile(invalidStorePath, JSON.stringify(invalidStore), 'utf8');
+ const invalidImportDir = join(cwd, 'invalid-import');
+ expect(
+ (await invoke(jsonArgs(invalidImportDir, ['import', '--input', invalidStorePath]))).exitCode
+ ).toBe(3);
+ await expect(access(join(invalidImportDir, 'workflow.json'))).rejects.toThrow();
+
+ await invoke(
+ jsonArgs(importedDir, [
+ 'edge',
+ 'reconnect',
+ '--from',
+ 'text-copy@next',
+ '--old-to',
+ 'answer@target',
+ '--to',
+ 'text@target'
+ ])
+ );
+ await invoke(
+ jsonArgs(importedDir, [
+ 'edge',
+ 'disconnect',
+ '--from',
+ 'text-copy@next',
+ '--to',
+ 'text@target'
+ ])
+ );
+ const remove = await invoke(jsonArgs(importedDir, ['node', 'remove', '--node', 'answer']));
+ expect(JSON.parse(remove.stdout[0]).changes[0]).toMatchObject({
+ type: 'node.remove',
+ nodeId: 'answer'
+ });
+ });
+
+ it('supports explicit variable input types and type-specific config', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'variable-types');
+ await invoke(jsonArgs(dir, ['init']));
+
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'quizResults',
+ '--type',
+ 'internal',
+ '--value-type',
+ 'arrayObject',
+ '--value-json',
+ '[]'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'theme',
+ '--type',
+ 'select',
+ '--value-type',
+ 'string',
+ '--options-json',
+ '[{"value":"math"},{"label":"Science","value":"science"}]'
+ ])
+ );
+ const numberConfigPath = join(cwd, 'number-variable.json');
+ await writeFile(numberConfigPath, JSON.stringify({ step: 0.5, precision: 1 }), 'utf8');
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'questionCount',
+ '--type',
+ 'numberInput',
+ '--value-type',
+ 'number',
+ '--config-file',
+ numberConfigPath,
+ '--min',
+ '1',
+ '--max',
+ '20'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'externalContext',
+ '--type',
+ 'external',
+ '--value-type',
+ 'object'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, ['variable', 'update', '--key', 'quizResults', '--value-type', 'arrayString'])
+ );
+
+ const variables = JSON.parse(
+ (await invoke(jsonArgs(dir, ['variable', 'list']))).stdout[0]
+ ).result;
+ expect(variables).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ key: 'quizResults',
+ type: 'internal',
+ valueType: 'arrayString',
+ required: false
+ }),
+ expect.objectContaining({
+ key: 'theme',
+ type: 'select',
+ list: [
+ { label: 'math', value: 'math' },
+ { label: 'Science', value: 'science' }
+ ]
+ }),
+ expect.objectContaining({
+ key: 'questionCount',
+ type: 'numberInput',
+ min: 1,
+ max: 20,
+ step: 0.5,
+ precision: 1
+ }),
+ expect.objectContaining({ key: 'externalContext', type: 'custom', valueType: 'object' })
+ ])
+ );
+
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'invalidSwitch',
+ '--type',
+ 'switch',
+ '--value-type',
+ 'string'
+ ])
+ )
+ ).exitCode
+ ).toBe(2);
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'invalidInternal',
+ '--type',
+ 'internal',
+ '--value-type',
+ 'string',
+ '--required'
+ ])
+ )
+ ).exitCode
+ ).toBe(2);
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'variable',
+ 'add',
+ '--key',
+ 'invalidConfig',
+ '--value-type',
+ 'string',
+ '--config-json',
+ '{"type":"internal"}'
+ ])
+ )
+ ).exitCode
+ ).toBe(2);
+ });
+
+ it('adds system config when importing a legacy workflow without overwriting chatConfig', async () => {
+ const { cwd, invoke } = await createHarness();
+ const sourceDir = join(cwd, 'legacy-source');
+ await invoke(jsonArgs(sourceDir, ['init']));
+ await invoke(
+ jsonArgs(sourceDir, ['config', 'set', '--path', 'welcomeText', '--value', 'Legacy welcome'])
+ );
+ await invoke(
+ jsonArgs(sourceDir, ['variable', 'add', '--key', 'tenantId', '--value-type', 'string'])
+ );
+ const sourceStorePath = join(cwd, 'legacy-store.json');
+ await invoke(jsonArgs(sourceDir, ['build', '--output', sourceStorePath]));
+ const legacyStore = JSON.parse(await readFile(sourceStorePath, 'utf8'));
+ legacyStore.nodes = legacyStore.nodes.filter(
+ (node: { flowNodeType: string }) => node.flowNodeType !== 'userGuide'
+ );
+ await writeFile(sourceStorePath, JSON.stringify(legacyStore), 'utf8');
+
+ const importedDir = join(cwd, 'legacy-imported');
+ expect(
+ (await invoke(jsonArgs(importedDir, ['import', '--input', sourceStorePath]))).exitCode
+ ).toBe(0);
+ const importedDocument = JSON.parse(await readFile(join(importedDir, 'workflow.json'), 'utf8'));
+ expect(importedDocument.nodes).toContainEqual(
+ expect.objectContaining({ nodeId: 'userGuide', flowNodeType: 'userGuide' })
+ );
+ expect(importedDocument.chatConfig).toMatchObject({
+ welcomeText: 'Legacy welcome',
+ variables: [expect.objectContaining({ key: 'tenantId' })]
+ });
+ });
+
+ it('supports PR3 branch, insert, tool, output and nesting commands', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'pr3-complex');
+ await invoke(jsonArgs(dir, ['init']));
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'route',
+ '--template',
+ 'builtin:if-else',
+ '--after',
+ 'start@next'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'input',
+ 'set',
+ '--node',
+ 'route',
+ '--key',
+ 'ifElseList',
+ '--value-json',
+ '[{"branchId":"positive","condition":"AND","list":[{"variable":["start","userChatInput"],"condition":"isNotEmpty","valueType":"input"}]}]'
+ ])
+ );
+ await invoke(
+ jsonArgs(dir, ['node', 'add', '--node', 'answer', '--template', 'builtin:assigned-answer'])
+ );
+ await invoke(
+ jsonArgs(dir, ['input', 'set', '--node', 'answer', '--key', 'text', '--value', 'ok'])
+ );
+ await invoke(
+ jsonArgs(dir, ['edge', 'connect', '--from', 'route@branch:positive', '--to', 'answer@target'])
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'insert',
+ '--from',
+ 'route@branch:positive',
+ '--to',
+ 'answer@target',
+ '--template',
+ 'builtin:text-editor',
+ '--id',
+ 'middle'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+
+ await invoke(
+ jsonArgs(dir, ['node', 'add', '--node', 'caller', '--template', 'builtin:tool-call'])
+ );
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'tool',
+ 'attach',
+ '--tool-call',
+ 'caller',
+ '--template',
+ 'builtin:user-select',
+ '--id',
+ 'confirm'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ expect(
+ JSON.parse((await invoke(jsonArgs(dir, ['tool', 'list', '--tool-call', 'caller']))).stdout[0])
+ .result
+ ).toHaveLength(1);
+
+ await invoke(jsonArgs(dir, ['node', 'add', '--node', 'code', '--template', 'builtin:code']));
+ await invoke(jsonArgs(dir, ['node', 'update', '--node', 'code', '--catch-error']));
+ await invoke(
+ jsonArgs(dir, ['output', 'add', '--node', 'code', '--key', 'score', '--value-type', 'number'])
+ );
+ expect(
+ JSON.parse((await invoke(jsonArgs(dir, ['output', 'list', '--node', 'code']))).stdout[0])
+ .result
+ ).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'score' })]));
+ expect(
+ (await invoke(jsonArgs(dir, ['output', 'remove', '--node', 'code', '--key', 'score'])))
+ .exitCode
+ ).toBe(0);
+
+ await invoke(
+ jsonArgs(dir, ['node', 'add', '--node', 'loop', '--template', 'builtin:loop-run'])
+ );
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'break',
+ '--template',
+ 'builtin:loop-run-break',
+ '--parent',
+ 'loop'
+ ])
+ );
+ expect(
+ JSON.parse(
+ (await invoke(jsonArgs(dir, ['container', 'children', '--node', 'loop']))).stdout[0]
+ ).result.map((node: { nodeId: string }) => node.nodeId)
+ ).toEqual(expect.arrayContaining(['loop__start', 'break']));
+ expect(
+ (await invoke(jsonArgs(dir, ['node', 'move', '--node', 'break', '--root']))).exitCode
+ ).toBe(3);
+ });
+
+ it('builds a file-upload workflow after enabling file selection', async () => {
+ const { cwd, invoke } = await createHarness();
+ const dir = join(cwd, 'file-upload');
+ await invoke(jsonArgs(dir, ['init']));
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'config',
+ 'set',
+ '--path',
+ 'fileSelectConfig',
+ '--value-json',
+ '{"maxFiles":1,"canSelectFile":true}'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ expect(
+ (
+ await invoke(
+ jsonArgs(dir, [
+ 'node',
+ 'add',
+ '--node',
+ 'read',
+ '--template',
+ 'builtin:read-files',
+ '--after',
+ 'start@next'
+ ])
+ )
+ ).exitCode
+ ).toBe(0);
+ expect((await invoke(jsonArgs(dir, ['validate']))).exitCode).toBe(0);
+ expect(
+ JSON.parse(
+ (await invoke(jsonArgs(dir, ['input', 'show', '--node', 'read', '--key', 'fileUrlList'])))
+ .stdout[0]
+ ).result.value
+ ).toEqual([['start', 'userFiles']]);
+ });
+});
diff --git a/packages/workflow-cli/test/io/workflowFile.test.ts b/packages/workflow-cli/test/io/workflowFile.test.ts
new file mode 100644
index 000000000000..2548869351d6
--- /dev/null
+++ b/packages/workflow-cli/test/io/workflowFile.test.ts
@@ -0,0 +1,40 @@
+import {
+ parseWorkflowDocument,
+ readWorkflowFile,
+ serializeWorkflowDocument,
+ writeFileAtomic,
+ writeWorkflowFileAtomic
+} from '../../src';
+import aiWorkflow from '../../../workflow-core/test/fixtures/basic-ai/workflow.json';
+import { mkdtemp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+describe('workflow file codec', () => {
+ it('round-trips with stable formatting and a trailing newline', async () => {
+ const document = parseWorkflowDocument(aiWorkflow);
+ const serialized = serializeWorkflowDocument(document);
+ expect(serialized.endsWith('\n')).toBe(true);
+ expect(parseWorkflowDocument(JSON.parse(serialized))).toEqual(document);
+
+ const dir = await mkdtemp(join(tmpdir(), 'workflow-file-'));
+ await writeWorkflowFileAtomic(dir, document);
+ await expect(readWorkflowFile(dir)).resolves.toEqual(document);
+ expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(serialized);
+ });
+
+ it('keeps an existing directory and cleans temporary files when rename fails', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'workflow-atomic-'));
+ const targetDirectory = join(dir, 'target');
+ await mkdir(targetDirectory);
+ await expect(writeFileAtomic(targetDirectory, 'value')).rejects.toThrow();
+ expect(await readdir(dir)).toEqual(['target']);
+ });
+
+ it('reports malformed workflow JSON as a CLI argument error', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'workflow-invalid-'));
+ await writeFile(join(dir, 'workflow.json'), '{', 'utf8');
+ await expect(readWorkflowFile(dir)).rejects.toMatchObject({ code: 'CLI_ARGUMENT_INVALID' });
+ });
+});
diff --git a/packages/workflow-cli/test/output.test.ts b/packages/workflow-cli/test/output.test.ts
new file mode 100644
index 000000000000..f00a4ee350dc
--- /dev/null
+++ b/packages/workflow-cli/test/output.test.ts
@@ -0,0 +1,44 @@
+import { createSuccessEnvelope, renderError, renderSuccess } from '../src';
+import { describe, expect, it } from 'vitest';
+
+describe('CLI output', () => {
+ it('renders stable success envelopes and text variants', () => {
+ expect(createSuccessEnvelope('validate', { changed: false, result: { valid: true } })).toEqual({
+ schemaVersion: 'fastgpt-workflow-cli-result/v1',
+ ok: true,
+ command: 'validate',
+ changed: false,
+ checksum: undefined,
+ changes: undefined,
+ result: { valid: true },
+ warnings: []
+ });
+ expect(
+ renderSuccess({ command: 'x', result: { changed: false, result: 'value' }, format: 'text' })
+ ).toBe('value');
+ expect(
+ renderSuccess({
+ command: 'x',
+ result: { changed: false, result: { a: 1 } },
+ format: 'text'
+ })
+ ).toBe('{\n "a": 1\n}');
+ expect(
+ renderSuccess({ command: 'x', result: { changed: false, message: 'done' }, format: 'text' })
+ ).toBe('done');
+ expect(renderSuccess({ command: 'x', result: { changed: false }, format: 'text' })).toBe('OK');
+ });
+
+ it('renders errors as pure JSON or readable text', () => {
+ expect(
+ JSON.parse(renderError({ command: 'x', code: 'BAD', params: { key: 1 }, format: 'json' }))
+ ).toMatchObject({
+ ok: false,
+ changed: false,
+ errors: [{ code: 'BAD', params: { key: 1 } }]
+ });
+ expect(
+ renderError({ command: 'x', code: 'BAD', diagnostics: [{ code: 'D' }], format: 'text' })
+ ).toContain('"code": "D"');
+ });
+});
diff --git a/packages/workflow-cli/test/parser.test.ts b/packages/workflow-cli/test/parser.test.ts
new file mode 100644
index 000000000000..0c667fb63aa5
--- /dev/null
+++ b/packages/workflow-cli/test/parser.test.ts
@@ -0,0 +1,52 @@
+import { CliArgumentError, parseCliArgs } from '../src';
+import { describe, expect, it } from 'vitest';
+
+const parse = (argv: string[]) => parseCliArgs({ argv, cwd: '/tmp', env: { NODE_ENV: 'test' } });
+
+describe('parseCliArgs', () => {
+ it('allows global options after the command and resolves context', () => {
+ const parsed = parse([
+ 'node',
+ 'add',
+ '--node',
+ 'ai',
+ '--template',
+ 'builtin:ai-chat',
+ '--dir',
+ './demo',
+ '--format',
+ 'json',
+ '--dry-run'
+ ]);
+ expect(parsed.context.dir).toBe('/tmp/demo');
+ expect(parsed.context.format).toBe('json');
+ expect(parsed.input.dryRun).toBe(true);
+ });
+
+ it.each([
+ [['unknown'], 'Unknown command'],
+ [['validate', '--output', 'x'], 'Option is not valid'],
+ [['validate', '--format', 'yaml'], '--format must be'],
+ [['node', 'show'], 'Command options are invalid'],
+ [['node', 'show', '--missing'], 'Unknown option']
+ ] as const)('rejects invalid arguments', (argv, message) => {
+ expect(() => parse([...argv])).toThrowError(new RegExp(message));
+ });
+
+ it('enforces mutually exclusive value options', () => {
+ expect(() =>
+ parse([
+ 'input',
+ 'set',
+ '--node',
+ 'ai',
+ '--key',
+ 'model',
+ '--value',
+ 'a',
+ '--value-json',
+ '"b"'
+ ])
+ ).toThrow(CliArgumentError);
+ });
+});
diff --git a/packages/workflow-cli/test/registry.test.ts b/packages/workflow-cli/test/registry.test.ts
new file mode 100644
index 000000000000..040bbfba08d4
--- /dev/null
+++ b/packages/workflow-cli/test/registry.test.ts
@@ -0,0 +1,579 @@
+import { cliCommandRegistry, renderHelp } from '../src';
+import { describe, expect, it } from 'vitest';
+
+describe('cliCommandRegistry', () => {
+ it('registers exactly the PR1 through PR3 command surface once', () => {
+ const snapshot = cliCommandRegistry.map((definition) => ({
+ path: definition.path.join(' '),
+ introducedIn: definition.introducedIn,
+ kind: definition.kind,
+ supportsDryRun: definition.supportsDryRun,
+ confirm: definition.confirm,
+ options: definition.options.map((item) => item.name)
+ }));
+ expect(new Set(snapshot.map((item) => item.path)).size).toBe(snapshot.length);
+ expect(snapshot).toMatchInlineSnapshot(`
+ [
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "localMutation",
+ "options": [
+ "--name",
+ "--dry-run",
+ ],
+ "path": "init",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "artifact",
+ "options": [
+ "--output",
+ ],
+ "path": "build",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--input",
+ "--dry-run",
+ ],
+ "path": "import",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [],
+ "path": "inspect",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [],
+ "path": "meta show",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--name",
+ "--intro",
+ "--dry-run",
+ ],
+ "path": "meta set",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [],
+ "path": "config list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [
+ "--path",
+ ],
+ "path": "config get",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--path",
+ "--value",
+ "--value-json",
+ "--value-file",
+ "--value-env",
+ "--dry-run",
+ ],
+ "path": "config set",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--path",
+ "--dry-run",
+ ],
+ "path": "config unset",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [],
+ "path": "variable list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--key",
+ "--type",
+ "--value-type",
+ "--label",
+ "--description",
+ "--required",
+ "--config-json",
+ "--config-file",
+ "--options-json",
+ "--min",
+ "--max",
+ "--max-length",
+ "--time-granularity",
+ "--value",
+ "--value-json",
+ "--value-file",
+ "--value-env",
+ "--dry-run",
+ ],
+ "path": "variable add",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--key",
+ "--new-key",
+ "--type",
+ "--value-type",
+ "--label",
+ "--description",
+ "--required",
+ "--optional",
+ "--config-json",
+ "--config-file",
+ "--options-json",
+ "--min",
+ "--max",
+ "--max-length",
+ "--time-granularity",
+ "--value",
+ "--value-json",
+ "--value-file",
+ "--value-env",
+ "--dry-run",
+ ],
+ "path": "variable update",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--key",
+ "--dry-run",
+ ],
+ "path": "variable remove",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "query",
+ "options": [
+ "--source",
+ ],
+ "path": "template list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "query",
+ "options": [
+ "--template",
+ ],
+ "path": "template show",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "query",
+ "options": [
+ "--type",
+ "--parent",
+ ],
+ "path": "node list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "query",
+ "options": [
+ "--node",
+ ],
+ "path": "node show",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--template",
+ "--name",
+ "--after",
+ "--parent",
+ "--position",
+ "--dry-run",
+ ],
+ "path": "node add",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--name",
+ "--position",
+ "--catch-error",
+ "--no-catch-error",
+ "--dry-run",
+ ],
+ "path": "node update",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--position",
+ "--parent",
+ "--root",
+ "--dry-run",
+ ],
+ "path": "node move",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--from",
+ "--to",
+ "--template",
+ "--id",
+ "--position",
+ "--dry-run",
+ ],
+ "path": "node insert",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--id",
+ "--position",
+ "--offset",
+ "--dry-run",
+ ],
+ "path": "node clone",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--dry-run",
+ ],
+ "path": "node remove",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [
+ "--node",
+ "--kind",
+ ],
+ "path": "edge list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--from",
+ "--to",
+ "--dry-run",
+ ],
+ "path": "edge connect",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--from",
+ "--to",
+ "--dry-run",
+ ],
+ "path": "edge disconnect",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--from",
+ "--old-to",
+ "--to",
+ "--dry-run",
+ ],
+ "path": "edge reconnect",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "query",
+ "options": [
+ "--node",
+ ],
+ "path": "input list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [
+ "--node",
+ "--key",
+ ],
+ "path": "input show",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--value-type",
+ "--mode",
+ "--label",
+ "--description",
+ "--required",
+ "--dry-run",
+ ],
+ "path": "input add",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--dry-run",
+ ],
+ "path": "input remove",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--value",
+ "--value-json",
+ "--value-file",
+ "--value-env",
+ "--dry-run",
+ ],
+ "path": "input set",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--from",
+ "--dry-run",
+ ],
+ "path": "input ref",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--dry-run",
+ ],
+ "path": "input unset",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR2",
+ "kind": "query",
+ "options": [
+ "--node",
+ "--key",
+ ],
+ "path": "input available",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "query",
+ "options": [
+ "--node",
+ ],
+ "path": "output list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--value-type",
+ "--label",
+ "--description",
+ "--dry-run",
+ ],
+ "path": "output add",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--node",
+ "--key",
+ "--dry-run",
+ ],
+ "path": "output remove",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "query",
+ "options": [
+ "--tool-call",
+ ],
+ "path": "tool list",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--tool-call",
+ "--template",
+ "--tool-node",
+ "--id",
+ "--position",
+ "--dry-run",
+ ],
+ "path": "tool attach",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "localMutation",
+ "options": [
+ "--tool-call",
+ "--tool-node",
+ "--dry-run",
+ ],
+ "path": "tool detach",
+ "supportsDryRun": true,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR3",
+ "kind": "query",
+ "options": [
+ "--node",
+ ],
+ "path": "container children",
+ "supportsDryRun": false,
+ },
+ {
+ "confirm": "none",
+ "introducedIn": "PR1",
+ "kind": "query",
+ "options": [],
+ "path": "validate",
+ "supportsDryRun": false,
+ },
+ ]
+ `);
+ });
+});
+
+describe('renderHelp', () => {
+ it('derives global and command help from the registry', () => {
+ const help = renderHelp();
+ expect(help).toContain('template show');
+ expect(help).toContain('input ref');
+ expect(help).not.toContain('changeset');
+ expect(
+ renderHelp(cliCommandRegistry.find((item) => item.path.join(' ') === 'build'))
+ ).toContain('--output (required)');
+ });
+});
diff --git a/packages/workflow-cli/tsconfig.json b/packages/workflow-cli/tsconfig.json
new file mode 100644
index 000000000000..2304b849fa98
--- /dev/null
+++ b/packages/workflow-cli/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "paths": {
+ "@fastgpt/workflow-core": ["../workflow-core/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "tsdown.config.ts"]
+}
diff --git a/packages/workflow-cli/tsdown.config.ts b/packages/workflow-cli/tsdown.config.ts
new file mode 100644
index 000000000000..a117e77c0f7e
--- /dev/null
+++ b/packages/workflow-cli/tsdown.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: ['src/index.ts', 'src/cli.ts'],
+ format: 'esm',
+ platform: 'node',
+ target: 'node20',
+ deps: {
+ alwaysBundle: [/^@fastgpt\/workflow-core/, /^@fastgpt\/global/, /^@fastgpt\/web/],
+ onlyBundle: false
+ },
+ dts: false,
+ outExtensions: () => ({ js: '.js', dts: '.d.ts' })
+});
diff --git a/packages/workflow-cli/vitest.config.ts b/packages/workflow-cli/vitest.config.ts
new file mode 100644
index 000000000000..a5b552d5029c
--- /dev/null
+++ b/packages/workflow-cli/vitest.config.ts
@@ -0,0 +1,20 @@
+import { resolve } from 'node:path';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@fastgpt/workflow-core': resolve('../workflow-core/src/index.ts'),
+ '@fastgpt': resolve('..')
+ }
+ },
+ test: {
+ include: ['test/**/*.test.ts'],
+ coverage: {
+ enabled: true,
+ reporter: ['text', 'text-summary', 'json-summary'],
+ include: ['src/**/*.ts'],
+ exclude: ['src/**/type.ts', 'src/cli.ts', 'src/index.ts']
+ }
+ }
+});
diff --git a/packages/workflow-core/package.json b/packages/workflow-core/package.json
new file mode 100644
index 000000000000..11c1c833b6ae
--- /dev/null
+++ b/packages/workflow-core/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@fastgpt/workflow-core",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "sideEffects": false,
+ "scripts": {
+ "build": "tsdown",
+ "test": "vitest run --config ./vitest.config.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@fastgpt/global": "workspace:*",
+ "zod": "catalog:"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "tsdown": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:",
+ "@vitest/coverage-v8": "catalog:"
+ }
+}
diff --git a/packages/workflow-core/src/binding/service.ts b/packages/workflow-core/src/binding/service.ts
new file mode 100644
index 000000000000..e40765cbb95e
--- /dev/null
+++ b/packages/workflow-core/src/binding/service.ts
@@ -0,0 +1,53 @@
+import type { WorkflowDocument } from '../domain/document';
+import type { WorkflowDiagnostic } from '../domain/diagnostic';
+import { getInputAutomationMeta } from '../template/automationMeta';
+import { hasConfiguredValue } from '../template/defaultValue';
+import type { WorkflowBindingRequirement } from './type';
+
+/** 收集无法由本地结构校验证明完整或有效的外部输入绑定,不返回实际值。 */
+export const collectWorkflowBindings = (document: WorkflowDocument): WorkflowBindingRequirement[] =>
+ document.nodes.flatMap((node) =>
+ node.inputs.flatMap((input) => {
+ const meta = getInputAutomationMeta(node.flowNodeType, input.key);
+ if (
+ !meta ||
+ (meta.defaultPolicy !== 'userRequired' && meta.defaultPolicy !== 'remoteValidated')
+ )
+ return [];
+
+ const defaultPolicy = meta.defaultPolicy;
+ const configured = hasConfiguredValue(input.value);
+ const bindingRequired = input.required === true || meta.bindingRequired === true;
+ const status = (() => {
+ if (!configured && bindingRequired) return 'missing' as const;
+ if (configured && defaultPolicy === 'remoteValidated') return 'unverified' as const;
+ })();
+ if (!status) return [];
+
+ return [
+ {
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ defaultPolicy,
+ resourceKind: meta.resourceKind,
+ status
+ }
+ ];
+ })
+ );
+
+/** 将绑定状态转换为 CLI/Web 可统一展示的非阻断诊断。 */
+export const getWorkflowBindingDiagnostics = (
+ bindings: WorkflowBindingRequirement[]
+): WorkflowDiagnostic[] =>
+ bindings.map((binding) => ({
+ code:
+ binding.status === 'missing' ? 'WORKFLOW_BINDING_REQUIRED' : 'WORKFLOW_BINDING_UNVERIFIED',
+ severity: 'warning',
+ nodeId: binding.nodeId,
+ inputKey: binding.inputKey,
+ params: {
+ defaultPolicy: binding.defaultPolicy,
+ ...(binding.resourceKind ? { resourceKind: binding.resourceKind } : {})
+ }
+ }));
diff --git a/packages/workflow-core/src/binding/type.ts b/packages/workflow-core/src/binding/type.ts
new file mode 100644
index 000000000000..4f34f92ecf33
--- /dev/null
+++ b/packages/workflow-core/src/binding/type.ts
@@ -0,0 +1,11 @@
+import type { WorkflowInputDefaultPolicy, WorkflowResourceKind } from '../template/type';
+
+export type WorkflowBindingStatus = 'missing' | 'unverified';
+
+export type WorkflowBindingRequirement = {
+ nodeId: string;
+ inputKey: string;
+ defaultPolicy: Exclude;
+ resourceKind?: WorkflowResourceKind;
+ status: WorkflowBindingStatus;
+};
diff --git a/packages/workflow-core/src/code/io.ts b/packages/workflow-core/src/code/io.ts
new file mode 100644
index 000000000000..19df88328d77
--- /dev/null
+++ b/packages/workflow-core/src/code/io.ts
@@ -0,0 +1,278 @@
+import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+
+export type CodeIoDefinition = {
+ key: string;
+ valueType?: WorkflowIOValueTypeEnum;
+};
+
+const workflowValueTypes = new Set(Object.values(WorkflowIOValueTypeEnum));
+
+const toWorkflowValueType = (value: string | undefined) =>
+ value && workflowValueTypes.has(value) ? (value as WorkflowIOValueTypeEnum) : undefined;
+
+const isIdentifierCharacter = (value: string | undefined) =>
+ value !== undefined && /[\p{L}\p{N}_$]/u.test(value);
+
+const skipQuotedValue = (source: string, start: number) => {
+ const quote = source[start];
+ let index = start + 1;
+ while (index < source.length) {
+ if (source[index] === '\\') {
+ index += 2;
+ continue;
+ }
+ if (source[index] === quote) return index + 1;
+ index += 1;
+ }
+ return source.length;
+};
+
+const skipComment = (source: string, start: number) => {
+ if (source.startsWith('//', start) || source[start] === '#') {
+ const end = source.indexOf('\n', start + 1);
+ return end < 0 ? source.length : end + 1;
+ }
+ if (source.startsWith('/*', start)) {
+ const end = source.indexOf('*/', start + 2);
+ return end < 0 ? source.length : end + 2;
+ }
+ return start;
+};
+
+const isRegexLiteralStart = (source: string, start: number) => {
+ if (source[start] !== '/' || source[start + 1] === '/' || source[start + 1] === '*') {
+ return false;
+ }
+ const prefix = source.slice(0, start).trimEnd();
+ const previous = prefix.at(-1);
+ return (
+ previous === undefined ||
+ ['(', '[', '{', ':', ',', ';', '=', '!', '?', '&', '|'].includes(previous) ||
+ /\b(?:return|case|throw|typeof|instanceof|in|of|yield)$/.test(prefix)
+ );
+};
+
+const skipRegexLiteral = (source: string, start: number) => {
+ let inCharacterClass = false;
+ for (let index = start + 1; index < source.length; index += 1) {
+ if (source[index] === '\\') {
+ index += 1;
+ continue;
+ }
+ if (source[index] === '[') inCharacterClass = true;
+ if (source[index] === ']') inCharacterClass = false;
+ if (source[index] === '/' && !inCharacterClass) {
+ index += 1;
+ while (/[a-z]/i.test(source[index] ?? '')) index += 1;
+ return index;
+ }
+ }
+ return source.length;
+};
+
+const findMatchingCharacter = ({
+ source,
+ start,
+ open,
+ close
+}: {
+ source: string;
+ start: number;
+ open: string;
+ close: string;
+}) => {
+ let depth = 0;
+ for (let index = start; index < source.length; index += 1) {
+ const commentEnd = skipComment(source, index);
+ if (commentEnd !== index) {
+ index = commentEnd - 1;
+ continue;
+ }
+ if (isRegexLiteralStart(source, index)) {
+ index = skipRegexLiteral(source, index) - 1;
+ continue;
+ }
+ if (["'", '"', '`'].includes(source[index])) {
+ index = skipQuotedValue(source, index) - 1;
+ continue;
+ }
+ if (source[index] === open) depth += 1;
+ if (source[index] === close) {
+ depth -= 1;
+ if (depth === 0) return index;
+ }
+ }
+ return -1;
+};
+
+const splitTopLevelItems = (value: string) => {
+ const items: string[] = [];
+ let start = 0;
+ let depth = 0;
+ for (let index = 0; index < value.length; index += 1) {
+ const commentEnd = skipComment(value, index);
+ if (commentEnd !== index) {
+ index = commentEnd - 1;
+ continue;
+ }
+ if (isRegexLiteralStart(value, index)) {
+ index = skipRegexLiteral(value, index) - 1;
+ continue;
+ }
+ if (["'", '"', '`'].includes(value[index])) {
+ index = skipQuotedValue(value, index) - 1;
+ continue;
+ }
+ if (['{', '[', '('].includes(value[index])) depth += 1;
+ if (['}', ']', ')'].includes(value[index])) depth -= 1;
+ if (value[index] === ',' && depth === 0) {
+ items.push(value.slice(start, index));
+ start = index + 1;
+ }
+ }
+ items.push(value.slice(start));
+ return items;
+};
+
+const getStaticPropertyKey = (property: string) => {
+ const value = property.trim();
+ if (!value || value.startsWith('...') || value.startsWith('[')) return;
+ if (["'", '"'].includes(value[0])) {
+ const end = skipQuotedValue(value, 0);
+ if (value.slice(end).trimStart()[0] !== ':') return;
+ return value.slice(1, end - 1).replace(/\\([\\'"`])/g, '$1');
+ }
+ return value.match(/^([\p{L}_$][\p{L}\p{N}_$]*)\s*(?::|=|$)/u)?.[1];
+};
+
+const getDocumentedDefinitions = (code: string, tag: 'param' | 'property') =>
+ [...code.matchAll(new RegExp(`@${tag}\\s*\\{([^}]+)\\}\\s*([^\\s-]+)\\s*-?\\s*.*`, 'g'))]
+ .map((match) => ({
+ key: match[2].trim(),
+ valueType: toWorkflowValueType(match[1].trim())
+ }))
+ .filter((item) => item.key);
+
+const mergeDefinitions = ({
+ keys,
+ documented
+}: {
+ keys: string[];
+ documented: CodeIoDefinition[];
+}) =>
+ keys
+ .filter((key, index) => keys.indexOf(key) === index)
+ .map((key, index) => ({
+ key,
+ valueType:
+ documented.find((item) => item.key === key)?.valueType ??
+ (documented.length === keys.length ? documented[index]?.valueType : undefined)
+ }));
+
+/** 提取 main 函数的静态参数名;无法识别动态参数对象时返回 undefined。 */
+export const extractCodeInputDefinitions = (code: string): CodeIoDefinition[] | undefined => {
+ const documented = getDocumentedDefinitions(code, 'param');
+ const jsMatch = code.match(/(?:async\s+)?function\s+main\s*\(\s*/);
+ if (jsMatch?.index !== undefined) {
+ const paramsStart = jsMatch.index + jsMatch[0].length;
+ if (code[paramsStart] === ')') return [];
+ if (code[paramsStart] !== '{') return undefined;
+ const objectStart = paramsStart;
+ const objectEnd = findMatchingCharacter({
+ source: code,
+ start: objectStart,
+ open: '{',
+ close: '}'
+ });
+ if (objectEnd >= 0) {
+ const keys = splitTopLevelItems(code.slice(objectStart + 1, objectEnd))
+ .map(getStaticPropertyKey)
+ .filter((key): key is string => Boolean(key));
+ return mergeDefinitions({ keys, documented });
+ }
+ }
+
+ const pythonMatch = code.match(/def\s+main\s*\(/);
+ if (pythonMatch?.index !== undefined) {
+ const paramsStart = pythonMatch.index + pythonMatch[0].length - 1;
+ const paramsEnd = findMatchingCharacter({
+ source: code,
+ start: paramsStart,
+ open: '(',
+ close: ')'
+ });
+ if (paramsEnd >= 0) {
+ const keys = splitTopLevelItems(code.slice(paramsStart + 1, paramsEnd))
+ .map(
+ (item) =>
+ item
+ .trim()
+ .replace(/^\*+/, '')
+ .match(/^([\p{L}_][\p{L}\p{N}_]*)/u)?.[1]
+ )
+ .filter((key): key is string => Boolean(key));
+ return mergeDefinitions({ keys, documented });
+ }
+ }
+
+ return undefined;
+};
+
+const extractReturnedObjectKeysOrUndefined = (code: string): string[] | undefined => {
+ const keys: string[] = [];
+ let foundReturnObject = false;
+ for (let index = 0; index < code.length; index += 1) {
+ const commentEnd = skipComment(code, index);
+ if (commentEnd !== index) {
+ index = commentEnd - 1;
+ continue;
+ }
+ if (isRegexLiteralStart(code, index)) {
+ index = skipRegexLiteral(code, index) - 1;
+ continue;
+ }
+ if (["'", '"', '`'].includes(code[index])) {
+ index = skipQuotedValue(code, index) - 1;
+ continue;
+ }
+ if (
+ !code.startsWith('return', index) ||
+ isIdentifierCharacter(code[index - 1]) ||
+ isIdentifierCharacter(code[index + 6])
+ ) {
+ continue;
+ }
+ let objectStart = index + 6;
+ while (/\s/.test(code[objectStart] ?? '')) objectStart += 1;
+ if (code[objectStart] === '(') {
+ objectStart += 1;
+ while (/\s/.test(code[objectStart] ?? '')) objectStart += 1;
+ }
+ if (code[objectStart] !== '{') continue;
+ const objectEnd = findMatchingCharacter({
+ source: code,
+ start: objectStart,
+ open: '{',
+ close: '}'
+ });
+ if (objectEnd < 0) continue;
+ foundReturnObject = true;
+ splitTopLevelItems(code.slice(objectStart + 1, objectEnd)).forEach((property) => {
+ const key = getStaticPropertyKey(property);
+ if (key && !keys.includes(key)) keys.push(key);
+ });
+ index = objectEnd;
+ }
+ return foundReturnObject ? keys : undefined;
+};
+
+/** 从实际 return 对象提取稳定输出 key;计算属性和展开属性不会被推断为固定输出。 */
+export const extractReturnedObjectKeys = (code: string): string[] =>
+ extractReturnedObjectKeysOrUndefined(code) ?? [];
+
+/** return 对象是输出事实源,JSDoc 只补充可选的类型信息。 */
+export const extractCodeOutputDefinitions = (code: string): CodeIoDefinition[] | undefined => {
+ const keys = extractReturnedObjectKeysOrUndefined(code);
+ if (!keys) return;
+ return mergeDefinitions({ keys, documented: getDocumentedDefinitions(code, 'property') });
+};
diff --git a/packages/workflow-core/src/command/apply.ts b/packages/workflow-core/src/command/apply.ts
new file mode 100644
index 000000000000..087aab3c5f98
--- /dev/null
+++ b/packages/workflow-core/src/command/apply.ts
@@ -0,0 +1,453 @@
+import { getWorkflowChecksum } from '../domain/checksum';
+import type { WorkflowDocument } from '../domain/document';
+import type { WorkflowDiagnostic } from '../domain/diagnostic';
+import {
+ assertExecutionEdge,
+ connectExecutionEdge,
+ disconnectExecutionEdge,
+ getDefaultExecutionSourcePort,
+ reconnectExecutionEdge
+} from '../edge/service';
+import type { WorkflowExecutionEdge } from '../edge/type';
+import { setInputReference, setInputValue, unsetInput } from '../reference/service';
+import { cloneNode, removeNode, updateNode } from '../node/service';
+import {
+ addGlobalVariable,
+ removeGlobalVariable,
+ setChatConfigValue,
+ unsetChatConfigValue,
+ updateGlobalVariable
+} from '../config/service';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import type { WorkflowTemplateProvider } from '../template/type';
+import { WorkflowCommandSchema, type WorkflowChangeSummary, type WorkflowCommand } from './type';
+import { addNodeFromTemplate } from '../node/add';
+import {
+ addNodeInput,
+ addNodeOutput,
+ removeNodeInput,
+ removeNodeOutput,
+ syncCodeNodeIO,
+ syncFormInputOutputs
+} from '../io/service';
+import { getDocumentNode, moveNodeToParent } from '../nesting/service';
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+
+export type WorkflowCommandResult = {
+ document: WorkflowDocument;
+ changes: WorkflowChangeSummary[];
+ warnings: WorkflowDiagnostic[];
+ checksum: string;
+};
+
+/**
+ * 对结构化副本执行一条领域命令。任何步骤失败都会丢弃副本,调用方不会拿到半成品。
+ */
+export const applyWorkflowCommand = async ({
+ document,
+ command: rawCommand,
+ dependencies
+}: {
+ document: WorkflowDocument;
+ command: WorkflowCommand;
+ dependencies: {
+ templateProvider: WorkflowTemplateProvider;
+ locale?: string;
+ translate?: (value: string) => string;
+ };
+}): Promise => {
+ const command = WorkflowCommandSchema.parse(rawCommand);
+ const nextDocument = structuredClone(document);
+ const warnings: WorkflowDiagnostic[] = [];
+ const changes: WorkflowChangeSummary[] = [];
+
+ if (command.type === 'node.add') {
+ const added = await addNodeFromTemplate({
+ document: nextDocument,
+ template: command.template,
+ nodeId: command.nodeId,
+ name: command.name,
+ position: command.position,
+ parentNodeId: command.parentNodeId,
+ dependencies
+ });
+ warnings.push(...added.warnings);
+
+ for (const [inputKey, value] of Object.entries(command.inputOverrides ?? {})) {
+ setInputValue({ document: nextDocument, nodeId: command.nodeId, inputKey, value });
+ }
+
+ if (command.connectFrom) {
+ const edge: WorkflowExecutionEdge = {
+ source: command.connectFrom,
+ target: { kind: 'target', nodeId: command.nodeId }
+ };
+ connectExecutionEdge({ document: nextDocument, edge });
+ }
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ ...(added.nodeIds.length > 1 ? { details: { nodeIds: added.nodeIds } } : {})
+ });
+ }
+
+ if (command.type === 'input.set') {
+ const inputNode = getDocumentNode(nextDocument, command.nodeId);
+ let previousFormFieldKeys: string[] = [];
+ if (
+ inputNode.flowNodeType === FlowNodeTypeEnum.formInput &&
+ command.inputKey === NodeInputKeyEnum.userInputForms
+ ) {
+ const previousForms =
+ (inputNode.inputs.find((item) => item.key === command.inputKey)?.value as
+ | Array<{ key?: unknown }>
+ | undefined) ?? [];
+ previousFormFieldKeys = previousForms
+ .map((item) => item.key)
+ .filter((key): key is string => typeof key === 'string');
+ }
+ setInputValue({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ inputKey: command.inputKey,
+ value: command.value
+ });
+ syncFormInputOutputs({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ previousFieldKeys: previousFormFieldKeys
+ });
+ const codeIOSync =
+ inputNode.flowNodeType === FlowNodeTypeEnum.code &&
+ command.inputKey === NodeInputKeyEnum.code &&
+ typeof command.value === 'string'
+ ? syncCodeNodeIO({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ code: command.value
+ })
+ : undefined;
+ codeIOSync?.removedOutputs.forEach((output) => {
+ if (output.references.length === 0) return;
+ warnings.push({
+ code: 'WORKFLOW_OUTPUT_REFERENCES_REMAIN',
+ severity: 'warning',
+ nodeId: command.nodeId,
+ params: { outputKey: output.outputKey, references: output.references }
+ });
+ });
+
+ let removedBranchEdgeCount = 0;
+ nextDocument.executionEdges = nextDocument.executionEdges.filter((edge) => {
+ if (edge.source.kind !== 'branch' || edge.source.nodeId !== command.nodeId) return true;
+ try {
+ assertExecutionEdge(nextDocument, edge);
+ return true;
+ } catch {
+ removedBranchEdgeCount += 1;
+ return false;
+ }
+ });
+ if (removedBranchEdgeCount > 0) {
+ warnings.push({
+ code: 'WORKFLOW_BRANCH_EDGES_REMOVED',
+ severity: 'warning',
+ nodeId: command.nodeId,
+ params: { removedEdgeCount: removedBranchEdgeCount }
+ });
+ }
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ inputKey: command.inputKey,
+ ...(removedBranchEdgeCount > 0 || codeIOSync
+ ? {
+ details: {
+ ...(removedBranchEdgeCount > 0 ? { removedBranchEdgeCount } : {}),
+ ...(codeIOSync ? { codeIOSync } : {})
+ }
+ }
+ : {})
+ });
+ }
+
+ if (command.type === 'input.ref') {
+ setInputReference({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ inputKey: command.inputKey,
+ ref: command.ref
+ });
+ changes.push({ type: command.type, nodeId: command.nodeId, inputKey: command.inputKey });
+ }
+
+ if (command.type === 'input.add') {
+ addNodeInput({ document: nextDocument, nodeId: command.nodeId, input: command.input });
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ inputKey: command.input.key
+ });
+ }
+
+ if (command.type === 'input.remove') {
+ removeNodeInput({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ inputKey: command.inputKey
+ });
+ changes.push({ type: command.type, nodeId: command.nodeId, inputKey: command.inputKey });
+ }
+
+ if (command.type === 'node.update') {
+ if (
+ command.name === undefined &&
+ command.position === undefined &&
+ command.catchError === undefined
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_UPDATE_EMPTY', severity: 'error', nodeId: command.nodeId }
+ ]);
+ }
+ updateNode({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ name: command.name,
+ position: command.position,
+ catchError: command.catchError
+ });
+ let removedCatchEdgeCount = 0;
+ if (command.catchError === false) {
+ const before = nextDocument.executionEdges.length;
+ nextDocument.executionEdges = nextDocument.executionEdges.filter(
+ (edge) => !(edge.source.kind === 'catch' && edge.source.nodeId === command.nodeId)
+ );
+ removedCatchEdgeCount = before - nextDocument.executionEdges.length;
+ }
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ ...(removedCatchEdgeCount > 0 ? { details: { removedCatchEdgeCount } } : {})
+ });
+ }
+
+ if (command.type === 'node.move') {
+ let details: Record = {};
+ if (command.parentNodeId !== undefined) {
+ details = moveNodeToParent({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ parentNodeId: command.parentNodeId ?? undefined,
+ position: command.position
+ });
+ } else {
+ updateNode({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ position: command.position
+ });
+ }
+ changes.push({ type: command.type, nodeId: command.nodeId, details });
+ }
+
+ if (command.type === 'node.insert') {
+ if (command.from.kind === 'selectedTools') {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_INSERT_TOOL_EDGE_UNSUPPORTED', severity: 'error' }
+ ]);
+ }
+ const oldEdge: WorkflowExecutionEdge = { source: command.from, target: command.to };
+ disconnectExecutionEdge({ document: nextDocument, edge: oldEdge });
+ const targetNode = getDocumentNode(nextDocument, command.to.nodeId);
+ const added = await addNodeFromTemplate({
+ document: nextDocument,
+ template: command.template,
+ nodeId: command.nodeId,
+ position: command.position,
+ parentNodeId: targetNode.parentNodeId,
+ dependencies
+ });
+ warnings.push(...added.warnings);
+ connectExecutionEdge({
+ document: nextDocument,
+ edge: { source: command.from, target: { kind: 'target', nodeId: command.nodeId } }
+ });
+ connectExecutionEdge({
+ document: nextDocument,
+ edge: {
+ source: getDefaultExecutionSourcePort(nextDocument, command.nodeId),
+ target: command.to
+ }
+ });
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ details: { replacedEdge: oldEdge, nodeIds: added.nodeIds }
+ });
+ }
+
+ if (command.type === 'node.clone') {
+ cloneNode({
+ document: nextDocument,
+ sourceNodeId: command.sourceNodeId,
+ nodeId: command.nodeId,
+ position: command.position,
+ offset: command.offset
+ });
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ details: { sourceNodeId: command.sourceNodeId }
+ });
+ }
+
+ if (command.type === 'node.remove') {
+ const details = removeNode({ document: nextDocument, nodeId: command.nodeId });
+ changes.push({ type: command.type, nodeId: command.nodeId, details });
+ }
+
+ if (command.type === 'edge.connect') {
+ connectExecutionEdge({ document: nextDocument, edge: command.edge });
+ changes.push({ type: command.type, details: { edge: command.edge } });
+ }
+
+ if (command.type === 'edge.disconnect') {
+ disconnectExecutionEdge({ document: nextDocument, edge: command.edge });
+ changes.push({ type: command.type, details: { edge: command.edge } });
+ }
+
+ if (command.type === 'edge.reconnect') {
+ reconnectExecutionEdge({
+ document: nextDocument,
+ oldEdge: command.oldEdge,
+ newEdge: command.newEdge
+ });
+ changes.push({
+ type: command.type,
+ details: { oldEdge: command.oldEdge, newEdge: command.newEdge }
+ });
+ }
+
+ if (command.type === 'input.unset') {
+ unsetInput({ document: nextDocument, nodeId: command.nodeId, inputKey: command.inputKey });
+ changes.push({ type: command.type, nodeId: command.nodeId, inputKey: command.inputKey });
+ }
+
+ if (command.type === 'output.add') {
+ addNodeOutput({ document: nextDocument, nodeId: command.nodeId, output: command.output });
+ changes.push({ type: command.type, nodeId: command.nodeId, key: command.output.key });
+ }
+
+ if (command.type === 'output.remove') {
+ const details = removeNodeOutput({
+ document: nextDocument,
+ nodeId: command.nodeId,
+ outputKey: command.outputKey
+ });
+ changes.push({
+ type: command.type,
+ nodeId: command.nodeId,
+ key: command.outputKey,
+ details
+ });
+ if (details.references.length > 0) {
+ warnings.push({
+ code: 'WORKFLOW_OUTPUT_REFERENCES_REMAIN',
+ severity: 'warning',
+ nodeId: command.nodeId,
+ params: { outputKey: command.outputKey, references: details.references }
+ });
+ }
+ }
+
+ if (command.type === 'tool.attach') {
+ const toolCallNode = getDocumentNode(nextDocument, command.toolCallNodeId);
+ let toolNodeId = command.toolNodeId;
+ if (command.template && command.newNodeId) {
+ const added = await addNodeFromTemplate({
+ document: nextDocument,
+ template: command.template,
+ nodeId: command.newNodeId,
+ position: command.position,
+ parentNodeId: toolCallNode.parentNodeId,
+ dependencies
+ });
+ warnings.push(...added.warnings);
+ toolNodeId = command.newNodeId;
+ }
+ connectExecutionEdge({
+ document: nextDocument,
+ edge: {
+ source: { kind: 'selectedTools', nodeId: command.toolCallNodeId },
+ target: { kind: 'selectedTools', nodeId: toolNodeId! }
+ }
+ });
+ changes.push({
+ type: command.type,
+ nodeId: command.toolCallNodeId,
+ details: { toolNodeId }
+ });
+ }
+
+ if (command.type === 'tool.detach') {
+ disconnectExecutionEdge({
+ document: nextDocument,
+ edge: {
+ source: { kind: 'selectedTools', nodeId: command.toolCallNodeId },
+ target: { kind: 'selectedTools', nodeId: command.toolNodeId }
+ }
+ });
+ changes.push({
+ type: command.type,
+ nodeId: command.toolCallNodeId,
+ details: { toolNodeId: command.toolNodeId }
+ });
+ }
+
+ if (command.type === 'meta.update') {
+ if (command.name === undefined && command.intro === undefined) {
+ throw new WorkflowCommandError([{ code: 'WORKFLOW_META_UPDATE_EMPTY', severity: 'error' }]);
+ }
+ if (command.name !== undefined) nextDocument.app.name = command.name;
+ if (command.intro !== undefined) nextDocument.app.intro = command.intro;
+ changes.push({ type: command.type });
+ }
+
+ if (command.type === 'config.set') {
+ setChatConfigValue({ document: nextDocument, path: command.path, value: command.value });
+ changes.push({ type: command.type, path: command.path });
+ }
+
+ if (command.type === 'config.unset') {
+ unsetChatConfigValue({ document: nextDocument, path: command.path });
+ changes.push({ type: command.type, path: command.path });
+ }
+
+ if (command.type === 'variable.add') {
+ addGlobalVariable({ document: nextDocument, variable: command.variable });
+ changes.push({ type: command.type, key: command.variable.key });
+ }
+
+ if (command.type === 'variable.update') {
+ if (Object.keys(command.patch).length === 0) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_UPDATE_EMPTY', severity: 'error', params: { key: command.key } }
+ ]);
+ }
+ updateGlobalVariable({ document: nextDocument, key: command.key, patch: command.patch });
+ changes.push({ type: command.type, key: command.key });
+ }
+
+ if (command.type === 'variable.remove') {
+ removeGlobalVariable({ document: nextDocument, key: command.key });
+ changes.push({ type: command.type, key: command.key });
+ }
+
+ return {
+ document: nextDocument,
+ changes,
+ warnings,
+ checksum: getWorkflowChecksum(nextDocument)
+ };
+};
diff --git a/packages/workflow-core/src/command/type.ts b/packages/workflow-core/src/command/type.ts
new file mode 100644
index 000000000000..fff307919a52
--- /dev/null
+++ b/packages/workflow-core/src/command/type.ts
@@ -0,0 +1,222 @@
+import z from 'zod';
+import { ExecutionSourcePortRefSchema } from '../edge/type';
+import { VariableRefSchema } from '../reference/type';
+import { NodeTemplateRefSchema } from '../template/type';
+import { WorkflowExecutionEdgeSchema } from '../edge/type';
+import { VariableItemTypeSchema } from '@fastgpt/global/core/app/type';
+import {
+ FlowNodeInputItemTypeSchema,
+ FlowNodeOutputItemTypeSchema
+} from '@fastgpt/global/core/workflow/type/io';
+
+const NodeAddCommandSchema = z.object({
+ type: z.literal('node.add'),
+ nodeId: z.string().min(1),
+ template: NodeTemplateRefSchema,
+ name: z.string().min(1).optional(),
+ position: z.object({ x: z.number(), y: z.number() }).optional(),
+ parentNodeId: z.string().min(1).optional(),
+ connectFrom: ExecutionSourcePortRefSchema.optional(),
+ inputOverrides: z.record(z.string(), z.unknown()).optional()
+});
+
+const InputSetCommandSchema = z.object({
+ type: z.literal('input.set'),
+ nodeId: z.string().min(1),
+ inputKey: z.string().min(1),
+ value: z.any()
+});
+
+const InputRefCommandSchema = z.object({
+ type: z.literal('input.ref'),
+ nodeId: z.string().min(1),
+ inputKey: z.string().min(1),
+ ref: VariableRefSchema
+});
+
+const PositionSchema = z.object({ x: z.number(), y: z.number() });
+
+const NodeUpdateCommandSchema = z.object({
+ type: z.literal('node.update'),
+ nodeId: z.string().min(1),
+ name: z.string().min(1).optional(),
+ position: PositionSchema.optional(),
+ catchError: z.boolean().optional()
+});
+
+const NodeMoveCommandSchema = z
+ .object({
+ type: z.literal('node.move'),
+ nodeId: z.string().min(1),
+ position: PositionSchema.optional(),
+ parentNodeId: z.string().min(1).nullable().optional()
+ })
+ .refine((value) => value.position !== undefined || value.parentNodeId !== undefined);
+
+const NodeInsertCommandSchema = z.object({
+ type: z.literal('node.insert'),
+ nodeId: z.string().min(1),
+ template: NodeTemplateRefSchema,
+ from: ExecutionSourcePortRefSchema,
+ to: z.object({ kind: z.literal('target'), nodeId: z.string().min(1) }),
+ position: PositionSchema.optional()
+});
+
+const NodeCloneCommandSchema = z.object({
+ type: z.literal('node.clone'),
+ sourceNodeId: z.string().min(1),
+ nodeId: z.string().min(1),
+ position: PositionSchema.optional(),
+ offset: PositionSchema.optional()
+});
+
+const NodeRemoveCommandSchema = z.object({
+ type: z.literal('node.remove'),
+ nodeId: z.string().min(1)
+});
+
+const EdgeConnectCommandSchema = z.object({
+ type: z.literal('edge.connect'),
+ edge: WorkflowExecutionEdgeSchema
+});
+
+const EdgeDisconnectCommandSchema = z.object({
+ type: z.literal('edge.disconnect'),
+ edge: WorkflowExecutionEdgeSchema
+});
+
+const EdgeReconnectCommandSchema = z.object({
+ type: z.literal('edge.reconnect'),
+ oldEdge: WorkflowExecutionEdgeSchema,
+ newEdge: WorkflowExecutionEdgeSchema
+});
+
+const InputUnsetCommandSchema = z.object({
+ type: z.literal('input.unset'),
+ nodeId: z.string().min(1),
+ inputKey: z.string().min(1)
+});
+
+const InputAddCommandSchema = z.object({
+ type: z.literal('input.add'),
+ nodeId: z.string().min(1),
+ input: FlowNodeInputItemTypeSchema
+});
+
+const InputRemoveCommandSchema = z.object({
+ type: z.literal('input.remove'),
+ nodeId: z.string().min(1),
+ inputKey: z.string().min(1)
+});
+
+const OutputAddCommandSchema = z.object({
+ type: z.literal('output.add'),
+ nodeId: z.string().min(1),
+ output: FlowNodeOutputItemTypeSchema
+});
+
+const OutputRemoveCommandSchema = z.object({
+ type: z.literal('output.remove'),
+ nodeId: z.string().min(1),
+ outputKey: z.string().min(1)
+});
+
+const ToolAttachCommandSchema = z
+ .object({
+ type: z.literal('tool.attach'),
+ toolCallNodeId: z.string().min(1),
+ toolNodeId: z.string().min(1).optional(),
+ template: NodeTemplateRefSchema.optional(),
+ newNodeId: z.string().min(1).optional(),
+ position: PositionSchema.optional()
+ })
+ .superRefine((value, context) => {
+ const usesExistingNode = value.toolNodeId !== undefined;
+ const createsNode = value.template !== undefined || value.newNodeId !== undefined;
+ if (
+ usesExistingNode === createsNode ||
+ (createsNode && (!value.template || !value.newNodeId))
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Use either toolNodeId or template with newNodeId'
+ });
+ }
+ });
+
+const ToolDetachCommandSchema = z.object({
+ type: z.literal('tool.detach'),
+ toolCallNodeId: z.string().min(1),
+ toolNodeId: z.string().min(1)
+});
+
+const MetaUpdateCommandSchema = z.object({
+ type: z.literal('meta.update'),
+ name: z.string().min(1).optional(),
+ intro: z.string().optional()
+});
+
+const ConfigSetCommandSchema = z.object({
+ type: z.literal('config.set'),
+ path: z.string().min(1),
+ value: z.any()
+});
+
+const ConfigUnsetCommandSchema = z.object({
+ type: z.literal('config.unset'),
+ path: z.string().min(1)
+});
+
+const VariableAddCommandSchema = z.object({
+ type: z.literal('variable.add'),
+ variable: VariableItemTypeSchema
+});
+
+const VariableUpdateCommandSchema = z.object({
+ type: z.literal('variable.update'),
+ key: z.string().min(1),
+ patch: VariableItemTypeSchema.partial()
+});
+
+const VariableRemoveCommandSchema = z.object({
+ type: z.literal('variable.remove'),
+ key: z.string().min(1)
+});
+
+export const WorkflowCommandSchema = z.discriminatedUnion('type', [
+ NodeAddCommandSchema,
+ NodeUpdateCommandSchema,
+ NodeMoveCommandSchema,
+ NodeInsertCommandSchema,
+ NodeCloneCommandSchema,
+ NodeRemoveCommandSchema,
+ EdgeConnectCommandSchema,
+ EdgeDisconnectCommandSchema,
+ EdgeReconnectCommandSchema,
+ InputSetCommandSchema,
+ InputRefCommandSchema,
+ InputUnsetCommandSchema,
+ InputAddCommandSchema,
+ InputRemoveCommandSchema,
+ OutputAddCommandSchema,
+ OutputRemoveCommandSchema,
+ ToolAttachCommandSchema,
+ ToolDetachCommandSchema,
+ MetaUpdateCommandSchema,
+ ConfigSetCommandSchema,
+ ConfigUnsetCommandSchema,
+ VariableAddCommandSchema,
+ VariableUpdateCommandSchema,
+ VariableRemoveCommandSchema
+]);
+
+export type WorkflowCommand = z.infer;
+
+export type WorkflowChangeSummary = {
+ type: WorkflowCommand['type'];
+ nodeId?: string;
+ inputKey?: string;
+ path?: string;
+ key?: string;
+ details?: Record;
+};
diff --git a/packages/workflow-core/src/config/service.ts b/packages/workflow-core/src/config/service.ts
new file mode 100644
index 000000000000..41a0b719382b
--- /dev/null
+++ b/packages/workflow-core/src/config/service.ts
@@ -0,0 +1,275 @@
+import {
+ AppChatConfigTypeSchema,
+ type AppChatConfigType,
+ VariableItemTypeSchema,
+ type VariableItemType
+} from '@fastgpt/global/core/app/type';
+import { NodeOutputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
+import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/workflowStart';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+
+export const CHAT_CONFIG_PATHS = [
+ 'welcomeText',
+ 'autoExecute',
+ 'autoExecute.open',
+ 'autoExecute.defaultPrompt',
+ 'questionGuide',
+ 'questionGuide.open',
+ 'questionGuide.model',
+ 'questionGuide.customPrompt',
+ 'ttsConfig',
+ 'whisperConfig',
+ 'scheduledTriggerConfig',
+ 'chatInputGuide',
+ 'fileSelectConfig',
+ 'instruction'
+] as const;
+
+const assertConfigPath = (path: string) => {
+ if (!(CHAT_CONFIG_PATHS as readonly string[]).includes(path)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_CHAT_CONFIG_PATH_NOT_ALLOWED', severity: 'error', params: { path } }
+ ]);
+ }
+};
+
+const canUploadFiles = (fileSelectConfig: AppChatConfigType['fileSelectConfig']) =>
+ Boolean(
+ fileSelectConfig?.canSelectFile ||
+ fileSelectConfig?.canSelectImg ||
+ fileSelectConfig?.canSelectVideo ||
+ fileSelectConfig?.canSelectAudio ||
+ fileSelectConfig?.canSelectCustomFileExtension
+ );
+
+const referencesOutput = (value: unknown, nodeId: string, outputKey: string): boolean => {
+ if (!Array.isArray(value)) return false;
+ if (value.length === 2 && value[0] === nodeId && value[1] === outputKey) return true;
+ return value.some((item) => referencesOutput(item, nodeId, outputKey));
+};
+
+/** 根据文件选择配置同步 Start 的文件输出,避免上传能力与图引用契约分离。 */
+const syncWorkflowStartFileOutput = ({
+ document,
+ fileSelectConfig
+}: {
+ document: WorkflowDocument;
+ fileSelectConfig: AppChatConfigType['fileSelectConfig'];
+}) => {
+ const startNode = document.nodes.find(
+ (node) => node.flowNodeType === FlowNodeTypeEnum.workflowStart
+ );
+ if (!startNode) return;
+
+ const outputIndex = startNode.outputs.findIndex(
+ (output) => output.key === NodeOutputKeyEnum.userFiles
+ );
+ if (canUploadFiles(fileSelectConfig)) {
+ if (outputIndex < 0) startNode.outputs.push(structuredClone(userFilesInput));
+ return;
+ }
+ if (outputIndex < 0) return;
+
+ const references = document.nodes.flatMap((node) =>
+ node.inputs
+ .filter((input) =>
+ referencesOutput(input.value, startNode.nodeId, NodeOutputKeyEnum.userFiles)
+ )
+ .map((input) => ({ nodeId: node.nodeId, inputKey: input.key }))
+ );
+ if (references.length > 0) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_FILE_OUTPUT_STILL_REFERENCED',
+ severity: 'error',
+ nodeId: startNode.nodeId,
+ params: { references }
+ }
+ ]);
+ }
+ startNode.outputs.splice(outputIndex, 1);
+};
+
+export const getChatConfigValue = (document: WorkflowDocument, path: string) => {
+ assertConfigPath(path);
+ return path.split('.').reduce((value, key) => {
+ if (!value || typeof value !== 'object') return undefined;
+ return (value as Record)[key];
+ }, document.chatConfig);
+};
+
+export const setChatConfigValue = ({
+ document,
+ path,
+ value
+}: {
+ document: WorkflowDocument;
+ path: string;
+ value: unknown;
+}) => {
+ assertConfigPath(path);
+ const keys = path.split('.');
+ const next = structuredClone(document.chatConfig) as Record;
+ let target = next;
+ for (const key of keys.slice(0, -1)) {
+ const current = target[key];
+ target[key] = current && typeof current === 'object' ? current : {};
+ target = target[key] as Record;
+ }
+ target[keys.at(-1)!] = structuredClone(value);
+ const nextChatConfig = AppChatConfigTypeSchema.parse(next);
+ if (path === 'fileSelectConfig') {
+ syncWorkflowStartFileOutput({
+ document,
+ fileSelectConfig: nextChatConfig.fileSelectConfig
+ });
+ }
+ document.chatConfig = nextChatConfig;
+};
+
+export const unsetChatConfigValue = ({
+ document,
+ path
+}: {
+ document: WorkflowDocument;
+ path: string;
+}) => {
+ assertConfigPath(path);
+ const keys = path.split('.');
+ const next = structuredClone(document.chatConfig) as Record;
+ let target: Record | undefined = next;
+ for (const key of keys.slice(0, -1)) {
+ const current: unknown = target?.[key];
+ target =
+ current && typeof current === 'object' ? (current as Record) : undefined;
+ }
+ if (target) delete target[keys.at(-1)!];
+ const nextChatConfig = AppChatConfigTypeSchema.parse(next);
+ if (path === 'fileSelectConfig') {
+ syncWorkflowStartFileOutput({
+ document,
+ fileSelectConfig: nextChatConfig.fileSelectConfig
+ });
+ }
+ document.chatConfig = nextChatConfig;
+};
+
+const getVariables = (document: WorkflowDocument) => document.chatConfig.variables ?? [];
+
+export const addGlobalVariable = ({
+ document,
+ variable
+}: {
+ document: WorkflowDocument;
+ variable: VariableItemType;
+}) => {
+ if (getVariables(document).some((item) => item.key === variable.key)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_KEY_DUPLICATED', severity: 'error', params: { key: variable.key } }
+ ]);
+ }
+ document.chatConfig.variables = [
+ ...getVariables(document),
+ VariableItemTypeSchema.parse(variable)
+ ];
+};
+
+export const updateGlobalVariable = ({
+ document,
+ key,
+ patch
+}: {
+ document: WorkflowDocument;
+ key: string;
+ patch: Partial;
+}) => {
+ const variables = getVariables(document);
+ const index = variables.findIndex((item) => item.key === key);
+ if (index < 0) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_NOT_FOUND', severity: 'error', params: { key } }
+ ]);
+ }
+ const nextKey = patch.key ?? key;
+ if (nextKey !== key && variables.some((item) => item.key === nextKey)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_KEY_DUPLICATED', severity: 'error', params: { key: nextKey } }
+ ]);
+ }
+ const nextValueType = patch.valueType ?? variables[index].valueType;
+ const references = document.nodes.flatMap((node) =>
+ node.inputs
+ .filter(
+ (input) =>
+ Array.isArray(input.value) &&
+ input.value[0] === VARIABLE_NODE_ID &&
+ input.value[1] === key
+ )
+ .map((input) => ({ node, input }))
+ );
+ const incompatibleReference = references.find(
+ ({ input }) =>
+ input.valueType !== undefined &&
+ input.valueType !== WorkflowIOValueTypeEnum.any &&
+ nextValueType !== undefined &&
+ nextValueType !== WorkflowIOValueTypeEnum.any &&
+ input.valueType !== nextValueType
+ );
+ if (incompatibleReference) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_VARIABLE_TYPE_CHANGE_INCOMPATIBLE',
+ severity: 'error',
+ nodeId: incompatibleReference.node.nodeId,
+ inputKey: incompatibleReference.input.key,
+ params: { key, valueType: nextValueType }
+ }
+ ]);
+ }
+ const next = [...variables];
+ next[index] = VariableItemTypeSchema.parse({ ...variables[index], ...structuredClone(patch) });
+ document.chatConfig.variables = next;
+ if (nextKey !== key) {
+ for (const { input } of references) {
+ input.value = [VARIABLE_NODE_ID, nextKey];
+ }
+ }
+};
+
+export const removeGlobalVariable = ({
+ document,
+ key
+}: {
+ document: WorkflowDocument;
+ key: string;
+}) => {
+ const variables = getVariables(document);
+ if (!variables.some((item) => item.key === key)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_NOT_FOUND', severity: 'error', params: { key } }
+ ]);
+ }
+ const references = document.nodes.flatMap((node) =>
+ node.inputs
+ .filter(
+ (input) =>
+ Array.isArray(input.value) &&
+ input.value[0] === VARIABLE_NODE_ID &&
+ input.value[1] === key
+ )
+ .map((input) => ({ nodeId: node.nodeId, inputKey: input.key }))
+ );
+ if (references.length > 0) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_VARIABLE_STILL_REFERENCED',
+ severity: 'error',
+ params: { key, references }
+ }
+ ]);
+ }
+ document.chatConfig.variables = variables.filter((item) => item.key !== key);
+};
diff --git a/packages/workflow-core/src/domain/checksum.ts b/packages/workflow-core/src/domain/checksum.ts
new file mode 100644
index 000000000000..11428fa1c59b
--- /dev/null
+++ b/packages/workflow-core/src/domain/checksum.ts
@@ -0,0 +1,42 @@
+import type { WorkflowDocument } from './document';
+
+const sortRecord = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(sortRecord);
+ if (!value || typeof value !== 'object') return value;
+
+ return Object.fromEntries(
+ Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, sortRecord(item)])
+ );
+};
+
+/** 返回字段和集合顺序稳定的文档副本,供序列化、比较和基础 checksum 使用。 */
+export const normalizeWorkflowDocument = (document: WorkflowDocument): WorkflowDocument => ({
+ ...structuredClone(document),
+ app: sortRecord(document.app) as WorkflowDocument['app'],
+ nodes: [...document.nodes]
+ .sort((left, right) => left.nodeId.localeCompare(right.nodeId))
+ .map((node) => sortRecord(node) as WorkflowDocument['nodes'][number]),
+ executionEdges: [...document.executionEdges]
+ .sort((left, right) => {
+ const leftKey = JSON.stringify(left);
+ const rightKey = JSON.stringify(right);
+ return leftKey.localeCompare(rightKey);
+ })
+ .map((edge) => sortRecord(edge) as WorkflowDocument['executionEdges'][number]),
+ chatConfig: sortRecord(document.chatConfig) as WorkflowDocument['chatConfig']
+});
+
+const fnv1a = (value: string) => {
+ let hash = 0x811c9dc5;
+ for (const character of value) {
+ hash ^= character.charCodeAt(0);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0');
+};
+
+/** PR1 的稳定变更标识;PR4 再升级为带并发语义的 canonical SHA-256。 */
+export const getWorkflowChecksum = (document: WorkflowDocument) =>
+ `fnv1a:${fnv1a(JSON.stringify(normalizeWorkflowDocument(document)))}`;
diff --git a/packages/workflow-core/src/domain/diagnostic.ts b/packages/workflow-core/src/domain/diagnostic.ts
new file mode 100644
index 000000000000..106b03c4974c
--- /dev/null
+++ b/packages/workflow-core/src/domain/diagnostic.ts
@@ -0,0 +1,32 @@
+import z from 'zod';
+
+export const WorkflowDiagnosticSeveritySchema = z.enum(['error', 'warning']);
+
+export const WorkflowDiagnosticSchema = z.object({
+ code: z.string(),
+ severity: WorkflowDiagnosticSeveritySchema,
+ path: z.array(z.union([z.string(), z.number()])).optional(),
+ nodeId: z.string().optional(),
+ inputKey: z.string().optional(),
+ params: z.record(z.string(), z.unknown()).optional()
+});
+
+export type WorkflowDiagnostic = z.infer;
+
+export class WorkflowCommandError extends Error {
+ readonly code = 'WORKFLOW_COMMAND_FAILED';
+
+ constructor(readonly diagnostics: WorkflowDiagnostic[]) {
+ super(diagnostics[0]?.code ?? 'WORKFLOW_COMMAND_FAILED');
+ this.name = 'WorkflowCommandError';
+ }
+}
+
+export class WorkflowValidationError extends Error {
+ readonly code = 'WORKFLOW_VALIDATION_FAILED';
+
+ constructor(readonly diagnostics: WorkflowDiagnostic[]) {
+ super(diagnostics[0]?.code ?? 'WORKFLOW_VALIDATION_FAILED');
+ this.name = 'WorkflowValidationError';
+ }
+}
diff --git a/packages/workflow-core/src/domain/document.ts b/packages/workflow-core/src/domain/document.ts
new file mode 100644
index 000000000000..cd7a4c4f5531
--- /dev/null
+++ b/packages/workflow-core/src/domain/document.ts
@@ -0,0 +1,33 @@
+import { AppChatConfigTypeSchema } from '@fastgpt/global/core/app/type';
+import { StoreNodeItemTypeSchema } from '@fastgpt/global/core/workflow/type/node';
+import z from 'zod';
+import { WorkflowExecutionEdgeSchema } from '../edge/type';
+
+export const WORKFLOW_DOCUMENT_SCHEMA_VERSION = 'fastgpt-workflow/v1' as const;
+
+export const WorkflowDocumentSchema = z.object({
+ schemaVersion: z.literal(WORKFLOW_DOCUMENT_SCHEMA_VERSION),
+ app: z.object({
+ appId: z.string().optional(),
+ name: z.string().optional(),
+ intro: z.string().optional(),
+ appType: z.string().optional(),
+ baseVersionId: z.string().optional()
+ }),
+ nodes: z.array(StoreNodeItemTypeSchema),
+ executionEdges: z.array(WorkflowExecutionEdgeSchema),
+ chatConfig: AppChatConfigTypeSchema
+});
+
+export type WorkflowDocument = z.infer;
+
+export const createWorkflowDocument = (
+ input: Partial> = {}
+): WorkflowDocument =>
+ WorkflowDocumentSchema.parse({
+ schemaVersion: WORKFLOW_DOCUMENT_SCHEMA_VERSION,
+ app: input.app ?? {},
+ nodes: input.nodes ?? [],
+ executionEdges: input.executionEdges ?? [],
+ chatConfig: input.chatConfig ?? {}
+ });
diff --git a/packages/workflow-core/src/edge/compiler.ts b/packages/workflow-core/src/edge/compiler.ts
new file mode 100644
index 000000000000..4a9f79cd82d6
--- /dev/null
+++ b/packages/workflow-core/src/edge/compiler.ts
@@ -0,0 +1,165 @@
+import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import {
+ FlowNodeOutputTypeEnum,
+ FlowNodeTypeEnum
+} from '@fastgpt/global/core/workflow/node/constant';
+import { getHandleId } from '@fastgpt/global/core/workflow/utils';
+import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import type { ExecutionSourcePortRef, ExecutionTargetPortRef, WorkflowExecutionEdge } from './type';
+
+const assertNode = (document: WorkflowDocument, nodeId: string) => {
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_NOT_FOUND', severity: 'error', nodeId }
+ ]);
+ }
+ return node;
+};
+
+const compileSourceHandle = (port: ExecutionSourcePortRef, document: WorkflowDocument): string => {
+ const node = assertNode(document, port.nodeId);
+ if (port.kind === 'next') return getHandleId(port.nodeId, 'source', 'right');
+ if (port.kind === 'catch') return getHandleId(port.nodeId, 'source_catch', 'right');
+ if (port.kind === 'selectedTools') return NodeOutputKeyEnum.selectedTools;
+ if (port.kind === 'branch') {
+ if (
+ ![
+ FlowNodeTypeEnum.ifElseNode,
+ FlowNodeTypeEnum.userSelect,
+ FlowNodeTypeEnum.classifyQuestion
+ ].includes(node.flowNodeType)
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_BRANCH_PORT_UNSUPPORTED',
+ severity: 'error',
+ nodeId: port.nodeId,
+ params: { branchKey: port.branchKey }
+ }
+ ]);
+ }
+ return getHandleId(port.nodeId, 'source', port.branchKey);
+ }
+
+ const output = node.outputs.find((item) => item.key === port.outputKey);
+ if (!output || output.type !== FlowNodeOutputTypeEnum.source) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_SOURCE_OUTPUT_NOT_FOUND',
+ severity: 'error',
+ nodeId: port.nodeId,
+ params: { outputKey: port.outputKey }
+ }
+ ]);
+ }
+ return getHandleId(port.nodeId, 'source', port.outputKey);
+};
+
+const compileTargetHandle = (port: ExecutionTargetPortRef, document: WorkflowDocument): string => {
+ assertNode(document, port.nodeId);
+ return port.kind === 'selectedTools'
+ ? NodeOutputKeyEnum.selectedTools
+ : getHandleId(port.nodeId, 'target', 'left');
+};
+
+/** 将稳定语义端口编译为 FastGPT 当前 StoreEdge handle。 */
+export const compileExecutionEdge = (
+ edge: WorkflowExecutionEdge,
+ document: WorkflowDocument
+): StoreEdgeItemType => ({
+ source: edge.source.nodeId,
+ sourceHandle: compileSourceHandle(edge.source, document),
+ target: edge.target.nodeId,
+ targetHandle: compileTargetHandle(edge.target, document)
+});
+
+const parseSourceHandle = ({
+ edge,
+ document
+}: {
+ edge: StoreEdgeItemType;
+ document: WorkflowDocument;
+}): ExecutionSourcePortRef => {
+ if (edge.sourceHandle === NodeOutputKeyEnum.selectedTools) {
+ return { kind: 'selectedTools', nodeId: edge.source };
+ }
+ if (edge.sourceHandle === getHandleId(edge.source, 'source_catch', 'right')) {
+ return { kind: 'catch', nodeId: edge.source };
+ }
+ if (edge.sourceHandle === getHandleId(edge.source, 'source', 'right')) {
+ return { kind: 'next', nodeId: edge.source };
+ }
+
+ const prefix = getHandleId(edge.source, 'source', '');
+ if (!edge.sourceHandle.startsWith(prefix)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_EDGE_HANDLE_UNSUPPORTED',
+ severity: 'error',
+ params: { edge }
+ }
+ ]);
+ }
+
+ const key = edge.sourceHandle.slice(prefix.length);
+ const sourceNode = assertNode(document, edge.source);
+ const sourceOutput = sourceNode.outputs.find(
+ (output) => output.key === key && output.type === FlowNodeOutputTypeEnum.source
+ );
+ if (sourceOutput) return { kind: 'sourceOutput', nodeId: edge.source, outputKey: key };
+ if (
+ [
+ FlowNodeTypeEnum.ifElseNode,
+ FlowNodeTypeEnum.userSelect,
+ FlowNodeTypeEnum.classifyQuestion
+ ].includes(sourceNode.flowNodeType)
+ ) {
+ return { kind: 'branch', nodeId: edge.source, branchKey: key };
+ }
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_EDGE_HANDLE_UNSUPPORTED',
+ severity: 'error',
+ params: { edge }
+ }
+ ]);
+};
+
+const parseTargetHandle = (edge: StoreEdgeItemType): ExecutionTargetPortRef => {
+ if (edge.targetHandle === NodeOutputKeyEnum.selectedTools) {
+ return { kind: 'selectedTools', nodeId: edge.target };
+ }
+ if (edge.targetHandle === getHandleId(edge.target, 'target', 'left')) {
+ return { kind: 'target', nodeId: edge.target };
+ }
+
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_EDGE_HANDLE_UNSUPPORTED',
+ severity: 'error',
+ params: { edge }
+ }
+ ]);
+};
+
+/** 反编译 StoreEdge;未知 handle 会阻断,避免导入时静默丢边。 */
+export const decompileStoreEdge = (
+ edge: StoreEdgeItemType,
+ document: WorkflowDocument
+): WorkflowExecutionEdge => {
+ assertNode(document, edge.source);
+ assertNode(document, edge.target);
+ const result = {
+ source: parseSourceHandle({ edge, document }),
+ target: parseTargetHandle(edge)
+ };
+ if ((result.source.kind === 'selectedTools') !== (result.target.kind === 'selectedTools')) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_TOOL_EDGE_INVALID', severity: 'error', params: { edge } }
+ ]);
+ }
+ return result;
+};
diff --git a/packages/workflow-core/src/edge/parser.ts b/packages/workflow-core/src/edge/parser.ts
new file mode 100644
index 000000000000..af94971a17b2
--- /dev/null
+++ b/packages/workflow-core/src/edge/parser.ts
@@ -0,0 +1,51 @@
+import { WorkflowCommandError } from '../domain/diagnostic';
+import type { ExecutionSourcePortRef, ExecutionTargetPortRef } from './type';
+
+/** 解析 `node@port` 形式的执行源端口。 */
+export const parseExecutionSourcePortRef = (value: string): ExecutionSourcePortRef => {
+ const separatorIndex = value.lastIndexOf('@');
+ const nodeId = value.slice(0, separatorIndex);
+ const port = value.slice(separatorIndex + 1);
+
+ if (separatorIndex <= 0 || !port) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_SOURCE_PORT_INVALID', severity: 'error', params: { value } }
+ ]);
+ }
+
+ if (port === 'next') return { kind: 'next', nodeId };
+ if (port === 'catch') return { kind: 'catch', nodeId };
+ if (port === 'tools') return { kind: 'selectedTools', nodeId };
+ if (port.startsWith('branch:')) {
+ const branchKey = port.slice('branch:'.length);
+ if (branchKey) return { kind: 'branch', nodeId, branchKey };
+ }
+ if (port.startsWith('output:')) {
+ const outputKey = port.slice('output:'.length);
+ if (outputKey) return { kind: 'sourceOutput', nodeId, outputKey };
+ }
+
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_SOURCE_PORT_UNSUPPORTED', severity: 'error', params: { value } }
+ ]);
+};
+
+/** 解析 `node@target` 或 `node@tools` 形式的执行目标端口。 */
+export const parseExecutionTargetPortRef = (value: string): ExecutionTargetPortRef => {
+ const separatorIndex = value.lastIndexOf('@');
+ const nodeId = value.slice(0, separatorIndex);
+ const port = value.slice(separatorIndex + 1);
+
+ if (separatorIndex <= 0 || !port) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_TARGET_PORT_INVALID', severity: 'error', params: { value } }
+ ]);
+ }
+
+ if (port === 'target') return { kind: 'target', nodeId };
+ if (port === 'tools') return { kind: 'selectedTools', nodeId };
+
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_TARGET_PORT_UNSUPPORTED', severity: 'error', params: { value } }
+ ]);
+};
diff --git a/packages/workflow-core/src/edge/service.ts b/packages/workflow-core/src/edge/service.ts
new file mode 100644
index 000000000000..60a7f0ec5fd4
--- /dev/null
+++ b/packages/workflow-core/src/edge/service.ts
@@ -0,0 +1,214 @@
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import { compileExecutionEdge } from './compiler';
+import type { WorkflowExecutionEdge } from './type';
+import type { ExecutionSourcePortRef } from './type';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
+import { getIfElseBranchHandleKey } from '@fastgpt/global/core/workflow/template/system/ifElse/utils';
+import type { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
+import { getDocumentNode } from '../nesting/service';
+
+const edgeEquals = (left: WorkflowExecutionEdge, right: WorkflowExecutionEdge) =>
+ JSON.stringify(left) === JSON.stringify(right);
+
+const toolTargetTypes = new Set([
+ FlowNodeTypeEnum.tool,
+ FlowNodeTypeEnum.toolSet,
+ FlowNodeTypeEnum.pluginModule,
+ FlowNodeTypeEnum.appModule,
+ FlowNodeTypeEnum.runApp,
+ FlowNodeTypeEnum.chatNode,
+ FlowNodeTypeEnum.answerNode,
+ FlowNodeTypeEnum.datasetSearchNode,
+ FlowNodeTypeEnum.contentExtract,
+ FlowNodeTypeEnum.httpRequest468,
+ FlowNodeTypeEnum.toolParams,
+ FlowNodeTypeEnum.userSelect,
+ FlowNodeTypeEnum.formInput,
+ FlowNodeTypeEnum.variableUpdate
+]);
+
+const nodesWithoutNextPort = new Set([
+ FlowNodeTypeEnum.ifElseNode,
+ FlowNodeTypeEnum.userSelect,
+ FlowNodeTypeEnum.classifyQuestion,
+ FlowNodeTypeEnum.answerNode,
+ FlowNodeTypeEnum.loopRunBreak,
+ FlowNodeTypeEnum.nestedEnd,
+ FlowNodeTypeEnum.pluginOutput
+]);
+
+/** 返回节点在 insert 场景下用于承接旧 target 的默认执行出口。 */
+export const getDefaultExecutionSourcePort = (
+ document: WorkflowDocument,
+ nodeId: string
+): ExecutionSourcePortRef => {
+ const node = getDocumentNode(document, nodeId);
+ if (node.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
+ return { kind: 'branch', nodeId, branchKey: IfElseResultEnum.ELSE };
+ }
+ if (
+ node.flowNodeType === FlowNodeTypeEnum.userSelect ||
+ node.flowNodeType === FlowNodeTypeEnum.classifyQuestion
+ ) {
+ const inputKey =
+ node.flowNodeType === FlowNodeTypeEnum.userSelect
+ ? NodeInputKeyEnum.userSelectOptions
+ : NodeInputKeyEnum.agents;
+ const options = node.inputs.find((item) => item.key === inputKey)?.value;
+ const branchKey =
+ Array.isArray(options) && options[0] && typeof options[0].key === 'string'
+ ? options[0].key
+ : undefined;
+ if (branchKey) return { kind: 'branch', nodeId, branchKey };
+ }
+ if (nodesWithoutNextPort.has(node.flowNodeType)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_HAS_NO_DEFAULT_SOURCE_PORT', severity: 'error', nodeId }
+ ]);
+ }
+ return { kind: 'next', nodeId };
+};
+
+const assertBranchKey = (document: WorkflowDocument, edge: WorkflowExecutionEdge) => {
+ if (edge.source.kind !== 'branch') return;
+ const node = getDocumentNode(document, edge.source.nodeId);
+ const inputKey =
+ node.flowNodeType === FlowNodeTypeEnum.ifElseNode
+ ? NodeInputKeyEnum.ifElseList
+ : node.flowNodeType === FlowNodeTypeEnum.userSelect
+ ? NodeInputKeyEnum.userSelectOptions
+ : NodeInputKeyEnum.agents;
+ const value = node.inputs.find((item) => item.key === inputKey)?.value;
+ const keys = (() => {
+ if (!Array.isArray(value)) return [];
+ if (node.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
+ return [
+ ...value.map((item, index) => getIfElseBranchHandleKey(item as IfElseListItemType, index)),
+ IfElseResultEnum.ELSE
+ ];
+ }
+ return value
+ .map((item) =>
+ item && typeof item === 'object' ? (item as { key?: unknown }).key : undefined
+ )
+ .filter((item): item is string => typeof item === 'string');
+ })();
+ if (!keys.includes(edge.source.branchKey)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_BRANCH_KEY_NOT_FOUND',
+ severity: 'error',
+ nodeId: edge.source.nodeId,
+ params: { branchKey: edge.source.branchKey }
+ }
+ ]);
+ }
+};
+
+/** 校验复杂执行边的端口配对、作用域和节点能力。 */
+export const assertExecutionEdge = (document: WorkflowDocument, edge: WorkflowExecutionEdge) => {
+ const sourceNode = getDocumentNode(document, edge.source.nodeId);
+ const targetNode = getDocumentNode(document, edge.target.nodeId);
+ if (sourceNode.nodeId === targetNode.nodeId) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_SELF_CONNECTION', severity: 'error', nodeId: sourceNode.nodeId }
+ ]);
+ }
+ if (sourceNode.parentNodeId !== targetNode.parentNodeId) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_CROSS_SCOPE', severity: 'error', params: { edge } }
+ ]);
+ }
+ const sourceIsTool = edge.source.kind === 'selectedTools';
+ const targetIsTool = edge.target.kind === 'selectedTools';
+ if (sourceIsTool !== targetIsTool) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_TOOL_EDGE_INVALID', severity: 'error', params: { edge } }
+ ]);
+ }
+ if (sourceIsTool) {
+ if (
+ sourceNode.flowNodeType !== FlowNodeTypeEnum.toolCall ||
+ !toolTargetTypes.has(targetNode.flowNodeType)
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_TOOL_EDGE_NODE_INVALID', severity: 'error', params: { edge } }
+ ]);
+ }
+ } else if (edge.target.kind !== 'target') {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_TARGET_KIND_INVALID', severity: 'error', params: { edge } }
+ ]);
+ }
+ if (edge.source.kind === 'catch' && sourceNode.catchError !== true) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_CATCH_NOT_ENABLED', severity: 'error', nodeId: sourceNode.nodeId }
+ ]);
+ }
+ if (edge.source.kind === 'next' && nodesWithoutNextPort.has(sourceNode.flowNodeType)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_NEXT_PORT_UNSUPPORTED',
+ severity: 'error',
+ nodeId: sourceNode.nodeId
+ }
+ ]);
+ }
+ assertBranchKey(document, edge);
+ compileExecutionEdge(edge, document);
+};
+
+export const connectExecutionEdge = ({
+ document,
+ edge
+}: {
+ document: WorkflowDocument;
+ edge: WorkflowExecutionEdge;
+}) => {
+ assertExecutionEdge(document, edge);
+ if (document.executionEdges.some((item) => edgeEquals(item, edge))) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_DUPLICATED', severity: 'error', params: { edge } }
+ ]);
+ }
+ document.executionEdges.push(edge);
+};
+
+export const disconnectExecutionEdge = ({
+ document,
+ edge
+}: {
+ document: WorkflowDocument;
+ edge: WorkflowExecutionEdge;
+}) => {
+ const index = document.executionEdges.findIndex((item) => edgeEquals(item, edge));
+ if (index < 0) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_NOT_FOUND', severity: 'error', params: { edge } }
+ ]);
+ }
+ document.executionEdges.splice(index, 1);
+};
+
+/** 先校验新边,再移除旧边,保证重连不会留下半成品。 */
+export const reconnectExecutionEdge = ({
+ document,
+ oldEdge,
+ newEdge
+}: {
+ document: WorkflowDocument;
+ oldEdge: WorkflowExecutionEdge;
+ newEdge: WorkflowExecutionEdge;
+}) => {
+ assertExecutionEdge(document, newEdge);
+ if (document.executionEdges.some((item) => edgeEquals(item, newEdge))) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_EDGE_DUPLICATED', severity: 'error', params: { edge: newEdge } }
+ ]);
+ }
+ disconnectExecutionEdge({ document, edge: oldEdge });
+ document.executionEdges.push(newEdge);
+};
diff --git a/packages/workflow-core/src/edge/type.ts b/packages/workflow-core/src/edge/type.ts
new file mode 100644
index 000000000000..b72234ba1cff
--- /dev/null
+++ b/packages/workflow-core/src/edge/type.ts
@@ -0,0 +1,27 @@
+import z from 'zod';
+
+export const ExecutionSourcePortRefSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('next'), nodeId: z.string().min(1) }),
+ z.object({ kind: z.literal('branch'), nodeId: z.string().min(1), branchKey: z.string().min(1) }),
+ z.object({
+ kind: z.literal('sourceOutput'),
+ nodeId: z.string().min(1),
+ outputKey: z.string().min(1)
+ }),
+ z.object({ kind: z.literal('catch'), nodeId: z.string().min(1) }),
+ z.object({ kind: z.literal('selectedTools'), nodeId: z.string().min(1) })
+]);
+
+export const ExecutionTargetPortRefSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('target'), nodeId: z.string().min(1) }),
+ z.object({ kind: z.literal('selectedTools'), nodeId: z.string().min(1) })
+]);
+
+export const WorkflowExecutionEdgeSchema = z.object({
+ source: ExecutionSourcePortRefSchema,
+ target: ExecutionTargetPortRefSchema
+});
+
+export type ExecutionSourcePortRef = z.infer;
+export type ExecutionTargetPortRef = z.infer;
+export type WorkflowExecutionEdge = z.infer;
diff --git a/packages/workflow-core/src/index.ts b/packages/workflow-core/src/index.ts
new file mode 100644
index 000000000000..b0fa25dc550c
--- /dev/null
+++ b/packages/workflow-core/src/index.ts
@@ -0,0 +1,30 @@
+export * from './command/apply';
+export * from './command/type';
+export * from './code/io';
+export * from './binding/service';
+export * from './binding/type';
+export * from './domain/checksum';
+export * from './domain/diagnostic';
+export * from './domain/document';
+export * from './edge/compiler';
+export * from './edge/parser';
+export * from './edge/service';
+export * from './edge/type';
+export * from './reference/service';
+export * from './reference/type';
+export * from './node/service';
+export * from './node/add';
+export * from './io/service';
+export * from './nesting/service';
+export * from './config/service';
+export * from './store/compile';
+export * from './store/decompile';
+export * from './template/automationMeta';
+export * from './template/builtin';
+export * from './template/descriptor';
+export * from './template/defaultValue';
+export * from './template/instantiate';
+export * from './template/type';
+export * from './template/valueSchema';
+export * from './validation';
+export * from './public';
diff --git a/packages/workflow-core/src/io/service.ts b/packages/workflow-core/src/io/service.ts
new file mode 100644
index 000000000000..8de7be0f9fe4
--- /dev/null
+++ b/packages/workflow-core/src/io/service.ts
@@ -0,0 +1,348 @@
+import {
+ NodeInputKeyEnum,
+ NodeOutputKeyEnum,
+ WorkflowIOValueTypeEnum
+} from '@fastgpt/global/core/workflow/constants';
+import {
+ FlowNodeInputTypeEnum,
+ FlowNodeOutputTypeEnum,
+ FlowNodeTypeEnum
+} from '@fastgpt/global/core/workflow/node/constant';
+import {
+ FlowNodeInputItemTypeSchema,
+ type FlowNodeInputItemType,
+ FlowNodeOutputItemTypeSchema,
+ type FlowNodeOutputItemType
+} from '@fastgpt/global/core/workflow/type/io';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import { getDocumentNode } from '../nesting/service';
+import { extractCodeInputDefinitions, extractCodeOutputDefinitions } from '../code/io';
+
+const dynamicOutputNodeTypes = new Set([
+ FlowNodeTypeEnum.code,
+ FlowNodeTypeEnum.contentExtract,
+ FlowNodeTypeEnum.httpRequest468,
+ FlowNodeTypeEnum.loopRun
+]);
+
+const supportsDynamicOutputs = (node: ReturnType) =>
+ dynamicOutputNodeTypes.has(node.flowNodeType) ||
+ node.outputs.some((item) => item.key === NodeOutputKeyEnum.addOutputParam);
+
+const dynamicInputMarkerKeys = new Set([
+ NodeInputKeyEnum.addInputParam,
+ NodeInputKeyEnum.datasetQuoteList
+]);
+
+const supportsDynamicInputs = (node: ReturnType) =>
+ node.inputs.some((item) => dynamicInputMarkerKeys.has(item.key));
+
+/** 向带动态输入标记的节点添加一个可编辑输入。 */
+export const addNodeInput = ({
+ document,
+ nodeId,
+ input
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ input: FlowNodeInputItemType;
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ if (!supportsDynamicInputs(node)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_DYNAMIC_INPUT_UNSUPPORTED', severity: 'error', nodeId }
+ ]);
+ }
+ if (node.inputs.some((item) => item.key === input.key)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_INPUT_KEY_DUPLICATED',
+ severity: 'error',
+ nodeId,
+ inputKey: input.key
+ }
+ ]);
+ }
+ const parsedInput = FlowNodeInputItemTypeSchema.parse({
+ ...input,
+ label: input.label || input.key,
+ valueType: input.valueType ?? WorkflowIOValueTypeEnum.any,
+ renderTypeList:
+ input.renderTypeList.length > 0 ? input.renderTypeList : [FlowNodeInputTypeEnum.input],
+ canEdit: true
+ });
+ node.inputs.push(parsedInput);
+};
+
+/** 删除动态输入;输入携带的引用会随输入本身一并删除。 */
+export const removeNodeInput = ({
+ document,
+ nodeId,
+ inputKey
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ const inputIndex = node.inputs.findIndex((item) => item.key === inputKey);
+ if (inputIndex < 0) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_INPUT_NOT_FOUND', severity: 'error', nodeId, inputKey }
+ ]);
+ }
+ if (
+ !supportsDynamicInputs(node) ||
+ dynamicInputMarkerKeys.has(inputKey) ||
+ node.inputs[inputIndex].canEdit !== true
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_INPUT_REMOVE_FORBIDDEN', severity: 'error', nodeId, inputKey }
+ ]);
+ }
+ node.inputs.splice(inputIndex, 1);
+};
+
+/** 向支持动态输出的节点添加一个数据输出。 */
+export const addNodeOutput = ({
+ document,
+ nodeId,
+ output
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ output: FlowNodeOutputItemType;
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ if (!supportsDynamicOutputs(node)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_DYNAMIC_OUTPUT_UNSUPPORTED', severity: 'error', nodeId }
+ ]);
+ }
+ if (node.outputs.some((item) => item.key === output.key)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_OUTPUT_KEY_DUPLICATED',
+ severity: 'error',
+ nodeId,
+ params: { outputKey: output.key }
+ }
+ ]);
+ }
+ const parsedOutput = FlowNodeOutputItemTypeSchema.parse({
+ ...output,
+ id: output.id || output.key,
+ label: output.label || output.key,
+ type: output.type ?? FlowNodeOutputTypeEnum.dynamic,
+ valueType: output.valueType ?? WorkflowIOValueTypeEnum.any
+ });
+ const markerIndex = node.outputs.findIndex(
+ (item) => item.key === NodeOutputKeyEnum.addOutputParam
+ );
+ if (markerIndex >= 0) node.outputs.splice(markerIndex, 0, parsedOutput);
+ else node.outputs.push(parsedOutput);
+};
+
+const isReferenceToOutput = (value: unknown, nodeId: string, outputKey: string): boolean => {
+ if (!Array.isArray(value)) return false;
+ if (value.length === 2 && value[0] === nodeId && value[1] === outputKey) return true;
+ return value.some((item) => isReferenceToOutput(item, nodeId, outputKey));
+};
+
+/** 删除动态输出并清理其 source execution edge;数据引用保留并在结果中报告。 */
+export const removeNodeOutput = ({
+ document,
+ nodeId,
+ outputKey
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ outputKey: string;
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ const outputIndex = node.outputs.findIndex((item) => item.key === outputKey);
+ if (outputIndex < 0) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_OUTPUT_NOT_FOUND', severity: 'error', nodeId, params: { outputKey } }
+ ]);
+ }
+ const output = node.outputs[outputIndex];
+ if (
+ output.type !== FlowNodeOutputTypeEnum.dynamic &&
+ output.type !== FlowNodeOutputTypeEnum.source
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_OUTPUT_REMOVE_FORBIDDEN', severity: 'error', nodeId, params: { outputKey } }
+ ]);
+ }
+ node.outputs.splice(outputIndex, 1);
+ const before = document.executionEdges.length;
+ document.executionEdges = document.executionEdges.filter(
+ (edge) =>
+ !(
+ edge.source.kind === 'sourceOutput' &&
+ edge.source.nodeId === nodeId &&
+ edge.source.outputKey === outputKey
+ )
+ );
+ const references = document.nodes.flatMap((item) =>
+ item.inputs
+ .filter((input) => isReferenceToOutput(input.value, nodeId, outputKey))
+ .map((input) => ({ nodeId: item.nodeId, inputKey: input.key }))
+ );
+ return { removedEdgeCount: before - document.executionEdges.length, references };
+};
+
+const codeSystemOutputKeys = new Set([
+ NodeOutputKeyEnum.addOutputParam,
+ NodeOutputKeyEnum.rawResponse,
+ NodeOutputKeyEnum.error
+]);
+
+/**
+ * 以 main 参数和 return 对象为代码节点动态 IO 的事实源。
+ * 无法静态识别参数或返回对象时保留原配置,避免编辑中的不完整代码造成破坏性删除。
+ */
+export const syncCodeNodeIO = ({
+ document,
+ nodeId,
+ code
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ code: string;
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ if (node.flowNodeType !== FlowNodeTypeEnum.code) {
+ return {
+ addedInputKeys: [],
+ removedInputKeys: [],
+ addedOutputKeys: [],
+ removedOutputs: []
+ };
+ }
+
+ const inputDefinitions = extractCodeInputDefinitions(code);
+ const addedInputKeys: string[] = [];
+ const removedInputKeys: string[] = [];
+ if (inputDefinitions) {
+ const nextInputKeys = new Set(inputDefinitions.map((item) => item.key));
+ node.inputs
+ .filter(
+ (input) =>
+ input.canEdit === true &&
+ !dynamicInputMarkerKeys.has(input.key) &&
+ !nextInputKeys.has(input.key)
+ )
+ .forEach((input) => {
+ removeNodeInput({ document, nodeId, inputKey: input.key });
+ removedInputKeys.push(input.key);
+ });
+
+ inputDefinitions.forEach((definition) => {
+ const existingInput = node.inputs.find((input) => input.key === definition.key);
+ if (existingInput) {
+ if (definition.valueType) existingInput.valueType = definition.valueType;
+ return;
+ }
+ addNodeInput({
+ document,
+ nodeId,
+ input: FlowNodeInputItemTypeSchema.parse({
+ key: definition.key,
+ label: definition.key,
+ valueType: definition.valueType ?? WorkflowIOValueTypeEnum.any,
+ renderTypeList: [FlowNodeInputTypeEnum.reference],
+ canEdit: true,
+ required: true,
+ customInputConfig: {
+ selectValueTypeList: Object.values(WorkflowIOValueTypeEnum),
+ showDescription: false,
+ showDefaultValue: true
+ }
+ })
+ });
+ addedInputKeys.push(definition.key);
+ });
+ }
+
+ const outputDefinitions = extractCodeOutputDefinitions(code);
+ const addedOutputKeys: string[] = [];
+ const removedOutputs: Array<{
+ outputKey: string;
+ removedEdgeCount: number;
+ references: Array<{ nodeId: string; inputKey: string }>;
+ }> = [];
+ if (outputDefinitions) {
+ const nextOutputKeys = new Set(outputDefinitions.map((item) => item.key));
+ node.outputs
+ .filter(
+ (output) =>
+ output.type === FlowNodeOutputTypeEnum.dynamic &&
+ !codeSystemOutputKeys.has(output.key) &&
+ !nextOutputKeys.has(output.key)
+ )
+ .forEach((output) => {
+ removedOutputs.push({
+ outputKey: output.key,
+ ...removeNodeOutput({ document, nodeId, outputKey: output.key })
+ });
+ });
+
+ outputDefinitions.forEach((definition) => {
+ const existingOutput = node.outputs.find((output) => output.key === definition.key);
+ if (existingOutput) {
+ if (definition.valueType) existingOutput.valueType = definition.valueType;
+ return;
+ }
+ addNodeOutput({
+ document,
+ nodeId,
+ output: FlowNodeOutputItemTypeSchema.parse({
+ id: definition.key,
+ key: definition.key,
+ label: definition.key,
+ type: FlowNodeOutputTypeEnum.dynamic,
+ valueType: definition.valueType ?? WorkflowIOValueTypeEnum.any
+ })
+ });
+ addedOutputKeys.push(definition.key);
+ });
+ }
+
+ return { addedInputKeys, removedInputKeys, addedOutputKeys, removedOutputs };
+};
+
+/** 表单字段是输出的事实源;字段整体更新后同步对应静态输出。 */
+export const syncFormInputOutputs = ({
+ document,
+ nodeId,
+ previousFieldKeys
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ previousFieldKeys: string[];
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ if (node.flowNodeType !== FlowNodeTypeEnum.formInput) return;
+ const forms = node.inputs.find((item) => item.key === NodeInputKeyEnum.userInputForms)?.value;
+ if (!Array.isArray(forms)) return;
+ const oldKeys = new Set(previousFieldKeys);
+ node.outputs = node.outputs.filter((output) => !oldKeys.has(output.key));
+ for (const form of forms) {
+ if (!form || typeof form !== 'object') continue;
+ const field = form as { key?: unknown; label?: unknown; valueType?: unknown };
+ if (typeof field.key !== 'string' || !field.key) continue;
+ node.outputs.push(
+ FlowNodeOutputItemTypeSchema.parse({
+ id: field.key,
+ key: field.key,
+ label: typeof field.label === 'string' ? field.label : field.key,
+ type: FlowNodeOutputTypeEnum.static,
+ valueType:
+ typeof field.valueType === 'string' ? field.valueType : WorkflowIOValueTypeEnum.any
+ })
+ );
+ }
+};
diff --git a/packages/workflow-core/src/nesting/service.ts b/packages/workflow-core/src/nesting/service.ts
new file mode 100644
index 000000000000..b0094bf690b8
--- /dev/null
+++ b/packages/workflow-core/src/nesting/service.ts
@@ -0,0 +1,166 @@
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import {
+ FlowNodeTypeEnum,
+ isInteractiveNodeType,
+ isNestedChildSystemNodeType,
+ isNestedParentNodeType
+} from '@fastgpt/global/core/workflow/node/constant';
+import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+
+const forbiddenChildTypes = new Set([
+ FlowNodeTypeEnum.workflowStart,
+ FlowNodeTypeEnum.loop,
+ FlowNodeTypeEnum.loopRun,
+ FlowNodeTypeEnum.parallelRun,
+ FlowNodeTypeEnum.pluginInput,
+ FlowNodeTypeEnum.pluginOutput,
+ FlowNodeTypeEnum.pluginConfig,
+ FlowNodeTypeEnum.systemConfig,
+ FlowNodeTypeEnum.globalVariable
+]);
+
+export const getDocumentNode = (document: WorkflowDocument, nodeId: string) => {
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_NOT_FOUND', severity: 'error', nodeId }
+ ]);
+ }
+ return node;
+};
+
+/** 以 parentNodeId 为事实源,同步容器的 childrenNodeIdList 兼容字段。 */
+export const syncContainerChildren = (document: WorkflowDocument, parentNodeId: string) => {
+ const parent = getDocumentNode(document, parentNodeId);
+ const input = parent.inputs.find((item) => item.key === NodeInputKeyEnum.childrenNodeIdList);
+ if (input) {
+ input.value = document.nodes
+ .filter((item) => item.parentNodeId === parentNodeId)
+ .map((item) => item.nodeId);
+ }
+};
+
+/** 校验节点是否可以处于指定容器;undefined 表示根级。 */
+export const assertParentAssignment = ({
+ document,
+ node,
+ parentNodeId,
+ allowSystemChild = false
+}: {
+ document: WorkflowDocument;
+ node: StoreNodeItemType;
+ parentNodeId?: string;
+ allowSystemChild?: boolean;
+}) => {
+ if (!parentNodeId) {
+ if (node.flowNodeType === FlowNodeTypeEnum.loopRunBreak) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_LOOP_BREAK_PARENT_REQUIRED', severity: 'error', nodeId: node.nodeId }
+ ]);
+ }
+ if (isNestedChildSystemNodeType(node.flowNodeType) && !allowSystemChild) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_SYSTEM_CHILD_MOVE_FORBIDDEN', severity: 'error', nodeId: node.nodeId }
+ ]);
+ }
+ return;
+ }
+
+ const parent = getDocumentNode(document, parentNodeId);
+ if (!isNestedParentNodeType(parent.flowNodeType)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_PARENT_NOT_CONTAINER',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { parentNodeId }
+ }
+ ]);
+ }
+ if (forbiddenChildTypes.has(node.flowNodeType)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_NODE_NOT_ALLOWED_IN_CONTAINER',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { parentNodeId }
+ }
+ ]);
+ }
+ if (
+ parent.flowNodeType === FlowNodeTypeEnum.parallelRun &&
+ isInteractiveNodeType(node.flowNodeType)
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_INTERACTIVE_NODE_NOT_ALLOWED_IN_PARALLEL',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { parentNodeId }
+ }
+ ]);
+ }
+ if (
+ node.flowNodeType === FlowNodeTypeEnum.loopRunBreak &&
+ parent.flowNodeType !== FlowNodeTypeEnum.loopRun
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_LOOP_BREAK_PARENT_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { parentNodeId }
+ }
+ ]);
+ }
+ if (isNestedChildSystemNodeType(node.flowNodeType) && !allowSystemChild) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_SYSTEM_CHILD_MOVE_FORBIDDEN', severity: 'error', nodeId: node.nodeId }
+ ]);
+ }
+};
+
+/** 改变父作用域时同步父子字段,并移除跨作用域执行边。 */
+export const moveNodeToParent = ({
+ document,
+ nodeId,
+ parentNodeId,
+ position
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ parentNodeId?: string;
+ position?: { x: number; y: number };
+}) => {
+ const node = getDocumentNode(document, nodeId);
+ const previousParentNodeId = node.parentNodeId;
+ assertParentAssignment({ document, node, parentNodeId });
+
+ node.parentNodeId = parentNodeId;
+ if (position) node.position = position;
+
+ let removedEdgeCount = 0;
+ if (previousParentNodeId !== parentNodeId) {
+ const before = document.executionEdges.length;
+ document.executionEdges = document.executionEdges.filter(
+ (edge) => edge.source.nodeId !== nodeId && edge.target.nodeId !== nodeId
+ );
+ removedEdgeCount = before - document.executionEdges.length;
+ if (previousParentNodeId) syncContainerChildren(document, previousParentNodeId);
+ if (parentNodeId) syncContainerChildren(document, parentNodeId);
+ }
+
+ return { previousParentNodeId, parentNodeId, removedEdgeCount };
+};
+
+export const listContainerChildren = (document: WorkflowDocument, parentNodeId: string) => {
+ const parent = getDocumentNode(document, parentNodeId);
+ if (!isNestedParentNodeType(parent.flowNodeType)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_PARENT_NOT_CONTAINER', severity: 'error', nodeId: parentNodeId }
+ ]);
+ }
+ return document.nodes.filter((item) => item.parentNodeId === parentNodeId);
+};
diff --git a/packages/workflow-core/src/node/add.ts b/packages/workflow-core/src/node/add.ts
new file mode 100644
index 000000000000..306cfdb493da
--- /dev/null
+++ b/packages/workflow-core/src/node/add.ts
@@ -0,0 +1,155 @@
+import {
+ FlowNodeTypeEnum,
+ isNestedParentNodeType
+} from '@fastgpt/global/core/workflow/node/constant';
+import type { WorkflowDocument } from '../domain/document';
+import { createWorkflowDocument } from '../domain/document';
+import type { WorkflowDiagnostic } from '../domain/diagnostic';
+import { assertParentAssignment, syncContainerChildren } from '../nesting/service';
+import { instantiateNodeFromTemplate } from '../template/instantiate';
+import type { NodeTemplateRef, WorkflowTemplateProvider } from '../template/type';
+
+export type AddNodeDependencies = {
+ templateProvider: WorkflowTemplateProvider;
+ locale?: string;
+ translate?: (value: string) => string;
+};
+
+export const SYSTEM_CONFIG_NODE_ID = 'userGuide';
+export const WORKFLOW_START_NODE_ID = 'start';
+
+/**
+ * 为工作流补齐唯一的系统配置节点。
+ * 配置数据仍以 chatConfig 为事实源;该节点只负责提供 Web 画布上的统一编辑入口。
+ */
+export const ensureSystemConfigNode = async ({
+ document,
+ dependencies
+}: {
+ document: WorkflowDocument;
+ dependencies: AddNodeDependencies;
+}) => {
+ if (document.nodes.some((node) => node.flowNodeType === FlowNodeTypeEnum.systemConfig)) {
+ return { nodeIds: [], warnings: [] };
+ }
+
+ return addNodeFromTemplate({
+ document,
+ template: { kind: 'builtin', templateId: '__system-config' },
+ nodeId: SYSTEM_CONFIG_NODE_ID,
+ position: { x: 260, y: -480 },
+ dependencies
+ });
+};
+
+/** 创建与 FastGPT Web 默认结构一致的空工作流。 */
+export const createDefaultWorkflowDocument = async ({
+ app = {},
+ dependencies
+}: {
+ app?: WorkflowDocument['app'];
+ dependencies: AddNodeDependencies;
+}) => {
+ const document = createWorkflowDocument({ app });
+ const systemConfigResult = await ensureSystemConfigNode({ document, dependencies });
+ const workflowStartResult = await addNodeFromTemplate({
+ document,
+ template: { kind: 'builtin', templateId: 'workflow-start' },
+ nodeId: WORKFLOW_START_NODE_ID,
+ position: { x: 560, y: 120 },
+ dependencies
+ });
+
+ return {
+ document,
+ nodeIds: [...systemConfigResult.nodeIds, ...workflowStartResult.nodeIds],
+ warnings: [...systemConfigResult.warnings, ...workflowStartResult.warnings]
+ };
+};
+
+/** 创建完整节点;容器会在同一事务内生成必需的系统子节点。 */
+export const addNodeFromTemplate = async ({
+ document,
+ template,
+ nodeId,
+ name,
+ position,
+ parentNodeId,
+ dependencies
+}: {
+ document: WorkflowDocument;
+ template: NodeTemplateRef;
+ nodeId: string;
+ name?: string;
+ position?: { x: number; y: number };
+ parentNodeId?: string;
+ dependencies: AddNodeDependencies;
+}): Promise<{ nodeIds: string[]; warnings: WorkflowDiagnostic[] }> => {
+ const instantiate = (params: {
+ templateRef: NodeTemplateRef;
+ childNodeId: string;
+ childPosition?: { x: number; y: number };
+ childParentNodeId?: string;
+ childName?: string;
+ }) =>
+ instantiateNodeFromTemplate({
+ document,
+ templateRef: params.templateRef,
+ nodeId: params.childNodeId,
+ name: params.childName,
+ position: params.childPosition,
+ parentNodeId: params.childParentNodeId,
+ provider: dependencies.templateProvider,
+ locale: dependencies.locale ?? 'en',
+ translate: dependencies.translate
+ });
+
+ const instantiated = await instantiate({
+ templateRef: template,
+ childNodeId: nodeId,
+ childPosition: position,
+ childParentNodeId: parentNodeId,
+ childName: name
+ });
+ assertParentAssignment({ document, node: instantiated.node, parentNodeId });
+ document.nodes.push(instantiated.node);
+
+ const nodeIds = [nodeId];
+ const warnings = [...instantiated.warnings];
+ if (parentNodeId) syncContainerChildren(document, parentNodeId);
+
+ if (!isNestedParentNodeType(instantiated.node.flowNodeType)) {
+ return { nodeIds, warnings };
+ }
+
+ const systemTemplates =
+ instantiated.node.flowNodeType === FlowNodeTypeEnum.loopRun
+ ? [{ templateId: '__loop-run-start', suffix: 'start', offset: { x: 60, y: 280 } }]
+ : [
+ { templateId: '__nested-start', suffix: 'start', offset: { x: 60, y: 280 } },
+ { templateId: '__nested-end', suffix: 'end', offset: { x: 420, y: 680 } }
+ ];
+
+ for (const systemTemplate of systemTemplates) {
+ const childNodeId = `${nodeId}__${systemTemplate.suffix}`;
+ const child = await instantiate({
+ templateRef: { kind: 'builtin', templateId: systemTemplate.templateId },
+ childNodeId,
+ childPosition: position
+ ? { x: position.x + systemTemplate.offset.x, y: position.y + systemTemplate.offset.y }
+ : undefined,
+ childParentNodeId: nodeId
+ });
+ assertParentAssignment({
+ document,
+ node: child.node,
+ parentNodeId: nodeId,
+ allowSystemChild: true
+ });
+ document.nodes.push(child.node);
+ nodeIds.push(childNodeId);
+ warnings.push(...child.warnings);
+ }
+ syncContainerChildren(document, nodeId);
+ return { nodeIds, warnings };
+};
diff --git a/packages/workflow-core/src/node/service.ts b/packages/workflow-core/src/node/service.ts
new file mode 100644
index 000000000000..05df75ea93d2
--- /dev/null
+++ b/packages/workflow-core/src/node/service.ts
@@ -0,0 +1,189 @@
+import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import {
+ FlowNodeTypeEnum,
+ isNestedChildSystemNodeType,
+ isNestedParentNodeType
+} from '@fastgpt/global/core/workflow/node/constant';
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import { syncContainerChildren } from '../nesting/service';
+
+const getNode = (document: WorkflowDocument, nodeId: string) => {
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_NOT_FOUND', severity: 'error', nodeId }
+ ]);
+ }
+ return node;
+};
+
+export const updateNode = ({
+ document,
+ nodeId,
+ name,
+ position,
+ catchError
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ name?: string;
+ position?: { x: number; y: number };
+ catchError?: boolean;
+}) => {
+ const node = getNode(document, nodeId);
+ if (name !== undefined) node.name = name;
+ if (position !== undefined) node.position = position;
+ if (catchError !== undefined) node.catchError = catchError;
+};
+
+const clearSecretInputs = (node: StoreNodeItemType) => {
+ for (const input of node.inputs) {
+ if (
+ input.renderTypeList.includes(FlowNodeInputTypeEnum.password) ||
+ input.key === NodeInputKeyEnum.headerSecret
+ ) {
+ input.value = undefined;
+ }
+ }
+ if (node.toolConfig?.mcpToolSet) node.toolConfig.mcpToolSet.headerSecret = undefined;
+ if (node.toolConfig?.httpToolSet) node.toolConfig.httpToolSet.headerSecret = undefined;
+};
+
+/** 克隆节点运行态结构,但不复制密码类输入,避免生成可复用凭据副本。 */
+export const cloneNode = ({
+ document,
+ sourceNodeId,
+ nodeId,
+ position,
+ offset
+}: {
+ document: WorkflowDocument;
+ sourceNodeId: string;
+ nodeId: string;
+ position?: { x: number; y: number };
+ offset?: { x: number; y: number };
+}) => {
+ const source = getNode(document, sourceNodeId);
+ if (document.nodes.some((item) => item.nodeId === nodeId)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_ID_DUPLICATED', severity: 'error', nodeId }
+ ]);
+ }
+ if (
+ source.flowNodeType === FlowNodeTypeEnum.workflowStart ||
+ source.flowNodeType === FlowNodeTypeEnum.systemConfig ||
+ isNestedParentNodeType(source.flowNodeType) ||
+ isNestedChildSystemNodeType(source.flowNodeType)
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_UNIQUE_NODE_CLONE_FORBIDDEN', severity: 'error', nodeId: sourceNodeId }
+ ]);
+ }
+
+ const clone = structuredClone(source);
+ clone.nodeId = nodeId;
+ clone.name = source.name;
+ clone.position =
+ position ??
+ (source.position
+ ? {
+ x: source.position.x + (offset?.x ?? 40),
+ y: source.position.y + (offset?.y ?? 40)
+ }
+ : offset);
+ clearSecretInputs(clone);
+ document.nodes.push(clone);
+ if (clone.parentNodeId) syncContainerChildren(document, clone.parentNodeId);
+};
+
+const isReferenceToDeletedNode = (value: unknown, deletedNodeIds: Set): boolean => {
+ if (!Array.isArray(value)) return false;
+ if (value.length === 2 && typeof value[0] === 'string' && typeof value[1] === 'string') {
+ return deletedNodeIds.has(value[0]);
+ }
+ return value.some((item) => isReferenceToDeletedNode(item, deletedNodeIds));
+};
+
+/** 删除节点、全部后代、相关执行边,并清除指向被删节点的基础输入引用。 */
+export const removeNode = ({
+ document,
+ nodeId
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+}) => {
+ const node = getNode(document, nodeId);
+ const parentNodeId = node.parentNodeId;
+ if (
+ node.flowNodeType === FlowNodeTypeEnum.workflowStart ||
+ node.flowNodeType === FlowNodeTypeEnum.systemConfig ||
+ isNestedChildSystemNodeType(node.flowNodeType)
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_DELETE_FORBIDDEN', severity: 'error', nodeId }
+ ]);
+ }
+ if (node.flowNodeType === FlowNodeTypeEnum.loopRunBreak && parentNodeId) {
+ const parent = getNode(document, parentNodeId);
+ const mode = parent.inputs.find((item) => item.key === NodeInputKeyEnum.loopRunMode)?.value;
+ const breakCount = document.nodes.filter(
+ (item) =>
+ item.parentNodeId === parentNodeId && item.flowNodeType === FlowNodeTypeEnum.loopRunBreak
+ ).length;
+ if (mode === 'conditional' && breakCount <= 1) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_CONDITIONAL_LOOP_BREAK_REQUIRED',
+ severity: 'error',
+ nodeId: parentNodeId
+ }
+ ]);
+ }
+ }
+
+ const deletedNodeIds = new Set([nodeId]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const item of document.nodes) {
+ if (
+ item.parentNodeId &&
+ deletedNodeIds.has(item.parentNodeId) &&
+ !deletedNodeIds.has(item.nodeId)
+ ) {
+ deletedNodeIds.add(item.nodeId);
+ changed = true;
+ }
+ }
+ }
+
+ const removedEdges = document.executionEdges.filter(
+ (edge) => deletedNodeIds.has(edge.source.nodeId) || deletedNodeIds.has(edge.target.nodeId)
+ );
+ document.nodes = document.nodes.filter((item) => !deletedNodeIds.has(item.nodeId));
+ document.executionEdges = document.executionEdges.filter(
+ (edge) => !deletedNodeIds.has(edge.source.nodeId) && !deletedNodeIds.has(edge.target.nodeId)
+ );
+
+ const clearedReferences: Array<{ nodeId: string; inputKey: string }> = [];
+ for (const item of document.nodes) {
+ for (const input of item.inputs) {
+ if (!isReferenceToDeletedNode(input.value, deletedNodeIds)) continue;
+ input.value = undefined;
+ clearedReferences.push({ nodeId: item.nodeId, inputKey: input.key });
+ }
+ }
+
+ if (parentNodeId && document.nodes.some((item) => item.nodeId === parentNodeId)) {
+ syncContainerChildren(document, parentNodeId);
+ }
+
+ return {
+ deletedNodeIds: [...deletedNodeIds],
+ removedEdgeCount: removedEdges.length,
+ clearedReferences
+ };
+};
diff --git a/packages/workflow-core/src/public.ts b/packages/workflow-core/src/public.ts
new file mode 100644
index 000000000000..f54ab5aa3faa
--- /dev/null
+++ b/packages/workflow-core/src/public.ts
@@ -0,0 +1,13 @@
+export {
+ VARIABLE_NODE_ID,
+ VariableInputEnum,
+ WorkflowIOValueTypeEnum,
+ textInputVariableValueTypes,
+ variableMap
+} from '@fastgpt/global/core/workflow/constants';
+export {
+ FlowNodeInputTypeEnum,
+ FlowNodeOutputTypeEnum,
+ FlowNodeTypeEnum
+} from '@fastgpt/global/core/workflow/node/constant';
+export { VariableItemTypeSchema, type VariableItemType } from '@fastgpt/global/core/app/type';
diff --git a/packages/workflow-core/src/reference/codec.ts b/packages/workflow-core/src/reference/codec.ts
new file mode 100644
index 000000000000..469546f5677c
--- /dev/null
+++ b/packages/workflow-core/src/reference/codec.ts
@@ -0,0 +1,80 @@
+import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
+import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+
+const NODE_VARIABLE_PATTERN = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g;
+
+type ReferenceCodecDirection = 'encode' | 'decode';
+
+/**
+ * 转换 Document 和 StoreWorkflow 的输出引用命名空间。
+ * Document 使用稳定的 output key,Store/Web/Runtime 使用 output id;全局变量 key 保持不变。
+ */
+const transformNodeReferences = ({
+ nodes,
+ direction
+}: {
+ nodes: StoreNodeItemType[];
+ direction: ReferenceCodecDirection;
+}): StoreNodeItemType[] => {
+ const nodeMap = new Map(nodes.map((node) => [node.nodeId, node]));
+
+ const transformOutputSelector = (nodeId: string, selector: string) => {
+ if (nodeId === VARIABLE_NODE_ID) return selector;
+ const node = nodeMap.get(nodeId);
+ if (!node) return selector;
+
+ const output = (() => {
+ if (direction === 'encode') {
+ return (
+ node.outputs.find((output) => output.key === selector) ??
+ node.outputs.find((output) => output.id === selector)
+ );
+ }
+ return (
+ node.outputs.find((output) => output.id === selector) ??
+ node.outputs.find((output) => output.key === selector)
+ );
+ })();
+ if (!output) return selector;
+ return direction === 'encode' ? output.id : output.key;
+ };
+
+ const transformValue = (value: unknown): unknown => {
+ if (typeof value === 'string') {
+ return value.replace(NODE_VARIABLE_PATTERN, (match, nodeId: string, selector: string) => {
+ const transformedSelector = transformOutputSelector(nodeId, selector);
+ if (transformedSelector === selector) return match;
+ return `{{$${nodeId}.${transformedSelector}$}}`;
+ });
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 2 && typeof value[0] === 'string' && typeof value[1] === 'string') {
+ const transformedSelector = transformOutputSelector(value[0], value[1]);
+ if (transformedSelector !== value[1]) return [value[0], transformedSelector];
+ }
+ return value.map(transformValue);
+ }
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [key, transformValue(item)])
+ );
+ }
+ return value;
+ };
+
+ return nodes.map((node) => ({
+ ...structuredClone(node),
+ inputs: node.inputs.map((input) => ({
+ ...structuredClone(input),
+ value: transformValue(input.value)
+ }))
+ }));
+};
+
+/** 将 WorkflowDocument 的 output key 引用编码为 StoreWorkflow output id。 */
+export const encodeWorkflowNodeReferences = (nodes: StoreNodeItemType[]) =>
+ transformNodeReferences({ nodes, direction: 'encode' });
+
+/** 将 StoreWorkflow 的 output id 引用解码为 WorkflowDocument output key。 */
+export const decodeWorkflowNodeReferences = (nodes: StoreNodeItemType[]) =>
+ transformNodeReferences({ nodes, direction: 'decode' });
diff --git a/packages/workflow-core/src/reference/service.ts b/packages/workflow-core/src/reference/service.ts
new file mode 100644
index 000000000000..63f61a1cbf45
--- /dev/null
+++ b/packages/workflow-core/src/reference/service.ts
@@ -0,0 +1,364 @@
+import { VARIABLE_NODE_ID, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import type { VariableRef } from './type';
+import { getInputAutomationMeta } from '../template/automationMeta';
+import { assertValueSchema, inputValueNeedsSchema } from '../template/valueSchema';
+
+const arrayItemTypeMap: Partial> = {
+ [WorkflowIOValueTypeEnum.arrayString]: WorkflowIOValueTypeEnum.string,
+ [WorkflowIOValueTypeEnum.arrayNumber]: WorkflowIOValueTypeEnum.number,
+ [WorkflowIOValueTypeEnum.arrayBoolean]: WorkflowIOValueTypeEnum.boolean,
+ [WorkflowIOValueTypeEnum.arrayObject]: WorkflowIOValueTypeEnum.object
+};
+
+/** 统一判断普通引用和聚合引用的输入输出类型是否兼容。 */
+export const areWorkflowValueTypesCompatible = ({
+ expected,
+ actual,
+ collection = false
+}: {
+ expected?: string;
+ actual?: string;
+ collection?: boolean;
+}) => {
+ if (
+ expected === undefined ||
+ actual === undefined ||
+ expected === WorkflowIOValueTypeEnum.any ||
+ expected === WorkflowIOValueTypeEnum.dynamic ||
+ actual === WorkflowIOValueTypeEnum.any ||
+ actual === WorkflowIOValueTypeEnum.dynamic
+ ) {
+ return true;
+ }
+ if (!collection) return expected === actual;
+ if (expected === WorkflowIOValueTypeEnum.arrayAny) return true;
+ return expected === actual || arrayItemTypeMap[expected] === actual;
+};
+
+const getInput = ({
+ document,
+ nodeId,
+ inputKey
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+}) => {
+ const node = document.nodes.find((item) => item.nodeId === nodeId);
+ if (!node) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_NOT_FOUND', severity: 'error', nodeId }
+ ]);
+ }
+ const input = node.inputs.find((item) => item.key === inputKey);
+ if (!input) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_INPUT_NOT_FOUND', severity: 'error', nodeId, inputKey }
+ ]);
+ }
+ if (
+ input.canEdit === false ||
+ getInputAutomationMeta(node.flowNodeType, inputKey)?.configurable === false
+ ) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_INPUT_NOT_CONFIGURABLE', severity: 'error', nodeId, inputKey }
+ ]);
+ }
+ return input;
+};
+
+export const valueMatchesType = (value: unknown, valueType?: string): boolean => {
+ if (valueType === undefined || valueType === WorkflowIOValueTypeEnum.any) return true;
+ if (valueType === WorkflowIOValueTypeEnum.string) return typeof value === 'string';
+ if (valueType === WorkflowIOValueTypeEnum.number) return typeof value === 'number';
+ if (valueType === WorkflowIOValueTypeEnum.boolean) return typeof value === 'boolean';
+ if (valueType === WorkflowIOValueTypeEnum.object) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+ }
+ if (valueType === WorkflowIOValueTypeEnum.arrayString) {
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
+ }
+ if (valueType === WorkflowIOValueTypeEnum.arrayNumber) {
+ return Array.isArray(value) && value.every((item) => typeof item === 'number');
+ }
+ if (valueType === WorkflowIOValueTypeEnum.arrayBoolean) {
+ return Array.isArray(value) && value.every((item) => typeof item === 'boolean');
+ }
+ if (
+ valueType === WorkflowIOValueTypeEnum.arrayObject ||
+ valueType === WorkflowIOValueTypeEnum.arrayAny ||
+ valueType === WorkflowIOValueTypeEnum.chatHistory ||
+ valueType === WorkflowIOValueTypeEnum.datasetQuote
+ ) {
+ return Array.isArray(value) || typeof value === 'number';
+ }
+ return true;
+};
+
+const assertInputMode = ({
+ input,
+ nodeId,
+ mode
+}: {
+ input: FlowNodeInputItemType;
+ nodeId: string;
+ mode: 'literal' | 'reference';
+}) => {
+ const hasMode =
+ mode === 'reference'
+ ? input.renderTypeList.includes(FlowNodeInputTypeEnum.reference)
+ : input.renderTypeList.some((type) => type !== FlowNodeInputTypeEnum.reference);
+ if (!hasMode) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_INPUT_MODE_NOT_ALLOWED',
+ severity: 'error',
+ nodeId,
+ inputKey: input.key,
+ params: { mode }
+ }
+ ]);
+ }
+};
+
+/** 更新固定值,并同步 Web 使用的 selectedTypeIndex。 */
+export const setInputValue = ({
+ document,
+ nodeId,
+ inputKey,
+ value
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+ value: unknown;
+}) => {
+ const input = getInput({ document, nodeId, inputKey });
+ assertInputMode({ input, nodeId, mode: 'literal' });
+ if (!valueMatchesType(value, input.valueType)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_INPUT_VALUE_TYPE_INVALID',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: { expected: input.valueType }
+ }
+ ]);
+ }
+ const valueSchema = getInputAutomationMeta(
+ document.nodes.find((item) => item.nodeId === nodeId)!.flowNodeType,
+ inputKey
+ )?.valueSchema;
+ const needsSchema = inputValueNeedsSchema({ value, valueType: input.valueType });
+ if (needsSchema && !valueSchema) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_TEMPLATE_PARAMETER_SCHEMA_MISSING',
+ severity: 'error',
+ nodeId,
+ inputKey
+ }
+ ]);
+ }
+ if (valueSchema) assertValueSchema({ value, schema: valueSchema, nodeId, inputKey });
+
+ input.value = structuredClone(value);
+ const literalIndex = input.renderTypeList.findIndex(
+ (type) => type !== FlowNodeInputTypeEnum.reference
+ );
+ input.selectedTypeIndex = literalIndex >= 0 ? literalIndex : undefined;
+};
+
+/** 设置基础节点输出引用,WorkflowDocument 使用稳定的 `[nodeId, outputKey]` 语义格式。 */
+export const setInputReference = ({
+ document,
+ nodeId,
+ inputKey,
+ ref
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+ ref: VariableRef;
+}) => {
+ const input = getInput({ document, nodeId, inputKey });
+ assertInputMode({ input, nodeId, mode: 'reference' });
+
+ if (ref.nodeId === VARIABLE_NODE_ID) {
+ const variable = document.chatConfig.variables?.find((item) => item.key === ref.outputKey);
+ if (!variable) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_REFERENCE_OUTPUT_NOT_FOUND',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: ref
+ }
+ ]);
+ }
+ if (
+ !areWorkflowValueTypesCompatible({
+ expected: input.valueType,
+ actual: variable.valueType
+ })
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_REFERENCE_TYPE_MISMATCH',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: { expected: input.valueType, actual: variable.valueType }
+ }
+ ]);
+ }
+ input.value = [ref.nodeId, ref.outputKey];
+ input.selectedTypeIndex = input.renderTypeList.indexOf(FlowNodeInputTypeEnum.reference);
+ return;
+ }
+
+ const sourceNode = document.nodes.find((item) => item.nodeId === ref.nodeId);
+ const sourceOutput = sourceNode?.outputs.find((output) => output.key === ref.outputKey);
+ if (!sourceNode || !sourceOutput) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_REFERENCE_OUTPUT_NOT_FOUND',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: ref
+ }
+ ]);
+ }
+
+ const reachableNodeIds = new Set([sourceNode.nodeId]);
+ const pendingNodeIds = [sourceNode.nodeId];
+ while (pendingNodeIds.length > 0) {
+ const currentNodeId = pendingNodeIds.shift()!;
+ for (const edge of document.executionEdges) {
+ if (edge.source.nodeId !== currentNodeId || reachableNodeIds.has(edge.target.nodeId))
+ continue;
+ reachableNodeIds.add(edge.target.nodeId);
+ pendingNodeIds.push(edge.target.nodeId);
+ }
+ }
+ if (sourceNode.nodeId === nodeId || !reachableNodeIds.has(nodeId)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_REFERENCE_SOURCE_NOT_UPSTREAM',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: ref
+ }
+ ]);
+ }
+ if (
+ !areWorkflowValueTypesCompatible({
+ expected: input.valueType,
+ actual: sourceOutput.valueType
+ })
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_REFERENCE_TYPE_MISMATCH',
+ severity: 'error',
+ nodeId,
+ inputKey,
+ params: { expected: input.valueType, actual: sourceOutput.valueType }
+ }
+ ]);
+ }
+
+ input.value = [ref.nodeId, ref.outputKey];
+ input.selectedTypeIndex = input.renderTypeList.indexOf(FlowNodeInputTypeEnum.reference);
+};
+
+/** 清空可选且可配置的节点输入。 */
+export const unsetInput = ({
+ document,
+ nodeId,
+ inputKey
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+}) => {
+ const input = getInput({ document, nodeId, inputKey });
+ if (input.required === true) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_REQUIRED_INPUT_UNSET_FORBIDDEN', severity: 'error', nodeId, inputKey }
+ ]);
+ }
+ input.value = undefined;
+};
+
+/** 返回对指定输入类型兼容且在当前节点上游可达的输出和全局变量。 */
+export const getAvailableInputReferences = ({
+ document,
+ nodeId,
+ inputKey
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ inputKey: string;
+}) => {
+ const input = getInput({ document, nodeId, inputKey });
+ assertInputMode({ input, nodeId, mode: 'reference' });
+ const upstreamNodeIds = new Set();
+ const pending = document.executionEdges
+ .filter((edge) => edge.target.nodeId === nodeId)
+ .map((edge) => edge.source.nodeId);
+ while (pending.length > 0) {
+ const current = pending.shift()!;
+ if (upstreamNodeIds.has(current)) continue;
+ upstreamNodeIds.add(current);
+ pending.push(
+ ...document.executionEdges
+ .filter((edge) => edge.target.nodeId === current)
+ .map((edge) => edge.source.nodeId)
+ );
+ }
+
+ const nodeOutputs = document.nodes
+ .filter((node) => upstreamNodeIds.has(node.nodeId))
+ .flatMap((node) =>
+ node.outputs
+ .filter(
+ (output) =>
+ input.valueType === undefined ||
+ input.valueType === WorkflowIOValueTypeEnum.any ||
+ output.valueType === undefined ||
+ output.valueType === WorkflowIOValueTypeEnum.any ||
+ output.valueType === input.valueType
+ )
+ .map((output) => ({
+ ref: { nodeId: node.nodeId, outputKey: output.key },
+ label: output.label,
+ valueType: output.valueType,
+ source: 'node' as const
+ }))
+ );
+ const variables = (document.chatConfig.variables ?? [])
+ .filter(
+ (variable) =>
+ input.valueType === undefined ||
+ input.valueType === WorkflowIOValueTypeEnum.any ||
+ variable.valueType === undefined ||
+ variable.valueType === WorkflowIOValueTypeEnum.any ||
+ variable.valueType === input.valueType
+ )
+ .map((variable) => ({
+ ref: { nodeId: VARIABLE_NODE_ID, outputKey: variable.key },
+ label: variable.label,
+ valueType: variable.valueType,
+ source: 'variable' as const
+ }));
+ return [...variables, ...nodeOutputs];
+};
diff --git a/packages/workflow-core/src/reference/type.ts b/packages/workflow-core/src/reference/type.ts
new file mode 100644
index 000000000000..1eeb6caccc92
--- /dev/null
+++ b/packages/workflow-core/src/reference/type.ts
@@ -0,0 +1,24 @@
+import z from 'zod';
+import { WorkflowCommandError } from '../domain/diagnostic';
+
+export const VariableRefSchema = z.object({
+ nodeId: z.string().min(1),
+ outputKey: z.string().min(1)
+});
+
+export type VariableRef = z.infer;
+
+/** 解析 `node.output`,节点 ID 可包含点,最后一个点固定分隔 output key。 */
+export const parseVariableRef = (value: string): VariableRef => {
+ const separatorIndex = value.lastIndexOf('.');
+ if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_VARIABLE_REF_INVALID', severity: 'error', params: { value } }
+ ]);
+ }
+
+ return {
+ nodeId: value.slice(0, separatorIndex),
+ outputKey: value.slice(separatorIndex + 1)
+ };
+};
diff --git a/packages/workflow-core/src/store/compile.ts b/packages/workflow-core/src/store/compile.ts
new file mode 100644
index 000000000000..cd89c53206ff
--- /dev/null
+++ b/packages/workflow-core/src/store/compile.ts
@@ -0,0 +1,12 @@
+import { WorkflowTemplateBasicTypeSchema } from '@fastgpt/global/core/workflow/type';
+import type { WorkflowDocument } from '../domain/document';
+import { compileExecutionEdge } from '../edge/compiler';
+import { encodeWorkflowNodeReferences } from '../reference/codec';
+
+/** 将唯一规范状态编译为 FastGPT Web/Service 可读取的 StoreWorkflow。 */
+export const compileStoreWorkflow = (document: WorkflowDocument) =>
+ WorkflowTemplateBasicTypeSchema.parse({
+ nodes: encodeWorkflowNodeReferences(document.nodes),
+ edges: document.executionEdges.map((edge) => compileExecutionEdge(edge, document)),
+ chatConfig: structuredClone(document.chatConfig)
+ });
diff --git a/packages/workflow-core/src/store/decompile.ts b/packages/workflow-core/src/store/decompile.ts
new file mode 100644
index 000000000000..451713e8616c
--- /dev/null
+++ b/packages/workflow-core/src/store/decompile.ts
@@ -0,0 +1,30 @@
+import {
+ WorkflowTemplateBasicTypeSchema,
+ type WorkflowTemplateBasicType
+} from '@fastgpt/global/core/workflow/type';
+import { createWorkflowDocument, type WorkflowDocument } from '../domain/document';
+import { decompileStoreEdge } from '../edge/compiler';
+import { decodeWorkflowNodeReferences } from '../reference/codec';
+
+/** 将 StoreWorkflow 转成语义 Document,并保留显式提供的应用绑定信息。 */
+export const decompileStoreWorkflow = ({
+ workflow,
+ app = {}
+}: {
+ workflow: WorkflowTemplateBasicType;
+ app?: WorkflowDocument['app'];
+}): WorkflowDocument => {
+ const parsedWorkflow = WorkflowTemplateBasicTypeSchema.parse(workflow);
+ const documentWithoutEdges = createWorkflowDocument({
+ app,
+ nodes: decodeWorkflowNodeReferences(parsedWorkflow.nodes),
+ chatConfig: parsedWorkflow.chatConfig ?? {}
+ });
+
+ return {
+ ...documentWithoutEdges,
+ executionEdges: parsedWorkflow.edges.map((edge) =>
+ decompileStoreEdge(edge, documentWithoutEdges)
+ )
+ };
+};
diff --git a/packages/workflow-core/src/template/automationMeta.ts b/packages/workflow-core/src/template/automationMeta.ts
new file mode 100644
index 000000000000..aac1bf0d19ad
--- /dev/null
+++ b/packages/workflow-core/src/template/automationMeta.ts
@@ -0,0 +1,277 @@
+import {
+ formatNodeTemplateRef,
+ type NodeTemplateAutomationMeta,
+ type NodeTemplateRef
+} from './type';
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+
+const remoteModelInput = {
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'model'
+} as const;
+
+const automationMetaMap: Record = {
+ 'builtin:ai-chat': {
+ inputs: {
+ [NodeInputKeyEnum.aiModel]: {
+ ...remoteModelInput,
+ agentHint: 'workflow:cli.input.ai_model'
+ },
+ systemPrompt: {
+ agentHint: 'workflow:cli.input.system_prompt',
+ examples: ['You are a helpful assistant.']
+ },
+ userChatInput: {
+ agentHint: 'workflow:cli.input.user_question'
+ }
+ }
+ },
+ 'builtin:text-editor': {
+ inputs: {
+ system_textareaInput: {
+ agentHint: 'workflow:cli.input.text_editor',
+ examples: ['Hello {{name}}']
+ }
+ }
+ },
+ 'builtin:assigned-answer': {
+ inputs: {
+ text: {
+ agentHint: 'workflow:cli.input.answer',
+ examples: ['Done']
+ }
+ }
+ },
+ 'builtin:dataset-search': {
+ inputs: {
+ [NodeInputKeyEnum.datasetSelectList]: {
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'dataset',
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['datasetId', 'name', 'avatar', 'vectorModel'],
+ properties: {
+ datasetId: { type: 'string' },
+ name: { type: 'string' },
+ avatar: { type: 'string' },
+ vectorModel: {
+ type: 'object',
+ required: ['model'],
+ properties: { model: { type: 'string' } }
+ }
+ }
+ }
+ }
+ },
+ [NodeInputKeyEnum.datasetSearchRerankModel]: remoteModelInput,
+ [NodeInputKeyEnum.datasetSearchExtensionModel]: remoteModelInput
+ }
+ },
+ 'builtin:question-optimization': {
+ inputs: {
+ [NodeInputKeyEnum.aiModel]: remoteModelInput
+ }
+ },
+ 'builtin:content-extract': {
+ inputs: {
+ [NodeInputKeyEnum.aiModel]: remoteModelInput,
+ [NodeInputKeyEnum.extractKeys]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'valueType'],
+ properties: {
+ key: { type: 'string' },
+ valueType: { type: 'string' },
+ description: { type: 'string' },
+ required: { type: 'boolean' },
+ enum: { type: 'array', items: { type: 'string' } }
+ }
+ }
+ }
+ }
+ }
+ },
+ 'builtin:http-request': {
+ inputs: {
+ [NodeInputKeyEnum.httpReqUrl]: {
+ defaultPolicy: 'userRequired',
+ bindingRequired: true
+ },
+ [NodeInputKeyEnum.addInputParam]: {
+ configurable: false
+ },
+ [NodeInputKeyEnum.httpHeaders]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'value'],
+ properties: { key: { type: 'string' }, value: { type: 'string' } }
+ }
+ }
+ },
+ [NodeInputKeyEnum.httpParams]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'value'],
+ properties: { key: { type: 'string' }, value: {} }
+ }
+ }
+ },
+ [NodeInputKeyEnum.httpFormBody]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'value'],
+ properties: { key: { type: 'string' }, value: {} }
+ }
+ }
+ },
+ [NodeInputKeyEnum.httpJsonBody]: {
+ valueSchema: { type: 'string' }
+ },
+ [NodeInputKeyEnum.headerSecret]: {
+ configurable: false,
+ defaultPolicy: 'userRequired',
+ resourceKind: 'secret'
+ }
+ }
+ },
+ 'builtin:code': {
+ inputs: {
+ [NodeInputKeyEnum.addInputParam]: {
+ configurable: false
+ }
+ }
+ },
+ 'builtin:call-app': {
+ inputs: {
+ [NodeInputKeyEnum.runAppSelectApp]: {
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'app',
+ valueSchema: {
+ type: 'object',
+ required: ['appId'],
+ properties: { appId: { type: 'string' } }
+ }
+ }
+ }
+ },
+ 'builtin:if-else': {
+ inputs: {
+ [NodeInputKeyEnum.ifElseList]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['condition', 'list'],
+ properties: {
+ branchId: { type: 'string' },
+ condition: { type: 'string', enum: ['AND', 'OR'] },
+ list: { type: 'array', items: { type: 'object' } }
+ }
+ }
+ }
+ }
+ }
+ },
+ 'builtin:question-classification': {
+ inputs: {
+ [NodeInputKeyEnum.aiModel]: remoteModelInput,
+ [NodeInputKeyEnum.agents]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'value'],
+ properties: { key: { type: 'string' }, value: { type: 'string' } }
+ }
+ }
+ }
+ }
+ },
+ 'builtin:user-select': {
+ inputs: {
+ [NodeInputKeyEnum.userSelectOptions]: {
+ valueSchema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['key', 'value'],
+ properties: { key: { type: 'string' }, value: { type: 'string' } }
+ }
+ }
+ }
+ }
+ },
+ 'builtin:form-input': {
+ inputs: {
+ [NodeInputKeyEnum.userInputForms]: {
+ valueSchema: { type: 'array', items: { type: 'object' } }
+ }
+ }
+ },
+ 'builtin:variable-update': {
+ inputs: {
+ [NodeInputKeyEnum.updateList]: {
+ valueSchema: { type: 'array', items: { type: 'object' } }
+ }
+ }
+ },
+ 'builtin:tool-call': {
+ inputs: {
+ [NodeInputKeyEnum.aiModel]: remoteModelInput
+ }
+ },
+ 'builtin:dataset-concat': {
+ inputs: {
+ [NodeInputKeyEnum.datasetQuoteList]: {
+ configurable: false,
+ agentHint: 'workflow:cli.input.dataset_quotes'
+ }
+ }
+ },
+ 'builtin:custom-feedback': {
+ inputs: {
+ [NodeInputKeyEnum.textareaInput]: {
+ agentHint: 'workflow:cli.input.custom_feedback'
+ }
+ }
+ }
+};
+
+const templateRefByFlowNodeType: Partial> = {
+ [FlowNodeTypeEnum.chatNode]: 'builtin:ai-chat',
+ [FlowNodeTypeEnum.textEditor]: 'builtin:text-editor',
+ [FlowNodeTypeEnum.answerNode]: 'builtin:assigned-answer',
+ [FlowNodeTypeEnum.datasetSearchNode]: 'builtin:dataset-search',
+ [FlowNodeTypeEnum.queryExtension]: 'builtin:question-optimization',
+ [FlowNodeTypeEnum.contentExtract]: 'builtin:content-extract',
+ [FlowNodeTypeEnum.httpRequest468]: 'builtin:http-request',
+ [FlowNodeTypeEnum.code]: 'builtin:code',
+ [FlowNodeTypeEnum.runApp]: 'builtin:call-app',
+ [FlowNodeTypeEnum.ifElseNode]: 'builtin:if-else',
+ [FlowNodeTypeEnum.classifyQuestion]: 'builtin:question-classification',
+ [FlowNodeTypeEnum.userSelect]: 'builtin:user-select',
+ [FlowNodeTypeEnum.formInput]: 'builtin:form-input',
+ [FlowNodeTypeEnum.variableUpdate]: 'builtin:variable-update',
+ [FlowNodeTypeEnum.toolCall]: 'builtin:tool-call',
+ [FlowNodeTypeEnum.datasetConcatNode]: 'builtin:dataset-concat',
+ [FlowNodeTypeEnum.customFeedback]: 'builtin:custom-feedback'
+};
+
+export const getAutomationMeta = (ref: NodeTemplateRef) =>
+ automationMetaMap[formatNodeTemplateRef(ref)];
+
+export const getInputAutomationMeta = (flowNodeType: string, inputKey: string) => {
+ const templateRef = templateRefByFlowNodeType[flowNodeType as FlowNodeTypeEnum];
+ return templateRef ? automationMetaMap[templateRef]?.inputs?.[inputKey] : undefined;
+};
diff --git a/packages/workflow-core/src/template/builtin.ts b/packages/workflow-core/src/template/builtin.ts
new file mode 100644
index 000000000000..7b182eb908d2
--- /dev/null
+++ b/packages/workflow-core/src/template/builtin.ts
@@ -0,0 +1,92 @@
+import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiChat';
+import { AssignedAnswerModule } from '@fastgpt/global/core/workflow/template/system/assignedAnswer';
+import { TextEditorNode } from '@fastgpt/global/core/workflow/template/system/textEditor';
+import { WorkflowStart } from '@fastgpt/global/core/workflow/template/system/workflowStart';
+import { SystemConfigNode } from '@fastgpt/global/core/workflow/template/system/systemConfig';
+import { DatasetSearchModule } from '@fastgpt/global/core/workflow/template/system/datasetSearch';
+import { AiQueryExtension } from '@fastgpt/global/core/workflow/template/system/queryExtension';
+import { ContextExtractModule } from '@fastgpt/global/core/workflow/template/system/contextExtract';
+import { HttpNode468 } from '@fastgpt/global/core/workflow/template/system/http468';
+import { CodeNode } from '@fastgpt/global/core/workflow/template/system/sandbox';
+import { RunAppModule } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp';
+import { IfElseNode } from '@fastgpt/global/core/workflow/template/system/ifElse';
+import { ClassifyQuestionModule } from '@fastgpt/global/core/workflow/template/system/classifyQuestion';
+import { UserSelectNode } from '@fastgpt/global/core/workflow/template/system/interactive/userSelect';
+import { FormInputNode } from '@fastgpt/global/core/workflow/template/system/interactive/formInput';
+import { ToolCallNode } from '@fastgpt/global/core/workflow/template/system/toolCall';
+import { ReadFilesNode } from '@fastgpt/global/core/workflow/template/system/readFiles';
+import { VariableUpdateNode } from '@fastgpt/global/core/workflow/template/system/variableUpdate';
+import { ParallelRunNode } from '@fastgpt/global/core/workflow/template/system/parallelRun/parallelRun';
+import { LoopRunNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
+import { LoopRunBreakNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunBreak';
+import { LoopRunStartNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunStart';
+import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
+import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
+import { DatasetConcatModule } from '@fastgpt/global/core/workflow/template/system/datasetConcat';
+import { CustomFeedbackNode } from '@fastgpt/global/core/workflow/template/system/customFeedback';
+import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
+import { WorkflowCommandError } from '../domain/diagnostic';
+import { getAutomationMeta } from './automationMeta';
+import { formatNodeTemplateRef, type NodeTemplateRef, type WorkflowTemplateProvider } from './type';
+
+const builtinTemplates: Record = {
+ // 仅供工作流初始化自动实例化,不在 template list 中暴露。
+ 'builtin:__system-config': SystemConfigNode,
+ 'builtin:workflow-start': WorkflowStart,
+ 'builtin:ai-chat': AiChatModule,
+ 'builtin:text-editor': TextEditorNode,
+ 'builtin:assigned-answer': AssignedAnswerModule,
+ 'builtin:dataset-search': DatasetSearchModule,
+ 'builtin:question-optimization': AiQueryExtension,
+ 'builtin:content-extract': ContextExtractModule,
+ 'builtin:http-request': HttpNode468,
+ 'builtin:code': CodeNode,
+ 'builtin:call-app': RunAppModule,
+ 'builtin:if-else': IfElseNode,
+ 'builtin:question-classification': ClassifyQuestionModule,
+ 'builtin:user-select': UserSelectNode,
+ 'builtin:form-input': FormInputNode,
+ 'builtin:tool-call': ToolCallNode,
+ 'builtin:read-files': ReadFilesNode,
+ 'builtin:variable-update': VariableUpdateNode,
+ 'builtin:parallel-run': ParallelRunNode,
+ 'builtin:loop-run': LoopRunNode,
+ 'builtin:loop-run-break': LoopRunBreakNode,
+ 'builtin:dataset-concat': DatasetConcatModule,
+ 'builtin:custom-feedback': CustomFeedbackNode,
+ // 仅供容器命令自动实例化,不在 template list 中暴露。
+ 'builtin:__nested-start': LoopStartNode,
+ 'builtin:__nested-end': LoopEndNode,
+ 'builtin:__loop-run-start': LoopRunStartNode
+};
+
+export const builtinTemplateRefs: NodeTemplateRef[] = Object.keys(builtinTemplates)
+ .filter((value) => !value.startsWith('builtin:__'))
+ .map((value) => ({
+ kind: 'builtin',
+ templateId: value.slice('builtin:'.length)
+ }));
+
+export const builtinTemplateProvider: WorkflowTemplateProvider = {
+ async list() {
+ return [...builtinTemplateRefs];
+ },
+ async resolve(ref) {
+ const template =
+ ref.kind === 'builtin' ? builtinTemplates[formatNodeTemplateRef(ref)] : undefined;
+ if (!template) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_TEMPLATE_NOT_FOUND',
+ severity: 'error',
+ params: { template: formatNodeTemplateRef(ref) }
+ }
+ ]);
+ }
+ return {
+ // invalidCondition 等函数只参与 Web 展示计算,本来就不会进入 Store JSON。
+ template: JSON.parse(JSON.stringify(template)) as FlowNodeTemplateType,
+ automationMeta: getAutomationMeta(ref)
+ };
+ }
+};
diff --git a/packages/workflow-core/src/template/defaultValue.ts b/packages/workflow-core/src/template/defaultValue.ts
new file mode 100644
index 000000000000..e762209fa89b
--- /dev/null
+++ b/packages/workflow-core/src/template/defaultValue.ts
@@ -0,0 +1,70 @@
+import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
+import type { NodeInputAutomationMeta, WorkflowResourceKind } from './type';
+
+const arrayValueTypes = new Set([
+ WorkflowIOValueTypeEnum.arrayString,
+ WorkflowIOValueTypeEnum.arrayNumber,
+ WorkflowIOValueTypeEnum.arrayBoolean,
+ WorkflowIOValueTypeEnum.arrayObject,
+ WorkflowIOValueTypeEnum.arrayAny,
+ WorkflowIOValueTypeEnum.chatHistory,
+ WorkflowIOValueTypeEnum.datasetQuote,
+ WorkflowIOValueTypeEnum.selectDataset
+]);
+
+const cloneValue = (value: T): T => (value === undefined ? value : structuredClone(value));
+
+/** 返回不会伪造远端资源、且与输入值类型兼容的初始空值。 */
+export const getResourceSafeEmptyValue = ({
+ valueType,
+ resourceKind
+}: {
+ valueType?: string;
+ resourceKind?: WorkflowResourceKind;
+}): unknown => {
+ if (resourceKind === 'dataset' || valueType === WorkflowIOValueTypeEnum.selectDataset) {
+ return [];
+ }
+ if (resourceKind !== undefined) return undefined;
+ return valueType !== undefined && arrayValueTypes.has(valueType) ? [] : undefined;
+};
+
+/**
+ * 解析模板输入的非用户初始值。用户显式覆盖由 Command 在实例化后应用,
+ * 因而 `[]`、空字符串、false 和 0 都不会被默认值覆盖。
+ */
+export const resolveInitialInputValue = ({
+ input,
+ meta,
+ validatedRemoteDefault
+}: {
+ input: FlowNodeInputItemType;
+ meta?: NodeInputAutomationMeta;
+ validatedRemoteDefault?: { provided: true; value: unknown };
+}): unknown => {
+ const defaultPolicy = meta?.defaultPolicy ?? 'template';
+ const acceptsRemoteDefault = defaultPolicy !== 'userRequired' && meta?.resourceKind !== 'secret';
+
+ if (acceptsRemoteDefault && validatedRemoteDefault?.provided === true) {
+ return cloneValue(validatedRemoteDefault.value);
+ }
+
+ // 资源 ID 必须由远端 Provider 验证;原始模板里的资源值不能直接成为本地绑定。
+ if (defaultPolicy === 'template' && meta?.resourceKind === undefined) {
+ const templateDefault = input.defaultValue ?? input.value;
+ if (templateDefault !== undefined) return cloneValue(templateDefault);
+ }
+
+ return getResourceSafeEmptyValue({
+ valueType: input.valueType,
+ resourceKind: meta?.resourceKind
+ });
+};
+
+/** 判断模板/远端初始值是否会阻止后置的 Start 默认引用。 */
+export const hasConfiguredValue = (value: unknown): boolean => {
+ if (value === undefined || value === null || value === '') return false;
+ if (Array.isArray(value)) return value.length > 0;
+ return true;
+};
diff --git a/packages/workflow-core/src/template/descriptor.ts b/packages/workflow-core/src/template/descriptor.ts
new file mode 100644
index 000000000000..06e7660d21f6
--- /dev/null
+++ b/packages/workflow-core/src/template/descriptor.ts
@@ -0,0 +1,133 @@
+import {
+ FlowNodeInputTypeEnum,
+ FlowNodeOutputTypeEnum
+} from '@fastgpt/global/core/workflow/node/constant';
+import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
+import type {
+ NodeTemplateAutomationMeta,
+ NodeTemplateRef,
+ WorkflowInputDefaultPolicy,
+ WorkflowResourceKind
+} from './type';
+
+export type NodeParameterInputMode = 'literal' | 'reference' | 'secret';
+
+export type NodeParameterDescriptor = {
+ key: string;
+ label: string;
+ description: string;
+ valueType?: string;
+ required: boolean;
+ defaultValue?: unknown;
+ defaultPolicy: WorkflowInputDefaultPolicy;
+ resourceKind?: WorkflowResourceKind;
+ bindingRequired: boolean;
+ configurable: boolean;
+ inputModes: NodeParameterInputMode[];
+ enum?: Array<{ label?: string; value: string; description?: string }>;
+ constraints?: {
+ min?: number;
+ max?: number;
+ minLength?: number;
+ maxLength?: number;
+ valueSchema?: Record;
+ };
+ examples?: unknown[];
+};
+
+export type NodeTemplateDescriptor = {
+ template: NodeTemplateRef;
+ name: string;
+ intro?: string;
+ flowNodeType: string;
+ inputs: NodeParameterDescriptor[];
+ outputs: Array<{
+ id: string;
+ key: string;
+ label: string;
+ description?: string;
+ valueType?: string;
+ required: boolean;
+ executable: boolean;
+ }>;
+ constraints: {
+ unique: boolean;
+ isTool: boolean;
+ };
+};
+
+const getInputModes = (renderTypes: FlowNodeInputTypeEnum[]): NodeParameterInputMode[] => {
+ const modes = [
+ renderTypes.some((type) => type !== FlowNodeInputTypeEnum.reference) ? 'literal' : undefined,
+ renderTypes.includes(FlowNodeInputTypeEnum.reference) ? 'reference' : undefined,
+ renderTypes.includes(FlowNodeInputTypeEnum.password) ? 'secret' : undefined
+ ].filter((item): item is NodeParameterInputMode => item !== undefined);
+ return [...new Set(modes)];
+};
+
+/** 将现有模板实时归一化为 CLI/Agent 可读取的参数契约。 */
+export const normalizeNodeTemplateDescriptor = ({
+ template,
+ templateRef,
+ automationMeta,
+ translate = (value) => value
+}: {
+ template: FlowNodeTemplateType;
+ templateRef: NodeTemplateRef;
+ automationMeta?: NodeTemplateAutomationMeta;
+ translate?: (value: string) => string;
+}): NodeTemplateDescriptor => ({
+ template: templateRef,
+ name: translate(template.name),
+ intro: template.intro ? translate(template.intro) : undefined,
+ flowNodeType: template.flowNodeType,
+ inputs: template.inputs
+ .filter((input) => input.deprecated !== true)
+ .map((input) => {
+ const meta = automationMeta?.inputs?.[input.key];
+ const constraints = {
+ min: input.min,
+ max: input.max,
+ minLength: input.minLength,
+ maxLength: input.maxLength,
+ valueSchema: meta?.valueSchema
+ };
+ return {
+ key: input.key,
+ label: translate(input.label),
+ description: translate(
+ meta?.agentHint ?? input.toolDescription ?? input.description ?? input.label
+ ),
+ valueType: input.valueType,
+ required: input.required ?? false,
+ defaultValue: input.defaultValue ?? input.value,
+ defaultPolicy: meta?.defaultPolicy ?? 'template',
+ resourceKind: meta?.resourceKind,
+ bindingRequired: meta?.bindingRequired ?? false,
+ configurable: meta?.configurable ?? input.canEdit !== false,
+ inputModes: getInputModes(input.renderTypeList),
+ enum: input.list?.map((item) => ({
+ ...item,
+ label: item.label ? translate(item.label) : undefined,
+ description: item.description ? translate(item.description) : undefined
+ })),
+ constraints,
+ examples: meta?.examples
+ };
+ }),
+ outputs: template.outputs
+ .filter((output) => output.deprecated !== true)
+ .map((output) => ({
+ id: output.id,
+ key: output.key,
+ label: translate(output.label ?? output.key),
+ description: output.description ? translate(output.description) : undefined,
+ valueType: output.valueType,
+ required: output.required ?? false,
+ executable: output.type === FlowNodeOutputTypeEnum.source
+ })),
+ constraints: {
+ unique: template.unique === true,
+ isTool: template.isTool === true
+ }
+});
diff --git a/packages/workflow-core/src/template/instantiate.ts b/packages/workflow-core/src/template/instantiate.ts
new file mode 100644
index 000000000000..4ef50ca5ac85
--- /dev/null
+++ b/packages/workflow-core/src/template/instantiate.ts
@@ -0,0 +1,160 @@
+import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import {
+ FlowNodeInputTypeEnum,
+ FlowNodeTypeEnum
+} from '@fastgpt/global/core/workflow/node/constant';
+import {
+ StoreNodeItemTypeSchema,
+ type StoreNodeItemType
+} from '@fastgpt/global/core/workflow/type/node';
+import type { WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError, type WorkflowDiagnostic } from '../domain/diagnostic';
+import { areWorkflowValueTypesCompatible } from '../reference/service';
+import { hasConfiguredValue, resolveInitialInputValue } from './defaultValue';
+import type { NodeTemplateRef, WorkflowTemplateProvider } from './type';
+
+/** 从原始 FastGPT 模板创建完整 StoreNode,Descriptor 元数据不会进入运行时对象。 */
+export const instantiateNodeFromTemplate = async ({
+ document,
+ templateRef,
+ nodeId,
+ name,
+ position,
+ parentNodeId,
+ provider,
+ locale,
+ translate = (value) => value
+}: {
+ document: WorkflowDocument;
+ templateRef: NodeTemplateRef;
+ nodeId: string;
+ name?: string;
+ position?: { x: number; y: number };
+ parentNodeId?: string;
+ provider: WorkflowTemplateProvider;
+ locale: string;
+ translate?: (value: string) => string;
+}): Promise<{ node: StoreNodeItemType; warnings: WorkflowDiagnostic[] }> => {
+ if (document.nodes.some((node) => node.nodeId === nodeId)) {
+ throw new WorkflowCommandError([
+ { code: 'WORKFLOW_NODE_ID_DUPLICATED', severity: 'error', nodeId }
+ ]);
+ }
+
+ const { template, automationMeta, validatedInputDefaults } = await provider.resolve(templateRef, {
+ locale
+ });
+ if (
+ template.unique === true &&
+ document.nodes.some(
+ (node) => node.flowNodeType === template.flowNodeType && node.parentNodeId === parentNodeId
+ )
+ ) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_UNIQUE_NODE_EXISTS',
+ severity: 'error',
+ params: { flowNodeType: template.flowNodeType }
+ }
+ ]);
+ }
+
+ const node = StoreNodeItemTypeSchema.parse({
+ ...structuredClone(template),
+ name: name ?? translate(template.name),
+ intro: template.intro ? translate(template.intro) : undefined,
+ nodeId,
+ parentNodeId,
+ position,
+ inputs: template.inputs.map((input) => {
+ const meta = automationMeta?.inputs?.[input.key];
+ const usesSafeDefault =
+ meta?.resourceKind !== undefined ||
+ meta?.defaultPolicy === 'userRequired' ||
+ meta?.defaultPolicy === 'remoteValidated';
+ return {
+ ...structuredClone(input),
+ value: resolveInitialInputValue({
+ input,
+ meta,
+ validatedRemoteDefault: validatedInputDefaults?.[input.key]
+ }),
+ ...(usesSafeDefault ? { defaultValue: undefined } : {}),
+ label: translate(input.label),
+ description: input.description ? translate(input.description) : undefined,
+ placeholder: input.placeholder ? translate(input.placeholder) : undefined,
+ debugLabel: input.debugLabel ? translate(input.debugLabel) : undefined,
+ toolDescription: input.toolDescription ? translate(input.toolDescription) : undefined,
+ list: input.list?.map((item) => ({
+ ...item,
+ label: item.label ? translate(item.label) : item.label,
+ description: item.description ? translate(item.description) : undefined
+ }))
+ };
+ }),
+ outputs: template.outputs.map((output) => ({
+ ...structuredClone(output),
+ label: output.label ? translate(output.label) : output.label,
+ description: output.description ? translate(output.description) : undefined
+ }))
+ });
+
+ const startNode = document.nodes.find(
+ (item) => item.flowNodeType === FlowNodeTypeEnum.workflowStart
+ );
+ if (startNode) {
+ const userInputOutput = startNode.outputs.find(
+ (output) => output.key === NodeOutputKeyEnum.userChatInput
+ );
+ const userFilesOutput = startNode.outputs.find(
+ (output) => output.key === NodeOutputKeyEnum.userFiles
+ );
+ for (const input of node.inputs) {
+ const referenceIndex = input.renderTypeList.indexOf(FlowNodeInputTypeEnum.reference);
+ if (referenceIndex < 0 || hasConfiguredValue(input.value)) continue;
+
+ const referenceDefault = (() => {
+ if (input.key === NodeInputKeyEnum.userChatInput && userInputOutput) {
+ return {
+ value: [startNode.nodeId, userInputOutput.key],
+ outputs: [userInputOutput],
+ collection: false
+ };
+ }
+ if (input.key === NodeInputKeyEnum.fileUrlList && userFilesOutput) {
+ return {
+ value: [[startNode.nodeId, userFilesOutput.key]],
+ outputs: [userFilesOutput],
+ collection: true
+ };
+ }
+ if (input.key === NodeInputKeyEnum.datasetSearchInput && userInputOutput) {
+ return {
+ value: [
+ [startNode.nodeId, userInputOutput.key],
+ ...(userFilesOutput ? [[startNode.nodeId, userFilesOutput.key]] : [])
+ ],
+ outputs: [userInputOutput, ...(userFilesOutput ? [userFilesOutput] : [])],
+ collection: true
+ };
+ }
+ })();
+ if (
+ !referenceDefault ||
+ !referenceDefault.outputs.every((output) =>
+ areWorkflowValueTypesCompatible({
+ expected: input.valueType,
+ actual: output.valueType,
+ collection: referenceDefault.collection
+ })
+ )
+ ) {
+ continue;
+ }
+ input.value = referenceDefault.value;
+ input.selectedTypeIndex = referenceIndex;
+ }
+ }
+
+ return { node, warnings: [] };
+};
diff --git a/packages/workflow-core/src/template/type.ts b/packages/workflow-core/src/template/type.ts
new file mode 100644
index 000000000000..fdd7efebe606
--- /dev/null
+++ b/packages/workflow-core/src/template/type.ts
@@ -0,0 +1,75 @@
+import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
+import z from 'zod';
+
+export const NodeTemplateRefSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('builtin'), templateId: z.string().min(1) }),
+ z.object({
+ kind: z.literal('teamApp'),
+ appId: z.string().min(1),
+ versionId: z.string().optional()
+ }),
+ z.object({
+ kind: z.literal('systemTool'),
+ toolId: z.string().min(1),
+ versionId: z.string().optional()
+ }),
+ z.object({
+ kind: z.literal('tool'),
+ toolId: z.string().min(1),
+ parentId: z.string().optional(),
+ versionId: z.string().optional()
+ })
+]);
+export type NodeTemplateRef = z.infer;
+
+/** CLI 字符串只在适配边界解析,Core 始终接收结构化 TemplateRef。 */
+export const parseNodeTemplateRef = (value: string): NodeTemplateRef => {
+ const separatorIndex = value.indexOf(':');
+ const kind = value.slice(0, separatorIndex);
+ const id = value.slice(separatorIndex + 1);
+ if (separatorIndex <= 0 || !id) return NodeTemplateRefSchema.parse({ kind, templateId: '' });
+ if (kind === 'builtin') return { kind, templateId: id };
+ if (kind === 'teamApp') return { kind, appId: id };
+ if (kind === 'systemTool') return { kind, toolId: id };
+ if (kind === 'tool') return { kind, toolId: id };
+ return NodeTemplateRefSchema.parse({ kind });
+};
+
+export const formatNodeTemplateRef = (ref: NodeTemplateRef) => {
+ if (ref.kind === 'builtin') return `${ref.kind}:${ref.templateId}`;
+ if (ref.kind === 'teamApp') return `${ref.kind}:${ref.appId}`;
+ return `${ref.kind}:${ref.toolId}`;
+};
+
+export type WorkflowInputDefaultPolicy = 'template' | 'userRequired' | 'remoteValidated';
+export type WorkflowResourceKind = 'dataset' | 'model' | 'app' | 'tool' | 'secret';
+
+export type NodeInputAutomationMeta = {
+ configurable?: boolean;
+ agentHint?: string;
+ valueSchema?: Record;
+ examples?: unknown[];
+ defaultPolicy?: WorkflowInputDefaultPolicy;
+ resourceKind?: WorkflowResourceKind;
+ bindingRequired?: boolean;
+};
+
+export type NodeTemplateAutomationMeta = {
+ inputs?: Record;
+};
+
+export type ResolvedWorkflowTemplate = {
+ template: FlowNodeTemplateType;
+ automationMeta?: NodeTemplateAutomationMeta;
+ validatedInputDefaults?: Record;
+};
+
+export type TemplateResolveContext = {
+ locale: string;
+ translate?: (value: string) => string;
+};
+
+export type WorkflowTemplateProvider = {
+ list(context: TemplateResolveContext): Promise;
+ resolve(ref: NodeTemplateRef, context: TemplateResolveContext): Promise;
+};
diff --git a/packages/workflow-core/src/template/valueSchema.ts b/packages/workflow-core/src/template/valueSchema.ts
new file mode 100644
index 000000000000..03355b7281f7
--- /dev/null
+++ b/packages/workflow-core/src/template/valueSchema.ts
@@ -0,0 +1,83 @@
+import { WorkflowCommandError } from '../domain/diagnostic';
+import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+
+type JsonSchema = {
+ type?: string;
+ required?: string[];
+ properties?: Record;
+ items?: JsonSchema;
+ enum?: unknown[];
+ additionalProperties?: boolean;
+};
+
+export const inputValueNeedsSchema = ({
+ value,
+ valueType
+}: {
+ value: unknown;
+ valueType?: string;
+}) =>
+ valueType === WorkflowIOValueTypeEnum.object ||
+ valueType === WorkflowIOValueTypeEnum.arrayObject ||
+ valueType === WorkflowIOValueTypeEnum.arrayAny ||
+ valueType === WorkflowIOValueTypeEnum.selectDataset ||
+ valueType === WorkflowIOValueTypeEnum.selectApp ||
+ (valueType === WorkflowIOValueTypeEnum.any && typeof value === 'object' && value !== null);
+
+export const valueMatchesSchema = (value: unknown, schema: Record): boolean => {
+ const parsedSchema = schema as JsonSchema;
+ if (parsedSchema.enum && !parsedSchema.enum.some((item) => Object.is(item, value))) return false;
+ if (parsedSchema.type === 'string' && typeof value !== 'string') return false;
+ if (parsedSchema.type === 'number' && typeof value !== 'number') return false;
+ if (parsedSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number'))
+ return false;
+ if (parsedSchema.type === 'boolean' && typeof value !== 'boolean') return false;
+ if (parsedSchema.type === 'array') {
+ if (!Array.isArray(value)) return false;
+ return (
+ !parsedSchema.items ||
+ value.every((item) => valueMatchesSchema(item, parsedSchema.items as Record))
+ );
+ }
+ if (parsedSchema.type === 'object') {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const record = value as Record;
+ if (parsedSchema.required?.some((key) => record[key] === undefined)) return false;
+ if (
+ parsedSchema.additionalProperties === false &&
+ Object.keys(record).some((key) => !parsedSchema.properties?.[key])
+ ) {
+ return false;
+ }
+ return Object.entries(parsedSchema.properties ?? {}).every(
+ ([key, itemSchema]) =>
+ record[key] === undefined ||
+ valueMatchesSchema(record[key], itemSchema as Record)
+ );
+ }
+ return true;
+};
+
+/** 校验 Automation Metadata 提供的 JSON Schema 子集。 */
+export const assertValueSchema = ({
+ value,
+ schema,
+ nodeId,
+ inputKey
+}: {
+ value: unknown;
+ schema: Record;
+ nodeId: string;
+ inputKey: string;
+}) => {
+ if (!valueMatchesSchema(value, schema)) {
+ throw new WorkflowCommandError([
+ {
+ code: 'WORKFLOW_INPUT_VALUE_SCHEMA_INVALID',
+ severity: 'error',
+ nodeId,
+ inputKey
+ }
+ ]);
+ }
+};
diff --git a/packages/workflow-core/src/validation/index.ts b/packages/workflow-core/src/validation/index.ts
new file mode 100644
index 000000000000..fc5a69db3380
--- /dev/null
+++ b/packages/workflow-core/src/validation/index.ts
@@ -0,0 +1,562 @@
+import { NodeInputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
+import {
+ FlowNodeInputTypeEnum,
+ FlowNodeTypeEnum,
+ isNestedChildSystemNodeType,
+ isNestedParentNodeType
+} from '@fastgpt/global/core/workflow/node/constant';
+import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import { WorkflowDocumentSchema, type WorkflowDocument } from '../domain/document';
+import { WorkflowCommandError, type WorkflowDiagnostic } from '../domain/diagnostic';
+import { assertExecutionEdge } from '../edge/service';
+import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
+import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
+import { assertParentAssignment } from '../nesting/service';
+import { areWorkflowValueTypesCompatible, valueMatchesType } from '../reference/service';
+import { getInputAutomationMeta } from '../template/automationMeta';
+import { inputValueNeedsSchema, valueMatchesSchema } from '../template/valueSchema';
+
+const isReferenceValue = (value: unknown): value is [string, string] =>
+ Array.isArray(value) &&
+ value.length === 2 &&
+ typeof value[0] === 'string' &&
+ typeof value[1] === 'string';
+
+const hasRequiredValue = (value: unknown) =>
+ value !== undefined &&
+ value !== null &&
+ value !== '' &&
+ (!Array.isArray(value) || value.length > 0);
+
+const getReachableNodeIds = (document: WorkflowDocument, startNodeId: string) => {
+ const reachable = new Set([startNodeId]);
+ const pending = [startNodeId];
+ while (pending.length > 0) {
+ const current = pending.shift()!;
+ for (const edge of document.executionEdges) {
+ if (edge.source.nodeId !== current || reachable.has(edge.target.nodeId)) continue;
+ reachable.add(edge.target.nodeId);
+ pending.push(edge.target.nodeId);
+ }
+ }
+ return reachable;
+};
+
+const validateReference = ({
+ document,
+ node,
+ input,
+ diagnostics
+}: {
+ document: WorkflowDocument;
+ node: StoreNodeItemType;
+ input: StoreNodeItemType['inputs'][number];
+ diagnostics: WorkflowDiagnostic[];
+}) => {
+ const references = isReferenceValue(input.value)
+ ? [input.value]
+ : Array.isArray(input.value) && input.value.every(isReferenceValue)
+ ? input.value
+ : undefined;
+ if (!references) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_FORMAT_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ return;
+ }
+ const collection = !isReferenceValue(input.value);
+
+ for (const [sourceNodeId, outputKey] of references) {
+ if (sourceNodeId === VARIABLE_NODE_ID) {
+ const variable = document.chatConfig.variables?.find((item) => item.key === outputKey);
+ if (!variable) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_OUTPUT_NOT_FOUND',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { sourceNodeId, outputKey }
+ });
+ } else if (
+ !areWorkflowValueTypesCompatible({
+ expected: input.valueType,
+ actual: variable.valueType,
+ collection
+ })
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_TYPE_MISMATCH',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { expected: input.valueType, actual: variable.valueType }
+ });
+ }
+ continue;
+ }
+
+ const sourceNode = document.nodes.find((item) => item.nodeId === sourceNodeId);
+ const sourceOutput = sourceNode?.outputs.find(
+ (output) => output.key === outputKey || output.id === outputKey
+ );
+ if (!sourceNode || !sourceOutput) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_OUTPUT_NOT_FOUND',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { sourceNodeId, outputKey }
+ });
+ continue;
+ }
+ if (
+ !areWorkflowValueTypesCompatible({
+ expected: input.valueType,
+ actual: sourceOutput.valueType,
+ collection
+ })
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_TYPE_MISMATCH',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { expected: input.valueType, actual: sourceOutput.valueType }
+ });
+ }
+
+ const downstream = getReachableNodeIds(document, sourceNodeId);
+ if (sourceNodeId === node.nodeId || !downstream.has(node.nodeId)) {
+ diagnostics.push({
+ code: 'WORKFLOW_REFERENCE_SOURCE_NOT_UPSTREAM',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { sourceNodeId }
+ });
+ }
+ }
+};
+
+const validateNodeIO = ({
+ document,
+ node,
+ diagnostics
+}: {
+ document: WorkflowDocument;
+ node: StoreNodeItemType;
+ diagnostics: WorkflowDiagnostic[];
+}) => {
+ const inputKeys = new Set();
+ for (const input of node.inputs) {
+ if (inputKeys.has(input.key)) {
+ diagnostics.push({
+ code: 'WORKFLOW_INPUT_KEY_DUPLICATED',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ }
+ inputKeys.add(input.key);
+
+ const selectedTypeIndex = input.selectedTypeIndex ?? 0;
+ const selectedType = input.renderTypeList[selectedTypeIndex];
+ if (selectedType === undefined) {
+ diagnostics.push({
+ code: 'WORKFLOW_INPUT_MODE_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ continue;
+ }
+ const isUnusedConditionalLoopArray =
+ node.flowNodeType === FlowNodeTypeEnum.loopRun &&
+ input.key === NodeInputKeyEnum.loopRunInputArray &&
+ node.inputs.find((item) => item.key === NodeInputKeyEnum.loopRunMode)?.value ===
+ LoopRunModeEnum.conditional;
+ const defaultPolicy = getInputAutomationMeta(node.flowNodeType, input.key)?.defaultPolicy;
+ const isExternalBinding =
+ defaultPolicy === 'userRequired' || defaultPolicy === 'remoteValidated';
+ if (
+ node.flowNodeType !== FlowNodeTypeEnum.workflowStart &&
+ input.required === true &&
+ !isUnusedConditionalLoopArray &&
+ !isExternalBinding &&
+ !hasRequiredValue(input.value)
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_REQUIRED_INPUT_MISSING',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ continue;
+ }
+ if (!hasRequiredValue(input.value)) continue;
+
+ if (selectedType === FlowNodeInputTypeEnum.reference) {
+ validateReference({ document, node, input, diagnostics });
+ } else if (!valueMatchesType(input.value, input.valueType)) {
+ diagnostics.push({
+ code: 'WORKFLOW_INPUT_VALUE_TYPE_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key,
+ params: { expected: input.valueType }
+ });
+ } else {
+ const valueSchema = getInputAutomationMeta(node.flowNodeType, input.key)?.valueSchema;
+ if (valueSchema && !valueMatchesSchema(input.value, valueSchema)) {
+ diagnostics.push({
+ code: 'WORKFLOW_INPUT_VALUE_SCHEMA_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ } else if (
+ !valueSchema &&
+ inputValueNeedsSchema({ value: input.value, valueType: input.valueType })
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_TEMPLATE_PARAMETER_SCHEMA_MISSING',
+ severity: 'warning',
+ nodeId: node.nodeId,
+ inputKey: input.key
+ });
+ }
+ }
+ }
+
+ const outputKeys = new Set();
+ for (const output of node.outputs) {
+ if (outputKeys.has(output.key)) {
+ diagnostics.push({
+ code: 'WORKFLOW_OUTPUT_KEY_DUPLICATED',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { outputKey: output.key }
+ });
+ }
+ outputKeys.add(output.key);
+ }
+};
+
+const reachabilityIgnoredNodeTypes = new Set([
+ FlowNodeTypeEnum.systemConfig,
+ FlowNodeTypeEnum.pluginConfig,
+ FlowNodeTypeEnum.comment,
+ FlowNodeTypeEnum.globalVariable,
+ FlowNodeTypeEnum.emptyNode
+]);
+
+const validateContainer = ({
+ document,
+ node,
+ diagnostics
+}: {
+ document: WorkflowDocument;
+ node: StoreNodeItemType;
+ diagnostics: WorkflowDiagnostic[];
+}) => {
+ if (!isNestedParentNodeType(node.flowNodeType)) return;
+ const children = document.nodes.filter((item) => item.parentNodeId === node.nodeId);
+ const listedChildren = node.inputs.find(
+ (item) => item.key === NodeInputKeyEnum.childrenNodeIdList
+ )?.value;
+ const actualIds = children.map((item) => item.nodeId).sort();
+ const listedIds = Array.isArray(listedChildren)
+ ? listedChildren.filter((item): item is string => typeof item === 'string').sort()
+ : [];
+ if (JSON.stringify(actualIds) !== JSON.stringify(listedIds)) {
+ diagnostics.push({
+ code: 'WORKFLOW_CONTAINER_CHILDREN_OUT_OF_SYNC',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+
+ const requiredSystemTypes =
+ node.flowNodeType === FlowNodeTypeEnum.loopRun
+ ? [FlowNodeTypeEnum.loopRunStart]
+ : [FlowNodeTypeEnum.nestedStart, FlowNodeTypeEnum.nestedEnd];
+ for (const systemType of requiredSystemTypes) {
+ const count = children.filter((item) => item.flowNodeType === systemType).length;
+ if (count !== 1) {
+ diagnostics.push({
+ code: 'WORKFLOW_CONTAINER_SYSTEM_CHILD_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { systemType, count }
+ });
+ }
+ }
+
+ if (node.flowNodeType === FlowNodeTypeEnum.loopRun) {
+ const mode = node.inputs.find((item) => item.key === NodeInputKeyEnum.loopRunMode)?.value;
+ if (
+ mode === LoopRunModeEnum.conditional &&
+ !children.some((item) => item.flowNodeType === FlowNodeTypeEnum.loopRunBreak)
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_CONDITIONAL_LOOP_BREAK_REQUIRED',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+};
+
+const validateSpecialNode = ({
+ document,
+ node,
+ diagnostics
+}: {
+ document: WorkflowDocument;
+ node: StoreNodeItemType;
+ diagnostics: WorkflowDiagnostic[];
+}) => {
+ const getInputValue = (key: NodeInputKeyEnum) =>
+ node.inputs.find((item) => item.key === key)?.value;
+
+ if (node.flowNodeType === FlowNodeTypeEnum.ifElseNode) {
+ const branches = getInputValue(NodeInputKeyEnum.ifElseList);
+ const invalid =
+ !Array.isArray(branches) ||
+ branches.length === 0 ||
+ branches.some((branch) => {
+ if (!branch || typeof branch !== 'object') return true;
+ const list = (branch as { list?: unknown }).list;
+ return (
+ !Array.isArray(list) ||
+ list.length === 0 ||
+ list.some((condition) => {
+ if (!condition || typeof condition !== 'object') return true;
+ const value = condition as {
+ variable?: unknown;
+ condition?: VariableConditionEnum;
+ value?: unknown;
+ };
+ return (
+ value.variable === undefined ||
+ value.condition === undefined ||
+ (value.value === undefined &&
+ value.condition !== VariableConditionEnum.isEmpty &&
+ value.condition !== VariableConditionEnum.isNotEmpty)
+ );
+ })
+ );
+ });
+ if (invalid) {
+ diagnostics.push({
+ code: 'WORKFLOW_BRANCH_CONFIGURATION_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+
+ if (
+ node.flowNodeType === FlowNodeTypeEnum.userSelect ||
+ node.flowNodeType === FlowNodeTypeEnum.classifyQuestion
+ ) {
+ const key =
+ node.flowNodeType === FlowNodeTypeEnum.userSelect
+ ? NodeInputKeyEnum.userSelectOptions
+ : NodeInputKeyEnum.agents;
+ const options = getInputValue(key);
+ const optionKeys = Array.isArray(options)
+ ? options.map((item) =>
+ item && typeof item === 'object' ? (item as { key?: unknown }).key : undefined
+ )
+ : [];
+ if (
+ !Array.isArray(options) ||
+ options.length === 0 ||
+ options.some(
+ (item) =>
+ !item ||
+ typeof item !== 'object' ||
+ typeof (item as { key?: unknown }).key !== 'string' ||
+ typeof (item as { value?: unknown }).value !== 'string' ||
+ !(item as { value: string }).value
+ ) ||
+ new Set(optionKeys).size !== optionKeys.length
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_BRANCH_CONFIGURATION_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+
+ if (node.flowNodeType === FlowNodeTypeEnum.formInput) {
+ const forms = getInputValue(NodeInputKeyEnum.userInputForms);
+ const formKeys = Array.isArray(forms)
+ ? forms.map((form) =>
+ form && typeof form === 'object' ? (form as { key?: unknown }).key : undefined
+ )
+ : [];
+ const hasInvalidField =
+ !Array.isArray(forms) ||
+ forms.length === 0 ||
+ formKeys.some((key) => typeof key !== 'string' || !key) ||
+ new Set(formKeys).size !== formKeys.length ||
+ formKeys.some(
+ (key) => typeof key === 'string' && !node.outputs.some((output) => output.key === key)
+ );
+ if (hasInvalidField) {
+ diagnostics.push({
+ code: 'WORKFLOW_FORM_INPUT_CONFIGURATION_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+
+ if (node.flowNodeType === FlowNodeTypeEnum.toolCall) {
+ const hasTool = document.executionEdges.some(
+ (edge) => edge.source.kind === 'selectedTools' && edge.source.nodeId === node.nodeId
+ );
+ const usesSandbox = getInputValue(NodeInputKeyEnum.useAgentSandbox) === true;
+ if (!hasTool && !usesSandbox) {
+ diagnostics.push({
+ code: 'WORKFLOW_TOOL_REQUIRED',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+};
+
+/** 共享 Validator:覆盖线性、分支、工具、动态 IO 和嵌套工作流。 */
+export const validateWorkflow = (document: WorkflowDocument): WorkflowDiagnostic[] => {
+ const schemaResult = WorkflowDocumentSchema.safeParse(document);
+ if (!schemaResult.success) {
+ return schemaResult.error.issues.map((issue) => ({
+ code: 'WORKFLOW_SCHEMA_INVALID',
+ severity: 'error' as const,
+ path: issue.path.map((item) => (typeof item === 'symbol' ? item.toString() : item))
+ }));
+ }
+
+ const diagnostics: WorkflowDiagnostic[] = [];
+ const nodeIds = new Set();
+ for (const node of document.nodes) {
+ if (nodeIds.has(node.nodeId)) {
+ diagnostics.push({
+ code: 'WORKFLOW_NODE_ID_DUPLICATED',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ nodeIds.add(node.nodeId);
+ }
+
+ const systemConfigNodes = document.nodes.filter(
+ (node) => node.flowNodeType === FlowNodeTypeEnum.systemConfig
+ );
+ if (systemConfigNodes.length > 1) {
+ diagnostics.push({
+ code: 'WORKFLOW_SYSTEM_CONFIG_NODE_DUPLICATED',
+ severity: 'error',
+ params: { count: systemConfigNodes.length }
+ });
+ }
+
+ for (const node of document.nodes) {
+ if (node.parentNodeId && !nodeIds.has(node.parentNodeId)) {
+ diagnostics.push({
+ code: 'WORKFLOW_PARENT_NODE_NOT_FOUND',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { parentNodeId: node.parentNodeId }
+ });
+ }
+ if (node.parentNodeId) {
+ const parent = document.nodes.find((item) => item.nodeId === node.parentNodeId);
+ if (parent) {
+ try {
+ assertParentAssignment({
+ document,
+ node,
+ parentNodeId: node.parentNodeId,
+ allowSystemChild: isNestedChildSystemNodeType(node.flowNodeType)
+ });
+ } catch (error) {
+ if (error instanceof WorkflowCommandError) diagnostics.push(...error.diagnostics);
+ }
+ }
+ } else if (
+ node.flowNodeType === FlowNodeTypeEnum.loopRunBreak ||
+ isNestedChildSystemNodeType(node.flowNodeType)
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_SYSTEM_OR_BREAK_PARENT_REQUIRED',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ if (node.pluginData?.error) {
+ diagnostics.push({
+ code: 'WORKFLOW_NODE_CONFIGURATION_INVALID',
+ severity: 'error',
+ nodeId: node.nodeId,
+ params: { error: node.pluginData.error }
+ });
+ }
+ validateNodeIO({ document, node, diagnostics });
+ validateSpecialNode({ document, node, diagnostics });
+ validateContainer({ document, node, diagnostics });
+ }
+
+ const startNodes = document.nodes.filter(
+ (node) => node.flowNodeType === FlowNodeTypeEnum.workflowStart
+ );
+ if (startNodes.length !== 1) {
+ diagnostics.push({
+ code: 'WORKFLOW_START_COUNT_INVALID',
+ severity: 'error',
+ params: { count: startNodes.length }
+ });
+ }
+
+ const edgeKeys = new Set();
+ for (const edge of document.executionEdges) {
+ const edgeKey = JSON.stringify(edge);
+ if (edgeKeys.has(edgeKey)) {
+ diagnostics.push({ code: 'WORKFLOW_EDGE_DUPLICATED', severity: 'error', params: { edge } });
+ }
+ edgeKeys.add(edgeKey);
+ try {
+ assertExecutionEdge(document, edge);
+ } catch (error) {
+ if (error instanceof WorkflowCommandError) diagnostics.push(...error.diagnostics);
+ diagnostics.push({ code: 'WORKFLOW_EDGE_INVALID', severity: 'error', params: { edge } });
+ }
+ }
+
+ const startNode = startNodes[0];
+ if (startNode) {
+ const reachableNodeIds = getReachableNodeIds(document, startNode.nodeId);
+ for (const node of document.nodes) {
+ if (
+ !reachabilityIgnoredNodeTypes.has(node.flowNodeType) &&
+ !node.parentNodeId &&
+ !reachableNodeIds.has(node.nodeId)
+ ) {
+ diagnostics.push({
+ code: 'WORKFLOW_NODE_NOT_REACHABLE',
+ severity: 'error',
+ nodeId: node.nodeId
+ });
+ }
+ }
+ }
+
+ return diagnostics;
+};
diff --git a/packages/workflow-core/test/binding/service.test.ts b/packages/workflow-core/test/binding/service.test.ts
new file mode 100644
index 000000000000..965ffeec5675
--- /dev/null
+++ b/packages/workflow-core/test/binding/service.test.ts
@@ -0,0 +1,119 @@
+import {
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ collectWorkflowBindings,
+ createWorkflowDocument,
+ getWorkflowBindingDiagnostics,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+describe('collectWorkflowBindings', () => {
+ it('reports required and unverified bindings without exposing their values', async () => {
+ const start = await applyWorkflowCommand({
+ document: createWorkflowDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ },
+ dependencies
+ });
+ const search = await applyWorkflowCommand({
+ document: start.document,
+ command: {
+ type: 'node.add',
+ nodeId: 'search',
+ template: parseNodeTemplateRef('builtin:dataset-search'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ },
+ dependencies
+ });
+
+ const missingBindings = collectWorkflowBindings(search.document);
+ expect(missingBindings).toEqual([
+ {
+ nodeId: 'search',
+ inputKey: 'datasets',
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'dataset',
+ status: 'missing'
+ }
+ ]);
+ expect(getWorkflowBindingDiagnostics(missingBindings)).toEqual([
+ {
+ code: 'WORKFLOW_BINDING_REQUIRED',
+ severity: 'warning',
+ nodeId: 'search',
+ inputKey: 'datasets',
+ params: { defaultPolicy: 'remoteValidated', resourceKind: 'dataset' }
+ }
+ ]);
+
+ const datasetInput = search.document.nodes
+ .find((node) => node.nodeId === 'search')!
+ .inputs.find((input) => input.key === 'datasets')!;
+ datasetInput.value = [
+ {
+ datasetId: 'dataset-id',
+ name: 'Dataset',
+ avatar: '',
+ vectorModel: { model: 'embedding-model' }
+ }
+ ];
+ const bindings = collectWorkflowBindings(search.document);
+ expect(bindings).toEqual([
+ {
+ nodeId: 'search',
+ inputKey: 'datasets',
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'dataset',
+ status: 'unverified'
+ }
+ ]);
+ expect(JSON.stringify(bindings)).not.toContain('dataset-id');
+ expect(getWorkflowBindingDiagnostics(bindings)).toEqual([
+ {
+ code: 'WORKFLOW_BINDING_UNVERIFIED',
+ severity: 'warning',
+ nodeId: 'search',
+ inputKey: 'datasets',
+ params: { defaultPolicy: 'remoteValidated', resourceKind: 'dataset' }
+ }
+ ]);
+ });
+
+ it('reports explicitly required user input but ignores optional secrets', async () => {
+ const result = await applyWorkflowCommand({
+ document: createWorkflowDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'http',
+ template: parseNodeTemplateRef('builtin:http-request')
+ },
+ dependencies
+ });
+
+ const bindings = collectWorkflowBindings(result.document);
+ expect(bindings).toEqual([
+ {
+ nodeId: 'http',
+ inputKey: 'system_httpReqUrl',
+ defaultPolicy: 'userRequired',
+ resourceKind: undefined,
+ status: 'missing'
+ }
+ ]);
+ expect(getWorkflowBindingDiagnostics(bindings)).toEqual([
+ {
+ code: 'WORKFLOW_BINDING_REQUIRED',
+ severity: 'warning',
+ nodeId: 'http',
+ inputKey: 'system_httpReqUrl',
+ params: { defaultPolicy: 'userRequired' }
+ }
+ ]);
+ });
+});
diff --git a/packages/workflow-core/test/code/io.test.ts b/packages/workflow-core/test/code/io.test.ts
new file mode 100644
index 000000000000..305b80b4477d
--- /dev/null
+++ b/packages/workflow-core/test/code/io.test.ts
@@ -0,0 +1,67 @@
+import {
+ extractCodeInputDefinitions,
+ extractCodeOutputDefinitions,
+ extractReturnedObjectKeys
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+describe('code IO parser', () => {
+ it('extracts JavaScript inputs and return outputs with documented types', () => {
+ const code = `
+ /**
+ * @param {number} amount - Amount
+ * @param {string} currency - Currency
+ * @property {number} total - Total
+ * @property {object} detail - Detail
+ */
+ function main({ amount, currency = 'CNY' }) {
+ return { total: amount * 2, detail: { amount, currency } };
+ }
+ `;
+
+ expect(extractCodeInputDefinitions(code)).toEqual([
+ { key: 'amount', valueType: 'number' },
+ { key: 'currency', valueType: 'string' }
+ ]);
+ expect(extractCodeOutputDefinitions(code)).toEqual([
+ { key: 'total', valueType: 'number' },
+ { key: 'detail', valueType: 'object' }
+ ]);
+ });
+
+ it('extracts Python inputs and ignores non-static returned properties', () => {
+ const code = `
+ def main(question, count=1):
+ # return {"ignored": True}
+ return {"items": [question] * count, **extra}
+ `;
+
+ expect(extractCodeInputDefinitions(code)).toEqual([
+ { key: 'question', valueType: undefined },
+ { key: 'count', valueType: undefined }
+ ]);
+ expect(extractReturnedObjectKeys(code)).toEqual(['items']);
+ });
+
+ it('distinguishes empty static IO from code that cannot be parsed safely', () => {
+ expect(extractCodeInputDefinitions('function main() { return {}; }')).toEqual([]);
+ expect(extractCodeOutputDefinitions('function main() { return {}; }')).toEqual([]);
+ expect(extractCodeInputDefinitions('function main(args) { return getResult(args); }')).toBe(
+ undefined
+ );
+ expect(extractCodeOutputDefinitions('function main(args) { return getResult(args); }')).toBe(
+ undefined
+ );
+ });
+
+ it('ignores template-like characters inside regular expression literals', () => {
+ expect(
+ extractCodeOutputDefinitions(`
+ function main({ text }) {
+ const parsed = String(text).replace(/^\`\`\`json|^\`\`\`|\`\`\`$/g, '');
+ return { parsed };
+ }
+ `)
+ ).toEqual([{ key: 'parsed', valueType: undefined }]);
+ });
+});
diff --git a/packages/workflow-core/test/command/additionalBuiltins.test.ts b/packages/workflow-core/test/command/additionalBuiltins.test.ts
new file mode 100644
index 000000000000..c9600387374c
--- /dev/null
+++ b/packages/workflow-core/test/command/additionalBuiltins.test.ts
@@ -0,0 +1,185 @@
+import {
+ FlowNodeInputTypeEnum,
+ WorkflowCommandError,
+ WorkflowIOValueTypeEnum,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ compileStoreWorkflow,
+ createWorkflowDocument,
+ decompileStoreWorkflow,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+const addNode = async ({
+ document,
+ nodeId,
+ template,
+ after
+}: {
+ document: ReturnType;
+ nodeId: string;
+ template: string;
+ after?: string;
+}) =>
+ (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId,
+ template: parseNodeTemplateRef(template),
+ connectFrom: after ? { kind: 'next', nodeId: after } : undefined
+ },
+ dependencies
+ })
+ ).document;
+
+describe('additional builtin nodes', () => {
+ it('configures dataset concat through generic dynamic input commands', async () => {
+ let document = await addNode({
+ document: createWorkflowDocument(),
+ nodeId: 'start',
+ template: 'builtin:workflow-start'
+ });
+ document = await addNode({
+ document,
+ nodeId: 'search',
+ template: 'builtin:dataset-search',
+ after: 'start'
+ });
+ document = await addNode({
+ document,
+ nodeId: 'concat',
+ template: 'builtin:dataset-concat',
+ after: 'search'
+ });
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.add',
+ nodeId: 'concat',
+ input: {
+ key: 'quote_1',
+ label: 'Quote 1',
+ valueType: WorkflowIOValueTypeEnum.datasetQuote,
+ renderTypeList: [FlowNodeInputTypeEnum.reference],
+ required: true,
+ canEdit: true
+ }
+ },
+ dependencies
+ })
+ ).document;
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.ref',
+ nodeId: 'concat',
+ inputKey: 'quote_1',
+ ref: { nodeId: 'search', outputKey: 'quoteQA' }
+ },
+ dependencies
+ })
+ ).document;
+
+ expect(document.nodes.find((node) => node.nodeId === 'concat')?.inputs).toContainEqual(
+ expect.objectContaining({
+ key: 'quote_1',
+ value: ['search', 'quoteQA'],
+ canEdit: true
+ })
+ );
+ const storeWorkflow = compileStoreWorkflow(document);
+ expect(compileStoreWorkflow(decompileStoreWorkflow({ workflow: storeWorkflow }))).toEqual(
+ storeWorkflow
+ );
+
+ const removed = await applyWorkflowCommand({
+ document,
+ command: { type: 'input.remove', nodeId: 'concat', inputKey: 'quote_1' },
+ dependencies
+ });
+ expect(
+ removed.document.nodes
+ .find((node) => node.nodeId === 'concat')
+ ?.inputs.some((input) => input.key === 'quote_1')
+ ).toBe(false);
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.remove',
+ nodeId: 'concat',
+ inputKey: 'system_datasetQuoteList'
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+
+ it('creates and configures custom feedback through the standard input command', async () => {
+ let document = await addNode({
+ document: createWorkflowDocument(),
+ nodeId: 'start',
+ template: 'builtin:workflow-start'
+ });
+ document = await addNode({
+ document,
+ nodeId: 'feedback',
+ template: 'builtin:custom-feedback',
+ after: 'start'
+ });
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'feedback',
+ inputKey: 'system_textareaInput',
+ value: 'Accurate answer'
+ },
+ dependencies
+ })
+ ).document;
+ expect(document.nodes.find((node) => node.nodeId === 'feedback')).toMatchObject({
+ flowNodeType: 'customFeedback',
+ inputs: [expect.objectContaining({ value: 'Accurate answer' })]
+ });
+ });
+
+ it('rejects dynamic inputs on nodes without a dynamic input marker', async () => {
+ let document = await addNode({
+ document: createWorkflowDocument(),
+ nodeId: 'start',
+ template: 'builtin:workflow-start'
+ });
+ document = await addNode({
+ document,
+ nodeId: 'feedback',
+ template: 'builtin:custom-feedback',
+ after: 'start'
+ });
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.add',
+ nodeId: 'feedback',
+ input: {
+ key: 'extra',
+ label: 'Extra',
+ valueType: WorkflowIOValueTypeEnum.string,
+ renderTypeList: [FlowNodeInputTypeEnum.input],
+ canEdit: true
+ }
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/command/apply.test.ts b/packages/workflow-core/test/command/apply.test.ts
new file mode 100644
index 000000000000..36b5dadae3a0
--- /dev/null
+++ b/packages/workflow-core/test/command/apply.test.ts
@@ -0,0 +1,143 @@
+import {
+ WorkflowCommandError,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+const createStartedDocument = async () =>
+ (
+ await applyWorkflowCommand({
+ document: createWorkflowDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ },
+ dependencies
+ })
+ ).document;
+
+describe('applyWorkflowCommand', () => {
+ it('adds and connects a node without mutating the input document', async () => {
+ const document = await createStartedDocument();
+ const before = structuredClone(document);
+ const result = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'ai',
+ template: parseNodeTemplateRef('builtin:ai-chat'),
+ connectFrom: { kind: 'next', nodeId: 'start' },
+ inputOverrides: { systemPrompt: 'Be concise' }
+ },
+ dependencies
+ });
+
+ expect(document).toEqual(before);
+ expect(result.document.nodes).toHaveLength(2);
+ expect(result.document.executionEdges).toEqual([
+ {
+ source: { kind: 'next', nodeId: 'start' },
+ target: { kind: 'target', nodeId: 'ai' }
+ }
+ ]);
+ expect(result.changes).toEqual([{ type: 'node.add', nodeId: 'ai' }]);
+ });
+
+ it('applies explicit empty, false and zero overrides after template and Start defaults', async () => {
+ const result = await applyWorkflowCommand({
+ document: await createStartedDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'ai',
+ template: parseNodeTemplateRef('builtin:ai-chat'),
+ inputOverrides: {
+ userChatInput: '',
+ isResponseAnswerText: false,
+ maxToken: 0,
+ fileUrlList: []
+ }
+ },
+ dependencies
+ });
+ const ai = result.document.nodes.find((node) => node.nodeId === 'ai')!;
+ expect(ai.inputs.find((input) => input.key === 'userChatInput')?.value).toBe('');
+ expect(ai.inputs.find((input) => input.key === 'isResponseAnswerText')?.value).toBe(false);
+ expect(ai.inputs.find((input) => input.key === 'maxToken')?.value).toBe(0);
+ expect(ai.inputs.find((input) => input.key === 'fileUrlList')?.value).toEqual([]);
+ });
+
+ it('sets literal and reference inputs through the same dispatcher', async () => {
+ const document = (
+ await applyWorkflowCommand({
+ document: await createStartedDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'answer',
+ template: parseNodeTemplateRef('builtin:assigned-answer'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ },
+ dependencies
+ })
+ ).document;
+ const literal = await applyWorkflowCommand({
+ document,
+ command: { type: 'input.set', nodeId: 'answer', inputKey: 'text', value: 'done' },
+ dependencies
+ });
+ expect(literal.document.nodes[1].inputs[0].value).toBe('done');
+
+ const reference = await applyWorkflowCommand({
+ document: literal.document,
+ command: {
+ type: 'input.ref',
+ nodeId: 'answer',
+ inputKey: 'text',
+ ref: { nodeId: 'start', outputKey: 'userChatInput' }
+ },
+ dependencies
+ });
+ expect(reference.document.nodes[1].inputs[0].value).toEqual(['start', 'userChatInput']);
+ });
+
+ it('does not expose a partial document when a command fails', async () => {
+ const document = await createStartedDocument();
+ const before = structuredClone(document);
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'ai',
+ template: parseNodeTemplateRef('builtin:ai-chat'),
+ inputOverrides: { missing: true }
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ expect(document).toEqual(before);
+ });
+
+ it('rejects an invalid connectFrom before returning the new node', async () => {
+ const document = await createStartedDocument();
+ const before = structuredClone(document);
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'ai',
+ template: parseNodeTemplateRef('builtin:ai-chat'),
+ connectFrom: { kind: 'next', nodeId: 'missing' }
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ expect(document).toEqual(before);
+ });
+});
diff --git a/packages/workflow-core/test/command/codeIo.test.ts b/packages/workflow-core/test/command/codeIo.test.ts
new file mode 100644
index 000000000000..134d6b4262bc
--- /dev/null
+++ b/packages/workflow-core/test/command/codeIo.test.ts
@@ -0,0 +1,168 @@
+import {
+ FlowNodeOutputTypeEnum,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+describe('code node IO synchronization', () => {
+ it('uses main parameters and return keys as the editable IO source of truth', async () => {
+ let document = createWorkflowDocument();
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'code',
+ template: parseNodeTemplateRef('builtin:code')
+ },
+ dependencies
+ })
+ ).document;
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'answer',
+ template: parseNodeTemplateRef('builtin:assigned-answer'),
+ connectFrom: { kind: 'next', nodeId: 'code' }
+ },
+ dependencies
+ })
+ ).document;
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.ref',
+ nodeId: 'answer',
+ inputKey: 'text',
+ ref: { nodeId: 'code', outputKey: 'result' }
+ },
+ dependencies
+ })
+ ).document;
+
+ const result = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'code',
+ inputKey: 'code',
+ value: `
+ /**
+ * @param {number} amount - Amount
+ * @property {number} total - Total
+ * @property {object} detail - Detail
+ */
+ function main({ amount }) {
+ return { total: amount * 2, detail: { amount } };
+ }
+ `
+ },
+ dependencies
+ });
+
+ const codeNode = result.document.nodes.find((node) => node.nodeId === 'code')!;
+ expect(codeNode.inputs.map((input) => input.key)).not.toEqual(
+ expect.arrayContaining(['data1', 'data2'])
+ );
+ expect(codeNode.inputs).toContainEqual(
+ expect.objectContaining({ key: 'amount', valueType: 'number', canEdit: true })
+ );
+ expect(
+ codeNode.outputs
+ .filter(
+ (output) =>
+ output.type === FlowNodeOutputTypeEnum.dynamic && output.key !== 'system_addOutputParam'
+ )
+ .map((output) => ({ key: output.key, valueType: output.valueType }))
+ ).toEqual([
+ { key: 'total', valueType: 'number' },
+ { key: 'detail', valueType: 'object' }
+ ]);
+ expect(result.warnings).toContainEqual(
+ expect.objectContaining({
+ code: 'WORKFLOW_OUTPUT_REFERENCES_REMAIN',
+ params: expect.objectContaining({ outputKey: 'result' })
+ })
+ );
+ });
+
+ it('keeps existing IO when the code shape cannot be identified safely', async () => {
+ let document = createWorkflowDocument();
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'code',
+ template: parseNodeTemplateRef('builtin:code')
+ },
+ dependencies
+ })
+ ).document;
+ const previousNode = structuredClone(document.nodes.find((node) => node.nodeId === 'code'));
+
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'code',
+ inputKey: 'code',
+ value: 'function main(args) { return getResult(args); }'
+ },
+ dependencies
+ })
+ ).document;
+
+ const nextNode = document.nodes.find((node) => node.nodeId === 'code')!;
+ expect(nextNode.inputs.filter((input) => input.canEdit)).toEqual(
+ previousNode?.inputs.filter((input) => input.canEdit)
+ );
+ expect(nextNode.outputs).toEqual(previousNode?.outputs);
+ });
+
+ it('removes all editable IO for an explicitly empty signature and return object', async () => {
+ let document = createWorkflowDocument();
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'code',
+ template: parseNodeTemplateRef('builtin:code')
+ },
+ dependencies
+ })
+ ).document;
+
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'code',
+ inputKey: 'code',
+ value: 'function main() { return {}; }'
+ },
+ dependencies
+ })
+ ).document;
+
+ const codeNode = document.nodes.find((node) => node.nodeId === 'code')!;
+ expect(codeNode.inputs.filter((input) => input.canEdit)).toHaveLength(0);
+ expect(
+ codeNode.outputs.filter(
+ (output) =>
+ output.type === FlowNodeOutputTypeEnum.dynamic && output.key !== 'system_addOutputParam'
+ )
+ ).toHaveLength(0);
+ });
+});
diff --git a/packages/workflow-core/test/command/pr2.test.ts b/packages/workflow-core/test/command/pr2.test.ts
new file mode 100644
index 000000000000..21de02f2c79a
--- /dev/null
+++ b/packages/workflow-core/test/command/pr2.test.ts
@@ -0,0 +1,225 @@
+import {
+ FlowNodeInputTypeEnum,
+ VARIABLE_NODE_ID,
+ VariableInputEnum,
+ WorkflowCommandError,
+ WorkflowIOValueTypeEnum,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ getAvailableInputReferences,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+const apply = async (
+ document: ReturnType,
+ command: Parameters[0]['command']
+) => (await applyWorkflowCommand({ document, command, dependencies })).document;
+
+const createLinearDocument = async () => {
+ let document = createWorkflowDocument();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ });
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'text',
+ template: parseNodeTemplateRef('builtin:text-editor'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ });
+ document = await apply(document, {
+ type: 'input.set',
+ nodeId: 'text',
+ inputKey: 'system_textareaInput',
+ value: 'hello'
+ });
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'answer',
+ template: parseNodeTemplateRef('builtin:assigned-answer'),
+ connectFrom: { kind: 'next', nodeId: 'text' }
+ });
+ return apply(document, {
+ type: 'input.ref',
+ nodeId: 'answer',
+ inputKey: 'text',
+ ref: { nodeId: 'text', outputKey: 'system_text' }
+ });
+};
+
+describe('PR2 workflow commands', () => {
+ it('updates, moves, clones and removes nodes with graph side effects', async () => {
+ const document = await createLinearDocument();
+ document.nodes
+ .find((node) => node.nodeId === 'text')!
+ .inputs[0].renderTypeList.push(FlowNodeInputTypeEnum.password);
+ const cloneResult = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.clone',
+ sourceNodeId: 'text',
+ nodeId: 'text-copy',
+ position: { x: 400, y: 100 }
+ },
+ dependencies
+ });
+ expect(cloneResult.document.nodes.find((node) => node.nodeId === 'text-copy')).toMatchObject({
+ nodeId: 'text-copy',
+ position: { x: 400, y: 100 }
+ });
+ expect(
+ cloneResult.document.nodes.find((node) => node.nodeId === 'text-copy')!.inputs[0].value
+ ).toBeUndefined();
+
+ const updated = await applyWorkflowCommand({
+ document: cloneResult.document,
+ command: { type: 'node.update', nodeId: 'answer', name: 'Final answer' },
+ dependencies
+ });
+ expect(updated.document.nodes.find((node) => node.nodeId === 'answer')?.name).toBe(
+ 'Final answer'
+ );
+
+ const removed = await applyWorkflowCommand({
+ document: updated.document,
+ command: { type: 'node.remove', nodeId: 'text' },
+ dependencies
+ });
+ expect(removed.document.executionEdges).toHaveLength(0);
+ expect(
+ removed.document.nodes.find((node) => node.nodeId === 'answer')!.inputs[0].value
+ ).toBeUndefined();
+ expect(removed.changes[0].details).toMatchObject({ removedEdgeCount: 2 });
+ });
+
+ it('connects, disconnects and atomically reconnects normal edges', async () => {
+ let document = await createLinearDocument();
+ document = await apply(document, {
+ type: 'edge.disconnect',
+ edge: {
+ source: { kind: 'next', nodeId: 'text' },
+ target: { kind: 'target', nodeId: 'answer' }
+ }
+ });
+ document = await apply(document, {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'next', nodeId: 'start' },
+ target: { kind: 'target', nodeId: 'answer' }
+ }
+ });
+ document = await apply(document, {
+ type: 'edge.reconnect',
+ oldEdge: {
+ source: { kind: 'next', nodeId: 'start' },
+ target: { kind: 'target', nodeId: 'answer' }
+ },
+ newEdge: {
+ source: { kind: 'next', nodeId: 'text' },
+ target: { kind: 'target', nodeId: 'answer' }
+ }
+ });
+ expect(document.executionEdges).toHaveLength(2);
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: { type: 'edge.connect', edge: document.executionEdges[0] },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+
+ it('manages ChatConfig and global variables and blocks referenced removal', async () => {
+ let document = await createLinearDocument();
+ document = await apply(document, {
+ type: 'config.set',
+ path: 'questionGuide.open',
+ value: true
+ });
+ document = await apply(document, {
+ type: 'variable.add',
+ variable: {
+ key: 'tenantId',
+ label: 'Tenant ID',
+ description: 'Current tenant',
+ type: VariableInputEnum.input,
+ valueType: WorkflowIOValueTypeEnum.string,
+ required: true
+ }
+ });
+ document = await apply(document, {
+ type: 'input.ref',
+ nodeId: 'answer',
+ inputKey: 'text',
+ ref: { nodeId: VARIABLE_NODE_ID, outputKey: 'tenantId' }
+ });
+ expect(document.chatConfig.questionGuide?.open).toBe(true);
+ expect(
+ getAvailableInputReferences({ document, nodeId: 'answer', inputKey: 'text' })
+ ).toContainEqual(
+ expect.objectContaining({
+ ref: { nodeId: VARIABLE_NODE_ID, outputKey: 'tenantId' },
+ source: 'variable'
+ })
+ );
+ document = await apply(document, {
+ type: 'variable.update',
+ key: 'tenantId',
+ patch: { key: 'currentTenantId' }
+ });
+ expect(document.nodes.find((node) => node.nodeId === 'answer')!.inputs[0].value).toEqual([
+ VARIABLE_NODE_ID,
+ 'currentTenantId'
+ ]);
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: { type: 'variable.remove', key: 'currentTenantId' },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+
+ it('validates common complex parameters and protects system-maintained inputs', async () => {
+ let document = await createLinearDocument();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'http',
+ template: parseNodeTemplateRef('builtin:http-request')
+ });
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'http',
+ inputKey: 'system_httpHeader',
+ value: { key: 'x-token', value: 'secret' }
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ document = await apply(document, {
+ type: 'input.set',
+ nodeId: 'http',
+ inputKey: 'system_httpHeader',
+ value: [{ key: 'x-token', value: 'secret' }]
+ });
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'http',
+ inputKey: 'addInputParam',
+ value: []
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/command/pr3.test.ts b/packages/workflow-core/test/command/pr3.test.ts
new file mode 100644
index 000000000000..cf81fa7dbfe9
--- /dev/null
+++ b/packages/workflow-core/test/command/pr3.test.ts
@@ -0,0 +1,272 @@
+import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import {
+ WorkflowCommandError,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ compileStoreWorkflow,
+ decompileStoreWorkflow,
+ parseNodeTemplateRef,
+ validateWorkflow
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+import {
+ createBranchingFixture,
+ createDynamicIoCatchFixture,
+ createNestedLoopFixture,
+ createToolCallToolsFixture,
+ pr3FixtureFactories
+} from '../fixtures/pr3';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+describe('PR3 workflow commands', () => {
+ it('builds four complex golden fixtures and preserves StoreWorkflow semantics', async () => {
+ for (const [name, factory] of Object.entries(pr3FixtureFactories)) {
+ const document = await factory();
+ expect(validateWorkflow(document), name).toEqual([]);
+ const storeWorkflow = compileStoreWorkflow(document);
+ expect(
+ compileStoreWorkflow(decompileStoreWorkflow({ workflow: storeWorkflow })),
+ name
+ ).toEqual(storeWorkflow);
+ }
+ });
+
+ it('connects stable branch ports and rejects unknown branch keys', async () => {
+ const document = await createBranchingFixture();
+ await expect(
+ applyWorkflowCommand({
+ document,
+ command: {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'branch', nodeId: 'route', branchKey: 'missing' },
+ target: { kind: 'target', nodeId: 'yes' }
+ }
+ },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+
+ it('inserts a node atomically between an existing complex edge', async () => {
+ const document = await createBranchingFixture();
+ const result = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.insert',
+ nodeId: 'middle',
+ template: parseNodeTemplateRef('builtin:text-editor'),
+ from: { kind: 'branch', nodeId: 'route', branchKey: 'positive' },
+ to: { kind: 'target', nodeId: 'yes' }
+ },
+ dependencies
+ });
+ expect(result.document.executionEdges).toContainEqual({
+ source: { kind: 'branch', nodeId: 'route', branchKey: 'positive' },
+ target: { kind: 'target', nodeId: 'middle' }
+ });
+ expect(result.document.executionEdges).toContainEqual({
+ source: { kind: 'next', nodeId: 'middle' },
+ target: { kind: 'target', nodeId: 'yes' }
+ });
+ expect(document.nodes.some((node) => node.nodeId === 'middle')).toBe(false);
+
+ const branchInsert = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.insert',
+ nodeId: 'outer-route',
+ template: parseNodeTemplateRef('builtin:if-else'),
+ from: { kind: 'next', nodeId: 'start' },
+ to: { kind: 'target', nodeId: 'route' }
+ },
+ dependencies
+ });
+ expect(branchInsert.document.executionEdges).toContainEqual({
+ source: { kind: 'branch', nodeId: 'outer-route', branchKey: 'ELSE' },
+ target: { kind: 'target', nodeId: 'route' }
+ });
+ });
+
+ it('attaches and detaches tool nodes through selectedTools ports', async () => {
+ const document = await createToolCallToolsFixture();
+ expect(document.executionEdges).toContainEqual({
+ source: { kind: 'selectedTools', nodeId: 'caller' },
+ target: { kind: 'selectedTools', nodeId: 'confirm' }
+ });
+ const detached = await applyWorkflowCommand({
+ document,
+ command: { type: 'tool.detach', toolCallNodeId: 'caller', toolNodeId: 'confirm' },
+ dependencies
+ });
+ expect(detached.document.executionEdges).toHaveLength(1);
+ });
+
+ it('creates system children, moves nodes between scopes and cascades container removal', async () => {
+ let document = await createNestedLoopFixture();
+ expect(document.nodes.find((node) => node.nodeId === 'loop__start')).toMatchObject({
+ parentNodeId: 'loop',
+ flowNodeType: 'loopRunStart'
+ });
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'inside',
+ template: parseNodeTemplateRef('builtin:text-editor'),
+ parentNodeId: 'loop'
+ },
+ dependencies
+ })
+ ).document;
+ const moved = await applyWorkflowCommand({
+ document,
+ command: { type: 'node.move', nodeId: 'inside', parentNodeId: null },
+ dependencies
+ });
+ expect(
+ moved.document.nodes.find((node) => node.nodeId === 'inside')?.parentNodeId
+ ).toBeUndefined();
+ const removed = await applyWorkflowCommand({
+ document: moved.document,
+ command: { type: 'node.remove', nodeId: 'loop' },
+ dependencies
+ });
+ expect(removed.document.nodes.map((node) => node.nodeId)).not.toContain('loop__start');
+ });
+
+ it('removes source-output edges and reports remaining data references', async () => {
+ let document = await createDynamicIoCatchFixture();
+ const score = document.nodes
+ .find((node) => node.nodeId === 'code')!
+ .outputs.find((output) => output.key === 'score')!;
+ score.type = FlowNodeOutputTypeEnum.source;
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'sourceOutput', nodeId: 'code', outputKey: 'score' },
+ target: { kind: 'target', nodeId: 'recover' }
+ }
+ },
+ dependencies
+ })
+ ).document;
+ document.nodes.find((node) => node.nodeId === 'recover')!.inputs[0].value = ['code', 'score'];
+ const result = await applyWorkflowCommand({
+ document,
+ command: { type: 'output.remove', nodeId: 'code', outputKey: 'score' },
+ dependencies
+ });
+ expect(result.document.executionEdges.some((edge) => edge.source.kind === 'sourceOutput')).toBe(
+ false
+ );
+ expect(result.warnings).toContainEqual(
+ expect.objectContaining({ code: 'WORKFLOW_OUTPUT_REFERENCES_REMAIN' })
+ );
+ });
+
+ it('cleans branch and catch edges when their source configuration is disabled', async () => {
+ let branching = await createBranchingFixture();
+ const branchResult = await applyWorkflowCommand({
+ document: branching,
+ command: {
+ type: 'input.set',
+ nodeId: 'route',
+ inputKey: 'ifElseList',
+ value: [
+ {
+ branchId: 'replacement',
+ condition: 'AND',
+ list: [
+ {
+ variable: ['start', 'userChatInput'],
+ condition: 'isNotEmpty',
+ valueType: 'input'
+ }
+ ]
+ }
+ ]
+ },
+ dependencies
+ });
+ branching = branchResult.document;
+ expect(
+ branching.executionEdges.some(
+ (edge) => edge.source.kind === 'branch' && edge.source.branchKey === 'positive'
+ )
+ ).toBe(false);
+ expect(branchResult.warnings).toContainEqual(
+ expect.objectContaining({ code: 'WORKFLOW_BRANCH_EDGES_REMOVED' })
+ );
+
+ const caught = await createDynamicIoCatchFixture();
+ const catchResult = await applyWorkflowCommand({
+ document: caught,
+ command: { type: 'node.update', nodeId: 'code', catchError: false },
+ dependencies
+ });
+ expect(catchResult.document.executionEdges.some((edge) => edge.source.kind === 'catch')).toBe(
+ false
+ );
+ });
+
+ it('synchronizes form fields to outputs and protects required system children', async () => {
+ let document = await createToolCallToolsFixture();
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'node.add',
+ nodeId: 'form',
+ template: parseNodeTemplateRef('builtin:form-input')
+ },
+ dependencies
+ })
+ ).document;
+ document = (
+ await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'input.set',
+ nodeId: 'form',
+ inputKey: 'userInputForms',
+ value: [
+ {
+ type: 'input',
+ key: 'email',
+ label: 'Email',
+ value: '',
+ valueType: 'string',
+ required: true
+ }
+ ]
+ },
+ dependencies
+ })
+ ).document;
+ expect(document.nodes.find((node) => node.nodeId === 'form')?.outputs).toContainEqual(
+ expect.objectContaining({ key: 'email', valueType: 'string' })
+ );
+
+ const nested = await createNestedLoopFixture();
+ await expect(
+ applyWorkflowCommand({
+ document: nested,
+ command: { type: 'node.remove', nodeId: 'loop__start' },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ await expect(
+ applyWorkflowCommand({
+ document: nested,
+ command: { type: 'node.remove', nodeId: 'break' },
+ dependencies
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/command/systemConfig.test.ts b/packages/workflow-core/test/command/systemConfig.test.ts
new file mode 100644
index 000000000000..bdaa731cd9dd
--- /dev/null
+++ b/packages/workflow-core/test/command/systemConfig.test.ts
@@ -0,0 +1,104 @@
+import {
+ FlowNodeTypeEnum,
+ VariableInputEnum,
+ WorkflowCommandError,
+ WorkflowIOValueTypeEnum,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createDefaultWorkflowDocument,
+ createWorkflowDocument,
+ ensureSystemConfigNode,
+ validateWorkflow
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+describe('system config node', () => {
+ it('creates system config and workflow start as the default workflow structure', async () => {
+ const result = await createDefaultWorkflowDocument({
+ app: { name: 'Default workflow' },
+ dependencies
+ });
+
+ expect(result.nodeIds).toEqual(['userGuide', 'start']);
+ expect(result.document.nodes).toEqual([
+ expect.objectContaining({
+ nodeId: 'userGuide',
+ flowNodeType: FlowNodeTypeEnum.systemConfig
+ }),
+ expect.objectContaining({
+ nodeId: 'start',
+ flowNodeType: FlowNodeTypeEnum.workflowStart
+ })
+ ]);
+ });
+
+ it('keeps variables in chatConfig while the system config node provides the editor entry', async () => {
+ const { document } = await createDefaultWorkflowDocument({ dependencies });
+ const result = await applyWorkflowCommand({
+ document,
+ command: {
+ type: 'variable.add',
+ variable: {
+ key: 'tenantId',
+ label: 'Tenant ID',
+ description: '',
+ type: VariableInputEnum.input,
+ valueType: WorkflowIOValueTypeEnum.string,
+ required: true
+ }
+ },
+ dependencies
+ });
+
+ expect(result.document.chatConfig.variables).toEqual([
+ expect.objectContaining({ key: 'tenantId' })
+ ]);
+ expect(
+ result.document.nodes.find((node) => node.flowNodeType === FlowNodeTypeEnum.systemConfig)
+ ?.inputs
+ ).toEqual([]);
+ });
+
+ it('adds a missing system config node once without overwriting chatConfig', async () => {
+ const document = createWorkflowDocument({ chatConfig: { welcomeText: 'Keep me' } });
+
+ const first = await ensureSystemConfigNode({ document, dependencies });
+ const second = await ensureSystemConfigNode({ document, dependencies });
+
+ expect(first.nodeIds).toEqual(['userGuide']);
+ expect(second.nodeIds).toEqual([]);
+ expect(document.chatConfig.welcomeText).toBe('Keep me');
+ expect(
+ document.nodes.filter((node) => node.flowNodeType === FlowNodeTypeEnum.systemConfig)
+ ).toHaveLength(1);
+ });
+
+ it.each(['node.remove', 'node.clone'] as const)('rejects %s for system config', async (type) => {
+ const { document } = await createDefaultWorkflowDocument({ dependencies });
+ const command =
+ type === 'node.remove'
+ ? ({ type, nodeId: 'userGuide' } as const)
+ : ({ type, sourceNodeId: 'userGuide', nodeId: 'userGuide-copy' } as const);
+
+ await expect(applyWorkflowCommand({ document, command, dependencies })).rejects.toThrow(
+ WorkflowCommandError
+ );
+ });
+
+ it('reports duplicate system config nodes as a validation error', async () => {
+ const { document } = await createDefaultWorkflowDocument({ dependencies });
+ const systemConfigNode = document.nodes.find(
+ (node) => node.flowNodeType === FlowNodeTypeEnum.systemConfig
+ )!;
+ document.nodes.push({ ...structuredClone(systemConfigNode), nodeId: 'userGuide-copy' });
+
+ expect(validateWorkflow(document)).toContainEqual(
+ expect.objectContaining({
+ code: 'WORKFLOW_SYSTEM_CONFIG_NODE_DUPLICATED',
+ severity: 'error'
+ })
+ );
+ });
+});
diff --git a/packages/workflow-core/test/config/service.test.ts b/packages/workflow-core/test/config/service.test.ts
new file mode 100644
index 000000000000..c9a8b2e1ba88
--- /dev/null
+++ b/packages/workflow-core/test/config/service.test.ts
@@ -0,0 +1,125 @@
+import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import {
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+const apply = async (
+ document: ReturnType,
+ command: Parameters[0]['command']
+) => applyWorkflowCommand({ document, command, dependencies });
+
+const createDocument = async () =>
+ (
+ await apply(createWorkflowDocument(), {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ })
+ ).document;
+
+describe('fileSelectConfig output synchronization', () => {
+ it.each([
+ 'canSelectFile',
+ 'canSelectImg',
+ 'canSelectVideo',
+ 'canSelectAudio',
+ 'canSelectCustomFileExtension'
+ ] as const)('adds one userFiles output when %s is enabled', async (key) => {
+ let document = await createDocument();
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { [key]: true }
+ })
+ ).document;
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { [key]: true, maxFiles: 1 }
+ })
+ ).document;
+
+ expect(
+ document.nodes
+ .find((node) => node.nodeId === 'start')
+ ?.outputs.filter((output) => output.key === NodeOutputKeyEnum.userFiles)
+ ).toHaveLength(1);
+ });
+
+ it('removes an unreferenced userFiles output when file upload is disabled or unset', async () => {
+ let document = await createDocument();
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { canSelectFile: true }
+ })
+ ).document;
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { canSelectFile: false }
+ })
+ ).document;
+ expect(
+ document.nodes[0].outputs.some((output) => output.key === NodeOutputKeyEnum.userFiles)
+ ).toBe(false);
+
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { canSelectFile: true }
+ })
+ ).document;
+ document = (await apply(document, { type: 'config.unset', path: 'fileSelectConfig' })).document;
+ expect(document.chatConfig.fileSelectConfig).toBeUndefined();
+ expect(
+ document.nodes[0].outputs.some((output) => output.key === NodeOutputKeyEnum.userFiles)
+ ).toBe(false);
+ });
+
+ it('atomically blocks disabling file upload while userFiles is referenced', async () => {
+ let document = await createDocument();
+ document = (
+ await apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { canSelectFile: true }
+ })
+ ).document;
+ document = (
+ await apply(document, {
+ type: 'node.add',
+ nodeId: 'read',
+ template: parseNodeTemplateRef('builtin:read-files'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ })
+ ).document;
+
+ const before = structuredClone(document);
+ await expect(
+ apply(document, {
+ type: 'config.set',
+ path: 'fileSelectConfig',
+ value: { canSelectFile: false }
+ })
+ ).rejects.toMatchObject({
+ diagnostics: [
+ expect.objectContaining({
+ code: 'WORKFLOW_FILE_OUTPUT_STILL_REFERENCED',
+ nodeId: 'start'
+ })
+ ]
+ });
+ expect(document).toEqual(before);
+ });
+});
diff --git a/packages/workflow-core/test/edge/compiler.test.ts b/packages/workflow-core/test/edge/compiler.test.ts
new file mode 100644
index 000000000000..08632679f733
--- /dev/null
+++ b/packages/workflow-core/test/edge/compiler.test.ts
@@ -0,0 +1,166 @@
+import {
+ WorkflowDocumentSchema,
+ WorkflowCommandError,
+ compileExecutionEdge,
+ createWorkflowDocument,
+ decompileStoreEdge
+} from '../../src';
+import aiWorkflow from '../fixtures/basic-ai/workflow.json';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import { describe, expect, it } from 'vitest';
+
+const document = createWorkflowDocument(aiWorkflow as Parameters[0]);
+
+describe('compileExecutionEdge', () => {
+ it('compiles and decompiles a normal edge', () => {
+ const edge = document.executionEdges[0];
+ const stored = compileExecutionEdge(edge, document);
+ expect(stored).toEqual({
+ source: 'start',
+ sourceHandle: 'start-source-right',
+ target: 'ai',
+ targetHandle: 'ai-target-left'
+ });
+ expect(decompileStoreEdge(stored, document)).toEqual(edge);
+ });
+
+ it('rejects missing nodes and unsupported handles', () => {
+ expect(() =>
+ compileExecutionEdge(
+ { source: { kind: 'next', nodeId: 'missing' }, target: { kind: 'target', nodeId: 'ai' } },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ decompileStoreEdge(
+ { source: 'start', sourceHandle: 'legacy', target: 'ai', targetHandle: 'ai-target-left' },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ decompileStoreEdge(
+ {
+ source: 'start',
+ sourceHandle: 'start-source-right',
+ target: 'ai',
+ targetHandle: 'legacy'
+ },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ decompileStoreEdge(
+ {
+ source: 'start',
+ sourceHandle: 'start-source-unknown',
+ target: 'ai',
+ targetHandle: 'ai-target-left'
+ },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ });
+
+ it('maps catch, branch, source output and tool handles', () => {
+ const expanded = WorkflowDocumentSchema.parse({
+ ...document,
+ nodes: document.nodes.map((node) =>
+ node.nodeId === 'start'
+ ? {
+ ...node,
+ outputs: [
+ ...node.outputs,
+ { id: 'execute', key: 'execute', type: 'source', valueType: 'string' }
+ ]
+ }
+ : node
+ )
+ });
+ const branchDocument = WorkflowDocumentSchema.parse({
+ ...expanded,
+ nodes: expanded.nodes.map((node) =>
+ node.nodeId === 'start' ? { ...node, flowNodeType: FlowNodeTypeEnum.ifElseNode } : node
+ )
+ });
+ const cases = [
+ {
+ semantic: {
+ source: { kind: 'catch' as const, nodeId: 'start' },
+ target: { kind: 'target' as const, nodeId: 'ai' }
+ },
+ sourceHandle: 'start-source_catch-right',
+ targetHandle: 'ai-target-left'
+ },
+ {
+ semantic: {
+ source: { kind: 'branch' as const, nodeId: 'start', branchKey: 'yes' },
+ target: { kind: 'target' as const, nodeId: 'ai' }
+ },
+ sourceHandle: 'start-source-yes',
+ targetHandle: 'ai-target-left',
+ document: branchDocument
+ },
+ {
+ semantic: {
+ source: { kind: 'sourceOutput' as const, nodeId: 'start', outputKey: 'execute' },
+ target: { kind: 'target' as const, nodeId: 'ai' }
+ },
+ sourceHandle: 'start-source-execute',
+ targetHandle: 'ai-target-left'
+ },
+ {
+ semantic: {
+ source: { kind: 'selectedTools' as const, nodeId: 'start' },
+ target: { kind: 'selectedTools' as const, nodeId: 'ai' }
+ },
+ sourceHandle: 'selectedTools',
+ targetHandle: 'selectedTools'
+ }
+ ];
+
+ for (const item of cases) {
+ const caseDocument = item.document ?? expanded;
+ const stored = compileExecutionEdge(item.semantic, caseDocument);
+ expect(stored).toMatchObject({
+ sourceHandle: item.sourceHandle,
+ targetHandle: item.targetHandle
+ });
+ expect(decompileStoreEdge(stored, caseDocument)).toEqual(item.semantic);
+ }
+ });
+
+ it('rejects branch ports on non-branch nodes and mismatched tool handles', () => {
+ expect(() =>
+ compileExecutionEdge(
+ {
+ source: { kind: 'branch', nodeId: 'start', branchKey: 'yes' },
+ target: { kind: 'target', nodeId: 'ai' }
+ },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ decompileStoreEdge(
+ {
+ source: 'start',
+ sourceHandle: 'selectedTools',
+ target: 'ai',
+ targetHandle: 'ai-target-left'
+ },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ });
+
+ it('rejects a semantic source output that is not executable', () => {
+ expect(() =>
+ compileExecutionEdge(
+ {
+ source: { kind: 'sourceOutput', nodeId: 'start', outputKey: 'userChatInput' },
+ target: { kind: 'target', nodeId: 'ai' }
+ },
+ document
+ )
+ ).toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/edge/parser.test.ts b/packages/workflow-core/test/edge/parser.test.ts
new file mode 100644
index 000000000000..9f81853b8fc8
--- /dev/null
+++ b/packages/workflow-core/test/edge/parser.test.ts
@@ -0,0 +1,53 @@
+import {
+ WorkflowCommandError,
+ parseExecutionSourcePortRef,
+ parseExecutionTargetPortRef,
+ parseVariableRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+describe('parseExecutionSourcePortRef', () => {
+ it.each([
+ ['start@next', { kind: 'next', nodeId: 'start' }],
+ ['route@branch:yes', { kind: 'branch', nodeId: 'route', branchKey: 'yes' }],
+ ['source@output:done', { kind: 'sourceOutput', nodeId: 'source', outputKey: 'done' }],
+ ['node@catch', { kind: 'catch', nodeId: 'node' }],
+ ['caller@tools', { kind: 'selectedTools', nodeId: 'caller' }]
+ ])('parses %s', (value, expected) => {
+ expect(parseExecutionSourcePortRef(value)).toEqual(expected);
+ });
+
+ it.each(['start', '@next', 'start@unknown', 'start@branch:', 'start@output:'])(
+ 'rejects %s',
+ (value) => {
+ expect(() => parseExecutionSourcePortRef(value)).toThrow(WorkflowCommandError);
+ }
+ );
+});
+
+describe('parseExecutionTargetPortRef', () => {
+ it('parses normal and tool targets', () => {
+ expect(parseExecutionTargetPortRef('ai@target')).toEqual({ kind: 'target', nodeId: 'ai' });
+ expect(parseExecutionTargetPortRef('tool@tools')).toEqual({
+ kind: 'selectedTools',
+ nodeId: 'tool'
+ });
+ });
+
+ it.each(['ai', '@target', 'ai@next'])('rejects %s', (value) => {
+ expect(() => parseExecutionTargetPortRef(value)).toThrow(WorkflowCommandError);
+ });
+});
+
+describe('parseVariableRef', () => {
+ it('uses the last dot as separator', () => {
+ expect(parseVariableRef('group.node.output')).toEqual({
+ nodeId: 'group.node',
+ outputKey: 'output'
+ });
+ });
+
+ it.each(['node', '.output', 'node.'])('rejects %s', (value) => {
+ expect(() => parseVariableRef(value)).toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/fixtures/basic-ai/expected-diagnostics.json b/packages/workflow-core/test/fixtures/basic-ai/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-ai/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/basic-ai/store-workflow.json b/packages/workflow-core/test/fixtures/basic-ai/store-workflow.json
new file mode 100644
index 000000000000..121a3a5748f3
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-ai/store-workflow.json
@@ -0,0 +1,248 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "chatNode",
+ "avatar": "core/workflow/template/aiChat",
+ "avatarLinear": "core/workflow/template/aiChatLinear",
+ "colorSchema": "blueDark",
+ "name": "AI Chat",
+ "intro": "AI Large Model Chat",
+ "showStatus": true,
+ "version": "4.9.7",
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "valueType": "string",
+ "renderTypeList": ["settingLLMModel", "reference"]
+ },
+ {
+ "key": "temperature",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "maxToken",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "isResponseAnswerText",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatQuoteRole",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "system"
+ },
+ {
+ "key": "quoteTemplate",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "quotePrompt",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatVision",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatAudio",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatVideo",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatExtractFiles",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoning",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoningEffort",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatTopP",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatStopSign",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatResponseFormat",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatJsonSchema",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "systemPrompt",
+ "label": "Prompt",
+ "valueType": "string",
+ "isRichText": true,
+ "placeholder": "Enter a prompt here",
+ "maxLength": 100000,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["textarea", "reference"],
+ "value": "You are a helpful assistant",
+ "description": "Fixed guide words for the model. By adjusting this content, you can guide the model's chat direction. This content will be fixed at the beginning of the context. You can use / to insert variables.\nIf a Dataset is associated, you can also guide the model when to call the Dataset search by appropriate description. For example:\nYou are an assistant for the movie 'Interstellar'. When users ask about content related to 'Interstellar', please search the Dataset and answer based on the search results."
+ },
+ {
+ "key": "history",
+ "label": "Chat History",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "Maximum Number of Dialog Rounds"
+ },
+ {
+ "key": "quoteQA",
+ "label": "",
+ "valueType": "datasetQuote",
+ "renderTypeList": ["settingDatasetQuotePrompt"],
+ "debugLabel": "Dataset Reference"
+ },
+ {
+ "key": "fileUrlList",
+ "label": "File Link",
+ "valueType": "arrayString",
+ "renderTypeList": ["reference", "input"],
+ "debugLabel": "File Link",
+ "description": "Links to documents and images uploaded by users."
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["start", "userChatInput"],
+ "toolDescription": "User Question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "history",
+ "key": "history",
+ "type": "static",
+ "valueType": "chatHistory",
+ "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]",
+ "label": "New Context",
+ "description": "Splice the current reply content with the history records and return it as the new context",
+ "required": true
+ },
+ {
+ "id": "answerText",
+ "key": "answerText",
+ "type": "static",
+ "valueType": "string",
+ "label": "AI Response Content",
+ "description": "Will be triggered after the stream reply is completed",
+ "required": true
+ },
+ {
+ "id": "reasoningText",
+ "key": "reasoningText",
+ "type": "static",
+ "valueType": "string",
+ "label": "Thinking text",
+ "required": false,
+ "invalid": true
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "Error text"
+ }
+ ],
+ "nodeId": "ai"
+ },
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "Start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "User Question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "User Question"
+ }
+ ],
+ "nodeId": "start"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "ai",
+ "targetHandle": "ai-target-left"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/basic-ai/workflow.json b/packages/workflow-core/test/fixtures/basic-ai/workflow.json
new file mode 100644
index 000000000000..69c418dade80
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-ai/workflow.json
@@ -0,0 +1,254 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "avatar": "core/workflow/template/aiChat",
+ "avatarLinear": "core/workflow/template/aiChatLinear",
+ "catchError": false,
+ "colorSchema": "blueDark",
+ "flowNodeType": "chatNode",
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "renderTypeList": ["settingLLMModel", "reference"],
+ "valueType": "string"
+ },
+ {
+ "key": "temperature",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "number"
+ },
+ {
+ "key": "maxToken",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "number"
+ },
+ {
+ "key": "isResponseAnswerText",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": true,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatQuoteRole",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": "system",
+ "valueType": "string"
+ },
+ {
+ "key": "quoteTemplate",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "quotePrompt",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "aiChatVision",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": true,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatAudio",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": false,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatVideo",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": false,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatExtractFiles",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": true,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatReasoning",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": true,
+ "valueType": "boolean"
+ },
+ {
+ "key": "aiChatReasoningEffort",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "aiChatTopP",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "number"
+ },
+ {
+ "key": "aiChatStopSign",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "aiChatResponseFormat",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "aiChatJsonSchema",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "description": "Fixed guide words for the model. By adjusting this content, you can guide the model's chat direction. This content will be fixed at the beginning of the context. You can use / to insert variables.\nIf a Dataset is associated, you can also guide the model when to call the Dataset search by appropriate description. For example:\nYou are an assistant for the movie 'Interstellar'. When users ask about content related to 'Interstellar', please search the Dataset and answer based on the search results.",
+ "isRichText": true,
+ "key": "systemPrompt",
+ "label": "Prompt",
+ "maxLength": 100000,
+ "placeholder": "Enter a prompt here",
+ "renderTypeList": ["textarea", "reference"],
+ "selectedTypeIndex": 0,
+ "value": "You are a helpful assistant",
+ "valueType": "string"
+ },
+ {
+ "description": "Maximum Number of Dialog Rounds",
+ "key": "history",
+ "label": "Chat History",
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "required": true,
+ "value": 6,
+ "valueType": "chatHistory"
+ },
+ {
+ "debugLabel": "Dataset Reference",
+ "key": "quoteQA",
+ "label": "",
+ "renderTypeList": ["settingDatasetQuotePrompt"],
+ "valueType": "datasetQuote"
+ },
+ {
+ "debugLabel": "File Link",
+ "description": "Links to documents and images uploaded by users.",
+ "key": "fileUrlList",
+ "label": "File Link",
+ "renderTypeList": ["reference", "input"],
+ "valueType": "arrayString"
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "toolDescription": "User Question",
+ "value": ["start", "userChatInput"],
+ "valueType": "string"
+ }
+ ],
+ "intro": "AI Large Model Chat",
+ "name": "AI Chat",
+ "nodeId": "ai",
+ "outputs": [
+ {
+ "description": "Splice the current reply content with the history records and return it as the new context",
+ "id": "history",
+ "key": "history",
+ "label": "New Context",
+ "required": true,
+ "type": "static",
+ "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]",
+ "valueType": "chatHistory"
+ },
+ {
+ "description": "Will be triggered after the stream reply is completed",
+ "id": "answerText",
+ "key": "answerText",
+ "label": "AI Response Content",
+ "required": true,
+ "type": "static",
+ "valueType": "string"
+ },
+ {
+ "id": "reasoningText",
+ "invalid": true,
+ "key": "reasoningText",
+ "label": "Thinking text",
+ "required": false,
+ "type": "static",
+ "valueType": "string"
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "label": "Error text",
+ "type": "error",
+ "valueType": "string"
+ }
+ ],
+ "showStatus": true,
+ "version": "4.9.7"
+ },
+ {
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "flowNodeType": "workflowStart",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "toolDescription": "User Question",
+ "valueType": "string"
+ }
+ ],
+ "name": "Start",
+ "nodeId": "start",
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "label": "User Question",
+ "type": "static",
+ "valueType": "string"
+ }
+ ]
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "ai"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/basic-static/expected-diagnostics.json b/packages/workflow-core/test/fixtures/basic-static/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-static/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/basic-static/store-workflow.json b/packages/workflow-core/test/fixtures/basic-static/store-workflow.json
new file mode 100644
index 000000000000..b34b7ac1f577
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-static/store-workflow.json
@@ -0,0 +1,101 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "Assigned Reply",
+ "intro": "This module can directly reply with a specified content. Commonly used for guidance or prompts. Non-string content will be converted to string for output.",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "Response Content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "maxLength": 100000,
+ "selectedTypeIndex": 1,
+ "renderTypeList": ["textarea", "reference"],
+ "value": ["text", "system_text"],
+ "description": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string"
+ }
+ ],
+ "outputs": [],
+ "nodeId": "answer"
+ },
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "Start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "User Question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "User Question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "textEditor",
+ "avatar": "core/workflow/template/textConcat",
+ "avatarLinear": "core/workflow/template/textConcatLinear",
+ "colorSchema": "orange",
+ "name": "Text Editor",
+ "intro": "Can process and output fixed or incoming text. Non-string type data will be converted to string type.",
+ "inputs": [
+ {
+ "key": "system_textareaInput",
+ "label": "Concatenation Text",
+ "valueType": "string",
+ "required": true,
+ "placeholder": "Type / to invoke variable list",
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["textarea"],
+ "value": "Static response"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_text",
+ "key": "system_text",
+ "type": "static",
+ "valueType": "string",
+ "label": "Concatenation Result"
+ }
+ ],
+ "nodeId": "text"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "text",
+ "targetHandle": "text-target-left"
+ },
+ {
+ "source": "text",
+ "sourceHandle": "text-source-right",
+ "target": "answer",
+ "targetHandle": "answer-target-left"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/basic-static/workflow.json b/packages/workflow-core/test/fixtures/basic-static/workflow.json
new file mode 100644
index 000000000000..14bb6bc1221b
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/basic-static/workflow.json
@@ -0,0 +1,111 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "flowNodeType": "answerNode",
+ "inputs": [
+ {
+ "description": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "isRichText": false,
+ "key": "text",
+ "label": "Response Content",
+ "maxLength": 100000,
+ "placeholder": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "renderTypeList": ["textarea", "reference"],
+ "required": true,
+ "selectedTypeIndex": 1,
+ "value": ["text", "system_text"],
+ "valueType": "any"
+ }
+ ],
+ "intro": "This module can directly reply with a specified content. Commonly used for guidance or prompts. Non-string content will be converted to string for output.",
+ "name": "Assigned Reply",
+ "nodeId": "answer",
+ "outputs": []
+ },
+ {
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "flowNodeType": "workflowStart",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "toolDescription": "User Question",
+ "valueType": "string"
+ }
+ ],
+ "name": "Start",
+ "nodeId": "start",
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "label": "User Question",
+ "type": "static",
+ "valueType": "string"
+ }
+ ]
+ },
+ {
+ "avatar": "core/workflow/template/textConcat",
+ "avatarLinear": "core/workflow/template/textConcatLinear",
+ "colorSchema": "orange",
+ "flowNodeType": "textEditor",
+ "inputs": [
+ {
+ "key": "system_textareaInput",
+ "label": "Concatenation Text",
+ "placeholder": "Type / to invoke variable list",
+ "renderTypeList": ["textarea"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": "Static response",
+ "valueType": "string"
+ }
+ ],
+ "intro": "Can process and output fixed or incoming text. Non-string type data will be converted to string type.",
+ "name": "Text Editor",
+ "nodeId": "text",
+ "outputs": [
+ {
+ "id": "system_text",
+ "key": "system_text",
+ "label": "Concatenation Result",
+ "type": "static",
+ "valueType": "string"
+ }
+ ]
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "text"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "text"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "answer"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/branching/expected-diagnostics.json b/packages/workflow-core/test/fixtures/branching/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/branching/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/branching/store-workflow.json b/packages/workflow-core/test/fixtures/branching/store-workflow.json
new file mode 100644
index 000000000000..2983c17adcef
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/branching/store-workflow.json
@@ -0,0 +1,143 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "ifElseNode",
+ "avatar": "core/workflow/template/ifelse",
+ "avatarLinear": "core/workflow/template/ifelseLinear",
+ "colorSchema": "greenLight",
+ "name": "workflow:condition_checker",
+ "intro": "workflow:execute_different_branches_based_on_conditions",
+ "showStatus": true,
+ "inputs": [
+ {
+ "key": "ifElseList",
+ "label": "",
+ "valueType": "any",
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["hidden"],
+ "value": [
+ {
+ "branchId": "positive",
+ "condition": "AND",
+ "list": [
+ {
+ "variable": ["start", "userChatInput"],
+ "condition": "isNotEmpty",
+ "valueType": "input"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "ifElseResult",
+ "key": "ifElseResult",
+ "type": "static",
+ "valueType": "string",
+ "label": "workflow:judgment_result"
+ }
+ ],
+ "nodeId": "route"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["textarea", "reference"],
+ "value": "yes",
+ "description": "common:core.module.input.description.Response content"
+ }
+ ],
+ "outputs": [],
+ "nodeId": "yes"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["textarea", "reference"],
+ "value": "fallback",
+ "description": "common:core.module.input.description.Response content"
+ }
+ ],
+ "outputs": [],
+ "nodeId": "fallback"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "route",
+ "targetHandle": "route-target-left"
+ },
+ {
+ "source": "route",
+ "sourceHandle": "route-source-positive",
+ "target": "yes",
+ "targetHandle": "yes-target-left"
+ },
+ {
+ "source": "route",
+ "sourceHandle": "route-source-ELSE",
+ "target": "fallback",
+ "targetHandle": "fallback-target-left"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/branching/workflow.json b/packages/workflow-core/test/fixtures/branching/workflow.json
new file mode 100644
index 000000000000..1afbc6611218
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/branching/workflow.json
@@ -0,0 +1,159 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "ifElseNode",
+ "avatar": "core/workflow/template/ifelse",
+ "avatarLinear": "core/workflow/template/ifelseLinear",
+ "colorSchema": "greenLight",
+ "name": "workflow:condition_checker",
+ "intro": "workflow:execute_different_branches_based_on_conditions",
+ "showStatus": true,
+ "inputs": [
+ {
+ "key": "ifElseList",
+ "label": "",
+ "valueType": "any",
+ "renderTypeList": ["hidden"],
+ "value": [
+ {
+ "branchId": "positive",
+ "condition": "AND",
+ "list": [
+ {
+ "variable": ["start", "userChatInput"],
+ "condition": "isNotEmpty",
+ "valueType": "input"
+ }
+ ]
+ }
+ ],
+ "selectedTypeIndex": 0
+ }
+ ],
+ "outputs": [
+ {
+ "id": "ifElseResult",
+ "key": "ifElseResult",
+ "type": "static",
+ "valueType": "string",
+ "label": "workflow:judgment_result"
+ }
+ ],
+ "nodeId": "route"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "renderTypeList": ["textarea", "reference"],
+ "description": "common:core.module.input.description.Response content",
+ "value": "yes",
+ "selectedTypeIndex": 0
+ }
+ ],
+ "outputs": [],
+ "nodeId": "yes"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "renderTypeList": ["textarea", "reference"],
+ "description": "common:core.module.input.description.Response content",
+ "value": "fallback",
+ "selectedTypeIndex": 0
+ }
+ ],
+ "outputs": [],
+ "nodeId": "fallback"
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "route"
+ }
+ },
+ {
+ "source": {
+ "kind": "branch",
+ "nodeId": "route",
+ "branchKey": "positive"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "yes"
+ }
+ },
+ {
+ "source": {
+ "kind": "branch",
+ "nodeId": "route",
+ "branchKey": "ELSE"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "fallback"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/common-linear/expected-diagnostics.json b/packages/workflow-core/test/fixtures/common-linear/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/common-linear/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/common-linear/store-workflow.json b/packages/workflow-core/test/fixtures/common-linear/store-workflow.json
new file mode 100644
index 000000000000..55116aec1fe5
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/common-linear/store-workflow.json
@@ -0,0 +1,860 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "Assigned Reply",
+ "intro": "This module can directly reply with a specified content. Commonly used for guidance or prompts. Non-string content will be converted to string for output.",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "Response Content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "maxLength": 100000,
+ "selectedTypeIndex": 1,
+ "renderTypeList": ["textarea", "reference"],
+ "value": ["call", "answerText"],
+ "description": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string"
+ }
+ ],
+ "outputs": [],
+ "nodeId": "answer",
+ "position": {
+ "x": 2240,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "app",
+ "avatar": "core/workflow/template/runApp",
+ "name": "Application Call",
+ "intro": "You can choose another application to call",
+ "showStatus": true,
+ "inputs": [
+ {
+ "key": "app",
+ "label": "Select an Application",
+ "valueType": "selectApp",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["selectApp", "reference"],
+ "value": {
+ "appId": "app-demo"
+ },
+ "description": "Select another application to call"
+ },
+ {
+ "key": "history",
+ "label": "Chat History",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "Maximum Number of Dialog Rounds"
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["query", "system_text"],
+ "toolDescription": "User Question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "history",
+ "key": "history",
+ "type": "static",
+ "valueType": "chatHistory",
+ "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]",
+ "label": "New Context",
+ "description": "Append the application's reply to the history as new context",
+ "required": true
+ },
+ {
+ "id": "answerText",
+ "key": "answerText",
+ "type": "static",
+ "valueType": "string",
+ "label": "Reply Text",
+ "description": "Will be triggered after the application is fully completed"
+ }
+ ],
+ "nodeId": "call",
+ "position": {
+ "x": 1920,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "code",
+ "avatar": "core/workflow/template/codeRun",
+ "avatarLinear": "core/workflow/template/codeRunLinear",
+ "colorSchema": "lime",
+ "name": "Code Sandbox",
+ "intro": "Executing a script code in the sandbox can be used to perform complex data processing, but the syntax and available dependencies will be limited.",
+ "showStatus": true,
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "system_addInputParam",
+ "label": "",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "These variables will be input parameters for code execution"
+ },
+ {
+ "key": "data1",
+ "label": "data1",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference"],
+ "value": ["query", "system_text"],
+ "canEdit": true
+ },
+ {
+ "key": "data2",
+ "label": "data2",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference"],
+ "value": ["extract", "fields"],
+ "canEdit": true
+ },
+ {
+ "key": "codeType",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "js"
+ },
+ {
+ "key": "code",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["custom"],
+ "value": "function main({data1, data2}){\n \n return {\n result: data1,\n data2\n }\n}"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "type": "dynamic",
+ "valueType": "dynamic",
+ "label": "",
+ "description": "Pass the object returned in the code as output to the next nodes. The variable name needs to correspond to the return key.",
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ }
+ },
+ {
+ "id": "system_rawResponse",
+ "key": "system_rawResponse",
+ "type": "static",
+ "valueType": "object",
+ "label": "Full Response Data"
+ },
+ {
+ "id": "qLUQfhG0ILRX",
+ "key": "result",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "result"
+ },
+ {
+ "id": "gR0mkQpJ4Og8",
+ "key": "data2",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "data2"
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "type": "error",
+ "valueType": "string",
+ "label": "Error text"
+ }
+ ],
+ "nodeId": "code",
+ "position": {
+ "x": 1600,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "contentExtract",
+ "avatar": "core/workflow/template/extractJson",
+ "avatarLinear": "core/workflow/template/extractJsonLinear",
+ "colorSchema": "teal",
+ "name": "Text Extract",
+ "intro": "Can extract specified data from text, such as SQL statements, search keywords, code, etc.",
+ "showStatus": true,
+ "version": "4.9.2",
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["selectLLMModel", "reference"],
+ "value": "gpt-4.1"
+ },
+ {
+ "key": "description",
+ "label": "Extraction Requirements Description",
+ "valueType": "string",
+ "placeholder": "For example: 1. The current time is: {{cTime}}. \nYou are a laboratory reservation assistant. Your task is to help users make laboratory reservations and obtain the corresponding reservation information from the text.\n\n2. You are the Google Search Assistant and need to extract appropriate search terms from text.",
+ "renderTypeList": ["textarea", "reference"],
+ "description": "Provide AI with some background knowledge or requirements to guide it in completing the task better.\\nThis input box can use global variables."
+ },
+ {
+ "key": "history",
+ "label": "Chat History",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "Maximum Number of Dialog Rounds"
+ },
+ {
+ "key": "content",
+ "label": "Text to Extract",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["query", "system_text"],
+ "toolDescription": "Content to Retrieve"
+ },
+ {
+ "key": "extractKeys",
+ "label": "",
+ "valueType": "any",
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["custom"],
+ "value": [
+ {
+ "description": "Summary",
+ "key": "summary",
+ "required": true,
+ "valueType": "string"
+ }
+ ],
+ "description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted."
+ }
+ ],
+ "outputs": [
+ {
+ "id": "success",
+ "key": "success",
+ "type": "static",
+ "valueType": "boolean",
+ "label": "Full Field Extraction",
+ "description": "Returns true when all fields are fully extracted (success includes model extraction or using default values)",
+ "required": true
+ },
+ {
+ "id": "fields",
+ "key": "fields",
+ "type": "static",
+ "valueType": "string",
+ "label": "Complete Extraction Result",
+ "description": "A JSON string, e.g., {\"name\":\"YY\",\"Time\":\"2023/7/2 18:00\"}",
+ "required": true
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "Error text"
+ }
+ ],
+ "nodeId": "extract",
+ "position": {
+ "x": 960,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "httpRequest468",
+ "avatar": "core/workflow/template/httpRequest",
+ "avatarLinear": "core/workflow/template/httpRequestLinear",
+ "colorSchema": "indigo",
+ "name": "HTTP",
+ "intro": "Can send an HTTP request to perform more complex operations (network search, database query, etc.)",
+ "showStatus": true,
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "system_addInputParam",
+ "label": "",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "Receive the output value of the previous node as a variable, which can be used by the HTTP request parameters.",
+ "deprecated": false
+ },
+ {
+ "key": "system_httpMethod",
+ "label": "",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["custom"],
+ "value": "POST"
+ },
+ {
+ "key": "system_httpTimeout",
+ "label": "",
+ "valueType": "number",
+ "required": true,
+ "max": 600,
+ "min": 5,
+ "renderTypeList": ["custom"],
+ "value": 30
+ },
+ {
+ "key": "system_httpReqUrl",
+ "label": "",
+ "valueType": "string",
+ "required": false,
+ "placeholder": "https://api.ai.com/getInventory",
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["hidden"],
+ "value": "https://example.com/api",
+ "description": "New HTTP request address. If there are two 'request addresses', you can delete this module and re-add it to pull the latest module configuration."
+ },
+ {
+ "key": "system_header_secret",
+ "label": "",
+ "valueType": "object",
+ "required": false,
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "system_httpHeader",
+ "label": "",
+ "valueType": "any",
+ "required": false,
+ "placeholder": "Custom request headers, please strictly fill in the JSON string.\n1. Ensure that the last attribute has no comma\n2. Ensure that the key contains double quotes\nFor example: {\"Authorization\":\"Bearer xxx\"}",
+ "renderTypeList": ["custom"],
+ "value": [],
+ "description": "Custom request headers, please strictly fill in the JSON string.\n1. Ensure that the last attribute has no comma\n2. Ensure that the key contains double quotes\nFor example: {\"Authorization\":\"Bearer xxx\"}"
+ },
+ {
+ "key": "system_httpParams",
+ "label": "",
+ "valueType": "any",
+ "required": false,
+ "renderTypeList": ["hidden"],
+ "value": []
+ },
+ {
+ "key": "system_httpJsonBody",
+ "label": "",
+ "valueType": "any",
+ "required": false,
+ "renderTypeList": ["hidden"],
+ "value": ""
+ },
+ {
+ "key": "system_httpFormBody",
+ "label": "",
+ "valueType": "any",
+ "required": false,
+ "renderTypeList": ["hidden"],
+ "value": []
+ },
+ {
+ "key": "system_httpContentType",
+ "label": "",
+ "valueType": "string",
+ "required": false,
+ "renderTypeList": ["hidden"],
+ "value": "json"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "type": "dynamic",
+ "valueType": "dynamic",
+ "label": "Output field extraction",
+ "description": "Specified fields in the response value can be extracted through JSONPath syntax",
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ }
+ },
+ {
+ "id": "httpRawResponse",
+ "key": "httpRawResponse",
+ "type": "static",
+ "valueType": "any",
+ "label": "Raw Response",
+ "description": "Raw HTTP response. Only accepts string or JSON type response data.",
+ "required": true
+ },
+ {
+ "id": "system_httpRawError",
+ "key": "system_httpRawError",
+ "type": "error",
+ "valueType": "object",
+ "label": "Full error",
+ "description": "Complete error object when the HTTP request fails, including message, status, code, data, and other fields."
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "type": "error",
+ "valueType": "string",
+ "label": "Error text"
+ }
+ ],
+ "nodeId": "http",
+ "position": {
+ "x": 1280,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "cfr",
+ "avatar": "core/workflow/template/queryExtension",
+ "avatarLinear": "core/workflow/template/queryExtensionLinear",
+ "colorSchema": "indigo",
+ "name": "Query extension",
+ "intro": "Using question optimization can improve the accuracy of Dataset searches during continuous conversations. After using this function, AI will first construct one or more new search terms based on the context, which are more conducive to Dataset searches. This module is already built into the Dataset search module. If you only perform a single Dataset search, you can directly use the built-in completion function of the Dataset.",
+ "showStatus": true,
+ "version": "481",
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["selectLLMModel", "reference"],
+ "value": "gpt-4.1"
+ },
+ {
+ "key": "systemPrompt",
+ "label": "Conversation Background Description",
+ "valueType": "string",
+ "placeholder": "For example:\nQuestions about the introduction and use of Python.\nThe current conversation is related to the game 'GTA5'.",
+ "max": 300,
+ "selectedTypeIndex": 1,
+ "renderTypeList": ["textarea", "reference"],
+ "value": ["VARIABLE_NODE_ID", "tenantId"],
+ "description": "Describe the scope of the current conversation to help the AI complete and extend the current question. The content you fill in is usually for this assistant."
+ },
+ {
+ "key": "history",
+ "label": "Chat History",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "Maximum Number of Dialog Rounds"
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["start", "userChatInput"],
+ "toolDescription": "user question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_text",
+ "key": "system_text",
+ "type": "static",
+ "valueType": "string",
+ "label": "Optimization Result",
+ "description": "Output as a string array, which can be directly connected to the 'User Question' of 'Dataset Search'. It is recommended not to connect to the 'User Question' of 'AI Chat'"
+ }
+ ],
+ "nodeId": "query",
+ "position": {
+ "x": 320,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "datasetSearchNode",
+ "avatar": "core/workflow/template/datasetSearch",
+ "avatarLinear": "core/workflow/template/datasetSearchLinear",
+ "colorSchema": "blueLight",
+ "name": "Dataset Search",
+ "intro": "Use 'semantic search' and 'full-text search' capabilities to find potentially relevant reference content from the 'Dataset'.",
+ "showStatus": true,
+ "version": "4.9.2",
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "datasets",
+ "label": "Select Dataset",
+ "valueType": "selectDataset",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["selectDataset", "reference"],
+ "valueDesc": "[\n {\n \"datasetId\": \"6693a4a6b69b7a9b0e37d9b0\"\n }\n]",
+ "value": [
+ {
+ "avatar": "",
+ "datasetId": "dataset-demo",
+ "name": "Demo",
+ "vectorModel": {
+ "model": "text-embedding"
+ }
+ }
+ ]
+ },
+ {
+ "key": "similarity",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["selectDatasetParamsModal"],
+ "value": 0.4
+ },
+ {
+ "key": "limit",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 5000
+ },
+ {
+ "key": "searchMode",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "embedding"
+ },
+ {
+ "key": "embeddingWeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 0.5
+ },
+ {
+ "key": "usingReRank",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "rerankModel",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "rerankWeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 0.5
+ },
+ {
+ "key": "datasetSearchUsingExtensionQuery",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "datasetSearchExtensionModel",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "datasetSearchExtensionBg",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": ""
+ },
+ {
+ "key": "authTmbId",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "datasetSearchInput",
+ "label": "Search content",
+ "valueType": "arrayString",
+ "required": true,
+ "selectedTypeIndex": 1,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["hello"],
+ "toolDescription": "Search term or search statement"
+ },
+ {
+ "key": "collectionFilterMatch",
+ "label": "Collection Metadata Filter",
+ "valueType": "string",
+ "renderTypeList": ["textarea", "reference"],
+ "description": "Currently supports filtering by tags, creation time, and collection IDs. Fill in the format as follows:\n{\n \"tags\": {\n \"$and\": [\"Tag 1\",\"Tag 2\"],\n \"$or\": [\"When there are $and tags, and is effective, or is not effective\"]\n },\n \"createTime\": {\n \"$gte\": \"YYYY-MM-DD HH:mm format, collection creation time greater than this time\",\n \"$lte\": \"YYYY-MM-DD HH:mm format, collection creation time less than this time, can be used with $gte\"\n },\n \"collectionIds\": [\"collectionId1\", \"collectionId2\", \"Folder IDs are supported and will automatically expand to get all sub-collections\"]\n}",
+ "isPro": true
+ }
+ ],
+ "outputs": [
+ {
+ "id": "quoteQA",
+ "key": "quoteQA",
+ "type": "static",
+ "valueType": "datasetQuote",
+ "valueDesc": "{\n id: string;\n datasetId: string;\n collectionId: string;\n sourceName: string;\n sourceId?: string;\n q: string;\n a: string\n}[]",
+ "label": "Dataset Quote",
+ "description": "Special array format, returns an empty array when the search result is empty."
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "Error text"
+ }
+ ],
+ "nodeId": "search",
+ "position": {
+ "x": 640,
+ "y": 0
+ }
+ },
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "Start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "User Question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "User Question"
+ }
+ ],
+ "nodeId": "start"
+ }
+ ],
+ "edges": [
+ {
+ "source": "call",
+ "sourceHandle": "call-source-right",
+ "target": "answer",
+ "targetHandle": "answer-target-left"
+ },
+ {
+ "source": "code",
+ "sourceHandle": "code-source-right",
+ "target": "call",
+ "targetHandle": "call-target-left"
+ },
+ {
+ "source": "extract",
+ "sourceHandle": "extract-source-right",
+ "target": "http",
+ "targetHandle": "http-target-left"
+ },
+ {
+ "source": "http",
+ "sourceHandle": "http-source-right",
+ "target": "code",
+ "targetHandle": "code-target-left"
+ },
+ {
+ "source": "query",
+ "sourceHandle": "query-source-right",
+ "target": "search",
+ "targetHandle": "search-target-left"
+ },
+ {
+ "source": "search",
+ "sourceHandle": "search-source-right",
+ "target": "extract",
+ "targetHandle": "extract-target-left"
+ },
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "query",
+ "targetHandle": "query-target-left"
+ }
+ ],
+ "chatConfig": {
+ "welcomeText": "Welcome",
+ "variables": [
+ {
+ "key": "tenantId",
+ "label": "tenantId",
+ "valueType": "string",
+ "required": true,
+ "type": "input",
+ "description": "Tenant ID"
+ }
+ ]
+ }
+}
diff --git a/packages/workflow-core/test/fixtures/common-linear/workflow.json b/packages/workflow-core/test/fixtures/common-linear/workflow.json
new file mode 100644
index 000000000000..b46cacb057d1
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/common-linear/workflow.json
@@ -0,0 +1,893 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {
+ "intro": "Covers common linear nodes",
+ "name": "Common linear PR2"
+ },
+ "nodes": [
+ {
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "flowNodeType": "answerNode",
+ "inputs": [
+ {
+ "description": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "isRichText": false,
+ "key": "text",
+ "label": "Response Content",
+ "maxLength": 100000,
+ "placeholder": "You can use \\n to achieve continuous line breaks.\nYou can achieve replies through external module input, and the content filled in here will be overwritten by external module input.\nIf non-string type data is passed in, it will be automatically converted to a string",
+ "renderTypeList": ["textarea", "reference"],
+ "required": true,
+ "selectedTypeIndex": 1,
+ "value": ["call", "answerText"],
+ "valueType": "any"
+ }
+ ],
+ "intro": "This module can directly reply with a specified content. Commonly used for guidance or prompts. Non-string content will be converted to string for output.",
+ "name": "Assigned Reply",
+ "nodeId": "answer",
+ "outputs": [],
+ "position": {
+ "x": 2240,
+ "y": 0
+ }
+ },
+ {
+ "avatar": "core/workflow/template/runApp",
+ "flowNodeType": "app",
+ "inputs": [
+ {
+ "description": "Select another application to call",
+ "key": "app",
+ "label": "Select an Application",
+ "renderTypeList": ["selectApp", "reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": {
+ "appId": "app-demo"
+ },
+ "valueType": "selectApp"
+ },
+ {
+ "description": "Maximum Number of Dialog Rounds",
+ "key": "history",
+ "label": "Chat History",
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "required": true,
+ "value": 6,
+ "valueType": "chatHistory"
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "toolDescription": "User Question",
+ "value": ["query", "system_text"],
+ "valueType": "string"
+ }
+ ],
+ "intro": "You can choose another application to call",
+ "name": "Application Call",
+ "nodeId": "call",
+ "outputs": [
+ {
+ "description": "Append the application's reply to the history as new context",
+ "id": "history",
+ "key": "history",
+ "label": "New Context",
+ "required": true,
+ "type": "static",
+ "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]",
+ "valueType": "chatHistory"
+ },
+ {
+ "description": "Will be triggered after the application is fully completed",
+ "id": "answerText",
+ "key": "answerText",
+ "label": "Reply Text",
+ "type": "static",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 1920,
+ "y": 0
+ },
+ "showStatus": true
+ },
+ {
+ "avatar": "core/workflow/template/codeRun",
+ "avatarLinear": "core/workflow/template/codeRunLinear",
+ "catchError": false,
+ "colorSchema": "lime",
+ "flowNodeType": "code",
+ "inputs": [
+ {
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "description": "These variables will be input parameters for code execution",
+ "key": "system_addInputParam",
+ "label": "",
+ "renderTypeList": ["addInputParam"],
+ "required": false,
+ "valueType": "dynamic"
+ },
+ {
+ "canEdit": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "key": "data1",
+ "label": "data1",
+ "renderTypeList": ["reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": ["query", "system_text"],
+ "valueType": "string"
+ },
+ {
+ "canEdit": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "key": "data2",
+ "label": "data2",
+ "renderTypeList": ["reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": ["extract", "fields"],
+ "valueType": "string"
+ },
+ {
+ "key": "codeType",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": "js",
+ "valueType": "string"
+ },
+ {
+ "key": "code",
+ "label": "",
+ "renderTypeList": ["custom"],
+ "value": "function main({data1, data2}){\n \n return {\n result: data1,\n data2\n }\n}",
+ "valueType": "string"
+ }
+ ],
+ "intro": "Executing a script code in the sandbox can be used to perform complex data processing, but the syntax and available dependencies will be limited.",
+ "name": "Code Sandbox",
+ "nodeId": "code",
+ "outputs": [
+ {
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ },
+ "description": "Pass the object returned in the code as output to the next nodes. The variable name needs to correspond to the return key.",
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "label": "",
+ "type": "dynamic",
+ "valueType": "dynamic"
+ },
+ {
+ "id": "system_rawResponse",
+ "key": "system_rawResponse",
+ "label": "Full Response Data",
+ "type": "static",
+ "valueType": "object"
+ },
+ {
+ "id": "qLUQfhG0ILRX",
+ "key": "result",
+ "label": "result",
+ "type": "dynamic",
+ "valueType": "string"
+ },
+ {
+ "id": "gR0mkQpJ4Og8",
+ "key": "data2",
+ "label": "data2",
+ "type": "dynamic",
+ "valueType": "string"
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "label": "Error text",
+ "type": "error",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 1600,
+ "y": 0
+ },
+ "showStatus": true
+ },
+ {
+ "avatar": "core/workflow/template/extractJson",
+ "avatarLinear": "core/workflow/template/extractJsonLinear",
+ "catchError": false,
+ "colorSchema": "teal",
+ "flowNodeType": "contentExtract",
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "renderTypeList": ["selectLLMModel", "reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": "gpt-4.1",
+ "valueType": "string"
+ },
+ {
+ "description": "Provide AI with some background knowledge or requirements to guide it in completing the task better.\\nThis input box can use global variables.",
+ "key": "description",
+ "label": "Extraction Requirements Description",
+ "placeholder": "For example: 1. The current time is: {{cTime}}. \nYou are a laboratory reservation assistant. Your task is to help users make laboratory reservations and obtain the corresponding reservation information from the text.\n\n2. You are the Google Search Assistant and need to extract appropriate search terms from text.",
+ "renderTypeList": ["textarea", "reference"],
+ "valueType": "string"
+ },
+ {
+ "description": "Maximum Number of Dialog Rounds",
+ "key": "history",
+ "label": "Chat History",
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "required": true,
+ "value": 6,
+ "valueType": "chatHistory"
+ },
+ {
+ "key": "content",
+ "label": "Text to Extract",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "toolDescription": "Content to Retrieve",
+ "value": ["query", "system_text"],
+ "valueType": "string"
+ },
+ {
+ "description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted.",
+ "key": "extractKeys",
+ "label": "",
+ "renderTypeList": ["custom"],
+ "selectedTypeIndex": 0,
+ "value": [
+ {
+ "description": "Summary",
+ "key": "summary",
+ "required": true,
+ "valueType": "string"
+ }
+ ],
+ "valueType": "any"
+ }
+ ],
+ "intro": "Can extract specified data from text, such as SQL statements, search keywords, code, etc.",
+ "name": "Text Extract",
+ "nodeId": "extract",
+ "outputs": [
+ {
+ "description": "Returns true when all fields are fully extracted (success includes model extraction or using default values)",
+ "id": "success",
+ "key": "success",
+ "label": "Full Field Extraction",
+ "required": true,
+ "type": "static",
+ "valueType": "boolean"
+ },
+ {
+ "description": "A JSON string, e.g., {\"name\":\"YY\",\"Time\":\"2023/7/2 18:00\"}",
+ "id": "fields",
+ "key": "fields",
+ "label": "Complete Extraction Result",
+ "required": true,
+ "type": "static",
+ "valueType": "string"
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "label": "Error text",
+ "type": "error",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 960,
+ "y": 0
+ },
+ "showStatus": true,
+ "version": "4.9.2"
+ },
+ {
+ "avatar": "core/workflow/template/httpRequest",
+ "avatarLinear": "core/workflow/template/httpRequestLinear",
+ "catchError": false,
+ "colorSchema": "indigo",
+ "flowNodeType": "httpRequest468",
+ "inputs": [
+ {
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "deprecated": false,
+ "description": "Receive the output value of the previous node as a variable, which can be used by the HTTP request parameters.",
+ "key": "system_addInputParam",
+ "label": "",
+ "renderTypeList": ["addInputParam"],
+ "required": false,
+ "valueType": "dynamic"
+ },
+ {
+ "key": "system_httpMethod",
+ "label": "",
+ "renderTypeList": ["custom"],
+ "required": true,
+ "value": "POST",
+ "valueType": "string"
+ },
+ {
+ "key": "system_httpTimeout",
+ "label": "",
+ "max": 600,
+ "min": 5,
+ "renderTypeList": ["custom"],
+ "required": true,
+ "value": 30,
+ "valueType": "number"
+ },
+ {
+ "description": "New HTTP request address. If there are two 'request addresses', you can delete this module and re-add it to pull the latest module configuration.",
+ "key": "system_httpReqUrl",
+ "label": "",
+ "placeholder": "https://api.ai.com/getInventory",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "selectedTypeIndex": 0,
+ "value": "https://example.com/api",
+ "valueType": "string"
+ },
+ {
+ "key": "system_header_secret",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "valueType": "object"
+ },
+ {
+ "description": "Custom request headers, please strictly fill in the JSON string.\n1. Ensure that the last attribute has no comma\n2. Ensure that the key contains double quotes\nFor example: {\"Authorization\":\"Bearer xxx\"}",
+ "key": "system_httpHeader",
+ "label": "",
+ "placeholder": "Custom request headers, please strictly fill in the JSON string.\n1. Ensure that the last attribute has no comma\n2. Ensure that the key contains double quotes\nFor example: {\"Authorization\":\"Bearer xxx\"}",
+ "renderTypeList": ["custom"],
+ "required": false,
+ "value": [],
+ "valueType": "any"
+ },
+ {
+ "key": "system_httpParams",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "value": [],
+ "valueType": "any"
+ },
+ {
+ "key": "system_httpJsonBody",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "value": "",
+ "valueType": "any"
+ },
+ {
+ "key": "system_httpFormBody",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "value": [],
+ "valueType": "any"
+ },
+ {
+ "key": "system_httpContentType",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "required": false,
+ "value": "json",
+ "valueType": "string"
+ }
+ ],
+ "intro": "Can send an HTTP request to perform more complex operations (network search, database query, etc.)",
+ "name": "HTTP",
+ "nodeId": "http",
+ "outputs": [
+ {
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ },
+ "description": "Specified fields in the response value can be extracted through JSONPath syntax",
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "label": "Output field extraction",
+ "type": "dynamic",
+ "valueType": "dynamic"
+ },
+ {
+ "description": "Raw HTTP response. Only accepts string or JSON type response data.",
+ "id": "httpRawResponse",
+ "key": "httpRawResponse",
+ "label": "Raw Response",
+ "required": true,
+ "type": "static",
+ "valueType": "any"
+ },
+ {
+ "description": "Complete error object when the HTTP request fails, including message, status, code, data, and other fields.",
+ "id": "system_httpRawError",
+ "key": "system_httpRawError",
+ "label": "Full error",
+ "type": "error",
+ "valueType": "object"
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "label": "Error text",
+ "type": "error",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 1280,
+ "y": 0
+ },
+ "showStatus": true
+ },
+ {
+ "avatar": "core/workflow/template/queryExtension",
+ "avatarLinear": "core/workflow/template/queryExtensionLinear",
+ "colorSchema": "indigo",
+ "flowNodeType": "cfr",
+ "inputs": [
+ {
+ "key": "model",
+ "label": "AI Model",
+ "renderTypeList": ["selectLLMModel", "reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": "gpt-4.1",
+ "valueType": "string"
+ },
+ {
+ "description": "Describe the scope of the current conversation to help the AI complete and extend the current question. The content you fill in is usually for this assistant.",
+ "key": "systemPrompt",
+ "label": "Conversation Background Description",
+ "max": 300,
+ "placeholder": "For example:\nQuestions about the introduction and use of Python.\nThe current conversation is related to the game 'GTA5'.",
+ "renderTypeList": ["textarea", "reference"],
+ "selectedTypeIndex": 1,
+ "value": ["VARIABLE_NODE_ID", "tenantId"],
+ "valueType": "string"
+ },
+ {
+ "description": "Maximum Number of Dialog Rounds",
+ "key": "history",
+ "label": "Chat History",
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "required": true,
+ "value": 6,
+ "valueType": "chatHistory"
+ },
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "toolDescription": "user question",
+ "value": ["start", "userChatInput"],
+ "valueType": "string"
+ }
+ ],
+ "intro": "Using question optimization can improve the accuracy of Dataset searches during continuous conversations. After using this function, AI will first construct one or more new search terms based on the context, which are more conducive to Dataset searches. This module is already built into the Dataset search module. If you only perform a single Dataset search, you can directly use the built-in completion function of the Dataset.",
+ "name": "Query extension",
+ "nodeId": "query",
+ "outputs": [
+ {
+ "description": "Output as a string array, which can be directly connected to the 'User Question' of 'Dataset Search'. It is recommended not to connect to the 'User Question' of 'AI Chat'",
+ "id": "system_text",
+ "key": "system_text",
+ "label": "Optimization Result",
+ "type": "static",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 320,
+ "y": 0
+ },
+ "showStatus": true,
+ "version": "481"
+ },
+ {
+ "avatar": "core/workflow/template/datasetSearch",
+ "avatarLinear": "core/workflow/template/datasetSearchLinear",
+ "catchError": false,
+ "colorSchema": "blueLight",
+ "flowNodeType": "datasetSearchNode",
+ "inputs": [
+ {
+ "key": "datasets",
+ "label": "Select Dataset",
+ "renderTypeList": ["selectDataset", "reference"],
+ "required": true,
+ "selectedTypeIndex": 0,
+ "value": [
+ {
+ "avatar": "",
+ "datasetId": "dataset-demo",
+ "name": "Demo",
+ "vectorModel": {
+ "model": "text-embedding"
+ }
+ }
+ ],
+ "valueDesc": "[\n {\n \"datasetId\": \"6693a4a6b69b7a9b0e37d9b0\"\n }\n]",
+ "valueType": "selectDataset"
+ },
+ {
+ "key": "similarity",
+ "label": "",
+ "renderTypeList": ["selectDatasetParamsModal"],
+ "value": 0.4,
+ "valueType": "number"
+ },
+ {
+ "key": "limit",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": 5000,
+ "valueType": "number"
+ },
+ {
+ "key": "searchMode",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": "embedding",
+ "valueType": "string"
+ },
+ {
+ "key": "embeddingWeight",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": 0.5,
+ "valueType": "number"
+ },
+ {
+ "key": "usingReRank",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": false,
+ "valueType": "boolean"
+ },
+ {
+ "key": "rerankModel",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "rerankWeight",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": 0.5,
+ "valueType": "number"
+ },
+ {
+ "key": "datasetSearchUsingExtensionQuery",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": true,
+ "valueType": "boolean"
+ },
+ {
+ "key": "datasetSearchExtensionModel",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "valueType": "string"
+ },
+ {
+ "key": "datasetSearchExtensionBg",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "key": "authTmbId",
+ "label": "",
+ "renderTypeList": ["hidden"],
+ "value": false,
+ "valueType": "boolean"
+ },
+ {
+ "key": "datasetSearchInput",
+ "label": "Search content",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "selectedTypeIndex": 1,
+ "toolDescription": "Search term or search statement",
+ "value": ["hello"],
+ "valueType": "arrayString"
+ },
+ {
+ "description": "Currently supports filtering by tags, creation time, and collection IDs. Fill in the format as follows:\n{\n \"tags\": {\n \"$and\": [\"Tag 1\",\"Tag 2\"],\n \"$or\": [\"When there are $and tags, and is effective, or is not effective\"]\n },\n \"createTime\": {\n \"$gte\": \"YYYY-MM-DD HH:mm format, collection creation time greater than this time\",\n \"$lte\": \"YYYY-MM-DD HH:mm format, collection creation time less than this time, can be used with $gte\"\n },\n \"collectionIds\": [\"collectionId1\", \"collectionId2\", \"Folder IDs are supported and will automatically expand to get all sub-collections\"]\n}",
+ "isPro": true,
+ "key": "collectionFilterMatch",
+ "label": "Collection Metadata Filter",
+ "renderTypeList": ["textarea", "reference"],
+ "valueType": "string"
+ }
+ ],
+ "intro": "Use 'semantic search' and 'full-text search' capabilities to find potentially relevant reference content from the 'Dataset'.",
+ "name": "Dataset Search",
+ "nodeId": "search",
+ "outputs": [
+ {
+ "description": "Special array format, returns an empty array when the search result is empty.",
+ "id": "quoteQA",
+ "key": "quoteQA",
+ "label": "Dataset Quote",
+ "type": "static",
+ "valueDesc": "{\n id: string;\n datasetId: string;\n collectionId: string;\n sourceName: string;\n sourceId?: string;\n q: string;\n a: string\n}[]",
+ "valueType": "datasetQuote"
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "label": "Error text",
+ "type": "error",
+ "valueType": "string"
+ }
+ ],
+ "position": {
+ "x": 640,
+ "y": 0
+ },
+ "showStatus": true,
+ "version": "4.9.2"
+ },
+ {
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "flowNodeType": "workflowStart",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "User Question",
+ "renderTypeList": ["reference", "textarea"],
+ "required": true,
+ "toolDescription": "User Question",
+ "valueType": "string"
+ }
+ ],
+ "name": "Start",
+ "nodeId": "start",
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "label": "User Question",
+ "type": "static",
+ "valueType": "string"
+ }
+ ]
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "call"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "answer"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "code"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "call"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "extract"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "http"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "http"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "code"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "query"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "search"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "search"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "extract"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "query"
+ }
+ }
+ ],
+ "chatConfig": {
+ "variables": [
+ {
+ "description": "Tenant ID",
+ "key": "tenantId",
+ "label": "tenantId",
+ "required": true,
+ "type": "input",
+ "valueType": "string"
+ }
+ ],
+ "welcomeText": "Welcome"
+ }
+}
diff --git a/packages/workflow-core/test/fixtures/dynamic-io-catch/expected-diagnostics.json b/packages/workflow-core/test/fixtures/dynamic-io-catch/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/dynamic-io-catch/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/dynamic-io-catch/store-workflow.json b/packages/workflow-core/test/fixtures/dynamic-io-catch/store-workflow.json
new file mode 100644
index 000000000000..9725b4d6a736
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/dynamic-io-catch/store-workflow.json
@@ -0,0 +1,255 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "code",
+ "avatar": "core/workflow/template/codeRun",
+ "avatarLinear": "core/workflow/template/codeRunLinear",
+ "colorSchema": "lime",
+ "name": "workflow:code_execution",
+ "intro": "workflow:code_sandbox_intro",
+ "showStatus": true,
+ "catchError": true,
+ "inputs": [
+ {
+ "key": "system_addInputParam",
+ "label": "",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "workflow:these_variables_will_be_input_parameters_for_code_execution"
+ },
+ {
+ "key": "data1",
+ "label": "data1",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference"],
+ "value": ["start", "userChatInput"],
+ "canEdit": true
+ },
+ {
+ "key": "data2",
+ "label": "data2",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference"],
+ "value": ["start", "userChatInput"],
+ "canEdit": true
+ },
+ {
+ "key": "codeType",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "js"
+ },
+ {
+ "key": "code",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["custom"],
+ "value": "function main({data1, data2}){\n \n return {\n result: data1,\n data2\n }\n}"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "score",
+ "key": "score",
+ "type": "dynamic",
+ "valueType": "number",
+ "label": "Score"
+ },
+ {
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "type": "dynamic",
+ "valueType": "dynamic",
+ "label": "",
+ "description": "workflow:pass_returned_object_as_output_to_next_nodes",
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ }
+ },
+ {
+ "id": "system_rawResponse",
+ "key": "system_rawResponse",
+ "type": "static",
+ "valueType": "object",
+ "label": "workflow:full_response_data"
+ },
+ {
+ "id": "qLUQfhG0ILRX",
+ "key": "result",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "result"
+ },
+ {
+ "id": "gR0mkQpJ4Og8",
+ "key": "data2",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "data2"
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "code"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["textarea", "reference"],
+ "value": "recover",
+ "description": "common:core.module.input.description.Response content"
+ }
+ ],
+ "outputs": [],
+ "nodeId": "recover"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "code",
+ "targetHandle": "code-target-left"
+ },
+ {
+ "source": "code",
+ "sourceHandle": "code-source_catch-right",
+ "target": "recover",
+ "targetHandle": "recover-target-left"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/dynamic-io-catch/workflow.json b/packages/workflow-core/test/fixtures/dynamic-io-catch/workflow.json
new file mode 100644
index 000000000000..a72a81274c4c
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/dynamic-io-catch/workflow.json
@@ -0,0 +1,265 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "code",
+ "avatar": "core/workflow/template/codeRun",
+ "avatarLinear": "core/workflow/template/codeRunLinear",
+ "colorSchema": "lime",
+ "name": "workflow:code_execution",
+ "intro": "workflow:code_sandbox_intro",
+ "showStatus": true,
+ "catchError": true,
+ "inputs": [
+ {
+ "key": "system_addInputParam",
+ "label": "",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "workflow:these_variables_will_be_input_parameters_for_code_execution"
+ },
+ {
+ "key": "data1",
+ "label": "data1",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["reference"],
+ "canEdit": true,
+ "value": ["start", "userChatInput"],
+ "selectedTypeIndex": 0
+ },
+ {
+ "key": "data2",
+ "label": "data2",
+ "valueType": "string",
+ "required": true,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": true,
+ "showDescription": false
+ },
+ "renderTypeList": ["reference"],
+ "canEdit": true,
+ "value": ["start", "userChatInput"],
+ "selectedTypeIndex": 0
+ },
+ {
+ "key": "codeType",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "js"
+ },
+ {
+ "key": "code",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["custom"],
+ "value": "function main({data1, data2}){\n \n return {\n result: data1,\n data2\n }\n}"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "score",
+ "key": "score",
+ "type": "dynamic",
+ "valueType": "number",
+ "label": "Score"
+ },
+ {
+ "id": "system_addOutputParam",
+ "key": "system_addOutputParam",
+ "type": "dynamic",
+ "valueType": "dynamic",
+ "label": "",
+ "description": "workflow:pass_returned_object_as_output_to_next_nodes",
+ "customFieldConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false
+ }
+ },
+ {
+ "id": "system_rawResponse",
+ "key": "system_rawResponse",
+ "type": "static",
+ "valueType": "object",
+ "label": "workflow:full_response_data"
+ },
+ {
+ "id": "qLUQfhG0ILRX",
+ "key": "result",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "result"
+ },
+ {
+ "id": "gR0mkQpJ4Og8",
+ "key": "data2",
+ "type": "dynamic",
+ "valueType": "string",
+ "label": "data2"
+ },
+ {
+ "id": "error",
+ "key": "error",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "code"
+ },
+ {
+ "flowNodeType": "answerNode",
+ "avatar": "core/workflow/template/reply",
+ "avatarLinear": "core/workflow/template/replyLinear",
+ "colorSchema": "blue",
+ "name": "workflow:assigned_reply",
+ "intro": "workflow:intro_assigned_reply",
+ "inputs": [
+ {
+ "key": "text",
+ "label": "common:core.module.input.label.Response content",
+ "valueType": "any",
+ "required": true,
+ "isRichText": false,
+ "placeholder": "common:core.module.input.description.Response content",
+ "maxLength": 100000,
+ "renderTypeList": ["textarea", "reference"],
+ "description": "common:core.module.input.description.Response content",
+ "value": "recover",
+ "selectedTypeIndex": 0
+ }
+ ],
+ "outputs": [],
+ "nodeId": "recover"
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "code"
+ }
+ },
+ {
+ "source": {
+ "kind": "catch",
+ "nodeId": "code"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "recover"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/nested-loop/expected-diagnostics.json b/packages/workflow-core/test/fixtures/nested-loop/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/nested-loop/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/nested-loop/store-workflow.json b/packages/workflow-core/test/fixtures/nested-loop/store-workflow.json
new file mode 100644
index 000000000000..805efc98e888
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/nested-loop/store-workflow.json
@@ -0,0 +1,229 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "loopRun",
+ "avatar": "core/workflow/template/loopRun",
+ "avatarLinear": "core/workflow/template/loopRunLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run",
+ "intro": "workflow:intro_loop_run",
+ "showStatus": true,
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "loopRunMode",
+ "label": "workflow:loop_run_mode",
+ "valueType": "string",
+ "required": true,
+ "list": [
+ {
+ "label": "workflow:loop_run_mode_array",
+ "value": "array",
+ "icon": "core/workflow/inputType/array",
+ "description": "workflow:loop_run_mode_array_desc"
+ },
+ {
+ "label": "workflow:loop_run_mode_conditional",
+ "value": "conditional",
+ "icon": "core/workflow/inputType/conditional",
+ "description": "workflow:loop_run_mode_conditional_desc"
+ }
+ ],
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["select"],
+ "value": "conditional",
+ "description": "workflow:loop_run_mode_tip"
+ },
+ {
+ "key": "loopRunInputArray",
+ "label": "workflow:loop_run_input_array",
+ "valueType": "arrayAny",
+ "required": true,
+ "renderTypeList": ["reference"],
+ "value": []
+ },
+ {
+ "key": "loopCustomOutputs",
+ "label": "workflow:loop_custom_outputs",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false,
+ "hideBottomDivider": true
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "workflow:loop_custom_outputs_tip"
+ },
+ {
+ "key": "childrenNodeIdList",
+ "label": "",
+ "valueType": "arrayString",
+ "renderTypeList": ["hidden"],
+ "value": ["loop__start", "break"]
+ },
+ {
+ "key": "nodeWidth",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 900
+ },
+ {
+ "key": "nodeHeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 500
+ },
+ {
+ "key": "loopNodeInputHeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 320
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "loop"
+ },
+ {
+ "parentNodeId": "loop",
+ "flowNodeType": "loopRunStart",
+ "avatar": "core/workflow/template/loopRunStart",
+ "avatarLinear": "core/workflow/template/loopRunStartLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run_start",
+ "showStatus": false,
+ "inputs": [
+ {
+ "key": "loopRunMode",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "array"
+ },
+ {
+ "key": "loopStartInput",
+ "label": "",
+ "valueType": "any",
+ "renderTypeList": ["hidden"],
+ "value": ""
+ },
+ {
+ "key": "loopStartIndex",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "currentIndex",
+ "key": "currentIndex",
+ "type": "static",
+ "valueType": "number",
+ "label": "workflow:current_index",
+ "description": "workflow:current_index_desc"
+ },
+ {
+ "id": "currentItem",
+ "key": "currentItem",
+ "type": "static",
+ "valueType": "any",
+ "label": "workflow:current_item",
+ "description": "workflow:current_item_desc"
+ },
+ {
+ "id": "currentIteration",
+ "key": "currentIteration",
+ "type": "static",
+ "valueType": "number",
+ "label": "workflow:current_iteration",
+ "description": "workflow:current_iteration_desc"
+ }
+ ],
+ "nodeId": "loop__start"
+ },
+ {
+ "parentNodeId": "loop",
+ "flowNodeType": "loopRunBreak",
+ "avatar": "core/workflow/template/loopRunBreak",
+ "avatarLinear": "core/workflow/template/loopRunBreakLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run_break",
+ "intro": "workflow:loop_run_break_tip",
+ "showStatus": false,
+ "inputs": [],
+ "outputs": [],
+ "nodeId": "break"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "loop",
+ "targetHandle": "loop-target-left"
+ },
+ {
+ "source": "loop__start",
+ "sourceHandle": "loop__start-source-right",
+ "target": "break",
+ "targetHandle": "break-target-left"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/nested-loop/workflow.json b/packages/workflow-core/test/fixtures/nested-loop/workflow.json
new file mode 100644
index 000000000000..fe3ee9092ed2
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/nested-loop/workflow.json
@@ -0,0 +1,239 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "loopRun",
+ "avatar": "core/workflow/template/loopRun",
+ "avatarLinear": "core/workflow/template/loopRunLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run",
+ "intro": "workflow:intro_loop_run",
+ "showStatus": true,
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "loopRunMode",
+ "label": "workflow:loop_run_mode",
+ "valueType": "string",
+ "required": true,
+ "list": [
+ {
+ "label": "workflow:loop_run_mode_array",
+ "value": "array",
+ "icon": "core/workflow/inputType/array",
+ "description": "workflow:loop_run_mode_array_desc"
+ },
+ {
+ "label": "workflow:loop_run_mode_conditional",
+ "value": "conditional",
+ "icon": "core/workflow/inputType/conditional",
+ "description": "workflow:loop_run_mode_conditional_desc"
+ }
+ ],
+ "renderTypeList": ["select"],
+ "value": "conditional",
+ "description": "workflow:loop_run_mode_tip",
+ "selectedTypeIndex": 0
+ },
+ {
+ "key": "loopRunInputArray",
+ "label": "workflow:loop_run_input_array",
+ "valueType": "arrayAny",
+ "required": true,
+ "renderTypeList": ["reference"],
+ "value": []
+ },
+ {
+ "key": "loopCustomOutputs",
+ "label": "workflow:loop_custom_outputs",
+ "valueType": "dynamic",
+ "required": false,
+ "customInputConfig": {
+ "selectValueTypeList": [
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "arrayString",
+ "arrayNumber",
+ "arrayBoolean",
+ "arrayObject",
+ "arrayAny",
+ "any",
+ "chatHistory",
+ "datasetQuote",
+ "dynamic",
+ "selectDataset",
+ "selectApp"
+ ],
+ "showDefaultValue": false,
+ "showDescription": false,
+ "hideBottomDivider": true
+ },
+ "renderTypeList": ["addInputParam"],
+ "description": "workflow:loop_custom_outputs_tip"
+ },
+ {
+ "key": "childrenNodeIdList",
+ "label": "",
+ "valueType": "arrayString",
+ "renderTypeList": ["hidden"],
+ "value": ["loop__start", "break"]
+ },
+ {
+ "key": "nodeWidth",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 900
+ },
+ {
+ "key": "nodeHeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 500
+ },
+ {
+ "key": "loopNodeInputHeight",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"],
+ "value": 320
+ }
+ ],
+ "outputs": [
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "loop"
+ },
+ {
+ "parentNodeId": "loop",
+ "flowNodeType": "loopRunStart",
+ "avatar": "core/workflow/template/loopRunStart",
+ "avatarLinear": "core/workflow/template/loopRunStartLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run_start",
+ "showStatus": false,
+ "inputs": [
+ {
+ "key": "loopRunMode",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"],
+ "value": "array"
+ },
+ {
+ "key": "loopStartInput",
+ "label": "",
+ "valueType": "any",
+ "renderTypeList": ["hidden"],
+ "value": ""
+ },
+ {
+ "key": "loopStartIndex",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "currentIndex",
+ "key": "currentIndex",
+ "type": "static",
+ "valueType": "number",
+ "label": "workflow:current_index",
+ "description": "workflow:current_index_desc"
+ },
+ {
+ "id": "currentItem",
+ "key": "currentItem",
+ "type": "static",
+ "valueType": "any",
+ "label": "workflow:current_item",
+ "description": "workflow:current_item_desc"
+ },
+ {
+ "id": "currentIteration",
+ "key": "currentIteration",
+ "type": "static",
+ "valueType": "number",
+ "label": "workflow:current_iteration",
+ "description": "workflow:current_iteration_desc"
+ }
+ ],
+ "nodeId": "loop__start"
+ },
+ {
+ "parentNodeId": "loop",
+ "flowNodeType": "loopRunBreak",
+ "avatar": "core/workflow/template/loopRunBreak",
+ "avatarLinear": "core/workflow/template/loopRunBreakLinear",
+ "colorSchema": "loopRun",
+ "name": "workflow:loop_run_break",
+ "intro": "workflow:loop_run_break_tip",
+ "showStatus": false,
+ "inputs": [],
+ "outputs": [],
+ "nodeId": "break"
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "loop"
+ }
+ },
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "loop__start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "break"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/pr3.ts b/packages/workflow-core/test/fixtures/pr3.ts
new file mode 100644
index 000000000000..8fcbc4381fe5
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/pr3.ts
@@ -0,0 +1,195 @@
+import {
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ FlowNodeOutputTypeEnum,
+ parseNodeTemplateRef,
+ WorkflowIOValueTypeEnum,
+ type WorkflowCommand,
+ type WorkflowDocument
+} from '../../src';
+import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
+import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
+import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
+
+const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+
+const apply = async (document: WorkflowDocument, command: WorkflowCommand) =>
+ (await applyWorkflowCommand({ document, command, dependencies })).document;
+
+const addStart = () =>
+ apply(createWorkflowDocument(), {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ });
+
+const addAnswer = async ({
+ document,
+ nodeId,
+ source
+}: {
+ document: WorkflowDocument;
+ nodeId: string;
+ source: WorkflowCommand & { type: 'edge.connect' };
+}) => {
+ let next = await apply(document, {
+ type: 'node.add',
+ nodeId,
+ template: parseNodeTemplateRef('builtin:assigned-answer')
+ });
+ next = await apply(next, source);
+ return apply(next, {
+ type: 'input.set',
+ nodeId,
+ inputKey: NodeInputKeyEnum.answerText,
+ value: nodeId
+ });
+};
+
+export const createBranchingFixture = async () => {
+ let document = await addStart();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'route',
+ template: parseNodeTemplateRef('builtin:if-else'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ });
+ document = await apply(document, {
+ type: 'input.set',
+ nodeId: 'route',
+ inputKey: NodeInputKeyEnum.ifElseList,
+ value: [
+ {
+ branchId: 'positive',
+ condition: 'AND',
+ list: [
+ {
+ variable: ['start', 'userChatInput'],
+ condition: VariableConditionEnum.isNotEmpty,
+ valueType: 'input'
+ }
+ ]
+ }
+ ]
+ });
+ document = await addAnswer({
+ document,
+ nodeId: 'yes',
+ source: {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'branch', nodeId: 'route', branchKey: 'positive' },
+ target: { kind: 'target', nodeId: 'yes' }
+ }
+ }
+ });
+ return addAnswer({
+ document,
+ nodeId: 'fallback',
+ source: {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'branch', nodeId: 'route', branchKey: 'ELSE' },
+ target: { kind: 'target', nodeId: 'fallback' }
+ }
+ }
+ });
+};
+
+export const createToolCallToolsFixture = async () => {
+ let document = await addStart();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'caller',
+ template: parseNodeTemplateRef('builtin:tool-call'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ });
+ return apply(document, {
+ type: 'tool.attach',
+ toolCallNodeId: 'caller',
+ template: parseNodeTemplateRef('builtin:user-select'),
+ newNodeId: 'confirm'
+ });
+};
+
+export const createNestedLoopFixture = async () => {
+ let document = await addStart();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'loop',
+ template: parseNodeTemplateRef('builtin:loop-run'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ });
+ document = await apply(document, {
+ type: 'input.set',
+ nodeId: 'loop',
+ inputKey: NodeInputKeyEnum.loopRunMode,
+ value: LoopRunModeEnum.conditional
+ });
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'break',
+ template: parseNodeTemplateRef('builtin:loop-run-break'),
+ parentNodeId: 'loop'
+ });
+ return apply(document, {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'next', nodeId: 'loop__start' },
+ target: { kind: 'target', nodeId: 'break' }
+ }
+ });
+};
+
+export const createDynamicIoCatchFixture = async () => {
+ let document = await addStart();
+ document = await apply(document, {
+ type: 'node.add',
+ nodeId: 'code',
+ template: parseNodeTemplateRef('builtin:code'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ });
+ document = await apply(document, {
+ type: 'node.update',
+ nodeId: 'code',
+ catchError: true
+ });
+ for (const inputKey of ['data1', 'data2']) {
+ document = await apply(document, {
+ type: 'input.ref',
+ nodeId: 'code',
+ inputKey,
+ ref: { nodeId: 'start', outputKey: 'userChatInput' }
+ });
+ }
+ document = await apply(document, {
+ type: 'output.add',
+ nodeId: 'code',
+ output: {
+ id: 'score',
+ key: 'score',
+ label: 'Score',
+ type: FlowNodeOutputTypeEnum.dynamic,
+ valueType: WorkflowIOValueTypeEnum.number
+ }
+ });
+ return addAnswer({
+ document,
+ nodeId: 'recover',
+ source: {
+ type: 'edge.connect',
+ edge: {
+ source: { kind: 'catch', nodeId: 'code' },
+ target: { kind: 'target', nodeId: 'recover' }
+ }
+ }
+ });
+};
+
+export const pr3FixtureFactories = {
+ branching: createBranchingFixture,
+ 'tool-call-tools': createToolCallToolsFixture,
+ 'nested-loop': createNestedLoopFixture,
+ 'dynamic-io-catch': createDynamicIoCatchFixture
+};
diff --git a/packages/workflow-core/test/fixtures/tool-call-tools/expected-diagnostics.json b/packages/workflow-core/test/fixtures/tool-call-tools/expected-diagnostics.json
new file mode 100644
index 000000000000..fe51488c7066
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/tool-call-tools/expected-diagnostics.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/workflow-core/test/fixtures/tool-call-tools/store-workflow.json b/packages/workflow-core/test/fixtures/tool-call-tools/store-workflow.json
new file mode 100644
index 000000000000..f4952b824162
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/tool-call-tools/store-workflow.json
@@ -0,0 +1,265 @@
+{
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "tools",
+ "avatar": "core/workflow/template/toolCall",
+ "avatarLinear": "core/workflow/template/toolCallLinear",
+ "colorSchema": "indigo",
+ "name": "workflow:template.agent",
+ "intro": "workflow:template.agent_intro",
+ "showStatus": true,
+ "version": "4.9.2",
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "model",
+ "label": "common:core.module.input.label.aiModel",
+ "valueType": "string",
+ "renderTypeList": ["settingLLMModel", "reference"]
+ },
+ {
+ "key": "temperature",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "maxToken",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "isResponseAnswerText",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatVision",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatAudio",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatVideo",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatExtractFiles",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoning",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoningEffort",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatTopP",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatStopSign",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatResponseFormat",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatJsonSchema",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "useAgentSandbox",
+ "label": "app:use_agent_sandbox",
+ "valueType": "boolean",
+ "renderTypeList": ["switch"],
+ "value": false,
+ "description": "app:use_computer_desc"
+ },
+ {
+ "key": "sandboxEntrypoint",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["custom"]
+ },
+ {
+ "key": "systemPrompt",
+ "label": "common:core.ai.Prompt",
+ "valueType": "string",
+ "isRichText": true,
+ "placeholder": "common:core.app.tip.chatNodeSystemPromptTip",
+ "maxLength": 100000,
+ "renderTypeList": ["textarea", "reference"],
+ "description": "common:core.app.tip.systemPromptTip"
+ },
+ {
+ "key": "history",
+ "label": "common:core.module.input.label.chat history",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "workflow:max_dialog_rounds"
+ },
+ {
+ "key": "fileUrlList",
+ "label": "app:workflow.user_file_input",
+ "valueType": "arrayString",
+ "renderTypeList": ["reference", "input"],
+ "debugLabel": "app:workflow.user_file_input",
+ "description": "app:workflow.user_file_input_desc"
+ },
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "selectedTypeIndex": 0,
+ "renderTypeList": ["reference", "textarea"],
+ "value": ["start", "userChatInput"],
+ "toolDescription": "user question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "answerText",
+ "key": "answerText",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.output.label.Ai response content",
+ "description": "common:core.module.output.description.Ai response content"
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "caller"
+ },
+ {
+ "flowNodeType": "userSelect",
+ "avatar": "core/workflow/template/userSelect",
+ "avatarLinear": "core/workflow/template/userSelectLinear",
+ "colorSchema": "green",
+ "name": "app:workflow.user_select",
+ "intro": "app:workflow.user_select_tip",
+ "inputs": [
+ {
+ "key": "description",
+ "label": "app:workflow.select_description",
+ "valueType": "string",
+ "placeholder": "app:workflow.select_description_placeholder",
+ "renderTypeList": ["textarea"],
+ "description": "app:workflow.select_description_tip"
+ },
+ {
+ "key": "userSelectOptions",
+ "label": "",
+ "valueType": "any",
+ "renderTypeList": ["custom"],
+ "value": [
+ {
+ "value": "Confirm",
+ "key": "option1"
+ },
+ {
+ "value": "Cancel",
+ "key": "option2"
+ }
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "selectResult",
+ "key": "selectResult",
+ "type": "static",
+ "valueType": "string",
+ "label": "app:workflow.select_result",
+ "required": true
+ }
+ ],
+ "nodeId": "confirm"
+ }
+ ],
+ "edges": [
+ {
+ "source": "start",
+ "sourceHandle": "start-source-right",
+ "target": "caller",
+ "targetHandle": "caller-target-left"
+ },
+ {
+ "source": "caller",
+ "sourceHandle": "selectedTools",
+ "target": "confirm",
+ "targetHandle": "selectedTools"
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/fixtures/tool-call-tools/workflow.json b/packages/workflow-core/test/fixtures/tool-call-tools/workflow.json
new file mode 100644
index 000000000000..f74d06f0e874
--- /dev/null
+++ b/packages/workflow-core/test/fixtures/tool-call-tools/workflow.json
@@ -0,0 +1,275 @@
+{
+ "schemaVersion": "fastgpt-workflow/v1",
+ "app": {},
+ "nodes": [
+ {
+ "flowNodeType": "workflowStart",
+ "avatar": "core/workflow/template/workflowStart",
+ "avatarLinear": "core/workflow/template/workflowStartLinear",
+ "colorSchema": "blue",
+ "name": "workflow:template.workflow_start",
+ "inputs": [
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "workflow:user_question"
+ }
+ ],
+ "outputs": [
+ {
+ "id": "userChatInput",
+ "key": "userChatInput",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.input.label.user question"
+ }
+ ],
+ "nodeId": "start"
+ },
+ {
+ "flowNodeType": "tools",
+ "avatar": "core/workflow/template/toolCall",
+ "avatarLinear": "core/workflow/template/toolCallLinear",
+ "colorSchema": "indigo",
+ "name": "workflow:template.agent",
+ "intro": "workflow:template.agent_intro",
+ "showStatus": true,
+ "version": "4.9.2",
+ "catchError": false,
+ "inputs": [
+ {
+ "key": "model",
+ "label": "common:core.module.input.label.aiModel",
+ "valueType": "string",
+ "renderTypeList": ["settingLLMModel", "reference"]
+ },
+ {
+ "key": "temperature",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "maxToken",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "isResponseAnswerText",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatVision",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatAudio",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatVideo",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": false
+ },
+ {
+ "key": "aiChatExtractFiles",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoning",
+ "label": "",
+ "valueType": "boolean",
+ "renderTypeList": ["hidden"],
+ "value": true
+ },
+ {
+ "key": "aiChatReasoningEffort",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatTopP",
+ "label": "",
+ "valueType": "number",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatStopSign",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatResponseFormat",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "aiChatJsonSchema",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["hidden"]
+ },
+ {
+ "key": "useAgentSandbox",
+ "label": "app:use_agent_sandbox",
+ "valueType": "boolean",
+ "renderTypeList": ["switch"],
+ "value": false,
+ "description": "app:use_computer_desc"
+ },
+ {
+ "key": "sandboxEntrypoint",
+ "label": "",
+ "valueType": "string",
+ "renderTypeList": ["custom"]
+ },
+ {
+ "key": "systemPrompt",
+ "label": "common:core.ai.Prompt",
+ "valueType": "string",
+ "isRichText": true,
+ "placeholder": "common:core.app.tip.chatNodeSystemPromptTip",
+ "maxLength": 100000,
+ "renderTypeList": ["textarea", "reference"],
+ "description": "common:core.app.tip.systemPromptTip"
+ },
+ {
+ "key": "history",
+ "label": "common:core.module.input.label.chat history",
+ "valueType": "chatHistory",
+ "required": true,
+ "max": 50,
+ "min": 0,
+ "renderTypeList": ["numberInput", "reference"],
+ "value": 6,
+ "description": "workflow:max_dialog_rounds"
+ },
+ {
+ "key": "fileUrlList",
+ "label": "app:workflow.user_file_input",
+ "valueType": "arrayString",
+ "renderTypeList": ["reference", "input"],
+ "debugLabel": "app:workflow.user_file_input",
+ "description": "app:workflow.user_file_input_desc"
+ },
+ {
+ "key": "userChatInput",
+ "label": "workflow:user_question",
+ "valueType": "string",
+ "required": true,
+ "renderTypeList": ["reference", "textarea"],
+ "toolDescription": "user question",
+ "value": ["start", "userChatInput"],
+ "selectedTypeIndex": 0
+ }
+ ],
+ "outputs": [
+ {
+ "id": "answerText",
+ "key": "answerText",
+ "type": "static",
+ "valueType": "string",
+ "label": "common:core.module.output.label.Ai response content",
+ "description": "common:core.module.output.description.Ai response content"
+ },
+ {
+ "id": "system_error_text",
+ "key": "system_error_text",
+ "type": "error",
+ "valueType": "string",
+ "label": "workflow:error_text"
+ }
+ ],
+ "nodeId": "caller"
+ },
+ {
+ "flowNodeType": "userSelect",
+ "avatar": "core/workflow/template/userSelect",
+ "avatarLinear": "core/workflow/template/userSelectLinear",
+ "colorSchema": "green",
+ "name": "app:workflow.user_select",
+ "intro": "app:workflow.user_select_tip",
+ "inputs": [
+ {
+ "key": "description",
+ "label": "app:workflow.select_description",
+ "valueType": "string",
+ "placeholder": "app:workflow.select_description_placeholder",
+ "renderTypeList": ["textarea"],
+ "description": "app:workflow.select_description_tip"
+ },
+ {
+ "key": "userSelectOptions",
+ "label": "",
+ "valueType": "any",
+ "renderTypeList": ["custom"],
+ "value": [
+ {
+ "value": "Confirm",
+ "key": "option1"
+ },
+ {
+ "value": "Cancel",
+ "key": "option2"
+ }
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "selectResult",
+ "key": "selectResult",
+ "type": "static",
+ "valueType": "string",
+ "label": "app:workflow.select_result",
+ "required": true
+ }
+ ],
+ "nodeId": "confirm"
+ }
+ ],
+ "executionEdges": [
+ {
+ "source": {
+ "kind": "next",
+ "nodeId": "start"
+ },
+ "target": {
+ "kind": "target",
+ "nodeId": "caller"
+ }
+ },
+ {
+ "source": {
+ "kind": "selectedTools",
+ "nodeId": "caller"
+ },
+ "target": {
+ "kind": "selectedTools",
+ "nodeId": "confirm"
+ }
+ }
+ ],
+ "chatConfig": {}
+}
diff --git a/packages/workflow-core/test/reference/codec.test.ts b/packages/workflow-core/test/reference/codec.test.ts
new file mode 100644
index 000000000000..763fe2f92e6c
--- /dev/null
+++ b/packages/workflow-core/test/reference/codec.test.ts
@@ -0,0 +1,70 @@
+import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
+import { WorkflowDocumentSchema } from '../../src';
+import {
+ decodeWorkflowNodeReferences,
+ encodeWorkflowNodeReferences
+} from '../../src/reference/codec';
+import commonLinearWorkflow from '../fixtures/common-linear/workflow.json';
+import { describe, expect, it } from 'vitest';
+
+describe('workflow reference codec', () => {
+ const createNodes = () => {
+ const document = WorkflowDocumentSchema.parse(commonLinearWorkflow);
+ const answerInput = document.nodes
+ .find((node) => node.nodeId === 'answer')!
+ .inputs.find((input) => input.key === 'text')!;
+ answerInput.value = ['code', 'result'];
+
+ const httpHeaderInput = document.nodes
+ .find((node) => node.nodeId === 'http')!
+ .inputs.find((input) => input.key === 'system_httpHeader')!;
+ httpHeaderInput.value = [
+ {
+ key: 'x-result',
+ value:
+ 'Result: {{$code.result$}}; Global: {{$VARIABLE_NODE_ID.globalResult$}}; Missing: {{$missing.result$}}',
+ nested: {
+ refs: [
+ ['code', 'result'],
+ [VARIABLE_NODE_ID, 'globalResult'],
+ ['missing', 'result']
+ ]
+ }
+ }
+ ];
+ return document.nodes;
+ };
+
+ it('encodes output keys recursively without mutating the document nodes', () => {
+ const nodes = createNodes();
+ const encodedNodes = encodeWorkflowNodeReferences(nodes);
+ const encodedAnswerInput = encodedNodes
+ .find((node) => node.nodeId === 'answer')!
+ .inputs.find((input) => input.key === 'text')!;
+ const encodedHeaders = encodedNodes
+ .find((node) => node.nodeId === 'http')!
+ .inputs.find((input) => input.key === 'system_httpHeader')!.value as Array<{
+ value: string;
+ nested: { refs: string[][] };
+ }>;
+
+ expect(encodedAnswerInput.value).toEqual(['code', 'qLUQfhG0ILRX']);
+ expect(encodedHeaders[0].value).toBe(
+ 'Result: {{$code.qLUQfhG0ILRX$}}; Global: {{$VARIABLE_NODE_ID.globalResult$}}; Missing: {{$missing.result$}}'
+ );
+ expect(encodedHeaders[0].nested.refs).toEqual([
+ ['code', 'qLUQfhG0ILRX'],
+ [VARIABLE_NODE_ID, 'globalResult'],
+ ['missing', 'result']
+ ]);
+ expect(
+ nodes.find((node) => node.nodeId === 'answer')!.inputs.find((input) => input.key === 'text')!
+ .value
+ ).toEqual(['code', 'result']);
+ });
+
+ it('decodes output ids back to stable output keys', () => {
+ const nodes = createNodes();
+ expect(decodeWorkflowNodeReferences(encodeWorkflowNodeReferences(nodes))).toEqual(nodes);
+ });
+});
diff --git a/packages/workflow-core/test/reference/service.test.ts b/packages/workflow-core/test/reference/service.test.ts
new file mode 100644
index 000000000000..e7c39f4fb3d1
--- /dev/null
+++ b/packages/workflow-core/test/reference/service.test.ts
@@ -0,0 +1,104 @@
+import {
+ WorkflowCommandError,
+ WorkflowDocumentSchema,
+ WorkflowIOValueTypeEnum,
+ areWorkflowValueTypesCompatible,
+ setInputReference,
+ setInputValue
+} from '../../src';
+import aiWorkflow from '../fixtures/basic-ai/workflow.json';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+describe('input mutation services', () => {
+ let document: ReturnType;
+
+ beforeEach(() => {
+ document = WorkflowDocumentSchema.parse(aiWorkflow);
+ });
+
+ it('validates node, input, configurability, mode and scalar type', () => {
+ expect(() =>
+ setInputValue({ document, nodeId: 'missing', inputKey: 'model', value: 'x' })
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ setInputValue({ document, nodeId: 'ai', inputKey: 'missing', value: 'x' })
+ ).toThrow(WorkflowCommandError);
+
+ const model = document.nodes.find((node) => node.nodeId === 'ai')!.inputs[0];
+ model.canEdit = false;
+ expect(() =>
+ setInputValue({ document, nodeId: 'ai', inputKey: model.key, value: 'x' })
+ ).toThrow(WorkflowCommandError);
+ model.canEdit = true;
+
+ expect(() =>
+ setInputValue({ document, nodeId: 'ai', inputKey: 'maxToken', value: '100' })
+ ).toThrow(WorkflowCommandError);
+ setInputValue({ document, nodeId: 'ai', inputKey: 'maxToken', value: 100 });
+ expect(
+ document.nodes
+ .find((node) => node.nodeId === 'ai')!
+ .inputs.find((input) => input.key === 'maxToken')?.value
+ ).toBe(100);
+ });
+
+ it('validates reference mode, source output and value type', () => {
+ expect(() =>
+ setInputReference({
+ document,
+ nodeId: 'ai',
+ inputKey: 'maxToken',
+ ref: { nodeId: 'start', outputKey: 'userChatInput' }
+ })
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ setInputReference({
+ document,
+ nodeId: 'ai',
+ inputKey: 'userChatInput',
+ ref: { nodeId: 'start', outputKey: 'missing' }
+ })
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ setInputReference({
+ document,
+ nodeId: 'ai',
+ inputKey: 'fileUrlList',
+ ref: { nodeId: 'start', outputKey: 'userChatInput' }
+ })
+ ).toThrow(WorkflowCommandError);
+ expect(() =>
+ setInputReference({
+ document,
+ nodeId: 'ai',
+ inputKey: 'userChatInput',
+ ref: { nodeId: 'ai', outputKey: 'answerText' }
+ })
+ ).toThrow(WorkflowCommandError);
+ });
+});
+
+describe('areWorkflowValueTypesCompatible', () => {
+ it('accepts scalar items only for aggregate references', () => {
+ expect(
+ areWorkflowValueTypesCompatible({
+ expected: WorkflowIOValueTypeEnum.arrayString,
+ actual: WorkflowIOValueTypeEnum.string,
+ collection: true
+ })
+ ).toBe(true);
+ expect(
+ areWorkflowValueTypesCompatible({
+ expected: WorkflowIOValueTypeEnum.arrayString,
+ actual: WorkflowIOValueTypeEnum.string
+ })
+ ).toBe(false);
+ expect(
+ areWorkflowValueTypesCompatible({
+ expected: WorkflowIOValueTypeEnum.arrayString,
+ actual: WorkflowIOValueTypeEnum.number,
+ collection: true
+ })
+ ).toBe(false);
+ });
+});
diff --git a/packages/workflow-core/test/store/roundtrip.test.ts b/packages/workflow-core/test/store/roundtrip.test.ts
new file mode 100644
index 000000000000..f280f89eaf64
--- /dev/null
+++ b/packages/workflow-core/test/store/roundtrip.test.ts
@@ -0,0 +1,90 @@
+import {
+ WorkflowDocumentSchema,
+ compileStoreWorkflow,
+ decompileStoreWorkflow,
+ getWorkflowChecksum,
+ normalizeWorkflowDocument,
+ validateWorkflow
+} from '../../src';
+import { WorkflowTemplateBasicTypeSchema } from '@fastgpt/global/core/workflow/type';
+import aiStore from '../fixtures/basic-ai/store-workflow.json';
+import aiWorkflow from '../fixtures/basic-ai/workflow.json';
+import staticStore from '../fixtures/basic-static/store-workflow.json';
+import staticWorkflow from '../fixtures/basic-static/workflow.json';
+import commonLinearStore from '../fixtures/common-linear/store-workflow.json';
+import commonLinearWorkflow from '../fixtures/common-linear/workflow.json';
+import branchingStore from '../fixtures/branching/store-workflow.json';
+import branchingWorkflow from '../fixtures/branching/workflow.json';
+import toolCallToolsStore from '../fixtures/tool-call-tools/store-workflow.json';
+import toolCallToolsWorkflow from '../fixtures/tool-call-tools/workflow.json';
+import nestedLoopStore from '../fixtures/nested-loop/store-workflow.json';
+import nestedLoopWorkflow from '../fixtures/nested-loop/workflow.json';
+import dynamicIoCatchStore from '../fixtures/dynamic-io-catch/store-workflow.json';
+import dynamicIoCatchWorkflow from '../fixtures/dynamic-io-catch/workflow.json';
+import { describe, expect, it } from 'vitest';
+
+describe('StoreWorkflow round-trip', () => {
+ it.each([
+ ['basic-ai', aiStore, aiWorkflow],
+ ['basic-static', staticStore, staticWorkflow],
+ ['common-linear', commonLinearStore, commonLinearWorkflow],
+ ['branching', branchingStore, branchingWorkflow],
+ ['tool-call-tools', toolCallToolsStore, toolCallToolsWorkflow],
+ ['nested-loop', nestedLoopStore, nestedLoopWorkflow],
+ ['dynamic-io-catch', dynamicIoCatchStore, dynamicIoCatchWorkflow]
+ ])('preserves %s semantics', (_name, store, workflow) => {
+ const parsedStore = WorkflowTemplateBasicTypeSchema.parse(store);
+ const parsedWorkflow = WorkflowDocumentSchema.parse(workflow);
+ const document = decompileStoreWorkflow({ workflow: parsedStore, app: parsedWorkflow.app });
+ expect(compileStoreWorkflow(document)).toEqual(parsedStore);
+ expect(normalizeWorkflowDocument(document)).toEqual(normalizeWorkflowDocument(parsedWorkflow));
+ expect(validateWorkflow(document)).toEqual([]);
+ });
+
+ it('computes the same checksum regardless of node and edge order', () => {
+ const parsedWorkflow = WorkflowDocumentSchema.parse(aiWorkflow);
+ const reversed = {
+ ...parsedWorkflow,
+ nodes: [...parsedWorkflow.nodes].reverse(),
+ executionEdges: [...parsedWorkflow.executionEdges].reverse()
+ };
+ expect(getWorkflowChecksum(parsedWorkflow)).toBe(getWorkflowChecksum(reversed));
+ });
+
+ it('compiles semantic output keys to Store output ids and decompiles them back', () => {
+ const document = WorkflowDocumentSchema.parse(commonLinearWorkflow);
+ const answerInput = document.nodes
+ .find((node) => node.nodeId === 'answer')!
+ .inputs.find((input) => input.key === 'text')!;
+ answerInput.value = ['code', 'result'];
+
+ const requestUrlInput = document.nodes
+ .find((node) => node.nodeId === 'http')!
+ .inputs.find((input) => input.key === 'system_httpReqUrl')!;
+ requestUrlInput.value = 'https://example.com/{{$code.result$}}';
+
+ const store = compileStoreWorkflow(document);
+ expect(
+ store.nodes
+ .find((node) => node.nodeId === 'answer')!
+ .inputs.find((input) => input.key === 'text')!.value
+ ).toEqual(['code', 'qLUQfhG0ILRX']);
+ expect(
+ store.nodes
+ .find((node) => node.nodeId === 'http')!
+ .inputs.find((input) => input.key === 'system_httpReqUrl')!.value
+ ).toBe('https://example.com/{{$code.qLUQfhG0ILRX$}}');
+
+ const roundTripDocument = decompileStoreWorkflow({ workflow: store });
+ expect(
+ roundTripDocument.nodes
+ .find((node) => node.nodeId === 'answer')!
+ .inputs.find((input) => input.key === 'text')!.value
+ ).toEqual(['code', 'result']);
+ expect(
+ roundTripDocument.nodes
+ .find((node) => node.nodeId === 'http')!
+ .inputs.find((input) => input.key === 'system_httpReqUrl')!.value
+ ).toBe('https://example.com/{{$code.result$}}');
+ });
+});
diff --git a/packages/workflow-core/test/template/defaultValue.test.ts b/packages/workflow-core/test/template/defaultValue.test.ts
new file mode 100644
index 000000000000..941907b838b2
--- /dev/null
+++ b/packages/workflow-core/test/template/defaultValue.test.ts
@@ -0,0 +1,80 @@
+import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
+import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
+import { getResourceSafeEmptyValue, hasConfiguredValue, resolveInitialInputValue } from '../../src';
+import { describe, expect, it } from 'vitest';
+
+const createInput = (patch: Partial = {}): FlowNodeInputItemType => ({
+ key: 'value',
+ label: 'Value',
+ renderTypeList: [FlowNodeInputTypeEnum.input],
+ valueType: WorkflowIOValueTypeEnum.string,
+ ...patch
+});
+
+describe('resolveInitialInputValue', () => {
+ it.each([
+ { value: '', name: 'empty string' },
+ { value: false, name: 'false' },
+ { value: 0, name: 'zero' },
+ { value: [], name: 'empty array' }
+ ])('keeps a validated remote $name instead of the template default', ({ value }) => {
+ expect(
+ resolveInitialInputValue({
+ input: createInput({ value: 'template' }),
+ meta: { defaultPolicy: 'remoteValidated', resourceKind: 'model' },
+ validatedRemoteDefault: { provided: true, value }
+ })
+ ).toEqual(value);
+ });
+
+ it.each([
+ { value: '', name: 'empty string' },
+ { value: false, name: 'false' },
+ { value: 0, name: 'zero' }
+ ])('keeps a safe template $name', ({ value }) => {
+ expect(resolveInitialInputValue({ input: createInput({ value }) })).toEqual(value);
+ });
+
+ it('never accepts template or remote defaults for user-required secrets', () => {
+ expect(
+ resolveInitialInputValue({
+ input: createInput({ value: 'template-secret' }),
+ meta: { defaultPolicy: 'userRequired', resourceKind: 'secret' },
+ validatedRemoteDefault: { provided: true, value: 'remote-secret' }
+ })
+ ).toBeUndefined();
+ });
+
+ it('maps unverified resources and array inputs to type-safe empty values', () => {
+ expect(
+ resolveInitialInputValue({
+ input: createInput({
+ value: [{ datasetId: 'template-dataset' }],
+ valueType: WorkflowIOValueTypeEnum.selectDataset
+ }),
+ meta: { defaultPolicy: 'remoteValidated', resourceKind: 'dataset' }
+ })
+ ).toEqual([]);
+ expect(
+ resolveInitialInputValue({
+ input: createInput({ value: 'template-model' }),
+ meta: { defaultPolicy: 'remoteValidated', resourceKind: 'model' }
+ })
+ ).toBeUndefined();
+ expect(getResourceSafeEmptyValue({ valueType: WorkflowIOValueTypeEnum.arrayString })).toEqual(
+ []
+ );
+ });
+});
+
+describe('hasConfiguredValue', () => {
+ it('treats template empty values as available for deterministic Start references', () => {
+ expect(hasConfiguredValue(undefined)).toBe(false);
+ expect(hasConfiguredValue(null)).toBe(false);
+ expect(hasConfiguredValue('')).toBe(false);
+ expect(hasConfiguredValue([])).toBe(false);
+ expect(hasConfiguredValue(false)).toBe(true);
+ expect(hasConfiguredValue(0)).toBe(true);
+ });
+});
diff --git a/packages/workflow-core/test/template/template.test.ts b/packages/workflow-core/test/template/template.test.ts
new file mode 100644
index 000000000000..f0b38c639972
--- /dev/null
+++ b/packages/workflow-core/test/template/template.test.ts
@@ -0,0 +1,292 @@
+import {
+ FlowNodeInputTypeEnum,
+ WorkflowIOValueTypeEnum,
+ WorkflowCommandError,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ formatNodeTemplateRef,
+ instantiateNodeFromTemplate,
+ normalizeNodeTemplateDescriptor,
+ parseNodeTemplateRef
+} from '../../src';
+import { describe, expect, it } from 'vitest';
+
+describe('builtinTemplateProvider', () => {
+ it('exposes the PR1 through PR3 builtin templates', async () => {
+ await expect(builtinTemplateProvider.list({ locale: 'en' })).resolves.toEqual([
+ { kind: 'builtin', templateId: 'workflow-start' },
+ { kind: 'builtin', templateId: 'ai-chat' },
+ { kind: 'builtin', templateId: 'text-editor' },
+ { kind: 'builtin', templateId: 'assigned-answer' },
+ { kind: 'builtin', templateId: 'dataset-search' },
+ { kind: 'builtin', templateId: 'question-optimization' },
+ { kind: 'builtin', templateId: 'content-extract' },
+ { kind: 'builtin', templateId: 'http-request' },
+ { kind: 'builtin', templateId: 'code' },
+ { kind: 'builtin', templateId: 'call-app' },
+ { kind: 'builtin', templateId: 'if-else' },
+ { kind: 'builtin', templateId: 'question-classification' },
+ { kind: 'builtin', templateId: 'user-select' },
+ { kind: 'builtin', templateId: 'form-input' },
+ { kind: 'builtin', templateId: 'tool-call' },
+ { kind: 'builtin', templateId: 'read-files' },
+ { kind: 'builtin', templateId: 'variable-update' },
+ { kind: 'builtin', templateId: 'parallel-run' },
+ { kind: 'builtin', templateId: 'loop-run' },
+ { kind: 'builtin', templateId: 'loop-run-break' },
+ { kind: 'builtin', templateId: 'dataset-concat' },
+ { kind: 'builtin', templateId: 'custom-feedback' }
+ ]);
+ await expect(
+ builtinTemplateProvider.resolve(parseNodeTemplateRef('builtin:missing'), { locale: 'en' })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+
+ it('publishes value schemas for common complex PR2 parameters', async () => {
+ const ref = parseNodeTemplateRef('builtin:http-request');
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const descriptor = normalizeNodeTemplateDescriptor({
+ ...resolved,
+ templateRef: ref
+ });
+ expect(
+ descriptor.inputs.find((input) => input.key === 'system_httpHeader')?.constraints?.valueSchema
+ ).toMatchObject({ type: 'array' });
+ expect(
+ descriptor.inputs.find((input) => input.key === 'system_addInputParam')?.configurable
+ ).toBe(false);
+ });
+
+ it('keeps automation metadata aligned with template inputs and explicit for resources', async () => {
+ const resourceRenderTypes = new Map([
+ [FlowNodeInputTypeEnum.selectDataset, 'dataset'],
+ [FlowNodeInputTypeEnum.selectLLMModel, 'model'],
+ [FlowNodeInputTypeEnum.settingLLMModel, 'model'],
+ [FlowNodeInputTypeEnum.selectApp, 'app'],
+ [FlowNodeInputTypeEnum.password, 'secret']
+ ]);
+ for (const ref of await builtinTemplateProvider.list({ locale: 'en' })) {
+ const templateName = formatNodeTemplateRef(ref);
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const inputKeys = new Set(resolved.template.inputs.map((input) => input.key));
+ for (const [inputKey, meta] of Object.entries(resolved.automationMeta?.inputs ?? {})) {
+ expect(inputKeys.has(inputKey), `${templateName}.${inputKey}`).toBe(true);
+ if (meta.resourceKind !== undefined) {
+ expect(meta.defaultPolicy, `${templateName}.${inputKey}`).toBeDefined();
+ }
+ if (meta.resourceKind === 'secret') {
+ expect(meta.defaultPolicy, `${templateName}.${inputKey}`).toBe('userRequired');
+ }
+ }
+ for (const input of resolved.template.inputs) {
+ const resourceKind = input.renderTypeList
+ .map((renderType) => resourceRenderTypes.get(renderType))
+ .find((value) => value !== undefined);
+ if (resourceKind !== undefined) {
+ expect(
+ resolved.automationMeta?.inputs?.[input.key],
+ `${templateName}.${input.key}`
+ ).toMatchObject({ resourceKind });
+ }
+ }
+ }
+ });
+});
+
+describe('normalizeNodeTemplateDescriptor', () => {
+ it('normalizes current template fields and automation metadata', async () => {
+ const ref = parseNodeTemplateRef('builtin:ai-chat');
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const descriptor = normalizeNodeTemplateDescriptor({
+ ...resolved,
+ templateRef: ref,
+ translate: (value) => `translated:${value}`
+ });
+ const userInput = descriptor.inputs.find((input) => input.key === 'userChatInput');
+ const promptInput = descriptor.inputs.find((input) => input.key === 'systemPrompt');
+ const modelInput = descriptor.inputs.find((input) => input.key === 'model');
+
+ expect(descriptor.name).toMatch(/^translated:/);
+ expect(userInput?.inputModes).toEqual(['literal', 'reference']);
+ expect(promptInput?.examples).toEqual(['You are a helpful assistant.']);
+ expect(modelInput).toMatchObject({
+ defaultPolicy: 'remoteValidated',
+ resourceKind: 'model',
+ bindingRequired: false
+ });
+ expect(modelInput?.examples).toBeUndefined();
+ expect(descriptor.constraints.isTool).toBe(true);
+ expect(JSON.stringify(resolved.template)).not.toContain('invalidCondition');
+ });
+
+ it('exposes explicit binding requirements without storing metadata in nodes', async () => {
+ const ref = parseNodeTemplateRef('builtin:http-request');
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const descriptor = normalizeNodeTemplateDescriptor({
+ ...resolved,
+ templateRef: ref
+ });
+
+ expect(descriptor.inputs.find((input) => input.key === 'system_httpReqUrl')).toMatchObject({
+ required: true,
+ bindingRequired: true,
+ defaultPolicy: 'userRequired'
+ });
+ });
+});
+
+describe('instantiateNodeFromTemplate', () => {
+ it('creates a complete store node with the current default reference', async () => {
+ const start = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: parseNodeTemplateRef('builtin:workflow-start'),
+ nodeId: 'start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+ const document = createWorkflowDocument({ nodes: [start.node] });
+ const ai = await instantiateNodeFromTemplate({
+ document,
+ templateRef: parseNodeTemplateRef('builtin:ai-chat'),
+ nodeId: 'ai',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+
+ expect(ai.node.inputs.length).toBeGreaterThan(10);
+ expect(ai.node.inputs.find((input) => input.key === 'userChatInput')?.value).toEqual([
+ 'start',
+ 'userChatInput'
+ ]);
+ expect(JSON.stringify(ai.node)).not.toContain('agentHint');
+ expect(JSON.stringify(ai.node)).not.toContain('examples');
+ });
+
+ it('uses validated remote defaults and clears unverified resource template values', async () => {
+ const ref = parseNodeTemplateRef('builtin:ai-chat');
+ const provider = {
+ list: builtinTemplateProvider.list,
+ resolve: async () => {
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const template = structuredClone(resolved.template);
+ template.inputs.find((input) => input.key === 'model')!.value = 'template-model';
+ return {
+ ...resolved,
+ template,
+ validatedInputDefaults: {
+ model: { provided: true as const, value: 'validated-model' }
+ }
+ };
+ }
+ };
+ const node = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: ref,
+ nodeId: 'ai',
+ provider,
+ locale: 'en'
+ });
+ expect(node.node.inputs.find((input) => input.key === 'model')).toMatchObject({
+ value: 'validated-model'
+ });
+
+ const localNode = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: ref,
+ nodeId: 'local-ai',
+ provider: {
+ ...provider,
+ resolve: async () => {
+ const resolved = await provider.resolve();
+ return { ...resolved, validatedInputDefaults: undefined };
+ }
+ },
+ locale: 'en'
+ });
+ const localModel = localNode.node.inputs.find((input) => input.key === 'model');
+ expect(localModel?.value).toBeUndefined();
+ expect(localModel?.defaultValue).toBeUndefined();
+ });
+
+ it('does not create a mismatched Start reference for an array input', async () => {
+ const start = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: parseNodeTemplateRef('builtin:workflow-start'),
+ nodeId: 'start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+ const ref = parseNodeTemplateRef('builtin:ai-chat');
+ const provider = {
+ list: builtinTemplateProvider.list,
+ resolve: async () => {
+ const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' });
+ const template = structuredClone(resolved.template);
+ const userInput = template.inputs.find((input) => input.key === 'userChatInput')!;
+ userInput.valueType = WorkflowIOValueTypeEnum.arrayString;
+ return { ...resolved, template };
+ }
+ };
+ const ai = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument({ nodes: [start.node] }),
+ templateRef: ref,
+ nodeId: 'ai',
+ provider,
+ locale: 'en'
+ });
+ const userInput = ai.node.inputs.find((input) => input.key === 'userChatInput');
+ expect(userInput?.value).toEqual([]);
+ expect(userInput?.selectedTypeIndex).toBeUndefined();
+ });
+
+ it('keeps the composite Start reference for the array search input', async () => {
+ const start = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: parseNodeTemplateRef('builtin:workflow-start'),
+ nodeId: 'start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+ const search = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument({ nodes: [start.node] }),
+ templateRef: parseNodeTemplateRef('builtin:dataset-search'),
+ nodeId: 'search',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+ expect(search.node.inputs.find((input) => input.key === 'datasetSearchInput')?.value).toEqual([
+ ['start', 'userChatInput']
+ ]);
+ expect(search.node.inputs.find((input) => input.key === 'datasets')?.value).toEqual([]);
+ });
+
+ it('rejects duplicate IDs and unique templates', async () => {
+ const start = await instantiateNodeFromTemplate({
+ document: createWorkflowDocument(),
+ templateRef: parseNodeTemplateRef('builtin:workflow-start'),
+ nodeId: 'start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ });
+ const document = createWorkflowDocument({ nodes: [start.node] });
+
+ await expect(
+ instantiateNodeFromTemplate({
+ document,
+ templateRef: parseNodeTemplateRef('builtin:ai-chat'),
+ nodeId: 'start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ await expect(
+ instantiateNodeFromTemplate({
+ document,
+ templateRef: parseNodeTemplateRef('builtin:workflow-start'),
+ nodeId: 'other-start',
+ provider: builtinTemplateProvider,
+ locale: 'en'
+ })
+ ).rejects.toThrow(WorkflowCommandError);
+ });
+});
diff --git a/packages/workflow-core/test/validation/validation.test.ts b/packages/workflow-core/test/validation/validation.test.ts
new file mode 100644
index 000000000000..9a1c8a93b2c9
--- /dev/null
+++ b/packages/workflow-core/test/validation/validation.test.ts
@@ -0,0 +1,124 @@
+import {
+ VARIABLE_NODE_ID,
+ FlowNodeInputTypeEnum,
+ VariableInputEnum,
+ WorkflowDocumentSchema,
+ WorkflowIOValueTypeEnum,
+ applyWorkflowCommand,
+ builtinTemplateProvider,
+ createWorkflowDocument,
+ parseNodeTemplateRef,
+ type WorkflowDocument,
+ validateWorkflow
+} from '../../src';
+import aiWorkflow from '../fixtures/basic-ai/workflow.json';
+import staticWorkflow from '../fixtures/basic-static/workflow.json';
+import commonLinearWorkflow from '../fixtures/common-linear/workflow.json';
+import { describe, expect, it } from 'vitest';
+
+describe('validateWorkflow', () => {
+ it.each([aiWorkflow, staticWorkflow, commonLinearWorkflow])(
+ 'accepts a golden workflow',
+ (workflow) => {
+ expect(validateWorkflow(WorkflowDocumentSchema.parse(workflow))).toEqual([]);
+ }
+ );
+
+ it('reports duplicate IDs, missing start, unreachable nodes and required inputs', () => {
+ const broken: WorkflowDocument = structuredClone(WorkflowDocumentSchema.parse(staticWorkflow));
+ broken.nodes = broken.nodes.filter((node) => node.nodeId !== 'start');
+ broken.nodes[0].inputs[0].value = undefined;
+ broken.nodes.push({ ...broken.nodes[0], nodeId: broken.nodes[0].nodeId });
+ broken.executionEdges = [];
+ const codes = validateWorkflow(broken).map((item) => item.code);
+ expect(codes).toContain('WORKFLOW_NODE_ID_DUPLICATED');
+ expect(codes).toContain('WORKFLOW_START_COUNT_INVALID');
+ expect(codes).toContain('WORKFLOW_REQUIRED_INPUT_MISSING');
+ });
+
+ it('reports malformed and non-upstream references', () => {
+ const malformed: WorkflowDocument = structuredClone(WorkflowDocumentSchema.parse(aiWorkflow));
+ const input = malformed.nodes
+ .find((node) => node.nodeId === 'ai')!
+ .inputs.find((item) => item.key === 'userChatInput')!;
+ input.value = ['missing'];
+ expect(validateWorkflow(malformed).map((item) => item.code)).toContain(
+ 'WORKFLOW_REFERENCE_FORMAT_INVALID'
+ );
+
+ input.value = ['ai', 'answerText'];
+ expect(validateWorkflow(malformed).map((item) => item.code)).toContain(
+ 'WORKFLOW_REFERENCE_SOURCE_NOT_UPSTREAM'
+ );
+ });
+
+ it('accepts missing external bindings and aggregate Start references', async () => {
+ const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' };
+ const start = await applyWorkflowCommand({
+ document: createWorkflowDocument(),
+ command: {
+ type: 'node.add',
+ nodeId: 'start',
+ template: parseNodeTemplateRef('builtin:workflow-start')
+ },
+ dependencies
+ });
+ const search = await applyWorkflowCommand({
+ document: start.document,
+ command: {
+ type: 'node.add',
+ nodeId: 'search',
+ template: parseNodeTemplateRef('builtin:dataset-search'),
+ connectFrom: { kind: 'next', nodeId: 'start' }
+ },
+ dependencies
+ });
+
+ expect(validateWorkflow(search.document)).toEqual([]);
+ });
+
+ it('returns schema diagnostics instead of throwing', () => {
+ expect(validateWorkflow({ schemaVersion: 'wrong' } as never)[0]?.code).toBe(
+ 'WORKFLOW_SCHEMA_INVALID'
+ );
+ });
+
+ it('reports duplicate and invalid execution edges', () => {
+ const broken: WorkflowDocument = structuredClone(WorkflowDocumentSchema.parse(aiWorkflow));
+ broken.executionEdges.push(structuredClone(broken.executionEdges[0]));
+ broken.executionEdges.push({
+ source: { kind: 'next', nodeId: 'missing' },
+ target: { kind: 'target', nodeId: 'ai' }
+ });
+ const codes = validateWorkflow(broken).map((item) => item.code);
+ expect(codes).toContain('WORKFLOW_EDGE_DUPLICATED');
+ expect(codes).toContain('WORKFLOW_EDGE_INVALID');
+ });
+
+ it('validates global references, reference types and deleted relation leftovers', () => {
+ const broken: WorkflowDocument = structuredClone(WorkflowDocumentSchema.parse(staticWorkflow));
+ broken.chatConfig.variables = [
+ {
+ key: 'count',
+ label: 'Count',
+ description: 'Counter',
+ type: VariableInputEnum.numberInput,
+ valueType: WorkflowIOValueTypeEnum.number
+ }
+ ];
+ const answer = broken.nodes.find((node) => node.nodeId === 'answer')!;
+ answer.parentNodeId = 'deleted-parent';
+ const textInput = broken.nodes.find((node) => node.nodeId === 'text')!.inputs[0];
+ textInput.renderTypeList.push(FlowNodeInputTypeEnum.reference);
+ textInput.value = [VARIABLE_NODE_ID, 'count'];
+ textInput.selectedTypeIndex = textInput.renderTypeList.indexOf(FlowNodeInputTypeEnum.reference);
+ const codes = validateWorkflow(broken).map((item) => item.code);
+ expect(codes).toContain('WORKFLOW_PARENT_NODE_NOT_FOUND');
+ expect(codes).toContain('WORKFLOW_REFERENCE_TYPE_MISMATCH');
+
+ textInput.value = [VARIABLE_NODE_ID, 'deleted-variable'];
+ expect(validateWorkflow(broken).map((item) => item.code)).toContain(
+ 'WORKFLOW_REFERENCE_OUTPUT_NOT_FOUND'
+ );
+ });
+});
diff --git a/packages/workflow-core/tsconfig.json b/packages/workflow-core/tsconfig.json
new file mode 100644
index 000000000000..d359844513cd
--- /dev/null
+++ b/packages/workflow-core/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "moduleResolution": "bundler"
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "tsdown.config.ts"]
+}
diff --git a/packages/workflow-core/tsdown.config.ts b/packages/workflow-core/tsdown.config.ts
new file mode 100644
index 000000000000..cba3cda5aaf3
--- /dev/null
+++ b/packages/workflow-core/tsdown.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: 'src/index.ts',
+ format: 'esm',
+ dts: {
+ enabled: true,
+ sourcemap: false
+ },
+ outExtensions: () => ({ js: '.js', dts: '.d.ts' })
+});
diff --git a/packages/workflow-core/vitest.config.ts b/packages/workflow-core/vitest.config.ts
new file mode 100644
index 000000000000..f0f11ab93bfa
--- /dev/null
+++ b/packages/workflow-core/vitest.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from 'node:path';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@fastgpt': resolve('..')
+ }
+ },
+ test: {
+ include: ['test/**/*.test.ts'],
+ coverage: {
+ enabled: true,
+ reporter: ['text', 'text-summary', 'json-summary'],
+ include: ['src/**/*.ts'],
+ exclude: ['src/**/type.ts', 'src/**/schema.ts', 'src/index.ts']
+ }
+ }
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0c4a469dfacf..d70f61f6cf1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -920,6 +920,59 @@ importers:
specifier: ^18
version: 18.3.0
+ packages/workflow-cli:
+ dependencies:
+ '@fastgpt/web':
+ specifier: workspace:*
+ version: link:../web
+ '@fastgpt/workflow-core':
+ specifier: workspace:*
+ version: link:../workflow-core
+ zod:
+ specifier: 'catalog:'
+ version: 4.1.12
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.17.24
+ '@vitest/coverage-v8':
+ specifier: 'catalog:'
+ version: 4.1.5(vitest@4.1.5)
+ tsdown:
+ specifier: 'catalog:'
+ version: 0.21.10(typescript@6.0.3)
+ typescript:
+ specifier: 'catalog:'
+ version: 6.0.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@20.17.24)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))
+
+ packages/workflow-core:
+ dependencies:
+ '@fastgpt/global':
+ specifier: workspace:*
+ version: link:../global
+ zod:
+ specifier: 'catalog:'
+ version: 4.1.12
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.17.24
+ '@vitest/coverage-v8':
+ specifier: 'catalog:'
+ version: 4.1.5(vitest@4.1.5)
+ tsdown:
+ specifier: 'catalog:'
+ version: 0.21.10(typescript@6.0.3)
+ typescript:
+ specifier: 'catalog:'
+ version: 6.0.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@20.17.24)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))
+
pro/admin:
dependencies:
'@alicloud/dysmsapi20170525':
@@ -1370,6 +1423,9 @@ importers:
'@fastgpt/web':
specifier: workspace:*
version: link:../../packages/web
+ '@fastgpt/workflow-core':
+ specifier: workspace:*
+ version: link:../../packages/workflow-core
'@fortaine/fetch-event-source':
specifier: ^3.0.6
version: 3.0.6
diff --git a/projects/app/package.json b/projects/app/package.json
index 80e5043b52a5..500bb0514935 100644
--- a/projects/app/package.json
+++ b/projects/app/package.json
@@ -47,6 +47,7 @@
"@fastgpt/next": "workspace:*",
"@fastgpt/service": "workspace:*",
"@fastgpt/web": "workspace:*",
+ "@fastgpt/workflow-core": "workspace:*",
"@fortaine/fetch-event-source": "^3.0.6",
"@llamaindex/liteparse-wasm": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
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..976d4044c408 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx
@@ -19,6 +19,7 @@ import NodeTemplateListHeader from './components/NodeTemplates/header';
import NodeTemplateList from './components/NodeTemplates/list';
import { useNodeTemplates } from './components/NodeTemplates/useNodeTemplates';
import { popoverHeight, popoverWidth } from './hooks/useWorkflow';
+import { validateConnectionWithCore } from '../adapters/command';
const NodeTemplatesPopover = () => {
const { handleParams, setHandleParams } = useContextSelector(WorkflowModalContext, (v) => v);
@@ -91,7 +92,22 @@ const NodeTemplatesPopover = () => {
target: node.id,
targetHandle: isToolHandle ? 'selectedTools' : `${node.id}-target-left`,
type: EDGE_TYPE
- }));
+ }))
+ .filter((edge) => {
+ const result = validateConnectionWithCore({
+ nodes: [...nodes, ...newNodes],
+ edges,
+ connection: edge
+ });
+ if (result.status === 'adapter-error') {
+ console.error(
+ '[Workflow Core Adapter] Failed to validate auto connection, falling back to Web behavior',
+ result.error
+ );
+ return true;
+ }
+ return result.status === 'success';
+ });
setEdges((state) => {
const newState = state.concat(newEdges);
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx
index f8aa0f444783..4619706e6b21 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useDebug.tsx
@@ -10,6 +10,7 @@ import { getNodeAllSource } from '@/web/core/workflow/utils';
import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { uiWorkflow2StoreWorkflow } from '../../utils';
+import { checkWorkflowNodeAndConnection } from '../../adapters/validation';
import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import dynamic from 'next/dynamic';
@@ -105,11 +106,22 @@ export const useDebug = () => {
const flowData2StoreDataAndCheck = useCallback(async () => {
const nodes = getNodes();
- const { issueMap, hasError, firstErrorNodeId } = checkWorkflowBeforeRunOrPublish({
+ const coreErrorNodeIds = checkWorkflowNodeAndConnection({
+ nodes,
+ edges,
+ chatConfig: appDetail.chatConfig
+ });
+ const {
+ issueMap,
+ hasError: hasWebError,
+ firstErrorNodeId: firstWebErrorNodeId
+ } = checkWorkflowBeforeRunOrPublish({
nodes,
edges,
t: workflowT
});
+ const hasError = hasWebError || !!coreErrorNodeIds?.length;
+ const firstErrorNodeId = firstWebErrorNodeId ?? coreErrorNodeIds?.[0];
if (!hasError) {
onRemoveError();
@@ -121,7 +133,6 @@ export const useDebug = () => {
return JSON.stringify(storeNodes);
}
-
onSyncWorkflowCheckIssues(issueMap);
if (firstErrorNodeId) {
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx
index 885266113d80..55df8c6a800f 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx
@@ -43,6 +43,7 @@ import { WorkflowUIContext } from '../../context/workflowUIContext';
import { WorkflowModalContext } from '../../context/workflowModalContext';
import { WorkflowLayoutContext } from '../../context/workflowComputeContext';
import { type HelperLinesController } from '../components/HelperLines';
+import { getRemovalNodeIdsWithCore, validateConnectionWithCore } from '../../adapters/command';
/*
限定容量的最大堆,根为当前最大距离。保留为通用最近邻筛选工具,
@@ -587,23 +588,34 @@ export const useWorkflow = ({ helperLinesRef }: UseWorkflowParams) => {
/* node */
// Remove change node and its child nodes and edges
const handleRemoveNode = useCallback(
- (change: NodeRemoveChange, nodeId: string) => {
- // If the node has child nodes, remove the child nodes
- const deletedNodeIdList = [nodeId];
- const deletedEdgeIdList = edges
- .filter((edge) => edge.source === nodeId || edge.target === nodeId)
- .map((edge) => edge.id);
-
- const childNodes = nodes.filter((n) => n.data.parentNodeId === nodeId);
- if (childNodes.length > 0) {
- const childNodeIds = childNodes.map((node) => node.id);
- deletedNodeIdList.push(...childNodeIds);
+ (_change: NodeRemoveChange, nodeId: string) => {
+ const removalResult = getRemovalNodeIdsWithCore({
+ nodes,
+ edges,
+ nodeId,
+ chatConfig: appDetail.chatConfig
+ });
+ const deletedNodeIdList = (() => {
+ if (removalResult.status === 'success') return removalResult.data;
+ if (removalResult.status === 'domain-error') return [];
- const childEdges = edges.filter(
- (edge) => childNodeIds.includes(edge.source) || childNodeIds.includes(edge.target)
+ console.error(
+ '[Workflow Core Adapter] Failed to remove node with Core, falling back to Web behavior',
+ removalResult.error
);
- deletedEdgeIdList.push(...childEdges.map((edge) => edge.id));
- }
+ return [
+ nodeId,
+ ...nodes.filter((node) => node.data.parentNodeId === nodeId).map((node) => node.id)
+ ];
+ })();
+ if (deletedNodeIdList.length === 0) return;
+
+ const deletedEdgeIdList = edges
+ .filter(
+ (edge) =>
+ deletedNodeIdList.includes(edge.source) || deletedNodeIdList.includes(edge.target)
+ )
+ .map((edge) => edge.id);
onNodesChange(
deletedNodeIdList.map((id) => ({
@@ -618,7 +630,7 @@ export const useWorkflow = ({ helperLinesRef }: UseWorkflowParams) => {
}))
);
},
- [edges, nodes, onNodesChange, onEdgesChange]
+ [appDetail.chatConfig, edges, nodes, onNodesChange, onEdgesChange]
);
const handleSelectNode = useMemoizedFn((change: NodeSelectionChange) => {
// If the node is not selected and the Ctrl key is pressed, select the node
@@ -939,11 +951,30 @@ export const useWorkflow = ({ helperLinesRef }: UseWorkflowParams) => {
});
}
+ const connectionResult = validateConnectionWithCore({
+ nodes,
+ edges,
+ connection: connect,
+ chatConfig: appDetail.chatConfig
+ });
+ if (connectionResult.status === 'domain-error') {
+ return toast({
+ status: 'warning',
+ title: t('workflow:connection_invalid')
+ });
+ }
+ if (connectionResult.status === 'adapter-error') {
+ console.error(
+ '[Workflow Core Adapter] Failed to validate connection, falling back to Web behavior',
+ connectionResult.error
+ );
+ }
+
onConnect({
connect
});
},
- [onConnect, t, toast]
+ [appDetail.chatConfig, edges, nodes, onConnect, t, toast]
);
/* edge */
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx
index a0e76714ed50..784287792908 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/Copilot.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useMemo, useRef, useState } from 'react';
import { Box, Button, CloseButton, Flex } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
@@ -68,7 +68,6 @@ const NodeCopilot = ({
const [optimizerInput, setOptimizerInput] = useState('');
const [codeResult, setCodeResult] = useState('');
const [selectedModel, setSelectedModel] = useState(defaultModels.llm?.model || '');
- const [conversationHistory, setConversationHistory] = useState([]);
const [abortController, setAbortController] = useState(null);
const closePopoverRef = useRef<() => void>();
@@ -103,10 +102,10 @@ const NodeCopilot = ({
};
}, [realTimeInputs, realTimeOutputs]);
- useEffect(() => {
- if (conversationHistory.length === 0) {
- const configMessage = {
- role: 'user' as const,
+ const [conversationHistory, setConversationHistory] = useState(
+ () => [
+ {
+ role: 'user',
content: t('app:copilot_config_message', {
codeType,
code,
@@ -120,20 +119,16 @@ const NodeCopilot = ({
})
.join('\n'),
outputs: dynamicOutputs
- .map((output) => `- ${output.label} (${output.valueType})`)
+ .map((output) => `- ${output.key} (${output.valueType})`)
.join('\n')
})
- };
-
- const confirmMessage = {
- role: 'assistant' as const,
+ },
+ {
+ role: 'assistant',
content: t('app:copilot_confirm_message')
- };
-
- const initialConversationHistory = [configMessage, confirmMessage];
- setConversationHistory(initialConversationHistory);
- }
- }, [conversationHistory, codeType, code, dynamicInputs, dynamicOutputs, t]);
+ }
+ ]
+ );
const modelOptions = useMemo(() => {
return llmModelList.map((model) => ({
@@ -266,25 +261,25 @@ const NodeCopilot = ({
});
});
const existingOutputIdMap = new Map(dynamicOutputs.map((output) => [output.key, output.id]));
- const nextOutputKeys = new Set(outputs.map((output) => output.label));
+ const nextOutputKeys = new Set(outputs.map((output) => output.key));
dynamicOutputs.forEach((output) => {
if (!nextOutputKeys.has(output.key)) {
onChangeNode({ nodeId, type: 'delOutput', key: output.key });
}
});
outputs.forEach((output) => {
- const existingId = existingOutputIdMap.get(output.label);
+ const existingId = existingOutputIdMap.get(output.key);
if (existingId) {
onChangeNode({
nodeId,
type: 'updateOutput',
- key: output.label,
+ key: output.key,
value: {
id: existingId,
type: FlowNodeOutputTypeEnum.dynamic,
- key: output.label,
+ key: output.key,
valueType: output.type as WorkflowIOValueTypeEnum,
- label: output.label,
+ label: output.key,
valueDesc: '',
description: ''
}
@@ -296,9 +291,9 @@ const NodeCopilot = ({
value: {
id: nanoid(),
type: FlowNodeOutputTypeEnum.dynamic,
- key: output.label,
+ key: output.key,
valueType: output.type as WorkflowIOValueTypeEnum,
- label: output.label,
+ label: output.key,
valueDesc: '',
description: ''
}
@@ -311,7 +306,7 @@ const NodeCopilot = ({
status: 'success',
title: t('app:code_applied_successfully')
});
- } catch (error) {
+ } catch {
toast({
status: 'error',
title: t('app:apply_code_failed')
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/parser.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/parser.ts
index 916bccc27364..3f32c05809f0 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/parser.ts
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/parser.ts
@@ -1,13 +1,17 @@
+import { extractCodeOutputDefinitions } from '@fastgpt/workflow-core';
+
+export { extractReturnedObjectKeys } from '@fastgpt/workflow-core';
+
export const extractCodeFromMarkdown = (
markdownContent: string
): {
code: string;
inputs: Array<{ label: string; type: string; reference?: string }>;
- outputs: Array<{ label: string; type: string }>;
+ outputs: Array<{ key: string; type: string }>;
} => {
const codeBlockRegex = /```(?:\w+\n)?([\s\S]*?)```/;
const codeMatch = markdownContent.match(codeBlockRegex);
- let code = codeMatch ? codeMatch[1].trim() : markdownContent.trim();
+ const code = codeMatch ? codeMatch[1].trim() : markdownContent.trim();
// Enhanced regex to capture reference information in square brackets
const paramMatches = [
@@ -19,11 +23,19 @@ export const extractCodeFromMarkdown = (
reference: paramMatch[3] ? paramMatch[3].trim() : undefined
}));
- const propertyMatches = [...code.matchAll(/@property\s*\{([^}]+)\}\s*(\w+)\s*-?\s*.*/g)];
- const outputs = propertyMatches.map((propertyMatch) => ({
- label: propertyMatch[2].trim(),
+ const documentedOutputs = [
+ ...code.matchAll(/@property\s*\{([^}]+)\}\s*([^\s-]+)\s*-?\s*.*/g)
+ ].map((propertyMatch) => ({
+ key: propertyMatch[2].trim(),
type: propertyMatch[1].trim()
}));
+ const returnedOutputs = extractCodeOutputDefinitions(code);
+ const outputs = returnedOutputs
+ ? returnedOutputs.map((output) => ({
+ key: output.key,
+ type: output.valueType ?? 'any'
+ }))
+ : documentedOutputs;
// Remove comments from code before returning
const cleanCode = code.replace(/\/\*\*[\s\S]*?\*\//g, '').trim();
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/index.tsx
index 883cfc899a6b..c35924e741ef 100644
--- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/index.tsx
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/index.tsx
@@ -1,9 +1,9 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import NodeCard from '../render/NodeCard';
import { useTranslation } from 'next-i18next';
import { Box, Button, Flex } from '@chakra-ui/react';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
-import { type NodeProps, Position } from 'reactflow';
+import { type NodeProps, Position, useUpdateNodeInternals } from 'reactflow';
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import {
@@ -23,6 +23,7 @@ import { WorkflowActionsContext } from '../../../context/workflowActionsContext'
const NodeIfElse = ({ data, selected }: NodeProps) => {
const { t } = useTranslation();
const { nodeId, inputs = [] } = data;
+ const updateNodeInternals = useUpdateNodeInternals();
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const elseHandleId = getHandleId(nodeId, 'source', IfElseResultEnum.ELSE);
@@ -32,6 +33,16 @@ const NodeIfElse = ({ data, selected }: NodeProps) => {
?.value as IfElseListItemType[]) || [],
[inputs]
);
+ const branchHandleKeys = useMemo(
+ () => JSON.stringify(ifElseList.map((item, index) => getIfElseBranchHandleKey(item, index))),
+ [ifElseList]
+ );
+
+ useEffect(() => {
+ // 条件分支的 Handle 是动态渲染的。导入或调整分支后需要通知 ReactFlow
+ // 重新测量,否则边数据虽然存在,画布仍可能因找不到 Handle 而不显示连线。
+ updateNodeInternals(nodeId);
+ }, [branchHandleKeys, nodeId, updateNodeInternals]);
const onUpdateIfElseList = useCallback(
(value: IfElseListItemType[]) => {
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/command.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/command.ts
new file mode 100644
index 000000000000..5a42d48b3ca4
--- /dev/null
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/command.ts
@@ -0,0 +1,85 @@
+import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
+import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
+import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import {
+ WorkflowCommandError,
+ connectExecutionEdge,
+ decompileStoreEdge,
+ removeNode,
+ type WorkflowDiagnostic
+} from '@fastgpt/workflow-core';
+import type { Connection, Edge, Node } from 'reactflow';
+import { reactFlowStateToWorkflowDocument } from './document';
+
+export type WorkflowCoreAdapterResult =
+ | { status: 'success'; data: T }
+ | { status: 'domain-error'; diagnostics: WorkflowDiagnostic[] }
+ | { status: 'adapter-error'; error: unknown };
+
+const executeWorkflowCoreAction = (action: () => T): WorkflowCoreAdapterResult => {
+ try {
+ return { status: 'success', data: action() };
+ } catch (error) {
+ if (error instanceof WorkflowCommandError) {
+ return { status: 'domain-error', diagnostics: error.diagnostics };
+ }
+ return { status: 'adapter-error', error };
+ }
+};
+
+/** 在 Web 写入 ReactFlow edge 前,复用 Core 的复杂端口和作用域规则。 */
+export const validateConnectionWithCore = ({
+ nodes,
+ edges,
+ connection,
+ chatConfig
+}: {
+ nodes: Node[];
+ edges: Edge[];
+ connection: Connection;
+ chatConfig?: AppChatConfigType;
+}): WorkflowCoreAdapterResult => {
+ if (
+ !connection.source ||
+ !connection.target ||
+ !connection.sourceHandle ||
+ !connection.targetHandle
+ ) {
+ return {
+ status: 'domain-error',
+ diagnostics: [{ code: 'WORKFLOW_EDGE_HANDLE_UNSUPPORTED', severity: 'error' }]
+ };
+ }
+
+ return executeWorkflowCoreAction(() => {
+ const document = reactFlowStateToWorkflowDocument({ nodes, edges, chatConfig });
+ const storeEdge: StoreEdgeItemType = {
+ source: connection.source!,
+ sourceHandle: connection.sourceHandle!,
+ target: connection.target!,
+ targetHandle: connection.targetHandle!
+ };
+ connectExecutionEdge({
+ document,
+ edge: decompileStoreEdge(storeEdge, document)
+ });
+ return undefined;
+ });
+};
+
+/** 复用 Core 的递归删除语义,供 ReactFlow 生成完整 remove change 集合。 */
+export const getRemovalNodeIdsWithCore = ({
+ nodes,
+ edges,
+ nodeId,
+ chatConfig
+}: {
+ nodes: Node[];
+ edges: Edge[];
+ nodeId: string;
+ chatConfig?: AppChatConfigType;
+}): WorkflowCoreAdapterResult =>
+ executeWorkflowCoreAction(() => {
+ const document = reactFlowStateToWorkflowDocument({ nodes, edges, chatConfig });
+ return removeNode({ document, nodeId }).deletedNodeIds;
+ });
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/document.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/document.ts
new file mode 100644
index 000000000000..807a9593fd41
--- /dev/null
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/document.ts
@@ -0,0 +1,22 @@
+import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
+import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import { decompileStoreWorkflow, type WorkflowDocument } from '@fastgpt/workflow-core';
+import type { Edge, Node } from 'reactflow';
+import { uiWorkflow2StoreWorkflow } from '../utils';
+
+/**
+ * 将 ReactFlow 编辑状态投影为唯一的 WorkflowDocument 领域状态。
+ * ReactFlow 外层状态和模板函数在 Store 投影阶段被移除,后续 Web 命令统一复用该入口。
+ */
+export const reactFlowStateToWorkflowDocument = ({
+ nodes,
+ edges,
+ chatConfig = {}
+}: {
+ nodes: Node[];
+ edges: Edge[];
+ chatConfig?: AppChatConfigType;
+}): WorkflowDocument => {
+ const storeWorkflow = uiWorkflow2StoreWorkflow({ nodes, edges });
+ return decompileStoreWorkflow({ workflow: { ...storeWorkflow, chatConfig } });
+};
diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/validation.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/validation.ts
new file mode 100644
index 000000000000..a890fd0cea7c
--- /dev/null
+++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/adapters/validation.ts
@@ -0,0 +1,129 @@
+import { checkWorkflowBeforeRunOrPublish } from '@/web/core/workflow/workflowCheck';
+import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
+import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
+import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
+import {
+ validateWorkflow,
+ WorkflowCommandError,
+ type WorkflowDiagnostic
+} from '@fastgpt/workflow-core';
+import type { Edge, Node } from 'reactflow';
+import { reactFlowStateToWorkflowDocument } from './document';
+
+const sharedWorkflowNodeTypes = new Set([
+ FlowNodeTypeEnum.workflowStart,
+ FlowNodeTypeEnum.chatNode,
+ FlowNodeTypeEnum.textEditor,
+ FlowNodeTypeEnum.answerNode,
+ FlowNodeTypeEnum.datasetSearchNode,
+ FlowNodeTypeEnum.queryExtension,
+ FlowNodeTypeEnum.contentExtract,
+ FlowNodeTypeEnum.httpRequest468,
+ FlowNodeTypeEnum.code,
+ FlowNodeTypeEnum.runApp,
+ FlowNodeTypeEnum.ifElseNode,
+ FlowNodeTypeEnum.classifyQuestion,
+ FlowNodeTypeEnum.userSelect,
+ FlowNodeTypeEnum.formInput,
+ FlowNodeTypeEnum.toolCall,
+ FlowNodeTypeEnum.readFiles,
+ FlowNodeTypeEnum.variableUpdate,
+ FlowNodeTypeEnum.parallelRun,
+ FlowNodeTypeEnum.loopRun,
+ FlowNodeTypeEnum.loopRunStart,
+ FlowNodeTypeEnum.loopRunBreak,
+ FlowNodeTypeEnum.loop,
+ FlowNodeTypeEnum.nestedStart,
+ FlowNodeTypeEnum.nestedEnd,
+ FlowNodeTypeEnum.tool,
+ FlowNodeTypeEnum.toolSet,
+ FlowNodeTypeEnum.toolParams,
+ FlowNodeTypeEnum.pluginModule,
+ FlowNodeTypeEnum.appModule
+]);
+
+const validateWorkflowWithWebRules = ({
+ nodes,
+ edges
+}: {
+ nodes: Node[];
+ edges: Edge[];
+}) => {
+ const { errorNodeIds } = checkWorkflowBeforeRunOrPublish({ nodes, edges });
+ return errorNodeIds.length > 0 ? errorNodeIds : undefined;
+};
+
+const diagnosticsToNodeIds = ({
+ diagnostics,
+ nodes
+}: {
+ diagnostics: WorkflowDiagnostic[];
+ nodes: Node[];
+}) => {
+ const nodeIds = new Set