CC Workflow Studio 的 Schema-Driven 节点属性面板:一份 zod 驱动的单一定义源

原创2026-10-12 23:13:581,687 阅读
文章标签:人工智能AI AgentAgent 工作流AI 技能开发工具低代码

CC Workflow Studio 的 Schema-Driven 节点属性面板:一份 zod 驱动的单一定义源

CC Workflow Studio(cc-wf-studio)是一套面向 AI Agent 工作流编排的 VSCode 扩展,其属性面板渲染、导出忽略字段告警与工作流校验三者共用同一份定义。本指南聚焦 .claude/rules/schema-driven-panels.md 所确立的「Schema-Driven 节点属性面板」架构:它以 @cc-wf-studio/core 中的 zod schema 作为每个节点的唯一事实来源,读者读完将掌握 12 种节点类型如何由一份定义同时驱动 UI 渲染、导出告警与运行时校验,并能按规则为节点安全地新增字段、理解 drift guard 与校验语义的取舍。

为什么需要「一份定义、三处消费」

在 schema-driven 模型出现之前,节点的属性面板渲染、导出器对「被忽略字段」的告警、以及工作流的校验逻辑各写各的,容易漂移:UI 上显示了一个字段,导出器却静默丢弃它,校验器也不知道它的合法取值。该规则的核心主张是:

每个节点类型只声明一次属性 schema,让属性面板 UI、导出忽略字段告警、工作流校验都从这同一个 registry 读取。

这样三处消费永远无法彼此不一致——UI 隐藏的字段导出器不会忽略,校验器校验的也是 UI 呈现的那套合法值。

各层职责与文件位置

规则用一张表划定了每个层次的职责边界,结合仓库源码可以进一步确认其实现:

层次 位置 职责
字段原语 packages/core/src/schema/field.ts field(zod, meta)、FieldMeta、getArrayBounds、AssertAssignable
节点 schema packages/core/src/schema/nodes/ 每个节点类型一个文件:<x>PropertySchema + <x>ZodObject + 可选的 derive<X>Update + drift guard
Registry packages/core/src/schema/node-schema-registry.ts NODE_PROPERTY_SCHEMAS / NODE_DERIVE_FNS,所有消费方的唯一查询入口
渲染器 packages/vscode/src/webview/src/components/property/ SchemaPropertyPanel + SchemaField + controls/ + field-styles.ts
面板配置 packages/vscode/src/webview/src/components/property/panels/ 下的 <type>-panel.tsx,注册于 node-panels.ts 每节点 mode、Header/Footer 槽、customControls、derive、sectionDefaultOpen

字段原语:zod 类型 + 渲染元数据的最小封装

field.ts 定义了 FieldMeta 的全部能力:

  • targets:该字段适用的导出目标('all' 或 ExportTarget 数组);
  • labelKey:字段标签的 i18n key(由 webview 解析);
  • control:UI 渲染方式('select' | 'text' | 'tools' | 'color' | 'textarea' | 'checkbox' | 'radio' | 'objectArray');缺省表示 data-only 字段——只参与校验与目标作用域,不由通用面板渲染(如 builtInType、MCP 动态参数);
  • options:select/radio 的可选值(UI 单一来源);
  • helpKey / placeholderKey:帮助行与占位文本的 i18n key;
  • sectionKey:把字段归入一个 CollapsibleSection,共享同一 key 的字段按首个出现位置聚合成组;
  • visibleWhen(data) / readonlyWhen(data):可见性与只读谓词;
  • readonlyHintKey:只读时的后缀提示(如「(controlled by preset)」);
  • editInEditor:textarea 附带「在外部编辑器中编辑」按钮;
  • allowCustom:select 附加「自定义」选项,可切换到自由文本输入;
  • itemFields / itemReadonlyWhen:objectArray 每行渲染的列与行级只读谓词。

field(zod, meta) 只是把 zod 类型与元数据打包成 { zod, meta }(field.ts),toZodObject 再把整份 PropertySchema 折叠成一个 z.ZodObject(field.ts)。getArrayBounds 从 zod 的 checks 里读取 min/max,让 UI 的增删按钮边界与校验器声明永不重复——length(n) 会产生 min === max === n,此时 UI 直接隐藏增删按钮(field.ts)。

Registry:所有消费方的单一查询入口

node-schema-registry.ts 中 NODE_PROPERTY_SCHEMAS 覆盖了 12 种节点类型:subAgent、prompt、branchSession、codex、group、askUserQuestion、branch、ifElse、switch、skill、mcp、subAgentFlow。没有可编辑字段的 start/end 节点刻意不在 registry 中,保留非 schema 的面板渲染。NODE_DERIVE_FNS 则登记了 4 个纯函数规范化器(askUserQuestion、branch、ifElse、switch),用于在每次字段 patch 落库前同步 outputPorts 或重置依赖字段;没有条目的节点类型直接原样持久化 patch。

添加一个字段的标准流程

规则给出了「给节点类型加字段」的三步操作,前两步在仓库中都有对应实现:

  1. 改 schema 文件:在 packages/core/src/schema/nodes/-schema.ts 中给该节点增加字段(zod 类型 + FieldMeta:labelKey、control 及所需谓词)。例如给 AskUserQuestion 加 multiSelect 字段只需 ask-user-question-schema.ts 中的一行 field(z.boolean().optional(), { targets: 'all', labelKey: 'askUserQuestion.field.multiSelect', control: 'checkbox' })。
  2. 补 i18n:把 <nodeType>.field.<name>(以及用到的 .help/.placeholder)加入 packages/vscode/src/webview/src/i18n/translation-keys.ts 和全部 5 个语言文件(en.ts、ja.ts、ko.ts、zh-CN.ts、zh-TW.ts),动态 key 约定参见 .claude/rules/translation.md。
  3. 供 AI Agent 编写:如果 AI 也应该能生成该字段,还要在 packages/core/resources/workflow-schema.json 中描述它——这份文件是手工调优的 AI 编写指南,绝不从 zod 自动生成。

对简单字段来说,改动到此为止:面板、告警与校验器会从 registry 自动接住,无需改动任何 webview 代码。这正是「单一事实来源」的收益——新增字段的边际成本被压到最低。

Guardrails:不要膨胀 FieldMeta

规则的核心约束是别把 FieldMeta 做成 DSL,仓库代码也处处贯彻这一点:

  • 无副作用元数据:跨字段效应(outputPorts 同步、依赖字段重置)放进与 schema 同文件的纯函数 derive<X>Update(data, patch)。以 branch-schema.ts 为例:切到 conditional 时把 branches 裁剪为前两条并同步 outputPorts: 2;增删 branches 时把 outputPorts 同步为分支数。ask-user-question-schema.ts 则演示了三种联动:开启 AI 建议强制单输出端口并清空 options、切换多选在 1 端口与每选项 1 端口之间切换、修改 options 同步端口数。
  • 无布局/样式元数据:声明顺序即渲染顺序,sectionKey 是唯一的分组机制。SchemaPropertyPanel 的 buildRenderItems 正是按声明顺序把顶层字段与 section 分组排布(SchemaPropertyPanel.tsx)。
  • 无对话框/导航元数据:编辑对话框、徽章、导航等谓词与槽表达不了的 UI,一律留在面板配置的 Header/Footer 槽或按字段的 customControls 里。例如 SubAgent 面板把 Built-in 徽章、编辑按钮与其对话框、Linked Command 展示全部放进 SubAgentHeader(sub-agent-panel.tsx)。
  • custom control 的两个形态:作用在带 control 的字段上时只替换输入控件(label/help 框架保留);作用在不带 control 的字段上时拥有整个渲染权。SchemaField 中正是这样分派的:无 control 的字段一旦注册了 CustomControl,直接渲染自定义控件而跳过 label/help 框架(SchemaField.tsx)。

Drift guards:把 schema 与 TS 接口钉死在一起

每个 schema 文件结尾都会导出两个 AssertAssignable 类型(定义于 field.ts),把 schema 的字段名与值类型锁定到节点的 TypeScript 接口上:

export type AskUserQuestionSchemaFieldNamesGuard = AssertAssignable<
  keyof z.infer<typeof askUserQuestionZodObject>,
  keyof AskUserQuestionData
>;
export type AskUserQuestionSchemaValueTypesGuard = AssertAssignable<
  z.infer<typeof askUserQuestionZodObject>,
  Partial<AskUserQuestionData>
>;

如果 schema 改动(比如打错一个枚举值)与接口背离,tsc 会在编译期失败。此时应修 schema(或是有意为之才改接口),绝不能删掉 guard。规则注明的例外是 MCP 的宽容 data-only 字段:parameters、aiToolSelectionConfig、aiParameterConfig、parameterValues 的 zod 类型刻意比接口宽松(自定义渲染 + 对话框编辑),所以 MCP 的 ValueTypesGuard 用 Omit 把这几项排除后再做 AssertAssignable(mcp-schema.ts)。

校验语义:validateNodeSchemaFields 的两条刻意规则

schema 校验回合位于 packages/core/src/utils/validate-workflow.ts 的 validateNodeSchemaFields(第 241-273 行),由 validateNodes 对每个节点调用(第 341 行)。它遵守两条刻意设计的规则:

  • 只校验「出现在数据上」的字段:value === undefined 直接跳过(第 251-253 行)。必填性要求仍然留在手写的检查里,因此旧工作流文件缺少后加字段也能继续加载——schema 回合防止漂移,不引入新的必填规则。
  • visibleWhen 谓词失败的字段按「非活跃」跳过:例如 AskUserQuestion 的 options 声明 .min(2).max(4) 只对手动模式生效,AI 建议模式合法地存放空数组(ask-user-question-schema.ts 的 visibleWhen: (data) => !data.useAiSuggestions)。outputPorts 则完全不进 schema 校验——旧版本容忍的 outputPorts: 0 瞬时状态继续被接受。

语义级、跨节点、连接与长度/模式检查仍留在同一文件的手写逻辑中(如 Switch 的默认分支唯一性、Skill 的必填字段、MCP 的分模式配置检查),规则明确警告:不要在没有检查错误码消费方的情况下把它们移植进 zod。报错统一为 { code, message, field? } 结构的 ValidationError(第 24-28 行),错误码(如 NODE_SCHEMA_VIOLATION、SWITCH_MULTIPLE_DEFAULT、MCP_MODE_CONFIG_MISMATCH)是既有契约,迁移必须保持兼容。

渲染器如何消费 registry:SchemaPropertyPanel 与面板配置

webview 侧的分层同样是规则的一部分。PropertyOverlay 只做外壳:头部、缩放把手、节点类型徽章、共享的 node-name 字段与错误兜底展示(PropertyOverlay.tsx),一旦命中 NODE_PANELS 就把渲染交给通用的 SchemaPropertyPanel——禁止把任何按节点的渲染塞回外壳。

SchemaPropertyPanel.tsx 是通用渲染核心:

  • 渲染顺序 = schema 声明顺序;共享 sectionKey 的字段在首个出现位置聚合成 CollapsibleSection;
  • 无 control 且无自定义控件的字段跳过;visibleWhen 失败必隐藏——自定义控件覆盖的是「如何渲染」而非「是否可见」;
  • 每个字段变更都先流经该节点的 derive 规范化器,再通过 updateNodeData 持久化(第 74-77 行)——这正是副作用与声明分离的落点。

面板配置 node-panels.ts 把 12 种类型映射到各自的 NodePanelConfig。mode: 'readonly' 表示只读摘要面板(编辑发生在对话框里,如 SubAgent/Skill/MCP),mode: 'edit' 渲染可交互控件(types.ts)。sectionDefaultOpen 可以按节点数据计算 section 初始展开状态——例如 Codex 的 advanced section 仅在设置了 sandbox 时展开。默认控件在 control-registry.ts 中按 FieldControl 映射(text/textarea/select/checkbox/radio/color/tools/objectArray)。

以 AskUserQuestion 面板为例(ask-user-question-panel.tsx):schema 字段由 registry 提供,derive 复用 core 的 deriveAskUserQuestionUpdate,customControls 里为 multiSelect/useAiSuggestions 提供随勾选状态切换的 help 文案、为 options 提供带本地化 new-item 工厂的对象数组编辑器。

字段级导出目标:targets 与 ignored-field 告警

targets.ts 定义了完整的导出目标词汇表:claudeCode、adk、copilot、copilot-cli、codex、gemini、roo-code、antigravity、cursor(EXPORT_TARGETS),'all' 是「适用所有目标」的哨兵值。字段的 targets 声明让 UI 与导出器共享同一套作用域判断:

  • UI 侧通过 queries.ts 的 getSchemaFields、isFieldAppliedToTarget、getFieldsForTarget 按目标过滤面板字段,切换导出目标时用 clearFieldsNotAppliedToTarget 把不适用的字段置为 undefined 以丢弃 Claude Code 专属字段;
  • 导出器侧用 warnings.ts 的 collectIgnoredFieldWarnings(workflow, target) 为「已设置但会被目标静默丢弃」的字段生成人性化告警(如 Sub-Agent "x": field "model" (=sonnet) is ignored when exporting to adk),空值/未设置字段被跳过以免误报。

SubAgent 的 schema 是典型样例:model/tools/memory/color/builtInType 是 Claude Code 专属字段,targets: <a href="https://link.gitcode.com/i/a872d9ffbbfd9d492a862bac90cacbb5" target="_blank">'claudeCode'],且内建预设(builtInType)通过 readonlyWhen/visibleWhen 谓词控制 model/tools 的只读与 memory/color 的可见性([sub-agent-schema.ts)。另注意节点级差异:BranchSession 节点整体是 Claude Code 专属(claude-code-only.ts),但其字段级 targets 仍是 'all'——因为节点级限制由导出器单独检查。

12 种节点 schema 一览

节点类型 面板 mode 要点
subAgent readonly model/tools/memory/color/builtInType 为 Claude Code 专属;readonlyWhen: isBuiltIn 让内建预设锁定 model/tools
prompt edit variables 是运行时填充的 data-only 字段,检测到的变量展示放在 Footer 槽
branchSession edit Claude Code 专属节点;label + workDescription(textarea + editInEditor)
codex edit sandbox 为 data-only 字段,UI 是自定义组合控件;model 用 allowCustom 支持自定义模型名
group — 用 data.label 而非 name
askUserQuestion edit options 有 .min(2).max(4) 且 visibleWhen: !useAiSuggestions;端口数由 derive 同步
branch edit 旧式 2-in-1 节点;切 conditional 裁剪到两条分支
ifElse edit branches 用 .length(2)(min === max,UI 隐藏增删按钮)
switch edit branches .min(2).max(10),默认分支经 itemReadonlyWhen 只读并由 derive 保持在末位
skill readonly 属性从 SKILL.md 加载、经 SkillNodeEditDialog 编辑;scope/validationStatus/executionMode 有自定义徽章渲染
mcp readonly 动态部分(parameters、模式配置对象、parameterValues)为宽容 data-only 字段;可见性跟随 mode
subAgentFlow readonly 引用工作流 ID 与 label;记忆/模型枚举由 schema 回合覆盖

每个文件的结尾都以两个 AssertAssignable guard 收束,与 field.ts 定义的约定完全一致。

实践建议

  • 给节点加字段时只动三处(schema 文件、i18n、如需 AI 编写再加 workflow-schema.json),不要顺手改面板组件;若面板需要新的交互形态,优先评估 customControls 或 Header/Footer 槽,而不是往 FieldMeta 里加元数据。
  • 跨字段联动(端口同步、字段重置)一律写纯函数 derive<X>Update,保持可测试性与无副作用。
  • 修改 schema 后立即跑 tsc,靠 drift guard 兜住字段名与值类型的一致性;若 guard 失败,先查 schema 再考虑接口。
  • 涉及校验时,注意「只校验 present 字段」与「visibleWhen 失败即跳过」两条语义,别把必填性、跨节点、连接与长度/模式检查盲目搬进 zod——先确认 validate-workflow.ts 中现有错误码的消费方。
登录后查看全文
cc-wf-studio