NodeSchema 与 PortSpec

`NodeSchema` 是节点的声明式元数据:它告诉框架「这种 kind 的节点有哪些端口、默认多大、有哪些字段」。`IFlowNode` 实现通过 `schema()` 方法返回它,`NodeRegistry` 按 `kind` 索引。本节聚焦 schema 主体与端口规格,字段系统见下一节。

NodeSchema 结构

pub struct NodeSchema {
    pub kind: NodeKind,        // 匹配 IFlowNode::kind
    pub label: String,         // 显示标签(gpui 层可 i18n 覆盖)
    pub ports: Vec<PortSpec>,  // 端口规格
    pub default_size: SizeF,   // 默认尺寸
    pub fields: Vec<FieldSpec>,// 字段定义(驱动属性面板)
}

| 字段 | 用途 | |------|------| | `kind` | 与 `Node.kind`、`IFlowNode::kind` 三方匹配,是策略分发的键 | | `label` | 工具栏/树形面板的默认显示文案,gpui 层可按 `(kind, key)` 做 i18n 覆盖 | | `ports` | 声明端口,决定边端点计算与渲染 | | `default_size` | 创建节点时的初始尺寸(未指定时 180×64) | | `fields` | 字段定义,驱动属性面板自动生成(详见下一节) |

链式构建器

`NodeSchema` 用链式 builder 构造,避免长结构体字面量:

impl NodeSchema {
    pub fn new(kind, label) -> Self { /* ports/fields 空, default_size 180×64 */ }
    pub fn with_port(mut self, port: PortSpec) -> Self { self.ports.push(port); self }
    pub fn with_size(mut self, size: SizeF) -> Self { self.default_size = size; self }
    pub fn with_field(mut self, field: FieldSpec) -> Self { self.fields.push(field); self }
}

一个 Action 节点的完整声明:

let schema = NodeSchema::new("action", "Action")
    .with_port(PortSpec::new("in",  PortDirection::In,  PortSide::Auto))
    .with_port(PortSpec::new("out", PortDirection::Out, PortSide::Auto))
    .with_field(FieldSpec::new("label", "Label", FieldType::Text)
        .with_default(json!("Action")))
    .with_field(FieldSpec::new("code", "Code", FieldType::CodeBlock)
        .with_default(json!("")));

PortSpec:端口规格

pub struct PortSpec {
    pub id: PortId,                // String,节点内唯一
    pub direction: PortDirection,  // In / Out
    pub side: PortSide,            // Top/Right/Bottom/Left/Auto
    pub fixed: bool,               // 强约束标志(默认 false)
    pub label: Option<String>,     // 端口标签(可选)
}

构建器同样链式:

impl PortSpec {
    pub fn new(id, direction, side) -> Self { /* fixed = false, label = None */ }
    pub fn with_label(mut self, label) -> Self { self.label = Some(label); self }
    pub fn with_fixed(mut self, fixed: bool) -> Self { self.fixed = fixed; self }
}

fixed:强约束 vs 弱约束

`fixed` 决定 `side` 字段是「硬声明」还是「软提示」,是端口 side 声明机制重构后引入的核心字段:

| fixed 值 | 行为 | 适用场景 | |----------|------|----------| | `false`(默认,弱约束) | `side=Auto` 时由布局层按主布局方向回退到默认 side(横向→Right/Left,纵向→Bottom/Top);布局层可自由调整 | Start/Action/End 等普通节点 | | `true`(强约束) | `side` 由节点实现决定,外部布局层**不可覆盖**;`compute_edge_endpoints` 跳过位置推导,直接按声明分配 | Condition 的 if/else、Loop 的 `loop_body`/`loop_in` 等结构化端口 |

典型用法:

// Loop:loop_body 出口强约束右侧、loop_in 入口强约束左侧,保证回环语义清晰
PortSpec::new("loop_body", PortDirection::Out, PortSide::Right).with_fixed(true)
PortSpec::new("loop_in",   PortDirection::In,  PortSide::Left).with_fixed(true)

> 设计取舍:强弱约束采用 `fixed: bool` 标志(而非给 `PortSide` 增加 `Fixed` 变体),保持现有 `match` 分支不破坏。`PortSpec::new` 签名不变(`fixed` 默认 false),所有旧调用向后兼容。

side 的两种策略

| side 值 | 行为 | 适用节点 | |---------|------|----------| | `Auto`(默认) | 框架根据相连节点相对位置推导 | Start/Action/End 等普通节点 | | 固定(Top/Right/Bottom/Left) | 始终在该侧 | Condition/Loop 等结构化节点 |

结构化节点需要固定 side 以保证语义清晰:

// Condition:if_N 出口固定在右侧不同高度
PortSpec::new("if_0", PortDirection::Out, PortSide::Right).with_label("分支1").with_fixed(true)
PortSpec::new("else", PortDirection::Out, PortSide::Right).with_label("Else").with_fixed(true)

// Loop:done 出口固定右侧,loop_body 出口固定右侧
PortSpec::new("done",      PortDirection::Out, PortSide::Right).with_fixed(true)
PortSpec::new("loop_body", PortDirection::Out, PortSide::Right).with_fixed(true)

`fixed=true` 让 gpui 层的 `compute_edge_endpoints`(见 [端口端点计算](../07-geometry-layout/port-calc.md))跳过位置推导,直接按声明分配——这对结构化节点的可读性至关重要。

> 注:历史上此职责由 core 层的 `resolve_endpoints` 承担,该函数**已废弃**(`#[deprecated]`)——它不读取 `PortSpec.fixed`,无法区分强/弱约束,也不会调用节点实现的 `port_position` 回调。渲染层现改走 `resolve_port` + `compute_edge_endpoints` 路径,二者是处理强约束端点的正确入口。

ports_by_direction:按方向过滤

pub fn ports_by_direction(&self, dir: PortDirection) -> impl Iterator<Item = &PortSpec> {
    self.ports.iter().filter(move |p| p.direction == dir)
}

渲染层用它分别绘制入边端口与出边端口,端口分布算法也按方向分组收集边(见 `distribute_on_side`)。

default_data:从 fields 推导初始数据

pub fn default_data(&self) -> serde_json::Value {
    let mut obj = serde_json::Map::new();
    for field in &self.fields {
        obj.insert(field.key.clone(), field.default.clone());
    }
    serde_json::Value::Object(obj)
}

创建新节点时,框架调用 `schema.default_data()` 生成 `Node.data`,保证每个字段都有合法初值,属性面板不会读到 `undefined`。这是 Schema 驱动的关键闭环:

NodeSchema.fields ──default_data()──> Node.data
     ↑                                       ↓
     └────── 属性面板读取字段渲染 ◄──────────┘

`default_data` 只填 `fields` 声明过的键,**不会**清掉 `data` 里多出的字段——这允许老数据携带新版本 schema 未识别的字段(向前兼容)。

default_size 的使用时机

| 场景 | size 来源 | |------|-----------| | `FlowDocument.from_document` 中 `NodeDef.size = None` | `schema.default_size`(实际由 gpui 层 `sync_node_sizes` 修正) | | 工具栏拖入新节点 | `schema.default_size` | | 结构化节点(Condition/Loop) | `IFlowNode` 实现里覆盖 `with_size` 给出准确尺寸 |

`default_size` 默认 `180×64`,结构化节点通常更大(如 Loop 的 `180×120` 以容纳标题栏与多个端口)。

内置节点 kind 全景

内置 8 种节点构成图灵完备控制流:

| kind | 语义 | 端口特征 | |------|------|----------| | `start` | 流程起点 | 仅 Out | | `end` | 流程终点 | 仅 In | | `action` | 步骤 | In + Out | | `condition` | 条件分支 | In + 多个 if_N/else Out | | `loop` | 循环迭代 | In + done/loop_body Out + loop_in In | | `variable` | 变量声明 | In + Out | | `adapter` | 数据适配 | In + Out | | `agent` | 智能体 | In + Out |

它们的 schema 在 `crates/gpui/src/builtin/` 各自模块中声明,注册到 `NodeRegistry`。自定义节点只需声明新 `kind` 的 schema 并注册,无需修改框架。

小结

`NodeSchema` 用链式 builder 声明节点的端口、尺寸与字段。`PortSpec.side` 的 Auto/固定两种策略分别服务普通节点与结构化节点。`default_data()` 把 `fields` 的 default 值灌入 `Node.data`,闭合 Schema 驱动的属性面板循环。`kind` 是贯穿 schema、`IFlowNode`、`Node` 三方的策略分发键。

下一节:[FieldSpec 与字段类型](fieldspec-types.md)