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).
This commit is contained in:
Fini 2026-05-22 22:56:20 +08:00
parent a654ed6800
commit 592cb9fa16
8 changed files with 112 additions and 164 deletions

View file

@ -152,7 +152,7 @@ mod tests {
fill: None,
},
subtasks: vec![],
style_guide: None,
style_guide_name: None,
}
}

View file

@ -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<f64>,
#[serde(default)]
pub padding: Option<f64>,
/// TS canonical fill 数组 `[{type:"solid",color:"#hex"}]`。
#[serde(default)]
pub fill: Option<String>,
pub fill: Option<Vec<PlanFill>>,
}
impl RootFrameSpec {
/// fill 数组里首个 `type == "solid"` 的颜色;无则 `None`。
pub fn first_solid_hex(&self) -> Option<String> {
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<String>,
}
/// 设计系统提示 —— 调色板等。
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
pub struct StyleGuide {
/// `名称 -> hex`,阶段 2 据此 seed `$color-*` 文档变量。
#[serde(default)]
pub palette: BTreeMap<String, String>,
}
/// 规划阶段的完整产物。
/// 规划阶段的完整产物。字段对齐规划语料 `decomposition.md`。
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct OrchestratorPlan {
#[serde(rename = "rootFrame")]
pub root_frame: RootFrameSpec,
pub subtasks: Vec<Subtask>,
#[serde(default)]
pub style_guide: Option<StyleGuide>,
/// 规划选中的 style guide 名(catalog 引用)。S3b-1a 写进 plan
/// 但暂无消费方(变量播种入眠、sub-agent 注入推迟)。
#[serde(rename = "styleGuideName", default)]
pub style_guide_name: Option<String>,
}
/// 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());
}

View file

@ -84,7 +84,7 @@ mod tests {
fill: None,
},
subtasks,
style_guide: None,
style_guide_name: None,
}
}

View file

@ -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": "<name>", "width": <px>, "height": <px>,
"layout": "vertical", "gap": <px>, "padding": <px>, "fill": "#RRGGBB" },
"rootFrame": { "id": "root", "name": "<name>", "width": <px>, "height": <px>,
"layout": "vertical", "gap": <px>,
"fill": [{ "type": "solid", "color": "#RRGGBB" }] },
"styleGuideName": "<style-guide-name or omit>",
"subtasks": [
{ "id": "<kebab-id>", "label": "<human label>",
"region": { "width": <px>, "height": <px> } }
],
"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::<Vec<_>>()
.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,
}
}

View file

@ -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!(

View file

@ -22,7 +22,9 @@ pub fn build_scaffold(
) -> Result<Vec<EditorCommand>, 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,
}
}

View file

@ -101,7 +101,7 @@ mod tests {
fill: None,
},
subtasks: vec![],
style_guide: None,
style_guide_name: None,
}
}

View file

@ -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<String>,
}
/// 在 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<EditorCommand> {
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<EditorCommand> {
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<Item = (&String, &String)> {
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());
}
}