415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
//! plan 规范化 —— 单屏路径。
|
|
//!
|
|
//! 行为忠实 TS,但**一次性算分类、干净派生**,不做 TS 那种
|
|
//! in-place strip-then-reclassify(`orchestrator.ts:838-845` 自标
|
|
//! fragile)。
|
|
|
|
use crate::dashboard_columns::{
|
|
infer_dashboard_section_height, infer_dashboard_section_width, is_dashboard_like_prompt,
|
|
};
|
|
use crate::plan::OrchestratorPlan;
|
|
use crate::types::DesignRequest;
|
|
|
|
/// 规范化产出的派生信息。
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct NormInfo {
|
|
/// 根 frame 窄到移动端宽度 —— scaffold 阶段据此注入固定状态栏。
|
|
pub is_mobile: bool,
|
|
}
|
|
|
|
/// 移动端宽度上限(含)—— ≤ 此值视为移动端单屏。
|
|
const MOBILE_MAX_WIDTH: f64 = 480.0;
|
|
|
|
/// subtask 的 id / label 命中即视为"状态栏"区块 —— 移动端由
|
|
/// scaffold 注入固定状态栏,plan 里若带状态栏 subtask 则剔除。
|
|
fn is_status_bar_subtask(id: &str, label: &str) -> bool {
|
|
let hay = format!("{} {}", id.to_lowercase(), label.to_lowercase());
|
|
hay.contains("status bar") || hay.contains("status-bar") || hay.contains("statusbar")
|
|
}
|
|
|
|
/// 就地规范化 `plan`:
|
|
/// - 一次性判定 `is_mobile`(根 frame 宽度);
|
|
/// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入);
|
|
/// - 给每个 subtask 赋 `id_prefix = id`、`parent_frame_id = 根 id`;
|
|
/// - dashboard-like plan:每个 subtask 的 region 用推断宽高覆写
|
|
/// (宽度无条件覆写;高度在 [inferred*0.6, inferred*1.6] 窗口内保留
|
|
/// LLM 值,超出则取推断值 —— 忠实 TS `normalizeOrchestratorPlan`
|
|
/// `orchestrator.ts:259-272`)。
|
|
pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo {
|
|
let is_mobile = plan.root_frame.width <= MOBILE_MAX_WIDTH;
|
|
|
|
if is_mobile {
|
|
plan.subtasks
|
|
.retain(|st| !is_status_bar_subtask(&st.id, &st.label));
|
|
}
|
|
|
|
let root_width = plan.root_frame.width;
|
|
let dashboard_like = is_dashboard_like_prompt(&req.prompt, plan);
|
|
|
|
let root_id = plan.root_frame.id.clone();
|
|
for st in &mut plan.subtasks {
|
|
st.id_prefix = st.id.clone();
|
|
st.parent_frame_id = Some(root_id.clone());
|
|
|
|
if dashboard_like {
|
|
let inferred_width = infer_dashboard_section_width(st, root_width);
|
|
let inferred_height = infer_dashboard_section_height(st);
|
|
|
|
st.region.width = inferred_width;
|
|
|
|
if st.region.height <= 0.0 {
|
|
st.region.height = inferred_height;
|
|
} else {
|
|
let min_height = (inferred_height * 0.6).round();
|
|
let max_height = (inferred_height * 1.6).round();
|
|
st.region.height = f64::max(min_height, f64::min(st.region.height, max_height));
|
|
}
|
|
}
|
|
}
|
|
|
|
NormInfo { is_mobile }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
|
|
|
|
fn req() -> DesignRequest {
|
|
DesignRequest {
|
|
prompt: "x".into(),
|
|
model: None,
|
|
provider: None,
|
|
design_md: None,
|
|
concurrency: 1,
|
|
append_context: None,
|
|
validation_enabled: true,
|
|
|
|
visual_ref_enabled: false,
|
|
}
|
|
}
|
|
|
|
fn subtask(id: &str, label: &str) -> Subtask {
|
|
Subtask {
|
|
id: id.into(),
|
|
label: label.into(),
|
|
region: Region {
|
|
width: 100.0,
|
|
height: 100.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
elements: None,
|
|
screen: None,
|
|
generated_root_id: None,
|
|
existing_section_labels: None,
|
|
}
|
|
}
|
|
|
|
fn plan(width: f64, subtasks: Vec<Subtask>) -> OrchestratorPlan {
|
|
OrchestratorPlan {
|
|
root_frame: RootFrameSpec {
|
|
id: "root".into(),
|
|
name: "P".into(),
|
|
width,
|
|
height: 800.0,
|
|
layout: None,
|
|
gap: None,
|
|
padding: None,
|
|
fill: None,
|
|
},
|
|
subtasks,
|
|
style_guide_name: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_assigns_id_prefix_and_parent() {
|
|
let mut p = plan(
|
|
1200.0,
|
|
vec![subtask("hero", "Hero"), subtask("feat", "Features")],
|
|
);
|
|
let info = normalize(&mut p, &req());
|
|
assert!(!info.is_mobile);
|
|
for st in &p.subtasks {
|
|
assert_eq!(st.id_prefix, st.id);
|
|
assert_eq!(st.parent_frame_id.as_deref(), Some("root"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_flags_mobile_by_width() {
|
|
let mut p = plan(390.0, vec![subtask("hero", "Hero")]);
|
|
let info = normalize(&mut p, &req());
|
|
assert!(info.is_mobile);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_strips_status_bar_subtask_on_mobile() {
|
|
let mut p = plan(
|
|
390.0,
|
|
vec![subtask("status-bar", "Status Bar"), subtask("hero", "Hero")],
|
|
);
|
|
normalize(&mut p, &req());
|
|
assert_eq!(p.subtasks.len(), 1);
|
|
assert_eq!(p.subtasks[0].id, "hero");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_keeps_status_bar_subtask_on_desktop() {
|
|
// 桌面端不剔除(只有移动端 scaffold 注入固定状态栏)。
|
|
let mut p = plan(
|
|
1200.0,
|
|
vec![subtask("status-bar", "Status Bar"), subtask("hero", "Hero")],
|
|
);
|
|
normalize(&mut p, &req());
|
|
assert_eq!(p.subtasks.len(), 2);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task C1 — dashboard branch
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn dash_req(prompt: &str) -> DesignRequest {
|
|
DesignRequest {
|
|
prompt: prompt.into(),
|
|
model: None,
|
|
provider: None,
|
|
design_md: None,
|
|
concurrency: 1,
|
|
append_context: None,
|
|
validation_enabled: true,
|
|
|
|
visual_ref_enabled: false,
|
|
}
|
|
}
|
|
|
|
/// Build a dashboard-like plan with a sidebar + two data subtasks.
|
|
fn dashboard_plan(root_width: f64) -> OrchestratorPlan {
|
|
let st_sidebar = Subtask {
|
|
id: "sidebar".into(),
|
|
label: "Sidebar Navigation".into(),
|
|
region: Region {
|
|
width: 100.0,
|
|
height: 500.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
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
|
|
let st_chart = Subtask {
|
|
id: "revenue-chart".into(),
|
|
label: "Revenue Chart".into(),
|
|
region: Region {
|
|
width: 800.0,
|
|
height: 300.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
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 {
|
|
id: "kpi-metrics".into(),
|
|
label: "KPI Metrics".into(),
|
|
region: Region {
|
|
width: 800.0,
|
|
height: 0.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
elements: None,
|
|
screen: None,
|
|
generated_root_id: None,
|
|
existing_section_labels: None,
|
|
};
|
|
OrchestratorPlan {
|
|
root_frame: RootFrameSpec {
|
|
id: "root".into(),
|
|
name: "Dashboard".into(),
|
|
width: root_width,
|
|
height: 800.0,
|
|
layout: None,
|
|
gap: None,
|
|
padding: None,
|
|
fill: None,
|
|
},
|
|
subtasks: vec![st_sidebar, st_chart, st_metric],
|
|
style_guide_name: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_dashboard_rewrites_width_per_infer() {
|
|
// root_width = 1200 → main_width = max(320, 1200-260) = 940
|
|
// sidebar → 260
|
|
// chart/revenue → round(940 * 0.62) = round(582.8) = 583
|
|
// metric (default) → 940
|
|
let mut p = dashboard_plan(1200.0);
|
|
let req = dash_req("an analytics admin dashboard");
|
|
normalize(&mut p, &req);
|
|
|
|
let sidebar = p.subtasks.iter().find(|s| s.id == "sidebar").unwrap();
|
|
assert_eq!(sidebar.region.width, 260.0, "sidebar width must be 260");
|
|
|
|
let chart = p.subtasks.iter().find(|s| s.id == "revenue-chart").unwrap();
|
|
let expected_chart_w = (940.0_f64 * 0.62).round();
|
|
assert_eq!(
|
|
chart.region.width, expected_chart_w,
|
|
"chart width = main*0.62"
|
|
);
|
|
|
|
let metric = p.subtasks.iter().find(|s| s.id == "kpi-metrics").unwrap();
|
|
assert_eq!(metric.region.width, 940.0, "metric width = full main");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_dashboard_height_kept_when_in_range() {
|
|
// chart: inferred=320, LLM=300
|
|
// min=round(320*0.6)=192, max=round(320*1.6)=512
|
|
// 300 ∈ [192,512] → keep 300
|
|
let mut p = dashboard_plan(1200.0);
|
|
let req = dash_req("analytics admin dashboard");
|
|
normalize(&mut p, &req);
|
|
|
|
let chart = p.subtasks.iter().find(|s| s.id == "revenue-chart").unwrap();
|
|
assert_eq!(
|
|
chart.region.height, 300.0,
|
|
"LLM height kept when in clamp range"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_dashboard_height_replaced_when_zero() {
|
|
// metric: LLM height=0 (invalid) → use inferred=160
|
|
let mut p = dashboard_plan(1200.0);
|
|
let req = dash_req("data analytics admin dashboard");
|
|
normalize(&mut p, &req);
|
|
|
|
let metric = p.subtasks.iter().find(|s| s.id == "kpi-metrics").unwrap();
|
|
assert_eq!(
|
|
metric.region.height, 160.0,
|
|
"zero LLM height replaced by inferred"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_dashboard_height_clamped_to_max() {
|
|
// "customer-table" subtask: id+label match "table" (no "transaction"),
|
|
// so inferred = 340 → max=round(340*1.6)=544
|
|
// max(round(340*0.6), min(2000, 544)) = max(204, 544) = 544
|
|
let mut p = OrchestratorPlan {
|
|
root_frame: RootFrameSpec {
|
|
id: "root".into(),
|
|
name: "Dashboard".into(),
|
|
width: 1200.0,
|
|
height: 800.0,
|
|
layout: None,
|
|
gap: None,
|
|
padding: None,
|
|
fill: None,
|
|
},
|
|
subtasks: vec![Subtask {
|
|
id: "customer-table".into(),
|
|
label: "Customer Table".into(),
|
|
region: Region {
|
|
width: 800.0,
|
|
height: 2000.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
elements: None,
|
|
screen: None,
|
|
generated_root_id: None,
|
|
existing_section_labels: None,
|
|
}],
|
|
style_guide_name: None,
|
|
};
|
|
normalize(&mut p, &dash_req("data analytics admin dashboard"));
|
|
assert_eq!(
|
|
p.subtasks[0].region.height,
|
|
(340.0_f64 * 1.6).round(),
|
|
"over-tall LLM height clamped to max"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_dashboard_height_clamped_to_min() {
|
|
// chart subtask with too-short LLM height (50)
|
|
// inferred=320 → min=round(320*0.6)=192, max=round(320*1.6)=512
|
|
// max(192, min(50, 512)) = max(192, 50) = 192
|
|
let mut p = OrchestratorPlan {
|
|
root_frame: RootFrameSpec {
|
|
id: "root".into(),
|
|
name: "Dashboard".into(),
|
|
width: 1200.0,
|
|
height: 800.0,
|
|
layout: None,
|
|
gap: None,
|
|
padding: None,
|
|
fill: None,
|
|
},
|
|
subtasks: vec![Subtask {
|
|
id: "rev-chart".into(),
|
|
label: "Revenue Chart".into(),
|
|
region: Region {
|
|
width: 800.0,
|
|
height: 50.0,
|
|
},
|
|
id_prefix: String::new(),
|
|
parent_frame_id: None,
|
|
elements: None,
|
|
screen: None,
|
|
generated_root_id: None,
|
|
existing_section_labels: None,
|
|
}],
|
|
style_guide_name: None,
|
|
};
|
|
normalize(&mut p, &dash_req("data analytics admin dashboard"));
|
|
assert_eq!(
|
|
p.subtasks[0].region.height,
|
|
(320.0_f64 * 0.6).round(),
|
|
"too-short LLM height clamped to min"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_non_dashboard_plan_is_unaffected() {
|
|
// A landing page prompt — NOT dashboard-like — must not rewrite regions.
|
|
let mut p = plan(
|
|
1200.0,
|
|
vec![
|
|
subtask("hero", "Hero Section"),
|
|
subtask("features", "Feature Cards"),
|
|
],
|
|
);
|
|
// Give them non-zero regions so default-fill doesn't fire.
|
|
p.subtasks[0].region = Region {
|
|
width: 1200.0,
|
|
height: 560.0,
|
|
};
|
|
p.subtasks[1].region = Region {
|
|
width: 1200.0,
|
|
height: 400.0,
|
|
};
|
|
|
|
normalize(
|
|
&mut p,
|
|
&dash_req("a beautiful landing page for a SaaS product"),
|
|
);
|
|
|
|
assert_eq!(p.subtasks[0].region.width, 1200.0);
|
|
assert_eq!(p.subtasks[0].region.height, 560.0);
|
|
assert_eq!(p.subtasks[1].region.width, 1200.0);
|
|
assert_eq!(p.subtasks[1].region.height, 400.0);
|
|
}
|
|
}
|