openpencil/crates/op-orchestrator/src/variables.rs
Kayshen-X b1aebc3252 feat(ai): agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-06-25 00:12:45 +08:00

343 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 设计变量 seed —— 语义调色板播种 / 快照 / 回滚。
//!
//! 编排开始时把 56-token 语义调色板([`crate::semantic_palette`],
//! TS `applySemanticPalette` 的 Rust 移植)播进文档,让 theme="system"
//! 元素发出的 `$color-*` 引用在 paint 时能解析(此前 seeding 休眠,
//! 悬空引用渲染灰底)。
//!
//! 合并语义:**已有者赢**(对齐 TS)。`set_variables_bulk(merge)` 在
//! editor-core 侧是 `.extend`(新值赢),所以这里先用快照算出缺失集,
//! seed 只发缺失的 token / 轴 —— 文档里已自定义的 `color-accent`
//! 永远不被覆盖。
//!
//! 当 plan 带有 styleGuideName 时,先播种缺省语义 palette,再用该
//! guide 的颜色改写本次新建的同义 token;已有用户变量仍然不覆盖。
use crate::plan::OrchestratorPlan;
use crate::semantic_palette;
use crate::types::DocSink;
use jian_ops_schema::variable::{VariableDefinition, VariableScalar, VariableValue};
use op_ai_skills::style_guide::{
extract_style_guide_values, select_style_guide, style_guide_registry, SelectOptions,
StyleGuideValues,
};
use op_editor_core::EditorCommand;
use std::collections::BTreeMap;
/// 回滚快照 —— seed 前"不存在"的调色板变量名 / 主题轴集合(即 seed
/// 会真正新建的那批)。
#[derive(Debug, Clone, Default)]
pub struct VarSnapshot {
/// seed 前不存在的变量名 —— 回滚时删除这些。
pub created: Vec<String>,
/// seed 前不存在的主题轴 —— 回滚时从 themes 里剔除。
pub created_axes: Vec<String>,
}
/// 在 seed *之前* 调用:对照现有文档变量/主题轴,算出调色板里缺失
/// 的部分。快照同时是 [`seed_commands`] 的输入(只播缺失项)和
/// [`rollback`] 的依据(只删自己新建的)。
pub fn snapshot_plan_vars(sink: &dyn DocSink, _plan: &OrchestratorPlan) -> VarSnapshot {
let doc = &sink.state().doc;
let existing_vars = doc.variables.as_ref();
let created = semantic_palette::palette_names()
.into_iter()
.filter(|name| !existing_vars.is_some_and(|vars| vars.contains_key(*name)))
.map(str::to_string)
.collect();
let existing_axes = doc.themes.as_ref();
let created_axes = semantic_palette::palette_themes()
.into_keys()
.filter(|axis| !existing_axes.is_some_and(|axes| axes.contains_key(axis)))
.collect();
VarSnapshot {
created,
created_axes,
}
}
/// 缺失的调色板 token / 轴 → 一条 `MergeThemePreset`。全部已存在时
/// 返回空(完全播种过的文档零命令)。
pub fn seed_commands(plan: &OrchestratorPlan, snap: &VarSnapshot) -> Vec<EditorCommand> {
if snap.created.is_empty() && snap.created_axes.is_empty() {
return Vec::new();
}
let mut palette = semantic_palette::palette_variables();
let style_values = selected_style_guide_values(plan);
// Harmonize the cool-slate neutral ramp to the design's temperature: a warm
// page (cream/orange) gets warm grays so the search bar / chips / avatar
// circle no longer read as off-palette cool gray. Neutral pages keep slate.
let harmonize_bg = style_values
.as_ref()
.and_then(|values| values.colors.background.clone())
.or_else(|| plan.root_frame.first_solid_hex());
if let Some(page_bg) = harmonize_bg {
crate::palette_harmonize::harmonize_palette_neutrals(&mut palette, &page_bg);
}
if let Some(values) = style_values.as_ref() {
apply_style_guide_palette(&mut palette, values);
}
let variables = snap
.created
.iter()
.filter_map(|name| palette.get(name).map(|def| (name.clone(), def.clone())))
.collect();
let themes = semantic_palette::palette_themes()
.into_iter()
.filter(|(axis, _)| snap.created_axes.contains(axis))
.collect();
vec![EditorCommand::MergeThemePreset { variables, themes }]
}
fn selected_style_guide_values(plan: &OrchestratorPlan) -> Option<StyleGuideValues> {
let name = plan.style_guide_name.as_ref()?;
let guide = select_style_guide(
style_guide_registry(),
&SelectOptions {
name: Some(name.clone()),
..Default::default()
},
)?;
Some(extract_style_guide_values(&guide.content))
}
fn apply_style_guide_palette(
palette: &mut BTreeMap<String, VariableDefinition>,
values: &StyleGuideValues,
) {
set_light_color(
palette,
"color-bg-deep",
values.colors.background.as_deref(),
);
set_light_color(palette, "color-surface", values.colors.surface.as_deref());
set_light_color(palette, "color-accent", values.colors.accent.as_deref());
set_light_color(
palette,
"color-text-primary",
values.colors.text_primary.as_deref(),
);
set_light_color(
palette,
"color-text-body",
values.colors.text_secondary.as_deref(),
);
set_light_color(
palette,
"color-text-muted",
values.colors.text_muted.as_deref(),
);
set_light_color(palette, "color-border", values.colors.border.as_deref());
}
fn set_light_color(
palette: &mut BTreeMap<String, VariableDefinition>,
name: &str,
color: Option<&str>,
) {
let Some(color) = color else {
return;
};
let Some(def) = palette.get_mut(name) else {
return;
};
match &mut def.value {
VariableValue::Themed(values) => {
if let Some(value) = values.iter_mut().find(|value| {
value
.theme
.as_ref()
.and_then(|theme| theme.get(semantic_palette::THEME_AXIS))
.is_some_and(|mode| mode == semantic_palette::THEME_LIGHT)
}) {
value.value = VariableScalar::Str(color.to_string());
}
}
VariableValue::Scalar(VariableScalar::Str(existing)) => {
*existing = color.to_string();
}
_ => {}
}
}
/// 回滚 seed 新建的变量与主题轴(已有者从未被覆盖,所以恢复 = 删除
/// 新建项)。
pub fn rollback(sink: &mut dyn DocSink, snap: &VarSnapshot) {
for name in &snap.created {
sink.apply(EditorCommand::DeleteVariable { name: name.clone() });
}
if !snap.created_axes.is_empty() {
let themes = sink
.state()
.doc
.themes
.clone()
.unwrap_or_default()
.into_iter()
.filter(|(axis, _)| !snap.created_axes.contains(axis))
.collect();
sink.apply(EditorCommand::SetThemes {
themes,
replace: true,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::VecDocSink;
use jian_ops_schema::variable::{
VariableDefinition, VariableKind, VariableScalar, VariableValue,
};
fn plan() -> OrchestratorPlan {
crate::plan::build_fallback_plan(&crate::types::DesignRequest {
prompt: "a page".into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
})
}
fn warm_food_plan() -> OrchestratorPlan {
let mut plan = plan();
plan.style_guide_name = Some("warm-food-mobile-light".into());
plan
}
fn light_hex(def: &VariableDefinition) -> String {
match &def.value {
VariableValue::Themed(values) => values
.iter()
.find(|v| {
v.theme
.as_ref()
.and_then(|theme| theme.get("Mode"))
.is_some_and(|mode| mode == "Light")
})
.and_then(|v| match &v.value {
VariableScalar::Str(s) => Some(s.clone()),
_ => None,
})
.expect("light color value"),
VariableValue::Scalar(VariableScalar::Str(s)) => s.clone(),
_ => panic!("expected color string"),
}
}
/// 空文档:快照报告全部 56 token + Mode 轴缺失,seed 出一条
/// MergeThemePreset 把它们全部带上。
#[test]
fn seed_on_empty_doc_carries_full_palette() {
let sink = VecDocSink::new();
let snap = snapshot_plan_vars(&sink, &plan());
assert_eq!(snap.created.len(), 56);
assert_eq!(snap.created_axes, vec!["Mode".to_string()]);
let cmds = seed_commands(&plan(), &snap);
assert_eq!(cmds.len(), 1);
let EditorCommand::MergeThemePreset { variables, themes } = &cmds[0] else {
panic!("expected MergeThemePreset, got {:?}", cmds[0]);
};
assert_eq!(variables.len(), 56);
assert!(variables.contains_key("color-accent"));
assert_eq!(themes.get("Mode").map(Vec::len), Some(2));
}
#[test]
fn seed_uses_selected_style_guide_palette_for_new_variables() {
let sink = VecDocSink::new();
let plan = warm_food_plan();
let snap = snapshot_plan_vars(&sink, &plan);
let cmds = seed_commands(&plan, &snap);
let EditorCommand::MergeThemePreset { variables, .. } = &cmds[0] else {
panic!("expected MergeThemePreset");
};
assert_eq!(
light_hex(variables.get("color-accent").expect("color-accent")),
"#FF5A1F"
);
}
/// 已有同名变量获胜(TS applySemanticPalette 语义):快照不把它
/// 计入 created,seed 不发它 —— editor-core 的 merge 是新值赢,
/// 守住"已有者赢"全靠这里不发。
#[test]
fn seed_respects_existing_variables_and_axes() {
let mut sink = VecDocSink::new();
sink.state.doc.variables = Some(
[(
"color-accent".to_string(),
VariableDefinition {
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#FF00FF".into())),
},
)]
.into(),
);
sink.state.doc.themes = Some([("Mode".to_string(), vec!["Light".to_string()])].into());
let snap = snapshot_plan_vars(&sink, &plan());
assert_eq!(snap.created.len(), 55);
assert!(!snap.created.contains(&"color-accent".to_string()));
assert!(snap.created_axes.is_empty(), "Mode axis pre-exists");
let cmds = seed_commands(&plan(), &snap);
let EditorCommand::MergeThemePreset { variables, themes } = &cmds[0] else {
panic!("expected MergeThemePreset");
};
assert!(!variables.contains_key("color-accent"));
assert!(themes.is_empty());
}
/// 全量播种过的文档:零命令。
#[test]
fn seed_is_noop_when_fully_seeded() {
let mut sink = VecDocSink::new();
sink.state.doc.variables = Some(
crate::semantic_palette::palette_variables()
.into_iter()
.collect(),
);
sink.state.doc.themes = Some(crate::semantic_palette::palette_themes());
let snap = snapshot_plan_vars(&sink, &plan());
assert!(snap.created.is_empty());
assert!(snap.created_axes.is_empty());
assert!(seed_commands(&plan(), &snap).is_empty());
}
/// 回滚删除新建变量,并把新建的 Mode 轴从 themes 里剔除。
#[test]
fn rollback_deletes_created_vars_and_axes() {
let mut sink = VecDocSink::new();
let snap = VarSnapshot {
created: vec!["color-accent".to_string()],
created_axes: vec!["Mode".to_string()],
};
rollback(&mut sink, &snap);
assert!(sink.applied.iter().any(|c| matches!(
c,
EditorCommand::DeleteVariable { name } if name == "color-accent"
)));
assert!(sink.applied.iter().any(|c| matches!(
c,
EditorCommand::SetThemes { themes, replace: true } if !themes.contains_key("Mode")
)));
}
#[test]
fn rollback_of_empty_snapshot_is_noop() {
let mut sink = VecDocSink::new();
rollback(&mut sink, &VarSnapshot::default());
assert!(sink.applied.is_empty());
}
}