feat(orchestrator): types — AppendContext + append_context + existing_section_labels

This commit is contained in:
Fini 2026-05-23 07:05:23 +08:00
parent 3484bd3783
commit 25b3b8bf39
19 changed files with 241 additions and 7 deletions

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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<String>,
/// 从 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<Vec<String>>,
}
/// 规划阶段的完整产物。字段对齐规划语料 `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}"
);
}
}

View file

@ -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,
};

View file

@ -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,
})
}

View file

@ -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,
}
}

View file

@ -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(),

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -304,6 +304,7 @@ mod tests {
elements: None,
screen: screen.map(|s| s.to_string()),
generated_root_id: None,
existing_section_labels: None,
}
}

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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<String>,
/// 目标 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<String>,
@ -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<AppendContext>,
}
#[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}"
);
}
}

View file

@ -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();