diff --git "a/.agents/design/core/chat/chatbox-instance-isolation-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/.agents/design/core/chat/chatbox-instance-isolation-\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..8b6d6edaf668 --- /dev/null +++ "b/.agents/design/core/chat/chatbox-instance-isolation-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,280 @@ +# ChatBox 实例隔离功能开发文档 + +## 文档标识 + +- 任务前缀:`chatbox-instance-isolation` +- 文档文件名:`chatbox-instance-isolation-功能开发文档.md` +- 关联需求:`chatbox-instance-isolation-需求设计文档.md` + +## 0. 开发目标与约束 + +- 功能目标:让同一 React 页面中的多个 ChatBox 拥有完全独立的消息动作、记录、输入、生成和外部触发边界。 +- 直接验收场景:左侧 Workflow Builder 与右侧运行预览同时打开。 +- 代码范围:`projects/app/src/components/core/chat/**`、必要的聊天上下文与对应测试。 +- 非目标:不改 API、数据库、工作流执行引擎、Figma 样式和后端会话模型。 +- 适用维度:Frontend[x] Logging[x] Testing[x];API[ ] Data[ ] Packaging[ ] DocI18n[ ]。 +- 约束:不复制 ChatBox;不使用无 target 的全局广播修补;不新增第三方依赖。 + +## 1. 实施任务拆解 + +| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义(DoD) | +|---|---|---|---|---|---| +| T1 | 定义 ChatBox 实例身份和动作上下文 | 前端基础组件 | `sourceKey/chatId/sendPrompt/lastInteractive` | `ChatInstanceActionsContext` | 子树组件可直接调用所属 ChatBox 动作 | +| T2 | 迁移交互卡提交入口 | AIResponseBox | user form/select/preview/payment | 无全局 emit 的实例提交 | 当前串线场景消失,交互参数保持一致 | +| T3 | 迁移快捷问题与编辑入口 | Chat UI/Markdown | question guide、welcome、AI suggestions | 本地 `sendMessage/fillInput` | `rg` 不再发现聊天内部 send/edit 全局 emit | +| T4 | 重构外部消息桥接 | ChatBox 基础设施 | `window.postMessage` | 目标化实例 registry | 指定目标单发;多实例无目标拒绝;精确注销 | +| T5 | 升级草稿与 DOM 作用域 | 输入/导出 | `sourceKey/chatId/rootRef` | 实例化 storage key 和 DOM ref | 草稿、变量查询和导出不跨实例 | +| T6 | 编写回归测试 | 测试 | 两实例场景矩阵 | 单元和组件测试 | 主路径、边界、卸载和歧义分支覆盖 | +| T7 | 质量验证 | 工程 | 修改文件 | lint/typecheck/test 结果 | 全部通过,无新增 warning/error | + +## 2. 文件级改动清单 + +> 最终文件名可以在实现时按现有目录命名收敛,但职责不得合并回全局 `eventBus`。 + +| 文件路径 | 类型 | 变更摘要 | 关键代码/伪代码 | 任务 | +|---|---|---|---|---| +| `projects/app/src/components/core/chat/ChatContainer/context/chatInstanceActionsContext.tsx` | 新增 | 定义实例动作类型、Provider 与消费 hook | `sendMessage/continueInteractive/fillInput` | T1 | +| `projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx` | 修改 | 构造实例动作,包裹 ChatBox 子树;移除内部 send/edit 全局监听 | `continueInteractive({ ...input, interactive: lastInteractive })` | T1/T3 | +| `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils/externalPromptRegistry.ts` | 新增 | 注册实例并路由外部 prompt | `Map` + 精确 cleanup | T4 | +| `projects/app/src/components/core/chat/components/AIResponseBox/utils.ts` | 修改 | 删除全局 `onSendPrompt`,保留纯转换函数 | 不再 import `eventBus` | T2 | +| `.../AIResponseBox/RenderUserFormInteractive.tsx` | 修改 | 表单提交调用 `continueInteractive` | JSON payload 保持不变 | T2 | +| `.../AIResponseBox/RenderUserSelectInteractive.tsx` | 修改 | 选择提交调用所属实例 action | option value 保持不变 | T2 | +| `.../AIResponseBox/RenderWorkflowBuilderPreviewInteractive.tsx` | 修改 | Builder preview 回答调用实例 action | 保留 `agentPlanAskResponse` | T2 | +| `.../AIResponseBox/RenderPaymentPauseInteractive.tsx` | 修改 | Continue 进入所属实例 | `continueInteractive({ text: 'Continue' })` | T2 | +| `.../ChatBox/components/AIChatBubble/Actions.tsx` | 修改 | 问题建议使用 `sendMessage` | 不 import `eventBus` | T3 | +| `.../ChatBox/components/AppChatMain.tsx` | 修改 | 欢迎问题 fallback 使用实例 action | QuickReply context 保持兼容 | T3 | +| `projects/app/src/components/Markdown/A.tsx` | 修改 | 空链接快捷问题使用实例 action | hook 仅在 ChatBox 子树启用 | T3 | +| `projects/app/src/components/Markdown/chat/Guide.tsx` | 修改 | Guide 快捷问题实例发送 | 移除 sendQuestion emit | T3 | +| `projects/app/src/components/Markdown/chat/QuestionGuide.tsx` | 修改 | 发送与填充输入实例化 | `sendMessage/fillInput` | T3 | +| `.../ChatBox/hooks/useChatInputForm.ts` | 修改 | 草稿键加入 `sourceKey`,封装迁移逻辑 | `getChatDraftKey(identity)` | T5 | +| `.../ChatBox/components/ChatRecordsList.tsx` | 修改 | `#history` 改为 ref/data attribute | 不依赖全局 DOM ID | T5 | +| `.../ChatBox/hooks/useChatBox.tsx` | 修改 | 导出接收当前 history root | 不使用 `document.getElementById` | T5 | +| `projects/app/test/components/core/chat/ChatContainer/context/chatInstanceActionsContext.test.tsx` | 新增 | 两实例 action 路由测试 | 右侧点击只调用右侧 spy | T6 | +| `projects/app/test/components/core/chat/ChatContainer/ChatBox/utils/externalPromptRegistry.test.ts` | 新增 | registry 路由、歧义、注销测试 | 覆盖全部返回状态 | T6 | +| `projects/app/test/components/core/chat/ChatContainer/ChatBox/hooks/useChatInputForm.test.ts` | 新增/修改 | 草稿 key 和迁移测试 | 两 identity 不共享草稿 | T6 | +| `projects/app/test/components/core/chat/components/AIResponseBox/utils.test.ts` | 修改 | 删除全局 emit 断言,保留纯转换测试 | 不再操作全局 eventBus | T6 | +| `projects/app/test/web/common/utils/eventbus.test.ts` | 修改 | 若 send/edit enum 移除则同步测试;其他事件保持原义 | 只测试剩余全局事件 | T6 | + +### 2.1 关键代码片段 + +#### T1:实例动作上下文 + +```ts +type ChatInstanceActions = { + identity: ChatInstanceIdentity; + sendMessage: (input: ChatBoxInputType) => void; + continueInteractive: (input: ChatBoxInputType) => void; + fillInput: (input: ChatBoxInputType) => void; +}; + +const actions = useMemoEnhance( + () => ({ + identity: { sourceKey, chatId }, + sendMessage: sendPromptWithDisabledGuard, + continueInteractive: (input) => + sendPrompt({ ...input, interactive: input.interactive ?? lastInteractive }), + fillInput: resetInputVal + }), + [sourceKey, chatId, sendPromptWithDisabledGuard, sendPrompt, lastInteractive, resetInputVal] +); +``` + +关键点:`continueInteractive` 必须由所属 ChatBox 注入自己的 `lastInteractive`,不能让卡片从全局 listener 间接获得。 + +#### T2:交互卡提交 + +```ts +const continueInteractive = useChatInstanceAction((value) => value.continueInteractive); + +const handleFormSubmit = (data: Record) => { + const finalData = pickInteractiveFields(data, interactive.params.inputForm); + continueInteractive({ text: JSON.stringify(finalData) }); +}; +``` + +#### T4:外部消息 registry + +```ts +const handlers = new Map(); + +export const dispatchExternalPrompt = (payload: ExternalChatPrompt) => { + if (payload.target) { + const handler = handlers.get(getChatInstanceKey(payload.target)); + if (!handler) return 'not-found'; + handler({ text: payload.text }); + return 'sent'; + } + + if (handlers.size !== 1) return 'ambiguous'; + handlers.values().next().value?.({ text: payload.text }); + return 'sent'; +}; +``` + +#### T5:草稿 key + +```ts +export const getChatDraftKey = ({ sourceKey, chatId }: ChatInstanceIdentity) => + `chatInput_${sourceKey}:${chatId}`; +``` + +## 2.2 Reviewer 阅读流程 + +### 2.2.1 一句话改动主线 + +所有 ChatBox 子组件的发送和编辑动作从全局 `eventBus` 回收到当前 ChatBox 的 React action context;只有真正的外部消息继续经过 registry,并按 `sourceKey + chatId` 精确路由。 + +### 2.2.2 阅读顺序表 + +| 步骤 | Reviewer 看什么 | 文件/符号 | 关注点 | 应得结论 | +|---|---|---|---|---| +| 1 | 实例身份来源 | `workflowRuntimeContext.tsx`:`sourceKey/chatId` | Builder 与 app target 是否不同 | 每个 ChatBox 有稳定身份 | +| 2 | 动作边界 | `chatInstanceActionsContext.tsx` | 普通发送、交互继续、填充输入是否分开 | 动作不会离开实例子树 | +| 3 | ChatBox 注入 | `ChatBox/index.tsx` | `lastInteractive` 和 sendPrompt 绑定 | 交互提交使用正确历史状态 | +| 4 | 主问题入口 | `RenderUserFormInteractive.tsx` 等 | 是否完全移除全局 onSendPrompt | 右侧答案只能进入右侧 | +| 5 | 其余入口 | Markdown、welcome、AI suggestions | 是否还有 send/edit 全局 emit | 不存在遗漏入口 | +| 6 | 外部桥接 | `externalPromptRegistry.ts` | target、单实例兼容、歧义拒绝、cleanup | 外部入口不广播 | +| 7 | 草稿/DOM | `useChatInputForm.ts`、history ref | identity 是否贯穿持久化和 DOM | 内容层同样隔离 | +| 8 | 测试 | 新增测试文件 | 双实例、卸载、并发、歧义 | 回归能够被自动发现 | + +### 2.2.3 调用链展开 + +1. 入口:右侧 `RenderUserFormInteractive.handleFormSubmit`。 +2. 状态进入:通过 `useChatInstanceActions` 取得右侧 Provider 的 `continueInteractive`。 +3. 核心调用:右侧 `ChatBox` 把右侧 `lastInteractive` 注入 input,调用右侧 `sendPrompt`。 +4. 数据变化:只写右侧 `ChatRecordContext.setChatRecords`,只请求运行预览 `chatTest`。 +5. 页面结果:右侧交互变成 submitted 并继续流式输出;左侧 records 和 Builder 状态不变。 +6. 错误分支:如果外部消息 target 不存在或多实例无 target,registry 返回失败状态,不产生聊天记录。 +7. 测试验证:两个实例分别注入 spy,提交右侧后断言 `right=1`、`left=0`;卸载右侧后左侧仍可发送。 + +## 3. 后端实施说明 + +### 3.1 API 改动 + +Not Applicable。现有运行预览与 Workflow Builder API 已通过不同 source target 和 chatId 工作。 + +### 3.2 Core/Service 改动 + +Not Applicable。 + +### 3.3 数据层改动 + +Not Applicable。无 Schema、索引和迁移。 + +## 4. 前端实施说明 + +| 场景 | 改造前 | 改造后 | 状态覆盖 | +|---|---|---|---| +| 普通输入发送 | ChatInput 直接调用实例函数 | 保持现状 | 空/加载/错误/成功不变 | +| 历史交互表单 | 全局 `onSendPrompt` | `continueInteractive` | 待填/提交中/已提交/失败 | +| 快捷问题 | 部分本地、部分 eventBus | 统一实例 `sendMessage` | 可发送/生成中禁用 | +| 编辑问题 | 全局 `editQuestion` | 实例 `fillInput` | 填充所属输入框 | +| 外部 postMessage | 所有 ChatBox 监听 | registry 精确目标 | sent/not-found/ambiguous | +| 草稿 | 仅 chatId | sourceKey + chatId | 读取/写入/清空/旧 key 迁移 | + +i18n:无新增用户文案;开发 warning 不进入 UI。 + +## 5. 日志与可观测性 + +| 触发点 | 级别 | 分类 | 字段 | 脱敏 | +|---|---|---|---|---| +| 多实例无 target 外部消息 | `console.warn`(development only) | `ChatBox external dispatch` | `registeredInstanceCount` | 不记录正文、文件、用户 ID | +| target 不存在 | 可选 development warning | 同上 | target 类型摘要,不记录完整 ID | 截断/不输出 identity | + +## 6. 文档 i18n + +Not Applicable。 + +## 7. 测试与验证 + +### 7.1 测试文件映射 + +| 源文件 | 测试文件 | 跳过 | 理由 | +|---|---|---|---| +| `chatInstanceActionsContext.tsx` | `test/components/core/chat/ChatContainer/context/chatInstanceActionsContext.test.tsx` | 否 | 核心实例边界 | +| `externalPromptRegistry.ts` | `test/components/core/chat/ChatContainer/ChatBox/utils/externalPromptRegistry.test.ts` | 否 | 外部路由核心逻辑 | +| `useChatInputForm.ts` | 对应 hook/utils 测试 | 否 | 草稿身份隔离 | +| 四个交互渲染组件 | 优先通过实例 action 集成测试覆盖 | 否 | 当前真实串线入口 | +| Markdown/快捷入口 | 组件测试或 action spy 测试 | 否 | 防止遗漏全局 emit | + +### 7.2 自动化测试设计 + +| 类型 | 用例 | 预期 | +|---|---|---| +| 单元 | 两个 identity 生成不同 instance key | key 不相同 | +| 单元 | registry 指定右侧 target | 只调用右侧 handler | +| 单元 | registry 多实例无 target | 返回 `ambiguous`,两个 handler 都不调用 | +| 单元 | 注销右侧 handler | 左侧 handler 仍存在且可调用 | +| 单元 | 同 chatId、不同 sourceKey 的草稿 | 分别读写 | +| 组件 | 两个 action Provider 下各渲染交互按钮 | 点击右侧只触发右侧 spy | +| 组件 | user form/select/preview/payment 分别提交 | payload 和原实现一致,目标实例正确 | +| 组件 | 快捷问题和编辑问题 | 只发送/填充来源实例 | +| 集成 | Builder + Run Preview 同时生成 | 两侧 records 独立更新 | +| 集成 | 任一侧关闭/重开 | 另一侧发送能力不丢失 | +| 集成 | 刷新后恢复 | 两侧按各自 target 恢复 | + +### 7.3 场景覆盖 + +| 场景 | 覆盖 | 对应用例 | +|---|---|---| +| 基础场景 | 是 | 左右普通发送、右侧表单提交 | +| 复杂场景 | 是 | 两边并发生成、交互嵌套、关闭重开 | +| 边界值 | 是 | 相同 chatId 不同 sourceKey、单实例 legacy 外部消息 | +| 异常场景 | 是 | target 不存在、多实例无 target、handler 注销 | +| 安全边界 | 是 | 外部消息不广播、warning 不记录正文 | + +### 7.4 执行命令与目标 + +| 命令 | 目标 | 覆盖率目标 | +|---|---|---| +| `pnpm --dir projects/app test test/components/core/chat/ChatContainer/context/chatInstanceActionsContext.test.tsx` | action context | 100% 行/分支 | +| `pnpm --dir projects/app test test/components/core/chat/ChatContainer/ChatBox/utils/externalPromptRegistry.test.ts` | 外部 registry | 100% 行/分支 | +| `pnpm --dir projects/app test test/components/core/chat/components/AIResponseBox/utils.test.ts` | 交互纯函数回归 | 100% 相关分支 | +| `pnpm --dir projects/app typecheck` | TypeScript | 0 error | +| `pnpm exec eslint <本次修改文件>` | Lint | 0 新 error/warning | +| `pnpm prettier --check <本次修改文件>` | 格式 | 全部通过 | + +实现完成后再填写真实执行结果,不得在开发前伪填通过。 + +## 8. 质量自检清单 + +- [ ] ChatBox 内部不存在 `sendQuestion/editQuestion` 全局 emit。 +- [ ] 交互提交保留正确 `lastInteractive` 与 `agentPlanAskResponse`。 +- [ ] 一个实例 cleanup 不会注销另一个实例。 +- [ ] 多实例外部无 target 消息不会广播。 +- [ ] `sourceKey + chatId` 贯穿动作、草稿、外部桥接。 +- [ ] 单实例聊天行为无回归。 +- [ ] 不新增用户文案,无 i18n 遗漏。 +- [ ] 测试覆盖当前截图复现路径。 + +## 9. 发布与回滚 + +### 9.1 发布步骤 + +1. 先提交实例 action context、纯逻辑与测试。 +2. 再迁移交互入口和快捷入口。 +3. 最后移除 ChatBox 内部全局 send/edit 监听,上线外部 registry。 +4. 在应用详情页手工执行双 ChatBox 验收矩阵。 + +### 9.2 回滚触发条件 + +- 单实例聊天无法发送、继续交互或填充编辑内容。 +- 外部嵌入调用无法提供 target 且无法走单实例兼容。 +- 双实例中仍出现任何一次跨实例记录写入。 + +### 9.3 回滚步骤 + +1. 回滚实例 action 迁移提交,恢复原全局入口。 +2. 保留无数据副作用的测试和问题复现用例,避免问题被遗忘。 +3. 新草稿 key 无需数据回滚;必要时恢复双读旧 key。 + +## 10. AI 实施 TODO + +- [ ] T1:新增实例 action context,并在 ChatBox 中绑定 identity 和本地动作。 +- [ ] T2:迁移四类 interactive 组件,验证 payload 完全兼容。 +- [ ] T3:迁移所有 send/edit 全局 emit,使用 `rg` 做零遗漏检查。 +- [ ] T4:实现外部 registry、精确 cleanup 和歧义拒绝。 +- [ ] T5:升级草稿 key 和 DOM 查询作用域。 +- [ ] T6:先写能稳定复现“右侧提交进入左侧”的失败测试,再完成修复。 +- [ ] T7:运行针对性测试、typecheck、lint、prettier 和双面板手工验证。 + diff --git "a/.agents/design/core/chat/chatbox-instance-isolation-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/.agents/design/core/chat/chatbox-instance-isolation-\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..5b7eb5528363 --- /dev/null +++ "b/.agents/design/core/chat/chatbox-instance-isolation-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,362 @@ +# ChatBox 实例隔离需求设计文档 + +## 0. 文档标识 + +- 任务前缀:`chatbox-instance-isolation` +- 文档文件名:`chatbox-instance-isolation-需求设计文档.md` +- 关联现象:同一应用详情页同时打开左侧 Workflow Builder 和右侧运行预览时,运行预览的交互答案被发送到 Workflow Builder。 +- 设计结论:聊天记录本身已经按 Provider 和会话目标隔离;串线发生在前端全局动作分发层。 + +## 1. 需求背景与目标 + +### 1.1 背景 + +当前应用详情页允许同时存在两个 `ChatBox`: + +1. 左侧 Workflow Builder:`sourceType=workflowBuilder`,使用独立 Builder `chatId`。 +2. 右侧运行预览:`sourceType=app`,使用测试会话 `chatId`。 + +两边的 `ChatItemContextProvider`、`ChatRecordContextProvider`、生成控制器和后端会话目标原本是独立的。但是,历史交互卡片、快捷问题和编辑问题仍通过模块级全局 `eventBus` 发送 `sendQuestion/editQuestion`。`eventBus` 对每种事件只保存一个函数,两个 `ChatBox` 会争夺同一个监听槽。 + +已确认的错误链路: + +```mermaid +sequenceDiagram + participant R as "运行预览表单" + participant E as "全局 eventBus.sendQuestion" + participant B as "最后注册的 Builder ChatBox" + participant P as "运行预览 ChatBox" + + R->>E: emit({ text: JSON, focus: true }) + Note over E: Map.set 只保留最后一个监听器 + E->>B: sendPrompt(JSON) + B->>B: 写入 Builder 记录并请求 Builder API + Note over P: 没收到提交,流程停在原交互卡片 +``` + +### 1.2 目标 + +#### 业务目标 + +- Workflow Builder 与运行预览可以同时打开、同时聊天、同时生成,互不抢占消息和状态。 +- 用户在任意一侧执行表单提交、选项选择、Ask 回答、快捷问题、编辑问题、继续运行时,只影响操作发生的那一个聊天框。 +- 关闭、刷新或重新挂载任意一侧,不得删除或覆盖另一侧的动作监听。 + +#### 技术目标 + +- 将 ChatBox 内部动作从“全局广播”改为“实例内调用”。 +- 使用 `sourceKey + chatId` 作为完整实例身份,覆盖动作、草稿和外部消息桥接。 +- 保留真正的外部消息入口,但多实例时必须精确寻址;禁止在目标不明确时猜测接收者。 +- 不复制 Workflow Builder 专用 ChatBox,不引入两套聊天实现。 + +#### 成功指标 + +1. 两个 ChatBox 同时挂载时,所有内部发送动作 100% 只进入来源实例。 +2. 任一实例卸载、重渲染或恢复生成,不影响另一实例继续发送。 +3. 两边同时流式生成、停止和恢复时,记录、状态、控制器和 UI 无交叉写入。 +4. 单 ChatBox 页面现有交互行为保持不变。 +5. 新增纯逻辑和路由模块达到 100% 行/分支覆盖;受影响组件关键分支不低于 90%。 + +## 2. 项目事实基线 + +### 2.1 项目画像 + +| 项目项 | 事实 | +|---|---| +| 主应用 | `projects/app`,Next.js + React + TypeScript + Chakra UI | +| 前端状态 | React Context、`use-context-selector`、Zustand、React Hook Form | +| 聊天记录 | `ChatRecordContextProvider` 按请求参数维护实例内记录 | +| 聊天运行时 | `WorkflowRuntimeContextProvider` 提供 `sourceTarget/sourceKey/chatId` | +| 测试 | Vitest,项目命令 `pnpm --dir projects/app test` | +| 质量检查 | ESLint、Prettier、TypeScript `tsc --noEmit` | +| 设计文档 | `.agents/design/`;现有 ChatBox 重构设计位于 `.agents/design/core/chat/chatbox-refactor.md` | + +### 2.2 现有能力与隔离状态 + +| 能力项 | 代码锚点 | 当前状态 | 结论 | +|---|---|---|---| +| Builder 会话身份 | `WorkflowBuilder/ChatPanel.tsx`:`sourceTarget`、`sourceChatIdMap` | 使用 `workflowBuilder:${appId}` 与独立 `chatId` | 已隔离,复用 | +| 运行预览身份 | `WorkflowComponents/Flow/ChatTest.tsx`、`useChatTest.tsx` | 使用 `sourceType=app` 与测试 `chatId` | 已隔离,复用 | +| ChatItem 状态 | `web/core/chat/context/chatItemContext.tsx`:`ChatItemContextProvider` | 两侧分别创建 Provider | 已隔离,复用 | +| ChatRecord 状态 | `web/core/chat/context/chatRecordContext.tsx` | 两侧分别创建 Provider,Builder 额外传入 `sourceType=workflowBuilder` | 已隔离,复用 | +| 生成与恢复 | `ChatBox/hooks/useChatGenerate.ts`、`useChatResume.ts` | 控制器、active target ref 和记录 setter 均在实例内 | 已隔离,保留 | +| 运行时身份 | `ChatContainer/context/workflowRuntimeContext.tsx` | 已提供 `sourceKey` 与 `chatId` | 可直接作为实例身份 | +| 发送/编辑动作 | `web/common/utils/eventbus.ts`、`ChatBox/index.tsx` | 全局单监听槽,后注册覆盖先注册 | 必须修改 | +| 交互卡提交 | `AIResponseBox/utils.ts`:`onSendPrompt` | 无目标信息,`focus=true` 可绕过普通可发送条件 | 必须修改 | +| 外部消息 | `ChatBox/index.tsx`:`window.addEventListener('message')` | 每个实例都监听同一无目标消息 | 必须修改 | +| 输入草稿 | `ChatBox/hooks/useChatInputForm.ts` | 仅使用 `chatInput_${chatId}` | 应升级为 `sourceKey + chatId` | +| DOM 标识 | `ChatRecordsList.tsx` 的 `#history`、`AppChatMain.tsx` 的 `#variable-input` | 页面会出现重复 ID;变量查询已限定容器,导出仍使用全局查找 | 本期处理与实例动作相关的风险;导出作用域作为关联修复 | + +### 2.3 根因 + +根因不是后端会话、数据库记录或 React Provider 混用,而是全局动作通道不支持多实例: + +1. `eventBus.list` 是 `Map`。 +2. `on(name, fn)` 使用 `Map.set`,相同事件的新监听器覆盖旧监听器。 +3. `ChatBox` effect 会随 `lastInteractive/canSendPrompt` 变化反复注册。 +4. `off(name)` 按事件名整体删除,任一实例 cleanup 都可能删除另一实例刚注册的处理器。 +5. `onSendPrompt` 不携带实例身份,且 `focus=true` 让错误接收者仍可发送。 + +因此接收方取决于 React effect 最近一次执行顺序,属于生命周期竞争,而不是稳定业务规则。 + +## 3. 需求澄清记录 + +| 维度 | 已确认内容 | 设计决策 | 待确认内容 | +|---|---|---|---| +| 业务目标 | 两个聊天框与内容完全隔离,可同时操作 | 按通用 ChatBox 多实例能力设计,不做 Builder 特例 | 无 | +| 范围边界 | 当前直接场景是 Builder + 运行预览 | 内部所有发送入口一并实例化,避免下一类按钮继续串线 | 无 | +| 数据模型 | 两边应保留各自记录与 `chatId` | 不改数据库和后端接口 | 无 | +| 前端交互 | 一侧提交时另一侧不动 | 操作只进入来源实例;目标不明确时拒绝分发 | 无 | +| 兼容策略 | 单聊天框页面不能回归 | 单实例外部旧消息可兼容;多实例旧消息禁止猜目标 | 无 | +| UI | 用户未要求视觉变化 | 不改 Figma 样式和布局 | 无 | + +### 3.1 影响域判定 + +| 维度 | 是否命中 | 证据 | 核对规范 | 结论 | +|---|---|---|---|---| +| API | No | 后端会话目标已经通过 `sourceTarget/chatId` 区分 | 项目 API 规范 | Not Applicable,不新增或修改接口 | +| Data | No | 不新增数据库字段,记录 Provider 已按目标查询 | 项目数据规范 | Not Applicable | +| Frontend | Yes | 全局动作总线、ChatBox 生命周期和草稿键需要修改 | 项目前端规范 | 采用实例动作上下文 | +| Logging | Yes | 多实例收到无目标外部消息时需要可诊断 | 项目日志规范 | 仅开发环境警告,不记录消息正文 | +| Packaging | No | 不新增第三方依赖,不跨 workspace 调整依赖 | 项目模块规范 | Not Applicable | +| Testing | Yes | 需要证明多实例动作、卸载和恢复隔离 | `testing-standards.md` | 单元 + 组件集成测试 | +| DocI18n | No | 无新增用户文案 | 项目文档 i18n 规范 | Not Applicable | + +## 4. 范围定义 + +### 4.1 In Scope + +- ChatBox 内部发送、交互继续、快捷问题和编辑输入的实例化动作接口。 +- 以下现有全局入口迁移到实例动作: + - `RenderUserFormInteractive` + - `RenderUserSelectInteractive` + - `RenderWorkflowBuilderPreviewInteractive` + - `RenderPaymentPauseInteractive` + - AI 回复后的问题建议 + - 欢迎页快捷问题 + - Markdown 空链接快捷问题 + - Question Guide 的发送与编辑 +- `window.postMessage({ type: 'sendPrompt' })` 的目标化和歧义处理。 +- 监听器精确注册/解绑,消除 effect 顺序依赖。 +- 草稿 key 升级为完整实例 key。 +- 与多实例直接相关的 DOM 查询限定到当前 ChatBox 根节点。 +- 两实例并存、卸载、并发生成和刷新恢复测试。 + +### 4.2 Out of Scope + +- Workflow Builder 或运行预览 UI 样式调整。 +- 后端 Chat API、数据库 Schema、S3、Sandbox 隔离策略调整。 +- 重写 `useChatGenerate/useChatResume`。 +- 把所有全局 `eventBus` 能力整体替换;`refreshFeedback` 等非 ChatBox 动作保留现状。 +- 多浏览器 Tab 的会话协作能力。 + +## 5. 方案对比 + +| 方案 | 核心思路 | 优点 | 风险 | 成本 | 结论 | +|---|---|---|---|---|---| +| A. 实例动作上下文 + 目标化外部桥接 | ChatBox 创建本地 action context,内部组件直接调用所属实例;外部消息通过 `sourceKey + chatId` 注册表寻址 | 不广播、类型清晰、组件天然拿到所属实例、支持任意数量 ChatBox | 需要迁移所有内部 emit 点 | 中 | 推荐 | +| B. 多监听器事件总线 + payload 目标字段 | `eventBus` 改成 handler Set,每个 ChatBox 过滤目标 | 改动模型接近现状 | 任一漏传 target 就广播或丢失;业务继续依赖全局基础设施;cleanup 和兼容复杂 | 中 | 不推荐 | +| C. Builder 使用独立事件名或复制 ChatBox | 新增 `sendWorkflowBuilderQuestion`,或维护 Builder 专用 ChatBox | 表面改动快 | 只覆盖当前两边;第三个 ChatBox 再次冲突;两套实现持续漂移 | 低起步、高维护 | 拒绝 | +| D. 打开运行预览时卸载/冻结 Builder | 保证页面只有一个监听器 | 实现简单 | 违背“两个聊天框同时独立运行”的目标,会中断 Builder | 低 | 拒绝 | + +推荐方案 A。核心判断是:组件已经位于某个 ChatBox 的 React 子树内,所属实例是确定的,因此内部动作不应先逃逸到全局通道再猜接收者。 + +## 6. 推荐方案详细设计 + +### 6.1 实例身份 + +统一使用: + +```ts +type ChatInstanceIdentity = { + sourceKey: string; + chatId: string; +}; + +const instanceKey = `${sourceKey}:${chatId}`; +``` + +`sourceKey` 已由 `getChatSourceKey(sourceTarget)` 生成: + +- Builder:`workflowBuilder:` +- 运行预览:`app:` + +即使极端情况下两边 `chatId` 相同,完整实例 key 仍不同。 + +### 6.2 ChatBox 实例动作上下文 + +新增 ChatBox 内部 action context,建议职责如下: + +```ts +type ChatInstanceActions = { + identity: ChatInstanceIdentity; + sendMessage: (input: ChatBoxInputType) => void; + continueInteractive: (input: ChatBoxInputType) => void; + fillInput: (input: ChatBoxInputType) => void; +}; +``` + +动作语义必须分开: + +- `sendMessage`:普通用户发送,经过 `disabledSendTip` 和普通发送限制。 +- `continueInteractive`:提交当前交互,绑定当前实例的 `lastInteractive`,不能依赖旧的 `focus=true` 绕行语义。 +- `fillInput`:只修改所属实例的输入框。 + +Provider 放在 `ChatBox` 内部,在 `sendPrompt/lastInteractive/resetInputVal` 已创建后包裹消息区和输入区。内部组件使用 hook 取得 action,不再 import 全局 `eventBus`。 + +### 6.3 内部调用链 + +目标调用链: + +```mermaid +flowchart LR + A["运行预览交互卡"] --> B["useChatInstanceActions"] + B --> C["右侧 ChatBox continueInteractive"] + C --> D["右侧 lastInteractive"] + C --> E["右侧 useChatGenerate.sendPrompt"] + E --> F["右侧 ChatRecordContext"] + E --> G["运行预览 chatTest API"] + + H["Workflow Builder 交互卡"] --> I["左侧 ChatBox action context"] + I --> J["Builder ChatRecordContext"] + I --> K["Workflow Builder API"] +``` + +两条链路不存在共享可写节点。 + +### 6.4 外部消息桥接 + +仓库内没有 `window.postMessage({ type: 'sendPrompt' })` 的发送方,但该入口可能供嵌入场景使用,不能让每个 ChatBox 无条件监听。 + +新增目标化注册表: + +```ts +type ExternalChatPrompt = { + type: 'sendPrompt'; + text: string; + target?: ChatInstanceIdentity; +}; + +registerChatInstance(identity, handler): () => void; +dispatchExternalPrompt(payload): 'sent' | 'not-found' | 'ambiguous'; +``` + +路由规则: + +1. 有 `target`:只发送到完全匹配的实例。 +2. 无 `target` 且当前只注册一个可用 ChatBox:兼容旧行为。 +3. 无 `target` 且存在两个及以上 ChatBox:拒绝发送,不广播、不猜测;开发环境输出不含消息正文的 warning。 +4. cleanup 使用注册函数返回的精确注销函数,只注销当前实例。 + +### 6.5 草稿隔离 + +现有 `chatInput_${chatId}` 改为: + +```ts +const draftKey = `chatInput_${sourceKey}:${chatId}`; +``` + +兼容策略: + +1. 优先读取新 key。 +2. 新 key 不存在时,只在单实例/明确目标初始化阶段读取旧 key 一次并迁移。 +3. 后续写入和删除只操作新 key。 + +### 6.6 DOM 查询隔离 + +- `useVariableInputVisibility` 已从当前滚动容器查询 `#variable-input`,逻辑上已限定作用域,可改为 `data-chat-variable-input` 避免重复 ID。 +- `useChatBox.onExportChat` 当前使用全局 `document.getElementById('history')`,应由当前 ChatBox root/history ref 驱动,避免以后同页提供导出按钮时导出另一实例。 + +### 6.7 API 与数据 + +- API:Not Applicable,无路由或契约变更。 +- Data:Not Applicable,无数据库字段、索引或迁移。 +- 后端现有 `sourceType + sourceId + chatId` 隔离继续使用。 + +### 6.8 日志与隐私 + +多实例收到无目标外部消息时,仅在开发环境记录: + +```ts +console.warn('[ChatBox] Ignored ambiguous external prompt', { + registeredInstanceCount +}); +``` + +禁止记录 `text`、文件、用户输入或完整 identity。 + +## 7. 风险、迁移与回滚 + +### 7.1 风险 + +1. 漏迁某个 `eventBus.emit(sendQuestion/editQuestion)`,导致该入口继续依赖旧全局通道。 +2. 交互提交错误地走 `sendMessage` 而不是 `continueInteractive`,导致丢失 `lastInteractive`。 +3. 直接把 eventBus 改成多订阅者但未做 target,会把一次消息同时发送到两边。 +4. 旧外部 `postMessage` 在多实例页面没有 target 时会被拒绝;这是有意的安全行为,需要开发 warning 辅助发现调用方。 +5. 隐藏但仍挂载的运行预览不能算作可接收外部消息实例;注册条件必须绑定 `isReady/active`。 + +### 7.2 迁移策略 + +1. 先建立实例 action context 与纯逻辑测试。 +2. 迁移交互卡入口,优先修复当前串线主路径。 +3. 迁移快捷问题和编辑问题入口,删除 ChatBox 对内部 send/edit event 的监听。 +4. 上线目标化外部桥接并兼容单实例旧消息。 +5. 升级草稿 key 和 DOM ref。 +6. 最后通过 `rg` 保证聊天内部不再存在 `EventNameEnum.sendQuestion/editQuestion`。 + +### 7.3 回滚策略 + +- 代码不涉及持久数据,可按提交反向回滚。 +- 草稿新 key 回滚后旧代码不会读取,但只影响未发送草稿,不影响历史记录;迁移期可短期双读避免风险。 +- 回滚触发条件:单实例聊天的表单、快捷问题或编辑入口出现阻断,或外部嵌入场景无法指定目标。 + +## 8. 验收标准 + +| 验收项 | 验收方式 | 通过标准 | +|---|---|---| +| 右侧表单提交 | 同时打开 Builder 和运行预览,在右侧填写并提交 | 只有右侧新增记录并继续流程,左侧完全不变 | +| 左侧 Ask/预览操作 | 两边同时打开,在左侧选择或输入 | 只有 Builder 继续,右侧完全不变 | +| 快捷问题 | 两边分别点击欢迎问题/问题建议 | 点击来源实例独立发送 | +| 编辑问题 | 两边分别点击编辑入口 | 只填充来源实例输入框 | +| 并发生成 | 两边分别发起生成 | 可同时流式更新,停止任一边不停止另一边 | +| 生命周期 | 关闭/重开右侧、关闭/重开左侧 | 剩余实例动作持续有效,无监听丢失 | +| 刷新恢复 | 两边有独立运行状态时刷新 | 两边按自己的 `sourceKey/chatId` 恢复 | +| 外部目标消息 | 向指定 identity 发 `postMessage` | 只有指定实例接收 | +| 外部歧义消息 | 多实例时发送无 target 消息 | 两边都不发送,开发环境产生脱敏 warning | +| 草稿 | 两边输入不同未发送文本后刷新 | 分别恢复,不互换 | +| 单实例回归 | 普通聊天页执行原有动作 | 行为与改造前一致 | + +## 9. MECE 核查结论 + +### 9.1 相互独立 + +- 记录隔离由 `ChatRecordContextProvider` 负责。 +- 运行时身份由 `WorkflowRuntimeContextProvider` 负责。 +- 输入和动作隔离由新的 ChatBox action context 负责。 +- 外部跨树触发由目标化 registry 负责。 +- 每一层职责唯一,不让 registry 介入内部 React 子树通信。 + +### 9.2 完全穷尽 + +- 正常路径:普通发送、交互继续、快捷回复、编辑、外部目标发送。 +- 生命周期:挂载、重渲染、隐藏、卸载、刷新恢复。 +- 异常路径:目标不存在、多目标歧义、交互过期、生成中停止。 +- 兼容路径:单实例无 target 外部消息、旧草稿迁移。 + +### 9.3 修订动作 + +`[问题]` 仅修复运行预览表单入口会遗漏其他全局 emit 点。 +`影响:` 下一次点击问题建议或编辑问题仍可能串线。 +`修订动作:` 将所有 ChatBox 内部 send/edit emit 统一迁移到实例 action context。 +`修订后结果:` 内部动作链路完全脱离全局分发。 + +`[问题]` 只把 eventBus 改成多监听器会把无目标消息广播到两个 ChatBox。 +`影响:` 串线升级为双发,产生两份真实会话记录。 +`修订动作:` 内部使用实例回调;外部桥接必须目标化并拒绝歧义。 +`修订后结果:` 单次操作最多命中一个明确实例。 + +`[问题]` 草稿只按 `chatId` 分区,未表达完整会话来源。 +`影响:` 极端 ID 碰撞或未来复用会恢复错误草稿。 +`修订动作:` 草稿 key 使用 `sourceKey + chatId`。 +`修订后结果:` 输入持久化与运行时身份口径一致。 + diff --git a/.agents/design/core/workflow/index.md b/.agents/design/core/workflow/index.md index 8d60e7939a09..4bf6c205d8cb 100644 --- a/.agents/design/core/workflow/index.md +++ b/.agents/design/core/workflow/index.md @@ -4,6 +4,11 @@ FastGPT 工作流系统是一个基于 Node.js/TypeScript 的可视化工作流引擎,支持拖拽式节点编排、实时执行、并发控制和交互式调试。系统采用队列式执行架构,通过有向图模型实现复杂的业务逻辑编排。 +## 专题设计文档 + +- [Workflow Builder(工作流辅助生成)](./workflow-builder.md):当前工作流辅助生成的功能范围、端到端流程、双仓库职责、Sandbox/CLI/Core 架构、版本应用和测试部署说明。 +- [Workflow Builder 评测系统](../../../../pro/admin/test/core/ai/workflowBuilder/README.md):定义工作流自动生成的五项指标、案例数据结构、运行流程、报告和版本归档方式。 + ## 核心架构 ### 1. 项目结构 diff --git a/.agents/design/core/workflow/workflow-builder.md b/.agents/design/core/workflow/workflow-builder.md new file mode 100644 index 000000000000..82f7777f40c0 --- /dev/null +++ b/.agents/design/core/workflow/workflow-builder.md @@ -0,0 +1,701 @@ +# Workflow Builder(工作流辅助生成)设计文档 + +## 1. 功能概述 + +Workflow Builder 是 FastGPT 工作流编辑器中的端到端辅助生成功能。用户使用自然语言描述新建或修改目标,系统负责把需求转化为一个经过校验、可以一键应用到当前画布的完整工作流版本。 + +它交付的不是建议、代码片段或部分节点,而是: + +1. 理解当前画布和用户目标。 +2. 补齐会改变工作流结构的必要业务信息。 +3. 核对当前可用的系统配置、节点、模型、工具和其他受保护资源。 +4. 对改造方案进行破坏性影响预演。 +5. 生成并校验 Mermaid 方案预览。 +6. 等待用户确认方案。 +7. 在隔离的 Sandbox 草稿中完整搭建工作流。 +8. 使用 Workflow Core 校验并驱动 Agent 自主修复。 +9. 原子生成候选 `workflow.json`,归档为有时效的版本文件。 +10. 等待用户第二次确认后,用候选版本覆盖当前画布。 + +因此,这项功能有两道明确的用户确认: + +- 第一道确认:确认 Mermaid 方案,授权 Agent 开始搭建。 +- 第二道确认:点击“应用到画布”,授权候选版本覆盖当前画布。 + +### 1.1 已实现能力 + +- 支持从零创建工作流和修改当前工作流。 +- 支持普通节点、系统配置、全局变量、动态输入输出、执行边、分支、工具调用和容器关系。 +- 支持读取当前画布事实与当前成员有权限使用的系统工具模板。 +- 支持需求追问、方案修改和取消任务。 +- 支持方案预演以及服务端 Mermaid 真实解析校验。 +- 支持同一个 AgentLoop 中持续搭建、校验、自修复和提交。 +- 支持分片 ChangeSet,避免复杂工作流一次工具调用过大。 +- 支持生成过程、工具执行、交互和错误状态的 SSE 展示。 +- 支持候选版本 S3 归档、聊天版本卡片、顶部待应用横幅和已生成版本再次应用。 +- 支持应用失败时恢复应用前画布。 +- 支持页面刷新后的交互恢复和生成状态恢复。 +- 支持打开 Builder 面板时预热 Sandbox,减少首次生成等待时间。 +- 支持简体中文、繁体中文和英文界面文案。 +- 支持开发环境、主服务镜像和商业版镜像的依赖构建。 + +### 1.2 当前明确边界 + +- 完整生成能力依赖商业版 `pro` 后端、Agent Sandbox 和 S3 聊天文件存储。 +- 用户必须对当前应用具有写权限。 +- Builder 不会绕过用户确认自动覆盖画布。 +- Builder 不在数据库中维护另一套工作流草稿状态;草稿和 CLI 事务文件位于 Sandbox。 +- Builder 不通过研究型 Sandbox 工具直接读写当前工作流;当前工作流事实必须通过 Workflow CLI Gateway 获取。 +- 候选版本不是永久版本,目前归档有效期为 1 天。 +- 应用候选版本的产品语义是完整覆盖当前画布,不是与用户后续编辑做三方合并。 +- 自动布局失败不影响候选 JSON 已经应用成功的业务结果。 + +## 2. 用户看到的完整流程 + +```mermaid +flowchart TD + Open["用户打开工作流编辑器"] --> Access{"是否满足商业版、Sandbox 和写权限"} + Access -->|否| Upgrade["展示商业版能力与解锁入口"] + Access -->|是| Panel["打开工作流辅助生成面板并预热 Sandbox"] + Panel --> Request["用户描述创建或修改目标"] + Request --> Understand["Agent 理解需求并读取当前画布与资源"] + Understand --> Complete{"关键信息是否足以确定工作流结构"} + Complete -->|否| Ask["一次性追问必要业务问题"] + Ask --> Understand + Complete -->|是| Rehearse["预演删除、引用、工具和容器影响"] + Rehearse --> Preview["服务端校验并展示 Mermaid 完整方案"] + Preview --> Decision{"用户选择"} + Decision -->|修改方案| Understand + Decision -->|取消| Cancel["结束本次任务,不修改画布"] + Decision -->|确认并开始搭建| Build["Agent 在 Sandbox 私有草稿中分片搭建"] + Build --> Validate["Workflow Core 校验与 Agent 自修复"] + Validate -->|仍有错误| Build + Validate -->|通过| Candidate["CLI 原子提交候选 workflow.json"] + Candidate --> Archive["后端归档 S3 并写入聊天版本卡片"] + Archive --> ApplyDecision{"用户是否点击应用到画布"} + ApplyDecision -->|暂不应用| Keep["保留未过期版本卡片"] + ApplyDecision -->|应用| Load["按版本从 S3 加载并校验"] + Load --> Replace["完整覆盖当前画布并尝试自动布局"] + Replace --> Commit["记录首次应用时间与撤销快照"] +``` + +### 2.1 入口和可用性 + +入口位于 Workflow 编辑器左侧工具栏。系统根据以下条件决定入口行为: + +| 条件 | 行为 | +| --- | --- | +| 系统尚未初始化 | 暂不显示入口 | +| `show_agent_sandbox` 关闭 | 不显示入口 | +| `show_workflow_builder` 显式为 `false` | 不显示入口 | +| 用户没有应用写权限 | 不显示入口 | +| 非商业版但其余条件满足 | 显示入口,打开后展示商业版能力和解锁按钮 | +| 商业版、Sandbox 开启且有写权限 | 启用完整 Builder 对话 | + +首次进入工作流编辑器时,系统配置入口和 Workflow Builder 入口使用统一引导状态。Builder 支持: + +- 独立左侧聊天面板。 +- 模型选择,选择结果按应用保存在本地。 +- 欢迎语和示例问题。 +- 清空当前 Builder 会话历史。 +- 收起后继续生成。 +- 生成光环、待处理红点和待应用顶部横幅。 + +### 2.2 独立会话 + +Builder 使用 `ChatSourceTypeEnum.workflowBuilder`,不复用普通调试聊天。会话按以下维度隔离: + +- 应用 `appId` +- 团队成员 `tmbId` +- Builder `chatId` + +拥有应用写权限不代表可以读取其他成员的 Builder 会话。后端会同时校验应用权限和会话成员归属。 + +## 3. 一轮请求如何进入后端 + +用户发送消息时,前端从当前 React Flow 状态实时构造 `WorkflowDocument`: + +- `app`:应用标识、名称、简介、类型和基础版本信息。 +- `nodes`:当前完整节点配置。 +- `executionEdges`:从 React Flow 边反编译得到的语义执行边。 +- `chatConfig`:当前工作流对话配置。 + +前端计算文档 SHA-256 checksum,并把以下数据发送到商业版接口: + +```text +POST /api/proApi/core/workflow/builder/chat +``` + +核心请求字段: + +| 字段 | 作用 | +| --- | --- | +| `appId` | 当前工作流应用 | +| `chatId` | 独立 Builder 会话 | +| `responseChatItemId` | 当前 AI 消息标识,恢复时复用 | +| `messages` | 当前轮用户消息 | +| `model` | 用户选择的模型 | +| `agentPlanAskResponse` | 对需求追问或方案预览的结构化回答 | +| `workflowContext.document` | 请求开始时的完整画布事实 | +| `workflowContext.checksum` | 请求文档 checksum | + +后端 Handler 依次完成: + +1. 使用 API Schema 和 `parseApiInput` 校验请求。 +2. 校验应用写权限、功能开关和 Agent Sandbox 开关。 +3. 校验 Builder 会话属于当前成员。 +4. 加载成员有权使用的系统工具模板,形成 `WorkflowTemplateBundle`。 +5. 加载 Builder 聊天历史和未完成交互。 +6. 校验新任务请求中画布文档的 `appId` 和 checksum。 +7. 创建用量记录、聊天轮次和 SSE 上下文。 +8. 调用主 `AgentLoop` Runner。 +9. 持久化回答、工具过程、交互、最终文本和候选版本。 + +## 4. Agent 的业务生命周期 + +### 4.1 普通对话与工作流任务分流 + +Runner 首先判断用户是否真的在提出或继续一个工作流任务: + +- 问候、能力咨询和无关问题直接自然语言回答。 +- 创建、修改或继续搭建工作流才进入完整 Builder 生命周期。 + +普通对话不创建工作流计划、不查询画布,也不会产生候选版本。 + +### 4.2 需求建模 + +Agent 从用户输入中提炼: + +- 业务目标。 +- 触发方式和输入来源。 +- 核心处理过程。 +- 输出结果。 +- 必要依赖。 +- 成功标准。 + +只有缺失信息会改变节点类型、分支/循环/并行拓扑、执行方式、输入输出契约或核心结果时,Agent 才向用户追问。名称、文案和其他低影响细节使用合理默认值,避免把设计工作重新交给用户。 + +同一轮发现的缺口会合并为一次提问,最多提出三个简短业务问题。技术诊断、Schema 错误、连线错误和内部重试不会直接展示给用户处理。 + +### 4.3 事实和资源核对 + +当前工作流事实只能来自 `workflow_cli_query`。Agent 可以查询: + +- 当前或草稿文档概览。 +- 节点、输入、输出和可用引用。 +- 执行边、分支、工具和容器子节点。 +- 系统配置与全局变量。 +- 内置模板和当前成员可使用的系统工具模板。 +- 当前或草稿的 Workflow Core 校验结果。 + +研究 Sandbox 工具可以读取运行时 Skill 和用户输入文件,但不能访问私有事务目录、CLI 启动器或 `workflow.json`。 + +### 4.4 方案预演 + +在展示 Mermaid 之前,Agent 对会破坏现有工作流的操作做轻量预演: + +| 操作 | 必须核对的影响 | +| --- | --- | +| 删除工具 | 所属工具调用节点是否还剩可执行工具 | +| 删除或重配节点/输出 | 是否仍被其他节点引用 | +| 删除循环终止节点 | 条件循环是否仍有退出路径 | +| 删除容器节点 | 是否会连带删除容器内子节点 | + +能够安全修复的影响由 Agent 直接调整方案;需要业务取舍的影响才使用业务语言交给用户决定。配置完整性问题留到搭建后的正式校验阶段。 + +### 4.5 Mermaid 方案预览 + +Agent 必须调用专用工具 `workflow_builder_present_preview` 提交: + +- 方案标题。 +- 完整 Mermaid 源码。 +- 至少一个自由结构的 Markdown 说明章节。 + +后端不会重写 Mermaid,而是使用与前端一致的 `mermaid` 解析器真实校验: + +- 只接受 `flowchart TD` 或 `flowchart LR`。 +- 使用独立 JSDOM 初始化 DOMPurify,不污染 Node.js 全局 DOM。 +- 解析任务串行执行,避免 Mermaid 内部共享状态并发互相覆盖。 +- 失败时把解析错误返回给 Agent,最多允许连续修复 3 次。 +- 只有解析成功的源码才会形成 `workflowBuilderPreview` 交互并发送给前端。 + +前端不再负责把错误 Mermaid 猜测性转换为其他格式,因此不会展示一个已知无法渲染的空占位。 + +### 4.6 用户对预览的三种决定 + +| 动作 | 后端状态变化 | +| --- | --- | +| `confirm` | 标记方案已确认,允许进入连续搭建阶段 | +| `revise` | 把修改意见交回同一个 AgentLoop,要求重新提交完整预览 | +| `cancel` | 产生结构化取消事实并结束任务 | + +预览未确认前,Gateway 会拒绝 `workflow_cli_stage` 和 `workflow_cli_commit`。确认后,普通 `ask_agent` 也会被拒绝,避免 Agent 在技术执行阶段再次暂停并把内部问题交给用户。 + +## 5. 单 Sandbox 运行架构 + +### 5.1 为什么使用单一物理 Sandbox + +每个 Builder 会话复用一个按应用、成员和聊天隔离的物理 Sandbox。这样可以同时满足: + +- Agent 研究工具、内置 Skill 和用户文件不需要重复准备。 +- Workflow CLI 的草稿和分片可以跨 AgentLoop 轮次保留。 +- 页面刷新或交互恢复后仍可继续当前任务。 +- 避免研究 Sandbox 与 CLI Sandbox 分离后产生资源重复和状态漂移。 + +### 5.2 同一 Sandbox 内的权限分区 + +同一个物理 Sandbox 内分成两个逻辑区域: + +```text +普通工作目录 +└── Agent 研究工具、Skill、用户文件 + +.fastgpt/workflow-builder/ +└── transaction/ # 服务端私有事务区域 + ├── workflow.json + ├── template-bundle.json + ├── chunks/ + └── runs/ +``` + +服务端研究执行器显式拦截任何包含私有目录、CLI 入口或 `workflow.json` 的参数。Agent 只能通过 Gateway 暴露的结构化工具访问当前工作流。 + +### 5.3 稳定运行时与动态轮次 + +可预热的稳定内容包括: + +- Sandbox 实例和 provider 状态。 +- 包镜像配置。 +- Workflow Builder 内置 Skill。 +- Workflow CLI 构建产物和启动器。 +- Skill 扫描结果。 + +每轮请求动态写入: + +- 当前 `WorkflowDocument`。 +- 当前成员有权限使用的模板 Bundle。 +- 当前用户输入文件。 +- 本轮草稿、分片和运行目录。 + +前端首次打开可用 Builder 面板后调用: + +```text +POST /api/proApi/core/workflow/builder/runtime/prewarm +``` + +预热按 runtime key 去重,失败不阻断面板使用;正式请求仍会再次确保环境可用。 + +## 6. Workflow CLI Gateway 与草稿事务 + +Agent 不直接拼接 shell 命令,而是使用三个结构化系统工具: + +### 6.1 `workflow_cli_query` + +只读工具,支持读取 `current` 或 `draft` 视图,承担: + +- 当前事实查询。 +- 模板和描述符查询。 +- 输入输出、引用、边、工具和容器检查。 +- 草稿校验。 + +### 6.2 `workflow_cli_stage` + +把一个业务分片写入当前私有草稿: + +1. Agent 按业务模块产生 ChangeSet 分片。 +2. Gateway 校验分片结构、顺序、base checksum 和 draft revision。 +3. CLI 在事务目录应用分片。 +4. Workflow Core 返回变化摘要和诊断。 +5. 成功后更新分片 Manifest 与草稿 revision。 + +复杂工作流通过多个有依赖顺序的分片完成,推荐顺序是: + +1. 节点和系统配置。 +2. 执行关系。 +3. 输入引用、动态 IO、工具和容器关系。 + +### 6.3 `workflow_cli_commit` + +提交前必须先执行 `workflow_cli_query` 的 draft validate,并携带最新 `draftRevision`。Commit 的职责是: + +- 合并全部有效分片。 +- 再次验证 base checksum、目标 checksum 和阻断诊断。 +- 把最终候选文档原子写入 Sandbox 的 `workflow.json`。 +- 返回 `candidate_ready` 和 `taskComplete: true`。 + +这里的 CLI Commit 只表示候选 JSON 已准备完成,不会修改用户画布,也不是版本接口的 applied commit。 + +同一修复目标的 Stage 或 Commit 最多允许连续失败 10 次。在到达结构化终止条件前,Agent 必须根据诊断自主修复。 + +## 7. Workflow Core 与 CLI + +### 7.1 `@fastgpt/workflow-core` + +Workflow Core 是与 UI、数据库和 Agent 解耦的领域层,负责: + +- `WorkflowDocument` Schema 和兼容解析。 +- Store Workflow 与语义文档互相编译。 +- 节点模板实例化。 +- 系统配置和全局变量描述符。 +- 输入输出和变量引用编解码。 +- 语义端口和执行边编译。 +- 工具、容器和嵌套关系。 +- ChangeSet 应用、合并和计划生成。 +- checksum、诊断和完整工作流校验。 + +`WorkflowDocument` 是搭建过程中的唯一规范工作流状态: + +```text +schemaVersion: fastgpt-workflow/v1 +app +nodes +executionEdges +chatConfig +``` + +React Flow 坐标边不是 Agent 的主要编辑协议。Core 使用语义端口表达 `next`、`branch`、`catch`、`selectedTools` 等关系,最后再编译成 FastGPT Store Workflow。 + +### 7.2 `@fastgpt/workflow-cli` + +Workflow CLI 是 Core 的文件和命令行适配层,提供: + +- 文档初始化、导入、构建和查看。 +- Meta、系统配置和全局变量操作。 +- 模板、节点、输入、输出、边、工具和容器操作。 +- 校验。 +- ChangeSet plan/apply。 +- JSON 结构化输出和稳定退出码。 + +Builder 运行时通过 Gateway 使用 CLI,不把完整命令面直接暴露给模型。 + +### 7.3 双重可信校验 + +Sandbox 和 CLI 的输出不是最终信任边界。服务端还会: + +1. 使用 `WorkflowPlanSchema` 解析 Sandbox plan。 +2. 校验 plan 的 base checksum。 +3. 使用授权模板 Provider 在服务端重新执行 `planWorkflowChangeSet`。 +4. 以服务端重算的 target document、checksum、changes 和 diagnostics 为准。 +5. 再次应用 plan 并读取最终 `workflow.json`。 +6. 校验目标 `appId` 和 target checksum。 + +只有通过上述检查的文档才能成为候选版本。 + +## 8. 候选版本、S3 归档和聊天卡片 + +### 8.1 候选版本产生时机 + +CLI Commit 成功后,Handler 在发布版本卡片前立即归档候选文档: + +1. 解析并重新计算候选文档 checksum。 +2. 为当前 Builder 会话分配递增 `versionNo`。 +3. 生成名称和 JSON 文件名。 +4. 上传到聊天文件 S3,设置 1 天过期时间。 +5. 把版本对象写入当前 AI ChatItem。 +6. 通过 SSE 发送版本卡片。 + +如果 S3 上传失败,本轮不会发布一个无法应用的版本卡片。 + +版本结构包含: + +| 字段 | 语义 | +| --- | --- | +| `versionNo` | 当前 Builder 会话内的递增版本号 | +| `name` | 用户可见版本名 | +| `filename` | 归档 JSON 文件名 | +| `checksum` | 候选 WorkflowDocument checksum | +| `generatedAt` | 生成完成时间 | +| `s3Key` | 候选文档归档位置 | +| `expiresAt` | 版本过期时间 | +| `appliedAt` | 首次成功应用画布的时间 | + +### 8.2 版本展示状态 + +| 状态 | 条件 | UI 行为 | +| --- | --- | --- | +| `ready` | 未过期且从未应用 | 展示“应用到画布” | +| `available` | 未过期且已经应用过 | 仍可再次应用 | +| `expired` | 已到 `expiresAt` | 禁止应用并提示过期 | + +每个带 `s3Key` 且未过期的版本都可以从自己的 S3 对象独立加载,不依赖 Sandbox 中的最新草稿。未归档版本只支持从最新 Sandbox 读取。 + +## 9. 应用到画布的事务语义 + +用户点击版本卡片或顶部横幅的“应用到画布”后,前端执行: + +1. 调用版本 load API,按 ChatItem 身份从 S3 加载文档。 +2. 后端校验会话归属、过期时间、文档 Schema 和 checksum。 +3. 前端把文档转换为当前应用类型可接受的 Workflow 配置。 +4. 移除当前成员无权使用的模型引用。 +5. 保存应用前的节点、边和 `chatConfig`。 +6. 使用 `initData` 完整覆盖当前画布。 +7. 尝试自动布局;布局失败只记录警告,不回滚业务配置。 +8. 调用版本 commit API,幂等记录首次 `appliedAt`。 +9. 写入“我的编辑”撤销快照并更新版本卡片状态。 + +版本接口为: + +```text +POST /api/proApi/core/workflow/builder/version/load +POST /api/proApi/core/workflow/builder/version/commit +``` + +### 9.1 覆盖规则 + +应用版本就是完整覆盖当前画布。生成开始时的 base checksum 用于保证 Agent 在同一个基线上生成和校验候选版本,但用户最终点击应用时,不再要求当前画布仍等于生成基线,也不执行三方合并。 + +这意味着用户在生成期间对画布做的后续编辑可能被候选版本覆盖。第二道“应用到画布”确认就是这个覆盖动作的授权边界。 + +### 9.2 失败恢复 + +如果加载、转换、画布初始化或服务端 applied commit 失败: + +- 前端展示统一失败提示。 +- 在服务端 applied commit 成功前,尝试恢复应用前画布和 `chatConfig`。 +- 原始错误不会被恢复错误覆盖。 +- 版本过期使用独立提示。 + +服务端 commit 使用条件更新记录首次 `appliedAt`,并在并发请求下读取胜出的最终版本,保证幂等。 + +## 10. 交互恢复、状态和 SSE + +### 10.1 可恢复交互 + +需求追问和 `workflowBuilderPreview` 都作为结构化 interactive 写入 AI ChatItem。用户回答后,Handler 在长任务开始前先持久化回答,因此刷新页面不会丢失“已经回答/已经确认”的事实。 + +预览修改意见继续使用同一个 AgentPlan;普通新任务会清除上一轮未完成 Plan,保留历史文本和 memories。 + +### 10.2 前端派生状态 + +前端从聊天记录和生成状态推导: + +- `isChatGenerating`:聊天请求仍在运行。 +- `isBuildingWorkflow`:已经确认预览但尚未产生版本或终止事实。 +- `pendingInteractiveKey`:等待回答的需求问题或方案预览。 +- `errorAttentionKey`:收起面板后出现的新错误。 +- `latestVersion`:最新版本卡片和对应 AI 消息。 + +关键生成状态同时保存在 `sessionStorage`,用于 Fast Refresh 或页面刷新时避免入口光环瞬间丢失。 + +### 10.3 SSE 输出 + +Builder 复用 FastGPT 工作流 SSE 协议,主要发送: + +- Agent 文本和 reasoning。 +- 计划状态。 +- `workflow_cli_query/stage/commit` 工具过程。 +- Sandbox 初始化、升级和懒加载状态。 +- 需求追问或方案预览 interactive。 +- 候选版本卡片。 +- 节点响应、用量、耗时、停止和错误事件。 + +工具过程默认收纳在“过程详情”,最终用户结论与版本卡片保持突出。 + +## 11. 前后端和两个仓库的职责 + +### 11.1 FastGPT 主仓库 + +| 模块 | 职责 | 关键位置 | +| --- | --- | --- | +| Workflow Core | 领域文档、命令、模板、边、引用、校验 | `packages/workflow-core/` | +| Workflow CLI | 文件和命令行适配、结构化输出 | `packages/workflow-cli/` | +| 全局契约 | Builder 版本、API、interactive、SSE 类型 | `packages/global/core/workflow/builder/`、`packages/global/openapi/core/workflow/builder/` | +| 前端 Builder | 入口、面板、聊天、状态、版本应用 | `projects/app/src/pageComponents/app/detail/WorkflowComponents/WorkflowBuilder/` | +| 通用聊天渲染 | 预览交互、版本卡片、过程详情 | `projects/app/src/components/core/chat/` | +| 共享 UI 与 i18n | 图标、样式、中文/繁中/英文文案 | `packages/web/` | + +### 11.2 `pro` 商业版仓库 + +| 模块 | 职责 | 关键位置 | +| --- | --- | --- | +| API | chat、prewarm、version load/commit | `pro/admin/src/pages/api/core/workflow/builder/` | +| Handler | 权限、聊天轮次、SSE、持久化、版本归档 | `pro/admin/src/service/core/ai/workflowBuilder/handler.ts` | +| Runner | AgentLoop 生命周期、预览门禁、最终收尾 | `pro/admin/src/service/core/ai/workflowBuilder/runner.ts` | +| Preview Tool | 预览协议和服务端 Mermaid 校验 | `pro/admin/src/service/core/ai/workflowBuilder/previewTool.ts` | +| CLI Gateway | query/stage/commit、分片和修复门禁 | `pro/admin/src/service/core/ai/workflowBuilder/cliGateway.ts` | +| Sandbox | 预热、运行时准备、事务目录和 CLI 执行 | `pro/admin/src/service/core/ai/workflowBuilder/sandbox.ts` | +| Plan/Apply | 服务端重算、可信校验和候选生成 | `pro/admin/src/service/core/ai/workflowBuilder/plan.ts`、`apply.ts` | +| Version | S3 归档、加载、过期和 applied commit | `pro/admin/src/service/core/ai/workflowBuilder/version/service.ts` | +| Runtime Skill | Agent 操作知识和节点参考 | `pro/admin/src/service/core/ai/skill/builtin/workflow-builder/` | + +Skill 文档按主题分片是运行时上下文优化,不应并入本设计文档。 + +## 12. 权限与安全边界 + +系统同时执行以下安全检查: + +- 应用写权限。 +- 商业版和功能开关。 +- Agent Sandbox 开关。 +- Builder 会话的 team/member 归属。 +- 当前成员可使用的系统工具模板。 +- 版本 ChatItem 与当前成员归属。 +- S3 文件过期时间。 +- 文档 Schema、`appId` 和 checksum。 +- Sandbox plan 的服务端 Workflow Core 重算。 +- 研究工具对私有事务目录的访问阻断。 +- CLI 参数使用结构化 Schema,不允许 Agent 任意执行事务 shell。 + +画布权限和会话权限是两件事:即使两个成员都能编辑同一个应用,也不能互相读取或应用对方 Builder 会话里的私有生成版本。 + +## 13. 构建与部署 + +### 13.1 本地开发 + +仓库根命令: + +```bash +pnpm dev:pro +``` + +会先执行: + +```bash +pnpm run build:workflow-packages +``` + +该命令按顺序构建: + +```bash +pnpm --filter @fastgpt/workflow-core build +pnpm --filter @fastgpt/workflow-cli build +``` + +根 `postinstall` 也会构建这两个包,因此首次安装依赖后即可启动包含 Builder 的开发环境。 + +### 13.2 Docker 镜像 + +- `projects/app/Dockerfile` 构建 `@fastgpt/workflow-core`,用于主服务前端和共享工作流适配。 +- `pro/admin/Dockerfile` 构建 `@fastgpt/workflow-core` 与 `@fastgpt/workflow-cli`。 +- 商业版 runner 镜像会复制 `packages/workflow-cli/package.json` 和 `packages/workflow-cli/dist`,供运行时打包 CLI 到 Sandbox。 +- 商业版 runner 镜像会复制内置 Skill 目录,供 Sandbox 运行时部署 Workflow Builder Skill。 + +因此,使用当前两个仓库构建出的匹配版本主服务镜像与商业版镜像部署后,Workflow Builder 所需的 Core、CLI、后端 API 和运行时 Skill 才是完整闭环。 + +### 13.3 运行依赖 + +部署环境还必须正确提供: + +- 商业版 API 路由代理。 +- 可用的 Agent Sandbox provider。 +- Sandbox 镜像/运行时依赖。 +- 聊天文件 S3 存储。 +- 可用 LLM 模型和团队额度。 +- `isPlus`、`show_agent_sandbox` 和 `show_workflow_builder` 对应配置。 + +## 14. 错误处理和终止边界 + +| 阶段 | 处理方式 | +| --- | --- | +| 请求 Schema、权限或开关失败 | 在进入 AgentLoop 前返回用户错误 | +| Sandbox 初始化失败 | 发送结构化 Sandbox/流错误,不产生版本 | +| Mermaid 解析失败 | 返回 Agent 自动修复,最多 3 次 | +| Stage/Commit 诊断失败 | Agent 根据 Core 诊断自修复,同一目标最多 10 次 | +| S3 归档失败 | 本轮失败,不发布不可用版本卡片 | +| 版本加载过期或 checksum 不一致 | 拒绝应用并展示明确错误 | +| 前端应用失败 | 在 applied commit 前恢复旧画布 | +| 自动布局失败 | 保留已应用业务配置,只记录警告 | +| 用户取消 | 产生结构化取消事实,不生成候选版本 | +| 浏览器断开 | Runner 检查连接状态并由聊天轮次记录错误/恢复状态 | + +Runner 只有在以下事实之一成立时才允许结束一项工作流任务: + +- 需要用户补充会改变结构的业务信息。 +- 已经提交可确认的方案预览。 +- 用户取消。 +- CLI 已经产生 `candidate_ready`。 +- 到达结构化失败边界。 + +“任务复杂”“先完成一部分”“建议用户手动连线”都不是合法完成条件。 + +## 15. 测试体系 + +### 15.1 Workflow Core + +`packages/workflow-core/test/` 覆盖: + +- 文档 Schema、checksum 和 Store roundtrip。 +- 模板实例化和默认值。 +- 节点、边、引用、动态 IO、工具和容器命令。 +- ChangeSet apply/merge/plan。 +- 系统配置和全局变量。 +- 校验诊断。 +- 线性、分支、工具调用、动态 IO、catch 和嵌套循环 fixtures。 + +### 15.2 Workflow CLI + +`packages/workflow-cli/test/` 覆盖: + +- 参数解析和命令注册。 +- 文档文件 IO。 +- ChangeSet。 +- 模板 Provider 契约。 +- JSON 输出。 +- 端到端命令。 +- CLI 二进制和发布包 smoke test。 + +### 15.3 FastGPT 前端与共享契约 + +主要覆盖: + +- WorkflowDocument 与画布互转。 +- Builder 版本状态和应用数据合并。 +- 预览 interactive 的确认、修改和取消。 +- ChatBox 恢复、实例隔离和 SSE 渲染。 +- OpenAPI Schema 和 i18n 资源加载。 + +### 15.4 商业版 Builder + +`pro/admin/test/core/ai/workflowBuilder/` 覆盖: + +- Handler 权限、聊天轮次和持久化。 +- Runner 生命周期和终止事实。 +- Mermaid 校验与预览协议。 +- CLI Gateway query/stage/commit 门禁。 +- Sandbox 准备、预热和运行时隔离。 +- Plan 服务端重算和候选 apply。 +- S3 版本归档、加载、过期和幂等 commit。 +- 回归案例目录、语义断言和可选真实模型评测。 + +### 15.5 常用验证命令 + +```bash +pnpm --filter @fastgpt/workflow-core typecheck +pnpm --filter @fastgpt/workflow-core test +pnpm --filter @fastgpt/workflow-cli test:ci +pnpm --filter @fastgpt/app typecheck +pnpm --filter @fastgpt/admin typecheck +pnpm --filter @fastgpt/admin test +pnpm --filter @fastgpt/admin typecheck:workflow-builder:eval +pnpm --filter @fastgpt/admin test:workflow-builder:eval +``` + +真实模型评测不是普通 PR 必跑项,需要显式的模型、服务地址、鉴权和 Sandbox 环境。 + +## 16. 当前完成状态和维护约束 + +### 16.1 当前已经闭环 + +- 从入口、独立会话到 Agent 执行。 +- 从当前画布快照到 CLI 私有草稿。 +- 从需求闭合、预演到 Mermaid 确认。 +- 从分片搭建、校验修复到候选提交。 +- 从候选 S3 归档到版本卡片。 +- 从用户二次确认到完整画布覆盖和 applied 记录。 +- 从开发构建到主服务/商业版 Docker 镜像依赖携带。 +- 从单元测试到 Builder 回归和真实模型评测入口。 + +### 16.2 修改时必须保持的关键不变量 + +1. 当前画布事实只能通过 Workflow CLI Gateway 进入 Agent。 +2. 预览未确认前不能 Stage 或 Commit。 +3. Mermaid 必须在后端真实解析成功后才能发送给前端。 +4. 用户确认预览后,不再因技术实现问题打断用户。 +5. 只有 draft validate 成功才能 CLI Commit。 +6. Sandbox plan 必须经过服务端 Workflow Core 重算。 +7. S3 归档成功后才能发布当前格式的版本卡片。 +8. CLI Commit 不得直接修改画布。 +9. 覆盖画布必须由用户点击“应用到画布”触发。 +10. 会话归属不能被应用写权限替代。 +11. Skill 运行时文档保持按需分片,设计文档不得成为 Agent 操作契约的唯一来源。 +12. 修改用户可见文案时同步维护简中、繁中和英文 i18n。 diff --git a/.agents/issue/workflow-builder-apply-revert-analysis.md b/.agents/issue/workflow-builder-apply-revert-analysis.md new file mode 100644 index 000000000000..d9fe23d83ec1 --- /dev/null +++ b/.agents/issue/workflow-builder-apply-revert-analysis.md @@ -0,0 +1,138 @@ +# Workflow Builder「再次应用」后画布被还原 —— 问题审计报告 + +> 状态:已按方案 A 修复(2026-08-14),本报告保留审计过程与证据 +> 日期:2026-08-14 +> 涉及分支:`yyh/workflow-core-cli` + +## 1. 问题描述 + +在 Workflow Builder(AI 工作流自动生成)中,工作流生成完成后点击版本卡片「再次应用」: + +1. 点击瞬间画布短暂显示新版本内容(本次修改的节点已更新); +2. 约 1 秒后(「已应用到画布」toast 出现时)画布还原到点击前的状态; +3. 每次点击都会重复出现。 + +## 2. 复现方式 + +- 前置:AI 生成的工作流中包含一个**带自定义输出(collected results)的 Loop 节点**(本次复现为 `examLoop`,类型 `loopRun`,array 模式); +- 操作:点击版本卡片「再次应用」; +- 现象:画布先显示新内容,约 1 秒后还原。 + +## 3. 根因结论 + +**根因:AI 生成端输出的 loopRun 节点文档中,自定义输出 `collectedResults` 只有 outputs 声明,没有配套的 `canEdit: true` 输入声明;画布挂载 Loop 节点时,`NodeLoopRun` 组件的 useEffect 将 `collectedResults` 判定为「未声明的动态输出」,自动执行 `delOutput` 删除该输出并同步 `onDelEdge` 删除其连线,把刚应用的内容改回去。** + +由于 S3 归档的始终是 AI 原始文档(含 `collectedResults` 但缺声明),每次重新应用都会重复触发同样的修正,与「每次点击都还原」完全吻合。 + +## 4. 证据链 + +### 4.1 一次完整 apply 的日志时序(05:14:28,带埋点复现) + +| 时间 | 事件 | 说明 | +|---|---|---| +| 28.492 | `apply start version=AI 生成版本 3` | 点击「再次应用」 | +| 28.656 | `apply loaded document` | 从 S3 加载 AI 归档文档 | +| 28.660 | `apply document loop` | **AI 文档中 examLoop 结构**(见下) | +| 28.697 | `apply before initData nodes=31` | 目标文档 31 个节点 | +| 28.704 | `apply after initData` | 画布已显示新版本(此时用户能看到改动) | +| 29.327 | `examLoop:delOutput:collectedResults` at **NodeLoopRun.useEffect** | **修正触发点** | +| 29.503 | `onDelEdge nodeId=examLoop sh=examLoop-source-collectedResults` | 删除 `examLoop → aggResults` 连线 | +| 30.802 | `apply after autoLayout=applied` | 布局只改位置 | +| 30.868 | `apply before push nodes=31` | 已是修正后的残缺画布 | +| 30.870 | `push SAVED title=AI 生成版本 3 nodes=31` | **残缺画布被存为快照** | +| 30.871 | `apply toast success` | toast 出现,用户感知「还原」 | + +### 4.2 AI 文档中 loop 节点原始结构(埋点输出原文) + +```json +apply document loop: [ + { + "id": "examLoop", + "type": "loopRun", + "mode": "array", + "inputs": ["loopRunMode", "loopRunInputArray", "loopCustomOutputs", "childrenNodeIdList", "nodeWidth", "nodeHeight", "loopNodeInputHeight"], + "outputs": ["system_error_text", "collectedResults"] + }, + { + "id": "examLoop__start", + "type": "loopRunStart", + "mode": "array", + "inputs": ["loopRunMode", "loopStartInput", "loopStartIndex"], + "outputs": ["currentIndex", "currentItem"] + } +] +apply document loopEdges: ["examLoop:undefined->aggResults:undefined", "examLoop__start:undefined->readCurrent:undefined"] +``` + +关键点: +- `examLoop.outputs` 含自定义输出 `collectedResults`,但 `inputs` 中**没有** `collectedResults` 对应的 `canEdit: true` 声明(只有 7 个固定 key); +- 连线 `examLoop → aggResults` 在文档中存在,应用后被删。 + +### 4.3 代码逻辑 + +**动态输出的声明机制**:loop 节点的自定义输出由 `loopCustomOutputs`(`addInputParam` 渲染类型)驱动的 `canEdit: true` 输入声明,动态输出与 canEdit 输入**成对出现**(见 `DynamicInputs/index.tsx`:`inputs.filter((item) => item.canEdit)` 才是用户可见的动态字段)。 + +**转换链路**:`applyVersion` → `parseCompatibleWorkflowDocument` → `compileStoreWorkflow` → `parseWorkflowImportConfig` → `initData` → `storeNode2FlowNode`(`projects/app/src/web/core/workflow/utils.ts:163`): +- 模板 `loopRun.ts` 的 outputs 只有 `errorText`; +- store 中多出的 `collectedResults` 走 `.concat()` 分支补为 dynamic 输出(`type: dynamic`),inputs 无对应 canEdit 声明。 + +**修正触发点**:`Flow/nodes/Loop/NodeLoopRun.tsx`(86-233 行)挂载时两个 useEffect: +- Effect 1(mode sync,86-190 行):`mode=array` 时 `delOutput currentIteration`、`addOutput currentIndex/currentItem` —— 对应 `examLoop__start:delOutput:currentIteration`,属**良性修正**(文档缺 currentIteration,模板补全后按 array 模式删除); +- Effect 2(192-233 行,**根因所在**): + ```ts + const declared = inputs.filter((i) => i.canEdit === true); + const currentDynamic = outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic); + const declaredKeys = new Set(declared.map((i) => i.key)); + currentDynamic.forEach((o) => { + if (!declaredKeys.has(o.key)) { + onChangeNode({ nodeId, type: 'delOutput', key: o.key }); + } + }); + ``` + `collectedResults` 不在 `declaredKeys` 中 → `delOutput`,`onChangeNode` 内部对删除的输出同步执行 `onDelEdge`(日志 29.503 证实)。 + +**生成端规范缺失**:`pro/admin/src/service/core/ai/skill/builtin/workflow-builder/SKILL.md` 只写了 "Custom collected outputs are manual dynamic outputs. Add their exact keys and types before downstream references.",**没有要求**在 inputs 中声明对应的 canEdit 输入,导致生成端输出不合规文档。 + +### 4.4 为什么「先显示后还原」 + +`initData` 同步覆盖画布(用户看到新版本)→ React 挂载 Loop 节点 → `NodeLoopRun` useEffect 异步修正(delOutput/onDelEdge,约 1 秒后)→ toast 出现。修正发生在 applyVersion 的 await 链返回之后,视觉上表现为「toast 出现时还原」。 + +## 5. 已排除的假设 + +| 假设 | 排除依据 | +|---|---| +| 组件重挂载触发 `initData(isInit)` RESTORE past[0] | 画布先显示新版本(日志 28.704 后无 restore 行为),且 Workflow/index.tsx mount 埋点无重复挂载 | +| undo/redo 自动触发 | 日志无 `undo called` / `redo called` | +| 版本切换(onSwitchTmpVersion/onSwitchCloudVersion) | 日志无调用 | +| 自动布局覆盖 | `autoLayout=applied`,只改 position 不改结构;且还原含节点输出与连线,非位置 | +| push 快照回写画布 | push 只记录快照,不回写画布(workflowSnapshotContext) | +| workflowInitContext 监听 appDetail 触发 replaceWorkflowData | replaceWorkflowData 调用栈日志均来自正常编辑流程,无 apply 后的额外覆盖 | + +## 6. 衍生问题 + +1. **快照与 AI 版本不一致**:push SAVED 保存的是被修正后的残缺画布(31 节点、少一条 `examLoop→aggResults` 连线),与 AI 版本卡片内容不一致。用户在「我的编辑」里看到的版本是残缺的。 +2. **历史数据无法自愈**:S3 中已归档的 AI 文档均缺 canEdit 声明,仅修复生成端只能保证新生成的文档合规,旧文档重新应用仍会触发修正。 + +## 7. 修复方向建议(未实施,供讨论) + +### 方案 A(推荐,生成端修复 + 前端兜底) +1. **生成端**:`pro/admin/.../workflow-builder/SKILL.md` 补充规范——loop 自定义输出必须在 `inputs` 中声明同名 `canEdit: true` 输入(valueType 与输出一致),与 `loopCustomOutputs` 的 addInputParam 机制对齐;同时要求已生成的节点 `loopCustomOutputs` 值与 inputs 声明保持同步。 +2. **前端兜底(兼容历史数据)**:在 `storeNode2FlowNode` 或 `parseWorkflowImportConfig` 阶段,对 loop 节点「有 dynamic 输出但无对应 canEdit 输入」的情况自动补齐 canEdit 输入声明(key/valueType 从输出推导),避免 NodeLoopRun Effect 2 误判。注意与 `loopCustomOutputs` 的 value 存储结构对齐(需确认 addInputParam 的存储格式)。 + +### 方案 B(仅前端,覆盖历史数据) +同上 2,不修改生成端。可最快止血(新老版本均不再被修正),但生成文档本身仍不合规,Loop 节点的自定义输出在子工作流引用语义上依赖声明存在,属数据层兜底而非根治。 + +### 方案 C(不推荐,防御性) +`NodeLoopRun` Effect 2 删除动态输出前检查该输出是否存在连线,有连线则跳过删除。会掩盖生成端问题,且删除/保留逻辑不一致(动态输出依赖声明才能正确渲染),仅作临时止血考虑。 + +## 8. 结论 + +- 根因明确:**AI 生成文档 loop 节点缺 canEdit 输入声明 → NodeLoopRun 挂载时 useEffect 误删动态输出及连线 → 每次 apply 重复修正**; +- 建议按「方案 A:生成端规范 + 前端兼容补齐」修复,其中前端补齐是覆盖已归档历史数据所必需的。 + +## 9. 修复记录(2026-08-14 已实施,方案 A) + +1. **前端兜底(覆盖历史数据)**:`projects/app/src/web/core/workflow/utils.ts` `storeNode2FlowNode` 中,对 `loopRun` 节点按动态输出自动补齐缺失的 canEdit 输入声明(key/valueType 从输出推导,`renderTypeList: [reference]`、`canEdit: true`、`required: false`)。所有文档加载路径(initData、版本应用、快照切换、表单预览)均经过该转换,已归档的旧 AI 文档重新应用也会被修正。 +2. **生成端规范(根治新文档)**:`pro/admin/src/service/core/ai/skill/builtin/workflow-builder/references/container-nodes.md` Loop run 章节,明确自定义收集输出必须 `input.add`(canEdit 输入)与 `output.add`(动态输出)成对声明、key/valueType 一致。 +3. **测试**:`projects/app/test/web/core/app/workflow/utils.test.ts` 新增 3 个用例(自动补齐 / 已有声明不重复 / 非 loopRun 不受影响),全部通过;`utils.test.ts` 108 例、`store2flow.version/deprecated`、`workflowCorePr2/Pr3Adapter`、`localDraft` 共 133 例无回归,`tsc --noEmit` 通过。 +4. 未改动项:`NodeLoopRun` 挂载 Effect 的删除逻辑本身保持原样(用户主动删除声明仍会同步删除输出,属正常编辑行为)。 diff --git a/.agents/skills/core/workflow-builder-evaluation/SKILL.md b/.agents/skills/core/workflow-builder-evaluation/SKILL.md new file mode 100644 index 000000000000..ac07c21f2080 --- /dev/null +++ b/.agents/skills/core/workflow-builder-evaluation/SKILL.md @@ -0,0 +1,36 @@ +--- +name: workflow-builder-evaluation +description: 运行和维护 FastGPT Workflow Builder 自动生成测评系统。用户要求制作或检查 WBE 案例、运行部分或全部案例、计算五项指标、定位案例失败或卡住阶段、查看测评报告、归档某个版本 Benchmark 结果时使用。 +--- + +# Workflow Builder 测评 + +## 唯一入口 + +先完整读取 `pro/admin/test/core/ai/workflowBuilder/README.md`,再根据任务读取其中直接链接的对应文档。指标和案例规则以该目录为唯一事实源,不在 Skill 内复述或自行扩展评分口径。 + +## 任务路由 + +- 制作或修改案例:读取 `docs/case-authoring.md` 和 `docs/evaluation-standard.md`,修改后执行案例校验。 +- 执行测评:读取 `docs/runbook.md`,依次执行案例校验、Preflight 和 Runner。 +- 解读结果:读取 `docs/reporting.md`,以运行目录内 `report.json` 为事实源,使用 `summary.md` 和逐案例报告定位问题。 +- 归档版本结果:仅在用户明确要求归档时读取 `docs/benchmark-archive.md` 并执行归档命令。 + +## 标准执行顺序 + +1. 检查 `config/eval.local.yaml` 是否存在,但不得输出其中的 Cookie、Token 或其他凭证。 +2. 运行 `pnpm --filter @fastgpt/admin workflow-builder:eval:lint`。 +3. 运行 `pnpm --filter @fastgpt/admin workflow-builder:eval:preflight`。失败时停止正式计分并报告环境问题。 +4. 运行 `pnpm --filter @fastgpt/admin workflow-builder:eval:run`。用户指定案例时先确认本地配置的 `run.caseIds` 与请求一致。 +5. 读取新生成的 `report.json`,汇报五项指标的分子、分母、待完成数和无法评价数。 +6. 列出每个问题案例的最后完成阶段、失败阶段或卡住阶段、失败代码、原因和证据位置。 +7. 不自动归档。只有用户明确要求后才运行 `workflow-builder:eval:archive`。 + +## 约束 + +- 不允许自行计算、覆盖或手工修正 `report.json` 中的自动评分。 +- 不把环境错误、凭证问题或输入映射问题算成产品失败。 +- 不因节点数量、节点命名或汇聚结构不同,判定行为等价的工作流失败,除非用户需求明确规定这些结构。 +- 只运行部分案例时,必须说明这是子集结果,不能表述为完整数据集结论。 +- 不删除自动创建的 Workflow 应用或运行证据,除非用户明确要求清理并确认目标。 +- 归档前检查运行是否完整;使用 `--allow-incomplete` 必须得到用户明确指示。 diff --git a/.agents/skills/core/workflow-builder-evaluation/agents/openai.yaml b/.agents/skills/core/workflow-builder-evaluation/agents/openai.yaml new file mode 100644 index 000000000000..fe6c0b00a1c1 --- /dev/null +++ b/.agents/skills/core/workflow-builder-evaluation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Workflow Builder 测评' + short_description: '运行工作流生成测评、定位失败阶段并手动归档版本结果' + default_prompt: '使用 $workflow-builder-evaluation 运行指定 Workflow Builder 案例并汇报五项指标和问题阶段。' diff --git a/package.json b/package.json index ba5ca06c8b01..793806c31b29 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,11 @@ "version": "4.0", "private": true, "scripts": { - "dev:pro": "turbo run dev:pro --filter=@fastgpt/app", + "dev:pro": "pnpm run build:workflow-packages && turbo run dev:pro --filter=@fastgpt/app", "prepare": "husky install", "gen:theme-typings": "chakra-cli tokens packages/web/styles/theme.ts --out node_modules/.pnpm/node_modules/@chakra-ui/styled-system/dist/theming.types.d.ts", "gen:deploy": "node ./deploy/init.mjs", - "postinstall": "pnpm gen:theme-typings && pnpm run build:sdks", + "postinstall": "pnpm gen:theme-typings && pnpm run build:sdks && pnpm run build:workflow-packages", "initIcon": "node ./scripts/icon/init.js && prettier --config \"./.prettierrc.js\" --write \"packages/web/components/common/Icon/constants.ts\"", "previewIcon": "node ./scripts/icon/index.js", "lint": "turbo run lint", @@ -29,7 +29,8 @@ "generate:i18n-loaders": "node scripts/generate-i18n-resource-loaders.mjs", "check:i18n-loaders": "node scripts/check-i18n-resource-loaders.mjs", "test:vector": "turbo run test:integration --filter=@fastgpt/service", - "build:sdks": "pnpm -r --filter @fastgpt-sdk/storage --filter @fastgpt-sdk/otel --filter @fastgpt-sdk/sandbox-adapter build" + "build:sdks": "pnpm -r --filter @fastgpt-sdk/storage --filter @fastgpt-sdk/otel --filter @fastgpt-sdk/sandbox-adapter build", + "build:workflow-packages": "pnpm --filter @fastgpt/workflow-core build && pnpm --filter @fastgpt/workflow-cli build" }, "devDependencies": { "@chakra-ui/cli": "^2.4.1", diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 63420a0deb7b..580de9ff811c 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -92,6 +92,7 @@ export type FastGPTFeConfigsType = { show_publish_offiaccount?: boolean; show_publish_wechat?: boolean; show_agent_sandbox?: boolean; + show_workflow_builder?: boolean; pluginRemoteDebug?: boolean; enable_team_plugin_upload?: boolean; diff --git a/packages/global/core/chat/constants.ts b/packages/global/core/chat/constants.ts index f706bc092e4d..f11e05334681 100644 --- a/packages/global/core/chat/constants.ts +++ b/packages/global/core/chat/constants.ts @@ -41,10 +41,13 @@ export enum ChatSourceEnum { * 会话所属资源类型。 * * `ChatSourceEnum` 表示对话入口来源,例如 test/api/online。 - * `ChatSourceTypeEnum` 表示会话归属资源类型,用于在同一套 chat 表中隔离 App 和 Skill Edit。 + * `ChatSourceTypeEnum` 表示会话归属资源类型;辅助生成会话使用独立来源, + * 以隔离聊天记录、上传文件和 Sandbox 资源。 */ export enum ChatSourceTypeEnum { app = 'app', + /** Workflow Builder 的独立聊天、上传文件和 Sandbox 来源。 */ + workflowBuilder = 'workflowBuilder', skillEdit = 'skillEdit', chatAgentHelper = 'chatAgentHelper' } diff --git a/packages/global/core/chat/type.ts b/packages/global/core/chat/type.ts index 9ec3c8126cb3..44615db992ed 100644 --- a/packages/global/core/chat/type.ts +++ b/packages/global/core/chat/type.ts @@ -21,6 +21,7 @@ import { AgentPlanStatusSchema } from '../ai/agent/type'; import { ObjectIdSchema } from '../../common/type/mongo'; +import { WorkflowBuilderVersionSchema } from '../workflow/builder/type'; export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({ nodeId: z.string(), @@ -231,6 +232,7 @@ export const AIChatItemValueSchema = z.object({ agentPlanUpdate: AgentLoopPlanUpdateSchema.nullish(), agentAsk: AgentLoopAskSchema.nullish(), contextCheckpoint: ContextCheckpointValueSchema.nullish(), + workflowBuilderVersion: WorkflowBuilderVersionSchema.optional(), tool: ToolModuleResponseItemSchema.nullish().meta({ deprecated: true }), hideReason: z.boolean().optional(), hideInUI: z.boolean().optional() diff --git a/packages/global/core/workflow/builder/constants.ts b/packages/global/core/workflow/builder/constants.ts new file mode 100644 index 000000000000..88832714a7ea --- /dev/null +++ b/packages/global/core/workflow/builder/constants.ts @@ -0,0 +1,29 @@ +import { defaultAppSelectFileConfig } from '../../app/constants'; +import type { AppChatConfigType } from '../../app/type'; + +export const WORKFLOW_BUILDER_MAX_FILE_AMOUNT = 10; + +/** + * Workflow Builder 的独立对话配置。 + * + * Builder 可以通过 WorkflowDocument 读取和修改原工作流的 chatConfig, + * 但其自身对话不得继承这些运行前置配置。 + */ +export const WORKFLOW_BUILDER_CHAT_CONFIG = { + whisperConfig: { + open: true, + autoSend: false, + autoTTSResponse: false + }, + fileSelectConfig: { + ...defaultAppSelectFileConfig, + maxFiles: WORKFLOW_BUILDER_MAX_FILE_AMOUNT, + canSelectFile: true, + canSelectImg: true, + customPdfParse: false, + canSelectVideo: true, + canSelectAudio: true, + canSelectCustomFileExtension: true, + customFileExtensionList: [] + } +} satisfies AppChatConfigType; diff --git a/packages/global/core/workflow/builder/type.ts b/packages/global/core/workflow/builder/type.ts new file mode 100644 index 000000000000..f74bbdde62aa --- /dev/null +++ b/packages/global/core/workflow/builder/type.ts @@ -0,0 +1,48 @@ +import { IntSchema } from '../../../common/zod'; +import { z } from 'zod'; + +export const WorkflowChecksumSchema = z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .meta({ + description: 'WorkflowDocument 的 SHA-256 checksum', + example: `sha256:${'0'.repeat(64)}` + }); + +/** AI 完成 Workflow Builder 生成后写入 ChatItem 的版本文件信息。 */ +export const WorkflowBuilderVersionSchema = z + .object({ + versionNo: IntSchema.positive().meta({ + description: '当前工作流辅助生成会话中的 AI 生成版本序号', + example: 1 + }), + name: z.string().min(1).meta({ + description: '版本展示名称', + example: 'AI 生成版本 1' + }), + filename: z.string().min(1).meta({ + description: '版本 JSON 文件名', + example: 'AI 生成版本 1.json' + }), + checksum: WorkflowChecksumSchema, + generatedAt: z.string().datetime().meta({ + description: 'AI 完成生成的时间', + example: '2026-08-12T10:00:00.000Z' + }), + s3Key: z.string().min(1).optional().meta({ + description: '生成校验成功后归档到聊天文件 S3 的对象 key' + }), + expiresAt: z.string().datetime().optional().meta({ + description: 'S3 版本过期时间', + example: '2026-08-13T10:00:00.000Z' + }), + appliedAt: z.string().datetime().optional().meta({ + description: '版本首次成功应用到画布的时间', + example: '2026-08-12T10:00:00.000Z' + }) + }) + .strict(); + +export type WorkflowBuilderVersion = z.infer; + +export type WorkflowBuilderVersionDisplayState = 'ready' | 'available' | 'expired'; diff --git a/packages/global/core/workflow/builder/utils.ts b/packages/global/core/workflow/builder/utils.ts new file mode 100644 index 000000000000..a4a9eea08217 --- /dev/null +++ b/packages/global/core/workflow/builder/utils.ts @@ -0,0 +1,15 @@ +import type { WorkflowBuilderVersion, WorkflowBuilderVersionDisplayState } from './type'; + +/** + * 根据归档、应用和过期事实推导版本卡片状态。 + */ +export const getWorkflowBuilderVersionDisplayState = ({ + version, + now = Date.now() +}: { + version: WorkflowBuilderVersion; + now?: number; +}): WorkflowBuilderVersionDisplayState => { + if (version.expiresAt && new Date(version.expiresAt).getTime() <= now) return 'expired'; + return version.appliedAt ? 'available' : 'ready'; +}; 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/runtime/constants.ts b/packages/global/core/workflow/runtime/constants.ts index bb5164a1e2a4..ae790768ee29 100644 --- a/packages/global/core/workflow/runtime/constants.ts +++ b/packages/global/core/workflow/runtime/constants.ts @@ -24,7 +24,11 @@ export enum SseResponseEventEnum { // Sandbox lifecycle sandboxStatus = 'sandboxStatus', // sandbox lifecycle phase notification - skillCall = 'skillCall' // skill invocation announce (when SKILL.md is loaded) + skillCall = 'skillCall', // skill invocation announce (when SKILL.md is loaded) + + // Workflow Builder + workflowBuilderApplied = 'workflowBuilderApplied', + workflowBuilderVersion = 'workflowBuilderVersion' } export const StreamResumePhaseEvent = 'resumePhase'; diff --git a/packages/global/core/workflow/runtime/sse.ts b/packages/global/core/workflow/runtime/sse.ts index 97946855633c..5d2dd3f745fe 100644 --- a/packages/global/core/workflow/runtime/sse.ts +++ b/packages/global/core/workflow/runtime/sse.ts @@ -6,6 +6,8 @@ import type { } from '../../chat/type'; import type { AgentPlanStatusType, AgentPlanType } from '../../ai/agent/type'; import type { WorkflowInteractiveResponseType } from '../template/system/interactive/type'; +import type { WorkflowBuilderApplied } from '../../../openapi/core/workflow/builder/api'; +import type { WorkflowBuilderVersion } from '../builder/type'; import { SseResponseEventEnum } from './constants'; import { textAdaptGptResponse } from './utils'; @@ -35,6 +37,8 @@ export type WorkflowSsePayloadMap = { [SseResponseEventEnum.planStatus]: { planStatus: AgentPlanStatusType }; [SseResponseEventEnum.sandboxStatus]: SandboxStatusItemType; [SseResponseEventEnum.skillCall]: { skill: SkillModuleResponseItemType }; + [SseResponseEventEnum.workflowBuilderApplied]: WorkflowBuilderApplied; + [SseResponseEventEnum.workflowBuilderVersion]: { version: WorkflowBuilderVersion }; }; export type WorkflowTypedSseEvent< @@ -148,6 +152,26 @@ export const workflowSseEvent = { }; }, + /** 输出经 CLI Apply 和 checksum 校验后的目标工作流文档。 */ + workflowBuilderApplied( + result: WorkflowBuilderApplied + ): WorkflowTypedSseEvent { + return { + event: SseResponseEventEnum.workflowBuilderApplied, + data: result + }; + }, + + /** 输出已生成、等待用户确认应用的 Workflow Builder 版本元数据。 */ + workflowBuilderVersion( + version: WorkflowBuilderVersion + ): WorkflowTypedSseEvent { + return { + event: SseResponseEventEnum.workflowBuilderVersion, + data: { version } + }; + }, + /** * 输出工作流节点运行状态,前端据此展示当前正在执行的节点名称。 */ diff --git a/packages/global/core/workflow/runtime/utils.ts b/packages/global/core/workflow/runtime/utils.ts index f32a26449d3c..86313e551c08 100644 --- a/packages/global/core/workflow/runtime/utils.ts +++ b/packages/global/core/workflow/runtime/utils.ts @@ -186,8 +186,9 @@ export const getLastInteractiveValue = ( { question: lastValue.interactive.params.content, options: lastValue.interactive.params.options.map((option) => ({ - summary: option, - value: option + // AgentPlanAskOption 同时支持纯字符串与带 label/inputMode 的对象形式 + summary: typeof option === 'string' ? option : option.label, + value: typeof option === 'string' ? option : option.value })), answer: '' } @@ -225,6 +226,13 @@ export const getLastInteractiveValue = ( if (lastValue.interactive.type === 'paymentPause' && !lastValue.interactive.params.continue) { return lastValue.interactive; } + + if ( + lastValue.interactive.type === 'workflowBuilderPreview' && + !lastValue.interactive.params.answerValue + ) { + return lastValue.interactive; + } } return; diff --git a/packages/global/core/workflow/template/system/interactive/type.ts b/packages/global/core/workflow/template/system/interactive/type.ts index 09aac1e15bfc..97b3c1571e39 100644 --- a/packages/global/core/workflow/template/system/interactive/type.ts +++ b/packages/global/core/workflow/template/system/interactive/type.ts @@ -93,9 +93,28 @@ export type LoopRunInteractive = InteractiveNodeType & { }; }; -export const AgentPlanAskOptionSchema = z.string().min(1); +export const AgentPlanAskOptionSchema = z.union([ + z.string().trim().min(1), + z + .object({ + value: z.string().trim().min(1), + label: z.string().trim().min(1), + inputMode: z.enum(['none', 'text']).optional(), + inputPlaceholder: z.string().trim().min(1).optional() + }) + .strict() +]); export type AgentPlanAskOption = z.infer; +export const AgentPlanAskResponseSchema = z + .object({ + askId: z.string().min(1), + optionValue: z.string().min(1), + text: z.string().trim().min(1).optional() + }) + .strict(); +export type AgentPlanAskResponse = z.infer; + /** * Legacy `ask_user` schema. * @@ -124,6 +143,40 @@ export const AgentPlanAskQueryInteractiveSchema = z */ export type AgentPlanAskQueryInteractive = z.infer; +export const WorkflowBuilderPreviewActionSchema = z.object({ + value: z.enum(['confirm', 'revise', 'cancel']), + label: z.string().trim().min(1), + inputMode: z.enum(['none', 'text']), + inputPlaceholder: z.string().trim().min(1).optional() +}); +export type WorkflowBuilderPreviewAction = z.infer; + +export const WorkflowBuilderPreviewSectionSchema = z.object({ + title: z.string().trim().min(1), + content: z.string() +}); +export type WorkflowBuilderPreviewSection = z.infer; + +/** + * Workflow Builder 的唯一预览载荷。 + * Mermaid、说明和用户动作都挂在同一个交互节点上,避免 assistant 文本与预览字段各自维护一份方案。 + */ +export const WorkflowBuilderPreviewInteractiveSchema = z.object({ + type: z.literal('workflowBuilderPreview'), + previewId: z.string().min(1), + params: z.object({ + title: z.string().trim().min(1), + mermaid: z.string().trim().min(1), + sections: z.array(WorkflowBuilderPreviewSectionSchema), + actions: z.array(WorkflowBuilderPreviewActionSchema).length(3), + answerValue: z.enum(['confirm', 'revise', 'cancel']).optional(), + answerText: z.string().optional() + }) +}); +export type WorkflowBuilderPreviewInteractive = z.infer< + typeof WorkflowBuilderPreviewInteractiveSchema +>; + // User selector export const UserSelectOptionItemSchema = z.object({ key: z.string(), @@ -164,7 +217,8 @@ export type UserInputFormItemType = z.infer; export const UserInputInteractiveSchema = z.object({ type: z.literal('userInput'), params: z.object({ - description: z.string(), + // 表单节点未配置说明时,运行时 JSON 会省略 undefined;展示不依赖该字段。 + description: z.string().optional(), inputForm: z.array(UserInputFormItemSchema), submitted: z.boolean().optional() }) @@ -208,7 +262,8 @@ export const InteractiveNodeResponseTypeSchema = z.intersection( LoopRunInteractiveSchema, PaymentPauseInteractiveSchema, AgentPlanAskQueryInteractiveSchema, - AgentAskInteractiveSchema + AgentAskInteractiveSchema, + WorkflowBuilderPreviewInteractiveSchema ]), z.object({ askId: z.string().nullish() 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/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 25901932f693..4cb979e16031 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -89,6 +89,86 @@ export const nodeInputIsReference = (input: FlowNodeInputItemType) => { return false; }; +const workflowReferenceSourceTypeMap: Record = { + [WorkflowIOValueTypeEnum.string]: [WorkflowIOValueTypeEnum.string], + [WorkflowIOValueTypeEnum.number]: [WorkflowIOValueTypeEnum.number], + [WorkflowIOValueTypeEnum.boolean]: [WorkflowIOValueTypeEnum.boolean], + [WorkflowIOValueTypeEnum.object]: [WorkflowIOValueTypeEnum.object], + [WorkflowIOValueTypeEnum.arrayString]: [ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayNumber]: [ + WorkflowIOValueTypeEnum.number, + WorkflowIOValueTypeEnum.arrayNumber, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayBoolean]: [ + WorkflowIOValueTypeEnum.boolean, + WorkflowIOValueTypeEnum.arrayBoolean, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayObject]: [ + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayObject, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.chatHistory, + WorkflowIOValueTypeEnum.datasetQuote, + WorkflowIOValueTypeEnum.dynamic, + WorkflowIOValueTypeEnum.selectDataset, + WorkflowIOValueTypeEnum.selectApp + ], + [WorkflowIOValueTypeEnum.chatHistory]: [ + WorkflowIOValueTypeEnum.chatHistory, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.datasetQuote]: [ + WorkflowIOValueTypeEnum.datasetQuote, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.dynamic]: [ + WorkflowIOValueTypeEnum.dynamic, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.selectDataset]: [ + WorkflowIOValueTypeEnum.selectDataset, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.selectApp]: [ + WorkflowIOValueTypeEnum.selectApp, + WorkflowIOValueTypeEnum.arrayAny + ], + [WorkflowIOValueTypeEnum.arrayAny]: Object.values(WorkflowIOValueTypeEnum), + [WorkflowIOValueTypeEnum.any]: Object.values(WorkflowIOValueTypeEnum) +}; + +/** + * 返回 Web 引用选择器和 Workflow Core 共同接受的上游输出类型。 + * 数组输入允许引用同类型数组、通用数组或一个元素类型,运行时会按目标类型完成格式化。 + */ +export const getWorkflowReferenceSourceValueTypes = ( + expected?: string +): WorkflowIOValueTypeEnum[] => { + if (expected === undefined) return Object.values(WorkflowIOValueTypeEnum); + const acceptedTypes = workflowReferenceSourceTypeMap[expected as WorkflowIOValueTypeEnum] ?? []; + return [...new Set([...acceptedTypes, WorkflowIOValueTypeEnum.any])]; +}; + +/** 判断一个上游输出类型能否作为目标输入的完整引用值。 */ +export const areWorkflowValueTypesCompatible = ({ + expected, + actual +}: { + expected?: string; + actual?: string; +}) => { + if (expected === undefined || actual === undefined || actual === WorkflowIOValueTypeEnum.any) { + return true; + } + return getWorkflowReferenceSourceValueTypes(expected).includes(actual as WorkflowIOValueTypeEnum); +}; + /** 判断 App 工作流是否有 Agent 或 ToolCall 节点开启 Sandbox。 */ export const isAppSandboxEnabledInNodes = (nodes: StoreNodeItemType[]) => nodes.some( diff --git a/packages/global/openapi/core/chat/api.ts b/packages/global/openapi/core/chat/api.ts index 0d7288f487b9..fcfce43bd065 100644 --- a/packages/global/openapi/core/chat/api.ts +++ b/packages/global/openapi/core/chat/api.ts @@ -20,11 +20,17 @@ const ChatTargetInputShape = { example: '68ad85a7463006c963799a06', description: 'Skill Edit 调试 ID。appId、skillId 和 outLinkAuthData 必须且只能传一个。' }), - sourceType: z.enum([ChatSourceTypeEnum.app, ChatSourceTypeEnum.chatAgentHelper]).optional().meta({ - example: ChatSourceTypeEnum.chatAgentHelper, - description: - 'appId 对应的会话资源类型。缺省为 App 会话;ChatAgentHelper 会话传 chatAgentHelper。' - }) + sourceType: z + .enum([ + ChatSourceTypeEnum.app, + ChatSourceTypeEnum.chatAgentHelper, + ChatSourceTypeEnum.workflowBuilder + ]) + .optional() + .meta({ + example: ChatSourceTypeEnum.chatAgentHelper, + description: 'appId 对应的会话资源类型。缺省为 App 会话;辅助生成会话传对应 sourceType。' + }) }; type ChatTargetInput = { @@ -60,6 +66,11 @@ export type ChatTargetInputType = appId: string; skillId?: never; sourceType: ChatSourceTypeEnum.chatAgentHelper; + } + | { + appId: string; + skillId?: never; + sourceType: ChatSourceTypeEnum.workflowBuilder; }; export type ChatTargetResponseType = ChatTargetInputType; @@ -87,6 +98,11 @@ const getNormalizedOutLinkAuthData = (data: ChatTargetInput): OutLinkChatAuthPro }; export const ChatTargetResponseSchema = z.union([ + z.object({ + appId: ObjectIdSchema.describe('应用 ID,仅工作流辅助生成会话返回'), + skillId: z.undefined().optional(), + sourceType: z.literal(ChatSourceTypeEnum.workflowBuilder).describe('工作流辅助生成会话资源类型') + }), z.object({ appId: ObjectIdSchema.describe('应用 ID,仅 ChatAgentHelper 会话返回'), skillId: z.undefined().optional(), @@ -131,6 +147,10 @@ export const buildChatTargetResponse = ({ return { appId: id, sourceType: ChatSourceTypeEnum.chatAgentHelper }; } + if (sourceType === ChatSourceTypeEnum.workflowBuilder) { + return { appId: id, sourceType: ChatSourceTypeEnum.workflowBuilder }; + } + const exhaustiveCheck: never = sourceType; throw new Error(`Unsupported chat source type: ${exhaustiveCheck}`); }; @@ -176,7 +196,9 @@ const refineChatAuthTargetInput = ( ) => { const hasAppTarget = !!data.appId; const hasSkillTarget = !!data.skillId; - const isChatAgentHelperTarget = data.sourceType === ChatSourceTypeEnum.chatAgentHelper; + const isExplicitAppSource = + data.sourceType === ChatSourceTypeEnum.chatAgentHelper || + data.sourceType === ChatSourceTypeEnum.workflowBuilder; const nestedAuthData = getNestedOutLinkAuthData(data); const shareId = nestedAuthData.shareId; const outLinkUid = nestedAuthData.outLinkUid; @@ -199,17 +221,17 @@ const refineChatAuthTargetInput = ( }); } - if (isChatAgentHelperTarget && !hasAppTarget) { + if (isExplicitAppSource && !hasAppTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'sourceType=chatAgentHelper requires appId' + message: `sourceType=${String(data.sourceType)} requires appId` }); } - if (isChatAgentHelperTarget && hasSkillTarget) { + if (isExplicitAppSource && hasSkillTarget) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'sourceType=chatAgentHelper cannot be used with skillId' + message: `sourceType=${String(data.sourceType)} cannot be used with skillId` }); } @@ -259,6 +281,9 @@ export const transformChatTargetInput = (data: T) => if (rawSourceType === ChatSourceTypeEnum.chatAgentHelper) { return ChatSourceTypeEnum.chatAgentHelper; } + if (rawSourceType === ChatSourceTypeEnum.workflowBuilder) { + return ChatSourceTypeEnum.workflowBuilder; + } return ChatSourceTypeEnum.app; })(), sourceId @@ -297,6 +322,9 @@ export const transformChatAuthTargetInput = (data: T) if (rawSourceType === ChatSourceTypeEnum.chatAgentHelper) { return ChatSourceTypeEnum.chatAgentHelper; } + if (rawSourceType === ChatSourceTypeEnum.workflowBuilder) { + return ChatSourceTypeEnum.workflowBuilder; + } return ChatSourceTypeEnum.app; })(), sourceId: sourceId! @@ -337,6 +365,9 @@ export const transformOptionalChatTargetInput = (data if (rawSourceType === ChatSourceTypeEnum.chatAgentHelper) { return ChatSourceTypeEnum.chatAgentHelper; } + if (rawSourceType === ChatSourceTypeEnum.workflowBuilder) { + return ChatSourceTypeEnum.workflowBuilder; + } return ChatSourceTypeEnum.app; })(), sourceId: sourceId! diff --git a/packages/global/openapi/core/chat/record/api.ts b/packages/global/openapi/core/chat/record/api.ts index 7f3b0f3036f1..524801bac57f 100644 --- a/packages/global/openapi/core/chat/record/api.ts +++ b/packages/global/openapi/core/chat/record/api.ts @@ -224,7 +224,14 @@ export type GetChatSpeechBodyType = z.infer; * ============================================================================ */ export const AudioTranscriptionsDataRawSchema = z.object({ - sourceType: z.enum(ChatSourceTypeEnum).describe('会话归属资源类型'), + sourceType: z + .enum([ + ChatSourceTypeEnum.app, + ChatSourceTypeEnum.skillEdit, + ChatSourceTypeEnum.chatAgentHelper, + ChatSourceTypeEnum.workflowBuilder + ]) + .describe('会话归属资源类型'), sourceId: ObjectIdSchema.describe('会话归属资源 ID'), chatId: z.string().min(1).max(256).describe('会话 ID'), outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据'), diff --git a/packages/global/openapi/core/workflow/builder/api.ts b/packages/global/openapi/core/workflow/builder/api.ts new file mode 100644 index 000000000000..4f8523e928c3 --- /dev/null +++ b/packages/global/openapi/core/workflow/builder/api.ts @@ -0,0 +1,234 @@ +import { z } from 'zod'; +import { AppChatConfigTypeSchema } from '../../../../core/app/type'; +import { ChatCompletionMessageParamSchema } from '../../../../core/ai/llm/type'; +import { StoreNodeItemTypeSchema } from '../../../../core/workflow/type/node'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; +import { AgentPlanAskResponseSchema } from '../../../../core/workflow/template/system/interactive/type'; +import { OpenAPIAppChatConfigSchema } from '../../app/common/api'; +import { + WorkflowBuilderVersionSchema, + WorkflowChecksumSchema +} from '../../../../core/workflow/builder/type'; +import { OpenAPIStoreNodeItemTypeSchema } from '../node'; + +/* ============================================================================ + * API: Workflow Builder 辅助生成对话 + * Route: POST /api/proApi/core/workflow/builder/chat + * Method: POST + * Description: 基于当前 WorkflowDocument 生成并自动应用工作流,通过 SSE 返回目标文档 + * Tags: ['Workflow Builder'] + * ============================================================================ */ + +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) }) +]); + +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) }) +]); + +/** + * Workflow Builder 请求的传输层 Document Schema。 + * + * Handler 还会用 workflow-core 再次执行版本和领域校验;这里只负责 API + * 边界的完整结构约束,避免 global package 反向依赖 workflow-core。 + */ +export const WorkflowBuilderDocumentSchema = z + .object({ + schemaVersion: z.literal('fastgpt-workflow/v1').meta({ + description: 'WorkflowDocument schema 版本', + example: '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() + }) + .meta({ description: '应用元数据' }), + nodes: z.array(StoreNodeItemTypeSchema).meta({ description: '工作流节点' }), + executionEdges: z + .array( + z.object({ + source: ExecutionSourcePortRefSchema, + target: ExecutionTargetPortRefSchema + }) + ) + .meta({ description: '语义执行边' }), + chatConfig: AppChatConfigTypeSchema.meta({ description: '工作流对话配置' }) + }) + .strict(); + +export const WorkflowBuilderChatBodySchema = z + .object({ + appId: ObjectIdSchema.meta({ + description: '当前 Workflow 应用 ID', + example: '67f4c91c79a4d61b1f116b2a' + }), + chatId: z.string().min(1).meta({ + description: '工作流辅助生成独立会话 ID', + example: 'workflow-builder-chat-id' + }), + responseChatItemId: z.string().min(1).optional().meta({ + description: '当前轮 AI 消息 ID', + example: 'response-chat-item-id' + }), + messages: z.array(ChatCompletionMessageParamSchema).min(1).meta({ + description: '当前轮 ChatBox 消息' + }), + model: z.string().min(1).optional().meta({ + description: '工作流辅助生成使用的 LLM 模型', + example: 'gpt-5' + }), + agentPlanAskResponse: AgentPlanAskResponseSchema.optional().meta({ + description: '用户对当前 Agent 结构化选项的回答' + }), + workflowContext: z + .object({ + document: WorkflowBuilderDocumentSchema, + checksum: WorkflowChecksumSchema + }) + .strict() + .meta({ description: '当前画布事实和对应 checksum' }) + }) + .strict(); + +export type WorkflowBuilderChatBody = z.infer; + +/** CLI Apply 完成后通过 SSE 返回给画布的目标文档。 */ +export const WorkflowBuilderAppliedSchema = z + .object({ + baseChecksum: WorkflowChecksumSchema.meta({ + description: '生成开始时的 WorkflowDocument checksum,用于应用前的乐观并发校验' + }), + document: WorkflowBuilderDocumentSchema.meta({ + description: 'CLI Apply 后经服务端校验的目标 WorkflowDocument' + }), + checksum: WorkflowChecksumSchema.meta({ description: '目标 WorkflowDocument checksum' }) + }) + .strict(); + +export type WorkflowBuilderApplied = z.infer; + +const WorkflowBuilderVersionIdentitySchema = z + .object({ + appId: ObjectIdSchema.meta({ + description: 'Workflow 应用 ID', + example: '67f4c91c79a4d61b1f116b2a' + }), + chatId: z.string().min(1).meta({ + description: '工作流辅助生成会话 ID', + example: 'workflow-builder-chat-id' + }), + responseChatItemId: z.string().min(1).meta({ + description: '包含目标版本的 AI ChatItem dataId', + example: 'response-chat-item-id' + }) + }) + .strict(); + +/* ============================================================================ + * API: 加载 Workflow Builder 版本 + * Route: POST /api/proApi/core/workflow/builder/version/load + * Method: POST + * Description: 从 S3 加载聊天卡片绑定的 WorkflowDocument;旧消息保留 Sandbox 兼容 + * Tags: ['Workflow Builder'] + * ============================================================================ */ + +export const WorkflowBuilderVersionLoadBodySchema = WorkflowBuilderVersionIdentitySchema; +export type WorkflowBuilderVersionLoadBody = z.infer; + +// OpenAPI 输出不能包含节点旧枚举兼容用的 transform;运行时请求仍使用完整 Document Schema。 +const WorkflowBuilderVersionDocumentResponseSchema = WorkflowBuilderDocumentSchema.omit({ + nodes: true, + chatConfig: true +}).extend({ + nodes: z.array(OpenAPIStoreNodeItemTypeSchema).meta({ description: '工作流节点' }), + chatConfig: OpenAPIAppChatConfigSchema.meta({ description: '工作流对话配置' }) +}); + +export const WorkflowBuilderVersionLoadResponseSchema = z + .object({ + versionNo: WorkflowBuilderVersionSchema.shape.versionNo, + document: WorkflowBuilderVersionDocumentResponseSchema, + checksum: WorkflowChecksumSchema, + source: z.enum(['sandbox', 's3']).meta({ + description: '版本 JSON 的读取来源', + example: 'sandbox' + }) + }) + .strict(); +export type WorkflowBuilderVersionLoadResponse = z.infer< + typeof WorkflowBuilderVersionLoadResponseSchema +>; + +/* ============================================================================ + * API: 确认 Workflow Builder 版本已应用 + * Route: POST /api/proApi/core/workflow/builder/version/commit + * Method: POST + * Description: 画布应用成功后幂等记录首次应用时间 + * Tags: ['Workflow Builder'] + * ============================================================================ */ + +export const WorkflowBuilderVersionCommitBodySchema = WorkflowBuilderVersionIdentitySchema.extend({ + document: WorkflowBuilderDocumentSchema, + checksum: WorkflowChecksumSchema +}).strict(); +export type WorkflowBuilderVersionCommitBody = z.infer< + typeof WorkflowBuilderVersionCommitBodySchema +>; + +export const WorkflowBuilderVersionCommitResponseSchema = WorkflowBuilderVersionSchema.required({ + s3Key: true, + expiresAt: true, + appliedAt: true +}); +export type WorkflowBuilderVersionCommitResponse = z.infer< + typeof WorkflowBuilderVersionCommitResponseSchema +>; + +/* ============================================================================ + * API: Workflow Builder 运行环境预热 + * Route: POST /api/proApi/core/workflow/builder/runtime/prewarm + * Method: POST + * Description: 在打开 Workflow Builder 后提前准备可复用的 Sandbox 运行环境 + * Tags: ['Workflow Builder'] + * ============================================================================ */ + +export const WorkflowBuilderRuntimePrewarmBodySchema = z + .object({ + appId: ObjectIdSchema.meta({ + description: '当前 Workflow 应用 ID', + example: '67f4c91c79a4d61b1f116b2a' + }), + chatId: z.string().min(1).meta({ + description: '工作流辅助生成独立会话 ID', + example: 'workflow-builder-chat-id' + }) + }) + .strict(); +export type WorkflowBuilderRuntimePrewarmBody = z.infer< + typeof WorkflowBuilderRuntimePrewarmBodySchema +>; + +export const WorkflowBuilderRuntimePrewarmResponseSchema = z.undefined().meta({ + description: '运行环境预热完成' +}); +export type WorkflowBuilderRuntimePrewarmResponse = z.infer< + typeof WorkflowBuilderRuntimePrewarmResponseSchema +>; diff --git a/packages/global/openapi/core/workflow/builder/index.ts b/packages/global/openapi/core/workflow/builder/index.ts new file mode 100644 index 000000000000..4eac16e9b3ee --- /dev/null +++ b/packages/global/openapi/core/workflow/builder/index.ts @@ -0,0 +1,90 @@ +import type { OpenAPIPath } from '../../../type'; +import { DevApiTagsMap } from '../../../tag'; +import { + WorkflowBuilderChatBodySchema, + WorkflowBuilderVersionCommitBodySchema, + WorkflowBuilderVersionCommitResponseSchema, + WorkflowBuilderVersionLoadBodySchema, + WorkflowBuilderVersionLoadResponseSchema, + WorkflowBuilderRuntimePrewarmBodySchema, + WorkflowBuilderRuntimePrewarmResponseSchema +} from './api'; + +export const WorkflowBuilderPath: OpenAPIPath = { + '/core/workflow/builder/chat': { + post: { + summary: '发起工作流辅助生成对话', + description: + '基于当前 WorkflowDocument 运行 Agent + CLI,生成经服务端验证、等待用户确认应用的目标版本。', + tags: [DevApiTagsMap.workflowHelper], + requestBody: { + content: { + 'application/json': { + schema: WorkflowBuilderChatBodySchema + } + } + }, + responses: { + 200: { + description: '返回 text/event-stream 工作流辅助生成事件流', + content: { + 'text/event-stream': { + schema: { + type: 'string' + } + } + } + } + } + } + }, + '/core/workflow/builder/version/load': { + post: { + summary: '加载工作流辅助生成版本', + description: '从 Sandbox 或 S3 加载聊天版本卡片绑定的 WorkflowDocument。', + tags: [DevApiTagsMap.workflowHelper], + requestBody: { + content: { 'application/json': { schema: WorkflowBuilderVersionLoadBodySchema } } + }, + responses: { + 200: { + description: '版本文档', + content: { 'application/json': { schema: WorkflowBuilderVersionLoadResponseSchema } } + } + } + } + }, + '/core/workflow/builder/version/commit': { + post: { + summary: '归档工作流辅助生成版本', + description: '画布应用成功后,将实际应用的 WorkflowDocument 归档到 S3 一天。', + tags: [DevApiTagsMap.workflowHelper], + requestBody: { + content: { 'application/json': { schema: WorkflowBuilderVersionCommitBodySchema } } + }, + responses: { + 200: { + description: '归档后的版本信息', + content: { 'application/json': { schema: WorkflowBuilderVersionCommitResponseSchema } } + } + } + } + }, + '/core/workflow/builder/runtime/prewarm': { + post: { + summary: '预热工作流辅助生成环境', + description: + '在用户打开工作流辅助生成后提前准备可复用的 Sandbox、内置 Skill 和 Workflow CLI。', + tags: [DevApiTagsMap.workflowHelper], + requestBody: { + content: { 'application/json': { schema: WorkflowBuilderRuntimePrewarmBodySchema } } + }, + responses: { + 200: { + description: '运行环境预热完成', + content: { 'application/json': { schema: WorkflowBuilderRuntimePrewarmResponseSchema } } + } + } + } + } +}; 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/global/openapi/path.ts b/packages/global/openapi/path.ts index 33858980e709..f0d2edeff50d 100644 --- a/packages/global/openapi/path.ts +++ b/packages/global/openapi/path.ts @@ -1,5 +1,6 @@ import { AIPath } from './core/ai'; import { SkillPath } from './core/ai/skill'; +import { WorkflowBuilderPath } from './core/workflow/builder'; import { AppPath } from './core/app'; import { ChatPath } from './core/chat'; import { DatasetPath } from './core/dataset'; @@ -23,7 +24,8 @@ export const openAPIPaths: NonNullable = { ...CommonPath, ...InvokePath, ...AIPath, - ...SkillPath + ...SkillPath, + ...WorkflowBuilderPath }; export const openAPITagGroups = [ diff --git a/packages/global/test/core/workflow/builder/utils.test.ts b/packages/global/test/core/workflow/builder/utils.test.ts new file mode 100644 index 000000000000..d8ab93e851ba --- /dev/null +++ b/packages/global/test/core/workflow/builder/utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { getWorkflowBuilderVersionDisplayState } from '@fastgpt/global/core/workflow/builder/utils'; +import type { WorkflowBuilderVersion } from '@fastgpt/global/core/workflow/builder/type'; + +const version: WorkflowBuilderVersion = { + versionNo: 1, + name: 'AI 生成版本 1', + filename: 'AI 生成版本 1.json', + checksum: `sha256:${'0'.repeat(64)}`, + generatedAt: '2026-08-12T10:00:00.000Z' +}; + +describe('getWorkflowBuilderVersionDisplayState', () => { + it('returns ready for a version that has not been applied', () => { + expect(getWorkflowBuilderVersionDisplayState({ version })).toBe('ready'); + }); + + it('keeps an older archived version independently applicable', () => { + expect( + getWorkflowBuilderVersionDisplayState({ + version: { ...version, s3Key: 'chat/app/version.json' } + }) + ).toBe('ready'); + }); + + it('returns available for a valid archived version', () => { + const archived = { + ...version, + s3Key: 'chat/app/version.json', + expiresAt: '2026-08-13T10:00:00.000Z' + }; + const now = new Date('2026-08-12T12:00:00.000Z').getTime(); + + expect( + getWorkflowBuilderVersionDisplayState({ + version: { ...archived, appliedAt: '2026-08-12T11:00:00.000Z' }, + now + }) + ).toBe('available'); + }); + + it('treats expiresAt as authoritative for an archived version', () => { + expect( + getWorkflowBuilderVersionDisplayState({ + version: { + ...version, + s3Key: 'chat/app/version.json', + expiresAt: '2026-08-13T10:00:00.000Z' + }, + now: new Date('2026-08-13T10:00:00.000Z').getTime() + }) + ).toBe('expired'); + }); +}); diff --git a/packages/global/test/core/workflow/runtime/utils.test.ts b/packages/global/test/core/workflow/runtime/utils.test.ts index a77bca6151ad..6dfa95f42b24 100644 --- a/packages/global/test/core/workflow/runtime/utils.test.ts +++ b/packages/global/test/core/workflow/runtime/utils.test.ts @@ -921,6 +921,55 @@ describe('getLastInteractiveValue', () => { expect(getLastInteractiveValue(histories)).toBeUndefined(); }); + + it('should return an unanswered workflow builder preview', () => { + const interactive = { + type: 'workflowBuilderPreview', + previewId: 'preview-1', + entryNodeIds: ['node1'], + memoryEdges: [], + nodeOutputs: [], + params: { + title: 'Workflow preview', + mermaid: 'flowchart LR\n A --> B', + sections: [], + actions: [ + { value: 'confirm', label: 'Confirm', inputMode: 'none' }, + { value: 'revise', label: 'Revise', inputMode: 'text' }, + { value: 'cancel', label: 'Cancel', inputMode: 'none' } + ] + } + } as WorkflowInteractiveResponseType; + + expect(getLastInteractiveValue([{ obj: ChatRoleEnum.AI, value: [{ interactive }] }])).toBe( + interactive + ); + }); + + it('should not return a workflow builder preview after an answer', () => { + const interactive = { + type: 'workflowBuilderPreview', + previewId: 'preview-1', + entryNodeIds: ['node1'], + memoryEdges: [], + nodeOutputs: [], + params: { + title: 'Workflow preview', + mermaid: 'flowchart LR\n A --> B', + sections: [], + actions: [ + { value: 'confirm', label: 'Confirm', inputMode: 'none' }, + { value: 'revise', label: 'Revise', inputMode: 'text' }, + { value: 'cancel', label: 'Cancel', inputMode: 'none' } + ], + answerValue: 'confirm' + } + } as WorkflowInteractiveResponseType; + + expect( + getLastInteractiveValue([{ obj: ChatRoleEnum.AI, value: [{ interactive }] }]) + ).toBeUndefined(); + }); }); describe('storeEdges2RuntimeEdges', () => { diff --git a/packages/global/test/core/workflow/utils.test.ts b/packages/global/test/core/workflow/utils.test.ts index 740e4d2deec6..ec51b1f36865 100644 --- a/packages/global/test/core/workflow/utils.test.ts +++ b/packages/global/test/core/workflow/utils.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + areWorkflowValueTypesCompatible, + getWorkflowReferenceSourceValueTypes, getHandleId, getSelectedInputRenderType, nodeInputIsReference, @@ -268,6 +270,35 @@ describe('nodeInputIsReference', () => { }); }); +describe('workflow reference value type compatibility', () => { + it('matches the reference selector contract for scalar, array and special values', () => { + expect(getWorkflowReferenceSourceValueTypes(WorkflowIOValueTypeEnum.arrayString)).toEqual([ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.any + ]); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayString, + actual: WorkflowIOValueTypeEnum.string + }) + ).toBe(true); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayObject, + actual: WorkflowIOValueTypeEnum.datasetQuote + }) + ).toBe(true); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.string, + actual: WorkflowIOValueTypeEnum.arrayString + }) + ).toBe(false); + }); +}); + describe('getAppChatConfig', () => { it('should return config with default values when no params provided', () => { const result = getAppChatConfig({ isPublicFetch: false }); diff --git a/packages/global/test/openapi/core/chat/targetSchema.test.ts b/packages/global/test/openapi/core/chat/targetSchema.test.ts index b8cdd3a5a789..67432ed7e754 100644 --- a/packages/global/test/openapi/core/chat/targetSchema.test.ts +++ b/packages/global/test/openapi/core/chat/targetSchema.test.ts @@ -26,6 +26,7 @@ import { import { SandboxCheckExistBodySchema } from '../../../../openapi/core/ai/sandbox/api'; import { CreateQuestionGuideV2BodySchema } from '../../../../openapi/core/ai/agent/api'; import { ExportCollectionBodySchema } from '../../../../openapi/core/dataset/collection/api'; +import { buildChatTargetResponse } from '../../../../openapi/core/chat/api'; const appId = '68ad85a7463006c963799a05'; const skillId = '68ad85a7463006c963799a06'; @@ -78,6 +79,38 @@ describe('openapi/core/chat target schema', () => { expect('appId' in result).toBe(false); }); + it('transforms raw appId with workflowBuilder sourceType to internal builder source', () => { + const result = SandboxCheckExistBodySchema.parse({ + appId, + sourceType: ChatSourceTypeEnum.workflowBuilder, + chatId: 'chat-1' + }); + + expect(result).toMatchObject({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: appId, + chatId: 'chat-1' + }); + expect(buildChatTargetResponse(result)).toEqual({ + appId, + sourceType: ChatSourceTypeEnum.workflowBuilder + }); + }); + + it('allows workflowBuilder source for audio transcription', () => { + expect( + AudioTranscriptionsDataSchema.parse({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: appId, + chatId: 'chat-1' + }) + ).toMatchObject({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: appId, + chatId: 'chat-1' + }); + }); + it('transforms question guide skillId to internal skillEdit source with debug model config', () => { const result = CreateQuestionGuideV2BodySchema.parse({ skillId, @@ -115,6 +148,13 @@ describe('openapi/core/chat target schema', () => { chatId: 'chat-1' }) ).toThrow(); + expect(() => + StopV2ChatSchema.parse({ + skillId, + sourceType: ChatSourceTypeEnum.workflowBuilder, + chatId: 'chat-1' + }) + ).toThrow(); expect(() => GetPaginationRecordsBodySchema.parse({ ...payload, diff --git a/packages/service/common/s3/sources/chat/type.ts b/packages/service/common/s3/sources/chat/type.ts index 550efeec2f20..88f20adc0d72 100644 --- a/packages/service/common/s3/sources/chat/type.ts +++ b/packages/service/common/s3/sources/chat/type.ts @@ -4,8 +4,13 @@ import { UploadFileByBodySchema } from '../../contracts/type'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { UploadExtensionRuleSchema, UploadFileHintSchema } from '../../uploadPolicy/type'; -export const ChatS3SourceTypeSchema = z.enum(ChatSourceTypeEnum); -export type ChatS3SourceType = z.infer; +export const ChatS3SourceTypeSchema = z.enum([ + ChatSourceTypeEnum.app, + ChatSourceTypeEnum.skillEdit, + ChatSourceTypeEnum.chatAgentHelper, + ChatSourceTypeEnum.workflowBuilder +]); +export type ChatS3SourceType = ChatSourceTypeEnum; export const ChatFileUploadSchema = z.object({ sourceType: ChatS3SourceTypeSchema, @@ -22,7 +27,9 @@ export const ChatFileUploadSchema = z.object({ allowedExtensions: z.array(z.string().nonempty()).optional(), extensionRules: z.array(UploadExtensionRuleSchema).optional() }); -export type CheckChatFileKeys = z.input; +export type CheckChatFileKeys = Omit, 'sourceType'> & { + sourceType: ChatS3SourceType; +}; export const DelChatFileByPrefixSchema = z.object({ sourceType: ChatS3SourceTypeSchema, @@ -30,7 +37,12 @@ export const DelChatFileByPrefixSchema = z.object({ chatId: z.string().nonempty().optional(), uId: z.string().nonempty().optional() }); -export type DelChatFileByPrefixParams = z.input; +export type DelChatFileByPrefixParams = Omit< + z.input, + 'sourceType' +> & { + sourceType: ChatS3SourceType; +}; export const UploadChatFileSchema = z.object({ sourceType: ChatS3SourceTypeSchema, @@ -43,4 +55,6 @@ export const UploadChatFileSchema = z.object({ expiredTime: UploadFileByBodySchema.shape.expiredTime }); -export type UploadFileParams = z.input; +export type UploadFileParams = Omit, 'sourceType'> & { + sourceType: ChatS3SourceType; +}; diff --git a/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts b/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts index 014536cb0c40..381e96d77b78 100644 --- a/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts +++ b/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts @@ -57,7 +57,9 @@ export async function runAuxiliaryGenerationAgentLoop({ stream: true, useVision, useAudio, - useVideo + useVideo, + // 辅助生成只接收完成授权的聊天上传文件;转成 data URL 可避免外部模型访问私有 S3 地址失败。 + forceMediaToBase64: true }, systemTools: { ask: { enabled: true } diff --git a/packages/service/core/ai/llm/agentLoop/domain/mainPrompt.ts b/packages/service/core/ai/llm/agentLoop/domain/mainPrompt.ts index 1f6aa90fd9e5..56751a58aa9f 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/mainPrompt.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/mainPrompt.ts @@ -13,6 +13,14 @@ export type BuildDefaultAgentSystemPromptParams = { sandboxEnabled?: boolean; }; +export type AgentLoopSystemPromptBuilderInput = { + systemPrompt?: string; + hasExecutableTools: boolean; +}; + +/** AgentLoop 顶层系统提示词构建策略。 */ +export type AgentLoopSystemPromptBuilder = (input: AgentLoopSystemPromptBuilderInput) => string; + /** * 构建 Agent 的最终 system prompt。 * @@ -34,3 +42,14 @@ export const buildDefaultAgentSystemPrompt = ({ ] .filter(Boolean) .join('\n\n'); + +/** + * 解析当前 AgentLoop 使用的最终系统提示词。 + * 默认保留调用方已组装的提示词,只在业务场景注入 builder 时替换顶层角色。 + */ +export const resolveAgentLoopSystemPrompt = ({ + systemPromptBuilder, + ...input +}: AgentLoopSystemPromptBuilderInput & { + systemPromptBuilder?: AgentLoopSystemPromptBuilder; +}) => systemPromptBuilder?.(input) ?? input.systemPrompt ?? ''; diff --git a/packages/service/core/ai/llm/agentLoop/domain/runtime.ts b/packages/service/core/ai/llm/agentLoop/domain/runtime.ts index f863dee0780a..2fb21d53e171 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/runtime.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/runtime.ts @@ -4,6 +4,7 @@ import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import type { CreateLLMResponseProps } from '../../request'; import type { AgentLoopEvent } from './event'; import type { AgentLoopInteractiveToolExecuteParams } from './interactive'; +import type { AgentLoopSystemPromptBuilder } from './mainPrompt'; import type { AgentLoopSystemTools, AgentLoopToolCatalog, @@ -25,6 +26,7 @@ export type AgentLoopLLMParams = { useVision?: boolean; useAudio?: boolean; useVideo?: boolean; + forceMediaToBase64?: boolean; extractFiles?: boolean; }; @@ -32,13 +34,27 @@ export type AgentLoopResponseParams = { retainDatasetCite?: boolean; }; +export type AgentLoopCompletionDecision = + | { action: 'complete' } + | { action: 'continue'; message: string }; + +/** + * 在模型自然结束且没有工具调用时,决定是否接受本次结束。 + * 策略只接收运行时事实,不接收模型文本,避免业务层通过关键词判断完成状态。 + */ +export type AgentLoopCompletionPolicy = (context: { + requestIndex: number; +}) => AgentLoopCompletionDecision | Promise; + export type AgentLoopRuntime = { teamId: string; llmParams: AgentLoopLLMParams; + systemPromptBuilder?: AgentLoopSystemPromptBuilder; responseParams?: AgentLoopResponseParams; lang?: localeType; systemTools?: AgentLoopSystemTools; maxRunAgentTimes?: number; + completionPolicy?: AgentLoopCompletionPolicy; checkIsStopping?: () => boolean; toolCatalog: AgentLoopToolCatalog; executeTool: ( @@ -50,3 +66,16 @@ export type AgentLoopRuntime = { usagePush?: (usages: AgentLoopUsage[]) => void; emitEvent?: (event: AgentLoopEvent) => void; }; + +/** + * 判断 Agent 是否拥有能执行实际业务动作的工具。 + * ask/plan 只负责交互和状态维护,不应让主提示词误认为可以执行外部任务。 + */ +export const hasAgentLoopExecutableTools = ({ + toolCatalog, + systemTools +}: Pick) => + toolCatalog.runtimeTools.length > 0 || + systemTools?.sandbox?.enabled === true || + systemTools?.readFile?.enabled === true || + systemTools?.datasetSearch?.enabled === true; diff --git a/packages/service/core/ai/llm/agentLoop/domain/tool.ts b/packages/service/core/ai/llm/agentLoop/domain/tool.ts index 025a195cd9d4..f485eaa1d791 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/tool.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/tool.ts @@ -3,8 +3,8 @@ import type { ChatCompletionMessageToolCall, ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; -import type { SandboxClient } from '../../../sandbox/interface/runtime'; import type { AgentLoopDatasetSearchExecutor } from './systemTool/datasetSearch'; +import type { AgentAskPayload } from './systemTool/ask'; import type { AgentLoopUsage } from './usage'; export type AgentLoopToolCatalog = { @@ -45,16 +45,47 @@ export type AgentLoopReadFileExecutor = ( params: AgentLoopReadFileExecuteParams ) => Promise; +/** + * AgentLoop 使用的 Sandbox 工具执行边界。 + * + * 默认 Agent 可以由适配层继续使用 SandboxClient;需要隔离工作目录的业务 + * (例如 Workflow Builder)应注入自己的执行器,避免把物理 Sandbox 直接交给模型。 + */ +export type AgentLoopSandboxExecutionResult = { + success: boolean; + response: string; + errorMessage?: string; +}; + +export type AgentLoopSandboxExecutor = (params: { + toolName: string; + args: string; +}) => Promise; + +export type AgentLoopAskValidationContext = { + /** 当前 ask_agent 工具调用所属 assistant 消息的正文。 */ + assistantContent: string; +}; + +export type AgentLoopAskValidator = ( + ask: AgentAskPayload, + context: AgentLoopAskValidationContext +) => string | undefined | Promise; + export type AgentLoopSystemTools = { plan?: { enabled: boolean; }; ask?: { enabled: boolean; + /** 由具体 Agent 注入交互约束;返回错误时将结果反馈给模型并继续当前 loop。 */ + validate?: AgentLoopAskValidator; }; sandbox?: { enabled: boolean; - client: SandboxClient; + /** 普通 Agent 的兼容入口。Workflow Builder 应优先使用 executor。 */ + client?: import('../../../sandbox/interface/runtime').SandboxClient; + executor?: AgentLoopSandboxExecutor; }; readFile?: { enabled: boolean; diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts index 992b7fe7f870..408efd9adb9e 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts @@ -1,10 +1,11 @@ import { runFastAgentMainLoop } from './loop'; -import type { - AgentLoopInput, - AgentLoopProvider, - AgentLoopResult, - AgentLoopRuntime, - AgentLoopUsage +import { + hasAgentLoopExecutableTools, + type AgentLoopInput, + type AgentLoopProvider, + type AgentLoopResult, + type AgentLoopRuntime, + type AgentLoopUsage } from '../../domain'; import type { AgentLoopRuntime as FastAgentInternalRuntime } from './loop/type'; import { createAskUserAgentTool } from '../../domain/systemTool/ask'; @@ -66,6 +67,7 @@ export const runFastAgentLoop = async ({ const fastAgentRuntime: FastAgentInternalRuntime = { teamId: runtime.teamId, model: runtime.llmParams.model, + systemPromptBuilder: runtime.systemPromptBuilder, reasoningEffort: runtime.llmParams.reasoningEffort, userKey: runtime.llmParams.userKey, stream: runtime.llmParams.stream, @@ -78,11 +80,15 @@ export const runFastAgentLoop = async ({ useVision: runtime.llmParams.useVision, useAudio: runtime.llmParams.useAudio, useVideo: runtime.llmParams.useVideo, + forceMediaToBase64: runtime.llmParams.forceMediaToBase64, extractFiles: runtime.llmParams.extractFiles, lang: runtime.lang, + hasExecutableTools: hasAgentLoopExecutableTools(runtime), maxRunAgentTimes: runtime.maxRunAgentTimes, + completionPolicy: runtime.completionPolicy, batchToolSize: runtime.toolCatalog.batchToolSize, checkIsStopping: runtime.checkIsStopping, + validateAsk: runtime.systemTools?.ask?.validate, toolCatalog: { runtimeTools: runtime.toolCatalog.runtimeTools, ...(runtime.systemTools?.ask?.enabled ? { askTool: createAskUserAgentTool() } : {}), @@ -92,7 +98,8 @@ export const runFastAgentLoop = async ({ updatePlanTool: createUpdatePlanAgentTool() } : {}), - ...(runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + ...(runtime.systemTools?.sandbox?.enabled && + (runtime.systemTools.sandbox.client || runtime.systemTools.sandbox.executor) ? { sandboxTools: createAgentLoopSandboxTools() } : {}), ...(runtime.systemTools?.readFile?.enabled @@ -109,9 +116,11 @@ export const runFastAgentLoop = async ({ executeTool: runtime.executeTool, executeInteractiveTool: runtime.executeInteractiveTool, sandboxToolContext: - runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + runtime.systemTools?.sandbox?.enabled && + (runtime.systemTools.sandbox.client || runtime.systemTools.sandbox.executor) ? { - client: runtime.systemTools.sandbox.client + client: runtime.systemTools.sandbox.client, + executor: runtime.systemTools.sandbox.executor } : undefined, executeReadFileTool: runtime.systemTools?.readFile?.execute, diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts index 31729567345e..dc7f8c505de3 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts @@ -15,6 +15,7 @@ import { formatModelChars2Points } from '../../../../../../../support/wallet/usa import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { AgentLoopChildrenInteractiveParams, + AgentLoopCompletionPolicy, AgentLoopInteractiveToolExecuteParams, AgentLoopToolExecutionResult, AgentLoopUsage @@ -26,6 +27,7 @@ import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; type RunAgentCallProps = { maxRunAgentTimes: number; + completionPolicy?: AgentLoopCompletionPolicy; batchToolSize?: number; body: CreateLLMResponseProps['body'] & { tools: ChatCompletionTool[]; @@ -189,6 +191,7 @@ export const onCompressContext = async ({ */ export const runAgentLoop = async ({ maxRunAgentTimes, + completionPolicy, batchToolSize = 1, body: { model, messages, max_tokens, ...body }, @@ -693,6 +696,32 @@ export const runAgentLoop = async ({ } } + if ( + toolCalls.length === 0 && + !toolChildPause && + !stopAgentLoop && + !isAborted?.() && + completionPolicy + ) { + try { + const decision = await completionPolicy({ requestIndex: runTimes }); + if (decision.action === 'continue') { + if (runTimes >= maxRunAgentTimes) { + requestError = new Error(`Agent loop reached max run times: ${maxRunAgentTimes}`); + break; + } + await appendRequestMessages({ + role: ChatCompletionRequestMessageRoleEnum.System, + content: decision.message + }); + continue; + } + } catch (error) { + requestError = error; + break; + } + } + if (toolCalls.length === 0 || !!toolChildPause || stopAgentLoop || isAborted?.()) { break; } diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts index 24afa3b5694b..1481897d8d32 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts @@ -7,6 +7,10 @@ import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { parseJsonArgs } from '../../../../../utils'; import { runAgentLoop } from './base'; +import { + resolveAgentLoopSystemPrompt, + type AgentLoopSystemPromptBuilder +} from '../../../domain/mainPrompt'; import { formatAgentAskToolResponse, parseAgentAskToolCall, @@ -97,14 +101,44 @@ const pushAgentLoopUsages = ( const stripSystemMessages = (messages: ChatCompletionMessageParam[]) => messages.filter((message) => message.role !== ChatCompletionRequestMessageRoleEnum.System); +/** 读取 ask_agent 所属 assistant 消息的可展示正文,供业务校验交互内容。 */ +const getAssistantMessageContent = (message?: ChatCompletionMessageParam) => { + if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant || !message.content) + return ''; + if (typeof message.content === 'string') return message.content; + return message.content + .map((item) => { + if (item.type === 'text') return item.text; + if (item.type === 'refusal') return item.refusal; + return ''; + }) + .join(''); +}; + /** * 构建进入主 Agent 的初始消息链。 * systemPrompt 是调用方已经组装完成的最终提示词;messages 中的 system message 不再重复注入。 */ -const buildInitialMessages = ({ input }: { input: FastAgentLoopInput }) => [ - ...(input.systemPrompt ? [createSystemMessage(input.systemPrompt)] : []), - ...stripSystemMessages(input.messages) -]; +const buildInitialMessages = ({ + input, + hasExecutableTools, + systemPromptBuilder +}: { + input: FastAgentLoopInput; + hasExecutableTools: boolean; + systemPromptBuilder?: AgentLoopSystemPromptBuilder; +}): ChatCompletionMessageParam[] => { + const systemPrompt = resolveAgentLoopSystemPrompt({ + systemPrompt: input.systemPrompt, + hasExecutableTools, + systemPromptBuilder + }); + + return [ + ...(systemPrompt ? [createSystemMessage(systemPrompt)] : []), + ...stripSystemMessages(input.messages) + ]; +}; /** * ask_user 暂停时保存恢复所需上下文。 @@ -192,7 +226,11 @@ export const runFastAgentMainLoop = async ({ ) } as ChatCompletionMessageParam ] - : buildInitialMessages({ input }); + : buildInitialMessages({ + input, + hasExecutableTools: runtime.hasExecutableTools, + systemPromptBuilder: runtime.systemPromptBuilder + }); // 普通续轮通过 input.activePlan 恢复结构化 plan;ask_user 续跑则优先使用暂停时的完整快照。 // 历史 checkpoint 只负责给模型提供上下文,不再作为运行时状态的反序列化来源。 let activePlan = input.pendingMainContext?.activePlan ?? input.activePlan; @@ -223,6 +261,7 @@ export const runFastAgentMainLoop = async ({ const result = await runAgentLoop({ maxRunAgentTimes: runtime.maxRunAgentTimes ?? 100, + completionPolicy: runtime.completionPolicy, batchToolSize: runtime.batchToolSize ?? 5, childrenInteractiveParams: input.childrenInteractiveParams, body: { @@ -238,6 +277,7 @@ export const runFastAgentMainLoop = async ({ useVision: runtime.useVision, useAudio: runtime.useAudio, useVideo: runtime.useVideo, + forceMediaToBase64: runtime.forceMediaToBase64, extractFiles: runtime.extractFiles, messages, tools: getToolsForFastAgentLoop({ @@ -368,6 +408,13 @@ export const runFastAgentMainLoop = async ({ return createToolResponse(parsed.error, { skipResponseCompress: true }); } + const validationError = await runtime.validateAsk?.(parsed.ask, { + assistantContent: getAssistantMessageContent(assistantMessage) + }); + if (validationError) { + return createToolResponse(validationError, { skipResponseCompress: true }); + } + emitAgentLoopEvent(runtime, { type: 'ask_start', ask: parsed.ask, @@ -439,11 +486,21 @@ export const runFastAgentMainLoop = async ({ return createToolResponse(response, { skipResponseCompress: true }); } - const sandboxResult = await runSandboxTools({ - toolName: sandboxToolName, - args: call.function.arguments ?? '', - sandboxClient: runtime.sandboxToolContext.client - }); + const sandboxResult = runtime.sandboxToolContext.executor + ? await runtime.sandboxToolContext.executor({ + toolName: sandboxToolName, + args: call.function.arguments ?? '' + }) + : runtime.sandboxToolContext.client + ? await runSandboxTools({ + toolName: sandboxToolName, + args: call.function.arguments ?? '', + sandboxClient: runtime.sandboxToolContext.client + }) + : { + success: false, + response: 'Sandbox executor is not available.' + }; return createToolResponse(sandboxResult.response, { skipResponseCompress: true, errorMessage: sandboxResult.success ? undefined : sandboxResult.response diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts index d290ae7f01d5..7e6c0bd07b9d 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts @@ -6,22 +6,26 @@ import type { import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { localeType } from '@fastgpt/global/common/i18n/type'; import type { CreateLLMResponseProps } from '../../../../request'; -import type { SandboxClient } from '../../../../../sandbox/interface/runtime'; import type { AgentLoopToolCatalog } from '../tools'; import type { AgentLoopDatasetSearchExecutor } from '../../../domain/systemTool/datasetSearch'; import type { AgentLoopChildrenInteractiveParams, + AgentLoopCompletionPolicy, + AgentLoopAskValidator, AgentLoopEvent, AgentLoopInteractiveToolExecuteParams, AgentLoopPendingMainContext, AgentLoopPause, AgentLoopReadFileExecutor, + AgentLoopSystemPromptBuilder, AgentLoopToolExecutionResult, + AgentLoopSandboxExecutor, AgentLoopUsage } from '../../../domain'; export type { AgentLoopChildrenInteractiveParams, + AgentLoopCompletionPolicy, AgentLoopEvent, AgentLoopInteractiveToolExecuteParams, AgentLoopPause, @@ -32,6 +36,7 @@ export type { export type AgentLoopRuntime = { teamId: string; model: string; + systemPromptBuilder?: AgentLoopSystemPromptBuilder; reasoningEffort?: CreateLLMResponseProps['body']['reasoning_effort']; userKey?: CreateLLMResponseProps['userKey']; stream?: boolean; @@ -44,11 +49,15 @@ export type AgentLoopRuntime = { useVision?: boolean; useAudio?: boolean; useVideo?: boolean; + forceMediaToBase64?: boolean; extractFiles?: boolean; lang?: localeType; + hasExecutableTools: boolean; maxRunAgentTimes?: number; + completionPolicy?: AgentLoopCompletionPolicy; batchToolSize?: number; checkIsStopping?: () => boolean; + validateAsk?: AgentLoopAskValidator; toolCatalog: AgentLoopToolCatalog; executeTool: (e: { call: ChatCompletionMessageToolCall; @@ -58,7 +67,8 @@ export type AgentLoopRuntime = { e: AgentLoopInteractiveToolExecuteParams ) => Promise>; sandboxToolContext?: { - client: SandboxClient; + client?: import('../../../../../sandbox/interface/runtime').SandboxClient; + executor?: AgentLoopSandboxExecutor; }; executeReadFileTool?: AgentLoopReadFileExecutor; executeDatasetSearchTool?: AgentLoopDatasetSearchExecutor; diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts index 4d5c53d23a5b..3de7fb86333a 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts @@ -15,6 +15,7 @@ import { loadRequestMessages } from '../../../utils'; import { formatModelChars2Points } from '../../../../../../support/wallet/usage/utils'; import { getLLMModel } from '../../../../model'; import { AgentUsageModuleName } from '../../domain/usage'; +import { resolveAgentLoopSystemPrompt } from '../../domain/mainPrompt'; import { askUserToolName, formatAgentAskToolResponse, @@ -22,6 +23,7 @@ import { } from '../../domain/systemTool/ask'; import { setPlanToolName, updatePlanToolName } from '../../domain/systemTool/plan'; import { + hasAgentLoopExecutableTools, normalizeAgentLoopUsages, type AgentLoopInput, type AgentLoopResult, @@ -81,6 +83,7 @@ export const runPiAgentLoop = async ({ useVision: runtime.llmParams.useVision && modelData.vision, useAudio: runtime.llmParams.useAudio && modelData.audio, useVideo: runtime.llmParams.useVideo && modelData.video, + forceMediaToBase64: runtime.llmParams.forceMediaToBase64, extractFiles: runtime.llmParams.extractFiles, supportReason: modelData.reasoning }); @@ -88,6 +91,7 @@ export const runPiAgentLoop = async ({ let requestIndex = 0; let answerText = ''; let reasoningText = ''; + let currentAssistantContent = ''; let inputTokens = 0; let outputTokens = 0; let llmTotalPoints = 0; @@ -395,6 +399,7 @@ export const runPiAgentLoop = async ({ abortCurrentRunRef.current?.(); }, getMessages: () => completeMessages, + getAssistantContent: () => currentAssistantContent, onToolCall: emitOrdinaryToolExecution, onToolResult: ({ call, response, assistantMessages }) => { recordToolResult({ @@ -407,9 +412,14 @@ export const runPiAgentLoop = async ({ const pendingRequests: Array<{ requestId: string; requestIndex: number; startedAt: number }> = []; const maxRunAgentTimes = Math.max(1, runtime.maxRunAgentTimes ?? 100); + const systemPrompt = resolveAgentLoopSystemPrompt({ + systemPrompt: input.systemPrompt, + hasExecutableTools: hasAgentLoopExecutableTools(runtime), + systemPromptBuilder: runtime.systemPromptBuilder + }); const agent = new Agent({ initialState: { - systemPrompt: input.systemPrompt ?? '', + systemPrompt, model: piModel, thinkingLevel: getPiThinkingLevel(modelName, runtime.llmParams.reasoningEffort), tools, @@ -420,6 +430,7 @@ export const runPiAgentLoop = async ({ toolExecution: 'sequential', getApiKey: () => getModelApiKey(modelName, runtime.llmParams.userKey), onPayload: (payload) => { + currentAssistantContent = ''; if (requestIndex >= maxRunAgentTimes) { reachedRunLimit = true; throw new Error(`Agent loop reached max run times: ${maxRunAgentTimes}`); @@ -515,6 +526,7 @@ export const runPiAgentLoop = async ({ const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { answerText += assistantEvent.delta; + currentAssistantContent += assistantEvent.delta; runtime.emitEvent?.({ type: 'answer_delta', text: assistantEvent.delta @@ -575,6 +587,7 @@ export const runPiAgentLoop = async ({ ? normalizedMessage : event.message; const messageData = readAssistantMessage(assistantMessage); + currentAssistantContent = messageData.answerText; messageData.toolCalls.forEach(emitOrdinaryToolCall); if (!answerText && messageData.answerText) { answerText = messageData.answerText; @@ -682,6 +695,32 @@ export const runPiAgentLoop = async ({ await agent.prompt(prompt); } } + + while ( + runtime.completionPolicy && + !pendingAsk && + !pendingToolChild && + !pendingToolStop && + !runtime.checkIsStopping?.() && + !latestError && + !agent.state.errorMessage && + !reachedRunLimit + ) { + const decision = await runtime.completionPolicy({ requestIndex }); + if (decision.action === 'complete') break; + if (requestIndex >= maxRunAgentTimes) { + reachedRunLimit = true; + latestError = new Error(`Agent loop reached max run times: ${maxRunAgentTimes}`); + break; + } + + const continuationMessage: ChatCompletionMessageParam = { + role: ChatCompletionRequestMessageRoleEnum.User, + content: decision.message + }; + completeMessages.push(continuationMessage); + await agent.prompt(decision.message); + } } catch (error) { latestError = error; } finally { @@ -811,6 +850,20 @@ export const runPiAgentLoop = async ({ } if (reachedRunLimit) { + if (runtime.completionPolicy) { + return { + status: 'error', + activePlan, + providerState: nextProviderState, + completeMessages, + assistantMessages, + requestIds, + contextCheckpoint: latestContextCheckpoint, + finishReason: 'error', + usages: resultUsages, + error: latestError || new Error(`Agent loop reached max run times: ${maxRunAgentTimes}`) + }; + } return { status: 'done', activePlan, diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts index fd4b5be17e31..72a72ba85bdf 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts @@ -71,6 +71,7 @@ export const buildPiAgentTools = async ({ onToolChildPending, onToolStop, getMessages, + getAssistantContent, onToolCall, onToolResult }: { @@ -82,6 +83,7 @@ export const buildPiAgentTools = async ({ onToolChildPending?: (pause: { childrenResponse: TChildrenResponse; toolCallId: string }) => void; onToolStop?: () => void; getMessages: () => ChatCompletionMessageParam[]; + getAssistantContent: () => string; onToolCall: (call: ChatCompletionMessageToolCall) => void; onToolResult: (params: { call: ChatCompletionMessageToolCall; @@ -218,7 +220,8 @@ export const buildPiAgentTools = async ({ } } - if (runtime.systemTools?.ask?.enabled) { + const askSystemTool = runtime.systemTools?.ask; + if (askSystemTool?.enabled) { const askTool = createAskUserAgentTool(); tools.push({ name: askTool.function.name, @@ -238,6 +241,17 @@ export const buildPiAgentTools = async ({ return { content: [{ type: 'text' as const, text: response }], details: {} }; } + const validationError = await askSystemTool.validate?.(parsed.data, { + assistantContent: getAssistantContent() + }); + if (validationError) { + onToolResult({ + call: createToolCall({ id: callId, name: askTool.function.name, args: toolArgs }), + response: validationError + }); + return { content: [{ type: 'text' as const, text: validationError }], details: {} }; + } + setPendingAsk(parsed.data, callId); runtime.emitEvent?.({ type: 'ask_start', @@ -259,7 +273,10 @@ export const buildPiAgentTools = async ({ }); } - if (runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client) { + if ( + runtime.systemTools?.sandbox?.enabled && + (runtime.systemTools.sandbox.client || runtime.systemTools.sandbox.executor) + ) { for (const sandboxTool of createAgentLoopSandboxTools()) { const agentLoopToolName = sandboxTool.function.name; const sandboxToolName = toSandboxToolName(agentLoopToolName); @@ -279,16 +296,22 @@ export const buildPiAgentTools = async ({ const result = await executeOrdinaryTool({ call, execute: async () => { + const sandboxExecutor = runtime.systemTools?.sandbox?.executor; const sandboxClient = runtime.systemTools?.sandbox?.client; - if (!sandboxClient) { + if (!sandboxExecutor && !sandboxClient) { const response = 'Sandbox executor is not available.'; return { response, assistantMessages: [], usages: [], errorMessage: response }; } - const sandboxResult = await runSandboxTools({ - toolName: sandboxToolName, - args: call.function.arguments ?? '', - sandboxClient - }); + const sandboxResult = sandboxExecutor + ? await sandboxExecutor({ + toolName: sandboxToolName, + args: call.function.arguments ?? '' + }) + : await runSandboxTools({ + toolName: sandboxToolName, + args: call.function.arguments ?? '', + sandboxClient: sandboxClient! + }); return { response: sandboxResult.response, assistantMessages: [], diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/catalog.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/catalog.ts index 7d08888a3ee2..5d7554f8f0b1 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/catalog.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/catalog.ts @@ -11,7 +11,8 @@ const getPiAgentSystemTools = ( ): ChatCompletionTool[] => [ ...(runtime.systemTools?.plan?.enabled ? createPlanAgentTools() : []), ...(runtime.systemTools?.ask?.enabled ? [createAskUserAgentTool()] : []), - ...(runtime.systemTools?.sandbox?.enabled && runtime.systemTools.sandbox.client + ...(runtime.systemTools?.sandbox?.enabled && + (runtime.systemTools.sandbox.client || runtime.systemTools.sandbox.executor) ? createAgentLoopSandboxTools() : []), ...(runtime.systemTools?.readFile?.enabled diff --git a/packages/service/core/ai/llm/request/createLLMResponse.ts b/packages/service/core/ai/llm/request/createLLMResponse.ts index 4a128db34e35..8fb7ddfe7004 100644 --- a/packages/service/core/ai/llm/request/createLLMResponse.ts +++ b/packages/service/core/ai/llm/request/createLLMResponse.ts @@ -50,7 +50,16 @@ export const createLLMResponse = async ( saveLLMResponseRecord = true, teamId } = args; - const { messages, useVision, useAudio, useVideo, extractFiles, tools, toolCallMode } = body; + const { + messages, + useVision, + useAudio, + useVideo, + forceMediaToBase64, + extractFiles, + tools, + toolCallMode + } = body; const model = getLLMModel(body.model); // 先把 messages 中的文件/图片等 FastGPT 扩展结构加载成模型可直接消费的消息。 @@ -59,6 +68,7 @@ export const createLLMResponse = async ( useVision: useVision && model.vision, useAudio: useAudio && model.audio, useVideo: useVideo && model.video, + forceMediaToBase64, extractFiles, supportReason: model.reasoning }); diff --git a/packages/service/core/ai/llm/request/requestBody.ts b/packages/service/core/ai/llm/request/requestBody.ts index e170c611ec80..2e1998dae6ef 100644 --- a/packages/service/core/ai/llm/request/requestBody.ts +++ b/packages/service/core/ai/llm/request/requestBody.ts @@ -68,6 +68,7 @@ export const llmCompletionsBodyFormat = async = Omit< useVision?: boolean; useAudio?: boolean; useVideo?: boolean; + forceMediaToBase64?: boolean; extractFiles?: boolean; requestOrigin?: string; }; diff --git a/packages/service/core/ai/llm/utils.ts b/packages/service/core/ai/llm/utils.ts index c6a300f6eae1..07113707c05e 100644 --- a/packages/service/core/ai/llm/utils.ts +++ b/packages/service/core/ai/llm/utils.ts @@ -155,6 +155,7 @@ export const loadRequestMessages = async ({ useVision = false, useAudio = false, useVideo = false, + forceMediaToBase64 = false, extractFiles, supportReason = false }: { @@ -162,6 +163,7 @@ export const loadRequestMessages = async ({ useVision?: boolean; useAudio?: boolean; useVideo?: boolean; + forceMediaToBase64?: boolean; extractFiles?: boolean; supportReason?: boolean; }) => { @@ -292,10 +294,10 @@ export const loadRequestMessages = async ({ /** * 判断媒体是否需要在服务端转 base64。 - * 本地路径只有服务端能读取;MULTIPLE_DATA_TO_BASE64 用于兼容不支持远程 URL 的模型。 + * 本地路径只有服务端能读取;调用方可对已鉴权私有文件按请求强制内联;环境变量则保留全局兼容开关。 */ const shouldLoadMediaAsBase64 = (url: string) => - url.startsWith('/') || serviceEnv.MULTIPLE_DATA_TO_BASE64; + url.startsWith('/') || forceMediaToBase64 || serviceEnv.MULTIPLE_DATA_TO_BASE64; /** * 仅从短文本中识别媒体 URL。普通文档 URL 仍作为文本保留,不在这里转成 LLM 媒体输入。 diff --git a/packages/service/core/ai/sandbox/application/lease.ts b/packages/service/core/ai/sandbox/application/lease.ts index 2b5182dd3e79..93df8f4add27 100644 --- a/packages/service/core/ai/sandbox/application/lease.ts +++ b/packages/service/core/ai/sandbox/application/lease.ts @@ -3,7 +3,7 @@ * * 统一锁顺序为 Source Mutation Lease -> Sandbox Lifecycle Lease;调用方不得反向嵌套。 */ -import type { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { LeaseCache } from '@fastgpt/dal/redis/caches'; import type { RedisLeaseContext } from '@fastgpt/dal/redis/caches'; import { getLogger, LogCategories } from '../../../../common/logger'; @@ -15,6 +15,24 @@ const leaseCache = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) type LeaseTask = (context: RedisLeaseContext) => Promise; +/** + * 返回 Sandbox 所属业务源的 mutation 锁 key。 + * + * Workflow Builder 与普通工作流虽然使用不同的物理 Sandbox,但都归属于同一个 + * App。创建/恢复与 App 删除必须共享 App 锁,避免两个 source 的 mutation 操作并行。 + */ +const getSandboxSourceMutationLeaseKey = ({ + sourceType, + sourceId +}: { + sourceType: ChatSourceTypeEnum; + sourceId: string; +}) => { + const mutationSourceType = + sourceType === ChatSourceTypeEnum.workflowBuilder ? ChatSourceTypeEnum.app : sourceType; + return `agent-sandbox:source:${mutationSourceType}:${sourceId}`; +}; + /** 串行化同一 source 的首次创建、Legacy migration 和业务删除。 */ export const withSandboxSourceMutationLease = (params: { sourceType: ChatSourceTypeEnum; @@ -23,7 +41,7 @@ export const withSandboxSourceMutationLease = (params: { fn: LeaseTask; }) => leaseCache.withLease({ - key: `agent-sandbox:source:${params.sourceType}:${params.sourceId}`, + key: getSandboxSourceMutationLeaseKey(params), label: params.label, ttlMs: SANDBOX_SOURCE_MUTATION_LEASE_TTL_MS, fn: params.fn diff --git a/packages/service/core/ai/sandbox/application/preview.ts b/packages/service/core/ai/sandbox/application/preview.ts index 468f08a20092..7008170e6376 100644 --- a/packages/service/core/ai/sandbox/application/preview.ts +++ b/packages/service/core/ai/sandbox/application/preview.ts @@ -29,7 +29,9 @@ redis.call("expire", KEYS[1], ARGV[4]) return 1 `; -const SandboxPreviewSandboxIdSchema = z.string().regex(/^(?:app|skilledit)-[a-f0-9]{16}$/); +const SandboxPreviewSandboxIdSchema = z + .string() + .regex(/^(?:app|workflowbuilder|skilledit)-[a-f0-9]{16}$/); const SandboxPreviewSessionIdSchema = z .string() .length(SANDBOX_PREVIEW_SESSION_ID_LENGTH) diff --git a/packages/service/core/ai/sandbox/application/resource.ts b/packages/service/core/ai/sandbox/application/resource.ts index 66abaae204a7..163af7d9fd8d 100644 --- a/packages/service/core/ai/sandbox/application/resource.ts +++ b/packages/service/core/ai/sandbox/application/resource.ts @@ -231,10 +231,13 @@ const deleteSandboxResourceBatch = async (instances: SandboxResourceRef[]) => { /** 调用方持有 Source Mutation Lease 时,清理某个 App 的全部 v2 资源。 */ export const deleteAppSandboxes = async (appId: string) => { - const instances = await findSandboxResourcesBySource({ - sourceType: ChatSourceTypeEnum.app, - sourceId: appId - }); + const instances = ( + await Promise.all( + [ChatSourceTypeEnum.app, ChatSourceTypeEnum.workflowBuilder].map((sourceType) => + findSandboxResourcesBySource({ sourceType, sourceId: appId }) + ) + ) + ).flat(); await deleteSandboxResourceBatch(instances); }; diff --git a/packages/service/core/ai/sandbox/application/runtime/client.ts b/packages/service/core/ai/sandbox/application/runtime/client.ts index 4e19ea57d32d..929645cb76b0 100644 --- a/packages/service/core/ai/sandbox/application/runtime/client.ts +++ b/packages/service/core/ai/sandbox/application/runtime/client.ts @@ -434,7 +434,8 @@ export class SandboxClient { .then(() => this.provider.execute(command, { timeoutMs: timeout ? timeout * 1000 : undefined, - ...(this.sourceType === ChatSourceTypeEnum.app + ...(this.sourceType === ChatSourceTypeEnum.app || + this.sourceType === ChatSourceTypeEnum.workflowBuilder ? { workingDirectory: this.runtimePaths.sessionWorkDirectory } : {}) }) diff --git a/packages/service/core/ai/sandbox/application/sourceGuard.ts b/packages/service/core/ai/sandbox/application/sourceGuard.ts index a29732deeb1b..43dbbbdd569b 100644 --- a/packages/service/core/ai/sandbox/application/sourceGuard.ts +++ b/packages/service/core/ai/sandbox/application/sourceGuard.ts @@ -17,7 +17,10 @@ export async function assertSandboxSourceActive(params: { sourceId: string; }) { const active = await (async () => { - if (params.sourceType === ChatSourceTypeEnum.app) { + if ( + params.sourceType === ChatSourceTypeEnum.app || + params.sourceType === ChatSourceTypeEnum.workflowBuilder + ) { return MongoApp.exists({ _id: params.sourceId, deleteTime: null }); } if (params.sourceType === ChatSourceTypeEnum.skillEdit) { @@ -37,7 +40,10 @@ export async function assertSandboxSourceDeleted(params: { sourceId: string; }) { const deleted = await (async () => { - if (params.sourceType === ChatSourceTypeEnum.app) { + if ( + params.sourceType === ChatSourceTypeEnum.app || + params.sourceType === ChatSourceTypeEnum.workflowBuilder + ) { return MongoApp.exists({ _id: params.sourceId, deleteTime: { $ne: null } }); } if (params.sourceType === ChatSourceTypeEnum.skillEdit) { diff --git a/packages/service/core/ai/sandbox/infrastructure/instance/repository.ts b/packages/service/core/ai/sandbox/infrastructure/instance/repository.ts index 8dca5e45e430..9be99bcb8c05 100644 --- a/packages/service/core/ai/sandbox/infrastructure/instance/repository.ts +++ b/packages/service/core/ai/sandbox/infrastructure/instance/repository.ts @@ -96,7 +96,11 @@ const buildCurrentOperationFilter = (resource: SandboxResourceRef) => { }; const buildSandboxResourceSourceQuery = ({ sourceType, sourceId, userId }: SandboxSourceParams) => { - if (sourceType === ChatSourceTypeEnum.app || sourceType === ChatSourceTypeEnum.skillEdit) { + if ( + sourceType === ChatSourceTypeEnum.app || + sourceType === ChatSourceTypeEnum.workflowBuilder || + sourceType === ChatSourceTypeEnum.skillEdit + ) { return { sourceType, sourceId, ...(userId ? { userId } : {}) }; } if (sourceType === ChatSourceTypeEnum.chatAgentHelper) { diff --git a/packages/service/core/ai/sandbox/interface/runtime.ts b/packages/service/core/ai/sandbox/interface/runtime.ts index 411b17f69518..cfbad28c5338 100644 --- a/packages/service/core/ai/sandbox/interface/runtime.ts +++ b/packages/service/core/ai/sandbox/interface/runtime.ts @@ -38,6 +38,7 @@ export { preparePackageMirrors, prepareSandbox } from '../application/runtime/prepare'; +export { getSandboxRuntimeMirrorsConfig } from '../application/runtime/mirrors'; export type { SandboxPrepareContext, SandboxPrepareStep } from '../application/runtime/prepare'; export { resolveSandboxHome } from '../application/runtime/home'; export { getSafeSandboxInputFilename, joinSandboxPath } from '../utils'; @@ -75,7 +76,7 @@ export function buildSandboxClientQueryFromChatSource({ userId: sandboxUserId }); - if (sourceType === ChatSourceTypeEnum.app) { + if (sourceType === ChatSourceTypeEnum.app || sourceType === ChatSourceTypeEnum.workflowBuilder) { return { sandboxId, sourceType, diff --git a/packages/service/core/ai/sandbox/type.ts b/packages/service/core/ai/sandbox/type.ts index b4b934fb7059..92bffa015cbb 100644 --- a/packages/service/core/ai/sandbox/type.ts +++ b/packages/service/core/ai/sandbox/type.ts @@ -65,6 +65,7 @@ export const SandboxOperationTypeSchema = z.enum(sandboxOperationTypeList); export type SandboxOperationType = z.infer; export const SandboxSourceTypeSchema = z.enum([ ChatSourceTypeEnum.app, + ChatSourceTypeEnum.workflowBuilder, ChatSourceTypeEnum.skillEdit ]); export type SandboxSourceType = z.infer; diff --git a/packages/service/core/ai/sandbox/utils/id.ts b/packages/service/core/ai/sandbox/utils/id.ts index 4ebbf043bb2d..52ebd1fa5421 100644 --- a/packages/service/core/ai/sandbox/utils/id.ts +++ b/packages/service/core/ai/sandbox/utils/id.ts @@ -14,7 +14,7 @@ export const getSandboxUserId = ({ sourceType: ChatSourceTypeEnum; userId: string; }) => { - if (sourceType === ChatSourceTypeEnum.app) { + if (sourceType === ChatSourceTypeEnum.app || sourceType === ChatSourceTypeEnum.workflowBuilder) { return userId; } diff --git a/packages/service/core/ai/sandbox/utils/index.ts b/packages/service/core/ai/sandbox/utils/index.ts index b7702f54d104..1409318353b2 100644 --- a/packages/service/core/ai/sandbox/utils/index.ts +++ b/packages/service/core/ai/sandbox/utils/index.ts @@ -57,7 +57,7 @@ export const getSandboxRuntimePaths = ({ const workspaceRoot = trimSandboxPathRight(workDirectory); const runtimeSkillsRoot = joinSandboxPath(workspaceRoot, 'projects'); - if (sourceType === ChatSourceTypeEnum.app) { + if (sourceType === ChatSourceTypeEnum.app || sourceType === ChatSourceTypeEnum.workflowBuilder) { return { workspaceRoot, runtimeSkillsRoot, diff --git a/packages/service/core/app/tool/systemTool/capability.ts b/packages/service/core/app/tool/systemTool/capability.ts new file mode 100644 index 000000000000..020d365c2b31 --- /dev/null +++ b/packages/service/core/app/tool/systemTool/capability.ts @@ -0,0 +1,231 @@ +import { replaceRegChars } from '@fastgpt/global/common/string/tools'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { + getTeamPluginSource, + isDebugToolSource, + isTeamPluginSource +} from '@fastgpt/global/core/app/tool/utils'; +import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { + FlowNodeTemplateType, + NodeTemplateListItemType +} from '@fastgpt/global/core/workflow/type/node'; +import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type'; +import pLimit from 'p-limit'; +import { getUserDetail } from '../../../../support/user/controller'; +import { pluginClient } from '../../../../thirdProvider/fastgptPlugin'; +import { getClientToolPreviewNode } from '../utils/client'; +import { + assertTeamPluginSourceAccess, + getRawPluginIdFromSystemToolId, + getTeamPluginPolicyMap, + resolveTeamPluginList +} from '../../../plugin/teamPluginPolicy'; +import { SystemToolRepo } from './systemTool.repo'; + +export type AuthorizedSystemToolListItem = NodeTemplateListItemType & { source: string }; + +export type AuthorizedSystemToolTemplate = { + toolId: string; + source: string; + template: FlowNodeTemplateType; +}; + +type SystemToolCapabilityContext = { + teamId: string; + tmbId: string; + isRoot: boolean; + lang: localeType; +}; + +type SystemToolTemplateListParams = SystemToolCapabilityContext & { + tags?: string[]; + parentId?: string; + searchKey?: string; + source?: string; +}; + +const getSearchRegex = (searchKey?: string) => { + const trimmedSearchKey = searchKey?.trim(); + if (!trimmedSearchKey) return; + return new RegExp(replaceRegChars(trimmedSearchKey), 'i'); +}; + +/** Agent 工具选择列表只展示仍可新增配置的工具。 */ +const isSelectableToolStatus = (status?: PluginStatusType) => + status === undefined || status === PluginStatusEnum.Normal; + +const filterTemplateBySearchKey = ( + template: NodeTemplateListItemType & { toolDescription?: string }, + searchRegex?: RegExp +) => { + if (!searchRegex) return true; + return [ + template.name, + template.intro, + template.instructions, + template.toolDescription, + ...(template.tags ?? []) + ].some((text) => searchRegex.test(String(text ?? ''))); +}; + +const getActiveDebugSource = async ({ tmbId, source }: { tmbId: string; source?: string }) => { + const status = await pluginClient.getDebugSessionStatus({ tmbId }).catch(() => undefined); + if ( + status?.enabled && + (status.status === 'enabled' || status.status === 'connected') && + isDebugToolSource(status.source) && + (!source || status.source === source) + ) { + return status.source; + } +}; + +/** + * 按当前 Web 规则返回用户可选择的系统工具目录。 + * source 是模板身份的一部分,调用方不得只按 toolId 去重。 + */ +const getSystemToolTemplateList = async ({ + teamId, + tmbId, + isRoot, + lang, + userTags, + tags, + parentId, + searchKey, + source +}: SystemToolTemplateListParams & { userTags: string[] }): Promise< + AuthorizedSystemToolListItem[] +> => { + const systemToolRepo = SystemToolRepo.getInstance(); + const searchRegex = getSearchRegex(searchKey); + + if (parentId) { + if (isTeamPluginSource(source)) { + await assertTeamPluginSourceAccess({ + teamId, + source, + pluginId: getRawPluginIdFromSystemToolId(parentId) + }); + } + const parentSource = isDebugToolSource(source) + ? await getActiveDebugSource({ tmbId, source }) + : source; + const parent = await systemToolRepo.getSystemToolDisplayInfoWithChildIcons({ + pluginId: parentId, + lang, + source: parentSource ?? 'system' + }); + if (!isSelectableToolStatus(parent.status)) return []; + + return ( + parent.children + ?.filter((child) => isSelectableToolStatus(child.status)) + .map((child) => ({ + ...parent, + templateType: FlowNodeTemplateTypeEnum.tools, + flowNodeType: FlowNodeTypeEnum.tool, + name: child.name, + intro: child.description, + toolDescription: child.toolDescription, + id: `${parentId}/${child.id}`, + source: isTeamPluginSource(source) ? source : parent.source, + avatar: child.icon ?? parent.avatar, + currentCost: child.currentCost, + systemKeyCost: child.systemKeyCost, + hasTokenFee: parent.hasTokenFee, + status: child.status + })) + .filter((item) => filterTemplateBySearchKey(item, searchRegex)) ?? [] + ); + } + + const debugSource = await getActiveDebugSource({ tmbId }); + const [tools, policyMap] = await Promise.all([ + systemToolRepo.getSystemToolList({ + lang, + op: 'or', + sources: ['system', getTeamPluginSource(teamId), ...(debugSource ? [debugSource] : [])], + tags + }), + getTeamPluginPolicyMap(teamId) + ]); + + return resolveTeamPluginList({ + teamId, + tools, + policyMap, + canManage: false + }) + .sort((a, b) => Number(isDebugToolSource(b.source)) - Number(isDebugToolSource(a.source))) + .filter((item) => { + if (!isSelectableToolStatus(item.status)) return false; + if (isRoot) return true; + return !item.hideTags?.some((tag) => userTags.includes(tag)); + }) + .map((tool) => ({ + ...tool, + templateType: FlowNodeTemplateTypeEnum.tools, + flowNodeType: tool.isToolSet ? FlowNodeTypeEnum.toolSet : FlowNodeTypeEnum.tool, + source: tool.source, + name: tool.name, + intro: tool.intro, + instructions: tool.userGuide ?? '', + toolDescription: tool.toolDescription, + tags: tool.tags + })) + .filter((item) => filterTemplateBySearchKey(item, searchRegex)); +}; + +export const listAuthorizedSystemToolTemplates = async ( + params: SystemToolTemplateListParams +): Promise => { + const userDetail = await getUserDetail({ tmbId: params.tmbId }); + return getSystemToolTemplateList({ + ...params, + userTags: userDetail.tags ?? [] + }); +}; + +/** 为 Workflow Builder 生成当前请求可用的完整脱敏系统工具模板。 */ +export const getAuthorizedSystemToolTemplateCatalog = async ( + context: SystemToolCapabilityContext +): Promise => { + const userDetail = await getUserDetail({ tmbId: context.tmbId }); + const userTags = userDetail.tags ?? []; + const topLevelTemplates = await getSystemToolTemplateList({ ...context, userTags }); + const childTemplateGroups = await Promise.all( + topLevelTemplates + .filter((item) => item.flowNodeType === FlowNodeTypeEnum.toolSet) + .map((item) => + getSystemToolTemplateList({ + ...context, + userTags, + parentId: item.id, + source: item.source + }) + ) + ); + const templates = [...topLevelTemplates, ...childTemplateGroups.flat()]; + const uniqueTemplates = [ + ...new Map(templates.map((item) => [`${item.source}\u0000${item.id}`, item])).values() + ]; + const limit = pLimit(8); + + return Promise.all( + uniqueTemplates.map((item) => + limit(async () => ({ + toolId: item.id, + source: item.source, + template: await getClientToolPreviewNode({ + appId: item.id, + source: item.source, + getLatestVersion: true, + lang: context.lang + }) + })) + ) + ); +}; diff --git a/packages/service/core/app/tool/utils/client.ts b/packages/service/core/app/tool/utils/client.ts index fa9789752fb7..980581161de7 100644 --- a/packages/service/core/app/tool/utils/client.ts +++ b/packages/service/core/app/tool/utils/client.ts @@ -228,7 +228,11 @@ export async function getClientSystemToolPreviewNode({ isFolder: !isWorkflowTool && toolDetail.isToolSet, status: displayStatus, // 工具预览是首次加入工作流/Agent,使用 schema 声明的默认输入方式。 - inputs: initToolInputsTypeByDefaultMode(inputs, { forceDefaultMode: true }), + // 与普通工作流工具保持一致的 userChatInput 处理,避免该入参被固化为手动输入而误显示引用数组。 + inputs: initToolInputsTypeByDefaultMode(inputs, { + forceDefaultMode: true, + allowUserChatInputAgentGenerated: true + }), outputs: schemaOutputs ? schemaOutputs.some((item) => item.type === FlowNodeOutputTypeEnum.error) diff --git a/packages/service/core/chat/fileContext.ts b/packages/service/core/chat/fileContext.ts index ba89abbddd32..de6647117763 100644 --- a/packages/service/core/chat/fileContext.ts +++ b/packages/service/core/chat/fileContext.ts @@ -30,6 +30,10 @@ import { batchRun } from '@fastgpt/global/common/system/utils'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { FileSource } from '../../common/file/read/source'; import { createExternalHttpFileSource, createS3FileSource } from '../../common/file/read/source'; +import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat'; +import { createChatFilePreviewUrlGetter } from '../../common/s3/sources/chat'; +import { isAuthorizedChatFileS3Key } from '../../common/s3/sources/chat/key'; +import type { ChatS3SourceType } from '../../common/s3/sources/chat/type'; /** Workflow 等上层业务可显式注入的已授权文件读取能力。 */ export type FileReadContext = { @@ -63,6 +67,59 @@ type GetFileProps = { fileContext?: FileReadContext; }; +type AuthorizedChatFileInput = { + key?: string; + url?: string; +}; + +/** + * 校验聊天上传文件是否属于当前来源、成员和会话,并用服务端签名 URL 覆盖客户端 URL。 + * + * 客户端 URL 只用于上传完成后的即时预览,不能作为运行时可信文件来源;所有正式运行入口 + * 都必须依赖 S3 key 完成作用域校验后重新签名。 + */ +export const resolveAuthorizedChatFiles = async ({ + files, + sourceType, + sourceId, + uid, + chatId, + getPreviewUrl +}: { + files: File[]; + sourceType: ChatS3SourceType; + sourceId: string; + uid: string; + chatId: string; + getPreviewUrl?: (key: string) => Promise; +}): Promise> => { + if (files.length === 0) return []; + + const resolvePreviewUrl = getPreviewUrl ?? createChatFilePreviewUrlGetter(); + + return Promise.all( + files.map(async (file) => { + if ( + !file.key || + !isAuthorizedChatFileS3Key({ + key: file.key, + sourceType, + sourceId, + uid, + chatId + }) + ) { + throw ChatErrEnum.unAuthChat; + } + + return { + ...file, + key: file.key, + url: await resolvePreviewUrl(file.key) + }; + }) + ); +}; const fileTypeIncludesExtension = (fileTypes: string, extension: string) => !!extension && fileTypes.split(',').some((item) => item.trim() === extension); diff --git a/packages/service/core/chat/source.ts b/packages/service/core/chat/source.ts index a8577915726d..fc287a9c3e62 100644 --- a/packages/service/core/chat/source.ts +++ b/packages/service/core/chat/source.ts @@ -35,7 +35,8 @@ export function buildChatSourceQuery({ sourceType, sourceId }: ChatSourceParams) if ( sourceType === ChatSourceTypeEnum.skillEdit || - sourceType === ChatSourceTypeEnum.chatAgentHelper + sourceType === ChatSourceTypeEnum.chatAgentHelper || + sourceType === ChatSourceTypeEnum.workflowBuilder ) { return { appId: sourceId, diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts index 0f4bf50c9665..8da1cafc70af 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts @@ -1,5 +1,6 @@ export { createBuiltinSkillPrepareAction, ensureAgentSandboxRuntime, - type AgentSandboxPrepareAction + type AgentSandboxPrepareAction, + type AgentSandboxPrepareContext } from './prepare'; diff --git a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/run.ts b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/run.ts index 8259bf162feb..dd9316322b8b 100644 --- a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/run.ts +++ b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/run.ts @@ -1,5 +1,10 @@ +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall +} from '@fastgpt/global/core/ai/llm/type'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { + AgentLoopEvent, AgentLoopInput, AgentLoopProviderName, AgentLoopRuntime @@ -15,6 +20,12 @@ export type RunAgentLoopCoreParams = { provider?: AgentLoopProviderName; input: AgentLoopInput; runtime: AgentLoopRuntime; + bufferedToolEventFilter?: { + /** 需要等完整调用参数到达后再决定是否对外发送事件的工具。 */ + shouldBuffer: (call: ChatCompletionMessageToolCall) => boolean; + /** 返回 true 时,该工具调用的整组生命周期事件只供内部执行,不向外暴露。 */ + shouldDiscard: (call: ChatCompletionMessageToolCall) => boolean; + }; assistantResponses?: { extraResponses?: AIChatItemValueItemType[]; eventTarget?: AIChatItemValueItemType[]; @@ -28,6 +39,39 @@ export type RunAgentLoopCoreParams = { }; }; +/** 从对外 transcript 中成对移除内部工具调用及其响应,避免后续总结再次暴露内部错误。 */ +const discardToolMessages = ( + messages: ChatCompletionMessageParam[], + discardedToolCallIds: Set +): ChatCompletionMessageParam[] => { + if (discardedToolCallIds.size === 0) return messages; + + return messages.flatMap((message) => { + if (message.role === 'tool' && discardedToolCallIds.has(message.tool_call_id)) return []; + if (message.role !== 'assistant' || !message.tool_calls?.length) return [message]; + + const visibleToolCalls = message.tool_calls.filter( + (call) => !discardedToolCallIds.has(call.id) + ); + if (visibleToolCalls.length > 0) { + return [{ ...message, tool_calls: visibleToolCalls }]; + } + + const { tool_calls: _toolCalls, ...messageWithoutToolCalls } = message; + const hasOtherAssistantOutput = + message.content !== null && message.content !== undefined + ? true + : Boolean( + message.reasoning_content || + message.interactive || + message.audio || + message.function_call || + message.refusal + ); + return hasOtherAssistantOutput ? [messageWithoutToolCalls] : []; + }); +}; + /** * Workflow dispatch 侧统一运行 agent-loop 的核心入口。 * @@ -39,6 +83,7 @@ export const runAgentLoopCore = async ({ provider, input, runtime, + bufferedToolEventFilter, assistantResponses }: RunAgentLoopCoreParams): Promise> => { const eventAssistantResponses = assistantResponses?.eventTarget ?? []; @@ -48,33 +93,119 @@ export const runAgentLoopCore = async ({ getToolInfo: assistantResponses?.getEventToolInfo, metaEventNames: assistantResponses?.metaEventNames }); + const forwardEvent = (event: AgentLoopEvent) => { + assistantEventCollector.emitEvent(event); + runtime.emitEvent?.(event); + }; + const bufferedToolEvents = new Map(); + const discardedToolCallIds = new Set(); + const discardedResultToolCallIds = new Set(); + + /** + * 工具参数可能在 `tool_call` 之后才流式补齐。候选工具先缓冲到 `tool_run_start`, + * 再用完整参数判断整组事件应当转发还是仅保留在 agent-loop 内部。 + */ + const emitEvent = (event: AgentLoopEvent) => { + if (!bufferedToolEventFilter) { + forwardEvent(event); + return; + } + + if (event.type === 'llm_request_end' && event.toolCalls?.length) { + const visibleToolCalls = event.toolCalls.filter((call) => { + const shouldDiscard = + bufferedToolEventFilter.shouldBuffer(call) && bufferedToolEventFilter.shouldDiscard(call); + if (shouldDiscard) discardedResultToolCallIds.add(call.id); + return !shouldDiscard; + }); + forwardEvent({ + ...event, + toolCalls: visibleToolCalls + }); + return; + } + + if (event.type === 'tool_call') { + if (!bufferedToolEventFilter.shouldBuffer(event.call)) { + forwardEvent(event); + return; + } + bufferedToolEvents.set(event.call.id, [event]); + return; + } + + if (event.type === 'tool_params') { + if (discardedToolCallIds.has(event.callId)) return; + + const bufferedEvents = bufferedToolEvents.get(event.callId); + if (bufferedEvents) { + bufferedEvents.push(event); + return; + } + forwardEvent(event); + return; + } + + if (event.type === 'tool_run_start' || event.type === 'tool_run_end') { + const callId = event.call.id; + if (discardedToolCallIds.has(callId)) { + if (event.type === 'tool_run_end') discardedToolCallIds.delete(callId); + return; + } + + const bufferedEvents = bufferedToolEvents.get(callId); + const isBufferedTool = + bufferedEvents !== undefined || bufferedToolEventFilter.shouldBuffer(event.call); + if (!isBufferedTool) { + forwardEvent(event); + return; + } + + if (bufferedToolEventFilter.shouldDiscard(event.call)) { + bufferedToolEvents.delete(callId); + discardedResultToolCallIds.add(callId); + if (event.type === 'tool_run_start') discardedToolCallIds.add(callId); + return; + } + + bufferedEvents?.forEach(forwardEvent); + bufferedToolEvents.delete(callId); + forwardEvent(event); + return; + } + + forwardEvent(event); + }; + const wrappedRuntime: AgentLoopRuntime = { ...runtime, - emitEvent: (event) => { - assistantEventCollector.emitEvent(event); - runtime.emitEvent?.(event); - } + emitEvent }; const result = await runAgentLoop({ provider, input, runtime: wrappedRuntime }); + const publicResult = { + ...result, + completeMessages: discardToolMessages(result.completeMessages, discardedResultToolCallIds), + assistantMessages: discardToolMessages(result.assistantMessages, discardedResultToolCallIds) + }; const assistantResponseValues = compactAgentLoopCorePlanSnapshots([ ...(assistantResponses?.extraResponses ?? []), ...eventAssistantResponses ]); - if (result.status === 'paused') { + if (publicResult.status === 'paused') { return { - ...result, + ...publicResult, status: 'interactive', assistantResponses: assistantResponseValues }; } return { - ...result, + ...publicResult, assistantResponses: assistantResponseValues }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/runtime/systemTools.ts b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/runtime/systemTools.ts index a11879ff580e..06f4e0633562 100644 --- a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/runtime/systemTools.ts +++ b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/runtime/systemTools.ts @@ -1,4 +1,7 @@ -import type { AgentLoopSystemTools } from '../../../../../../ai/llm/agentLoop/interface'; +import type { + AgentLoopSandboxExecutor, + AgentLoopSystemTools +} from '../../../../../../ai/llm/agentLoop/interface'; import type { SandboxClient } from '../../../../../../ai/sandbox/interface/runtime'; import type { AgentLoopDatasetSearchExecutor } from '../../../../../../ai/llm/agentLoop/interface'; import type { AgentLoopReadFileExecutor } from '../../../../../../ai/llm/agentLoop/interface'; @@ -7,6 +10,7 @@ export type CreateAgentLoopCoreSystemToolsParams = { planEnabled: boolean; askEnabled: boolean; sandboxClient?: SandboxClient; + sandboxExecutor?: AgentLoopSandboxExecutor; readFile?: { enabled: boolean; maxFileAmount: number; @@ -29,6 +33,7 @@ export const createAgentLoopCoreSystemTools = ({ planEnabled, askEnabled, sandboxClient, + sandboxExecutor, readFile, datasetSearch }: CreateAgentLoopCoreSystemToolsParams): AgentLoopSystemTools => ({ @@ -38,11 +43,12 @@ export const createAgentLoopCoreSystemTools = ({ ask: { enabled: askEnabled }, - ...(sandboxClient + ...(sandboxClient || sandboxExecutor ? { sandbox: { enabled: true, - client: sandboxClient + ...(sandboxClient ? { client: sandboxClient } : {}), + ...(sandboxExecutor ? { executor: sandboxExecutor } : {}) } } : {}), diff --git a/packages/service/test/common/s3/sources/chat.test.ts b/packages/service/test/common/s3/sources/chat.test.ts index cf8c97dba2aa..4d6bbccb2f52 100644 --- a/packages/service/test/common/s3/sources/chat.test.ts +++ b/packages/service/test/common/s3/sources/chat.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { ChatFileUploadSchema } from '@fastgpt/service/common/s3/sources/chat/type'; const { S3ChatSource } = await vi.importActual< typeof import('@fastgpt/service/common/s3/sources/chat') @@ -49,3 +51,17 @@ describe('S3ChatSource.parseChatUrl', () => { }); }); }); + +describe('ChatFileUploadSchema', () => { + it('accepts workflow builder chat archives', () => { + const result = ChatFileUploadSchema.safeParse({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: '67f4c91c79a4d61b1f116b2a', + chatId: 'chat-1', + uId: 'user-1', + filename: 'input.txt' + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts b/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts index 0035add9cc23..c0a474f817f8 100644 --- a/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts +++ b/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts @@ -80,6 +80,9 @@ describe('runAuxiliaryGenerationAgentLoop', () => { expect(runAgentLoopMock).toHaveBeenCalledWith( expect.objectContaining({ runtime: expect.objectContaining({ + llmParams: expect.objectContaining({ + forceMediaToBase64: true + }), systemTools: { ask: { enabled: true } }, diff --git a/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts b/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts index 6f8c67a1982a..dcc798e64485 100644 --- a/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts +++ b/packages/service/test/core/ai/llm/agentLoop/_mocks/llmQueue.ts @@ -36,14 +36,17 @@ export const toolCall = ({ id, name, args = {}, + content, reasoning }: { id?: string; name: string; args?: string | Record; + content?: string; reasoning?: string; }): LLMQueueItem => ({ requestId: `req_${id || name}`, + answerText: content, finishReason: 'tool_calls', reasoningText: reasoning || '', toolCalls: [ diff --git a/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts index d3559c75e725..e08f660543db 100644 --- a/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts @@ -164,6 +164,113 @@ describe('runFastAgentMainLoop', () => { expect(mainAgentPrompt).toContain('你是一个 Work Agent'); }); + it('continues the same loop when completion policy rejects a natural completion', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ requestId: 'req_early', content: 'phase summary' }), + text({ requestId: 'req_terminal', content: 'terminal answer' }) + ]); + const completionPolicy = vi + .fn() + .mockResolvedValueOnce({ + action: 'continue', + message: 'Continue until terminal facts exist.' + }) + .mockResolvedValueOnce({ action: 'complete' }); + + const result = await runFastAgentMainLoop({ + runtime: createRuntime({ completionPolicy }), + input: { + messages: [{ role: ChatCompletionRequestMessageRoleEnum.User, content: 'build it' }] + } + }); + + expect(result.status).toBe('done'); + expect(createLLMResponseMock).toHaveBeenCalledTimes(2); + expect(completionPolicy).toHaveBeenNthCalledWith(1, { requestIndex: 1 }); + expect(completionPolicy).toHaveBeenNthCalledWith(2, { requestIndex: 2 }); + expect(createLLMResponseMock.mock.calls[1][0].body.messages).toContainEqual({ + role: ChatCompletionRequestMessageRoleEnum.System, + content: 'Continue until terminal facts exist.' + }); + }); + + it('returns an error when completion policy cannot reach terminal facts within the run budget', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ requestId: 'req_budget', content: 'still incomplete' }) + ]); + + const result = await runFastAgentMainLoop({ + runtime: createRuntime({ + maxRunAgentTimes: 1, + completionPolicy: () => ({ action: 'continue', message: 'Continue.' }) + }), + input: { + messages: [{ role: ChatCompletionRequestMessageRoleEnum.User, content: 'build it' }] + } + }); + + expect(result.status).toBe('error'); + expect(String(result.error)).toContain('Agent loop reached max run times: 1'); + expect(createLLMResponseMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + name: 'no runtime-capable tools', + toolCatalog: { runtimeTools: [] }, + expectedConstraint: true + }, + { + name: 'sandbox tools only', + toolCatalog: { runtimeTools: [], sandboxTools: [tool('sandbox_shell')] }, + expectedConstraint: false + }, + { + name: 'read file tool only', + toolCatalog: { runtimeTools: [], readFileTool: tool('read_files') }, + expectedConstraint: false + }, + { + name: 'dataset search tool only', + toolCatalog: { runtimeTools: [], datasetSearchTool: tool('dataset_search') }, + expectedConstraint: false + } + ] satisfies Array<{ + name: string; + toolCatalog: AgentLoopRuntime['toolCatalog']; + expectedConstraint: boolean; + }>)( + 'sets the runtime tool constraint correctly with $name', + async ({ toolCatalog, expectedConstraint }) => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_runtime_tool_constraint', + content: 'direct answer' + }) + ]); + + await runFastAgentMainLoop({ + runtime: createRuntime({ + toolCatalog, + hasExecutableTools: !expectedConstraint, + systemPromptBuilder: ({ hasExecutableTools }) => + hasExecutableTools ? '' : 'Use a tool.' + }), + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'hello' + } + ] + } + }); + + const mainAgentPrompt = createLLMResponseMock.mock.calls[0][0].body.messages[0].content; + expect(mainAgentPrompt.includes('')).toBe(expectedConstraint); + } + ); + it('creates an active plan through set_plan', async () => { const events: any[] = []; mockCreateLLMResponseQueue(createLLMResponseMock, [ @@ -612,6 +719,60 @@ describe('runFastAgentMainLoop', () => { expect(createLLMResponseMock).toHaveBeenCalledTimes(2); }); + it('returns an injected ask validation error to the model without pausing', async () => { + const validateAsk = vi.fn(() => 'Preview markdown is required.'); + mockCreateLLMResponseQueue(createLLMResponseMock, [ + toolCall({ + id: 'call_invalid_preview', + name: 'ask_user', + content: '```mermaid\nflowchart LR\n A --> B\n```', + args: { + reason: 'Need workflow confirmation', + blockerType: 'user_choice', + questions: [ + { + question: 'Confirm this workflow?', + options: [ + { summary: 'Confirm', value: 'Confirm the workflow.' }, + { summary: 'Revise', value: 'Revise the workflow.' } + ] + } + ] + } + }), + text({ + requestId: 'req_after_preview_validation', + content: 'Recovered after preview validation.' + }) + ]); + + const result = await runFastAgentMainLoop({ + runtime: createRuntime({ validateAsk }), + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'Build a workflow' + } + ] + } + }); + + expect(result.status).toBe('done'); + expect(validateAsk).toHaveBeenCalledWith( + expect.objectContaining({ + questions: [expect.objectContaining({ question: 'Confirm this workflow?' })] + }), + { assistantContent: '```mermaid\nflowchart LR\n A --> B\n```' } + ); + expect(result.completeMessages).toContainEqual({ + role: 'tool', + tool_call_id: 'call_invalid_preview', + content: 'Preview markdown is required.' + }); + expect(getFinalAssistantText(result)).toBe('Recovered after preview validation.'); + }); + it('normalizes empty ask_agent resume answer to none in tool message', async () => { mockCreateLLMResponseQueue(createLLMResponseMock, [ text({ diff --git a/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts index 8801c188819b..b4e2ffed4667 100644 --- a/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts @@ -207,6 +207,82 @@ describe('runFastAgentLoop', () => { expect(toolNames.some((name: string) => name.startsWith('sandbox_'))).toBe(true); }); + it('does not disable executable tools for a sandbox-only runtime', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_sandbox_only', + content: 'direct answer' + }) + ]); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'inspect workspace' + } + ], + systemPrompt: 'Sandbox is available.' + }, + runtime: createRuntime({ + toolCatalog: { + runtimeTools: [] + }, + systemTools: { + sandbox: { + enabled: true, + client: {} as any + } + } + }) + }); + + const systemMessage = createLLMResponseMock.mock.calls[0][0].body.messages.find( + (message: { role: string }) => message.role === ChatCompletionRequestMessageRoleEnum.System + ); + expect(systemMessage.content).toBe('Sandbox is available.'); + expect(systemMessage.content).not.toContain(''); + }); + + it('uses the injected system prompt builder', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_custom_prompt', + content: 'direct answer' + }) + ]); + const systemPromptBuilder = vi.fn( + ({ systemPrompt, hasExecutableTools }) => + `Workflow Builder\n${systemPrompt}\ntools=${hasExecutableTools}` + ); + + await runFastAgentLoop({ + input: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: 'build workflow' + } + ], + systemPrompt: 'runtime context' + }, + runtime: createRuntime({ + systemPromptBuilder + }) + }); + + const systemMessage = createLLMResponseMock.mock.calls[0][0].body.messages.find( + (message: { role: string }) => message.role === ChatCompletionRequestMessageRoleEnum.System + ); + expect(systemPromptBuilder).toHaveBeenCalledWith({ + systemPrompt: 'runtime context', + hasExecutableTools: true + }); + expect(systemMessage.content).toBe('Workflow Builder\nruntime context\ntools=true'); + expect(systemMessage.content).not.toContain('你是 Work Agent'); + }); + it('initializes runtime plan from history without changing request messages', async () => { const activePlan = { planId: 'plan_resume', diff --git a/packages/service/test/core/ai/llm/agentLoop/mainPrompt.test.ts b/packages/service/test/core/ai/llm/agentLoop/mainPrompt.test.ts index 00e7f0443adc..6a5cf1329bb4 100644 --- a/packages/service/test/core/ai/llm/agentLoop/mainPrompt.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/mainPrompt.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { buildDefaultAgentSystemPrompt } from '@fastgpt/service/core/ai/llm/agentLoop/interface'; +import { + buildDefaultAgentSystemPrompt, + resolveAgentLoopSystemPrompt +} from '@fastgpt/service/core/ai/llm/agentLoop/interface'; describe('buildDefaultAgentSystemPrompt', () => { it('uses the fixed prompt without capability-specific rules', () => { @@ -64,4 +67,25 @@ describe('buildDefaultAgentSystemPrompt', () => { '' ); }); + + it('uses an injected prompt builder without applying the default Work Agent prompt', () => { + const prompt = resolveAgentLoopSystemPrompt({ + systemPrompt: 'runtime context', + hasExecutableTools: true, + systemPromptBuilder: ({ systemPrompt, hasExecutableTools }) => + `Workflow Builder\n${systemPrompt}\ntools=${hasExecutableTools}` + }); + + expect(prompt).toBe('Workflow Builder\nruntime context\ntools=true'); + expect(prompt).not.toContain('你是一个 Work Agent'); + }); + + it('preserves the caller prompt when no builder is injected', () => { + expect( + resolveAgentLoopSystemPrompt({ + systemPrompt: 'prepared prompt', + hasExecutableTools: false + }) + ).toBe('prepared prompt'); + }); }); diff --git a/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts index eb865feba690..3c475a169b88 100644 --- a/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import type { AgentLoopSystemTools } from '@fastgpt/service/core/ai/llm/agentLoop/domain'; const { agentPromptMock, @@ -37,6 +38,7 @@ const { name: string; callId: string; args: Record; + assistantContent?: string; } | undefined }, @@ -136,6 +138,17 @@ vi.mock('@mariozechner/pi-agent-core', () => ({ } if (agentToolToExecute.value) { const pendingTool = agentToolToExecute.value; + if (pendingTool.assistantContent) { + subscribers.forEach((subscriber) => { + subscriber({ + type: 'message_update', + assistantMessageEvent: { + type: 'text_delta', + delta: pendingTool.assistantContent + } + }); + }); + } subscribers.forEach((subscriber) => { subscriber({ type: 'message_update', @@ -179,6 +192,9 @@ vi.mock('@mariozechner/pi-agent-core', () => ({ message: { role: 'assistant', content: [ + ...(pendingTool.assistantContent + ? [{ type: 'text', text: pendingTool.assistantContent }] + : []), { type: 'toolCall', id: pendingTool.callId, @@ -358,6 +374,107 @@ describe('runPiAgentLoop', () => { ]); }); + it('continues the same pi agent when completion policy rejects a natural completion', async () => { + const completionPolicy = vi + .fn() + .mockResolvedValueOnce({ + action: 'continue', + message: 'Continue until terminal facts exist.' + }) + .mockResolvedValueOnce({ action: 'complete' }); + + const result = await runPiAgentLoop({ + input: { messages: [{ role: 'user', content: 'build it' }] }, + runtime: { + teamId: 'team_1', + llmParams: { model: 'gpt-5' }, + completionPolicy, + toolCatalog: { runtimeTools: [] }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(result.status).toBe('done'); + expect(agentPromptMock).toHaveBeenNthCalledWith(1, 'build it'); + expect(agentPromptMock).toHaveBeenNthCalledWith(2, 'Continue until terminal facts exist.'); + expect(completionPolicy).toHaveBeenNthCalledWith(1, { requestIndex: 1 }); + expect(completionPolicy).toHaveBeenNthCalledWith(2, { requestIndex: 2 }); + expect(result.completeMessages).toContainEqual({ + role: 'user', + content: 'Continue until terminal facts exist.' + }); + }); + + it.each([ + { + name: 'no runtime-capable tools', + systemTools: undefined, + expectedConstraint: true + }, + { + name: 'sandbox tools only', + systemTools: { + sandbox: { + enabled: true, + client: {} as any + } + }, + expectedConstraint: false + }, + { + name: 'read file tool only', + systemTools: { + readFile: { + enabled: true, + maxFileAmount: 20, + execute: vi.fn() + } + }, + expectedConstraint: false + }, + { + name: 'dataset search tool only', + systemTools: { + datasetSearch: { + enabled: true, + execute: vi.fn() + } + }, + expectedConstraint: false + } + ] satisfies Array<{ + name: string; + systemTools?: AgentLoopSystemTools; + expectedConstraint: boolean; + }>)( + 'sets the runtime tool constraint correctly with $name', + async ({ systemTools, expectedConstraint }) => { + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'hello' }] + }, + runtime: { + llmParams: { + model: 'gpt-5' + }, + systemTools, + systemPromptBuilder: ({ hasExecutableTools }) => + hasExecutableTools ? '' : 'Use a tool.', + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect( + agentConstructorArgs.at(-1).initialState.systemPrompt.includes('') + ).toBe(expectedConstraint); + } + ); + it('preserves multimodal content in the current user prompt', async () => { await runPiAgentLoop({ input: { @@ -493,6 +610,40 @@ describe('runPiAgentLoop', () => { }); }); + it('uses the injected system prompt builder', async () => { + const systemPromptBuilder = vi.fn( + ({ systemPrompt, hasExecutableTools }) => + `Workflow Builder\n${systemPrompt}\ntools=${hasExecutableTools}` + ); + + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'build workflow' }], + systemPrompt: 'runtime context' + }, + runtime: { + llmParams: { + model: 'gpt-5', + stream: true + }, + systemPromptBuilder, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn() + } + }); + + expect(systemPromptBuilder).toHaveBeenCalledWith({ + systemPrompt: 'runtime context', + hasExecutableTools: false + }); + expect(agentConstructorArgs[0].initialState.systemPrompt).toBe( + 'Workflow Builder\nruntime context\ntools=false' + ); + expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain('你是 Work Agent'); + }); + it('reuses the compressed transcript for later requests in the same pi run', async () => { const events: any[] = []; agentRunTransformContext.value = true; @@ -568,6 +719,25 @@ describe('runPiAgentLoop', () => { expect(events.filter((event) => event.type === 'llm_request_end')).toHaveLength(1); }); + it('returns an error when completion policy cannot reach terminal facts within the run budget', async () => { + const result = await runPiAgentLoop({ + input: { messages: [{ role: 'user', content: 'build it' }] }, + runtime: { + teamId: 'team_1', + llmParams: { model: 'gpt-5' }, + maxRunAgentTimes: 1, + completionPolicy: () => ({ action: 'continue', message: 'Continue.' }), + toolCatalog: { runtimeTools: [] }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(result.status).toBe('error'); + expect(String(result.error)).toContain('Agent loop reached max run times: 1'); + expect(agentPromptMock).toHaveBeenCalledTimes(1); + }); + it('closes a started LLM request when pi-agent-core ends before message_end', async () => { const events: any[] = []; agentFailAfterPayload.value = 'network unavailable'; @@ -646,6 +816,42 @@ describe('runPiAgentLoop', () => { ]); }); + it('does not disable executable tools for a sandbox-only runtime', async () => { + await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: 'inspect workspace' }], + systemPrompt: 'Sandbox is available.' + }, + runtime: { + teamId: 'team_1', + llmParams: { + model: 'gpt-5' + }, + systemTools: { + sandbox: { + enabled: true, + client: {} as any + } + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(agentConstructorArgs.at(-1).initialState.systemPrompt).toBe('Sandbox is available.'); + expect(agentConstructorArgs.at(-1).initialState.systemPrompt).not.toContain( + '' + ); + expect( + agentConstructorArgs + .at(-1) + .initialState.tools.some((tool: { name: string }) => tool.name === 'sandbox_read_file') + ).toBe(true); + }); + it('emits one successful plan operation with the complete plan', async () => { const events: any[] = []; agentToolToExecute.value = { @@ -1453,6 +1659,55 @@ describe('runPiAgentLoop', () => { ); }); + it('returns an injected ask validation error without creating a pause', async () => { + const validate = vi.fn(() => 'Preview markdown is required.'); + agentToolToExecute.value = { + name: 'ask_user', + callId: 'call_invalid_preview', + args: { + reason: '需要确认流程拓扑', + blockerType: 'user_choice', + questions: [ + { + question: '请确认工作流', + options: [ + { summary: '确认', value: '确认当前工作流。' }, + { summary: '修改', value: '修改当前工作流。' } + ] + } + ] + }, + assistantContent: '```mermaid\nflowchart LR\n A --> B\n```' + }; + + const result = await runPiAgentLoop({ + input: { + messages: [{ role: 'user', content: '搭建工作流' }] + }, + runtime: { + llmParams: { model: 'gpt-5' }, + toolCatalog: { runtimeTools: [] }, + systemTools: { ask: { enabled: true, validate } }, + executeTool: vi.fn(), + checkIsStopping: vi.fn(() => false) + } + }); + + expect(result.status).toBe('done'); + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ + questions: [expect.objectContaining({ question: '请确认工作流' })] + }), + { assistantContent: '```mermaid\nflowchart LR\n A --> B\n```' } + ); + expect(result.providerState).not.toHaveProperty('pendingMainContext'); + expect(result.completeMessages).toContainEqual({ + role: 'tool', + tool_call_id: 'call_invalid_preview', + content: 'Preview markdown is required.' + }); + }); + it('stores ask pause as standard pending context without pi messages', async () => { agentToolToExecute.value = { name: 'ask_user', diff --git a/packages/service/test/core/ai/llm/agentLoop/runtime.test.ts b/packages/service/test/core/ai/llm/agentLoop/runtime.test.ts new file mode 100644 index 000000000000..14b9f8a3fd31 --- /dev/null +++ b/packages/service/test/core/ai/llm/agentLoop/runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + hasAgentLoopExecutableTools, + type AgentLoopRuntime +} from '@fastgpt/service/core/ai/llm/agentLoop/interface'; + +const createRuntime = ( + overrides?: Pick +): Pick => ({ + toolCatalog: { + runtimeTools: [] + }, + ...overrides +}); + +describe('hasAgentLoopExecutableTools', () => { + it('recognizes business and executable system tools', () => { + expect( + hasAgentLoopExecutableTools( + createRuntime({ + toolCatalog: { + runtimeTools: [ + { + type: 'function', + function: { + name: 'search', + description: 'Search', + parameters: {} + } + } + ] + } + }) + ) + ).toBe(true); + expect( + hasAgentLoopExecutableTools( + createRuntime({ + toolCatalog: { runtimeTools: [] }, + systemTools: { sandbox: { enabled: true, client: {} as any } } + }) + ) + ).toBe(true); + expect( + hasAgentLoopExecutableTools( + createRuntime({ + toolCatalog: { runtimeTools: [] }, + systemTools: { readFile: { enabled: true, execute: vi.fn() } } + }) + ) + ).toBe(true); + expect( + hasAgentLoopExecutableTools( + createRuntime({ + toolCatalog: { runtimeTools: [] }, + systemTools: { datasetSearch: { enabled: true, execute: vi.fn() } } + }) + ) + ).toBe(true); + }); + + it('does not treat plan and ask controls as executable tools', () => { + expect( + hasAgentLoopExecutableTools( + createRuntime({ + toolCatalog: { runtimeTools: [] }, + systemTools: { + plan: { enabled: true }, + ask: { enabled: true } + } + }) + ) + ).toBe(false); + expect(hasAgentLoopExecutableTools(createRuntime())).toBe(false); + }); +}); diff --git a/packages/service/test/core/ai/llm/request/requestBody.test.ts b/packages/service/test/core/ai/llm/request/requestBody.test.ts index ed101518f7ae..372742e47188 100644 --- a/packages/service/test/core/ai/llm/request/requestBody.test.ts +++ b/packages/service/test/core/ai/llm/request/requestBody.test.ts @@ -78,6 +78,7 @@ describe('llmCompletionsBodyFormat', () => { parallel_tool_calls: true, retainDatasetCite: false, useVision: true, + forceMediaToBase64: true, requestOrigin: 'test' }); @@ -105,6 +106,7 @@ describe('llmCompletionsBodyFormat', () => { expect(requestBody).not.toHaveProperty('toolCallMode'); expect(requestBody).not.toHaveProperty('retainDatasetCite'); expect(requestBody).not.toHaveProperty('useVision'); + expect(requestBody).not.toHaveProperty('forceMediaToBase64'); expect(requestBody).not.toHaveProperty('requestOrigin'); }); @@ -272,6 +274,7 @@ describe('llmCompletionsBodyFormat', () => { stream: false, retainDatasetCite: false, useVision: true, + forceMediaToBase64: true, requestOrigin: 'test' }); diff --git a/packages/service/test/core/ai/llm/utils.test.ts b/packages/service/test/core/ai/llm/utils.test.ts index 52ebafd1a4ef..56c96f751f02 100644 --- a/packages/service/test/core/ai/llm/utils.test.ts +++ b/packages/service/test/core/ai/llm/utils.test.ts @@ -1243,5 +1243,36 @@ describe('loadRequestMessages function tests', () => { const content = result[0].content as any[]; expect(content[0].image_url.url).toBe('data:image/png;base64,converted'); }); + + it('should force remote images to base64 for private upload requests', async () => { + serviceEnv.MULTIPLE_DATA_TO_BASE64 = false; + const imageUrl = 'http://localhost:9000/fastgpt-private/chat/image.png?X-Amz-Signature=test'; + const messages: ChatCompletionMessageParam[] = [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: [{ type: 'image_url', image_url: { url: imageUrl } }] + } + ]; + mockGetImageBase64.mockResolvedValue({ + completeBase64: 'data:image/png;base64,private-image', + base64: 'private-image', + mime: 'image/png' + }); + + const result = await loadRequestMessages({ + messages, + useVision: true, + forceMediaToBase64: true + }); + + expect(mockGetImageBase64).toHaveBeenCalledWith(imageUrl); + expect(mockAxiosHead).not.toHaveBeenCalled(); + expect(result[0].content).toEqual([ + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,private-image' } + } + ]); + }); }); }); diff --git a/packages/service/test/core/ai/sandbox/application/lease.test.ts b/packages/service/test/core/ai/sandbox/application/lease.test.ts index c05565a6e190..0266be77d21c 100644 --- a/packages/service/test/core/ai/sandbox/application/lease.test.ts +++ b/packages/service/test/core/ai/sandbox/application/lease.test.ts @@ -27,13 +27,19 @@ describe('sandbox lifecycle leases', () => { ); }); - it('uses separate source, lifecycle and migration lease keys', async () => { + it('uses the app mutation key for both app and workflow builder sources', async () => { await withSandboxSourceMutationLease({ sourceType: ChatSourceTypeEnum.app, sourceId: 'app-1', label: 'create-app-sandbox', fn: vi.fn().mockResolvedValue(undefined) }); + await withSandboxSourceMutationLease({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: 'app-1', + label: 'prewarm-workflow-builder-sandbox', + fn: vi.fn().mockResolvedValue(undefined) + }); await withSandboxLifecycleLease({ sandboxId: 'stable-id', label: 'archive-sandbox', @@ -45,6 +51,7 @@ describe('sandbox lifecycle leases', () => { }); expect(mocks.withLease.mock.calls.map(([params]) => params.key)).toEqual([ + 'agent-sandbox:source:app:app-1', 'agent-sandbox:source:app:app-1', 'agent-sandbox:lifecycle:stable-id', 'agent-sandbox:legacy-migration-job' diff --git a/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts b/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts index f7be25d60df2..cccceb03d64c 100644 --- a/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts +++ b/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts @@ -103,6 +103,64 @@ describe('builtin skill runtime', () => { expect(sandbox.writeFiles).not.toHaveBeenCalled(); expect(sandbox.readFiles).toHaveBeenCalledWith(['/home/sandbox/.fastgpt/runtime/state.json']); }); + + it('reinjects builtin skills after Sandbox recreation clears HOME and runtime state', async () => { + const sources = [ + { + name: 'skill-creator', + files: createBuiltinSkillSourceFiles() + } + ]; + const files = new Map(); + const sandbox = { + readFiles: vi.fn(async (paths: string[]) => + paths.map((path) => { + const content = files.get(path); + return content + ? { path, content, error: null } + : { path, content: Buffer.alloc(0), error: new Error('not found') }; + }) + ), + createDirectories: vi.fn(async () => undefined), + listDirectory: vi.fn(async () => []), + deleteDirectories: vi.fn(async () => undefined), + deleteFiles: vi.fn(async () => []), + writeFiles: vi.fn(async (entries: Array<{ path: string; data: Buffer | string }>) => + entries.map((entry) => { + const content = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data); + files.set(entry.path, content); + return { path: entry.path, bytesWritten: content.byteLength, error: null }; + }) + ) + }; + const skillPath = '/home/sandbox/.fastgpt/skills/skill-creator/SKILL.md'; + const runtimeStatePath = '/home/sandbox/.fastgpt/runtime/state.json'; + + await syncBuiltinSkillsToSandbox({ + sandbox: sandbox as any, + homeDirectory: '/home/sandbox', + sources + }); + expect(files.has(skillPath)).toBe(true); + expect(files.has(runtimeStatePath)).toBe(true); + + // OpenSandbox 只持久化 workspace;容器重建会同时清空 HOME Skill 和 runtime state。 + files.clear(); + + await syncBuiltinSkillsToSandbox({ + sandbox: sandbox as any, + homeDirectory: '/home/sandbox', + sources + }); + + expect(files.has(skillPath)).toBe(true); + expect(files.has(runtimeStatePath)).toBe(true); + expect( + sandbox.writeFiles.mock.calls.filter(([entries]) => + entries.some((entry) => entry.path === skillPath) + ) + ).toHaveLength(2); + }); }); function createBuiltinSkillSourceFiles() { diff --git a/packages/service/test/core/ai/sandbox/utils.test.ts b/packages/service/test/core/ai/sandbox/utils.test.ts index 0d2a8686068b..8bcae584f9e2 100644 --- a/packages/service/test/core/ai/sandbox/utils.test.ts +++ b/packages/service/test/core/ai/sandbox/utils.test.ts @@ -7,7 +7,7 @@ import { } from '@fastgpt/service/core/ai/sandbox/utils'; describe('sandbox runtime paths', () => { - it('derives App session and Skill Edit workspace paths', () => { + it('derives App, Workflow Builder session and Skill Edit workspace paths', () => { expect( getSandboxRuntimePaths({ sourceType: ChatSourceTypeEnum.app, @@ -19,6 +19,17 @@ describe('sandbox runtime paths', () => { runtimeSkillsRoot: '/workspace/projects', sessionWorkDirectory: '/workspace/sessions/chat-1' }); + expect( + getSandboxRuntimePaths({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + workDirectory: '/workspace/', + chatId: 'builder-chat-1' + }) + ).toEqual({ + workspaceRoot: '/workspace', + runtimeSkillsRoot: '/workspace/projects', + sessionWorkDirectory: '/workspace/sessions/builder-chat-1' + }); expect( getSandboxRuntimePaths({ sourceType: ChatSourceTypeEnum.skillEdit, diff --git a/packages/service/test/core/ai/sandbox/utils/id.test.ts b/packages/service/test/core/ai/sandbox/utils/id.test.ts index 57f34f50eebb..ea58d9241100 100644 --- a/packages/service/test/core/ai/sandbox/utils/id.test.ts +++ b/packages/service/test/core/ai/sandbox/utils/id.test.ts @@ -10,6 +10,12 @@ describe('sandbox v2 identity', () => { userId: 'user-1' }) ).toBe('user-1'); + expect( + getSandboxUserId({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + userId: 'user-1' + }) + ).toBe('user-1'); expect( getSandboxUserId({ sourceType: ChatSourceTypeEnum.skillEdit, @@ -23,4 +29,22 @@ describe('sandbox v2 identity', () => { }) ).toThrow('does not support sandbox identity'); }); + + it('uses a separate physical identity for Workflow Builder', async () => { + const { getRunningSandboxId } = await import('@fastgpt/service/core/ai/sandbox/utils/id'); + const appSandboxId = getRunningSandboxId({ + sourceType: ChatSourceTypeEnum.app, + sourceId: 'app-1', + userId: 'user-1' + }); + const builderSandboxId = getRunningSandboxId({ + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: 'app-1', + userId: 'user-1' + }); + + expect(appSandboxId).toMatch(/^app-[a-f0-9]{16}$/); + expect(builderSandboxId).toMatch(/^workflowbuilder-[a-f0-9]{16}$/); + expect(builderSandboxId).not.toBe(appSandboxId); + }); }); diff --git a/packages/service/test/core/chat/fileContext.test.ts b/packages/service/test/core/chat/fileContext.test.ts new file mode 100644 index 000000000000..b69c076a89b2 --- /dev/null +++ b/packages/service/test/core/chat/fileContext.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat'; +import { ChatFileTypeEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { resolveAuthorizedChatFiles } from '@fastgpt/service/core/chat/fileContext'; + +describe('resolveAuthorizedChatFiles', () => { + const scope = { + sourceType: ChatSourceTypeEnum.workflowBuilder, + sourceId: 'app-1', + uid: 'member-1', + chatId: 'chat-1' + }; + const key = 'chat/workflowBuilder/app-1/member-1/chat-1/requirements.md'; + + it('validates the S3 key scope and replaces the client URL with a trusted preview URL', async () => { + const getPreviewUrl = vi.fn(async () => 'https://trusted.test/requirements.md'); + + await expect( + resolveAuthorizedChatFiles({ + files: [ + { + type: ChatFileTypeEnum.file, + name: 'requirements.md', + key, + url: 'https://untrusted.test/requirements.md' + } + ], + ...scope, + getPreviewUrl + }) + ).resolves.toEqual([ + { + type: ChatFileTypeEnum.file, + name: 'requirements.md', + key, + url: 'https://trusted.test/requirements.md' + } + ]); + expect(getPreviewUrl).toHaveBeenCalledWith(key); + }); + + it.each([ + ['missing key', undefined, scope], + ['wrong source', key, { ...scope, sourceType: ChatSourceTypeEnum.chatAgentHelper }], + ['wrong source id', key, { ...scope, sourceId: 'app-2' }], + ['wrong member', key, { ...scope, uid: 'member-2' }], + ['wrong chat', key, { ...scope, chatId: 'chat-2' }] + ])('rejects %s before signing the file URL', async (_name, fileKey, invalidScope) => { + const getPreviewUrl = vi.fn(); + + await expect( + resolveAuthorizedChatFiles({ + files: [{ key: fileKey, url: 'https://untrusted.test/file' }], + ...invalidScope, + getPreviewUrl + }) + ).rejects.toBe(ChatErrEnum.unAuthChat); + expect(getPreviewUrl).not.toHaveBeenCalled(); + }); + + it('returns an empty list without creating preview URLs', async () => { + const getPreviewUrl = vi.fn(); + + await expect( + resolveAuthorizedChatFiles({ files: [], ...scope, getPreviewUrl }) + ).resolves.toEqual([]); + expect(getPreviewUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/run.test.ts b/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/run.test.ts index e864b41530ec..bcda2e682c11 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/run.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/run.test.ts @@ -346,6 +346,149 @@ describe('runAgentLoopCore', () => { expect(result.assistantResponses).toEqual(eventTarget); }); + it('keeps discarded buffered tool calls out of outward events and transcripts', async () => { + const forwardedEvents: any[] = []; + const privateCall = { + id: 'call_private', + type: 'function' as const, + function: { + name: 'sandbox_read_file', + arguments: '{"path":"/workspace/.fastgpt/workflow-builder/transaction/workflow.json"}' + } + }; + const publicCall = { + id: 'call_public', + type: 'function' as const, + function: { + name: 'sandbox_read_file', + arguments: '{"path":"/workspace/research.txt"}' + } + }; + + runAgentLoopMock.mockImplementation(async ({ runtime }) => { + runtime.emitEvent({ + type: 'llm_request_end', + requestIndex: 1, + modelName: 'gpt-4', + requestId: 'req_1', + finishReason: 'tool_calls', + toolCalls: [privateCall, publicCall], + seconds: 0.1 + }); + for (const call of [privateCall, publicCall]) { + runtime.emitEvent({ + type: 'tool_call', + call: { + ...call, + function: { ...call.function, arguments: '' } + } + }); + runtime.emitEvent({ + type: 'tool_params', + callId: call.id, + argsDelta: call.function.arguments + }); + runtime.emitEvent({ type: 'tool_run_start', call }); + runtime.emitEvent({ + type: 'tool_run_end', + call, + rawResponse: call === privateCall ? 'internal access denied' : 'public result', + response: call === privateCall ? 'internal access denied' : 'public result', + errorMessage: call === privateCall ? 'INTERNAL_ACCESS_DENIED' : undefined, + seconds: 0.1 + }); + } + + return { + status: 'done', + completeMessages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + content: null, + tool_calls: [privateCall, publicCall] + }, + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: privateCall.id, + content: 'internal access denied' + }, + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: publicCall.id, + content: 'public result' + } + ], + assistantMessages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: privateCall.id, + content: 'internal access denied' + } + ], + requestIds: ['req_1'], + finishReason: 'stop', + usages: [] + }; + }); + + const result = await runAgentLoopCore({ + input: { messages: [] }, + runtime: { + llmParams: { model: 'gpt-4' }, + toolCatalog: { runtimeTools: [] }, + executeTool: vi.fn(), + emitEvent: (event) => forwardedEvents.push(event) + } as any, + bufferedToolEventFilter: { + shouldBuffer: (call) => call.function.name.startsWith('sandbox_'), + shouldDiscard: (call) => call.function.arguments.includes('/transaction/') + }, + assistantResponses: { + getEventToolInfo: () => ({ name: 'Sandbox read file' }) + } + }); + + expect(forwardedEvents).toHaveLength(5); + expect(forwardedEvents[0]).toEqual( + expect.objectContaining({ + type: 'llm_request_end', + toolCalls: [publicCall] + }) + ); + const forwardedToolEvents = forwardedEvents.slice(1); + expect( + forwardedToolEvents.every((event) => + event.type === 'tool_params' + ? event.callId === publicCall.id + : event.call?.id === publicCall.id + ) + ).toBe(true); + expect(result.assistantResponses).toEqual([ + expect.objectContaining({ + id: publicCall.id, + tools: [ + expect.objectContaining({ + id: publicCall.id, + response: 'public result' + }) + ] + }) + ]); + expect(result.assistantMessages).toEqual([]); + expect(result.completeMessages).toEqual([ + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + content: null, + tool_calls: [publicCall] + }, + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: publicCall.id, + content: 'public result' + } + ]); + }); + it('maps low-level paused result to workflow core interactive status', async () => { const ask = { reason: 'Need confirmation', diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 90cae73dd5ab..cba76de8cb24 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -124,6 +124,7 @@ export const iconPaths = { import('./icons/core/app/agent/child/contextCompress.svg'), 'core/app/agent/child/plan': () => import('./icons/core/app/agent/child/plan.svg'), 'core/app/aiFill': () => import('./icons/core/app/aiFill.svg'), + 'core/app/aiGenerateFilled': () => import('./icons/core/app/aiGenerateFilled.svg'), 'core/app/aiLight': () => import('./icons/core/app/aiLight.svg'), 'core/app/aiLightSmall': () => import('./icons/core/app/aiLightSmall.svg'), 'core/app/configDrawerSetting': () => import('./icons/core/app/configDrawerSetting.svg'), @@ -135,6 +136,7 @@ export const iconPaths = { 'core/app/markLight': () => import('./icons/core/app/markLight.svg'), 'core/app/workflowToolbarAdd': () => import('./icons/core/app/workflowToolbarAdd.svg'), 'core/app/workflowToolbarSearch': () => import('./icons/core/app/workflowToolbarSearch.svg'), + 'core/app/workflowVersion': () => import('./icons/core/app/workflowVersion.svg'), 'core/app/publish/lark': () => import('./icons/core/app/publish/lark.svg'), 'core/app/publish/offiaccount': () => import('./icons/core/app/publish/offiaccount.svg'), 'core/app/publish/wechat': () => import('./icons/core/app/publish/wechat.svg'), @@ -206,6 +208,8 @@ export const iconPaths = { 'core/chat/fileSelect': () => import('./icons/core/chat/fileSelect.svg'), 'core/chat/monitor': () => import('./icons/core/chat/monitor.svg'), 'core/chat/markdown': () => import('./icons/core/chat/markdown.svg'), + 'core/chat/mermaidPreviewAdd': () => import('./icons/core/chat/mermaidPreviewAdd.svg'), + 'core/chat/mermaidPreviewClose': () => import('./icons/core/chat/mermaidPreviewClose.svg'), 'core/chat/quoteFill': () => import('./icons/core/chat/quoteFill.svg'), 'core/chat/recordFill': () => import('./icons/core/chat/recordFill.svg'), 'core/chat/sendFill': () => import('./icons/core/chat/sendFill.svg'), @@ -222,6 +226,18 @@ export const iconPaths = { 'core/chat/terminal': () => import('./icons/core/chat/terminal.svg'), 'core/chat/think': () => import('./icons/core/chat/think.svg'), 'core/chat/var': () => import('./icons/core/chat/var.svg'), + 'core/chat/workflowBuilder/cancel': () => import('./icons/core/chat/workflowBuilder/cancel.svg'), + 'core/chat/workflowBuilder/commercialCheck': () => + import('./icons/core/chat/workflowBuilder/commercialCheck.svg'), + 'core/chat/workflowBuilder/commercialMore': () => + import('./icons/core/chat/workflowBuilder/commercialMore.svg'), + 'core/chat/workflowBuilder/commercialStar': () => + import('./icons/core/chat/workflowBuilder/commercialStar.svg'), + 'core/chat/workflowBuilder/commit': () => import('./icons/core/chat/workflowBuilder/commit.svg'), + 'core/chat/workflowBuilder/preview': () => + import('./icons/core/chat/workflowBuilder/preview.svg'), + 'core/chat/workflowBuilder/query': () => import('./icons/core/chat/workflowBuilder/query.svg'), + 'core/chat/workflowBuilder/stage': () => import('./icons/core/chat/workflowBuilder/stage.svg'), 'core/dataset/commonDatasetColor': () => import('./icons/core/dataset/commonDatasetColor.svg'), 'core/dataset/commonDatasetOutline': () => import('./icons/core/dataset/commonDatasetOutline.svg'), diff --git a/packages/web/components/common/Icon/icons/core/app/aiGenerateFilled.svg b/packages/web/components/common/Icon/icons/core/app/aiGenerateFilled.svg new file mode 100644 index 000000000000..4a69c48411dc --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/aiGenerateFilled.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/app/workflowVersion.svg b/packages/web/components/common/Icon/icons/core/app/workflowVersion.svg new file mode 100644 index 000000000000..3aeaff0f30c5 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/workflowVersion.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewAdd.svg b/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewAdd.svg new file mode 100644 index 000000000000..20d7ed764a32 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewAdd.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewClose.svg b/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewClose.svg new file mode 100644 index 000000000000..f525028a1aca --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/mermaidPreviewClose.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/cancel.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/cancel.svg new file mode 100644 index 000000000000..babd230cb6fd --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/cancel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialCheck.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialCheck.svg new file mode 100644 index 000000000000..5323a69e9d4b --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialCheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialMore.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialMore.svg new file mode 100644 index 000000000000..0c911908e6c8 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialMore.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialStar.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialStar.svg new file mode 100644 index 000000000000..b87632b87e9f --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commercialStar.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commit.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commit.svg new file mode 100644 index 000000000000..5f9c49e3b286 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/commit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/preview.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/preview.svg new file mode 100644 index 000000000000..00fd7aa9f667 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/preview.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/query.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/query.svg new file mode 100644 index 000000000000..c50eb7dd64c8 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/query.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/stage.svg b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/stage.svg new file mode 100644 index 000000000000..24feb4456fee --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/workflowBuilder/stage.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/web/components/common/Image/PhotoView.tsx b/packages/web/components/common/Image/PhotoView.tsx index 0fa53b53e7f0..88e8a65d430b 100644 --- a/packages/web/components/common/Image/PhotoView.tsx +++ b/packages/web/components/common/Image/PhotoView.tsx @@ -6,11 +6,17 @@ import { useSystem } from '../../../hooks/useSystem'; import Loading from '../MyLoading'; import MyImage from './MyImage'; +type PhotoSliderImage = React.ComponentProps['images'][number]; + type MyPhotoSliderProps = { src?: string; visible: boolean; onClose: () => void; imageKey?: string; + initialScale?: number; + render?: PhotoSliderImage['render']; + width?: number; + height?: number; }; const MyPhotoView = (props: ImageProps) => { @@ -30,18 +36,62 @@ const MyPhotoView = (props: ImageProps) => { ); }; -export const MyPhotoSlider = ({ src, visible, onClose, imageKey }: MyPhotoSliderProps) => { +export const MyPhotoSlider = ({ + src, + visible, + onClose, + imageKey, + initialScale = 1, + render, + width, + height +}: MyPhotoSliderProps) => { const { isPc } = useSystem(); + const scaleKey = `${imageKey || src || 'custom-render'}:${initialScale}`; + const appliedScaleKey = React.useRef(); + const images = (() => { + if (!src && !render) return []; + + return [ + { + key: imageKey || src || 'custom-render', + src, + render, + width, + height + } + ]; + })(); + + React.useEffect(() => { + if (!visible) { + appliedScaleKey.current = undefined; + } + }, [visible, scaleKey]); return ( } + // react-photo-view 默认会把整图适配到视口,Mermaid 预览需要恢复调用方的原始刻度。 + overlayRender={ + initialScale > 1 + ? ({ onScale }) => { + if (appliedScaleKey.current !== scaleKey) { + appliedScaleKey.current = scaleKey; + window.requestAnimationFrame(() => { + if (visible) onScale(initialScale); + }); + } + return null; + } + : undefined + } /> ); }; diff --git a/packages/web/components/common/MyPopover/index.tsx b/packages/web/components/common/MyPopover/index.tsx index f71332490f00..68568bd543e8 100644 --- a/packages/web/components/common/MyPopover/index.tsx +++ b/packages/web/components/common/MyPopover/index.tsx @@ -11,8 +11,10 @@ import { Portal } from '@chakra-ui/react'; -interface Props extends PopoverContentProps { +type Props = PopoverContentProps & { Trigger: React.ReactNode; + /** 传入时由调用方控制显隐;省略时保持原有 useDisclosure 行为。 */ + isOpen?: boolean; placement?: PlacementWithLogical; offset?: [number, number]; trigger?: 'hover' | 'click'; @@ -24,10 +26,11 @@ interface Props extends PopoverContentProps { closeOnBlur?: boolean; usePortal?: boolean; flip?: boolean; -} +}; const MyPopover = ({ Trigger, + isOpen: controlledIsOpen, placement, offset, trigger, @@ -43,7 +46,21 @@ const MyPopover = ({ }: Props) => { const firstFieldRef = React.useRef(null); - const { onOpen, onClose, isOpen } = useDisclosure(); + const { + onOpen: onInternalOpen, + onClose: onInternalClose, + isOpen: internalIsOpen + } = useDisclosure(); + const isControlled = controlledIsOpen !== undefined; + const isOpen = controlledIsOpen ?? internalIsOpen; + const onOpen = () => { + if (!isControlled) onInternalOpen(); + onOpenFunc?.(); + }; + const onClose = () => { + if (!isControlled) onInternalClose(); + onCloseFunc?.(); + }; const popoverContent = ( @@ -56,14 +73,8 @@ const MyPopover = ({ { - onOpen(); - onOpenFunc?.(); - }} - onClose={() => { - onClose(); - onCloseFunc?.(); - }} + onOpen={onOpen} + onClose={onClose} placement={placement} offset={offset} flip={flip} diff --git a/packages/web/hooks/useLinkedScroll.tsx b/packages/web/hooks/useLinkedScroll.tsx index 87a2e003ba16..5375ae3d03aa 100644 --- a/packages/web/hooks/useLinkedScroll.tsx +++ b/packages/web/hooks/useLinkedScroll.tsx @@ -23,7 +23,8 @@ export function useLinkedScroll< currentData, defaultScroll = 'top', enablePagination = true, - showErrorToast = true + showErrorToast = true, + showInitialLoading = true }: { pageSize?: number; params?: Record; @@ -31,6 +32,8 @@ export function useLinkedScroll< defaultScroll?: 'top' | 'bottom'; enablePagination?: boolean; showErrorToast?: boolean; + /** 是否在初次历史请求期间覆盖滚动内容。关闭后可先渲染输入区。 */ + showInitialLoading?: boolean; } ) { const { t } = useTranslation(); @@ -87,7 +90,7 @@ export function useLinkedScroll< const { runAsync: callApi, loading: isLoading } = useRequest(api, { errorToast: '' }); const scrollSign = useRef(false); - const { runAsync: loadInitData } = useRequest( + const { run: loadInitData } = useRequest( async ({ scrollWhenFinish, refresh } = { scrollWhenFinish: true, refresh: false }) => { // 已经被加载的数据,直接滚动到该位置 const item = dataList.find((item) => item.id === currentData?.id); @@ -271,7 +274,13 @@ export function useLinkedScroll< ); return ( - + {hasMorePrev && prevLoading && ( {t('common:is_requesting')} @@ -286,7 +295,7 @@ export function useLinkedScroll< ); }, - [enablePagination, isLoading] + [enablePagination, isLoading, showInitialLoading] ); return { diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0d00082de252..906464f5af15 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -47,7 +47,11 @@ "Finish": "Finish", "Folder": "Folder", "FullScreen": "FullScreen", - "FullScreenLight": "FullScreenLight", + "FullScreenLight": "Full-screen preview", + "MermaidFitView": "Fit diagram to view", + "MermaidRenderFailed": "The flowchart could not be rendered. Please regenerate it and try again.", + "MermaidZoomIn": "Zoom in", + "MermaidZoomOut": "Zoom out", "Import": "Import", "Input": "Input", "Instructions": "Instruction", @@ -999,7 +1003,7 @@ "plugin.Invalid URL": "Invalid URL", "plugin.Search_app": "Search App", "price_over_wx_limit": "Exceed payment provider limit: WeChat Pay only supports less than 6,000 yuan", - "pro_modal_feature_1": "Intelligent assisted generation for Agents and Skills", + "pro_modal_feature_1": "AI-assisted generation for Agents, Skills, and Workflows", "pro_modal_feature_2": "Website sync and third-party datasets", "pro_modal_feature_3": "Multi-tenancy, organization structure, and audit logs", "pro_modal_later_button": "Maybe Later", diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 9e35096f537e..37c5b81de323 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -30,6 +30,25 @@ "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", + "cli.config.auto_execute": "Configure this when the app should run automatically as soon as a conversation opens; set both the switch and the default prompt.", + "cli.config.auto_execute_open": "Enable this only when opening a conversation should immediately run the workflow without waiting for user input.", + "cli.config.auto_execute_prompt": "Set the user question injected when automatic execution starts; configure it before enabling automatic execution.", + "cli.config.chat_input_guide": "Configure this when the chat input should use a custom guide page instead of the standard input experience.", + "cli.config.file_select": "Enable this before building a workflow that consumes files supplied by the user at the chat entry. Select the matching file types; enabling it adds the userFiles output to the workflow start node.", + "cli.config.instruction": "Configure this when users need persistent usage instructions on the application conversation page.", + "cli.config.question_guide": "Configure this when the application should generate follow-up question suggestions after a reply; enable it and bind an available model.", + "cli.config.question_guide_model": "Bind the available model used to generate follow-up question suggestions before enabling the question guide.", + "cli.config.question_guide_open": "Enable this when users should receive generated follow-up question suggestions after application replies.", + "cli.config.question_guide_prompt": "Set additional instructions only when generated follow-up question suggestions need application-specific guidance.", + "cli.config.scheduled_trigger": "Configure this when the workflow must run on a schedule; provide the cron expression, timezone, and injected default prompt together.", + "cli.config.tts": "Configure this when users need application replies to be read aloud by the browser or a speech model.", + "cli.config.welcome_text": "Configure this when a new conversation should begin with predefined guidance, examples, or quick actions.", + "cli.config.whisper": "Configure this when users should be able to submit questions through speech recognition.", "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 +292,44 @@ "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.", + "workflow_builder_title": "Workflow AI", + "workflow_builder_entry_tooltip": "Generate workflow with AI", + "workflow_builder_collapse": "Collapse", + "workflow_builder_clear_history": "Clear history", + "workflow_builder_guide_got_it": "Got it", + "workflow_builder_guide_system_title": "System configuration", + "workflow_builder_guide_system_description": "System configuration has moved here.", + "workflow_builder_guide_ai_title": "Generate with AI", + "workflow_builder_guide_ai_description": "Click to generate/edit workflows with a single sentence.", + "workflow_builder_commercial_title": "Business Edition Exclusive!", + "workflow_builder_commercial_feature_generation": "AI-assisted generation", + "workflow_builder_commercial_feature_dataset_tenant": "Dataset multi-tenancy", + "workflow_builder_commercial_feature_audit": "Organization and audit logs", + "workflow_builder_commercial_feature_third_party_dataset": "Third-party datasets", + "workflow_builder_commercial_feature_dashboard": "Analytics dashboard", + "workflow_builder_commercial_feature_more": "More", + "workflow_builder_commercial_unlock": "Unlock", + "workflow_builder_welcome": "Hi, what would you like to build in {{appName}}?", + "workflow_builder_example_daily_report": "Build a workflow to find today's AI news", + "workflow_builder_example_review_assistant": "Build a review assistant workflow", + "workflow_builder_example_resume_screening": "Build a resume screening workflow", + "workflow_builder_banner_ready": "{{name}} is ready to apply to the canvas", + "workflow_builder_banner_close": "Dismiss pending workflow reminder", + "workflow_builder_apply_failed": "Application failed. Please try again.", + "workflow_builder_canvas_changed": "The canvas changed while the workflow was being built. Please run the builder again.", + "workflow_builder_applied": "{{name}} applied successfully", + "workflow_builder_version_apply": "Apply to canvas", + "workflow_builder_version_applied": "Applied", + "workflow_builder_version_expired": "Expired", + "workflow_builder_version_expired_toast": "This workflow has expired and cannot be applied. Generate a new one.", + "workflow_builder_version_json_ready": "Workflow JSON generated and validated", + "workflow_builder_version_ready_suffix": " generated", + "workflow_builder_process_details": "Process details", + "workflow_builder_tool_query": "View current state", + "workflow_builder_tool_stage": "Build workflow", + "workflow_builder_tool_commit": "Finalize delivery", + "workflow_builder_tool_preview": "Plan preview", + "workflow_builder_tool_cancel": "Cancel task", + "connection_invalid": "This connection violates workflow rules" } diff --git a/packages/web/i18n/ko-KR/workflow.json b/packages/web/i18n/ko-KR/workflow.json index d015bd9fe907..5f7c9c6c7b95 100644 --- a/packages/web/i18n/ko-KR/workflow.json +++ b/packages/web/i18n/ko-KR/workflow.json @@ -273,5 +273,43 @@ "workflow_local_draft_auth_expired_notice": "로그인이 만료되어 현재 편집 내용이 로컬에 자동으로 저장되었습니다. 페이지를 닫지 마세요. 이 탭에서 다시 로그인하면 워크플로 편집 내용을 복원할 수 있습니다.", "workflow_local_draft_auth_expired_title": "자동 저장됨", "workflow_local_draft_relogin": "다시 로그인", - "workflow.exit_tips": "변경 사항이 저장되지 않았습니다. '바로 나가기'를 선택하면 편집 내용이 저장되지 않습니다." + "workflow.exit_tips": "변경 사항이 저장되지 않았습니다. '바로 나가기'를 선택하면 편집 내용이 저장되지 않습니다.", + "workflow_builder_title": "워크플로 AI", + "workflow_builder_entry_tooltip": "AI로 워크플로 생성", + "workflow_builder_collapse": "접기", + "workflow_builder_clear_history": "기록 지우기", + "workflow_builder_guide_got_it": "확인", + "workflow_builder_guide_system_title": "시스템 설정", + "workflow_builder_guide_system_description": "시스템 설정이 이곳으로 이동했습니다.", + "workflow_builder_guide_ai_title": "AI로 생성", + "workflow_builder_guide_ai_description": "한 문장으로 워크플로를 생성하거나 수정하세요.", + "workflow_builder_commercial_title": "비즈니스 에디션 전용!", + "workflow_builder_commercial_feature_generation": "AI 지원 생성", + "workflow_builder_commercial_feature_dataset_tenant": "데이터셋 멀티테넌시", + "workflow_builder_commercial_feature_audit": "조직 및 감사 로그", + "workflow_builder_commercial_feature_third_party_dataset": "타사 데이터셋", + "workflow_builder_commercial_feature_dashboard": "분석 대시보드", + "workflow_builder_commercial_feature_more": "더보기", + "workflow_builder_commercial_unlock": "잠금 해제", + "workflow_builder_welcome": "안녕하세요. {{appName}}에서 무엇을 만들고 싶으신가요?", + "workflow_builder_example_daily_report": "오늘의 AI 뉴스를 찾는 워크플로 만들기", + "workflow_builder_example_review_assistant": "리뷰 도우미 워크플로 만들기", + "workflow_builder_example_resume_screening": "이력서 심사 워크플로 만들기", + "workflow_builder_banner_ready": "{{name}} 버전을 캔버스에 적용할 수 있습니다", + "workflow_builder_banner_close": "대기 중인 워크플로 알림 닫기", + "workflow_builder_apply_failed": "적용에 실패했습니다. 다시 시도해 주세요.", + "workflow_builder_canvas_changed": "워크플로를 만드는 동안 캔버스가 변경되었습니다. Builder를 다시 실행해 주세요.", + "workflow_builder_applied": "{{name}} 적용 완료", + "workflow_builder_version_apply": "캔버스에 적용", + "workflow_builder_version_applied": "적용됨", + "workflow_builder_version_expired": "만료됨", + "workflow_builder_version_expired_toast": "이 워크플로는 만료되어 적용할 수 없습니다. 새로 생성해 주세요.", + "workflow_builder_version_json_ready": "워크플로 JSON 생성 및 검증 완료", + "workflow_builder_version_ready_suffix": " 생성 완료", + "workflow_builder_process_details": "처리 세부 정보", + "workflow_builder_tool_query": "현재 상태 확인", + "workflow_builder_tool_stage": "워크플로 빌드", + "workflow_builder_tool_commit": "최종 전달", + "workflow_builder_tool_preview": "계획 미리보기", + "workflow_builder_tool_cancel": "작업 취소" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 45ba4eb9fdd6..568c9686ceab 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -48,6 +48,10 @@ "Folder": "文件夹", "FullScreen": "全屏", "FullScreenLight": "全屏预览", + "MermaidFitView": "适应画布", + "MermaidRenderFailed": "流程图渲染失败,请重新生成后再试", + "MermaidZoomIn": "放大", + "MermaidZoomOut": "缩小", "Import": "导入", "Input": "输入", "Instructions": "使用说明", @@ -999,7 +1003,7 @@ "plugin.Invalid URL": "URL 无效", "plugin.Search_app": "搜索应用", "price_over_wx_limit": "超出支付提供商限额:微信支付仅支持 6000 元以下", - "pro_modal_feature_1": "Agent 与技能的智能辅助生成", + "pro_modal_feature_1": "Agent、技能与工作流的智能辅助生成", "pro_modal_feature_2": "web站点同步与第三方知识库", "pro_modal_feature_3": "多租户、组织架构与审计日志", "pro_modal_later_button": "我再想想", diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index 11b779db6c9d..d1574d795b20 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -30,6 +30,25 @@ "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": "由工作流开始节点提供的用户问题", + "cli.config.auto_execute": "当应用需要在用户进入会话后立即自动运行时配置;应同时设置开启状态和默认问题。", + "cli.config.auto_execute_open": "仅当进入会话后应立即运行工作流、无需等待用户输入时开启。", + "cli.config.auto_execute_prompt": "设置自动执行时注入的用户问题;开启自动执行前必须先配置。", + "cli.config.chat_input_guide": "当对话输入需要使用自定义引导页面而不是标准输入体验时配置。", + "cli.config.file_select": "当工作流需要消费用户从对话入口提供的文件时,必须在搭建文件处理节点前开启,并选择匹配的文件类型;开启后会为工作流开始节点增加 userFiles 输出。", + "cli.config.instruction": "当用户需要在应用对话页持续查看使用说明时配置。", + "cli.config.question_guide": "当应用回复后需要生成后续问题建议时配置;应开启功能并绑定可用模型。", + "cli.config.question_guide_model": "绑定用于生成后续问题建议的可用模型;开启问题引导前必须完成配置。", + "cli.config.question_guide_open": "当用户应在应用回复后看到生成的后续问题建议时开启。", + "cli.config.question_guide_prompt": "仅当后续问题建议需要应用特定的生成规则时设置补充提示词。", + "cli.config.scheduled_trigger": "当工作流需要定时运行时配置;应同时提供 Cron 表达式、时区和注入的默认问题。", + "cli.config.tts": "当用户需要通过浏览器或语音模型播报应用回复时配置。", + "cli.config.welcome_text": "当新会话需要展示预设引导、示例或快捷操作时配置。", + "cli.config.whisper": "当用户需要通过语音识别提交问题时配置。", "code.Reset template": "还原模板", "code.Reset template confirm": "确认还原代码模板?将会重置所有输入和输出至模板值,请注意保存当前代码。", "code.Switch language confirm": "切换语言将重置代码,是否继续?", @@ -82,7 +101,7 @@ "field_used_as_tool_input": "默认 AI 生成", "filter_description": "目前支持标签、创建时间和集合 ID 过滤,需按照以下格式填写:\n{\n \"tags\": {\n \"$and\": [\"标签 1\",\"标签 2\"],\n \"$or\": [\"有 $and 标签时,and 生效,or 不生效\"]\n },\n \"createTime\": {\n \"$gte\": \"YYYY-MM-DD HH:mm 格式即可,集合的创建时间大于该时间\",\n \"$lte\": \"YYYY-MM-DD HH:mm 格式即可,集合的创建时间小于该时间,可和 $gte 共同使用\"\n },\n \"collectionIds\": [\"集合ID1\", \"集合ID2\", \"支持文件夹ID,会自动展开获取所有子集合\"]\n}", "find_tip": "查找节点 ctrl f", - "find_tip_mac": "查找节点 ⌘ f", + "find_tip_mac": "查找节点 ⌘ f", "foldAll": "全部折叠", "form_input_result": "用户完整输入结果", "form_input_result_tip": "一个包含完整结果的对象", @@ -273,5 +292,44 @@ "workflow_local_draft_auth_expired_notice": "登录已过期,当前编辑内容已自动保存至本地。请不要关闭页面,在此标签页重新登录后,即可恢复编排内容。", "workflow_local_draft_auth_expired_title": "已自动保存", "workflow_local_draft_relogin": "重新登录", - "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。" + "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。", + "workflow_builder_title": "工作流 AI", + "workflow_builder_entry_tooltip": "AI 生成工作流", + "workflow_builder_collapse": "收起", + "workflow_builder_clear_history": "清除历史记录", + "workflow_builder_guide_got_it": "知道了", + "workflow_builder_guide_system_title": "系统配置", + "workflow_builder_guide_system_description": "系统配置移到这里啦~", + "workflow_builder_guide_ai_title": "AI 辅助生成", + "workflow_builder_guide_ai_description": "点击使用,一句话生成/编辑工作流", + "workflow_builder_commercial_title": "商业版专享!", + "workflow_builder_commercial_feature_generation": "智能辅助生成", + "workflow_builder_commercial_feature_dataset_tenant": "知识库多租户", + "workflow_builder_commercial_feature_audit": "组织架构与审计日志", + "workflow_builder_commercial_feature_third_party_dataset": "第三方知识库", + "workflow_builder_commercial_feature_dashboard": "数据看板", + "workflow_builder_commercial_feature_more": "更多", + "workflow_builder_commercial_unlock": "去解锁", + "workflow_builder_welcome": "你好,要在 {{appName}} 中搭建什么?", + "workflow_builder_example_daily_report": "帮我做一个今日 AI 新闻检索工作流", + "workflow_builder_example_review_assistant": "帮我做一个审核助手工作流", + "workflow_builder_example_resume_screening": "帮我做一个简历筛选工作流", + "workflow_builder_banner_ready": "{{name}} 已生成,可以应用到画布", + "workflow_builder_banner_close": "关闭待应用提醒", + "workflow_builder_apply_failed": "应用失败,请重新尝试", + "workflow_builder_canvas_changed": "工作流搭建期间画布发生了变化,请重新运行工作流辅助生成。", + "workflow_builder_applied": "{{name}} 应用成功", + "workflow_builder_version_apply": "应用到画布", + "workflow_builder_version_applied": "已应用", + "workflow_builder_version_expired": "已过期", + "workflow_builder_version_expired_toast": "该工作流已过期,无法应用,请重新生成", + "workflow_builder_version_json_ready": "工作流 JSON 已生成并完成校验", + "workflow_builder_version_ready_suffix": " 已生成完毕", + "workflow_builder_process_details": "过程详情", + "workflow_builder_tool_query": "查看现状", + "workflow_builder_tool_stage": "动手搭建", + "workflow_builder_tool_commit": "正式交付", + "workflow_builder_tool_preview": "方案预览", + "workflow_builder_tool_cancel": "取消任务", + "connection_invalid": "该连接不符合工作流规则" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index eeaadaa92ee4..51d5bf43747d 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -48,6 +48,10 @@ "Folder": "資料夾", "FullScreen": "全屏", "FullScreenLight": "全屏預覽", + "MermaidFitView": "適應畫布", + "MermaidRenderFailed": "流程圖渲染失敗,請重新生成後再試", + "MermaidZoomIn": "放大", + "MermaidZoomOut": "縮小", "Import": "匯入", "Input": "輸入", "Instructions": "使用說明", @@ -999,7 +1003,7 @@ "plugin.Invalid URL": "無效的網址", "plugin.Search_app": "搜尋應用程式", "price_over_wx_limit": "超出支付提供商限額:微信支付僅支持 6000 元以下", - "pro_modal_feature_1": "Agent 與技能的智能輔助生成", + "pro_modal_feature_1": "Agent、技能與工作流程的智能輔助生成", "pro_modal_feature_2": "Web 站點同步與第三方知識庫", "pro_modal_feature_3": "多租戶、組織架構與審計日誌", "pro_modal_later_button": "我再想想", diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index 957d7bf55d41..1003a583e6fa 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -30,6 +30,25 @@ "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": "由工作流程開始節點提供的使用者問題", + "cli.config.auto_execute": "當應用程式需要在使用者進入對話後立即自動執行時設定;應同時設定開啟狀態和預設問題。", + "cli.config.auto_execute_open": "僅當進入對話後應立即執行工作流程、無需等待使用者輸入時開啟。", + "cli.config.auto_execute_prompt": "設定自動執行時注入的使用者問題;開啟自動執行前必須先設定。", + "cli.config.chat_input_guide": "當對話輸入需要使用自訂引導頁面而不是標準輸入體驗時設定。", + "cli.config.file_select": "當工作流程需要使用者從對話入口提供檔案時,必須在建立檔案處理節點前開啟,並選擇相符的檔案類型;開啟後會為工作流程開始節點增加 userFiles 輸出。", + "cli.config.instruction": "當使用者需要在應用程式對話頁持續查看使用說明時設定。", + "cli.config.question_guide": "當應用程式回覆後需要產生後續問題建議時設定;應開啟功能並綁定可用模型。", + "cli.config.question_guide_model": "綁定用於產生後續問題建議的可用模型;開啟問題引導前必須完成設定。", + "cli.config.question_guide_open": "當使用者應在應用程式回覆後看到產生的後續問題建議時開啟。", + "cli.config.question_guide_prompt": "僅當後續問題建議需要應用程式特定的產生規則時設定補充提示詞。", + "cli.config.scheduled_trigger": "當工作流程需要定時執行時設定;應同時提供 Cron 表達式、時區和注入的預設問題。", + "cli.config.tts": "當使用者需要透過瀏覽器或語音模型播報應用程式回覆時設定。", + "cli.config.welcome_text": "當新對話需要顯示預設引導、範例或快捷操作時設定。", + "cli.config.whisper": "當使用者需要透過語音識別提交問題時設定。", "code.Reset template": "重設範本", "code.Reset template confirm": "確定要重設程式碼範本嗎?這將會把所有輸入和輸出重設為範本值。請儲存您目前的程式碼。", "code.Switch language confirm": "切換語言將重設代碼,是否繼續?", @@ -273,5 +292,44 @@ "workflow_local_draft_auth_expired_notice": "登入已過期,當前編輯內容已自動儲存至本機。請不要關閉頁面,在此分頁重新登入後,即可恢復編排內容。", "workflow_local_draft_auth_expired_title": "已自動儲存", "workflow_local_draft_relogin": "重新登入", - "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。" + "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。", + "workflow_builder_title": "工作流 AI", + "workflow_builder_entry_tooltip": "AI 生成工作流", + "workflow_builder_collapse": "收起", + "workflow_builder_clear_history": "清除歷史記錄", + "workflow_builder_guide_got_it": "知道了", + "workflow_builder_guide_system_title": "系統設定", + "workflow_builder_guide_system_description": "系統設定移到這裡啦~", + "workflow_builder_guide_ai_title": "AI 輔助產生", + "workflow_builder_guide_ai_description": "點擊使用,一句話產生/編輯工作流程", + "workflow_builder_commercial_title": "商業版專享!", + "workflow_builder_commercial_feature_generation": "智能輔助產生", + "workflow_builder_commercial_feature_dataset_tenant": "知識庫多租戶", + "workflow_builder_commercial_feature_audit": "組織架構與審計日誌", + "workflow_builder_commercial_feature_third_party_dataset": "第三方知識庫", + "workflow_builder_commercial_feature_dashboard": "資料看板", + "workflow_builder_commercial_feature_more": "更多", + "workflow_builder_commercial_unlock": "去解鎖", + "workflow_builder_welcome": "你好,要在 {{appName}} 中建立什麼?", + "workflow_builder_example_daily_report": "幫我做一個今日 AI 新聞檢索工作流程", + "workflow_builder_example_review_assistant": "幫我做一個審核助手工作流程", + "workflow_builder_example_resume_screening": "幫我做一個履歷篩選工作流程", + "workflow_builder_banner_ready": "{{name}} 已產生,可以套用到畫布", + "workflow_builder_banner_close": "關閉待套用提醒", + "workflow_builder_apply_failed": "套用失敗,請重新嘗試", + "workflow_builder_canvas_changed": "工作流程建立期間畫布發生了變更,請重新執行工作流程輔助生成。", + "workflow_builder_applied": "{{name}} 套用成功", + "workflow_builder_version_apply": "套用到畫布", + "workflow_builder_version_applied": "已套用", + "workflow_builder_version_expired": "已過期", + "workflow_builder_version_expired_toast": "此工作流程已過期,無法套用,請重新產生", + "workflow_builder_version_json_ready": "工作流程 JSON 已產生並完成驗證", + "workflow_builder_version_ready_suffix": " 已產生完畢", + "workflow_builder_process_details": "過程詳情", + "workflow_builder_tool_query": "查看現狀", + "workflow_builder_tool_stage": "動手搭建", + "workflow_builder_tool_commit": "正式交付", + "workflow_builder_tool_preview": "方案預覽", + "workflow_builder_tool_cancel": "取消任務", + "connection_invalid": "此連線不符合工作流程規則" } diff --git a/packages/workflow-cli/package.json b/packages/workflow-cli/package.json new file mode 100644 index 000000000000..064028bd0471 --- /dev/null +++ b/packages/workflow-cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "@fastgpt/workflow-cli", + "version": "0.3.0-beta.1", + "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", + "test:package": "pnpm test:bin && node test/package-smoke.mjs", + "test:ci": "pnpm typecheck && pnpm test && pnpm test:package", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@fastgpt/web": "workspace:*", + "@fastgpt/workflow-core": "workspace:*", + "zod": "catalog:", + "@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/changeSet.ts b/packages/workflow-cli/src/commands/changeSet.ts new file mode 100644 index 000000000000..d2e066872b80 --- /dev/null +++ b/packages/workflow-cli/src/commands/changeSet.ts @@ -0,0 +1,232 @@ +import { + WorkflowChangeSetSchema, + WorkflowCommandError, + WorkflowPlanSchema, + WorkflowValidationError, + getWorkflowChecksum, + planWorkflowChangeSet, + type WorkflowDocument, + type WorkflowPlan +} 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, writeJsonFileAtomic, writeWorkflowFileAtomic } from '../io/workflowFile'; +import type { CliAuditEvent, CliContext, CliResult } from '../type'; +import { requireString } from './helpers'; + +const readJsonInput = async ({ + source, + context, + label +}: { + source: string; + context: CliContext; + label: string; +}) => { + const content = + source === '-' + ? await context.readStdin() + : await readFile(resolve(context.cwd, source), 'utf8'); + try { + return JSON.parse(content) as unknown; + } catch { + throw new CliArgumentError(`${label} must contain valid JSON`); + } +}; + +const getChangedNodeIds = (plan: WorkflowPlan) => [ + ...new Set(plan.changes.flatMap((change) => (change.nodeId ? [change.nodeId] : []))) +]; + +const getChangedEdgeCount = (base: WorkflowDocument, target: WorkflowDocument) => { + const baseEdges = new Set(base.executionEdges.map((edge) => JSON.stringify(edge))); + const targetEdges = new Set(target.executionEdges.map((edge) => JSON.stringify(edge))); + return ( + [...baseEdges].filter((edge) => !targetEdges.has(edge)).length + + [...targetEdges].filter((edge) => !baseEdges.has(edge)).length + ); +}; + +const createDependencies = (context: CliContext) => ({ + templateProvider: context.templateProvider, + locale: context.locale, + translate: createTranslator(context.locale) +}); + +const createAuditEvent = ({ + command, + document, + targetDocument, + plan, + startedAt +}: { + command: string; + document: WorkflowDocument; + targetDocument: WorkflowDocument; + plan: WorkflowPlan; + startedAt: number; +}): CliAuditEvent => ({ + command, + appId: document.app.appId, + baseChecksum: plan.baseChecksum, + targetChecksum: plan.targetChecksum, + changedNodeIds: getChangedNodeIds(plan), + changedEdgeCount: getChangedEdgeCount(document, targetDocument), + durationMs: Date.now() - startedAt, + result: 'success' +}); + +const assertBaseChecksumUnchanged = async ({ + context, + expectedChecksum +}: { + context: CliContext; + expectedChecksum: string; +}) => { + const latestDocument = await readWorkflowFile(context.dir); + const actualChecksum = await getWorkflowChecksum(latestDocument); + if (actualChecksum !== expectedChecksum) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_BASE_CHECKSUM_MISMATCH', + severity: 'error', + params: { expected: expectedChecksum, actual: actualChecksum } + } + ]); + } +}; + +/** 从版本化 JSON 生成计划;stdin 与文件输入共用同一 Schema 和领域编排。 */ +export const planChangeSet = async ( + input: Record, + context: CliContext +): Promise => { + const startedAt = Date.now(); + const source = requireString(input, 'input'); + const changeSet = WorkflowChangeSetSchema.parse( + await readJsonInput({ source, context, label: 'ChangeSet input' }) + ); + const document = await readWorkflowFile(context.dir); + const result = await planWorkflowChangeSet({ + document, + changeSet, + dependencies: createDependencies(context) + }); + if (typeof input.output === 'string') { + await writeJsonFileAtomic(resolve(context.cwd, input.output), result.plan); + } + return { + changed: false, + checksum: result.plan.targetChecksum, + result: result.plan, + changes: result.plan.changes, + warnings: result.warnings, + audit: createAuditEvent({ + command: 'changeset plan', + document, + targetDocument: result.document, + plan: result.plan, + startedAt + }) + }; +}; + +/** 重新执行计划并校验 base/target checksum,通过确认门禁后才原子写入 workflow.json。 */ +export const applyChangeSetPlan = async ( + input: Record, + context: CliContext +): Promise => { + const startedAt = Date.now(); + const source = requireString(input, 'plan'); + const suppliedPlan = WorkflowPlanSchema.parse( + await readJsonInput({ source, context, label: 'Workflow plan' }) + ); + const document = await readWorkflowFile(context.dir); + const recomputed = await planWorkflowChangeSet({ + document, + changeSet: suppliedPlan.changeSet, + dependencies: createDependencies(context) + }); + if (recomputed.plan.targetChecksum !== suppliedPlan.targetChecksum) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_TARGET_CHECKSUM_MISMATCH', + severity: 'error', + params: { + expected: suppliedPlan.targetChecksum, + actual: recomputed.plan.targetChecksum + } + } + ]); + } + if (JSON.stringify(recomputed.plan) !== JSON.stringify(suppliedPlan)) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_PLAN_CONTENT_MISMATCH', + severity: 'error', + params: { targetChecksum: recomputed.plan.targetChecksum } + } + ]); + } + if (recomputed.plan.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + throw new WorkflowValidationError(recomputed.plan.diagnostics); + } + + const dryRun = input.dryRun === true; + if (!dryRun) { + const targetChecksum = recomputed.plan.targetChecksum; + if (typeof input.confirm === 'string' && input.confirm !== targetChecksum) { + throw new CliArgumentError( + '--confirm must equal the recomputed target checksum', + { expected: targetChecksum, actual: input.confirm }, + 'CLI_CONFIRM_CHECKSUM_MISMATCH' + ); + } + if (input.confirm === undefined) { + if (!context.isTTY) { + throw new CliArgumentError( + 'Non-interactive apply requires --confirm with the target checksum', + { targetChecksum }, + 'CLI_CONFIRM_REQUIRED' + ); + } + if (!(await context.requestConfirmation(targetChecksum))) { + throw new CliArgumentError( + 'Workflow plan was not confirmed', + { targetChecksum }, + 'CLI_CONFIRM_REJECTED' + ); + } + } + await assertBaseChecksumUnchanged({ + context, + expectedChecksum: recomputed.plan.baseChecksum + }); + await writeWorkflowFileAtomic(context.dir, recomputed.document); + } + + return { + changed: true, + checksum: recomputed.plan.targetChecksum, + result: { + dryRun, + baseChecksum: recomputed.plan.baseChecksum, + targetChecksum: recomputed.plan.targetChecksum, + diagnostics: recomputed.plan.diagnostics + }, + changes: recomputed.plan.changes, + warnings: [ + ...recomputed.warnings, + ...recomputed.plan.diagnostics.filter((diagnostic) => diagnostic.severity === 'warning') + ], + audit: createAuditEvent({ + command: 'changeset apply', + document, + targetDocument: recomputed.document, + plan: recomputed.plan, + startedAt + }) + }; +}; diff --git a/packages/workflow-cli/src/commands/config.ts b/packages/workflow-cli/src/commands/config.ts new file mode 100644 index 000000000000..ded3fb8d18dd --- /dev/null +++ b/packages/workflow-cli/src/commands/config.ts @@ -0,0 +1,58 @@ +import { getChatConfigDescriptor, listChatConfigDescriptors } from '@fastgpt/workflow-core'; +import { createTranslator } from '../i18n'; +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: listChatConfigDescriptors({ + document, + translate: createTranslator(context.locale) + }) + }; +}; + +export const getConfig = async ( + input: Record, + context: CliContext +): Promise => { + const path = requireString(input, 'path'); + return { + changed: false, + result: getChatConfigDescriptor({ + document: await readWorkflowFile(context.dir), + path, + translate: createTranslator(context.locale) + }) + }; +}; + +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..d00fdc73713d --- /dev/null +++ b/packages/workflow-cli/src/commands/document.ts @@ -0,0 +1,152 @@ +import { + 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: context.templateProvider, + locale: context.locale, + translate: createTranslator(context.locale) + } + }); + if (input.dryRun !== true) await writeWorkflowFileAtomic(context.dir, result.document); + return { + changed: true, + checksum: await 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: context.templateProvider, + locale: context.locale, + translate: createTranslator(context.locale) + } + }); + if (input.dryRun !== true) await writeWorkflowFileAtomic(context.dir, document); + return { + changed: true, + checksum: await 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, + checksum: await getWorkflowChecksum(document), + 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..e760523cce13 --- /dev/null +++ b/packages/workflow-cli/src/commands/helpers.ts @@ -0,0 +1,122 @@ +import { + WorkflowValidationError, + applyWorkflowCommand, + 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: context.templateProvider, + 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..9aab6da2ff95 --- /dev/null +++ b/packages/workflow-cli/src/commands/template.ts @@ -0,0 +1,74 @@ +import { + normalizeNodeTemplateDescriptor, + formatNodeTemplateRef, + 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 context.templateProvider.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 context.templateProvider.list({ locale: context.locale })).filter( + (ref) => input.kind === undefined || ref.kind === input.kind + ); + const counts = { + builtin: 0, + teamApp: 0, + systemTool: 0, + tool: 0 + }; + refs.forEach((ref) => { + counts[ref.kind] += 1; + }); + const items = await Promise.all( + refs.map(async (ref) => { + const descriptor = await resolveDescriptor(ref, context); + return { + kind: ref.kind, + ref: formatNodeTemplateRef(ref), + template: descriptor.template, + name: descriptor.name, + intro: descriptor.intro, + flowNodeType: descriptor.flowNodeType + }; + }) + ); + return { + changed: false, + result: { + total: items.length, + counts, + items + } + }; +}; + +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 context.templateProvider.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..cd7eb1867b26 --- /dev/null +++ b/packages/workflow-cli/src/error.ts @@ -0,0 +1,10 @@ +export class CliArgumentError extends Error { + constructor( + message: string, + readonly params?: Record, + readonly code = 'CLI_ARGUMENT_INVALID' + ) { + 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..9eefd9bdf165 --- /dev/null +++ b/packages/workflow-cli/src/index.ts @@ -0,0 +1,10 @@ +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 './template/provider'; +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..6b85d8c6a70c --- /dev/null +++ b/packages/workflow-cli/src/io/workflowFile.ts @@ -0,0 +1,73 @@ +import { + WORKFLOW_DOCUMENT_MIGRATION_GUIDANCE, + WorkflowDocumentVersionError, + normalizeWorkflowDocument, + parseCompatibleWorkflowDocument, + 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 => { + try { + return parseCompatibleWorkflowDocument(input); + } catch (error) { + if (error instanceof WorkflowDocumentVersionError) { + throw new CliArgumentError(error.message, { + code: error.code, + foundVersion: error.foundVersion, + guidance: WORKFLOW_DOCUMENT_MIGRATION_GUIDANCE + }); + } + throw error; + } +}; + +export const serializeWorkflowDocument = (document: WorkflowDocument) => + `${JSON.stringify(normalizeWorkflowDocument(parseCompatibleWorkflowDocument(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..9aa03b1ba1ff --- /dev/null +++ b/packages/workflow-cli/src/output/render.ts @@ -0,0 +1,78 @@ +import type { CliAuditEvent, 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[]; + audit?: CliAuditEvent; +}; + +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 ?? [], + audit: result.audit +}); + +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..b5580c54d540 --- /dev/null +++ b/packages/workflow-cli/src/parser.ts @@ -0,0 +1,126 @@ +import { resolve } from 'node:path'; +import { ZodError } from 'zod'; +import { CliArgumentError } from './error'; +import { cliCommandRegistry, globalCliOptions, getCommandName } from './registry'; +import type { CliCommandDefinition, CliOptionDefinition, ParsedCliContext } 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: ParsedCliContext; + 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, + isTTY: false, + requestConfirmation: async () => false, + 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..51a1ee2d7e73 --- /dev/null +++ b/packages/workflow-cli/src/registry.ts @@ -0,0 +1,992 @@ +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 { applyChangeSetPlan, planChangeSet } from './commands/changeSet'; +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({ kind: z.enum(['builtin', 'teamApp', 'systemTool', 'tool']).optional() }) + .strict(), + options: [option('--kind', 'Filter by template kind')], + 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 + }, + { + path: ['changeset', 'plan'], + introducedIn: 'PR4', + kind: 'artifact', + inputSchema: z + .object({ input: z.string().min(1), output: z.string().min(1).optional() }) + .strict(), + options: [ + option('--input', 'ChangeSet JSON path or - for stdin', { required: true }), + option('--output', 'Optional plan output path') + ], + supportsDryRun: false, + confirm: 'none', + handler: planChangeSet + }, + { + path: ['changeset', 'apply'], + introducedIn: 'PR4', + kind: 'localMutation', + inputSchema: z + .object({ + plan: z.string().min(1), + dryRun: z.literal(true).optional(), + confirm: z.string().min(1).optional() + }) + .strict(), + options: [ + option('--plan', 'Workflow plan JSON path or - for stdin', { required: true }), + option('--dry-run', 'Recompute and validate without writing', { value: false }), + option('--confirm', 'Recomputed target checksum') + ], + supportsDryRun: true, + confirm: 'checksum', + handler: applyChangeSetPlan + } +]; + +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..973c27585415 --- /dev/null +++ b/packages/workflow-cli/src/run.ts @@ -0,0 +1,111 @@ +import { WorkflowCommandError, WorkflowValidationError } from '@fastgpt/workflow-core'; +import { ZodError } from 'zod'; +import { createInterface } from 'node:readline/promises'; +import { CliArgumentError } from './error'; +import { renderHelp } from './help'; +import { renderError, renderSuccess } from './output/render'; +import { parseCliArgs } from './parser'; +import { loadCliTemplateProvider } from './template/provider'; + +export const CLI_VERSION = '0.3.0-beta.1' as const; + +export const runCli = async ({ + argv, + cwd = process.cwd(), + env = process.env, + isTTY = Boolean(process.stdin.isTTY && process.stderr.isTTY), + stdin, + requestConfirmation, + stdout = (value) => process.stdout.write(`${value}\n`), + stderr = (value) => process.stderr.write(`${value}\n`) +}: { + argv: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + isTTY?: boolean; + stdin?: () => Promise; + requestConfirmation?: (targetChecksum: string) => 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(CLI_VERSION); + return 0; + } + + const parsed = parseCliArgs({ argv, cwd, env }); + const templateProvider = await loadCliTemplateProvider(env); + const context = { + ...parsed.context, + templateProvider, + isTTY, + ...(stdin ? { readStdin: stdin } : {}), + requestConfirmation: + requestConfirmation ?? + (async (targetChecksum: string) => { + const readline = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await readline.question( + `Apply workflow plan ${targetChecksum}? Type "yes" to continue: ` + ); + return answer.trim().toLowerCase() === 'yes'; + } finally { + readline.close(); + } + }) + }; + 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, 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/template/provider.ts b/packages/workflow-cli/src/template/provider.ts new file mode 100644 index 000000000000..12f24bae2a9e --- /dev/null +++ b/packages/workflow-cli/src/template/provider.ts @@ -0,0 +1,24 @@ +import { + WorkflowTemplateBundleSchema, + builtinTemplateProvider, + composeWorkflowTemplateProviders, + createWorkflowTemplateBundleProvider, + type WorkflowTemplateProvider +} from '@fastgpt/workflow-core'; +import { readFile } from 'node:fs/promises'; + +export const WORKFLOW_TEMPLATE_BUNDLE_ENV = 'FASTGPT_WORKFLOW_TEMPLATE_BUNDLE'; + +/** 从受信环境变量指定的 JSON 文件加载本次 CLI 进程可用的组合模板 Provider。 */ +export const loadCliTemplateProvider = async ( + env: NodeJS.ProcessEnv +): Promise => { + const bundlePath = env[WORKFLOW_TEMPLATE_BUNDLE_ENV]; + if (!bundlePath) return builtinTemplateProvider; + + const bundle = WorkflowTemplateBundleSchema.parse(JSON.parse(await readFile(bundlePath, 'utf8'))); + return composeWorkflowTemplateProviders([ + builtinTemplateProvider, + createWorkflowTemplateBundleProvider(bundle) + ]); +}; diff --git a/packages/workflow-cli/src/type.ts b/packages/workflow-cli/src/type.ts new file mode 100644 index 000000000000..89736f6c03b3 --- /dev/null +++ b/packages/workflow-cli/src/type.ts @@ -0,0 +1,59 @@ +import type { z } from 'zod'; +import type { WorkflowTemplateProvider } from '@fastgpt/workflow-core'; + +export type CliFormat = 'text' | 'json'; + +export type CliContext = { + readonly cwd: string; + readonly dir: string; + readonly format: CliFormat; + readonly locale: string; + readonly quiet: boolean; + readonly color: boolean; + readonly env: NodeJS.ProcessEnv; + readonly isTTY: boolean; + readonly readStdin: () => Promise; + readonly requestConfirmation: (targetChecksum: string) => Promise; + readonly templateProvider: WorkflowTemplateProvider; +}; + +export type ParsedCliContext = Omit; + +export type CliAuditEvent = { + command: string; + appId?: string; + baseChecksum?: string; + targetChecksum?: string; + changedNodeIds?: string[]; + changedEdgeCount?: number; + durationMs: number; + result: 'success' | 'rejected' | 'conflict' | 'failed'; +}; + +export type CliResult = { + changed: boolean; + checksum?: string; + result?: unknown; + changes?: unknown[]; + message?: string; + warnings?: unknown[]; + audit?: CliAuditEvent; +}; + +export type CliOptionDefinition = { + name: string; + value: boolean; + required?: boolean; + description: string; +}; + +export type CliCommandDefinition = { + path: readonly string[]; + introducedIn: 'PR1' | 'PR2' | 'PR3' | 'PR4'; + kind: 'query' | 'localMutation' | 'artifact'; + inputSchema: z.ZodType; + options: readonly CliOptionDefinition[]; + supportsDryRun: boolean; + confirm: 'none' | 'checksum'; + 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..6f19e3acf7d7 --- /dev/null +++ b/packages/workflow-cli/test/bin-smoke.mjs @@ -0,0 +1,95 @@ +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 inspected = invoke(['inspect', '--dir', workflowDir]); + const changeSet = { + schemaVersion: 'fastgpt-workflow-changeset/v1', + baseChecksum: inspected.checksum, + commands: [{ type: 'meta.update', name: 'Changed by built bin' }] + }; + const plan = invoke( + ['changeset', 'plan', '--dir', workflowDir, '--input', '-'], + JSON.stringify(changeSet) + ).result; + invoke( + ['changeset', 'apply', '--dir', workflowDir, '--plan', '-', '--confirm', plan.targetChecksum], + JSON.stringify(plan) + ); + assert.equal(invoke(['inspect', '--dir', workflowDir]).result.app.name, 'Changed by built bin'); + + 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/changeSet.test.ts b/packages/workflow-cli/test/changeSet.test.ts new file mode 100644 index 000000000000..99ab33d3062d --- /dev/null +++ b/packages/workflow-cli/test/changeSet.test.ts @@ -0,0 +1,301 @@ +import { runCli } from '../src'; +import { mkdtemp, readFile } 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-changeset-')); + const dir = join(cwd, 'workflow'); + const invoke = async ({ + args, + stdin = '', + isTTY = false, + requestConfirmation + }: { + args: string[]; + stdin?: string; + isTTY?: boolean; + requestConfirmation?: (targetChecksum: string) => Promise; + }) => { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCode = await runCli({ + argv: [...args, '--dir', dir, '--format', 'json'], + cwd, + env: { NODE_ENV: 'test' }, + isTTY, + stdin: async () => stdin, + requestConfirmation, + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value) + }); + return { + exitCode, + stdout: stdout[0] ? JSON.parse(stdout[0]) : undefined, + stderr: stderr[0] ? JSON.parse(stderr[0]) : undefined + }; + }; + await invoke({ args: ['init', '--name', 'Initial'] }); + return { cwd, dir, invoke }; +}; + +const createChangeSet = ({ baseChecksum, name }: { baseChecksum: string; name: string }) => ({ + schemaVersion: 'fastgpt-workflow-changeset/v1', + baseChecksum, + commands: [{ type: 'meta.update', name }] +}); + +const inspect = async (invoke: Awaited>['invoke']) => + invoke({ args: ['inspect'] }); + +const plan = async ({ + invoke, + baseChecksum, + name, + output +}: { + invoke: Awaited>['invoke']; + baseChecksum: string; + name: string; + output?: string; +}) => + invoke({ + args: ['changeset', 'plan', '--input', '-', ...(output ? ['--output', output] : [])], + stdin: JSON.stringify(createChangeSet({ baseChecksum, name })) + }); + +describe('PR4 ChangeSet CLI', () => { + it('plans through stdin, supports an optional file, and applies only after checksum confirmation', async () => { + const { cwd, dir, invoke } = await createHarness(); + const before = await readFile(join(dir, 'workflow.json'), 'utf8'); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const planned = await plan({ + invoke, + baseChecksum, + name: 'Applied', + output: 'workflow.plan.json' + }); + expect(planned.exitCode).toBe(0); + expect(planned.stdout).toMatchObject({ + command: 'changeset plan', + changed: false, + result: { + schemaVersion: 'fastgpt-workflow-plan/v1', + baseChecksum, + targetChecksum: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) + }, + audit: { + command: 'changeset plan', + baseChecksum, + result: 'success' + } + }); + expect(JSON.parse(await readFile(join(cwd, 'workflow.plan.json'), 'utf8'))).toEqual( + planned.stdout.result + ); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + + const dryRun = await invoke({ + args: ['changeset', 'apply', '--plan', '-', '--dry-run'], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(dryRun.exitCode).toBe(0); + expect(dryRun.stdout.result.dryRun).toBe(true); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + + const missingConfirm = await invoke({ + args: ['changeset', 'apply', '--plan', '-'], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(missingConfirm).toMatchObject({ + exitCode: 2, + stderr: { errors: [{ code: 'CLI_CONFIRM_REQUIRED' }] } + }); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + + const wrongConfirm = await invoke({ + args: ['changeset', 'apply', '--plan', '-', '--confirm', `sha256:${'0'.repeat(64)}`], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(wrongConfirm).toMatchObject({ + exitCode: 2, + stderr: { errors: [{ code: 'CLI_CONFIRM_CHECKSUM_MISMATCH' }] } + }); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + + const applied = await invoke({ + args: [ + 'changeset', + 'apply', + '--plan', + '-', + '--confirm', + planned.stdout.result.targetChecksum + ], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(applied.exitCode).toBe(0); + expect(applied.stdout).toMatchObject({ + changed: true, + checksum: planned.stdout.result.targetChecksum, + result: { dryRun: false }, + audit: { command: 'changeset apply', result: 'success' } + }); + expect((await inspect(invoke)).stdout.result.app.name).toBe('Applied'); + }); + + it('invalidates an old plan after a manual mutation', async () => { + const { dir, invoke } = await createHarness(); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const planned = await plan({ invoke, baseChecksum, name: 'Automated' }); + await invoke({ args: ['meta', 'set', '--name', 'Manual'] }); + const manuallyChanged = await readFile(join(dir, 'workflow.json'), 'utf8'); + + const result = await invoke({ + args: [ + 'changeset', + 'apply', + '--plan', + '-', + '--confirm', + planned.stdout.result.targetChecksum + ], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(result).toMatchObject({ + exitCode: 3, + stderr: { + errors: [ + { + diagnostics: [expect.objectContaining({ code: 'WORKFLOW_BASE_CHECKSUM_MISMATCH' })] + } + ] + } + }); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(manuallyChanged); + }); + + it('rejects a plan whose review summary was tampered with', async () => { + const { dir, invoke } = await createHarness(); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const planned = await plan({ invoke, baseChecksum, name: 'Hidden change' }); + const before = await readFile(join(dir, 'workflow.json'), 'utf8'); + planned.stdout.result.changes = []; + const result = await invoke({ + args: [ + 'changeset', + 'apply', + '--plan', + '-', + '--confirm', + planned.stdout.result.targetChecksum + ], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(result.exitCode).toBe(3); + expect(result.stderr.errors[0].diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'WORKFLOW_PLAN_CONTENT_MISMATCH' })]) + ); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + }); + + it('prompts only in TTY mode and preserves the file when confirmation is rejected', async () => { + const { dir, invoke } = await createHarness(); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const planned = await plan({ invoke, baseChecksum, name: 'TTY applied' }); + const before = await readFile(join(dir, 'workflow.json'), 'utf8'); + let promptedChecksum = ''; + + const rejected = await invoke({ + args: ['changeset', 'apply', '--plan', '-'], + stdin: JSON.stringify(planned.stdout.result), + isTTY: true, + requestConfirmation: async (targetChecksum) => { + promptedChecksum = targetChecksum; + return false; + } + }); + expect(promptedChecksum).toBe(planned.stdout.result.targetChecksum); + expect(rejected).toMatchObject({ + exitCode: 2, + stderr: { errors: [{ code: 'CLI_CONFIRM_REJECTED' }] } + }); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + + const accepted = await invoke({ + args: ['changeset', 'apply', '--plan', '-'], + stdin: JSON.stringify(planned.stdout.result), + isTTY: true, + requestConfirmation: async () => true + }); + expect(accepted.exitCode).toBe(0); + expect((await inspect(invoke)).stdout.result.app.name).toBe('TTY applied'); + }); + + it('rechecks the base checksum after an interactive confirmation', async () => { + const { dir, invoke } = await createHarness(); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const planned = await plan({ invoke, baseChecksum, name: 'Must not overwrite' }); + const result = await invoke({ + args: ['changeset', 'apply', '--plan', '-'], + stdin: JSON.stringify(planned.stdout.result), + isTTY: true, + requestConfirmation: async () => { + expect( + (await invoke({ args: ['meta', 'set', '--name', 'Concurrent edit'] })).exitCode + ).toBe(0); + return true; + } + }); + expect(result.exitCode).toBe(3); + expect(result.stderr.errors[0].diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'WORKFLOW_BASE_CHECKSUM_MISMATCH' })]) + ); + expect(JSON.parse(await readFile(join(dir, 'workflow.json'), 'utf8')).app.name).toBe( + 'Concurrent edit' + ); + }); + + it('rejects malformed stdin and validation failures without writing', async () => { + const { dir, invoke } = await createHarness(); + expect(await invoke({ args: ['changeset', 'plan', '--input', '-'], stdin: '{' })).toMatchObject( + { exitCode: 2 } + ); + + const before = await readFile(join(dir, 'workflow.json'), 'utf8'); + const baseChecksum = (await inspect(invoke)).stdout.checksum as string; + const invalidChangeSet = { + schemaVersion: 'fastgpt-workflow-changeset/v1', + baseChecksum, + commands: [ + { + type: 'node.add', + nodeId: 'unreachable-answer', + template: { kind: 'builtin', templateId: 'assigned-answer' } + } + ] + }; + const planned = await invoke({ + args: ['changeset', 'plan', '--input', '-'], + stdin: JSON.stringify(invalidChangeSet) + }); + expect(planned.exitCode).toBe(0); + expect(planned.stdout.result.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ severity: 'error' })]) + ); + const applied = await invoke({ + args: [ + 'changeset', + 'apply', + '--plan', + '-', + '--confirm', + planned.stdout.result.targetChecksum + ], + stdin: JSON.stringify(planned.stdout.result) + }); + expect(applied.exitCode).toBe(4); + expect(await readFile(join(dir, 'workflow.json'), 'utf8')).toBe(before); + }); +}); diff --git a/packages/workflow-cli/test/e2e.test.ts b/packages/workflow-cli/test/e2e.test.ts new file mode 100644 index 000000000000..2715f5dc9ddc --- /dev/null +++ b/packages/workflow-cli/test/e2e.test.ts @@ -0,0 +1,1186 @@ +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 PR4 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).toEqual([ + 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', '--kind', 'builtin'])); + expect(JSON.parse(templateList.stdout[0]).result).toMatchObject({ + total: 22, + counts: { builtin: 22, teamApp: 0, systemTool: 0, tool: 0 } + }); + 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(JSON.parse(envResult.stdout[0])).toEqual({ + schemaVersion: 'fastgpt-workflow-cli-result/v1', + ok: true, + command: 'input set', + changed: true, + checksum: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + result: { dryRun: false }, + changes: [{ type: 'input.set', nodeId: 'ai', inputKey: 'maxToken' }], + warnings: [] + }); + 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.3.0-beta.1']); + 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('imports workflow config without injecting abandoned nodes', 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 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).not.toContainEqual( + expect.objectContaining({ 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); + const fileSelectDescriptor = JSON.parse( + ( + await invoke( + jsonArgs(dir, ['config', 'get', '--path', 'fileSelectConfig', '--locale', 'zh-CN']) + ) + ).stdout[0] + ).result; + expect(fileSelectDescriptor).toMatchObject({ + path: 'fileSelectConfig', + description: expect.stringContaining('用户从对话入口提供的文件'), + capabilities: ['user-file-input'], + value: { maxFiles: 1, canSelectFile: true }, + valueSchema: { + type: 'object', + properties: { + canSelectFile: expect.objectContaining({ type: 'boolean' }) + } + } + }); + 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..0201f31f759c --- /dev/null +++ b/packages/workflow-cli/test/io/workflowFile.test.ts @@ -0,0 +1,57 @@ +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' }); + }); + + it('reports unsupported schema versions with migration guidance', async () => { + const dir = await mkdtemp(join(tmpdir(), 'workflow-version-')); + await writeFile( + join(dir, 'workflow.json'), + JSON.stringify({ ...aiWorkflow, schemaVersion: 'fastgpt-workflow/v2' }), + 'utf8' + ); + await expect(readWorkflowFile(dir)).rejects.toMatchObject({ + code: 'CLI_ARGUMENT_INVALID', + params: { + code: 'WORKFLOW_DOCUMENT_VERSION_UNSUPPORTED', + foundVersion: 'fastgpt-workflow/v2', + guidance: expect.stringContaining('migrate one version at a time') + } + }); + }); +}); diff --git a/packages/workflow-cli/test/output.test.ts b/packages/workflow-cli/test/output.test.ts new file mode 100644 index 000000000000..ee79318c4dc9 --- /dev/null +++ b/packages/workflow-cli/test/output.test.ts @@ -0,0 +1,45 @@ +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: [], + audit: undefined + }); + 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/package-smoke.mjs b/packages/workflow-cli/test/package-smoke.mjs new file mode 100644 index 000000000000..52068de270c0 --- /dev/null +++ b/packages/workflow-cli/test/package-smoke.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } 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 workspace = await mkdtemp(join(tmpdir(), 'workflow-cli-package-')); +const packDirectory = join(workspace, 'pack'); +const previousSourceDirectory = join(workspace, 'previous-source'); +const previousPackDirectory = join(workspace, 'previous-pack'); +const installDirectory = join(workspace, 'install'); +const workflowDirectory = join(workspace, 'workflow'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: packageRoot, + env: { ...process.env, NODE_ENV: 'test' }, + encoding: 'utf8', + ...options + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +}; + +try { + await mkdir(packDirectory, { recursive: true }); + run('pnpm', ['pack', '--pack-destination', packDirectory]); + const tarballName = (await readdir(packDirectory)).find((file) => file.endsWith('.tgz')); + assert.ok(tarballName); + const tarballPath = join(packDirectory, tarballName); + + await mkdir(previousSourceDirectory, { recursive: true }); + await mkdir(previousPackDirectory, { recursive: true }); + run('tar', ['-xzf', tarballPath, '-C', previousSourceDirectory]); + const previousPackageRoot = join(previousSourceDirectory, 'package'); + const previousPackageJsonPath = join(previousPackageRoot, 'package.json'); + const previousPackageJson = JSON.parse(await readFile(previousPackageJsonPath, 'utf8')); + previousPackageJson.version = '0.2.0-beta.0'; + await writeFile(previousPackageJsonPath, `${JSON.stringify(previousPackageJson, null, 2)}\n`); + const previousDistDirectory = join(previousPackageRoot, 'dist'); + for (const file of await readdir(previousDistDirectory)) { + if (!file.endsWith('.js')) continue; + const filePath = join(previousDistDirectory, file); + const content = await readFile(filePath, 'utf8'); + await writeFile(filePath, content.replaceAll('0.3.0-beta.1', '0.2.0-beta.0')); + } + run('npm', ['pack', '--pack-destination', previousPackDirectory], { + cwd: previousPackageRoot + }); + const previousTarballName = (await readdir(previousPackDirectory)).find((file) => + file.endsWith('.tgz') + ); + assert.ok(previousTarballName); + const previousTarballPath = join(previousPackDirectory, previousTarballName); + + const install = (packagePath) => + run('npm', [ + 'install', + '--prefix', + installDirectory, + '--ignore-scripts', + '--offline', + packagePath + ]); + + const binPath = join(installDirectory, 'node_modules', '.bin', 'fastgpt-workflow'); + install(previousTarballPath); + assert.equal(run(binPath, ['--version']), '0.2.0-beta.0'); + run(binPath, ['init', '--dir', workflowDirectory, '--format', 'json']); + + install(tarballPath); + assert.equal(run(binPath, ['--version']), '0.3.0-beta.1'); + const inspected = JSON.parse( + run(binPath, ['inspect', '--dir', workflowDirectory, '--format', 'json']) + ); + assert.match(inspected.checksum, /^sha256:[a-f0-9]{64}$/); + + install(previousTarballPath); + assert.equal(run(binPath, ['--version']), '0.2.0-beta.0'); + JSON.parse(run(binPath, ['inspect', '--dir', workflowDirectory, '--format', 'json'])); + + install(tarballPath); + assert.equal(run(binPath, ['--version']), '0.3.0-beta.1'); + process.stdout.write('workflow-cli package install, upgrade and rollback smoke passed\n'); +} finally { + await rm(workspace, { recursive: true, force: true }); +} 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..f8a94636c35d --- /dev/null +++ b/packages/workflow-cli/test/registry.test.ts @@ -0,0 +1,603 @@ +import { cliCommandRegistry, renderHelp } from '../src'; +import { describe, expect, it } from 'vitest'; + +describe('cliCommandRegistry', () => { + it('registers exactly the PR1 through PR4 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": [ + "--kind", + ], + "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, + }, + { + "confirm": "none", + "introducedIn": "PR4", + "kind": "artifact", + "options": [ + "--input", + "--output", + ], + "path": "changeset plan", + "supportsDryRun": false, + }, + { + "confirm": "checksum", + "introducedIn": "PR4", + "kind": "localMutation", + "options": [ + "--plan", + "--dry-run", + "--confirm", + ], + "path": "changeset apply", + "supportsDryRun": true, + }, + ] + `); + }); +}); + +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).toContain('changeset plan'); + expect(help).toContain('changeset apply'); + expect( + renderHelp(cliCommandRegistry.find((item) => item.path.join(' ') === 'build')) + ).toContain('--output (required)'); + }); +}); diff --git a/packages/workflow-cli/test/templateContract.test.ts b/packages/workflow-cli/test/templateContract.test.ts new file mode 100644 index 000000000000..7be1de6348e1 --- /dev/null +++ b/packages/workflow-cli/test/templateContract.test.ts @@ -0,0 +1,72 @@ +import { listTemplates, showTemplate } from '../src/commands/template'; +import type { CliContext } from '../src/type'; +import { builtinTemplateProvider } from '@fastgpt/workflow-core'; +import { describe, expect, it } from 'vitest'; + +const context = { + locale: 'en', + format: 'json', + templateProvider: builtinTemplateProvider +} as CliContext; + +describe('template contract commands', () => { + it('returns versioned contracts for all builtin templates', async () => { + const result = await listTemplates({}, context); + const output = result.result as { + total: number; + counts: Record; + items: Array>; + }; + + expect(output).toMatchObject({ + total: 22, + counts: { builtin: 22, teamApp: 0, systemTool: 0, tool: 0 } + }); + expect(output.items).toContainEqual( + expect.objectContaining({ + kind: 'builtin', + ref: 'builtin:ai-chat', + template: { kind: 'builtin', templateId: 'ai-chat' } + }) + ); + }); + + it('returns branch semantics through template show', async () => { + const result = await showTemplate({ template: 'builtin:if-else' }, context); + + expect(result.result).toMatchObject({ + schemaVersion: 'fastgpt-workflow-node-contract/v1', + template: { kind: 'builtin', templateId: 'if-else' }, + execution: { + sourceKinds: ['branch'], + targetKinds: ['target'], + terminal: false, + branch: { + inputKey: 'ifElseList', + keyField: 'branchId', + keyFieldRequiredForNewValues: true, + fallbackKey: 'ELSE', + configureBeforeConnect: true + } + } + }); + }); + + it('returns Core reference compatibility through template show', async () => { + const result = await showTemplate({ template: 'builtin:dataset-search' }, context); + const descriptor = result.result as { + inputs: Array<{ + valueType?: string; + referencePolicy?: { acceptedSourceValueTypes: string[] }; + }>; + }; + const searchInput = descriptor.inputs.find((input) => input.valueType === 'arrayString'); + + expect(searchInput?.referencePolicy?.acceptedSourceValueTypes).toEqual([ + 'string', + 'arrayString', + 'arrayAny', + 'any' + ]); + }); +}); diff --git a/packages/workflow-cli/test/templateProvider.test.ts b/packages/workflow-cli/test/templateProvider.test.ts new file mode 100644 index 000000000000..b33b7109c66c --- /dev/null +++ b/packages/workflow-cli/test/templateProvider.test.ts @@ -0,0 +1,125 @@ +import { builtinTemplateProvider } from '@fastgpt/workflow-core'; +import { runCli } from '../src'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('request-scoped CLI template provider', () => { + it('lists and shows authorized system tools from the injected bundle', async () => { + const directory = await mkdtemp(join(tmpdir(), 'workflow-cli-templates-')); + const workflowDirectory = join(directory, 'workflow'); + const bundleFile = join(directory, 'template-bundle.json'); + const source = 'debug:tmbId:session-1'; + const toolId = 'systemTool-weather/forecast'; + const { template: workflowStartTemplate } = await builtinTemplateProvider.resolve( + { kind: 'builtin', templateId: 'workflow-start' }, + { locale: 'en' } + ); + await writeFile( + bundleFile, + JSON.stringify({ + schemaVersion: 'fastgpt-workflow-template-bundle/v1', + items: [ + { + ref: { kind: 'systemTool', source, toolId }, + template: { + ...workflowStartTemplate, + id: toolId, + pluginId: toolId, + flowNodeType: 'tool', + unique: false, + name: 'Weather forecast', + source, + inputs: [ + { + key: 'userChatInput', + label: 'Query', + valueType: 'string', + renderTypeList: ['input', 'reference'], + selectedType: 'input', + required: true + } + ], + outputs: [], + toolConfig: { systemTool: { toolId, source } } + } + } + ] + }) + ); + + const invoke = async (argv: string[]) => { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCode = await runCli({ + argv: [...argv, '--dir', workflowDirectory, '--format', 'json'], + cwd: directory, + env: { FASTGPT_WORKFLOW_TEMPLATE_BUNDLE: bundleFile }, + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value) + }); + return { exitCode, stdout, stderr }; + }; + + try { + const list = await invoke(['template', 'list', '--kind', 'systemTool']); + expect(list.exitCode).toBe(0); + expect(JSON.parse(list.stdout[0]).result).toMatchObject({ + total: 1, + counts: { builtin: 0, teamApp: 0, systemTool: 1, tool: 0 }, + items: [ + { + kind: 'systemTool', + ref: 'systemTool:debug%3AtmbId%3Asession-1/systemTool-weather%2Fforecast', + template: { kind: 'systemTool', source, toolId }, + name: 'Weather forecast' + } + ] + }); + + const show = await invoke([ + 'template', + 'show', + '--template', + 'systemTool:debug%3AtmbId%3Asession-1/systemTool-weather%2Fforecast' + ]); + expect(show.exitCode).toBe(0); + expect(JSON.parse(show.stdout[0]).result).toMatchObject({ + template: { kind: 'systemTool', source, toolId }, + name: 'Weather forecast' + }); + + const initialized = await invoke(['init', '--name', 'System tool workflow']); + expect(initialized.exitCode, JSON.stringify(initialized)).toBe(0); + const add = await invoke([ + 'node', + 'add', + '--node', + 'weather', + '--template', + 'systemTool:debug%3AtmbId%3Asession-1/systemTool-weather%2Fforecast' + ]); + expect(add.exitCode).toBe(0); + const workflow = JSON.parse(await readFile(join(workflowDirectory, 'workflow.json'), 'utf8')); + const weatherNode = workflow.nodes.find( + (node: { nodeId: string }) => node.nodeId === 'weather' + ); + expect(weatherNode).toMatchObject({ + flowNodeType: 'tool', + pluginId: toolId, + source, + inputs: [ + { + key: 'userChatInput', + value: ['start', 'userChatInput'], + selectedType: 'reference' + } + ] + }); + expect(weatherNode.inputs[0]).not.toHaveProperty('selectedTypeIndex'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); 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..41115f7ab7e5 --- /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/, /^zod/], + 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..e98d7fc2e5e4 --- /dev/null +++ b/packages/workflow-core/package.json @@ -0,0 +1,36 @@ +{ + "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:*", + "@types/crypto-js": "catalog:", + "crypto-js": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "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..97d1d61d558a --- /dev/null +++ b/packages/workflow-core/src/command/apply.ts @@ -0,0 +1,455 @@ +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); + const shouldSyncFormInputOutputs = + inputNode.flowNodeType === FlowNodeTypeEnum.formInput && + command.inputKey === NodeInputKeyEnum.userInputForms; + let previousFormFieldKeys: string[] = []; + if (shouldSyncFormInputOutputs) { + 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 + }); + if (shouldSyncFormInputOutputs) { + 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: await getWorkflowChecksum(nextDocument) + }; +}; diff --git a/packages/workflow-core/src/command/applyChangeSet.ts b/packages/workflow-core/src/command/applyChangeSet.ts new file mode 100644 index 000000000000..48c7cf1f5a41 --- /dev/null +++ b/packages/workflow-core/src/command/applyChangeSet.ts @@ -0,0 +1,94 @@ +import { getWorkflowChecksum } from '../domain/checksum'; +import { WorkflowCommandError, type WorkflowDiagnostic } from '../domain/diagnostic'; +import type { WorkflowDocument } from '../domain/document'; +import type { WorkflowTemplateProvider } from '../template/type'; +import { validateWorkflow } from '../validation'; +import { applyWorkflowCommand } from './apply'; +import { + WORKFLOW_PLAN_SCHEMA_VERSION, + WorkflowChangeSetSchema, + WorkflowPlanSchema, + type WorkflowChangeSet, + type WorkflowChangeSummary, + type WorkflowPlan +} from './type'; + +export type WorkflowChangeSetResult = { + document: WorkflowDocument; + changes: WorkflowChangeSummary[]; + warnings: WorkflowDiagnostic[]; + diagnostics: WorkflowDiagnostic[]; + checksum: string; +}; + +type ChangeSetDependencies = { + templateProvider: WorkflowTemplateProvider; + locale?: string; + translate?: (value: string) => string; +}; + +/** + * 在内存副本中按顺序执行 ChangeSet。任一命令失败时不返回中间文档,所有领域修改仍复用单命令 dispatcher。 + */ +export const applyWorkflowChangeSet = async ({ + document, + changeSet: rawChangeSet, + dependencies +}: { + document: WorkflowDocument; + changeSet: WorkflowChangeSet; + dependencies: ChangeSetDependencies; +}): Promise => { + const changeSet = WorkflowChangeSetSchema.parse(rawChangeSet); + const baseChecksum = await getWorkflowChecksum(document); + if (changeSet.baseChecksum !== baseChecksum) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_BASE_CHECKSUM_MISMATCH', + severity: 'error', + params: { expected: changeSet.baseChecksum, actual: baseChecksum } + } + ]); + } + + 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); + } + + const diagnostics = validateWorkflow(nextDocument); + return { + document: nextDocument, + changes, + warnings, + diagnostics, + checksum: await getWorkflowChecksum(nextDocument) + }; +}; + +/** 生成可审阅计划但不写入文档;apply 阶段必须重新执行并比较 targetChecksum。 */ +export const planWorkflowChangeSet = async ({ + document, + changeSet, + dependencies +}: { + document: WorkflowDocument; + changeSet: WorkflowChangeSet; + dependencies: ChangeSetDependencies; +}): Promise<{ plan: WorkflowPlan; document: WorkflowDocument; warnings: WorkflowDiagnostic[] }> => { + const result = await applyWorkflowChangeSet({ document, changeSet, dependencies }); + const plan = WorkflowPlanSchema.parse({ + schemaVersion: WORKFLOW_PLAN_SCHEMA_VERSION, + baseChecksum: changeSet.baseChecksum, + targetChecksum: result.checksum, + changeSet, + changes: result.changes, + diagnostics: result.diagnostics + }); + return { plan, document: result.document, warnings: result.warnings }; +}; diff --git a/packages/workflow-core/src/command/mergeChangeSet.ts b/packages/workflow-core/src/command/mergeChangeSet.ts new file mode 100644 index 000000000000..84c1dee49af0 --- /dev/null +++ b/packages/workflow-core/src/command/mergeChangeSet.ts @@ -0,0 +1,210 @@ +import { + WorkflowChangeSetChunkSchema, + WorkflowChangeSetSchema, + type WorkflowChangeSet, + type WorkflowChangeSetChunk, + type WorkflowCommand +} from './type'; + +export class WorkflowChangeSetMergeError extends Error { + readonly code: string; + readonly params?: Record; + + constructor(code: string, message: string, params?: Record) { + super(message); + this.name = 'WorkflowChangeSetMergeError'; + this.code = code; + this.params = params; + } +} + +const stableJson = (value: unknown) => JSON.stringify(value); + +export const getWorkflowCommandTarget = (command: WorkflowCommand): string | undefined => { + switch (command.type) { + case 'node.add': + case 'node.update': + case 'node.move': + case 'node.insert': + case 'node.remove': + return `node:${command.nodeId}`; + case 'node.clone': + return `node:${command.nodeId}`; + case 'input.set': + case 'input.ref': + case 'input.unset': + return `input:${command.nodeId}:${command.inputKey}`; + case 'input.add': + return `input:${command.nodeId}:${command.input.key}`; + case 'input.remove': + return `input:${command.nodeId}:${command.inputKey}`; + case 'output.add': + return `output:${command.nodeId}:${command.output.key}`; + case 'output.remove': + return `output:${command.nodeId}:${command.outputKey}`; + case 'tool.attach': + return `tool:${command.toolCallNodeId}:${command.toolNodeId ?? command.newNodeId ?? ''}`; + case 'tool.detach': + return `tool:${command.toolCallNodeId}:${command.toolNodeId}`; + case 'config.set': + case 'config.unset': + return `config:${command.path}`; + case 'variable.add': + return `variable:${command.variable.key}`; + case 'variable.update': + case 'variable.remove': + return `variable:${command.key}`; + case 'edge.connect': + case 'edge.disconnect': + return `edge:${stableJson(command.edge)}`; + case 'edge.reconnect': + return `edge:${stableJson(command.oldEdge)}`; + case 'meta.update': + return 'meta'; + } +}; + +const mergePatchCommand = ( + left: WorkflowCommand, + right: WorkflowCommand +): WorkflowCommand | undefined => { + if (left.type !== right.type) return; + if (left.type === 'node.update' && right.type === 'node.update') { + const leftFields = Object.keys(left).filter((key) => key !== 'type' && key !== 'nodeId'); + const rightFields = Object.keys(right).filter((key) => key !== 'type' && key !== 'nodeId'); + if (rightFields.some((key) => leftFields.includes(key))) return; + return { ...left, ...right }; + } + if (left.type === 'meta.update' && right.type === 'meta.update') { + const leftFields = Object.keys(left).filter((key) => key !== 'type'); + const rightFields = Object.keys(right).filter((key) => key !== 'type'); + if (rightFields.some((key) => leftFields.includes(key))) return; + return { ...left, ...right }; + } + if (left.type === 'variable.update' && right.type === 'variable.update') { + const leftFields = Object.keys(left.patch); + const rightFields = Object.keys(right.patch); + if (rightFields.some((key) => leftFields.includes(key))) return; + return { ...left, patch: { ...left.patch, ...right.patch } }; + } + return; +}; + +const sortChunks = (chunks: WorkflowChangeSetChunk[]) => { + const byId = new Map(chunks.map((chunk) => [chunk.chunkId, chunk])); + const state = new Map(); + const sorted: WorkflowChangeSetChunk[] = []; + const visit = (chunk: WorkflowChangeSetChunk) => { + const currentState = state.get(chunk.chunkId); + if (currentState === 'visited') return; + if (currentState === 'visiting') { + throw new WorkflowChangeSetMergeError( + 'WORKFLOW_CHANGESET_CHUNK_DEPENDENCY_CYCLE', + `Chunk dependency cycle includes ${chunk.chunkId}` + ); + } + state.set(chunk.chunkId, 'visiting'); + for (const dependencyId of chunk.dependsOn) { + const dependency = byId.get(dependencyId); + if (!dependency) { + throw new WorkflowChangeSetMergeError( + 'WORKFLOW_CHANGESET_CHUNK_DEPENDENCY_MISSING', + `Chunk ${chunk.chunkId} depends on missing chunk ${dependencyId}` + ); + } + visit(dependency); + } + state.set(chunk.chunkId, 'visited'); + sorted.push(chunk); + }; + chunks.forEach(visit); + return sorted; +}; + +/** + * 按依赖关系合并分片,并拒绝会让结果依赖到达顺序的冲突命令。 + * 该函数只产生完整 ChangeSet,不执行任何工作流写入。 + */ +export const mergeWorkflowChangeSetChunks = ({ + baseChecksum, + chunks +}: { + baseChecksum: string; + chunks: WorkflowChangeSetChunk[]; +}): WorkflowChangeSet => { + if (chunks.length === 0) { + throw new WorkflowChangeSetMergeError( + 'WORKFLOW_CHANGESET_CHUNKS_EMPTY', + 'At least one ChangeSet chunk is required' + ); + } + const parsedChunks = chunks.map((chunk) => WorkflowChangeSetChunkSchema.parse(chunk)); + const sortedChunks = sortChunks(parsedChunks); + const commands: WorkflowCommand[] = []; + const commandIndexes = new Map(); + const commandContributors = new Map< + string, + { + command: WorkflowCommand; + origin: { + chunkId: string; + commandIndex: number; + commandType: WorkflowCommand['type']; + }; + }[] + >(); + + for (const chunk of sortedChunks) { + for (const [commandIndex, command] of chunk.commands.entries()) { + const target = getWorkflowCommandTarget(command); + const identity = target ?? stableJson(command); + const incomingOrigin = { + chunkId: chunk.chunkId, + commandIndex, + commandType: command.type + }; + const existingIndex = commandIndexes.get(identity); + if (existingIndex === undefined) { + commandIndexes.set(identity, commands.length); + commandContributors.set(identity, [{ command, origin: incomingOrigin }]); + commands.push(command); + continue; + } + const existing = commands[existingIndex]; + const contributors = commandContributors.get(identity) ?? []; + if ( + stableJson(existing) === stableJson(command) || + contributors.some((contributor) => stableJson(contributor.command) === stableJson(command)) + ) { + continue; + } + const merged = mergePatchCommand(existing, command); + if (merged) { + commands[existingIndex] = merged; + contributors.push({ command, origin: incomingOrigin }); + continue; + } + const conflictingContributor = + contributors.find( + (contributor) => + contributor.command.type !== command.type || + mergePatchCommand(contributor.command, command) === undefined + ) ?? contributors[0]; + throw new WorkflowChangeSetMergeError( + 'WORKFLOW_CHANGESET_COMMAND_CONFLICT', + `Conflicting commands target ${target ?? identity}`, + { + target: target ?? identity, + existing: conflictingContributor?.origin, + incoming: incomingOrigin + } + ); + } + } + + return WorkflowChangeSetSchema.parse({ + schemaVersion: 'fastgpt-workflow-changeset/v1', + baseChecksum, + commands + }); +}; diff --git a/packages/workflow-core/src/command/type.ts b/packages/workflow-core/src/command/type.ts new file mode 100644 index 000000000000..5d9b03c7de2f --- /dev/null +++ b/packages/workflow-core/src/command/type.ts @@ -0,0 +1,280 @@ +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/variable/type'; +import { + FlowNodeInputItemTypeSchema, + FlowNodeOutputItemTypeSchema +} from '@fastgpt/global/core/workflow/type/io'; +import { WorkflowDiagnosticSchema } from '../domain/diagnostic'; + +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; + +const workflowCommandTypes = new Set( + WorkflowCommandSchema.options.map((option) => option.shape.type.value) +); +export const WorkflowCommandTypeSchema = z.custom( + (value) => typeof value === 'string' && workflowCommandTypes.has(value) +); + +export const WorkflowChangeSummarySchema = z.object({ + type: WorkflowCommandTypeSchema, + nodeId: z.string().optional(), + inputKey: z.string().optional(), + path: z.string().optional(), + key: z.string().optional(), + details: z.record(z.string(), z.unknown()).optional() +}); + +export type WorkflowChangeSummary = z.infer; + +export const WORKFLOW_CHANGESET_SCHEMA_VERSION = 'fastgpt-workflow-changeset/v1' as const; +export const WORKFLOW_CHANGESET_CHUNK_SCHEMA_VERSION = 'fastgpt-workflow-chunk/v1' as const; +export const WORKFLOW_PLAN_SCHEMA_VERSION = 'fastgpt-workflow-plan/v1' as const; + +const WorkflowChecksumSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/); + +export const WorkflowChangeSetSchema = z + .object({ + schemaVersion: z.literal(WORKFLOW_CHANGESET_SCHEMA_VERSION), + baseChecksum: WorkflowChecksumSchema, + commands: z.array(WorkflowCommandSchema).min(1) + }) + .strict(); + +export type WorkflowChangeSet = z.infer; + +/** + * Agent 分片提交的最小单元。分片不携带 baseChecksum,版本和基准由服务端事务会话持有, + * 避免模型在多个分片之间自行维护容易失真的工作流版本信息。 + */ +export const WorkflowChangeSetChunkSchema = z + .object({ + schemaVersion: z.literal(WORKFLOW_CHANGESET_CHUNK_SCHEMA_VERSION), + chunkId: z.string().min(1).max(128), + dependsOn: z.array(z.string().min(1).max(128)).max(128).default([]), + commands: z.array(WorkflowCommandSchema).min(1).max(100) + }) + .strict(); + +export type WorkflowChangeSetChunk = z.infer; + +export const WorkflowPlanSchema = z + .object({ + schemaVersion: z.literal(WORKFLOW_PLAN_SCHEMA_VERSION), + baseChecksum: WorkflowChecksumSchema, + targetChecksum: WorkflowChecksumSchema, + changeSet: WorkflowChangeSetSchema, + changes: z.array(WorkflowChangeSummarySchema), + diagnostics: z.array(WorkflowDiagnosticSchema) + }) + .strict() + .refine((plan) => plan.baseChecksum === plan.changeSet.baseChecksum, { + message: 'Plan baseChecksum must match ChangeSet baseChecksum', + path: ['baseChecksum'] + }); + +export type WorkflowPlan = z.infer; diff --git a/packages/workflow-core/src/config/automationMeta.ts b/packages/workflow-core/src/config/automationMeta.ts new file mode 100644 index 000000000000..cacadd9d9232 --- /dev/null +++ b/packages/workflow-core/src/config/automationMeta.ts @@ -0,0 +1,61 @@ +import type { ChatConfigPath, ConfigAutomationMeta } from './type'; + +/** 为系统配置补充 Agent 决策语义;字段结构和基础说明仍由 ChatConfig Schema 提供。 */ +export const configAutomationMetaMap = { + welcomeText: { + agentHint: 'workflow:cli.config.welcome_text', + capabilities: ['conversation-welcome'] + }, + autoExecute: { + agentHint: 'workflow:cli.config.auto_execute', + capabilities: ['automatic-conversation-start'] + }, + 'autoExecute.open': { + agentHint: 'workflow:cli.config.auto_execute_open', + capabilities: ['automatic-conversation-start'] + }, + 'autoExecute.defaultPrompt': { + agentHint: 'workflow:cli.config.auto_execute_prompt', + capabilities: ['automatic-conversation-start'] + }, + questionGuide: { + agentHint: 'workflow:cli.config.question_guide', + capabilities: ['follow-up-question-suggestions'] + }, + 'questionGuide.open': { + agentHint: 'workflow:cli.config.question_guide_open', + capabilities: ['follow-up-question-suggestions'] + }, + 'questionGuide.model': { + agentHint: 'workflow:cli.config.question_guide_model', + capabilities: ['follow-up-question-suggestions'] + }, + 'questionGuide.customPrompt': { + agentHint: 'workflow:cli.config.question_guide_prompt', + capabilities: ['follow-up-question-suggestions'] + }, + ttsConfig: { + agentHint: 'workflow:cli.config.tts', + capabilities: ['text-to-speech'] + }, + whisperConfig: { + agentHint: 'workflow:cli.config.whisper', + capabilities: ['speech-to-text'] + }, + scheduledTriggerConfig: { + agentHint: 'workflow:cli.config.scheduled_trigger', + capabilities: ['scheduled-execution'] + }, + chatInputGuide: { + agentHint: 'workflow:cli.config.chat_input_guide', + capabilities: ['custom-chat-input'] + }, + fileSelectConfig: { + agentHint: 'workflow:cli.config.file_select', + capabilities: ['user-file-input'] + }, + instruction: { + agentHint: 'workflow:cli.config.instruction', + capabilities: ['conversation-instructions'] + } +} satisfies Record; diff --git a/packages/workflow-core/src/config/descriptor.ts b/packages/workflow-core/src/config/descriptor.ts new file mode 100644 index 000000000000..2979feeaafa2 --- /dev/null +++ b/packages/workflow-core/src/config/descriptor.ts @@ -0,0 +1,66 @@ +import { AppChatConfigTypeSchema } from '@fastgpt/global/core/app/type'; +import z from 'zod'; +import type { WorkflowDocument } from '../domain/document'; +import { configAutomationMetaMap } from './automationMeta'; +import { getChatConfigValue } from './service'; +import { CHAT_CONFIG_PATHS, type ChatConfigPath, type ConfigDescriptor } from './type'; + +const chatConfigValueSchema = z.toJSONSchema(AppChatConfigTypeSchema, { + io: 'input', + reused: 'inline', + unrepresentable: 'any' +}) as Record; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const getConfigValueSchema = (path: ChatConfigPath): Record => { + const schema = path.split('.').reduce((current, key) => { + if (!isRecord(current)) return undefined; + const properties = current.properties; + if (!isRecord(properties)) return undefined; + return properties[key]; + }, chatConfigValueSchema); + + return isRecord(schema) ? structuredClone(schema) : {}; +}; + +/** 将 ChatConfig Schema 与 Agent automation metadata 归一化为 CLI 可查询的配置契约。 */ +export const getChatConfigDescriptor = ({ + document, + path, + translate = (value) => value +}: { + document: WorkflowDocument; + path: string; + translate?: (value: string) => string; +}): ConfigDescriptor => { + const value = getChatConfigValue(document, path); + const configPath = path as ChatConfigPath; + const valueSchema = getConfigValueSchema(configPath); + const automationMeta = configAutomationMetaMap[configPath]; + const schemaDescription = valueSchema.description; + + return { + path: configPath, + description: translate( + automationMeta.agentHint ?? + (typeof schemaDescription === 'string' && schemaDescription.trim().length > 0 + ? schemaDescription + : configPath) + ), + valueSchema, + capabilities: automationMeta.capabilities ?? [], + value + }; +}; + +/** 返回全部允许修改的系统配置及其当前值和 Agent 可读契约。 */ +export const listChatConfigDescriptors = ({ + document, + translate +}: { + document: WorkflowDocument; + translate?: (value: string) => string; +}): ConfigDescriptor[] => + CHAT_CONFIG_PATHS.map((path) => getChatConfigDescriptor({ document, path, translate })); diff --git a/packages/workflow-core/src/config/service.ts b/packages/workflow-core/src/config/service.ts new file mode 100644 index 000000000000..d10f9c4e63ff --- /dev/null +++ b/packages/workflow-core/src/config/service.ts @@ -0,0 +1,267 @@ +import { AppChatConfigTypeSchema, type AppChatConfigType } from '@fastgpt/global/core/app/type'; +import { + VariableItemTypeSchema, + type VariableItemType +} from '@fastgpt/global/core/app/variable/type'; +import { NodeOutputKeyEnum, VARIABLE_NODE_ID } 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 { areWorkflowValueTypesCompatible } from '@fastgpt/global/core/workflow/utils'; +import type { WorkflowDocument } from '../domain/document'; +import { WorkflowCommandError } from '../domain/diagnostic'; +import { getWorkflowReferenceValues } from '../reference/service'; +import { CHAT_CONFIG_PATHS } from './type'; + +export { CHAT_CONFIG_PATHS } from './type'; + +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 的文件输出,避免上传能力与图引用契约分离。 */ +export 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 ?? []; + +/** 收集引用指定全局变量的节点输入及其原始引用元组。 */ +const getGlobalVariableReferences = ({ + document, + key +}: { + document: WorkflowDocument; + key: string; +}) => + document.nodes.flatMap((node) => + node.inputs.flatMap((input) => { + const references = (getWorkflowReferenceValues(input.value) ?? []).filter( + ([sourceNodeId, outputKey]) => sourceNodeId === VARIABLE_NODE_ID && outputKey === key + ); + return references.length > 0 ? [{ node, input, references }] : []; + }) + ); + +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 = getGlobalVariableReferences({ document, key }); + const incompatibleReference = references.find( + ({ input }) => + !areWorkflowValueTypesCompatible({ + expected: input.valueType, + actual: 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 { references: matchedReferences } of references) { + for (const reference of matchedReferences) { + reference[1] = 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 = getGlobalVariableReferences({ document, key }); + if (references.length > 0) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_VARIABLE_STILL_REFERENCED', + severity: 'error', + params: { + key, + references: references.map(({ node, input }) => ({ + nodeId: node.nodeId, + inputKey: input.key + })) + } + } + ]); + } + document.chatConfig.variables = variables.filter((item) => item.key !== key); +}; diff --git a/packages/workflow-core/src/config/type.ts b/packages/workflow-core/src/config/type.ts new file mode 100644 index 000000000000..167cba60e3ff --- /dev/null +++ b/packages/workflow-core/src/config/type.ts @@ -0,0 +1,31 @@ +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; + +export type ChatConfigPath = (typeof CHAT_CONFIG_PATHS)[number]; + +export type ConfigAutomationMeta = { + agentHint?: string; + capabilities?: string[]; +}; + +export type ConfigDescriptor = { + path: ChatConfigPath; + description: string; + valueSchema: Record; + capabilities: string[]; + value: unknown; +}; diff --git a/packages/workflow-core/src/domain/checksum.ts b/packages/workflow-core/src/domain/checksum.ts new file mode 100644 index 000000000000..7c31e9607fc5 --- /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 < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => [key, sortRecord(item)]) + ); +}; + +/** 返回字段和集合顺序稳定的文档副本,供序列化、比较和基础 checksum 使用。 */ +export const normalizeWorkflowDocument = (document: WorkflowDocument): WorkflowDocument => ({ + schemaVersion: document.schemaVersion, + app: sortRecord(document.app) as WorkflowDocument['app'], + nodes: [...document.nodes] + .sort((left, right) => (left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0)) + .map((node) => sortRecord(node) as WorkflowDocument['nodes'][number]), + executionEdges: [...document.executionEdges] + .map((edge) => sortRecord(edge) as WorkflowDocument['executionEdges'][number]) + .sort((left, right) => { + const leftKey = JSON.stringify(left); + const rightKey = JSON.stringify(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }), + chatConfig: sortRecord(document.chatConfig) as WorkflowDocument['chatConfig'] +}); + +/** 对规范化后的 UTF-8 JSON 计算跨 Node/Browser 一致的 SHA-256。 */ +export const getWorkflowChecksum = async (document: WorkflowDocument) => { + const canonicalDocument = JSON.stringify(normalizeWorkflowDocument(document)); + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(canonicalDocument) + ); + const checksum = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0') + ).join(''); + return `sha256:${checksum}`; +}; 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..ec0570c4851d --- /dev/null +++ b/packages/workflow-core/src/domain/document.ts @@ -0,0 +1,67 @@ +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 WORKFLOW_DOCUMENT_SUPPORTED_SCHEMA_VERSIONS = [ + WORKFLOW_DOCUMENT_SCHEMA_VERSION +] as const; +export const WORKFLOW_DOCUMENT_MIGRATION_GUIDANCE = + 'Use a CLI version that supports the source schema, migrate one version at a time, and never remove or rewrite schemaVersion manually.'; + +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 class WorkflowDocumentVersionError extends Error { + readonly code = 'WORKFLOW_DOCUMENT_VERSION_UNSUPPORTED'; + + constructor(readonly foundVersion: string | undefined) { + super( + foundVersion === undefined + ? 'workflow.json is missing schemaVersion' + : `Unsupported workflow schemaVersion: ${foundVersion}` + ); + this.name = 'WorkflowDocumentVersionError'; + } +} + +/** + * 解析版本化工作流文档。当前没有历史迁移器,因此只接受 v1,并为缺失、旧版或新版文档返回明确迁移指引。 + */ +export const parseCompatibleWorkflowDocument = (input: unknown): WorkflowDocument => { + const schemaVersion = + input && typeof input === 'object' && !Array.isArray(input) && 'schemaVersion' in input + ? (input as { schemaVersion?: unknown }).schemaVersion + : undefined; + if (schemaVersion !== WORKFLOW_DOCUMENT_SCHEMA_VERSION) { + throw new WorkflowDocumentVersionError( + typeof schemaVersion === 'string' ? schemaVersion : undefined + ); + } + return WorkflowDocumentSchema.parse(input); +}; + +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..d6ff52b186f2 --- /dev/null +++ b/packages/workflow-core/src/edge/service.ts @@ -0,0 +1,188 @@ +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'; +import { NODE_TYPES_WITHOUT_NEXT_PORT, TOOL_TARGET_NODE_TYPES } from '../template/contract'; + +const edgeEquals = (left: WorkflowExecutionEdge, right: WorkflowExecutionEdge) => + JSON.stringify(left) === JSON.stringify(right); + +/** 返回节点在 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 (NODE_TYPES_WITHOUT_NEXT_PORT.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 || + !TOOL_TARGET_NODE_TYPES.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' && NODE_TYPES_WITHOUT_NEXT_PORT.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..a8a9df9515b3 --- /dev/null +++ b/packages/workflow-core/src/index.ts @@ -0,0 +1,40 @@ +export * from './command/apply'; +export * from './command/applyChangeSet'; +export * from './command/mergeChangeSet'; +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 './config/automationMeta'; +export * from './config/descriptor'; +export * from './config/type'; +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/provider'; +export * from './template/type'; +export * from './template/valueSchema'; +export * from './validation'; +export * from './public'; +export { + areWorkflowValueTypesCompatible, + getWorkflowReferenceSourceValueTypes +} from '@fastgpt/global/core/workflow/utils'; diff --git a/packages/workflow-core/src/io/service.ts b/packages/workflow-core/src/io/service.ts new file mode 100644 index 000000000000..bb65b287abdb --- /dev/null +++ b/packages/workflow-core/src/io/service.ts @@ -0,0 +1,337 @@ +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'; +import { DYNAMIC_INPUT_MARKER_KEYS, DYNAMIC_OUTPUT_NODE_TYPES } from '../template/contract'; + +const supportsDynamicOutputs = (node: ReturnType) => + DYNAMIC_OUTPUT_NODE_TYPES.has(node.flowNodeType) || + node.outputs.some((item) => item.key === NodeOutputKeyEnum.addOutputParam); + +const supportsDynamicInputs = (node: ReturnType) => + node.inputs.some((item) => DYNAMIC_INPUT_MARKER_KEYS.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) || + DYNAMIC_INPUT_MARKER_KEYS.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 && + !DYNAMIC_INPUT_MARKER_KEYS.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..2a5c7b796055 --- /dev/null +++ b/packages/workflow-core/src/nesting/service.ts @@ -0,0 +1,155 @@ +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'; +import { ROOT_ONLY_NODE_TYPES } from '../template/contract'; + +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 (ROOT_ONLY_NODE_TYPES.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..6af13f898612 --- /dev/null +++ b/packages/workflow-core/src/node/add.ts @@ -0,0 +1,153 @@ +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 { syncWorkflowStartFileOutput } from '../config/service'; +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 WORKFLOW_START_NODE_ID = 'start'; + +/** + * @deprecated 系统配置节点已迁移到 chatConfig;保留空操作仅兼容旧调用方。 + */ +export const ensureSystemConfigNode = async ({ + document, + dependencies +}: { + document: WorkflowDocument; + dependencies: AddNodeDependencies; +}) => { + void document; + void dependencies; + return { nodeIds: [], warnings: [] as WorkflowDiagnostic[] }; +}; + +/** 创建与 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); + // 配置可能先于 Start 创建,插入后重新同步动态文件输出,避免命令顺序影响最终文档。 + if (instantiated.node.flowNodeType === FlowNodeTypeEnum.workflowStart) { + syncWorkflowStartFileOutput({ + document, + fileSelectConfig: document.chatConfig.fileSelectConfig + }); + } + + 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..9dd957442e4e --- /dev/null +++ b/packages/workflow-core/src/node/service.ts @@ -0,0 +1,187 @@ +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 || + 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 || + 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..98c881ee6fa9 --- /dev/null +++ b/packages/workflow-core/src/public.ts @@ -0,0 +1,16 @@ +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/variable/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..39abcaec840a --- /dev/null +++ b/packages/workflow-core/src/reference/service.ts @@ -0,0 +1,376 @@ +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 { + areWorkflowValueTypesCompatible, + getSelectedInputRenderType +} from '@fastgpt/global/core/workflow/utils'; +import type { WorkflowDocument } from '../domain/document'; +import { WorkflowCommandError } from '../domain/diagnostic'; +import type { VariableRef } from './type'; +import { getInputAutomationMeta } from '../template/automationMeta'; +import { getNodeInputCapability, type NodeParameterInputMode } from '../template/descriptor'; +import { assertValueSchema, inputValueNeedsSchema } from '../template/valueSchema'; + +export type WorkflowReferenceValue = [string, string]; + +const isWorkflowReferenceValue = (value: unknown): value is WorkflowReferenceValue => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + typeof value[1] === 'string'; + +/** + * 解析单引用或一层引用数组,返回原始引用元组以供校验和安全重写。 + * 普通数组与非法结构返回 undefined,避免被误判为工作流引用。 + */ +export const getWorkflowReferenceValues = ( + value: unknown +): WorkflowReferenceValue[] | undefined => { + if (isWorkflowReferenceValue(value)) return [value]; + if (Array.isArray(value) && value.every(isWorkflowReferenceValue)) return value; + return undefined; +}; + +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 } + ]); + } + const automationMeta = getInputAutomationMeta(node.flowNodeType, inputKey); + const capability = getNodeInputCapability({ input, automationMeta }); + if (!capability.configurable) { + throw new WorkflowCommandError([ + { code: 'WORKFLOW_INPUT_NOT_CONFIGURABLE', severity: 'error', nodeId, inputKey } + ]); + } + return { input, inputModes: capability.inputModes }; +}; + +const isLiteralInputRenderType = (type: FlowNodeInputTypeEnum) => + type !== FlowNodeInputTypeEnum.reference && type !== FlowNodeInputTypeEnum.agentGenerated; + +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 = ({ + inputKey, + inputModes, + nodeId, + mode +}: { + inputKey: string; + inputModes: NodeParameterInputMode[]; + nodeId: string; + mode: 'literal' | 'reference'; +}) => { + if (!inputModes.includes(mode)) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_INPUT_MODE_NOT_ALLOWED', + severity: 'error', + nodeId, + inputKey, + params: { mode } + } + ]); + } +}; + +/** 切换输入模式;当前工作流只持久化 selectedType。 */ +const setSelectedInputRenderType = ( + input: FlowNodeInputItemType, + selectedType: FlowNodeInputTypeEnum +) => { + input.selectedType = selectedType; +}; + +/** 更新固定值,并同步 Web 使用的输入模式字段。 */ +export const setInputValue = ({ + document, + nodeId, + inputKey, + value +}: { + document: WorkflowDocument; + nodeId: string; + inputKey: string; + value: unknown; +}) => { + const { input, inputModes } = getInput({ document, nodeId, inputKey }); + assertInputMode({ inputKey, inputModes, 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 ?? input.customJsonSchema; + 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 currentSelectedType = getSelectedInputRenderType(input); + const literalType = + currentSelectedType && + input.renderTypeList.includes(currentSelectedType) && + isLiteralInputRenderType(currentSelectedType) + ? currentSelectedType + : input.renderTypeList.find(isLiteralInputRenderType)!; + setSelectedInputRenderType(input, literalType); +}; + +/** 设置基础节点输出引用,WorkflowDocument 使用稳定的 `[nodeId, outputKey]` 语义格式。 */ +export const setInputReference = ({ + document, + nodeId, + inputKey, + ref +}: { + document: WorkflowDocument; + nodeId: string; + inputKey: string; + ref: VariableRef; +}) => { + const { input, inputModes } = getInput({ document, nodeId, inputKey }); + assertInputMode({ inputKey, inputModes, 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]; + setSelectedInputRenderType(input, 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]; + setSelectedInputRenderType(input, 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, inputModes } = getInput({ document, nodeId, inputKey }); + assertInputMode({ inputKey, inputModes, 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 || + areWorkflowValueTypesCompatible({ + expected: input.valueType, + actual: output.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 || + areWorkflowValueTypesCompatible({ + expected: input.valueType, + actual: variable.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..3787e1963ea1 --- /dev/null +++ b/packages/workflow-core/src/template/automationMeta.ts @@ -0,0 +1,476 @@ +import { + formatNodeTemplateRef, + type NodeTemplateAutomationMeta, + type NodeTemplateRef +} from './type'; +import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { + FlowNodeInputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant'; + +const remoteModelInput = { + defaultPolicy: 'remoteValidated', + resourceKind: 'model' +} as const; + +const referenceItemSchema = { + type: 'array', + minItems: 1, + maxItems: 2, + items: { type: 'string' } +} as const; + +const inputOptionSchema = { + type: 'object', + required: ['key', 'value'], + additionalProperties: false, + properties: { + key: { type: 'string', minLength: 1 }, + value: { type: 'string', minLength: 1 } + } +} as const; + +const dynamicFieldSchema = { + type: 'object', + required: ['key', 'label', 'valueType'], + properties: { + key: { type: 'string', minLength: 1 }, + label: { type: 'string', minLength: 1 }, + valueType: { type: 'string', enum: Object.values(WorkflowIOValueTypeEnum) }, + required: { type: 'boolean' }, + description: { type: 'string' }, + defaultValue: {} + } +} as const; + +const containerSystemInputs = { + [NodeInputKeyEnum.childrenNodeIdList]: { + configurable: false, + agentHint: 'System-maintained child node IDs.' + }, + [NodeInputKeyEnum.nodeWidth]: { + configurable: false, + agentHint: 'Web layout width; managed by the editor.' + }, + [NodeInputKeyEnum.nodeHeight]: { + configurable: false, + agentHint: 'Web layout height; managed by the editor.' + }, + [NodeInputKeyEnum.nestedNodeInputHeight]: { + configurable: false, + agentHint: 'Web layout input height; managed by the editor.' + } +} satisfies NonNullable; + +const automationMetaMap: Record = { + 'builtin:workflow-start': { + inputs: { + [NodeInputKeyEnum.userChatInput]: { + agentHint: 'Initial user question accepted by the workflow.' + } + } + }, + '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. This is a literal text field. Use {{name}} only for a global variable, and use {{$nodeId.outputKey$}} for a node output embedded in text. Never use {{nodeId.outputKey}} for a node reference.', + examples: ['Plain text', 'Hello {{name}}', 'Topic: {{$validateCode.topic$}}'] + } + } + }, + 'builtin:assigned-answer': { + inputs: { + text: { + agentHint: 'workflow:cli.input.answer', + valueSchema: {}, + 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'], + additionalProperties: false, + properties: { + key: { type: 'string', minLength: 1 }, + valueType: { type: 'string', enum: Object.values(WorkflowIOValueTypeEnum) }, + description: { type: 'string' }, + required: { type: 'boolean' }, + enum: { type: 'array', items: { type: 'string' } } + } + } + } + } + } + }, + 'builtin:http-request': { + inputs: { + [NodeInputKeyEnum.httpReqUrl]: { + defaultPolicy: 'userRequired', + bindingRequired: true, + configurable: true, + inputModes: ['literal'], + agentHint: 'Absolute HTTP or HTTPS request URL.' + }, + [NodeInputKeyEnum.addInputParam]: { + configurable: false + }, + [NodeInputKeyEnum.httpHeaders]: { + agentHint: 'HTTP headers as key/value entries.', + valueSchema: { + type: 'array', + items: { + type: 'object', + required: ['key', 'value'], + additionalProperties: false, + properties: { key: { type: 'string' }, value: { type: 'string' } } + } + } + }, + [NodeInputKeyEnum.httpParams]: { + configurable: true, + inputModes: ['literal'], + agentHint: 'URL query parameters as key/value entries.', + valueSchema: { + type: 'array', + items: { + type: 'object', + required: ['key', 'value'], + additionalProperties: false, + properties: { key: { type: 'string' }, value: {} } + } + } + }, + [NodeInputKeyEnum.httpFormBody]: { + configurable: true, + inputModes: ['literal'], + agentHint: 'Form body fields as key/value entries.', + valueSchema: { + type: 'array', + items: { + type: 'object', + required: ['key', 'value'], + additionalProperties: false, + properties: { key: { type: 'string' }, value: {} } + } + } + }, + [NodeInputKeyEnum.httpJsonBody]: { + configurable: true, + inputModes: ['literal'], + agentHint: 'JSON request body serialized as a string.', + valueSchema: { type: 'string' } + }, + [NodeInputKeyEnum.httpContentType]: { + configurable: true, + inputModes: ['literal'], + agentHint: 'HTTP request body content type.' + }, + [NodeInputKeyEnum.headerSecret]: { + configurable: false, + defaultPolicy: 'userRequired', + resourceKind: 'secret' + } + } + }, + 'builtin:code': { + inputs: { + [NodeInputKeyEnum.addInputParam]: { + configurable: false + }, + [NodeInputKeyEnum.codeType]: { + agentHint: 'Code language identifier supported by the sandbox.' + }, + [NodeInputKeyEnum.code]: { + agentHint: + 'Complete sandbox function source. Parameters define dynamic inputs and returned object keys define outputs.' + } + } + }, + '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]: { + configurable: true, + inputModes: ['literal'], + agentHint: + 'Ordered conditional branches. Configure branches before connecting branch edges.', + valueSchema: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['condition', 'list'], + additionalProperties: false, + properties: { + branchId: { type: 'string', minLength: 1 }, + condition: { type: 'string', enum: ['AND', 'OR'] }, + list: { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + properties: { + variable: referenceItemSchema, + condition: { + type: 'string', + enum: Object.values(VariableConditionEnum) + }, + value: { + anyOf: [{ type: 'string' }, referenceItemSchema] + }, + valueType: { type: 'string', enum: ['input', 'reference'] } + } + } + } + } + } + } + } + } + }, + 'builtin:question-classification': { + inputs: { + [NodeInputKeyEnum.aiModel]: remoteModelInput, + [NodeInputKeyEnum.agents]: { + agentHint: 'Classification branches. Each key is also the execution branch key.', + valueSchema: { + type: 'array', + minItems: 1, + items: inputOptionSchema + } + } + } + }, + 'builtin:user-select': { + inputs: { + [NodeInputKeyEnum.userSelectOptions]: { + agentHint: 'User choices. Each key is also the execution branch key.', + valueSchema: { + type: 'array', + minItems: 1, + items: inputOptionSchema + } + } + } + }, + 'builtin:form-input': { + inputs: { + [NodeInputKeyEnum.userInputForms]: { + agentHint: 'Form fields. Field keys become dynamic node output keys.', + valueSchema: { + type: 'array', + minItems: 1, + items: { + ...dynamicFieldSchema, + required: ['type', 'key', 'label', 'value', 'valueType', 'required'], + properties: { + ...dynamicFieldSchema.properties, + type: { type: 'string', enum: Object.values(FlowNodeInputTypeEnum) }, + value: {}, + maxLength: { type: 'number' }, + minLength: { type: 'number' }, + max: { type: 'number' }, + min: { type: 'number' }, + list: { + type: 'array', + items: { + type: 'object', + required: ['label', 'value'], + additionalProperties: false, + properties: { + label: { type: 'string' }, + value: { type: 'string' } + } + } + } + } + } + } + } + } + }, + 'builtin:variable-update': { + inputs: { + [NodeInputKeyEnum.updateList]: { + configurable: true, + inputModes: ['literal'], + agentHint: + 'Variable updates. renderType determines whether value is literal or a node reference.', + valueSchema: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['renderType'], + additionalProperties: false, + properties: { + variable: referenceItemSchema, + value: { + anyOf: [referenceItemSchema, { type: 'array', items: referenceItemSchema }] + }, + valueType: { type: 'string', enum: Object.values(WorkflowIOValueTypeEnum) }, + renderType: { + type: 'string', + enum: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference] + }, + numberOperator: { type: 'string', enum: ['+', '-', '*', '/', '='] }, + booleanMode: { type: 'string', enum: ['true', 'false', 'negate'] }, + arrayMode: { type: 'string', enum: ['append', 'clear', 'equal'] } + } + } + } + } + } + }, + 'builtin:tool-call': { + inputs: { + [NodeInputKeyEnum.aiModel]: remoteModelInput + } + }, + 'builtin:read-files': { + inputs: { + [NodeInputKeyEnum.fileUrlList]: { + agentHint: 'Reference an upstream Array containing file URLs.' + } + } + }, + 'builtin:parallel-run': { + inputs: { + [NodeInputKeyEnum.nestedInputArray]: { + agentHint: 'Reference the array processed concurrently by child nodes.' + }, + ...containerSystemInputs + } + }, + 'builtin:loop-run': { + inputs: { + [NodeInputKeyEnum.loopRunInputArray]: { + agentHint: 'Reference the array iterated by child nodes.' + }, + [NodeInputKeyEnum.loopCustomOutputs]: { + configurable: false, + agentHint: 'Dynamic output marker. Use output add/remove commands instead of input set.' + }, + ...containerSystemInputs + } + }, + 'builtin:loop-run-break': { inputs: {} }, + '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.workflowStart]: 'builtin:workflow-start', + [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.readFiles]: 'builtin:read-files', + [FlowNodeTypeEnum.parallelRun]: 'builtin:parallel-run', + [FlowNodeTypeEnum.loopRun]: 'builtin:loop-run', + [FlowNodeTypeEnum.loopRunBreak]: 'builtin:loop-run-break', + [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..7dc4caf5aa16 --- /dev/null +++ b/packages/workflow-core/src/template/builtin.ts @@ -0,0 +1,89 @@ +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 { 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 = { + '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/contract.ts b/packages/workflow-core/src/template/contract.ts new file mode 100644 index 000000000000..4f34959700fa --- /dev/null +++ b/packages/workflow-core/src/template/contract.ts @@ -0,0 +1,212 @@ +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { + FlowNodeTypeEnum, + isInteractiveNodeType, + isNestedChildSystemNodeType, + isNestedParentNodeType +} from '@fastgpt/global/core/workflow/node/constant'; +import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; + +export type NodeExecutionSourceKind = + | 'next' + | 'branch' + | 'sourceOutput' + | 'catch' + | 'selectedTools'; +export type NodeExecutionTargetKind = 'target' | 'selectedTools'; + +export type NodeRuntimeContract = { + execution: { + sourceKinds: NodeExecutionSourceKind[]; + targetKinds: NodeExecutionTargetKind[]; + terminal: boolean; + branch?: { + inputKey: string; + keyField: 'branchId' | 'key'; + keyFieldRequiredForNewValues: boolean; + fallbackKey?: string; + configureBeforeConnect: true; + }; + }; + dynamicIO: { + inputs: { + manual: boolean; + derivedFromInputKeys: string[]; + }; + outputs: { + manual: boolean; + derivedFromInputKeys: string[]; + }; + }; + container: { + kind: 'none' | 'parallel' | 'loop'; + rootAllowed: boolean; + allowedParentTypes: string[]; + }; + effects: string[]; +}; + +export const NODE_TYPES_WITHOUT_NEXT_PORT: ReadonlySet = new Set([ + FlowNodeTypeEnum.ifElseNode, + FlowNodeTypeEnum.userSelect, + FlowNodeTypeEnum.classifyQuestion, + FlowNodeTypeEnum.answerNode, + FlowNodeTypeEnum.loopRunBreak, + FlowNodeTypeEnum.nestedEnd, + FlowNodeTypeEnum.pluginOutput +]); + +export const TOOL_TARGET_NODE_TYPES: ReadonlySet = 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 +]); + +export const DYNAMIC_OUTPUT_NODE_TYPES: ReadonlySet = new Set([ + FlowNodeTypeEnum.code, + FlowNodeTypeEnum.contentExtract, + FlowNodeTypeEnum.httpRequest468, + FlowNodeTypeEnum.loopRun +]); + +export const DYNAMIC_INPUT_MARKER_KEYS: ReadonlySet = new Set([ + NodeInputKeyEnum.addInputParam, + NodeInputKeyEnum.datasetQuoteList +]); + +export const ROOT_ONLY_NODE_TYPES: ReadonlySet = new Set([ + FlowNodeTypeEnum.workflowStart, + FlowNodeTypeEnum.loop, + FlowNodeTypeEnum.loopRun, + FlowNodeTypeEnum.parallelRun, + FlowNodeTypeEnum.pluginInput, + FlowNodeTypeEnum.pluginOutput, + FlowNodeTypeEnum.globalVariable +]); + +const CONTAINER_NODE_TYPES = [ + FlowNodeTypeEnum.loop, + FlowNodeTypeEnum.parallelRun, + FlowNodeTypeEnum.loopRun +]; + +const getNodePlacement = (flowNodeType: FlowNodeTypeEnum) => { + if (flowNodeType === FlowNodeTypeEnum.loopRunBreak) { + return { rootAllowed: false, allowedParentTypes: [FlowNodeTypeEnum.loopRun] }; + } + if (isNestedChildSystemNodeType(flowNodeType)) { + return { rootAllowed: false, allowedParentTypes: CONTAINER_NODE_TYPES }; + } + if (ROOT_ONLY_NODE_TYPES.has(flowNodeType)) { + return { rootAllowed: true, allowedParentTypes: [] }; + } + return { + rootAllowed: true, + allowedParentTypes: isInteractiveNodeType(flowNodeType) + ? [FlowNodeTypeEnum.loop, FlowNodeTypeEnum.loopRun] + : CONTAINER_NODE_TYPES + }; +}; + +const getBranchContract = (flowNodeType: FlowNodeTypeEnum) => { + if (flowNodeType === FlowNodeTypeEnum.ifElseNode) { + return { + inputKey: NodeInputKeyEnum.ifElseList, + keyField: 'branchId' as const, + keyFieldRequiredForNewValues: true, + fallbackKey: 'ELSE', + configureBeforeConnect: true as const + }; + } + if (flowNodeType === FlowNodeTypeEnum.userSelect) { + return { + inputKey: NodeInputKeyEnum.userSelectOptions, + keyField: 'key' as const, + keyFieldRequiredForNewValues: true, + configureBeforeConnect: true as const + }; + } + if (flowNodeType === FlowNodeTypeEnum.classifyQuestion) { + return { + inputKey: NodeInputKeyEnum.agents, + keyField: 'key' as const, + keyFieldRequiredForNewValues: true, + configureBeforeConnect: true as const + }; + } +}; + +/** + * 从模板和共享节点集合生成可供 Validator、CLI 和 Agent 共用的运行时契约。 + * 模板展示字段只决定当前节点实例是否开放 target/catch;节点类型集合承载稳定领域规则。 + */ +export const getNodeRuntimeContract = (template: FlowNodeTemplateType): NodeRuntimeContract => { + const flowNodeType = template.flowNodeType; + const branch = getBranchContract(flowNodeType); + const sourceKinds: NodeExecutionSourceKind[] = [ + !NODE_TYPES_WITHOUT_NEXT_PORT.has(flowNodeType) && template.showSourceHandle !== false + ? 'next' + : undefined, + branch ? 'branch' : undefined, + template.outputs.some((output) => output.type === 'source') ? 'sourceOutput' : undefined, + template.catchError !== undefined ? 'catch' : undefined, + flowNodeType === FlowNodeTypeEnum.toolCall ? 'selectedTools' : undefined + ].filter((kind): kind is NodeExecutionSourceKind => kind !== undefined); + const targetKinds: NodeExecutionTargetKind[] = [ + template.showTargetHandle !== false ? 'target' : undefined, + TOOL_TARGET_NODE_TYPES.has(flowNodeType) ? 'selectedTools' : undefined + ].filter((kind): kind is NodeExecutionTargetKind => kind !== undefined); + const dynamicInputs = template.inputs.some((input) => DYNAMIC_INPUT_MARKER_KEYS.has(input.key)); + const dynamicOutputs = + DYNAMIC_OUTPUT_NODE_TYPES.has(flowNodeType) || + template.outputs.some((output) => output.key === 'system_addOutputParam'); + const derivedInputKeys = flowNodeType === FlowNodeTypeEnum.code ? [NodeInputKeyEnum.code] : []; + const derivedOutputKeys = + flowNodeType === FlowNodeTypeEnum.code + ? [NodeInputKeyEnum.code] + : flowNodeType === FlowNodeTypeEnum.formInput + ? [NodeInputKeyEnum.userInputForms] + : []; + const effects = [ + branch ? 'prune-invalid-branch-edges' : undefined, + flowNodeType === FlowNodeTypeEnum.code ? 'sync-code-inputs-and-outputs' : undefined, + flowNodeType === FlowNodeTypeEnum.formInput ? 'sync-form-field-outputs' : undefined, + isNestedParentNodeType(flowNodeType) ? 'sync-container-children' : undefined, + dynamicOutputs ? 'prune-removed-output-edges' : undefined + ].filter((effect): effect is string => effect !== undefined); + const placement = getNodePlacement(flowNodeType); + + return { + execution: { + sourceKinds, + targetKinds, + terminal: sourceKinds.length === 0, + branch + }, + dynamicIO: { + inputs: { manual: dynamicInputs, derivedFromInputKeys: derivedInputKeys }, + outputs: { manual: dynamicOutputs, derivedFromInputKeys: derivedOutputKeys } + }, + container: { + kind: + flowNodeType === FlowNodeTypeEnum.parallelRun + ? 'parallel' + : flowNodeType === FlowNodeTypeEnum.loopRun + ? 'loop' + : 'none', + ...placement + }, + effects + }; +}; 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..aae1bfbd2486 --- /dev/null +++ b/packages/workflow-core/src/template/descriptor.ts @@ -0,0 +1,216 @@ +import { + FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import type { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; +import { getWorkflowReferenceSourceValueTypes } from '@fastgpt/global/core/workflow/utils'; +import type { + NodeTemplateAutomationMeta, + NodeInputAutomationMeta, + NodeTemplateRef, + WorkflowInputDefaultPolicy, + WorkflowResourceKind +} from './type'; +import { getNodeRuntimeContract, type NodeRuntimeContract } from './contract'; + +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[]; + referencePolicy?: { + acceptedSourceValueTypes: WorkflowIOValueTypeEnum[]; + }; + enum?: Array<{ label?: string; value: string; description?: string }>; + constraints?: { + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + valueSchema?: Record; + }; + examples?: unknown[]; +}; + +export type NodeTemplateDescriptor = { + schemaVersion: 'fastgpt-workflow-node-contract/v1'; + 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; + }; + execution: NodeRuntimeContract['execution']; + dynamicIO: NodeRuntimeContract['dynamicIO']; + container: NodeRuntimeContract['container']; + effects: NodeRuntimeContract['effects']; +}; + +const getInferredInputModes = (renderTypes: FlowNodeInputTypeEnum[]): NodeParameterInputMode[] => { + const literalRenderTypes = renderTypes.filter( + (type) => + type !== FlowNodeInputTypeEnum.reference && + type !== FlowNodeInputTypeEnum.addInputParam && + type !== FlowNodeInputTypeEnum.agentGenerated + ); + const modes = [ + literalRenderTypes.length > 0 ? '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)]; +}; + +/** + * 计算 Builder 对单个节点输入的可操作能力。 + * hidden 输入在 FastGPT 中也用于高级/弹窗设置,因此仍按字面量输入处理; + * system_input_config、addInputParam 和 agentGenerated-only 不属于 Builder 配置面。 + */ +export const getNodeInputCapability = ({ + input, + automationMeta +}: { + input: FlowNodeInputItemType; + automationMeta?: NodeInputAutomationMeta; +}): { configurable: boolean; inputModes: NodeParameterInputMode[] } => { + const inferredInputModes = getInferredInputModes(input.renderTypeList); + const isAgentGeneratedOnly = + input.renderTypeList.length > 0 && + input.renderTypeList.every((type) => type === FlowNodeInputTypeEnum.agentGenerated); + if ( + input.canEdit === false || + automationMeta?.configurable === false || + input.key === NodeInputKeyEnum.systemInputConfig || + input.renderTypeList.includes(FlowNodeInputTypeEnum.addInputParam) || + isAgentGeneratedOnly + ) { + return { configurable: false, inputModes: [] }; + } + + const requestedInputModes = automationMeta?.inputModes ?? inferredInputModes; + const inputModes = requestedInputModes.filter((mode) => { + if (mode === 'literal') { + return ( + inferredInputModes.includes('literal') || + (automationMeta?.configurable === true && + automationMeta.inputModes?.includes('literal') === true) + ); + } + if (mode === 'reference') return inferredInputModes.includes('reference'); + return inferredInputModes.includes('secret'); + }); + const configurable = (automationMeta?.configurable ?? true) && inputModes.length > 0; + + return { configurable, inputModes: configurable ? inputModes : [] }; +}; + +/** 将现有模板实时归一化为 CLI/Agent 可读取的参数契约。 */ +export const normalizeNodeTemplateDescriptor = ({ + template, + templateRef, + automationMeta, + translate = (value) => value +}: { + template: FlowNodeTemplateType; + templateRef: NodeTemplateRef; + automationMeta?: NodeTemplateAutomationMeta; + translate?: (value: string) => string; +}): NodeTemplateDescriptor => { + const runtimeContract = getNodeRuntimeContract(template); + return { + schemaVersion: 'fastgpt-workflow-node-contract/v1', + 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 { configurable, inputModes } = getNodeInputCapability({ + input, + automationMeta: meta + }); + const constraints = { + min: input.min, + max: input.max, + minLength: input.minLength, + maxLength: input.maxLength, + valueSchema: meta?.valueSchema ?? input.customJsonSchema + }; + return { + key: input.key, + label: translate(input.label), + description: translate( + [ + meta?.agentHint, + input.toolDescription, + input.description, + input.label, + input.key + ].find((value) => typeof value === 'string' && value.trim().length > 0) as string + ), + valueType: input.valueType, + required: input.required ?? false, + defaultValue: input.defaultValue ?? input.value, + defaultPolicy: meta?.defaultPolicy ?? 'template', + resourceKind: meta?.resourceKind, + bindingRequired: meta?.bindingRequired ?? false, + configurable, + inputModes, + referencePolicy: inputModes.includes('reference') + ? { + acceptedSourceValueTypes: getWorkflowReferenceSourceValueTypes(input.valueType) + } + : undefined, + 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 + }, + ...runtimeContract + }; +}; diff --git a/packages/workflow-core/src/template/instantiate.ts b/packages/workflow-core/src/template/instantiate.ts new file mode 100644 index 000000000000..1f7ac9954d37 --- /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 { areWorkflowValueTypesCompatible } from '@fastgpt/global/core/workflow/utils'; +import type { WorkflowDocument } from '../domain/document'; +import { WorkflowCommandError, type WorkflowDiagnostic } from '../domain/diagnostic'; +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) { + if ( + !input.renderTypeList.includes(FlowNodeInputTypeEnum.reference) || + hasConfiguredValue(input.value) + ) { + continue; + } + + const referenceDefault = (() => { + if (input.key === NodeInputKeyEnum.userChatInput && userInputOutput) { + return { + value: [startNode.nodeId, userInputOutput.key], + outputs: [userInputOutput] + }; + } + if (input.key === NodeInputKeyEnum.fileUrlList && userFilesOutput) { + return { + value: [[startNode.nodeId, userFilesOutput.key]], + outputs: [userFilesOutput] + }; + } + if (input.key === NodeInputKeyEnum.datasetSearchInput && userInputOutput) { + return { + value: [ + [startNode.nodeId, userInputOutput.key], + ...(userFilesOutput ? [[startNode.nodeId, userFilesOutput.key]] : []) + ], + outputs: [userInputOutput, ...(userFilesOutput ? [userFilesOutput] : [])] + }; + } + })(); + if ( + !referenceDefault || + !referenceDefault.outputs.every((output) => + areWorkflowValueTypesCompatible({ + expected: input.valueType, + actual: output.valueType + }) + ) + ) { + continue; + } + input.value = referenceDefault.value; + input.selectedType = FlowNodeInputTypeEnum.reference; + } + } + + return { node, warnings: [] }; +}; diff --git a/packages/workflow-core/src/template/provider.ts b/packages/workflow-core/src/template/provider.ts new file mode 100644 index 000000000000..c4f3ded008bf --- /dev/null +++ b/packages/workflow-core/src/template/provider.ts @@ -0,0 +1,168 @@ +import { FlowNodeTemplateTypeSchema } from '@fastgpt/global/core/workflow/type/node'; +import z from 'zod'; +import { WorkflowCommandError } from '../domain/diagnostic'; +import { + NodeTemplateAutomationMetaSchema, + NodeTemplateRefSchema, + formatNodeTemplateRef, + type NodeTemplateRef, + type ResolvedWorkflowTemplate, + type WorkflowTemplateProvider +} from './type'; + +export const WorkflowTemplateBundleItemSchema = z + .object({ + ref: NodeTemplateRefSchema, + template: FlowNodeTemplateTypeSchema.strict(), + automationMeta: NodeTemplateAutomationMetaSchema.optional() + }) + .strict() + .superRefine((item, context) => { + if (item.ref.kind !== 'systemTool') return; + if (item.template.pluginId !== item.ref.toolId) { + context.addIssue({ + code: 'custom', + path: ['template', 'pluginId'], + message: 'System tool template pluginId must match ref.toolId' + }); + } + if (item.template.source && item.template.source !== item.ref.source) { + context.addIssue({ + code: 'custom', + path: ['template', 'source'], + message: 'System tool template source must match ref.source' + }); + } + }); + +export const WorkflowTemplateBundleSchema = z + .object({ + schemaVersion: z.literal('fastgpt-workflow-template-bundle/v1'), + items: z.array(WorkflowTemplateBundleItemSchema) + }) + .strict(); + +export type WorkflowTemplateBundle = z.infer; + +const throwTemplateUnavailable = (ref: NodeTemplateRef): never => { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_TEMPLATE_UNAVAILABLE', + severity: 'error', + params: { template: formatNodeTemplateRef(ref) } + } + ]); +}; + +/** 用严格校验后的请求级模板包构造只读 Provider。 */ +export const createWorkflowTemplateBundleProvider = ( + input: WorkflowTemplateBundle +): WorkflowTemplateProvider => { + const bundle = WorkflowTemplateBundleSchema.parse(input); + const entries = new Map(); + + for (const item of bundle.items) { + const key = formatNodeTemplateRef(item.ref); + if (entries.has(key)) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_TEMPLATE_PROVIDER_CONFLICT', + severity: 'error', + params: { template: key } + } + ]); + } + entries.set(key, { + ref: item.ref, + resolved: { + template: item.template, + automationMeta: item.automationMeta + } + }); + } + + return { + async list() { + return [...entries.values()].map(({ ref }) => ref); + }, + async resolve(ref) { + return entries.get(formatNodeTemplateRef(ref))?.resolved ?? throwTemplateUnavailable(ref); + } + }; +}; + +/** 合并多个 Provider,并在每次 list/resolve 时拒绝重复模板身份。 */ +export const composeWorkflowTemplateProviders = ( + providers: WorkflowTemplateProvider[] +): WorkflowTemplateProvider => { + const getProviderRegistry = async (context: Parameters[0]) => { + const providerRefs = await Promise.all( + providers.map(async (provider) => ({ provider, refs: await provider.list(context) })) + ); + const entries = new Map(); + + for (const { provider, refs } of providerRefs) { + for (const ref of refs) { + const key = formatNodeTemplateRef(ref); + if (entries.has(key)) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_TEMPLATE_PROVIDER_CONFLICT', + severity: 'error', + params: { template: key } + } + ]); + } + entries.set(key, { provider, ref }); + } + } + return { entries, providerRefs }; + }; + + return { + async list(context) { + return [...(await getProviderRegistry(context)).entries.values()].map(({ ref }) => ref); + }, + async resolve(ref, context) { + const registry = await getProviderRegistry(context); + const entry = registry.entries.get(formatNodeTemplateRef(ref)); + if (entry) return entry.provider.resolve(entry.ref, context); + + // builtin 等 Provider 允许解析不公开在 list 中的内部模板。 + const candidates = registry.providerRefs.filter(({ refs }) => + refs.some((listedRef) => listedRef.kind === ref.kind) + ); + const results = await Promise.allSettled( + candidates.map(({ provider }) => provider.resolve(ref, context)) + ); + const resolved = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ); + if (resolved.length > 1) { + throw new WorkflowCommandError([ + { + code: 'WORKFLOW_TEMPLATE_PROVIDER_CONFLICT', + severity: 'error', + params: { template: formatNodeTemplateRef(ref) } + } + ]); + } + if (resolved[0]) return resolved[0]; + + const unexpectedFailure = results.find( + (result) => + result.status === 'rejected' && + !( + result.reason instanceof WorkflowCommandError && + result.reason.diagnostics.every((diagnostic) => + ['WORKFLOW_TEMPLATE_NOT_FOUND', 'WORKFLOW_TEMPLATE_UNAVAILABLE'].includes( + diagnostic.code + ) + ) + ) + ); + if (unexpectedFailure?.status === 'rejected') throw unexpectedFailure.reason; + return throwTemplateUnavailable(ref); + } + }; +}; diff --git a/packages/workflow-core/src/template/type.ts b/packages/workflow-core/src/template/type.ts new file mode 100644 index 000000000000..cc4a51c51019 --- /dev/null +++ b/packages/workflow-core/src/template/type.ts @@ -0,0 +1,108 @@ +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) }).strict(), + z + .object({ + kind: z.literal('teamApp'), + appId: z.string().min(1), + versionId: z.string().optional() + }) + .strict(), + z + .object({ + kind: z.literal('systemTool'), + toolId: z.string().min(1), + source: z.string().min(1), + versionId: z.string().optional() + }) + .strict(), + z + .object({ + kind: z.literal('tool'), + toolId: z.string().min(1), + parentId: z.string().optional(), + versionId: z.string().optional() + }) + .strict() +]); +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') { + const sourceSeparatorIndex = id.indexOf('/'); + if (sourceSeparatorIndex <= 0 || sourceSeparatorIndex === id.length - 1) { + return NodeTemplateRefSchema.parse({ kind, source: '', toolId: '' }); + } + const encodedSource = id.slice(0, sourceSeparatorIndex); + const encodedToolId = id.slice(sourceSeparatorIndex + 1); + try { + return NodeTemplateRefSchema.parse({ + kind, + source: decodeURIComponent(encodedSource), + toolId: decodeURIComponent(encodedToolId) + }); + } catch { + return NodeTemplateRefSchema.parse({ kind, source: '', toolId: '' }); + } + } + 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}`; + if (ref.kind === 'systemTool') { + return `${ref.kind}:${encodeURIComponent(ref.source)}/${encodeURIComponent(ref.toolId)}`; + } + return `${ref.kind}:${ref.toolId}`; +}; + +export type WorkflowInputDefaultPolicy = 'template' | 'userRequired' | 'remoteValidated'; +export type WorkflowResourceKind = 'dataset' | 'model' | 'app' | 'tool' | 'secret'; + +export const NodeInputAutomationMetaSchema = z + .object({ + configurable: z.boolean().optional(), + agentHint: z.string().optional(), + valueSchema: z.record(z.string(), z.unknown()).optional(), + examples: z.array(z.unknown()).optional(), + defaultPolicy: z.enum(['template', 'userRequired', 'remoteValidated']).optional(), + resourceKind: z.enum(['dataset', 'model', 'app', 'tool', 'secret']).optional(), + bindingRequired: z.boolean().optional(), + inputModes: z.array(z.enum(['literal', 'reference', 'secret'])).optional() + }) + .strict(); +export type NodeInputAutomationMeta = z.infer; + +export const NodeTemplateAutomationMetaSchema = z + .object({ + inputs: z.record(z.string(), NodeInputAutomationMetaSchema).optional() + }) + .strict(); +export type NodeTemplateAutomationMeta = z.infer; + +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..8316969e6c55 --- /dev/null +++ b/packages/workflow-core/src/template/valueSchema.ts @@ -0,0 +1,180 @@ +import { WorkflowCommandError } from '../domain/diagnostic'; +import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; + +type JsonSchema = { + type?: string | string[]; + const?: unknown; + required?: string[]; + properties?: Record; + items?: JsonSchema | JsonSchema[] | false; + prefixItems?: JsonSchema[]; + enum?: unknown[]; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + not?: JsonSchema; + additionalProperties?: boolean | JsonSchema; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + minLength?: number; + maxLength?: number; + pattern?: string; + minimum?: number; + maximum?: number; +}; + +const valuesEqual = (left: unknown, right: unknown) => + Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); + +const valueMatchesType = (value: unknown, type: string) => { + if (type === 'null') return value === null; + if (type === 'string') return typeof value === 'string'; + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'integer') return typeof value === 'number' && Number.isInteger(value); + if (type === 'boolean') return typeof value === 'boolean'; + if (type === 'array') return Array.isArray(value); + if (type === 'object') + return value !== null && typeof value === 'object' && !Array.isArray(value); + return true; +}; + +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.const !== undefined && !valuesEqual(parsedSchema.const, value)) return false; + if (parsedSchema.enum && !parsedSchema.enum.some((item) => valuesEqual(item, value))) + return false; + if (parsedSchema.anyOf && !parsedSchema.anyOf.some((item) => valueMatchesSchema(value, item))) { + return false; + } + if ( + parsedSchema.oneOf && + parsedSchema.oneOf.filter((item) => valueMatchesSchema(value, item)).length !== 1 + ) { + return false; + } + if (parsedSchema.allOf && !parsedSchema.allOf.every((item) => valueMatchesSchema(value, item))) { + return false; + } + if (parsedSchema.not && valueMatchesSchema(value, parsedSchema.not)) return false; + if (parsedSchema.type) { + const types = Array.isArray(parsedSchema.type) ? parsedSchema.type : [parsedSchema.type]; + if (!types.some((type) => valueMatchesType(value, type))) return false; + } + if (typeof value === 'string') { + if (parsedSchema.minLength !== undefined && value.length < parsedSchema.minLength) return false; + if (parsedSchema.maxLength !== undefined && value.length > parsedSchema.maxLength) return false; + if (parsedSchema.pattern !== undefined && !new RegExp(parsedSchema.pattern).test(value)) + return false; + } + if (typeof value === 'number') { + if (parsedSchema.minimum !== undefined && value < parsedSchema.minimum) return false; + if (parsedSchema.maximum !== undefined && value > parsedSchema.maximum) return false; + } + if (Array.isArray(value)) { + if (parsedSchema.minItems !== undefined && value.length < parsedSchema.minItems) return false; + if (parsedSchema.maxItems !== undefined && value.length > parsedSchema.maxItems) return false; + if ( + parsedSchema.uniqueItems && + value.some((item, index) => value.slice(0, index).some((other) => valuesEqual(item, other))) + ) { + return false; + } + if ( + parsedSchema.prefixItems?.some( + (itemSchema, index) => index < value.length && !valueMatchesSchema(value[index], itemSchema) + ) + ) { + return false; + } + if (parsedSchema.items === false && value.length > (parsedSchema.prefixItems?.length ?? 0)) { + return false; + } + if (Array.isArray(parsedSchema.items)) { + if ( + parsedSchema.items.some( + (itemSchema, index) => + index < value.length && !valueMatchesSchema(value[index], itemSchema) + ) + ) { + return false; + } + } else if (parsedSchema.items && typeof parsedSchema.items === 'object') { + const startIndex = parsedSchema.prefixItems?.length ?? 0; + if ( + value + .slice(startIndex) + .some((item) => !valueMatchesSchema(item, parsedSchema.items as JsonSchema)) + ) { + return false; + } + } + } + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const record = value as Record; + if (parsedSchema.required?.some((key) => record[key] === undefined)) return false; + const additionalKeys = Object.keys(record).filter((key) => !parsedSchema.properties?.[key]); + if (parsedSchema.additionalProperties === false && additionalKeys.length > 0) return false; + if ( + parsedSchema.additionalProperties && + typeof parsedSchema.additionalProperties === 'object' && + additionalKeys.some( + (key) => + !valueMatchesSchema( + record[key], + parsedSchema.additionalProperties as Record + ) + ) + ) { + return false; + } + if ( + Object.entries(parsedSchema.properties ?? {}).some( + ([key, itemSchema]) => + record[key] !== undefined && + !valueMatchesSchema(record[key], itemSchema as Record) + ) + ) { + return false; + } + } + return true; +}; + +/** 校验节点输入契约提供的 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..d1e3ccf9cda9 --- /dev/null +++ b/packages/workflow-core/src/validation/index.ts @@ -0,0 +1,615 @@ +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 { + areWorkflowValueTypesCompatible, + getSelectedInputRenderType +} from '@fastgpt/global/core/workflow/utils'; +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 { getWorkflowReferenceValues, valueMatchesType } from '../reference/service'; +import { getInputAutomationMeta } from '../template/automationMeta'; +import { inputValueNeedsSchema, valueMatchesSchema } from '../template/valueSchema'; + +const hasRequiredValue = (value: unknown) => + value !== undefined && + value !== null && + value !== '' && + (!Array.isArray(value) || value.length > 0); + +const isNonEmptyReference = (value: unknown): value is [string, string] => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + value[0].length > 0 && + typeof value[1] === 'string' && + value[1].length > 0; + +const isVariableUpdateValueMissing = (item: { + value?: unknown; + renderType?: unknown; + arrayMode?: unknown; + booleanMode?: unknown; +}) => { + if (item.renderType === FlowNodeInputTypeEnum.reference) { + if (isNonEmptyReference(item.value)) return false; + return !( + Array.isArray(item.value) && + item.value.length > 0 && + item.value.every(isNonEmptyReference) + ); + } + + if (item.arrayMode === 'clear' || item.booleanMode) return false; + return ( + !Array.isArray(item.value) || + item.value[1] === undefined || + item.value[1] === null || + item.value[1] === '' + ); +}; + +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 = getWorkflowReferenceValues(input.value); + if (!references) { + diagnostics.push({ + code: 'WORKFLOW_REFERENCE_FORMAT_INVALID', + severity: 'error', + nodeId: node.nodeId, + inputKey: input.key + }); + return; + } + 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 + }) + ) { + 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 + }) + ) { + 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 selectedType = getSelectedInputRenderType(input); + 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 ?? input.customJsonSchema; + 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.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.variableUpdate) { + const updateList = getInputValue(NodeInputKeyEnum.updateList); + if (Array.isArray(updateList)) { + updateList.forEach((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + diagnostics.push({ + code: 'WORKFLOW_VARIABLE_UPDATE_ITEM_INVALID', + severity: 'error', + nodeId: node.nodeId, + inputKey: NodeInputKeyEnum.updateList, + params: { index } + }); + return; + } + + const update = item as { + variable?: unknown; + value?: unknown; + renderType?: unknown; + arrayMode?: unknown; + booleanMode?: unknown; + }; + if (!isNonEmptyReference(update.variable)) { + diagnostics.push({ + code: 'WORKFLOW_VARIABLE_UPDATE_TARGET_MISSING', + severity: 'error', + nodeId: node.nodeId, + inputKey: NodeInputKeyEnum.updateList, + params: { index, field: 'variable' } + }); + } + if (isVariableUpdateValueMissing(update)) { + diagnostics.push({ + code: 'WORKFLOW_VARIABLE_UPDATE_VALUE_MISSING', + severity: 'error', + nodeId: node.nodeId, + inputKey: NodeInputKeyEnum.updateList, + params: { index, field: '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); + } + + 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/changeSet.test.ts b/packages/workflow-core/test/command/changeSet.test.ts new file mode 100644 index 000000000000..5003007200e4 --- /dev/null +++ b/packages/workflow-core/test/command/changeSet.test.ts @@ -0,0 +1,90 @@ +import staticWorkflowFixture from '../fixtures/basic-static/workflow.json'; +import { + WORKFLOW_CHANGESET_SCHEMA_VERSION, + WorkflowDocumentSchema, + applyWorkflowChangeSet, + applyWorkflowCommand, + builtinTemplateProvider, + getWorkflowChecksum, + normalizeWorkflowDocument, + planWorkflowChangeSet, + type WorkflowChangeSet +} from '../../src'; +import { describe, expect, it } from 'vitest'; + +const dependencies = { templateProvider: builtinTemplateProvider }; + +const createChangeSet = async ( + commands: WorkflowChangeSet['commands'], + document = WorkflowDocumentSchema.parse(staticWorkflowFixture) +): Promise => ({ + schemaVersion: WORKFLOW_CHANGESET_SCHEMA_VERSION, + baseChecksum: await getWorkflowChecksum(document), + commands +}); + +describe('WorkflowChangeSet', () => { + it('keeps a one-command ChangeSet equivalent to the command dispatcher', async () => { + const document = WorkflowDocumentSchema.parse(staticWorkflowFixture); + const command = { type: 'meta.update', name: 'Changed' } as const; + const changeSetResult = await applyWorkflowChangeSet({ + document, + changeSet: await createChangeSet([command], document), + dependencies + }); + const commandResult = await applyWorkflowCommand({ document, command, dependencies }); + expect(normalizeWorkflowDocument(changeSetResult.document)).toEqual( + normalizeWorkflowDocument(commandResult.document) + ); + }); + + it('applies multiple commands in memory and returns a versioned plan', async () => { + const document = WorkflowDocumentSchema.parse(staticWorkflowFixture); + const changeSet = await createChangeSet( + [ + { type: 'meta.update', name: 'Batch changed' }, + { + type: 'input.set', + nodeId: 'text', + inputKey: 'system_textareaInput', + value: 'Changed by ChangeSet' + } + ], + document + ); + const result = await planWorkflowChangeSet({ document, changeSet, dependencies }); + expect(result.plan).toMatchObject({ + schemaVersion: 'fastgpt-workflow-plan/v1', + baseChecksum: changeSet.baseChecksum, + targetChecksum: result.plan.targetChecksum, + changes: [{ type: 'meta.update' }, { type: 'input.set', nodeId: 'text' }] + }); + expect(result.document.app.name).toBe('Batch changed'); + expect(document.app.name).not.toBe('Batch changed'); + }); + + it('rejects a stale base checksum before executing commands', async () => { + const document = WorkflowDocumentSchema.parse(staticWorkflowFixture); + const changeSet = await createChangeSet([{ type: 'meta.update', name: 'Changed' }], document); + changeSet.baseChecksum = `sha256:${'0'.repeat(64)}`; + await expect( + applyWorkflowChangeSet({ document, changeSet, dependencies }) + ).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'WORKFLOW_BASE_CHECKSUM_MISMATCH' })] + }); + expect(document.app.name).not.toBe('Changed'); + }); + + it('does not expose a partially changed document when a later command fails', async () => { + const document = WorkflowDocumentSchema.parse(staticWorkflowFixture); + const changeSet = await createChangeSet( + [ + { type: 'meta.update', name: 'Should roll back' }, + { type: 'node.remove', nodeId: 'missing-node' } + ], + document + ); + await expect(applyWorkflowChangeSet({ document, changeSet, dependencies })).rejects.toThrow(); + expect(document.app.name).not.toBe('Should roll back'); + }); +}); 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/mergeChangeSet.test.ts b/packages/workflow-core/test/command/mergeChangeSet.test.ts new file mode 100644 index 000000000000..c9c758ddef86 --- /dev/null +++ b/packages/workflow-core/test/command/mergeChangeSet.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { + mergeWorkflowChangeSetChunks, + WorkflowChangeSetMergeError, + type WorkflowChangeSetChunk +} from '../../src'; + +const baseChecksum = `sha256:${'a'.repeat(64)}`; +const chunk = (value: Partial): WorkflowChangeSetChunk => ({ + schemaVersion: 'fastgpt-workflow-chunk/v1', + chunkId: 'chunk', + dependsOn: [], + commands: [{ type: 'meta.update', name: 'name' }], + ...value +}); + +describe('mergeWorkflowChangeSetChunks', () => { + it('orders dependencies deterministically and merges disjoint metadata patches', () => { + const result = mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [ + chunk({ + chunkId: 'intro', + dependsOn: ['name'], + commands: [{ type: 'meta.update', intro: 'intro' }] + }), + chunk({ chunkId: 'name', commands: [{ type: 'meta.update', name: 'workflow' }] }) + ] + }); + expect(result.baseChecksum).toBe(baseChecksum); + expect(result.commands).toEqual([{ type: 'meta.update', name: 'workflow', intro: 'intro' }]); + }); + + it('deduplicates identical commands but rejects conflicting commands', () => { + const same = chunk({ chunkId: 'same' }); + expect( + mergeWorkflowChangeSetChunks({ baseChecksum, chunks: [same, { ...same, chunkId: 'copy' }] }) + .commands + ).toHaveLength(1); + + try { + mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [ + chunk({ chunkId: 'one', commands: [{ type: 'config.set', path: 'x', value: 1 }] }), + chunk({ chunkId: 'two', commands: [{ type: 'config.set', path: 'x', value: 2 }] }) + ] + }); + expect.fail('Expected a command conflict'); + } catch (error) { + expect(error).toBeInstanceOf(WorkflowChangeSetMergeError); + expect(error).toMatchObject({ + code: 'WORKFLOW_CHANGESET_COMMAND_CONFLICT', + params: { + target: 'config:x', + existing: { + chunkId: 'one', + commandIndex: 0, + commandType: 'config.set' + }, + incoming: { + chunkId: 'two', + commandIndex: 0, + commandType: 'config.set' + } + } + }); + } + }); + + it('rejects different command types that write the same semantic target', () => { + expect(() => + mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [ + chunk({ + chunkId: 'literal', + commands: [ + { + type: 'input.set', + nodeId: 'answer', + inputKey: 'text', + value: 'literal' + } + ] + }), + chunk({ + chunkId: 'reference', + commands: [ + { + type: 'input.ref', + nodeId: 'answer', + inputKey: 'text', + ref: { nodeId: 'upstream', outputKey: 'text' } + } + ] + }) + ] + }) + ).toThrowError( + expect.objectContaining({ + code: 'WORKFLOW_CHANGESET_COMMAND_CONFLICT', + params: { + target: 'input:answer:text', + existing: { + chunkId: 'literal', + commandIndex: 0, + commandType: 'input.set' + }, + incoming: { + chunkId: 'reference', + commandIndex: 0, + commandType: 'input.ref' + } + } + }) + ); + }); + + it('reports the actual patch contributor when a merged field conflicts', () => { + expect(() => + mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [ + chunk({ + chunkId: 'name', + commands: [{ type: 'meta.update', name: 'Workflow' }] + }), + chunk({ + chunkId: 'intro', + commands: [{ type: 'meta.update', intro: 'First intro' }] + }), + chunk({ + chunkId: 'intro-conflict', + commands: [{ type: 'meta.update', intro: 'Second intro' }] + }) + ] + }) + ).toThrowError( + expect.objectContaining({ + code: 'WORKFLOW_CHANGESET_COMMAND_CONFLICT', + params: expect.objectContaining({ + existing: { + chunkId: 'intro', + commandIndex: 0, + commandType: 'meta.update' + }, + incoming: { + chunkId: 'intro-conflict', + commandIndex: 0, + commandType: 'meta.update' + } + }) + }) + ); + }); + + it('rejects missing dependencies and cycles', () => { + expect(() => + mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [chunk({ chunkId: 'child', dependsOn: ['missing'] })] + }) + ).toThrow(/missing chunk/); + expect(() => + mergeWorkflowChangeSetChunks({ + baseChecksum, + chunks: [ + chunk({ chunkId: 'one', dependsOn: ['two'] }), + chunk({ chunkId: 'two', dependsOn: ['one'] }) + ] + }) + ).toThrow(/cycle/); + }); +}); 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..eaba7bc69230 --- /dev/null +++ b/packages/workflow-core/test/command/pr3.test.ts @@ -0,0 +1,292 @@ +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' }) + ); + + document = ( + await applyWorkflowCommand({ + document, + command: { + type: 'input.set', + nodeId: 'form', + inputKey: 'description', + value: 'Please enter your email' + }, + dependencies + }) + ).document; + const formOutputs = document.nodes.find((node) => node.nodeId === 'form')?.outputs ?? []; + expect(formOutputs.filter((output) => output.key === 'email')).toHaveLength(1); + expect( + validateWorkflow(document).filter( + (diagnostic) => diagnostic.code === 'WORKFLOW_OUTPUT_KEY_DUPLICATED' + ) + ).toEqual([]); + + 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..09c98c97e972 --- /dev/null +++ b/packages/workflow-core/test/command/systemConfig.test.ts @@ -0,0 +1,66 @@ +import { + FlowNodeTypeEnum, + VariableInputEnum, + WorkflowIOValueTypeEnum, + applyWorkflowCommand, + builtinTemplateProvider, + createDefaultWorkflowDocument, + createWorkflowDocument, + ensureSystemConfigNode +} from '../../src'; +import { describe, expect, it } from 'vitest'; + +const dependencies = { templateProvider: builtinTemplateProvider, locale: 'en' }; + +describe('legacy system config compatibility', () => { + it('creates only workflow start as the default workflow structure', async () => { + const result = await createDefaultWorkflowDocument({ + app: { name: 'Default workflow' }, + dependencies + }); + + expect(result.nodeIds).toEqual(['start']); + expect(result.document.nodes).toEqual([ + expect.objectContaining({ + nodeId: 'start', + flowNodeType: FlowNodeTypeEnum.workflowStart + }) + ]); + }); + + it('keeps variables in chatConfig without creating a legacy config node', 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).toHaveLength(1); + }); + + it('keeps the legacy ensure call as a no-op 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([]); + expect(second.nodeIds).toEqual([]); + expect(document.chatConfig.welcomeText).toBe('Keep me'); + expect(document.nodes).toEqual([]); + }); +}); 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..5e68a7320fb4 --- /dev/null +++ b/packages/workflow-core/test/config/service.test.ts @@ -0,0 +1,306 @@ +import { + NodeInputKeyEnum, + NodeOutputKeyEnum, + VARIABLE_NODE_ID, + VariableInputEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { + applyWorkflowCommand, + builtinTemplateProvider, + createWorkflowDocument, + getChatConfigDescriptor, + listChatConfigDescriptors, + 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; + +const createDocumentWithVariableReferenceArray = 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; + 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; + + const input = document.nodes + .find((node) => node.nodeId === 'read') + ?.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList); + if (!input) throw new Error('Read files input is unavailable'); + input.value = [ + [VARIABLE_NODE_ID, 'tenantId'], + ['start', NodeOutputKeyEnum.userFiles] + ]; + + return document; +}; + +describe('ChatConfig descriptors', () => { + it('exposes Agent guidance, value schema, capabilities and current value', () => { + const document = createWorkflowDocument({ + chatConfig: { fileSelectConfig: { maxFiles: 1, canSelectFile: true } } + }); + const descriptor = getChatConfigDescriptor({ + document, + path: 'fileSelectConfig', + translate: (value) => + value === 'workflow:cli.config.file_select' ? 'Enable user file input' : value + }); + + expect(descriptor).toMatchObject({ + path: 'fileSelectConfig', + description: 'Enable user file input', + capabilities: ['user-file-input'], + value: { maxFiles: 1, canSelectFile: true }, + valueSchema: { + type: 'object', + properties: { + maxFiles: expect.objectContaining({ type: 'integer' }), + canSelectFile: expect.objectContaining({ type: 'boolean' }) + } + } + }); + }); + + it('returns one descriptor for every allowlisted configuration path', () => { + const descriptors = listChatConfigDescriptors({ document: createWorkflowDocument() }); + + expect(descriptors).toHaveLength(14); + expect(descriptors.every(({ description }) => description.length > 0)).toBe(true); + expect(descriptors.find(({ path }) => path === 'autoExecute.open')?.valueSchema).toMatchObject({ + type: 'boolean' + }); + }); +}); + +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('keeps file output available when configuration is applied before Start creation', async () => { + let document = createWorkflowDocument(); + document = ( + await apply(document, { + type: 'config.set', + path: 'fileSelectConfig', + value: { canSelectFile: true, maxFiles: 1 } + }) + ).document; + document = ( + await apply(document, { + type: 'node.add', + nodeId: 'start', + template: parseNodeTemplateRef('builtin:workflow-start') + }) + ).document; + document = ( + await apply(document, { + type: 'node.add', + nodeId: 'read', + template: parseNodeTemplateRef('builtin:read-files'), + connectFrom: { kind: 'next', nodeId: 'start' } + }) + ).document; + + const start = document.nodes.find((node) => node.nodeId === 'start'); + const readFiles = document.nodes.find((node) => node.nodeId === 'read'); + expect(start?.outputs.some((output) => output.key === NodeOutputKeyEnum.userFiles)).toBe(true); + expect( + readFiles?.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)?.value + ).toEqual([['start', NodeOutputKeyEnum.userFiles]]); + }); + + 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); + }); +}); + +describe('global variable references', () => { + it('renames variables inside reference arrays without changing other references', async () => { + const document = await createDocumentWithVariableReferenceArray(); + const result = await apply(document, { + type: 'variable.update', + key: 'tenantId', + patch: { key: 'currentTenantId' } + }); + + expect( + result.document.nodes + .find((node) => node.nodeId === 'read') + ?.inputs.find((input) => input.key === NodeInputKeyEnum.fileUrlList)?.value + ).toEqual([ + [VARIABLE_NODE_ID, 'currentTenantId'], + ['start', NodeOutputKeyEnum.userFiles] + ]); + }); + + it('blocks removing variables referenced inside reference arrays', async () => { + const document = await createDocumentWithVariableReferenceArray(); + const before = structuredClone(document); + + await expect( + apply(document, { + type: 'variable.remove', + key: 'tenantId' + }) + ).rejects.toMatchObject({ + diagnostics: [ + expect.objectContaining({ + code: 'WORKFLOW_VARIABLE_STILL_REFERENCED' + }) + ] + }); + expect(document).toEqual(before); + }); + + it('blocks incompatible type changes for variables referenced inside reference arrays', async () => { + const document = await createDocumentWithVariableReferenceArray(); + + await expect( + apply(document, { + type: 'variable.update', + key: 'tenantId', + patch: { valueType: WorkflowIOValueTypeEnum.object } + }) + ).rejects.toMatchObject({ + diagnostics: [ + expect.objectContaining({ + code: 'WORKFLOW_VARIABLE_TYPE_CHANGE_INCOMPATIBLE', + nodeId: 'read', + inputKey: NodeInputKeyEnum.fileUrlList + }) + ] + }); + }); +}); diff --git a/packages/workflow-core/test/domain/checksum.test.ts b/packages/workflow-core/test/domain/checksum.test.ts new file mode 100644 index 000000000000..85897eacf7f8 --- /dev/null +++ b/packages/workflow-core/test/domain/checksum.test.ts @@ -0,0 +1,29 @@ +import { createWorkflowDocument, getWorkflowChecksum, normalizeWorkflowDocument } from '../../src'; +import { describe, expect, it } from 'vitest'; + +describe('canonical workflow checksum', () => { + it('uses SHA-256 over canonical UTF-8 JSON', async () => { + const document = createWorkflowDocument(); + expect(JSON.stringify(normalizeWorkflowDocument(document))).toBe( + '{"schemaVersion":"fastgpt-workflow/v1","app":{},"nodes":[],"executionEdges":[],"chatConfig":{}}' + ); + expect(await getWorkflowChecksum(document)).toBe( + 'sha256:f22ba5bcf46d3006db695d6fe21e290c0321357c294d74b9c2b726c3fac4e5cd' + ); + }); + + it('normalizes object key order without changing array semantics', async () => { + const left = createWorkflowDocument({ + app: { name: 'Demo', intro: 'Intro' }, + chatConfig: { variables: [] } + }); + const right = { + chatConfig: left.chatConfig, + executionEdges: left.executionEdges, + nodes: left.nodes, + app: { intro: left.app.intro, name: left.app.name }, + schemaVersion: left.schemaVersion + }; + expect(await getWorkflowChecksum(left)).toBe(await getWorkflowChecksum(right)); + }); +}); diff --git a/packages/workflow-core/test/domain/document.test.ts b/packages/workflow-core/test/domain/document.test.ts new file mode 100644 index 000000000000..269a6f7aca50 --- /dev/null +++ b/packages/workflow-core/test/domain/document.test.ts @@ -0,0 +1,26 @@ +import { + WORKFLOW_DOCUMENT_MIGRATION_GUIDANCE, + WORKFLOW_DOCUMENT_SCHEMA_VERSION, + WorkflowDocumentVersionError, + createWorkflowDocument, + parseCompatibleWorkflowDocument +} from '../../src'; +import { describe, expect, it } from 'vitest'; + +describe('WorkflowDocument compatibility', () => { + it('accepts the current schema version', () => { + const document = createWorkflowDocument(); + expect(parseCompatibleWorkflowDocument(document).schemaVersion).toBe( + WORKFLOW_DOCUMENT_SCHEMA_VERSION + ); + }); + + it.each([undefined, 'fastgpt-workflow/v0', 'fastgpt-workflow/v2'])( + 'rejects unsupported schemaVersion %s without silently rewriting it', + (schemaVersion) => { + const input = { ...createWorkflowDocument(), schemaVersion }; + expect(() => parseCompatibleWorkflowDocument(input)).toThrow(WorkflowDocumentVersionError); + expect(WORKFLOW_DOCUMENT_MIGRATION_GUIDANCE).toContain('migrate one version at a time'); + } + ); +}); 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..6ff1f64ba4f6 --- /dev/null +++ b/packages/workflow-core/test/fixtures/basic-ai/store-workflow.json @@ -0,0 +1,303 @@ +{ + "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, + "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.", + "selectedType": "textarea" + }, + { + "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, + "renderTypeList": [ + "reference", + "textarea" + ], + "value": [ + "start", + "userChatInput" + ], + "toolDescription": "User Question", + "selectedType": "reference" + } + ], + "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..2f3090dc0b53 --- /dev/null +++ b/packages/workflow-core/test/fixtures/basic-ai/workflow.json @@ -0,0 +1,309 @@ +{ + "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" + ], + "value": "You are a helpful assistant", + "valueType": "string", + "selectedType": "textarea" + }, + { + "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, + "toolDescription": "User Question", + "value": [ + "start", + "userChatInput" + ], + "valueType": "string", + "selectedType": "reference" + } + ], + "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..148dfa88ad66 --- /dev/null +++ b/packages/workflow-core/test/fixtures/basic-static/store-workflow.json @@ -0,0 +1,112 @@ +{ + "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, + "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", + "selectedType": "reference" + } + ], + "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", + "renderTypeList": [ + "textarea" + ], + "value": "Static response", + "selectedType": "textarea" + } + ], + "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..383a809f4383 --- /dev/null +++ b/packages/workflow-core/test/fixtures/basic-static/workflow.json @@ -0,0 +1,122 @@ +{ + "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, + "value": [ + "text", + "system_text" + ], + "valueType": "any", + "selectedType": "reference" + } + ], + "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, + "value": "Static response", + "valueType": "string", + "selectedType": "textarea" + } + ], + "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..d2e55e822991 --- /dev/null +++ b/packages/workflow-core/test/fixtures/branching/store-workflow.json @@ -0,0 +1,157 @@ +{ + "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" + } + ] + } + ], + "selectedType": "hidden" + } + ], + "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" + ], + "value": "yes", + "description": "common:core.module.input.description.Response content", + "selectedType": "textarea" + } + ], + "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" + ], + "value": "fallback", + "description": "common:core.module.input.description.Response content", + "selectedType": "textarea" + } + ], + "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..87d45de9db5f --- /dev/null +++ b/packages/workflow-core/test/fixtures/branching/workflow.json @@ -0,0 +1,173 @@ +{ + "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" + } + ] + } + ], + "selectedType": "hidden" + } + ], + "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", + "selectedType": "textarea" + } + ], + "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", + "selectedType": "textarea" + } + ], + "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..6c0bd960bba5 --- /dev/null +++ b/packages/workflow-core/test/fixtures/common-linear/store-workflow.json @@ -0,0 +1,985 @@ +{ + "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, + "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", + "selectedType": "reference" + } + ], + "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, + "renderTypeList": [ + "selectApp", + "reference" + ], + "value": { + "appId": "app-demo" + }, + "description": "Select another application to call", + "selectedType": "selectApp" + }, + { + "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, + "renderTypeList": [ + "reference", + "textarea" + ], + "value": [ + "query", + "system_text" + ], + "toolDescription": "User Question", + "selectedType": "reference" + } + ], + "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 + }, + "renderTypeList": [ + "reference" + ], + "value": [ + "query", + "system_text" + ], + "canEdit": true, + "selectedType": "reference" + }, + { + "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" + ], + "value": [ + "extract", + "fields" + ], + "canEdit": true, + "selectedType": "reference" + }, + { + "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, + "renderTypeList": [ + "selectLLMModel", + "reference" + ], + "value": "gpt-4.1", + "selectedType": "selectLLMModel" + }, + { + "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, + "renderTypeList": [ + "reference", + "textarea" + ], + "value": [ + "query", + "system_text" + ], + "toolDescription": "Content to Retrieve", + "selectedType": "reference" + }, + { + "key": "extractKeys", + "label": "", + "valueType": "any", + "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.", + "selectedType": "custom" + } + ], + "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", + "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.", + "selectedType": "hidden" + }, + { + "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, + "renderTypeList": [ + "selectLLMModel", + "reference" + ], + "value": "gpt-4.1", + "selectedType": "selectLLMModel" + }, + { + "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, + "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.", + "selectedType": "reference" + }, + { + "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, + "renderTypeList": [ + "reference", + "textarea" + ], + "value": [ + "start", + "userChatInput" + ], + "toolDescription": "user question", + "selectedType": "reference" + } + ], + "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, + "renderTypeList": [ + "selectDataset", + "reference" + ], + "valueDesc": "[\n {\n \"datasetId\": \"6693a4a6b69b7a9b0e37d9b0\"\n }\n]", + "value": [ + { + "avatar": "", + "datasetId": "dataset-demo", + "name": "Demo", + "vectorModel": { + "model": "text-embedding" + } + } + ], + "selectedType": "selectDataset" + }, + { + "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, + "renderTypeList": [ + "reference", + "textarea" + ], + "value": [ + "hello" + ], + "toolDescription": "Search term or search statement", + "selectedType": "textarea" + }, + { + "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..d2b6674537d2 --- /dev/null +++ b/packages/workflow-core/test/fixtures/common-linear/workflow.json @@ -0,0 +1,1018 @@ +{ + "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, + "value": [ + "call", + "answerText" + ], + "valueType": "any", + "selectedType": "reference" + } + ], + "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, + "value": { + "appId": "app-demo" + }, + "valueType": "selectApp", + "selectedType": "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, + "toolDescription": "User Question", + "value": [ + "query", + "system_text" + ], + "valueType": "string", + "selectedType": "reference" + } + ], + "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, + "value": [ + "query", + "system_text" + ], + "valueType": "string", + "selectedType": "reference" + }, + { + "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, + "value": [ + "extract", + "fields" + ], + "valueType": "string", + "selectedType": "reference" + }, + { + "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, + "value": "gpt-4.1", + "valueType": "string", + "selectedType": "selectLLMModel" + }, + { + "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, + "toolDescription": "Content to Retrieve", + "value": [ + "query", + "system_text" + ], + "valueType": "string", + "selectedType": "reference" + }, + { + "description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted.", + "key": "extractKeys", + "label": "", + "renderTypeList": [ + "custom" + ], + "value": [ + { + "description": "Summary", + "key": "summary", + "required": true, + "valueType": "string" + } + ], + "valueType": "any", + "selectedType": "custom" + } + ], + "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, + "value": "https://example.com/api", + "valueType": "string", + "selectedType": "hidden" + }, + { + "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, + "value": "gpt-4.1", + "valueType": "string", + "selectedType": "selectLLMModel" + }, + { + "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" + ], + "value": [ + "VARIABLE_NODE_ID", + "tenantId" + ], + "valueType": "string", + "selectedType": "reference" + }, + { + "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, + "toolDescription": "user question", + "value": [ + "start", + "userChatInput" + ], + "valueType": "string", + "selectedType": "reference" + } + ], + "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, + "value": [ + { + "avatar": "", + "datasetId": "dataset-demo", + "name": "Demo", + "vectorModel": { + "model": "text-embedding" + } + } + ], + "valueDesc": "[\n {\n \"datasetId\": \"6693a4a6b69b7a9b0e37d9b0\"\n }\n]", + "valueType": "selectDataset", + "selectedType": "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, + "toolDescription": "Search term or search statement", + "value": [ + "hello" + ], + "valueType": "arrayString", + "selectedType": "textarea" + }, + { + "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..1b69b9e1a311 --- /dev/null +++ b/packages/workflow-core/test/fixtures/dynamic-io-catch/store-workflow.json @@ -0,0 +1,277 @@ +{ + "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" + ], + "value": [ + "start", + "userChatInput" + ], + "canEdit": true, + "selectedType": "reference" + }, + { + "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" + ], + "value": [ + "start", + "userChatInput" + ], + "canEdit": true, + "selectedType": "reference" + }, + { + "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" + ], + "value": "recover", + "description": "common:core.module.input.description.Response content", + "selectedType": "textarea" + } + ], + "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..f8ffdaa41d49 --- /dev/null +++ b/packages/workflow-core/test/fixtures/dynamic-io-catch/workflow.json @@ -0,0 +1,287 @@ +{ + "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" + ], + "selectedType": "reference" + }, + { + "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" + ], + "selectedType": "reference" + }, + { + "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", + "selectedType": "textarea" + } + ], + "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..351326424852 --- /dev/null +++ b/packages/workflow-core/test/fixtures/nested-loop/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": "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", + "selectedType": "select" + }, + { + "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..359d862efc53 --- /dev/null +++ b/packages/workflow-core/test/fixtures/nested-loop/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": "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", + "selectedType": "select" + }, + { + "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..1fadee044cb4 --- /dev/null +++ b/packages/workflow-core/test/fixtures/tool-call-tools/store-workflow.json @@ -0,0 +1,320 @@ +{ + "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" + ], + "value": [ + "start", + "userChatInput" + ], + "toolDescription": "user question", + "selectedType": "reference" + } + ], + "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..70901c2fb139 --- /dev/null +++ b/packages/workflow-core/test/fixtures/tool-call-tools/workflow.json @@ -0,0 +1,330 @@ +{ + "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" + ], + "selectedType": "reference" + } + ], + "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..538eb4a66395 --- /dev/null +++ b/packages/workflow-core/test/reference/service.test.ts @@ -0,0 +1,283 @@ +import { + FlowNodeInputTypeEnum, + VARIABLE_NODE_ID, + VariableInputEnum, + WorkflowCommandError, + WorkflowDocumentSchema, + WorkflowIOValueTypeEnum, + areWorkflowValueTypesCompatible, + builtinTemplateProvider, + createWorkflowDocument, + getAvailableInputReferences, + instantiateNodeFromTemplate, + parseNodeTemplateRef, + setInputReference, + setInputValue, + validateWorkflow +} 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: 'history', value: '100' }) + ).toThrow(WorkflowCommandError); + setInputValue({ document, nodeId: 'ai', inputKey: 'history', value: 100 }); + expect( + document.nodes + .find((node) => node.nodeId === 'ai')! + .inputs.find((input) => input.key === 'history')?.value + ).toBe(100); + }); + + it('distinguishes literal, reference and runtime-generated input capabilities', () => { + const userInput = document.nodes + .find((node) => node.nodeId === 'ai')! + .inputs.find((input) => input.key === 'userChatInput')!; + + userInput.renderTypeList = [FlowNodeInputTypeEnum.reference]; + expect(() => + setInputValue({ document, nodeId: 'ai', inputKey: userInput.key, value: 'manual' }) + ).toThrow('WORKFLOW_INPUT_MODE_NOT_ALLOWED'); + setInputReference({ + document, + nodeId: 'ai', + inputKey: userInput.key, + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }); + + userInput.renderTypeList = [FlowNodeInputTypeEnum.textarea]; + expect(() => + setInputReference({ + document, + nodeId: 'ai', + inputKey: userInput.key, + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }) + ).toThrow('WORKFLOW_INPUT_MODE_NOT_ALLOWED'); + setInputValue({ document, nodeId: 'ai', inputKey: userInput.key, value: 'manual' }); + + userInput.renderTypeList = [FlowNodeInputTypeEnum.agentGenerated]; + expect(() => + setInputValue({ document, nodeId: 'ai', inputKey: userInput.key, value: 'manual' }) + ).toThrow('WORKFLOW_INPUT_NOT_CONFIGURABLE'); + expect(() => + setInputReference({ + document, + nodeId: 'ai', + inputKey: userInput.key, + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }) + ).toThrow('WORKFLOW_INPUT_NOT_CONFIGURABLE'); + + userInput.renderTypeList = [ + FlowNodeInputTypeEnum.agentGenerated, + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference + ]; + setInputValue({ document, nodeId: 'ai', inputKey: userInput.key, value: 'manual' }); + setInputReference({ + document, + nodeId: 'ai', + inputKey: userInput.key, + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }); + }); + + it('uses explicit automation metadata to expose hidden modal inputs', async () => { + const ref = parseNodeTemplateRef('builtin:http-request'); + const { node } = await instantiateNodeFromTemplate({ + document: createWorkflowDocument(), + templateRef: ref, + nodeId: 'http', + provider: builtinTemplateProvider, + locale: 'en' + }); + const httpDocument = createWorkflowDocument({ nodes: [node] }); + + setInputValue({ + document: httpDocument, + nodeId: 'http', + inputKey: 'system_httpReqUrl', + value: 'https://example.com' + }); + expect( + httpDocument.nodes[0].inputs.find((input) => input.key === 'system_httpReqUrl')?.value + ).toBe('https://example.com'); + expect(() => + setInputReference({ + document: httpDocument, + nodeId: 'http', + inputKey: 'system_httpReqUrl', + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }) + ).toThrow('WORKFLOW_INPUT_MODE_NOT_ALLOWED'); + }); + + it('validates dynamic input customJsonSchema on input.set', () => { + const promptInput = document.nodes + .find((node) => node.nodeId === 'ai')! + .inputs.find((input) => input.key === 'systemPrompt')!; + promptInput.customJsonSchema = { type: 'string', minLength: 3 }; + + expect(() => + setInputValue({ document, nodeId: 'ai', inputKey: promptInput.key, value: 'x' }) + ).toThrow('WORKFLOW_INPUT_VALUE_SCHEMA_INVALID'); + setInputValue({ document, nodeId: 'ai', inputKey: promptInput.key, value: 'valid' }); + }); + + 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); + const fileInput = document.nodes + .find((node) => node.nodeId === 'ai')! + .inputs.find((input) => input.key === 'fileUrlList')!; + fileInput.selectedType = FlowNodeInputTypeEnum.input; + setInputReference({ + document, + nodeId: 'ai', + inputKey: 'fileUrlList', + ref: { nodeId: 'start', outputKey: 'userChatInput' } + }); + expect(fileInput).toMatchObject({ + value: ['start', 'userChatInput'], + selectedType: FlowNodeInputTypeEnum.reference + }); + + setInputValue({ document, nodeId: 'ai', inputKey: 'fileUrlList', value: ['manual-value'] }); + expect(fileInput).toMatchObject({ + value: ['manual-value'], + selectedType: FlowNodeInputTypeEnum.input + }); + document.chatConfig.variables = [ + { + key: 'fileUrl', + label: 'File URL', + description: '', + type: VariableInputEnum.input, + valueType: WorkflowIOValueTypeEnum.string + } + ]; + setInputReference({ + document, + nodeId: 'ai', + inputKey: 'fileUrlList', + ref: { nodeId: VARIABLE_NODE_ID, outputKey: 'fileUrl' } + }); + expect(document.nodes.find((node) => node.nodeId === 'ai')?.inputs).toContainEqual( + expect.objectContaining({ + key: 'fileUrlList', + value: [VARIABLE_NODE_ID, 'fileUrl'] + }) + ); + expect(validateWorkflow(document).map((diagnostic) => diagnostic.code)).not.toContain( + 'WORKFLOW_REFERENCE_TYPE_MISMATCH' + ); + expect(() => + setInputValue({ document, nodeId: 'ai', inputKey: 'fileUrlList', value: 'not-an-array' }) + ).toThrow(WorkflowCommandError); + expect(() => + setInputReference({ + document, + nodeId: 'ai', + inputKey: 'userChatInput', + ref: { nodeId: 'ai', outputKey: 'answerText' } + }) + ).toThrow(WorkflowCommandError); + + expect( + getAvailableInputReferences({ + document, + nodeId: 'ai', + inputKey: 'fileUrlList' + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ref: { nodeId: 'start', outputKey: 'userChatInput' }, + valueType: WorkflowIOValueTypeEnum.string + }), + expect.objectContaining({ + ref: { nodeId: VARIABLE_NODE_ID, outputKey: 'fileUrl' }, + valueType: WorkflowIOValueTypeEnum.string + }) + ]) + ); + }); +}); + +describe('areWorkflowValueTypesCompatible', () => { + it('accepts scalar items for array references without widening reverse types', () => { + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayString, + actual: WorkflowIOValueTypeEnum.string + }) + ).toBe(true); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayString, + actual: WorkflowIOValueTypeEnum.string + }) + ).toBe(true); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayString, + actual: WorkflowIOValueTypeEnum.arrayString + }) + ).toBe(true); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayString, + actual: WorkflowIOValueTypeEnum.number + }) + ).toBe(false); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.string, + actual: WorkflowIOValueTypeEnum.arrayString + }) + ).toBe(false); + expect( + areWorkflowValueTypesCompatible({ + expected: WorkflowIOValueTypeEnum.arrayAny, + actual: WorkflowIOValueTypeEnum.string + }) + ).toBe(true); + }); +}); 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..d97003c2c11c --- /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', async () => { + const parsedWorkflow = WorkflowDocumentSchema.parse(aiWorkflow); + const reversed = { + ...parsedWorkflow, + nodes: [...parsedWorkflow.nodes].reverse(), + executionEdges: [...parsedWorkflow.executionEdges].reverse() + }; + expect(await getWorkflowChecksum(parsedWorkflow)).toBe(await 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/provider.test.ts b/packages/workflow-core/test/template/provider.test.ts new file mode 100644 index 000000000000..03d5a8d02b91 --- /dev/null +++ b/packages/workflow-core/test/template/provider.test.ts @@ -0,0 +1,134 @@ +import { WorkflowStart } from '@fastgpt/global/core/workflow/template/system/workflowStart'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { + WorkflowCommandError, + WorkflowTemplateBundleSchema, + builtinTemplateProvider, + composeWorkflowTemplateProviders, + createWorkflowTemplateBundleProvider, + createWorkflowDocument, + formatNodeTemplateRef, + instantiateNodeFromTemplate, + parseNodeTemplateRef +} from '../../src'; +import { describe, expect, it } from 'vitest'; + +const systemToolRef = { + kind: 'systemTool' as const, + source: 'debug:tmbId:session-1', + toolId: 'systemTool-weather/forecast' +}; + +const createBundle = () => ({ + schemaVersion: 'fastgpt-workflow-template-bundle/v1' as const, + items: [ + { + ref: systemToolRef, + template: { + ...structuredClone(WorkflowStart), + id: systemToolRef.toolId, + pluginId: systemToolRef.toolId, + flowNodeType: FlowNodeTypeEnum.tool, + unique: false, + name: 'Weather forecast', + source: systemToolRef.source, + toolConfig: { + systemTool: { toolId: systemToolRef.toolId, source: systemToolRef.source } + } + } + } + ] +}); + +describe('workflow template provider', () => { + it('uses source and tool id as the canonical system tool identity', () => { + const encoded = 'systemTool:debug%3AtmbId%3Asession-1/systemTool-weather%2Fforecast'; + + expect(formatNodeTemplateRef(systemToolRef)).toBe(encoded); + expect(parseNodeTemplateRef(encoded)).toEqual(systemToolRef); + expect(() => parseNodeTemplateRef('systemTool:systemTool-weather')).toThrow(); + }); + + it('loads a strict request-scoped template bundle', async () => { + const provider = createWorkflowTemplateBundleProvider(createBundle()); + + await expect(provider.list({ locale: 'en' })).resolves.toEqual([systemToolRef]); + await expect(provider.resolve(systemToolRef, { locale: 'en' })).resolves.toMatchObject({ + template: { id: systemToolRef.toolId, source: systemToolRef.source } + }); + await expect( + provider.resolve( + { kind: 'systemTool', source: 'system', toolId: 'missing' }, + { locale: 'en' } + ) + ).rejects.toMatchObject({ + diagnostics: [{ code: 'WORKFLOW_TEMPLATE_UNAVAILABLE' }] + }); + }); + + it('composes builtin and request-scoped providers without changing Core execution', async () => { + const provider = composeWorkflowTemplateProviders([ + builtinTemplateProvider, + createWorkflowTemplateBundleProvider(createBundle()) + ]); + const refs = await provider.list({ locale: 'en' }); + + expect(refs).toContainEqual({ kind: 'builtin', templateId: 'ai-chat' }); + expect(refs).toContainEqual(systemToolRef); + await expect( + provider.resolve({ kind: 'builtin', templateId: '__system-config' }, { locale: 'en' }) + ).rejects.toMatchObject({ diagnostics: [{ code: 'WORKFLOW_TEMPLATE_UNAVAILABLE' }] }); + await expect( + instantiateNodeFromTemplate({ + document: createWorkflowDocument(), + templateRef: systemToolRef, + nodeId: 'weather', + provider, + locale: 'en' + }) + ).resolves.toMatchObject({ + node: { + nodeId: 'weather', + flowNodeType: FlowNodeTypeEnum.tool, + pluginId: systemToolRef.toolId, + source: systemToolRef.source + } + }); + }); + + it('rejects duplicate identities and unknown transport fields', async () => { + const duplicateProvider = composeWorkflowTemplateProviders([ + createWorkflowTemplateBundleProvider(createBundle()), + createWorkflowTemplateBundleProvider(createBundle()) + ]); + + await expect(duplicateProvider.list({ locale: 'en' })).rejects.toBeInstanceOf( + WorkflowCommandError + ); + expect(() => + WorkflowTemplateBundleSchema.parse({ + ...createBundle(), + items: [ + { + ...createBundle().items[0], + template: { + ...createBundle().items[0].template, + secretsVal: { token: 'secret' } + } + } + ] + }) + ).toThrow(); + expect(() => + WorkflowTemplateBundleSchema.parse({ + ...createBundle(), + items: [ + { + ...createBundle().items[0], + template: { ...createBundle().items[0].template, pluginId: 'another-tool' } + } + ] + }) + ).toThrow(); + }); +}); 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..e9e2fa46cfd8 --- /dev/null +++ b/packages/workflow-core/test/template/template.test.ts @@ -0,0 +1,551 @@ +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); + expect(descriptor.inputs.find((input) => input.key === 'system_httpReqUrl')).toMatchObject({ + configurable: true, + inputModes: ['literal'] + }); + }); + + it('exposes only builder-editable modes for agent-generated inputs', async () => { + const ref = parseNodeTemplateRef('builtin:ai-chat'); + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + const getDescriptorInput = (renderTypeList: FlowNodeInputTypeEnum[], canEdit = true) => { + const template = structuredClone(resolved.template); + const automationMeta = structuredClone(resolved.automationMeta); + const input = template.inputs.find((item) => item.key === 'systemPrompt')!; + input.renderTypeList = renderTypeList; + input.canEdit = canEdit; + delete automationMeta?.inputs?.systemPrompt; + return normalizeNodeTemplateDescriptor({ + template, + automationMeta, + templateRef: ref + }).inputs.find((item) => item.key === 'systemPrompt'); + }; + + expect(getDescriptorInput([FlowNodeInputTypeEnum.agentGenerated])).toMatchObject({ + configurable: false, + inputModes: [] + }); + expect( + getDescriptorInput([ + FlowNodeInputTypeEnum.agentGenerated, + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.reference + ]) + ).toMatchObject({ + configurable: true, + inputModes: ['literal', 'reference'] + }); + expect(getDescriptorInput([FlowNodeInputTypeEnum.hidden])).toMatchObject({ + configurable: true, + inputModes: ['literal'] + }); + expect(getDescriptorInput([FlowNodeInputTypeEnum.input], false)).toMatchObject({ + configurable: false, + inputModes: [] + }); + }); + + it('publishes customJsonSchema for dynamic system-tool input validation', async () => { + const ref = parseNodeTemplateRef('builtin:ai-chat'); + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + const template = structuredClone(resolved.template); + template.inputs.find((input) => input.key === 'systemPrompt')!.customJsonSchema = { + type: 'string', + minLength: 3 + }; + const descriptor = normalizeNodeTemplateDescriptor({ ...resolved, template, templateRef: ref }); + + expect( + descriptor.inputs.find((input) => input.key === 'systemPrompt')?.constraints?.valueSchema + ).toEqual({ type: 'string', minLength: 3 }); + }); + + it('documents distinct text and node-reference formats for text editor inputs', async () => { + const ref = parseNodeTemplateRef('builtin:text-editor'); + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + const descriptor = normalizeNodeTemplateDescriptor({ + ...resolved, + templateRef: ref + }); + const input = descriptor.inputs.find((item) => item.key === 'system_textareaInput'); + + expect(input?.examples).toEqual([ + 'Plain text', + 'Hello {{name}}', + 'Topic: {{$validateCode.topic$}}' + ]); + expect(input?.description).toContain('Never use {{nodeId.outputKey}} for a node reference.'); + }); + + 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)); + expect(resolved.automationMeta, templateName).toBeDefined(); + 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 }); + } + } + } + }); + + it('publishes a complete machine contract for every exposed builtin template', async () => { + const structuredValueTypes = new Set([ + WorkflowIOValueTypeEnum.object, + WorkflowIOValueTypeEnum.arrayObject, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.selectDataset, + WorkflowIOValueTypeEnum.selectApp, + WorkflowIOValueTypeEnum.dynamic + ]); + const systemInputKeys = new Set([ + 'system_input_config', + 'system_addInputParam', + 'childrenNodeIdList', + 'nodeWidth', + 'nodeHeight', + 'loopNodeInputHeight', + 'loopCustomOutputs' + ]); + + for (const ref of await builtinTemplateProvider.list({ locale: 'en' })) { + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + const descriptor = normalizeNodeTemplateDescriptor({ ...resolved, templateRef: ref }); + const templateName = formatNodeTemplateRef(ref); + + expect(descriptor.schemaVersion, templateName).toBe('fastgpt-workflow-node-contract/v1'); + expect(descriptor.execution.sourceKinds, templateName).toEqual( + expect.arrayContaining( + descriptor.execution.sourceKinds.filter((kind) => + ['next', 'branch', 'sourceOutput', 'catch', 'selectedTools'].includes(kind) + ) + ) + ); + expect(descriptor.execution.terminal, templateName).toBe( + descriptor.execution.sourceKinds.length === 0 + ); + for (const input of descriptor.inputs) { + if (input.configurable) { + expect(input.description.trim().length, `${templateName}.${input.key}`).toBeGreaterThan( + 0 + ); + expect(input.inputModes.length, `${templateName}.${input.key}`).toBeGreaterThan(0); + } + if (input.inputModes.includes('reference')) { + expect( + input.referencePolicy?.acceptedSourceValueTypes.length, + `${templateName}.${input.key}` + ).toBeGreaterThan(0); + } else { + expect(input.referencePolicy, `${templateName}.${input.key}`).toBeUndefined(); + } + if ( + input.configurable && + input.inputModes.includes('literal') && + input.valueType && + structuredValueTypes.has(input.valueType as WorkflowIOValueTypeEnum) + ) { + expect(input.constraints?.valueSchema, `${templateName}.${input.key}`).toBeDefined(); + } + if (systemInputKeys.has(input.key)) { + expect(input.configurable, `${templateName}.${input.key}`).toBe(false); + } + } + } + }); +}); + +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(descriptor.schemaVersion).toBe('fastgpt-workflow-node-contract/v1'); + expect(userInput?.inputModes).toEqual(['literal', 'reference']); + expect(userInput?.referencePolicy?.acceptedSourceValueTypes).toEqual( + expect.arrayContaining([WorkflowIOValueTypeEnum.string, WorkflowIOValueTypeEnum.any]) + ); + 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(descriptor.execution).toMatchObject({ + sourceKinds: expect.arrayContaining(['next']), + targetKinds: expect.arrayContaining(['target', 'selectedTools']), + terminal: false + }); + expect(JSON.stringify(resolved.template)).not.toContain('invalidCondition'); + }); + + it('publishes the same scalar-to-array reference contract used by the editor', async () => { + const ref = parseNodeTemplateRef('builtin:dataset-search'); + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + const descriptor = normalizeNodeTemplateDescriptor({ ...resolved, templateRef: ref }); + const searchInput = descriptor.inputs.find( + (input) => input.valueType === WorkflowIOValueTypeEnum.arrayString + ); + + expect(searchInput).toMatchObject({ + inputModes: expect.arrayContaining(['reference']), + referencePolicy: { + acceptedSourceValueTypes: [ + WorkflowIOValueTypeEnum.string, + WorkflowIOValueTypeEnum.arrayString, + WorkflowIOValueTypeEnum.arrayAny, + WorkflowIOValueTypeEnum.any + ] + } + }); + }); + + it('describes branch, terminal, container and dynamic IO capabilities', async () => { + const getDescriptor = async (templateId: string) => { + const ref = parseNodeTemplateRef(`builtin:${templateId}`); + const resolved = await builtinTemplateProvider.resolve(ref, { locale: 'en' }); + return normalizeNodeTemplateDescriptor({ ...resolved, templateRef: ref }); + }; + + await expect(getDescriptor('if-else')).resolves.toMatchObject({ + execution: { + sourceKinds: ['branch'], + targetKinds: ['target'], + terminal: false, + branch: { + inputKey: 'ifElseList', + keyField: 'branchId', + keyFieldRequiredForNewValues: true, + fallbackKey: 'ELSE', + configureBeforeConnect: true + } + }, + effects: expect.arrayContaining(['prune-invalid-branch-edges']) + }); + await expect(getDescriptor('assigned-answer')).resolves.toMatchObject({ + execution: { sourceKinds: [], terminal: true } + }); + await expect(getDescriptor('loop-run-break')).resolves.toMatchObject({ + execution: { sourceKinds: [], targetKinds: ['target'], terminal: true }, + container: { rootAllowed: false, allowedParentTypes: ['loopRun'] } + }); + await expect(getDescriptor('parallel-run')).resolves.toMatchObject({ + container: { kind: 'parallel', rootAllowed: true, allowedParentTypes: [] }, + dynamicIO: { + inputs: { manual: false, derivedFromInputKeys: [] }, + outputs: { manual: false, derivedFromInputKeys: [] } + } + }); + await expect(getDescriptor('code')).resolves.toMatchObject({ + dynamicIO: { + inputs: { manual: true, derivedFromInputKeys: ['code'] }, + outputs: { manual: true, derivedFromInputKeys: ['code'] } + } + }); + await expect(getDescriptor('form-input')).resolves.toMatchObject({ + container: { rootAllowed: true, allowedParentTypes: ['loop', 'loopRun'] }, + dynamicIO: { + inputs: { manual: false }, + outputs: { manual: false, derivedFromInputKeys: ['userInputForms'] } + } + }); + }); + + 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('overrides a template literal selectedType when applying the start-node reference', async () => { + const start = await instantiateNodeFromTemplate({ + document: createWorkflowDocument(), + templateRef: parseNodeTemplateRef('builtin:workflow-start'), + nodeId: 'start', + provider: builtinTemplateProvider, + locale: 'en' + }); + const provider = { + list: builtinTemplateProvider.list, + resolve: async () => { + const resolved = await builtinTemplateProvider.resolve( + parseNodeTemplateRef('builtin:ai-chat'), + { locale: 'en' } + ); + const template = structuredClone(resolved.template); + template.inputs.find((input) => input.key === 'userChatInput')!.selectedType = + FlowNodeInputTypeEnum.textarea; + return { ...resolved, template }; + } + }; + + const ai = await instantiateNodeFromTemplate({ + document: createWorkflowDocument({ nodes: [start.node] }), + templateRef: parseNodeTemplateRef('builtin:ai-chat'), + nodeId: 'ai', + provider, + locale: 'en' + }); + const userInput = ai.node.inputs.find((input) => input.key === 'userChatInput')!; + + expect(userInput.value).toEqual(['start', 'userChatInput']); + expect(userInput.selectedType).toBe(FlowNodeInputTypeEnum.reference); + }); + + 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('keeps the scalar 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(['start', 'userChatInput']); + expect(userInput?.selectedType).toBe(FlowNodeInputTypeEnum.reference); + }); + + 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/template/valueSchema.test.ts b/packages/workflow-core/test/template/valueSchema.test.ts new file mode 100644 index 000000000000..75aa054288c2 --- /dev/null +++ b/packages/workflow-core/test/template/valueSchema.test.ts @@ -0,0 +1,131 @@ +import { getAutomationMeta, parseNodeTemplateRef, valueMatchesSchema } from '../../src'; +import { describe, expect, it } from 'vitest'; + +describe('valueMatchesSchema', () => { + it('validates nested objects, arrays and scalar constraints', () => { + const schema = { + type: 'array', + minItems: 1, + maxItems: 2, + uniqueItems: true, + items: { + type: 'object', + required: ['key', 'score'], + additionalProperties: false, + properties: { + key: { type: 'string', minLength: 2, maxLength: 5, pattern: '^[a-z]+$' }, + score: { type: 'number', minimum: 0, maximum: 1 } + } + } + }; + + expect(valueMatchesSchema([{ key: 'ok', score: 0.5 }], schema)).toBe(true); + expect(valueMatchesSchema([], schema)).toBe(false); + expect(valueMatchesSchema([{ key: 'A', score: 2 }], schema)).toBe(false); + expect(valueMatchesSchema([{ key: 'ok', score: 0.5, extra: true }], schema)).toBe(false); + expect( + valueMatchesSchema( + [ + { key: 'ok', score: 0.5 }, + { key: 'ok', score: 0.5 } + ], + schema + ) + ).toBe(false); + }); + + it('validates unions, constants, tuples and additional property schemas', () => { + expect(valueMatchesSchema('x', { anyOf: [{ type: 'string' }, { type: 'number' }] })).toBe(true); + expect(valueMatchesSchema(true, { oneOf: [{ type: 'boolean' }, { const: true }] })).toBe(false); + expect(valueMatchesSchema(2, { allOf: [{ type: 'integer' }, { minimum: 1 }] })).toBe(true); + expect(valueMatchesSchema('blocked', { not: { const: 'blocked' } })).toBe(false); + expect( + valueMatchesSchema(['node', 'output'], { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'string' }], + items: false + }) + ).toBe(true); + expect( + valueMatchesSchema(['node', 'output', 'extra'], { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'string' }], + items: false + }) + ).toBe(false); + expect( + valueMatchesSchema( + { known: 'x', extra: 1 }, + { + type: 'object', + properties: { known: { type: 'string' } }, + additionalProperties: { type: 'integer' } + } + ) + ).toBe(true); + }); + + it('validates the complete branching, form and variable-update contracts', () => { + const getInputSchema = (templateId: string, inputKey: string) => + getAutomationMeta(parseNodeTemplateRef(`builtin:${templateId}`))?.inputs?.[inputKey] + ?.valueSchema as Record; + + const ifElseSchema = getInputSchema('if-else', 'ifElseList'); + expect( + valueMatchesSchema( + [ + { + branchId: 'matched', + condition: 'AND', + list: [ + { + variable: ['start', 'userChatInput'], + condition: 'equalTo', + value: ['', 'hello'], + valueType: 'input' + } + ] + } + ], + ifElseSchema + ) + ).toBe(true); + expect( + valueMatchesSchema([{ condition: 'XOR', list: [{ condition: 'unknown' }] }], ifElseSchema) + ).toBe(false); + + const formSchema = getInputSchema('form-input', 'userInputForms'); + expect( + valueMatchesSchema( + [ + { + type: 'input', + key: 'name', + label: 'Name', + value: '', + valueType: 'string', + required: true + } + ], + formSchema + ) + ).toBe(true); + expect(valueMatchesSchema([{ key: 'name' }], formSchema)).toBe(false); + + const updateSchema = getInputSchema('variable-update', 'updateList'); + expect( + valueMatchesSchema( + [ + { + variable: ['globalVariable', 'answer'], + value: ['', 'done'], + valueType: 'string', + renderType: 'input' + } + ], + updateSchema + ) + ).toBe(true); + expect(valueMatchesSchema([{ renderType: 'textarea' }], updateSchema)).toBe(false); + }); +}); 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..f98763a05965 --- /dev/null +++ b/packages/workflow-core/test/validation/validation.test.ts @@ -0,0 +1,151 @@ +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 incomplete variable update rows', 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 update = await applyWorkflowCommand({ + document: start.document, + command: { + type: 'node.add', + nodeId: 'update', + template: parseNodeTemplateRef('builtin:variable-update'), + connectFrom: { kind: 'next', nodeId: 'start' } + }, + dependencies + }); + + const codes = validateWorkflow(update.document).map((item) => item.code); + expect(codes).toContain('WORKFLOW_VARIABLE_UPDATE_TARGET_MISSING'); + expect(codes).toContain('WORKFLOW_VARIABLE_UPDATE_VALUE_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.selectedType = 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..43a3637def69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ catalogs: '@tanstack/react-query': specifier: ^4.24.10 version: 4.36.1 + '@types/crypto-js': + specifier: 4.2.2 + version: 4.2.2 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -99,6 +102,9 @@ catalogs: chalk: specifier: ^5.6.2 version: 5.6.2 + crypto-js: + specifier: 4.2.0 + version: 4.2.0 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -920,6 +926,64 @@ importers: specifier: ^18 version: 18.3.0 + packages/workflow-cli: + devDependencies: + '@fastgpt/web': + specifier: workspace:* + version: link:../web + '@fastgpt/workflow-core': + specifier: workspace:* + version: link:../workflow-core + '@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.4.3(@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)) + zod: + specifier: 'catalog:' + version: 4.1.12 + + packages/workflow-core: + dependencies: + '@fastgpt/global': + specifier: workspace:* + version: link:../global + '@types/crypto-js': + specifier: 'catalog:' + version: 4.2.2 + crypto-js: + specifier: 'catalog:' + version: 4.2.0 + 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.4.3(@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': @@ -952,6 +1016,9 @@ importers: '@fastgpt-sdk/otel': specifier: workspace:* version: link:../../sdk/otel + '@fastgpt-sdk/sandbox-adapter': + specifier: workspace:* + version: link:../../sdk/sandbox-adapter '@fastgpt-sdk/storage': specifier: workspace:* version: link:../../sdk/storage @@ -970,6 +1037,9 @@ importers: '@fastgpt/web': specifier: workspace:* version: link:../../packages/web + '@fastgpt/workflow-core': + specifier: workspace:* + version: link:../../packages/workflow-core '@kubernetes/client-node': specifier: ^1.4.0 version: 1.4.0(bufferutil@4.1.0)(encoding@0.1.13)(utf-8-validate@5.0.10) @@ -1036,6 +1106,9 @@ importers: dns-packet: specifier: ^5.6.1 version: 5.6.1 + dompurify: + specifier: ^3.4.2 + version: 3.4.2 fast-xml-parser: specifier: ^4.4.1 version: 4.5.7 @@ -1051,6 +1124,9 @@ importers: js-yaml: specifier: 'catalog:' version: 4.1.1 + jsdom: + specifier: ^26.1.0 + version: 26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10) json5: specifier: 'catalog:' version: 2.2.3 @@ -1066,6 +1142,9 @@ importers: lodash-es: specifier: 'catalog:' version: 4.18.1 + mermaid: + specifier: 'catalog:' + version: 11.16.1 mongoose: specifier: 'catalog:' version: 8.23.1(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.8.9) @@ -1151,6 +1230,9 @@ importers: '@types/js-yaml': specifier: 'catalog:' version: 4.0.9 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 '@types/jsonwebtoken': specifier: 'catalog:' version: 9.0.9 @@ -1370,6 +1452,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 @@ -6660,6 +6745,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -14074,10 +14162,6 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@5.1.0: - resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} - engines: {node: '>=18'} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -14806,10 +14890,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@14.1.1: - resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} - engines: {node: '>=18'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -20883,7 +20963,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 - postcss: 8.5.23 + postcss: 8.5.22 tailwindcss: 4.2.4 '@tanstack/query-core@4.36.1': {} @@ -21113,6 +21193,8 @@ snapshots: dependencies: '@types/node': 20.17.24 + '@types/crypto-js@4.2.2': {} + '@types/d3-array@3.2.1': {} '@types/d3-axis@3.0.6': @@ -21304,7 +21386,7 @@ snapshots: '@types/jsdom@21.1.7': dependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.43 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -21750,6 +21832,14 @@ snapshots: optionalDependencies: vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) + '@vitest/mocker@4.1.5(vite@6.4.3(@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))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@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) + '@vitest/mocker@4.1.5(vite@6.4.3(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 @@ -21838,7 +21928,7 @@ snapshots: '@vue/shared': 3.5.40 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.23 + postcss: 8.5.22 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.40': @@ -25148,7 +25238,7 @@ snapshots: '@types/hast': 3.0.4 devlop: 1.1.0 hast-util-from-parse5: 8.0.3 - parse5: 7.2.1 + parse5: 7.3.0 vfile: 6.0.3 vfile-message: 4.0.2 @@ -27375,7 +27465,7 @@ snapshots: mongodb-connection-string-url@3.0.2: dependencies: '@types/whatwg-url': 11.0.5 - whatwg-url: 14.1.1 + whatwg-url: 14.2.0 mongodb-memory-server-core@10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.8.9): dependencies: @@ -28235,30 +28325,30 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.23): + postcss-import@15.1.0(postcss@8.5.22): dependencies: - postcss: 8.5.23 + postcss: 8.5.22 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.10 - postcss-js@4.1.0(postcss@8.5.23): + postcss-js@4.1.0(postcss@8.5.22): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.23 + postcss: 8.5.22 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23)(tsx@4.20.6)(yaml@2.8.4): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.22)(tsx@4.20.6)(yaml@2.8.4): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.23 + postcss: 8.5.22 tsx: 4.20.6 yaml: 2.8.4 - postcss-nested@6.2.0(postcss@8.5.23): + postcss-nested@6.2.0(postcss@8.5.22): dependencies: - postcss: 8.5.23 + postcss: 8.5.22 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -30153,11 +30243,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.23 - postcss-import: 15.1.0(postcss@8.5.23) - postcss-js: 4.1.0(postcss@8.5.23) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23)(tsx@4.20.6)(yaml@2.8.4) - postcss-nested: 6.2.0(postcss@8.5.23) + postcss: 8.5.22 + postcss-import: 15.1.0(postcss@8.5.22) + postcss-js: 4.1.0(postcss@8.5.22) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.22)(tsx@4.20.6)(yaml@2.8.4) + postcss-nested: 6.2.0(postcss@8.5.22) postcss-selector-parser: 6.1.2 resolve: 1.22.10 sucrase: 3.35.0 @@ -30451,10 +30541,6 @@ snapshots: tr46@0.0.3: {} - tr46@5.1.0: - dependencies: - punycode: 2.3.1 - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -31055,6 +31141,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@6.4.3(@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): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.17.24 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + sass: 1.85.1 + terser: 5.39.0 + tsx: 4.20.6 + yaml: 2.8.4 + vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4): dependencies: esbuild: 0.25.12 @@ -31158,6 +31262,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@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.4.3(@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)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@6.4.3(@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)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.3(@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) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.17.24 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - msw + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@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.4.3(@types/node@24.13.3)(jiti@1.21.7)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 @@ -31285,11 +31419,6 @@ snapshots: whatwg-mimetype@4.0.0: {} - whatwg-url@14.1.1: - dependencies: - tr46: 5.1.0 - webidl-conversions: 7.0.0 - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e27dc4707e37..8d8e667fbb42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,12 +26,14 @@ catalog: '@emotion/react': ^11 '@emotion/styled': ^11 '@fastgpt-sdk/anydoc': 0.2.5 + '@hono/node-server': ^2.0.10 '@llamaindex/liteparse-wasm': 2.0.8 '@modelcontextprotocol/sdk': ^1 '@node-rs/jieba': 2.0.1 '@svgr/webpack': ^6.5.1 '@t3-oss/env-core': 0.13.10 '@tanstack/react-query': ^4.24.10 + '@types/crypto-js': 4.2.2 '@types/js-yaml': ^4.0.9 '@types/jsonwebtoken': ^9.0.3 '@types/lodash-es': ^4 @@ -48,6 +50,7 @@ catalog: ajv: ^8.17.1 axios: ^1.18.0 chalk: ^5.6.2 + crypto-js: 4.2.0 date-fns: ^3.6.0 dayjs: 1.11.19 debug: ^4.4.3 @@ -55,13 +58,14 @@ catalog: file-type: 21.3.0 gpt-tokenizer: 3.4.0 hono: 4.12.27 - '@hono/node-server': ^2.0.10 i18next: 23.16.8 ipaddr.js: ^2.4.0 js-yaml: ^4.1.1 json5: ^2.2.3 jsonwebtoken: ^9.0.3 lodash-es: ^4.17.21 + marked: ^18.0.9 + mermaid: ^11.16.1 mime: ^4.1.0 mime-types: 3.0.2 minio: 8.0.7 @@ -70,6 +74,7 @@ catalog: nanoid: ^5.1.3 next: 16.3.0 next-i18next: 15.4.2 + postcss: ^8.5.12 proxy-addr: 2.0.7 proxy-agent: ^6 react: ^18 @@ -89,9 +94,6 @@ catalog: vaul: ^1.1.2 vitest: ^4.1.5 zod: ^4 - postcss: ^8.5.12 - marked: ^18.0.9 - mermaid: ^11.16.1 catalogMode: prefer diff --git a/pro b/pro index f89709447d69..b25bf191b8ba 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit f89709447d69bb1a7a94459fa7e23ee2a1f668ab +Subproject commit b25bf191b8ba515759f391e304388462a4ed420b diff --git a/projects/app/Dockerfile b/projects/app/Dockerfile index 292eeac6f2de..fa89934e1883 100644 --- a/projects/app/Dockerfile +++ b/projects/app/Dockerfile @@ -46,6 +46,7 @@ ENV NEXT_PUBLIC_BASE_URL=$base_url # Build SDK workspace packages first so subpath exports like # @fastgpt-sdk/otel/logger have generated dist type files. RUN pnpm build:sdks +RUN pnpm --filter @fastgpt/workflow-core build RUN pnpm --filter=app build # Remove build-time-only packages from standalone output before copying to runner. 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/components/Markdown/A.tsx b/projects/app/src/components/Markdown/A.tsx index cd0f55b0ba16..9f4d35c0bff7 100644 --- a/projects/app/src/components/Markdown/A.tsx +++ b/projects/app/src/components/Markdown/A.tsx @@ -1,4 +1,3 @@ -import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus'; import { Button, Link, @@ -24,6 +23,7 @@ import Markdown from '.'; import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils'; import { isObjectId } from '@fastgpt/global/common/string/utils'; import type { ChatAuthTargetInput } from '@/web/core/chat/utils'; +import { useChatInstanceActions } from '../core/chat/ChatContainer/context/chatInstanceActionsContext'; type MarkdownChatAuthData = ChatAuthTargetInput & { chatId: string; @@ -45,6 +45,7 @@ export type AProps = { const EmptyHrefLink = function EmptyHrefLink({ content }: { content: string }) { const { t } = useTranslation(); + const { sendMessage } = useChatInstanceActions(); return ( diff --git a/projects/app/src/components/Markdown/chat/Guide.tsx b/projects/app/src/components/Markdown/chat/Guide.tsx index c071329ea0f1..b6624824b34a 100644 --- a/projects/app/src/components/Markdown/chat/Guide.tsx +++ b/projects/app/src/components/Markdown/chat/Guide.tsx @@ -5,8 +5,8 @@ import RemarkGfm from 'remark-gfm'; import RemarkMath from 'remark-math'; import RehypeKatex from 'rehype-katex'; import RemarkBreaks from 'remark-breaks'; -import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; import QuickQuestionButton from '@/components/core/chat/QuickQuestionButton'; +import { useChatInstanceActions } from '../../core/chat/ChatContainer/context/chatInstanceActionsContext'; import 'katex/dist/katex.min.css'; import styles from '../index.module.scss'; @@ -15,13 +15,14 @@ import Image from '../img/Image'; function MyLink(e: any) { const href = e.href; const text = String(e.children); + const { sendMessage } = useChatInstanceActions(); return !!href ? ( {text} ) : ( - eventBus.emit(EventNameEnum.sendQuestion, { text })}> + sendMessage({ text })}> {text} ); diff --git a/projects/app/src/components/Markdown/chat/QuestionGuide.tsx b/projects/app/src/components/Markdown/chat/QuestionGuide.tsx index af0e81764e3d..539bf2580eef 100644 --- a/projects/app/src/components/Markdown/chat/QuestionGuide.tsx +++ b/projects/app/src/components/Markdown/chat/QuestionGuide.tsx @@ -3,13 +3,14 @@ import { Box, Flex, useTheme } from '@chakra-ui/react'; import 'katex/dist/katex.min.css'; import ChatBoxDivider from '@/components/core/chat/Divider'; import { useTranslation } from 'next-i18next'; -import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useChatInstanceActions } from '../../core/chat/ChatContainer/context/chatInstanceActionsContext'; const QuestionGuide = ({ text }: { text: string }) => { const theme = useTheme(); const { t } = useTranslation(); + const { fillInput, sendMessage } = useChatInstanceActions(); const questionGuides = useMemo(() => { try { const json = JSON.parse(text); @@ -17,7 +18,7 @@ const QuestionGuide = ({ text }: { text: string }) => { return json as string[]; } return []; - } catch (error) { + } catch { return []; } }, [text]); @@ -68,7 +69,7 @@ const QuestionGuide = ({ text }: { text: string }) => { _hover={{ color: 'green.600' }} - onClick={() => eventBus.emit(EventNameEnum.editQuestion, { text })} + onClick={() => fillInput({ text })} /> @@ -78,7 +79,7 @@ const QuestionGuide = ({ text }: { text: string }) => { w={'14px'} cursor={'pointer'} _hover={{ color: 'primary.500' }} - onClick={() => eventBus.emit(EventNameEnum.sendQuestion, { text })} + onClick={() => sendMessage({ text })} /> diff --git a/projects/app/src/components/Markdown/img/MermaidCodeBlock.tsx b/projects/app/src/components/Markdown/img/MermaidCodeBlock.tsx index a49392b5a0a1..577cc5810cbc 100644 --- a/projects/app/src/components/Markdown/img/MermaidCodeBlock.tsx +++ b/projects/app/src/components/Markdown/img/MermaidCodeBlock.tsx @@ -1,33 +1,593 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; -import { Box } from '@chakra-ui/react'; +import { + Box, + Flex, + IconButton, + Modal, + ModalContent, + ModalOverlay, + Text, + useDisclosure +} from '@chakra-ui/react'; import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import { useTranslation } from 'next-i18next'; -const punctuationMap: Record = { - ',': ',', - ';': ';', - '。': '.', - ':': ':', - '!': '!', - '?': '?', - '“': '"', - '”': '"', - '‘': "'", - '’': "'", - '【': '[', - '】': ']', - '(': '(', - ')': ')', - '《': '<', - '》': '>', - '、': ',' +type MermaidPreview = { + key: string; + markup: string; + width: number; + height: number; }; -const MermaidBlock = ({ code }: { code: string }) => { +type MermaidViewportTransform = { + x: number; + y: number; + scale: number; +}; + +type MermaidCanvasProps = { + preview?: MermaidPreview; + visible: boolean; + onClose: () => void; + onExport: () => void; + closeOnOverlayClick?: boolean; +}; + +const MERMAID_MIN_SCALE = 0.2; +const MERMAID_MAX_SCALE = 5; +const MERMAID_VIEWPORT_PADDING = 48; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const getFitScale = ({ + viewportWidth, + viewportHeight, + contentWidth, + contentHeight +}: { + viewportWidth: number; + viewportHeight: number; + contentWidth: number; + contentHeight: number; +}) => + Math.min( + 1, + Math.max(viewportWidth - MERMAID_VIEWPORT_PADDING * 2, 1) / contentWidth, + Math.max(viewportHeight - MERMAID_VIEWPORT_PADDING * 2, 1) / contentHeight + ); + +const getMinimumScale = ({ + viewportWidth, + viewportHeight, + contentWidth, + contentHeight +}: { + viewportWidth: number; + viewportHeight: number; + contentWidth: number; + contentHeight: number; +}) => + Math.min( + MERMAID_MIN_SCALE, + getFitScale({ + viewportWidth, + viewportHeight, + contentWidth, + contentHeight + }) + ); + +const clampTransform = ({ + transform, + viewportWidth, + viewportHeight, + contentWidth, + contentHeight +}: { + transform: MermaidViewportTransform; + viewportWidth: number; + viewportHeight: number; + contentWidth: number; + contentHeight: number; +}): MermaidViewportTransform => { + const scaledWidth = contentWidth * transform.scale; + const scaledHeight = contentHeight * transform.scale; + const x = + scaledWidth + MERMAID_VIEWPORT_PADDING * 2 <= viewportWidth + ? (viewportWidth - scaledWidth) / 2 + : clamp( + transform.x, + viewportWidth - scaledWidth - MERMAID_VIEWPORT_PADDING, + MERMAID_VIEWPORT_PADDING + ); + const y = + scaledHeight + MERMAID_VIEWPORT_PADDING * 2 <= viewportHeight + ? (viewportHeight - scaledHeight) / 2 + : clamp( + transform.y, + viewportHeight - scaledHeight - MERMAID_VIEWPORT_PADDING, + MERMAID_VIEWPORT_PADDING + ); + + return { ...transform, x, y }; +}; + +const getPointerDistance = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y); + +/** + * Mermaid 放大预览画布:统一处理适配、缩放、拖拽和触控板/双指手势,避免使用图片查看器时丢失 SVG 的可交互性。 + */ +const MermaidCanvas = ({ + preview, + visible, + onClose, + onExport, + closeOnOverlayClick = false +}: MermaidCanvasProps) => { + const { t } = useTranslation(); + const viewportRef = useRef(null); + const pointersRef = useRef(new Map()); + const gestureRef = useRef< + | { + mode: 'pan'; + startPoint: { x: number; y: number }; + startTransform: MermaidViewportTransform; + } + | { + mode: 'pinch'; + startDistance: number; + startMidpoint: { x: number; y: number }; + startTransform: MermaidViewportTransform; + } + >(); + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); + const [isDragging, setIsDragging] = useState(false); + + const getViewportTransform = useCallback( + (nextTransform: MermaidViewportTransform) => { + const viewport = viewportRef.current; + if (!viewport || !preview) return nextTransform; + + return clampTransform({ + transform: nextTransform, + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + contentWidth: preview.width, + contentHeight: preview.height + }); + }, + [preview] + ); + + const resetView = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport || !preview || !viewport.clientWidth || !viewport.clientHeight) return; + + const scale = getFitScale({ + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + contentWidth: preview.width, + contentHeight: preview.height + }); + setTransform( + getViewportTransform({ + scale, + x: (viewport.clientWidth - preview.width * scale) / 2, + y: (viewport.clientHeight - preview.height * scale) / 2 + }) + ); + }, [getViewportTransform, preview]); + + const setScaleAtPoint = useCallback( + (nextScale: number, clientX: number, clientY: number) => { + const viewport = viewportRef.current; + if (!viewport) return; + + const rect = viewport.getBoundingClientRect(); + const point = { x: clientX - rect.left, y: clientY - rect.top }; + setTransform((current) => { + const scale = clamp( + nextScale, + getMinimumScale({ + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + contentWidth: preview?.width ?? 1, + contentHeight: preview?.height ?? 1 + }), + MERMAID_MAX_SCALE + ); + const contentPoint = { + x: (point.x - current.x) / current.scale, + y: (point.y - current.y) / current.scale + }; + return getViewportTransform({ + scale, + x: point.x - contentPoint.x * scale, + y: point.y - contentPoint.y * scale + }); + }); + }, + [getViewportTransform, preview] + ); + + const handleWheel = useCallback( + (event: React.WheelEvent) => { + event.preventDefault(); + const nextScale = transform.scale * Math.exp(-event.deltaY * 0.0015); + setScaleAtPoint(nextScale, event.clientX, event.clientY); + }, + [setScaleAtPoint, transform.scale] + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0 && event.button !== 1) return; + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + const pointers = [...pointersRef.current.values()]; + if (pointers.length >= 2) { + const [first, second] = pointers; + gestureRef.current = { + mode: 'pinch', + startDistance: getPointerDistance(first, second), + startMidpoint: { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2 + }, + startTransform: transform + }; + } else { + gestureRef.current = { + mode: 'pan', + startPoint: { x: event.clientX, y: event.clientY }, + startTransform: transform + }; + } + setIsDragging(true); + }, + [transform] + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!pointersRef.current.has(event.pointerId)) return; + + event.preventDefault(); + pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + const pointers = [...pointersRef.current.values()]; + const gesture = gestureRef.current; + if (!gesture) return; + + if (gesture.mode === 'pinch' && pointers.length >= 2) { + const [first, second] = pointers; + const distance = getPointerDistance(first, second); + const midpoint = { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2 + }; + const viewport = viewportRef.current; + if (!viewport || !preview) return; + const nextScale = clamp( + gesture.startTransform.scale * (distance / Math.max(gesture.startDistance, 1)), + getMinimumScale({ + viewportWidth: viewport.clientWidth, + viewportHeight: viewport.clientHeight, + contentWidth: preview.width, + contentHeight: preview.height + }), + MERMAID_MAX_SCALE + ); + const rect = viewport.getBoundingClientRect(); + const startPoint = { + x: gesture.startMidpoint.x - rect.left, + y: gesture.startMidpoint.y - rect.top + }; + const contentPoint = { + x: (startPoint.x - gesture.startTransform.x) / gesture.startTransform.scale, + y: (startPoint.y - gesture.startTransform.y) / gesture.startTransform.scale + }; + const currentPoint = { x: midpoint.x - rect.left, y: midpoint.y - rect.top }; + setTransform( + getViewportTransform({ + scale: nextScale, + x: currentPoint.x - contentPoint.x * nextScale, + y: currentPoint.y - contentPoint.y * nextScale + }) + ); + return; + } + + if (gesture.mode === 'pan' && pointers.length === 1) { + setTransform( + getViewportTransform({ + ...gesture.startTransform, + x: gesture.startTransform.x + event.clientX - gesture.startPoint.x, + y: gesture.startTransform.y + event.clientY - gesture.startPoint.y + }) + ); + } + }, + [getViewportTransform, preview] + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + pointersRef.current.delete(event.pointerId); + if (pointersRef.current.size === 0) { + gestureRef.current = undefined; + setIsDragging(false); + return; + } + + const remaining = [...pointersRef.current.values()][0]; + gestureRef.current = { + mode: 'pan', + startPoint: remaining, + startTransform: transform + }; + }, + [transform] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === '0' || event.key.toLowerCase() === 'f') { + event.preventDefault(); + resetView(); + } else if (event.key === '+' || event.key === '=') { + event.preventDefault(); + const rect = viewportRef.current?.getBoundingClientRect(); + if (rect) + setScaleAtPoint( + transform.scale * 1.2, + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + } else if (event.key === '-') { + event.preventDefault(); + const rect = viewportRef.current?.getBoundingClientRect(); + if (rect) + setScaleAtPoint( + transform.scale / 1.2, + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + } + }, + [resetView, setScaleAtPoint, transform.scale] + ); + + useEffect(() => { + if (!visible || !preview) return; + const frame = window.requestAnimationFrame(resetView); + viewportRef.current?.focus(); + return () => window.cancelAnimationFrame(frame); + }, [preview, resetView, visible]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !preview || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => { + setTransform((current) => getViewportTransform(current)); + }); + observer.observe(viewport); + return () => observer.disconnect(); + }, [getViewportTransform, preview]); + + if (!preview) return null; + + const zoomPercent = Math.round(transform.scale * 100); + const changeScale = (ratio: number) => { + const rect = viewportRef.current?.getBoundingClientRect(); + if (!rect) return; + setScaleAtPoint( + transform.scale * ratio, + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + }; + + return ( + + + + event.preventDefault()} + onKeyDown={handleKeyDown} + tabIndex={0} + > + + event.stopPropagation()} + > + + } + w={'30px'} + h={'30px'} + minW={'30px'} + minH={'30px'} + p={'6px'} + borderRadius={'6px'} + color={'#485264'} + variant={'ghost'} + onClick={resetView} + /> + + + + + + + } + w={'30px'} + h={'30px'} + minW={'30px'} + minH={'30px'} + p={'6px'} + borderRadius={'6px'} + color={'#485264'} + variant={'ghost'} + _active={{ transform: 'none' }} + onClick={() => changeScale(1 / 1.2)} + /> + + + {zoomPercent} % + + + + + + } + w={'30px'} + h={'30px'} + minW={'30px'} + minH={'30px'} + p={'6px'} + borderRadius={'6px'} + color={'#485264'} + variant={'ghost'} + _active={{ transform: 'none' }} + onClick={() => changeScale(1.2)} + /> + + + + + + + } + w={'30px'} + h={'30px'} + minW={'30px'} + minH={'30px'} + p={'6px'} + borderRadius={'6px'} + color={'#485264'} + variant={'ghost'} + onClick={onExport} + /> + + + + + + } + w={'30px'} + h={'30px'} + minW={'30px'} + minH={'30px'} + p={'6px'} + borderRadius={'6px'} + color={'#485264'} + variant={'ghost'} + onClick={onClose} + /> + + + + + + ); +}; + +const MermaidBlock = ({ + code, + clickToPreview = false +}: { + code: string; + clickToPreview?: boolean; +}) => { + const { t } = useTranslation(); const ref = useRef(null); const [svg, setSvg] = useState(''); const [mermaid, setMermaid] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(''); + const [error, setError] = useState(''); + const [preview, setPreview] = useState(); + const { isOpen, onOpen, onClose } = useDisclosure(); useEffect(() => { let mounted = true; @@ -59,36 +619,57 @@ const MermaidBlock = ({ code }: { code: string }) => { }) .catch((error) => { console.error('Failed to load mermaid:', error); + if (!mounted) return; + + setError(t('common:MermaidRenderFailed')); setIsLoading(false); }); return () => { mounted = false; }; - }, []); + }, [t]); useEffect(() => { + let mounted = true; + (async () => { - if (!code || !mermaid || isLoading) return; + if (!code) { + setSvg(''); + setError(''); + return; + } + if (!mermaid || isLoading) return; + + setSvg(''); + setError(''); try { - const formatCode = code.replace( - new RegExp(`[${Object.keys(punctuationMap).join('')}]`, 'g'), - (match) => punctuationMap[match] - ); - const { svg } = await mermaid.render(`mermaid-${Date.now()}`, formatCode); + const { svg } = await mermaid.render(`mermaid-${Date.now()}`, code); + if (!mounted) return; + setSvg(svg); } catch (e: any) { - console.log('[Mermaid] ', e?.message); + console.error('[Mermaid]', e?.message); + if (!mounted) return; + + setError(t('common:MermaidRenderFailed')); } })(); - }, [code, isLoading, mermaid]); + + return () => { + mounted = false; + }; + }, [code, isLoading, mermaid, t]); const onclickExport = useCallback(() => { const svgElement = ref.current?.children[0]; - if (!svgElement) return; + const markup = preview?.markup ?? ref.current?.innerHTML; + const width = preview?.width ?? svgElement?.clientWidth; + const height = preview?.height ?? svgElement?.clientHeight; + if (!markup || !width || !height) return; - const rate = svgElement.clientHeight / svgElement.clientWidth; + const rate = height / width; const w = 3000; const h = rate * w; @@ -102,8 +683,7 @@ const MermaidBlock = ({ code }: { code: string }) => { ctx.fillRect(0, 0, w, h); const img = new Image(); - const innerHTML = ref.current?.innerHTML || ''; - img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(innerHTML); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup); img.onload = () => { ctx.drawImage(img, 0, 0, w, h); @@ -119,7 +699,44 @@ const MermaidBlock = ({ code }: { code: string }) => { img.onerror = (e) => { console.log(e); }; - }, []); + }, [preview]); + + const onOpenPreview = useCallback(() => { + const svgElement = ref.current?.querySelector('svg'); + if (!svgElement) return; + + // 放大层直接渲染 SVG DOM,保持 foreignObject 内文字与行内 Mermaid 的布局一致。 + const previewSvg = svgElement.cloneNode(true) as SVGSVGElement; + const viewBox = previewSvg.getAttribute('viewBox')?.trim().split(/\s+/).map(Number); + if ( + !viewBox || + viewBox.length !== 4 || + viewBox.some((value) => !Number.isFinite(value)) || + viewBox[2] <= 0 || + viewBox[3] <= 0 + ) { + return; + } + const [viewBoxX, viewBoxY, viewBoxWidth, viewBoxHeight] = viewBox; + const background = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + background.setAttribute('x', String(viewBoxX)); + background.setAttribute('y', String(viewBoxY)); + background.setAttribute('width', String(viewBoxWidth)); + background.setAttribute('height', String(viewBoxHeight)); + background.setAttribute('fill', '#fff'); + previewSvg.insertBefore(background, previewSvg.firstChild); + previewSvg.setAttribute('width', String(viewBoxWidth)); + previewSvg.setAttribute('height', String(viewBoxHeight)); + previewSvg.style.display = 'block'; + const { width, height } = svgElement.getBoundingClientRect(); + setPreview({ + key: `${previewSvg.id || 'mermaid'}-${viewBoxWidth}-${viewBoxHeight}`, + markup: previewSvg.outerHTML, + width: viewBoxWidth || width, + height: viewBoxHeight || height + }); + onOpen(); + }, [onOpen]); if (isLoading) { return ( @@ -138,7 +755,7 @@ const MermaidBlock = ({ code }: { code: string }) => { if (error) { return ( - + {error} @@ -147,38 +764,75 @@ const MermaidBlock = ({ code }: { code: string }) => { } return ( - .export': { - display: 'block' - } - }} - > + <> - { + if (!clickToPreview || !svg || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + onOpenPreview(); }} - right={0} - top={0} - cursor={'pointer'} - onClick={onclickExport} + > + {!isOpen && ( + + )} + {!clickToPreview && ( + + + } + size={'xs'} + variant={'ghost'} + isDisabled={!svg} + onClick={onOpenPreview} + /> + + + } + size={'xs'} + variant={'ghost'} + isDisabled={!svg} + onClick={onclickExport} + /> + + + )} + + + - + ); }; diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index 07e9aef7b570..238aee5fb603 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -74,15 +74,15 @@ function MarkdownImgRenderer(props: any) { } function MarkdownCodeRenderer(props: any) { - const { showAnimation, autoPreviewHtmlCodeBlock, markdownClassName } = useContext( - MarkdownRendererRuntimeContext - ); + const { showAnimation, autoPreviewHtmlCodeBlock, mermaidClickToPreview, markdownClassName } = + useContext(MarkdownRendererRuntimeContext); return ( ); @@ -186,6 +186,7 @@ type Props = { forbidZhFormat?: boolean; className?: string; autoPreviewHtmlCodeBlock?: boolean; + mermaidClickToPreview?: boolean; } & AProps; const Markdown = (props: Props) => { @@ -204,6 +205,7 @@ const MarkdownRender = ({ forbidZhFormat, className, autoPreviewHtmlCodeBlock, + mermaidClickToPreview, chatAuthData, allowedCitationIds, @@ -218,6 +220,7 @@ const MarkdownRender = ({ () => ({ showAnimation, autoPreviewHtmlCodeBlock, + mermaidClickToPreview, markdownClassName: className, chatAuthData, allowedCitationIds, @@ -228,6 +231,7 @@ const MarkdownRender = ({ autoPreviewHtmlCodeBlock, chatAuthData, className, + mermaidClickToPreview, onOpenCiteModal, showAnimation ] @@ -360,6 +364,7 @@ function Code(e: any) { children, showAnimation, autoPreviewHtmlCodeBlock, + mermaidClickToPreview, markdownClassName } = e; const match = /language-([\w-]+)/.exec(className || ''); @@ -368,7 +373,7 @@ function Code(e: any) { const strChildren = String(children); if (codeType === CodeClassNameEnum.mermaid) { - return ; + return ; } if (codeType === CodeClassNameEnum.guide) { return ; diff --git a/projects/app/src/components/Markdown/runtimeContext.ts b/projects/app/src/components/Markdown/runtimeContext.ts index 066356c50868..a7c9a95e05b3 100644 --- a/projects/app/src/components/Markdown/runtimeContext.ts +++ b/projects/app/src/components/Markdown/runtimeContext.ts @@ -4,6 +4,7 @@ import type { AProps } from './A'; export type MarkdownRendererRuntimeContextValue = { showAnimation?: boolean; autoPreviewHtmlCodeBlock?: boolean; + mermaidClickToPreview?: boolean; markdownClassName?: string; chatAuthData?: AProps['chatAuthData']; allowedCitationIds?: AProps['allowedCitationIds']; diff --git a/projects/app/src/components/core/app/useAppEditorUIState.ts b/projects/app/src/components/core/app/useAppEditorUIState.ts index 7140bf49260b..45739b86e51d 100644 --- a/projects/app/src/components/core/app/useAppEditorUIState.ts +++ b/projects/app/src/components/core/app/useAppEditorUIState.ts @@ -6,6 +6,9 @@ type AppEditorUIState = { systemConfig: { hasCompletedFirstEntryGuide: boolean; }; + workflowBuilder?: { + hasCompletedFirstEntryGuide: boolean; + }; }; const APP_EDITOR_UI_STATE_STORAGE_KEY = 'app-editor-ui-state'; @@ -14,6 +17,9 @@ const DEFAULT_APP_EDITOR_UI_STATE: AppEditorUIState = { welcomeTextFoldedAppIds: {}, systemConfig: { hasCompletedFirstEntryGuide: false + }, + workflowBuilder: { + hasCompletedFirstEntryGuide: false } }; @@ -62,11 +68,27 @@ export const useAppEditorUIState = (appId = '') => { }); }, [setState]); + const completeWorkflowBuilderFirstEntryGuide = useCallback(() => { + setState((state = DEFAULT_APP_EDITOR_UI_STATE) => { + if (state.workflowBuilder?.hasCompletedFirstEntryGuide) return state; + + return { + ...state, + workflowBuilder: { + hasCompletedFirstEntryGuide: true + } + }; + }); + }, [setState]); + return { isWelcomeTextFolded, toggleWelcomeTextFold, hasCompletedSystemConfigFirstEntryGuide: state.systemConfig.hasCompletedFirstEntryGuide, - completeSystemConfigFirstEntryGuide + completeSystemConfigFirstEntryGuide, + hasCompletedWorkflowBuilderFirstEntryGuide: + state.workflowBuilder?.hasCompletedFirstEntryGuide ?? false, + completeWorkflowBuilderFirstEntryGuide }; }; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/AgentAskComposer.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/AgentAskComposer.tsx index 26d25e93de8f..b6d040b1025a 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/AgentAskComposer.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/AgentAskComposer.tsx @@ -2,18 +2,39 @@ import { Box, Button, Flex, Textarea, useColorMode } from '@chakra-ui/react'; import MyIcon from '@fastgpt/web/components/common/Icon'; import type { AgentAskQuestionInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import { useTranslation } from 'next-i18next'; -import { - type KeyboardEvent, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; type Answers = Record; type AgentAskOption = AgentAskQuestionInteractive['options'][number]; +export type AgentAskAnswerDetail = + | { kind: 'option'; value: string } + | { kind: 'custom'; value: string } + | { kind: 'skip' }; + +/** 将 Composer 内部答案状态转换为保留提交来源的结果,供需要区分选项和文本的调用方使用。 */ +export const getAgentAskAnswerDetails = ({ + questions, + answers, + selectedOptionIndexes +}: { + questions: AgentAskQuestionInteractive[]; + answers: Answers; + selectedOptionIndexes: Record; +}): AgentAskAnswerDetail[] => + questions.map((_, index) => { + const questionKey = String(index); + const value = answers[questionKey] ?? ''; + + if (selectedOptionIndexes[questionKey] !== undefined) { + return { kind: 'option', value }; + } + if (value) { + return { kind: 'custom', value }; + } + return { kind: 'skip' }; + }); + type AgentAskNavigationProps = { questionIndex: number; questionCount: number; @@ -160,6 +181,7 @@ type AgentAskOptionButtonProps = { index: number; isSelected: boolean; isDisabled: boolean; + showOptionValue: boolean; optionRef: (element: HTMLButtonElement | null) => void; isTemporarilyFocused: boolean; onMouseEnter: () => void; @@ -174,6 +196,7 @@ const AgentAskOptionButton = ({ index, isSelected, isDisabled, + showOptionValue, optionRef, isTemporarilyFocused, onMouseEnter, @@ -246,7 +269,7 @@ const AgentAskOptionButton = ({ {option.summary} - {option.summary !== option.value && ( + {showOptionValue && option.summary !== option.value && ( {` ${option.value}`} @@ -270,10 +293,27 @@ const AgentAskOptionButton = ({ /** 渲染统一的 Agent Ask 问题,并按题目顺序提交回答值。 */ const AgentAskComposer = ({ questions, - onSubmit + onSubmit, + customOptionLabel, + customOptionPlaceholder, + customAnswerRequired = false, + showOptionValue = true }: { questions: AgentAskQuestionInteractive[]; - onSubmit: (answers: string[]) => void; + onSubmit: (answers: string[], details: AgentAskAnswerDetail[]) => void; + /** + * 自定义答案行的展示文案与输入占位符(默认取 i18n 的 custom_answer)。 + * 供 workflow-builder 预览确认等场景把「文本输入动作」(如 revise)定制为自定义答案行。 + */ + customOptionLabel?: string; + customOptionPlaceholder?: string; + /** + * 自定义答案为必填时,提交按钮恒为「提交」且空文本不可提交(默认允许跳过)。 + * 与 customAnswerRequired 组合保证必填意见场景不会提交空值。 + */ + customAnswerRequired?: boolean; + /** 是否在选项文案后显示提交协议值,Workflow Builder 等内部枚举选项应关闭。 */ + showOptionValue?: boolean; }) => { const { t } = useTranslation(); const [questionIndex, setQuestionIndex] = useState(0); @@ -285,22 +325,9 @@ const AgentAskComposer = ({ const [isQuestionVisible, setIsQuestionVisible] = useState(true); const [isQuestionTransitioning, setIsQuestionTransitioning] = useState(false); const [isInitialFocusActive, setIsInitialFocusActive] = useState(false); - const [contentHeight, setContentHeight] = useState(); - const contentRef = useRef(null); const optionRefs = useRef>([]); const initialFocusStatus = useRef<'pending' | 'active' | 'cleared'>('pending'); const questionTransitionTimer = useRef>(); - const updateContentHeight = useCallback(() => { - const content = contentRef.current; - if (!content) return; - - const previousHeight = content.style.height; - content.style.height = 'auto'; - const nextHeight = content.offsetHeight; - content.style.height = previousHeight; - - if (nextHeight) setContentHeight(nextHeight); - }, []); const clearTemporaryInitialFocus = () => { if (initialFocusStatus.current === 'cleared') return; @@ -334,10 +361,6 @@ const AgentAskComposer = ({ }; }, []); - useLayoutEffect(() => { - updateContentHeight(); - }, [editingQuestionIndex, questionIndex, updateContentHeight]); - const question = questions[questionIndex]; if (!question) return null; @@ -380,11 +403,18 @@ const AgentAskComposer = ({ startTransition(); }; - const submit = (nextAnswers = answers) => { + const submit = (nextAnswers = answers, nextSelectedOptionIndexes = selectedOptionIndexes) => { if (isSubmitting) return; setIsSubmitting(true); - onSubmit(questions.map((_, index) => nextAnswers[String(index)] ?? '')); + onSubmit( + questions.map((_, index) => nextAnswers[String(index)] ?? ''), + getAgentAskAnswerDetails({ + questions, + answers: nextAnswers, + selectedOptionIndexes: nextSelectedOptionIndexes + }) + ); }; const goNext = ( nextAnswer: string, @@ -401,19 +431,17 @@ const AgentAskComposer = ({ if (nextSelectedOptionIndex === undefined && shouldTrimCustomAnswer) { setCustomValues((values) => ({ ...values, [questionKey]: normalizedAnswer })); } - setSelectedOptionIndexes((indexes) => { - const nextIndexes = { ...indexes }; - if (nextSelectedOptionIndex === undefined) { - delete nextIndexes[questionKey]; - } else { - nextIndexes[questionKey] = nextSelectedOptionIndex; - } - return nextIndexes; - }); + const nextSelectedOptionIndexes = { ...selectedOptionIndexes }; + if (nextSelectedOptionIndex === undefined) { + delete nextSelectedOptionIndexes[questionKey]; + } else { + nextSelectedOptionIndexes[questionKey] = nextSelectedOptionIndex; + } + setSelectedOptionIndexes(nextSelectedOptionIndexes); setEditingQuestionIndex(undefined); if (isLastQuestion) { - submit(nextAnswers); + submit(nextAnswers, nextSelectedOptionIndexes); return; } changeQuestion(questionIndex + 1, delayTransition); @@ -421,7 +449,7 @@ const AgentAskComposer = ({ const skipOrAdvance = () => goNext(shouldTrimCustomAnswer ? customValue : ''); const skipAll = () => { clearTemporaryInitialFocus(); - submit({}); + submit({}, {}); }; const selectCustom = () => { clearTemporaryInitialFocus(); @@ -439,21 +467,23 @@ const AgentAskComposer = ({ textarea.style.height = '40px'; textarea.style.height = `${Math.min(textarea.scrollHeight, 116)}px`; textarea.style.overflowY = textarea.scrollHeight > 116 ? 'auto' : 'hidden'; - - updateContentHeight(); }; const actionLabel = isCustom && customValue.trim() ? isLastQuestion ? t('common:Submit') : t('common:next_step') - : t('chat:interactive.agent_ask.skip'); + : customAnswerRequired + ? t('common:Submit') + : t('chat:interactive.agent_ask.skip'); const isInputDisabled = isSubmitting || isQuestionTransitioning; + // 必填自定义答案场景:无论是否处于 custom 编辑态,文本为空都禁止提交,避免提交空意见 + const isAdvanceDisabled = isInputDisabled || (customAnswerRequired && !customValue.trim()); const advanceButton = ( ); @@ -472,11 +502,8 @@ const AgentAskComposer = ({ bg={'white'} boxShadow={'0px 5px 10px rgba(19, 51, 107, 0.13)'} p={4} - ref={contentRef} onPointerDown={clearTemporaryInitialFocus} - h={contentHeight ? `${contentHeight}px` : undefined} overflow={'hidden'} - // transition={'height 0s ease'} > {question.question} @@ -519,8 +546,8 @@ const AgentAskComposer = ({ {question.options.map((option, index) => { const isSelected = selectedOptionIndex === index; @@ -532,6 +559,7 @@ const AgentAskComposer = ({ index={index} isSelected={isSelected} isDisabled={isInputDisabled} + showOptionValue={showOptionValue} isTemporarilyFocused={isInitialFocusActive && questionIndex === 0 && index === 0} optionRef={(element) => { optionRefs.current[index] = element; @@ -585,7 +613,7 @@ const AgentAskComposer = ({ bg={'primary.600'} color={'myGray.100'} > - +