From 592cb9fa169e1d0ff02c66199fcd1f3a078fff82 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 22 May 2026 22:56:20 +0800 Subject: [PATCH] refactor(orchestrator): migrate plan schema to TS-native shape OrchestratorPlan now accepts the TS-native rootFrame/fill-array/styleGuideName shape emitted by decomposition.md. Removes StyleGuide struct; adds PlanFill + first_solid_hex helper. variables.rs enters dormant state (no palette to seed). --- crates/op-orchestrator/src/cleanup.rs | 2 +- crates/op-orchestrator/src/plan.rs | 83 +++++++++---- crates/op-orchestrator/src/plan_normalize.rs | 2 +- crates/op-orchestrator/src/prompt.rs | 38 ++---- crates/op-orchestrator/src/run.rs | 15 +-- crates/op-orchestrator/src/scaffold.rs | 13 +- crates/op-orchestrator/src/subagent.rs | 2 +- crates/op-orchestrator/src/variables.rs | 121 ++++--------------- 8 files changed, 112 insertions(+), 164 deletions(-) diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index 67a05f746..d1b488831 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -152,7 +152,7 @@ mod tests { fill: None, }, subtasks: vec![], - style_guide: None, + style_guide_name: None, } } diff --git a/crates/op-orchestrator/src/plan.rs b/crates/op-orchestrator/src/plan.rs index e92947d7f..613d0e3c3 100644 --- a/crates/op-orchestrator/src/plan.rs +++ b/crates/op-orchestrator/src/plan.rs @@ -6,7 +6,16 @@ use crate::types::DesignRequest; use serde::Deserialize; -use std::collections::BTreeMap; + +/// 根 frame 的一个 fill —— 对齐 TS canonical `[{type,color}]`。 +/// 规划只需 solid 色;非 solid 项保留 `color` 字段(可能为空)。 +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PlanFill { + #[serde(rename = "type", default)] + pub kind: String, + #[serde(default)] + pub color: String, +} /// 一个 subtask 区域的尺寸。 #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -15,7 +24,7 @@ pub struct Region { pub height: f64, } -/// 根 frame 规格。 +/// 根 frame 规格。字段对齐规划语料 `decomposition.md` 的 `rootFrame`。 #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct RootFrameSpec { pub id: String, @@ -28,8 +37,20 @@ pub struct RootFrameSpec { pub gap: Option, #[serde(default)] pub padding: Option, + /// TS canonical fill 数组 `[{type:"solid",color:"#hex"}]`。 #[serde(default)] - pub fill: Option, + pub fill: Option>, +} + +impl RootFrameSpec { + /// fill 数组里首个 `type == "solid"` 的颜色;无则 `None`。 + pub fn first_solid_hex(&self) -> Option { + self.fill + .as_ref()? + .iter() + .find(|f| f.kind == "solid" && !f.color.is_empty()) + .map(|f| f.color.clone()) + } } /// 一个生成子任务 —— 对应设计里的一个区块。 @@ -46,21 +67,16 @@ pub struct Subtask { pub parent_frame_id: Option, } -/// 设计系统提示 —— 调色板等。 -#[derive(Debug, Clone, PartialEq, Default, Deserialize)] -pub struct StyleGuide { - /// `名称 -> hex`,阶段 2 据此 seed `$color-*` 文档变量。 - #[serde(default)] - pub palette: BTreeMap, -} - -/// 规划阶段的完整产物。 +/// 规划阶段的完整产物。字段对齐规划语料 `decomposition.md`。 #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OrchestratorPlan { + #[serde(rename = "rootFrame")] pub root_frame: RootFrameSpec, pub subtasks: Vec, - #[serde(default)] - pub style_guide: Option, + /// 规划选中的 style guide 名(catalog 引用)。S3b-1a 写进 plan + /// 但暂无消费方(变量播种入眠、sub-agent 注入推迟)。 + #[serde(rename = "styleGuideName", default)] + pub style_guide_name: Option, } /// plan 解析错误。 @@ -161,10 +177,13 @@ pub fn build_fallback_plan(req: &DesignRequest) -> OrchestratorPlan { layout: Some("vertical".into()), gap: Some(0.0), padding: Some(0.0), - fill: Some("#FFFFFF".into()), + fill: Some(vec![PlanFill { + kind: "solid".into(), + color: "#FFFFFF".into(), + }]), }, subtasks, - style_guide: None, + style_guide_name: None, } } @@ -181,26 +200,38 @@ mod tests { } #[test] - fn parse_plan_reads_fenced_json() { - let text = r#"Here is the plan: -```json + fn parse_plan_reads_ts_native_shape() { + // decomposition.md FORMAT 行的形状:rootFrame / fill 数组 / styleGuideName + let text = r##"```json { - "root_frame": { "id": "root", "name": "Page", "width": 1200, "height": 800 }, + "rootFrame": { "id": "page", "name": "Page", "width": 1200, "height": 0, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#0A0F1C" }] }, + "styleGuideName": "terminal-minimal-dark", "subtasks": [ - { "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } } + { "id": "hero", "label": "Hero", "elements": "headline, CTA", + "region": { "width": 1200, "height": 560 } } ] } -``` -done."#; +```"##; let plan = parse_plan(text).expect("parse"); - assert_eq!(plan.root_frame.id, "root"); + assert_eq!(plan.root_frame.id, "page"); + assert_eq!( + plan.style_guide_name.as_deref(), + Some("terminal-minimal-dark") + ); + // fill 数组 → first_solid_hex 取首个 solid 色 + assert_eq!( + plan.root_frame.first_solid_hex().as_deref(), + Some("#0A0F1C") + ); + // 未知字段 elements 被 serde 忽略,不报错 assert_eq!(plan.subtasks.len(), 1); - assert_eq!(plan.subtasks[0].id, "hero"); } #[test] fn parse_plan_rejects_no_subtasks() { - let text = r#"{ "root_frame": { "id": "r", "name": "P", "width": 1, "height": 1 }, "subtasks": [] }"#; + let text = r#"{ "rootFrame": { "id": "r", "name": "P", "width": 1, "height": 1 }, "subtasks": [] }"#; assert!(parse_plan(text).is_err()); } diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index 1d3a8092a..93559b996 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -84,7 +84,7 @@ mod tests { fill: None, }, subtasks, - style_guide: None, + style_guide_name: None, } } diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 837b410fa..2c53fdfa3 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -21,13 +21,14 @@ const SUBAGENT_TIMEOUT: Duration = Duration::from_secs(420); const PLAN_FORMAT: &str = r##" Respond with a single JSON object describing the design plan: { - "root_frame": { "id": "root", "name": "", "width": , "height": , - "layout": "vertical", "gap": , "padding": , "fill": "#RRGGBB" }, + "rootFrame": { "id": "root", "name": "", "width": , "height": , + "layout": "vertical", "gap": , + "fill": [{ "type": "solid", "color": "#RRGGBB" }] }, + "styleGuideName": "", "subtasks": [ { "id": "", "label": "", "region": { "width": , "height": } } - ], - "style_guide": { "palette": { "color-1": "#RRGGBB", "color-2": "#RRGGBB" } } + ] } Each subtask is one visual section. Use 1-6 subtasks. Output ONLY the JSON object."##; @@ -71,7 +72,7 @@ pub fn build_orchestrator_prompt(req: &DesignRequest, abort: AbortFlag) -> CallR /// 单个 sub-agent 的 LLM 调用输入。 pub fn build_subagent_prompt( subtask: &Subtask, - plan: &OrchestratorPlan, + _plan: &OrchestratorPlan, req: &DesignRequest, abort: AbortFlag, ) -> CallRequest { @@ -79,31 +80,10 @@ pub fn build_subagent_prompt( system_prompt.push_str("\n\n"); system_prompt.push_str(NODE_FORMAT); - let palette = plan - .style_guide - .as_ref() - .map(|sg| { - sg.palette - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - }) - .unwrap_or_default(); - let user_prompt = format!( "Overall design: {}\n\nGenerate the section \"{}\" \ - (区块 id 前缀 `{}-`). Target region: {:.0}x{:.0} px.\nPalette: {}", - req.prompt, - subtask.label, - subtask.id_prefix, - subtask.region.width, - subtask.region.height, - if palette.is_empty() { - "(default)" - } else { - &palette - }, + (区块 id 前缀 `{}-`). Target region: {:.0}x{:.0} px.\nPalette: (default)", + req.prompt, subtask.label, subtask.id_prefix, subtask.region.width, subtask.region.height, ); CallRequest { @@ -142,7 +122,7 @@ mod tests { fill: None, }, subtasks: vec![], - style_guide: None, + style_guide_name: None, } } diff --git a/crates/op-orchestrator/src/run.rs b/crates/op-orchestrator/src/run.rs index 3a9fb998f..6efc530bb 100644 --- a/crates/op-orchestrator/src/run.rs +++ b/crates/op-orchestrator/src/run.rs @@ -218,13 +218,14 @@ mod tests { } const PLAN_JSON: &str = r##"{ - "root_frame": { "id": "root", "name": "Page", "width": 1200, "height": 800, - "layout": "vertical", "gap": 0, "fill": "#FFFFFF" }, - "subtasks": [ - { "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } }, - { "id": "feat", "label": "Features", "region": { "width": 1200, "height": 400 } } - ] - }"##; + "rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } }, + { "id": "feat", "label": "Features", "region": { "width": 1200, "height": 400 } } + ] +}"##; fn node_json(prefix: &str) -> String { format!( diff --git a/crates/op-orchestrator/src/scaffold.rs b/crates/op-orchestrator/src/scaffold.rs index f2afff670..ac27528ab 100644 --- a/crates/op-orchestrator/src/scaffold.rs +++ b/crates/op-orchestrator/src/scaffold.rs @@ -22,7 +22,9 @@ pub fn build_scaffold( ) -> Result, String> { let rf = &plan.root_frame; let layout = rf.layout.as_deref().unwrap_or("vertical"); - let fill_hex = rf.fill.as_deref().unwrap_or("#FFFFFF"); + let fill_hex = rf + .first_solid_hex() + .unwrap_or_else(|| "#FFFFFF".to_string()); let children = if is_mobile { json!([{ @@ -66,7 +68,7 @@ pub fn build_scaffold( #[cfg(test)] mod tests { use super::*; - use crate::plan::{OrchestratorPlan, RootFrameSpec}; + use crate::plan::{OrchestratorPlan, PlanFill, RootFrameSpec}; use op_editor_core::PenNodeExt; fn plan() -> OrchestratorPlan { @@ -79,10 +81,13 @@ mod tests { layout: Some("vertical".into()), gap: Some(0.0), padding: Some(0.0), - fill: Some("#FFFFFF".into()), + fill: Some(vec![PlanFill { + kind: "solid".into(), + color: "#FFFFFF".into(), + }]), }, subtasks: vec![], - style_guide: None, + style_guide_name: None, } } diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index 1a156a695..41f689c61 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -101,7 +101,7 @@ mod tests { fill: None, }, subtasks: vec![], - style_guide: None, + style_guide_name: None, } } diff --git a/crates/op-orchestrator/src/variables.rs b/crates/op-orchestrator/src/variables.rs index 4281577ec..d1648a1a7 100644 --- a/crates/op-orchestrator/src/variables.rs +++ b/crates/op-orchestrator/src/variables.rs @@ -1,13 +1,8 @@ //! plan 派生变量 —— seed / 快照 / 回滚。 //! -//! 规划产出的 `style_guide.palette` 在阶段 2 被 seed 成文档里的 -//! 颜色变量;sub-agent 生成的节点会带 `$color-*` 引用。若本轮零 -//! 内容,这些 seed 出来的变量必须回滚,否则留下 dangling 引用。 -//! -//! 设计:`seed` 只用 `CreateVariable`(只创建不存在的变量,不 -//! 覆盖用户既有变量);快照记下"哪些 plan 变量名在 seed 前不 -//! 存在",回滚就 `DeleteVariable` 这些 —— 正好删掉 seed 真正新建 -//! 的那批,既有变量不受影响。 +//! S3b-1a:plan 已无 `palette`。三个公共函数入眠态(签名不变, +//! `run.rs` 调用方不动)。忠实的 styleGuideName → 解析 guide → +//! 播种变量是后续项。 use crate::plan::OrchestratorPlan; use crate::types::DocSink; @@ -21,109 +16,45 @@ pub struct VarSnapshot { pub created: Vec, } -/// 在 seed *之前* 调用 —— 记下 plan 调色板里哪些变量名当前 -/// 不存在(seed 会新建它们)。 -pub fn snapshot_plan_vars(sink: &dyn DocSink, plan: &OrchestratorPlan) -> VarSnapshot { - let state = sink.state(); - let created = palette_iter(plan) - .filter(|(name, _)| state.find_variable(name).is_none()) - .map(|(name, _)| name.clone()) - .collect(); - VarSnapshot { created } +/// 在 seed *之前* 调用。S3b-1a:plan 已无 `palette`,恒为空快照。 +/// 忠实的 styleGuideName → 解析 guide → 播种变量是后续项。 +pub fn snapshot_plan_vars(_sink: &dyn DocSink, _plan: &OrchestratorPlan) -> VarSnapshot { + VarSnapshot::default() } -/// plan 调色板 → `CreateVariable` 命令(每个颜色一条)。 -/// `CreateVariable` 对已存在的同名变量会被 applier 拒(返回 -/// `false`),故不会覆盖用户既有变量。 -pub fn seed_commands(plan: &OrchestratorPlan) -> Vec { - palette_iter(plan) - .map(|(name, hex)| EditorCommand::CreateVariable { - name: name.clone(), - kind: "color".into(), - default_value: hex.clone(), - }) - .collect() +/// plan 调色板 → seed 命令。S3b-1a:plan 无 `palette`,恒为空。 +pub fn seed_commands(_plan: &OrchestratorPlan) -> Vec { + Vec::new() } -/// 回滚 —— 删除 seed 真正新建的那批变量(快照里记下的)。 -/// 既有变量不在快照里,不受影响。 +/// 回滚 seed 新建的变量。S3b-1a:无 seed,故 no-op(快照恒空)。 pub fn rollback(sink: &mut dyn DocSink, snap: &VarSnapshot) { for name in &snap.created { sink.apply(EditorCommand::DeleteVariable { name: name.clone() }); } } -/// plan 调色板的 `(名称, hex)` 迭代器 —— `style_guide` 缺省时为空。 -fn palette_iter(plan: &OrchestratorPlan) -> impl Iterator { - plan.style_guide - .as_ref() - .into_iter() - .flat_map(|sg| sg.palette.iter()) -} - #[cfg(test)] mod tests { use super::*; - use crate::plan::{OrchestratorPlan, RootFrameSpec, StyleGuide}; use crate::test_support::VecDocSink; - use std::collections::BTreeMap; - - fn plan_with_palette(pairs: &[(&str, &str)]) -> OrchestratorPlan { - let mut palette = BTreeMap::new(); - for (k, v) in pairs { - palette.insert((*k).to_string(), (*v).to_string()); - } - OrchestratorPlan { - root_frame: RootFrameSpec { - id: "root".into(), - name: "P".into(), - width: 1200.0, - height: 800.0, - layout: None, - gap: None, - padding: None, - fill: None, - }, - subtasks: vec![], - style_guide: Some(StyleGuide { palette }), - } - } #[test] - fn seed_commands_one_per_palette_color() { - let plan = plan_with_palette(&[("color-1", "#2563EB"), ("color-2", "#0EA5E9")]); - let cmds = seed_commands(&plan); - assert_eq!(cmds.len(), 2); - assert!(matches!( - &cmds[0], - EditorCommand::CreateVariable { kind, .. } if kind == "color" - )); - } - - #[test] - fn seed_then_rollback_round_trips() { - let plan = plan_with_palette(&[("color-1", "#2563EB")]); - let mut sink = VecDocSink::new(); - - // seed 前快照:color-1 不存在 → 进 created 集 - let snap = snapshot_plan_vars(&sink, &plan); - assert_eq!(snap.created, vec!["color-1".to_string()]); - - // seed - for cmd in seed_commands(&plan) { - sink.apply(cmd); - } - assert!(sink.state().find_variable("color-1").is_some()); - - // 回滚 → 变量被删 - rollback(&mut sink, &snap); - assert!(sink.state().find_variable("color-1").is_none()); - } - - #[test] - fn no_style_guide_yields_no_commands() { - let mut plan = plan_with_palette(&[]); - plan.style_guide = None; + fn seed_is_dormant() { + let plan = crate::plan::build_fallback_plan(&crate::types::DesignRequest { + prompt: "a page".into(), + model: None, + provider: None, + }); assert!(seed_commands(&plan).is_empty()); + let sink = VecDocSink::new(); + assert!(snapshot_plan_vars(&sink, &plan).created.is_empty()); + } + + #[test] + fn rollback_of_empty_snapshot_is_noop() { + let mut sink = VecDocSink::new(); + rollback(&mut sink, &VarSnapshot::default()); + assert!(sink.applied.is_empty()); } }