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:
parent
a654ed6800
commit
592cb9fa16
|
|
@ -152,7 +152,7 @@ mod tests {
|
|||
fill: None,
|
||||
},
|
||||
subtasks: vec![],
|
||||
style_guide: None,
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ mod tests {
|
|||
fill: None,
|
||||
},
|
||||
subtasks,
|
||||
style_guide: None,
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ mod tests {
|
|||
fill: None,
|
||||
},
|
||||
subtasks: vec![],
|
||||
style_guide: None,
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue