diff --git a/crates/op-orchestrator/src/concurrent_tests.rs b/crates/op-orchestrator/src/concurrent_tests.rs index 89aa3284b..d7ffb2a0a 100644 --- a/crates/op-orchestrator/src/concurrent_tests.rs +++ b/crates/op-orchestrator/src/concurrent_tests.rs @@ -34,6 +34,7 @@ fn subtask_with_screen(id: &str, screen: Option<&str>) -> Subtask { elements: None, screen: screen.map(|s| s.to_string()), generated_root_id: None, + existing_section_labels: None, } } @@ -64,6 +65,7 @@ fn make_plan_with_subtasks(subtask_ids: &[&str]) -> crate::plan::OrchestratorPla elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }) .collect(), style_guide_name: None, @@ -77,6 +79,7 @@ fn make_req() -> crate::types::DesignRequest { provider: None, design_md: None, concurrency: 2, + append_context: None, } } diff --git a/crates/op-orchestrator/src/concurrent_tests_b2.rs b/crates/op-orchestrator/src/concurrent_tests_b2.rs index 6c81478e1..34f25dd47 100644 --- a/crates/op-orchestrator/src/concurrent_tests_b2.rs +++ b/crates/op-orchestrator/src/concurrent_tests_b2.rs @@ -21,6 +21,7 @@ fn make_req() -> crate::types::DesignRequest { provider: None, design_md: None, concurrency: 2, + append_context: None, } } @@ -51,6 +52,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan { elements: None, screen: Some("login".into()), generated_root_id: None, + existing_section_labels: None, }, crate::plan::Subtask { id: "s1".into(), @@ -64,6 +66,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan { elements: None, screen: Some("home".into()), generated_root_id: None, + existing_section_labels: None, }, ], style_guide_name: None, @@ -86,6 +89,7 @@ fn make_three_screen_plan() -> crate::plan::OrchestratorPlan { elements: None, screen: Some(screen.into()), generated_root_id: None, + existing_section_labels: None, }; OrchestratorPlan { root_frame: RootFrameSpec { diff --git a/crates/op-orchestrator/src/dashboard_columns_tests.rs b/crates/op-orchestrator/src/dashboard_columns_tests.rs index 0c97bba76..5348f11af 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests.rs @@ -32,6 +32,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { elements: elements.map(String::from), screen: None, generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs index 1c4ceef92..e0fbf43f5 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests_b.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs @@ -33,6 +33,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { elements: elements.map(String::from), screen: None, generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs index c287b5b1d..c9ac231e6 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs @@ -34,6 +34,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { elements: elements.map(String::from), screen: None, generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs index b5c2bd7ab..f9ba15ae7 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b3.rs @@ -33,6 +33,7 @@ fn st(id: &str, label: &str, width: f64, height: f64) -> Subtask { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, } } @@ -49,6 +50,7 @@ fn sidebar_st(height: f64) -> Subtask { elements: Some("nav links".into()), screen: None, generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/plan.rs b/crates/op-orchestrator/src/plan.rs index 6646df674..5ebc4d9c4 100644 --- a/crates/op-orchestrator/src/plan.rs +++ b/crates/op-orchestrator/src/plan.rs @@ -5,11 +5,11 @@ //! `buildFallbackPlanFromPrompt`)。 use crate::types::DesignRequest; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// 根 frame 的一个 fill —— 对齐 TS canonical `[{type,color}]`。 /// 规划只需 solid 色;非 solid 项保留 `color` 字段(可能为空)。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PlanFill { #[serde(rename = "type", default)] pub kind: String, @@ -18,14 +18,14 @@ pub struct PlanFill { } /// 一个 subtask 区域的尺寸。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Region { pub width: f64, pub height: f64, } /// 根 frame 规格。字段对齐规划语料 `decomposition.md` 的 `rootFrame`。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RootFrameSpec { pub id: String, pub name: String, @@ -54,7 +54,8 @@ impl RootFrameSpec { } /// 一个生成子任务 —— 对应设计里的一个区块。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Subtask { pub id: String, pub label: String, @@ -75,10 +76,14 @@ pub struct Subtask { /// `SubTask.generatedRootId`。仪器由 run.rs 填写;规划阶段为 None。 #[serde(skip)] pub generated_root_id: Option, + /// 从 AppendContext 传播而来 —— 告知 sub-agent 哪些已有区块不要重复。 + /// port of TS `SubTask.existingSectionLabels` (`ai-types.ts:134`)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub existing_section_labels: Option>, } /// 规划阶段的完整产物。字段对齐规划语料 `decomposition.md`。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OrchestratorPlan { #[serde(rename = "rootFrame")] pub root_frame: RootFrameSpec, @@ -177,6 +182,7 @@ pub fn build_fallback_plan(req: &DesignRequest) -> OrchestratorPlan { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, } }) .collect(); @@ -211,6 +217,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -265,4 +272,72 @@ mod tests { assert_eq!(long.subtasks.len(), 3); assert!(!long.subtasks.is_empty()); } + + // ── Task A1: Subtask.existing_section_labels ────────────────────────────── + + /// Subtask accepts existing_section_labels: None without breaking compilation. + #[test] + fn subtask_existing_section_labels_none_compiles() { + let st = Subtask { + id: "hero".into(), + label: "Hero".into(), + region: Region { + width: 1200.0, + height: 400.0, + }, + id_prefix: "hero".into(), + parent_frame_id: None, + elements: None, + screen: None, + generated_root_id: None, + existing_section_labels: None, + }; + assert!(st.existing_section_labels.is_none()); + } + + /// Subtask accepts a populated existing_section_labels vec. + #[test] + fn subtask_existing_section_labels_some_compiles() { + let st = Subtask { + id: "features".into(), + label: "Features".into(), + region: Region { + width: 1200.0, + height: 400.0, + }, + id_prefix: "features".into(), + parent_frame_id: None, + elements: None, + screen: None, + generated_root_id: None, + existing_section_labels: Some(vec!["Hero".into(), "About".into()]), + }; + let labels = st.existing_section_labels.as_ref().unwrap(); + assert_eq!(labels[0], "Hero"); + assert_eq!(labels[1], "About"); + } + + /// Subtask with existing_section_labels = Some([]) serializes without the field. + #[test] + fn subtask_existing_section_labels_omitted_when_none() { + let st = Subtask { + id: "hero".into(), + label: "Hero".into(), + region: Region { + width: 1200.0, + height: 400.0, + }, + id_prefix: "hero".into(), + parent_frame_id: None, + elements: None, + screen: None, + generated_root_id: None, + existing_section_labels: None, + }; + let json = serde_json::to_string(&st).expect("serialize"); + assert!( + !json.contains("existingSectionLabels"), + "existingSectionLabels should be omitted when None, got: {json}" + ); + } } diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index a447c7bfc..8a990fd63 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -82,6 +82,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -98,6 +99,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, } } @@ -172,6 +174,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -189,6 +192,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; // chart subtask — LLM-provided height 300, within [inferred*0.6, inferred*1.6] // inferred for "chart" = 320 → min=192, max=512 → 300 in range → keep 300 @@ -204,6 +208,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; // metric subtask — LLM-provided height 0 (invalid) → use inferred = 160 let st_metric = Subtask { @@ -218,6 +223,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; OrchestratorPlan { root_frame: RootFrameSpec { @@ -317,6 +323,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }], style_guide_name: None, }; @@ -356,6 +363,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }], style_guide_name: None, }; diff --git a/crates/op-orchestrator/src/plan_repair.rs b/crates/op-orchestrator/src/plan_repair.rs index f60d854ab..d823d6c46 100644 --- a/crates/op-orchestrator/src/plan_repair.rs +++ b/crates/op-orchestrator/src/plan_repair.rs @@ -359,6 +359,7 @@ fn coerce_subtask( elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }); } @@ -410,6 +411,7 @@ fn coerce_subtask( elements, screen, generated_root_id: None, + existing_section_labels: None, }) } diff --git a/crates/op-orchestrator/src/plan_repair_tests.rs b/crates/op-orchestrator/src/plan_repair_tests.rs index 73a38a2d9..7c2829ed3 100644 --- a/crates/op-orchestrator/src/plan_repair_tests.rs +++ b/crates/op-orchestrator/src/plan_repair_tests.rs @@ -243,6 +243,7 @@ fn req(prompt: &str) -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -266,6 +267,7 @@ fn req_with_design_md(prompt: &str) -> DesignRequest { generation_notes: None, }), concurrency: 1, + append_context: None, } } diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index e3efbee0d..055180068 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -230,6 +230,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -310,6 +311,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; let cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false); assert!(cr.user_prompt.contains("Hero")); @@ -331,6 +333,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; // minimal_skills=true: the system prompt should contain "schema" skill // content and "jsonl-format" skill content, but NOT layout/text-rules etc. @@ -362,6 +365,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; // req() uses model "claude" which is Full tier — no narrowing. // Use a basic-tier model to test narrowing. @@ -371,6 +375,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let full_cr = build_subagent_prompt(&st, &plan(), &basic_req, AbortFlag::new(), false, false); @@ -396,6 +401,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }; // req() uses "claude" which maps to Full tier → reduced_complexity is no-op let full_cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false); @@ -458,6 +464,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let long_prompt = "x".repeat(5000); // >= 4200 chars let long_req = DesignRequest { @@ -466,6 +473,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let short_pp = build_orchestrator_prompt(&short_req, PlanningMode::Rich, AbortFlag::new()); let long_pp = build_orchestrator_prompt(&long_req, PlanningMode::Rich, AbortFlag::new()); @@ -485,6 +493,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let pp = build_orchestrator_prompt(&ds_req, PlanningMode::Rich, AbortFlag::new()); // Short bucket base: 300_000ms × 2.0 = 600_000ms @@ -508,6 +517,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, } } @@ -540,6 +550,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let long_req = DesignRequest { prompt: "x".repeat(5000), @@ -547,6 +558,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let short_cr = build_subagent_prompt( &subtask(), @@ -579,6 +591,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }; let cr = build_subagent_prompt( &subtask(), diff --git a/crates/op-orchestrator/src/run_tests.rs b/crates/op-orchestrator/src/run_tests.rs index e259a5b45..942d446d2 100644 --- a/crates/op-orchestrator/src/run_tests.rs +++ b/crates/op-orchestrator/src/run_tests.rs @@ -13,6 +13,7 @@ fn req() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -25,6 +26,7 @@ fn req_standard() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -37,6 +39,7 @@ fn req_basic() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } diff --git a/crates/op-orchestrator/src/run_tests_c2.rs b/crates/op-orchestrator/src/run_tests_c2.rs index 1b25dff9b..b739e2a52 100644 --- a/crates/op-orchestrator/src/run_tests_c2.rs +++ b/crates/op-orchestrator/src/run_tests_c2.rs @@ -57,6 +57,7 @@ fn req_concurrent() -> DesignRequest { provider: None, design_md: None, concurrency: 2, + append_context: None, } } @@ -69,6 +70,7 @@ fn req_sequential() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } diff --git a/crates/op-orchestrator/src/run_tests_c3.rs b/crates/op-orchestrator/src/run_tests_c3.rs index 0c8776e0e..464fce950 100644 --- a/crates/op-orchestrator/src/run_tests_c3.rs +++ b/crates/op-orchestrator/src/run_tests_c3.rs @@ -56,6 +56,7 @@ fn req_dashboard() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -66,6 +67,7 @@ fn req_non_dashboard() -> DesignRequest { provider: None, design_md: None, concurrency: 1, + append_context: None, } } diff --git a/crates/op-orchestrator/src/scaffold.rs b/crates/op-orchestrator/src/scaffold.rs index 75e0c14fc..bdf88ed4a 100644 --- a/crates/op-orchestrator/src/scaffold.rs +++ b/crates/op-orchestrator/src/scaffold.rs @@ -304,6 +304,7 @@ mod tests { elements: None, screen: screen.map(|s| s.to_string()), generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/scaffold_tests_c2.rs b/crates/op-orchestrator/src/scaffold_tests_c2.rs index 60d6f7315..19d9898a1 100644 --- a/crates/op-orchestrator/src/scaffold_tests_c2.rs +++ b/crates/op-orchestrator/src/scaffold_tests_c2.rs @@ -60,6 +60,7 @@ fn dashboard_plan(root_id: &str) -> OrchestratorPlan { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }, Subtask { id: "revenue-chart".into(), @@ -73,6 +74,7 @@ fn dashboard_plan(root_id: &str) -> OrchestratorPlan { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }, Subtask { id: "kpi-metrics".into(), @@ -86,6 +88,7 @@ fn dashboard_plan(root_id: &str) -> OrchestratorPlan { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, }, ], style_guide_name: None, diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index d4c9ba4ca..aaac891c5 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -105,6 +105,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, } } @@ -138,6 +139,7 @@ mod tests { elements: None, screen: None, generated_root_id: None, + existing_section_labels: None, } } diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index 7eb5f4d59..9e84eeff0 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -5,6 +5,7 @@ use futures::stream::BoxStream; use op_editor_core::{EditorCommand, EditorState}; +use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -164,8 +165,26 @@ impl std::fmt::Display for OrchestratorError { impl std::error::Error for OrchestratorError {} +/// 追加上下文 —— 当用户要求扩展已有页面时由 host 填入。 +/// +/// Port of `AppendContext` in `apps/web/src/services/ai/ai-types.ts:28-37`. +/// 当存在时,编排器跳过创建新根 frame,将生成的区块插入现有目标 frame。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppendContext { + /// 新 sub-agent 区块应插入的 frame id。 + pub target_parent_id: String, + /// 目标 frame 的宽度(用于给 sub-agent 确定区域尺寸)。 + pub target_width: f64, + /// 现有顶层区块的标签列表 —— sub-agent 被告知不要重复这些。 + pub existing_section_labels: Vec, + /// 目标 frame 属于移动页面(宽度 ≤ 480)时为 true。 + pub is_mobile: bool, +} + /// 编排器输入 —— 一次设计请求。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct DesignRequest { pub prompt: String, pub model: Option, @@ -176,6 +195,10 @@ pub struct DesignRequest { /// 调用方应传 store-clamped 值 [1,6];crate 内部防御性 clamp。 /// 默认为 1(顺序执行)。Port of TS `request.concurrency ?? 1`. pub concurrency: u32, + /// 追加模式上下文 —— 仅当 host 检测到 append intent 时填入。 + /// Port of `AIDesignRequest.context.appendContext` in `ai-types.ts:51`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub append_context: Option, } #[cfg(test)] @@ -201,4 +224,89 @@ mod tests { flag.set(); assert!(clone.is_set()); } + + // ── Task A1: AppendContext + DesignRequest.append_context ───────────────── + + /// AppendContext serde round-trips with all 4 fields (camelCase wire names). + #[test] + fn append_context_serde_round_trip() { + let ctx = AppendContext { + target_parent_id: "frame-abc".into(), + target_width: 390.0, + existing_section_labels: vec!["Hero".into(), "Pricing".into()], + is_mobile: true, + }; + let json = serde_json::to_string(&ctx).expect("serialize"); + // Wire names are camelCase + assert!( + json.contains("targetParentId"), + "expected targetParentId in {json}" + ); + assert!( + json.contains("targetWidth"), + "expected targetWidth in {json}" + ); + assert!( + json.contains("existingSectionLabels"), + "expected existingSectionLabels in {json}" + ); + assert!(json.contains("isMobile"), "expected isMobile in {json}"); + let back: AppendContext = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.target_parent_id, "frame-abc"); + assert_eq!(back.target_width, 390.0); + assert_eq!(back.existing_section_labels, vec!["Hero", "Pricing"]); + assert!(back.is_mobile); + } + + /// DesignRequest accepts append_context: None without breaking compilation. + #[test] + fn design_request_append_context_none_compiles() { + let req = DesignRequest { + prompt: "test".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + }; + assert!(req.append_context.is_none()); + } + + /// DesignRequest accepts a populated AppendContext. + #[test] + fn design_request_append_context_some_compiles() { + let ctx = AppendContext { + target_parent_id: "p1".into(), + target_width: 1200.0, + existing_section_labels: vec![], + is_mobile: false, + }; + let req = DesignRequest { + prompt: "extend page".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: Some(ctx), + }; + assert!(req.append_context.is_some()); + } + + /// DesignRequest without append_context serializes without the field. + #[test] + fn design_request_append_context_omitted_from_json_when_none() { + let req = DesignRequest { + prompt: "test".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + }; + let json = serde_json::to_string(&req).expect("serialize"); + assert!( + !json.contains("appendContext"), + "appendContext should be omitted when None, got: {json}" + ); + } } diff --git a/crates/op-orchestrator/src/variables.rs b/crates/op-orchestrator/src/variables.rs index 1e9fcaeef..05ffd2bfe 100644 --- a/crates/op-orchestrator/src/variables.rs +++ b/crates/op-orchestrator/src/variables.rs @@ -47,6 +47,7 @@ mod tests { provider: None, design_md: None, concurrency: 1, + append_context: None, }); assert!(seed_commands(&plan).is_empty()); let sink = VecDocSink::new();