openpencil/crates/op-host-services/src/chat_intent.rs

1106 lines
40 KiB
Rust

//! CLI standard-mode chat routing — three-way intent classification
//! plus the DESIGN_MODIFY / append-to-document flows (GAP #33).
//!
//! Ports the TS standard-mode pipeline that runs for external CLI
//! providers (builtin / ACP turns return early in both stacks):
//!
//! - `apps/web/src/components/panels/ai-chat-intent-classifier.ts`
//! — `classifyIntent` (LLM call, 8s abort, fallback `new`) and
//! `classifyByKeywords`, both verbatim.
//! - `apps/web/src/components/panels/ai-chat-handlers.ts:693-776`
//! — modify-vs-new degrade rules, `generateDesignModification`
//! target selection, the design/chat dispatch.
//! - `apps/web/src/services/ai/design-generator.ts:95-173`
//! — `generateDesignModification` (maintenance skills + design-md
//! policy system prompt, `CONTEXT NODES / INSTRUCTION` user
//! message, variable context, parse-or-error semantics).
//! - `apps/web/src/services/ai/design-generator.ts:43-72`
//! — `buildVariableContext`.
//! - `apps/web/src/services/ai/append-intent-detector.ts`
//! — `detectAppendIntent` (append keywords, new-screen veto,
//! content-root pick, status-bar filter).
//!
//! Threading: classification needs an LLM round-trip, so the whole
//! route decision runs on a worker thread ([`run_cli_turn`]).
//! `chat_session::launch_if_pending` pre-builds every route's inputs
//! and channels on the UI thread, then parks BOTH a `ChatSession`
//! and a `DesignSession` on the app; the worker uses whichever route
//! the classifier picks and drops the other route's senders so its
//! session pump cleans up.
//!
//! Documented divergences from TS (file:line in the report):
//! - TS replaces the "Checking guidelines" step text with the raw
//! modification response; Rust deltas append, so the step line
//! stays above the response.
//! - TS wraps the modification apply in one history batch; Rust
//! applies per-node through the MCP tool path (same granularity
//! as the Rust design pipeline until host batch mode lands).
//! - Node parsing replays modify-only `I(parent, {...})` scripts with
//! `op_mcp::script_runner::run_script_to_program`, preserving authored ids
//! for recursive diff application, then falls back to
//! `op_orchestrator::parse::parse_nodes` for legacy flat JSON output.
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::time::{Duration, Instant};
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, ChatToolExecutor, StopReason};
use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_core::EditorState;
use op_orchestrator::{AppendContext, DesignRequest};
use crate::chat_canvas_tools::UiChatToolExecutor;
use crate::chat_provider_llm::ChatProviderLlmClient;
use crate::design_session::{run_design_worker, DesignCmdReq, DesignDelta};
/// Internal host-op name the modify worker sends over the chat tool
/// channel; intercepted by `chat_session::drain_tool_requests` (never
/// advertised to any model).
pub const APPLY_MODIFICATION_OP: &str = "__apply_design_modification";
/// TS `classifyIntent` abort budget (`ai-chat-intent-classifier.ts:26`).
const CLASSIFY_TIMEOUT: Duration = Duration::from_secs(8);
/// TS `DesignIntent = 'new' | 'modify' | 'chat'`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesignIntent {
New,
Modify,
Chat,
}
/// TS `CLASSIFY_PROMPT` — verbatim.
const CLASSIFY_PROMPT: &str = "You are a UI design tool assistant. Classify the user's message intent.
Reply with EXACTLY one of these tags, nothing else:
- DESIGN_NEW — user wants to create or generate a NEW design, screen, page, or component from scratch
- DESIGN_MODIFY — user wants to modify, adjust, refine, or iterate on an EXISTING design (e.g. change colors, resize, restyle, add/remove elements)
- CHAT — user is asking a question, seeking help, or having a conversation";
/// TS `MODIFY_KEYWORDS` alternatives (the `\b(...)\b/i` regex), plus
/// Rust-shell CJK shorthands used before an LLM route decision.
const MODIFY_KEYWORDS: &[&str] = &[
"change", "modify", "update", "adjust", "resize", "move", "restyle", "refine", "fix", "tweak",
"edit", "replace", "remove", "delete", "add to", "smaller", "larger", "bigger", "wider",
"taller",
];
const MODIFY_CJK: &[&str] = &[
"修改",
"改成",
"改为",
"改一下",
"调整",
"修复",
"替换",
"换成",
"变成",
"删除",
"移除",
"小一点",
"大一点",
];
/// TS `CHAT_KEYWORDS` alternatives.
const CHAT_KEYWORDS: &[&str] = &[
"what is", "how do", "explain", "tell me", "help", "why", "can you", "question", "describe",
];
const CHAT_CJK: &[&str] = &["是什么", "什么", "怎么", "为什么", "解释", "说明", "帮助"];
fn is_word_char(c: char) -> bool {
// JS `\w` — ASCII alphanumeric plus underscore.
c.is_ascii_alphanumeric() || c == '_'
}
/// `\b<phrase>\b` case-insensitive matcher over a (possibly
/// multi-word) literal phrase. Whitespace runs in the haystack are
/// treated as single separators so `\s+`-joined phrases still match.
fn matches_word_phrase(text_lower: &str, phrase: &str) -> bool {
let haystack: String = text_lower.split_whitespace().collect::<Vec<_>>().join(" ");
let mut start = 0;
while let Some(pos) = haystack[start..].find(phrase) {
let abs = start + pos;
let before_ok = haystack[..abs]
.chars()
.next_back()
.is_none_or(|c| !is_word_char(c));
let after_ok = haystack[abs + phrase.len()..]
.chars()
.next()
.is_none_or(|c| !is_word_char(c));
if before_ok && after_ok {
return true;
}
start = abs + 1;
}
false
}
fn matches_any_word_phrase(text_lower: &str, phrases: &[&str]) -> bool {
phrases.iter().any(|p| matches_word_phrase(text_lower, p))
}
/// TS `classifyByKeywords` — verbatim rule order.
pub fn classify_by_keywords(text: &str) -> DesignIntent {
let lower = text.to_lowercase();
let chat =
matches_any_word_phrase(&lower, CHAT_KEYWORDS) || CHAT_CJK.iter().any(|k| text.contains(k));
let modify = matches_any_word_phrase(&lower, MODIFY_KEYWORDS)
|| MODIFY_CJK.iter().any(|k| text.contains(k));
if chat && !modify {
return DesignIntent::Chat;
}
if modify {
return DesignIntent::Modify;
}
DesignIntent::New
}
pub fn looks_like_modify_request(text: &str) -> bool {
classify_by_keywords(text) == DesignIntent::Modify
}
/// Standard-mode routing should not let a lightweight classifier
/// overrule explicit edit wording. Providers like Codex / Claude Code
/// can be conservative and answer DESIGN_NEW for terse CJK edit
/// prompts; keep those on the modify path before asking the model.
pub fn classify_intent_for_standard_route(
provider: &dyn ChatProvider,
state: &EditorState,
text: &str,
model: Option<String>,
) -> DesignIntent {
// A whole-screen *draw* (creation verb + page noun, e.g. "重新画一个
// search 页面") is unambiguously a new screen — it must win over the
// modify classifier so it routes to the new-frame path, not edit-in-place.
// It already excludes existing-screen context ("把发现页改成深色" has no
// creation verb), so genuine edits still fall through to Modify below.
if requests_new_whole_screen(text) {
return DesignIntent::New;
}
let keyword_intent = classify_by_keywords(text);
if !state.selection.set.is_empty() && keyword_intent == DesignIntent::Chat {
return DesignIntent::Chat;
}
// A selection biases toward modify — UNLESS the prompt is itself a
// whole-screen design request (a stray selection must not hijack "Design
// a … page" into an edit; `requests_new_whole_screen` misses these when
// they mention "section", so check the veto-free creation signal too).
let selected_target_instruction = !state.selection.set.is_empty()
&& keyword_intent != DesignIntent::Chat
&& !has_new_screen_creation_signal(text);
if keyword_intent == DesignIntent::Modify || selected_target_instruction {
return DesignIntent::Modify;
}
if is_named_follow_on_screen(text) {
return DesignIntent::New;
}
classify_intent_llm(provider, text, model)
}
/// TS classification-tag parsing (`ai-chat-intent-classifier.ts:46-51`).
pub fn parse_classified(text: &str) -> DesignIntent {
let upper = text.trim().to_uppercase();
if upper.contains("DESIGN_MODIFY") {
return DesignIntent::Modify;
}
if upper.contains("DESIGN_NEW") || upper.contains("DESIGN") {
return DesignIntent::New;
}
if upper.contains("CHAT") {
return DesignIntent::Chat;
}
DesignIntent::New
}
/// TS `classifyIntent` — one lightweight LLM call through the (chat-
/// session-untracked) provider, with the TS 8s abort and the TS
/// fallback to `new` on any failure / timeout.
pub fn classify_intent_llm(
provider: &dyn ChatProvider,
text: &str,
model: Option<String>,
) -> DesignIntent {
classify_intent_llm_with_timeout(provider, text, model, CLASSIFY_TIMEOUT)
}
fn classify_intent_llm_with_timeout(
provider: &dyn ChatProvider,
text: &str,
model: Option<String>,
timeout: Duration,
) -> DesignIntent {
let req = ChatRequest {
system_prompt: CLASSIFY_PROMPT.to_string(),
user_message: text.to_string(),
max_output_tokens: 4096,
model,
..Default::default()
};
// `provider.send` returns a blocking iterator. We are already on
// the router worker thread, but the 8s budget needs a timed recv,
// so the drain rides one more (detached) thread; dropping `rx`
// after a timeout makes its sends fail and the drain unwind.
let iter = provider.send(req);
let (tx, rx) = mpsc::channel::<ChatDelta>();
std::thread::Builder::new()
.name("op-chat-classify".into())
.spawn(move || {
for delta in iter {
if tx.send(delta).is_err() {
return;
}
}
})
.expect("spawn op-chat-classify thread");
let deadline = Instant::now() + timeout;
let mut out = String::new();
loop {
let now = Instant::now();
if now >= deadline {
// TS: AbortController fires → catch → { intent: 'new' }.
return DesignIntent::New;
}
match rx.recv_timeout(deadline - now) {
Ok(ChatDelta::TextDelta(s)) => out.push_str(&s),
// TS consumeSSEAsText only accumulates text chunks.
Ok(ChatDelta::Thinking(_)) | Ok(ChatDelta::ToolUse { .. }) => {}
// TS: `if (!response.ok) throw` → catch → 'new'.
Ok(ChatDelta::Error(_)) => return DesignIntent::New,
Ok(ChatDelta::Done { .. }) => break,
Err(mpsc::RecvTimeoutError::Timeout) => return DesignIntent::New,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
parse_classified(&out)
}
// ---------------------------------------------------------------------------
// Append-intent detection — port of append-intent-detector.ts
// ---------------------------------------------------------------------------
const APPEND_EN: &[&str] = &[
"continue",
"continuing",
"append",
"also add",
// `add (?:another|more|a new)\s+section` expanded.
"add another section",
"add more section",
"add a new section",
"one more",
"next section",
"add to the",
];
const APPEND_CJK: &[&str] = &[
"继续",
"接着",
"再加",
"再加一个",
"再添加",
"再生成",
"再来一个",
"补充",
"追加",
];
const NEW_SCREEN_EN: &[&str] = &[
"new page",
"new screen",
"new design",
"new mockup",
"another page",
"another screen",
"another design",
"another mockup",
"from scratch",
"brand new",
];
const NEW_SCREEN_CJK: &[&str] = &[
"新页面",
"新屏",
"新设计",
"从零",
"全新",
"另起",
"另外一页",
];
const NAMED_SCREEN_EN: &[&str] = &[
"discover page",
"discover screen",
"search page",
"search screen",
"orders page",
"orders screen",
"order page",
"order screen",
"profile page",
"profile screen",
"account page",
"account screen",
"favorites page",
"favorites screen",
"saved page",
"saved screen",
"detail page",
"detail screen",
"details page",
"details screen",
"cart page",
"cart screen",
"checkout page",
"checkout screen",
"category page",
"category screen",
"menu page",
"menu screen",
];
const NAMED_SCREEN_CJK: &[&str] = &[
"发现页",
"发现页面",
"搜索页",
"搜索页面",
"订单页",
"订单页面",
"我的页",
"我的页面",
"个人页",
"个人页面",
"账户页",
"账户页面",
"收藏页",
"收藏页面",
"详情页",
"详情页面",
"购物车页",
"购物车页面",
"结算页",
"结算页面",
"分类页",
"分类页面",
"菜单页",
"菜单页面",
];
/// TS `加一个.{0,6}(区块|栏|模块|section|段)` — "加一个" followed by one
/// of the suffixes within 0-6 characters.
fn matches_cjk_add_section(text: &str) -> bool {
const SUFFIXES: &[&str] = &["区块", "栏", "模块", "section", "段"];
let mut search = text;
while let Some(pos) = search.find("加一个") {
let after = &search[pos + "加一个".len()..];
// A suffix may start at char offsets 0..=6 after the head.
for (byte_idx, _) in after.char_indices().take(7) {
if SUFFIXES.iter().any(|s| after[byte_idx..].starts_with(s)) {
return true;
}
}
search = after;
}
false
}
pub fn is_named_follow_on_screen(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
matches_any_word_phrase(&lower, NAMED_SCREEN_EN)
|| NAMED_SCREEN_CJK.iter().any(|k| prompt.contains(k))
}
/// True when the appended unit is a *section* of the current screen rather
/// than a whole new screen — "继续加一个区块", "add another section". These
/// keep appending into the existing frame.
fn is_section_add_request(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
matches_cjk_add_section(prompt)
|| ["区块", "模块", "栏目"].iter().any(|k| prompt.contains(k))
|| lower.contains("section")
}
/// CJK verbs that mean "create a whole thing" (vs. editing an existing one).
/// Used to confirm a page/screen noun is the unit being *made*, not just
/// referenced — "画一个登录页" makes a page; "给页面加个按钮" edits one.
/// Deliberately excludes 加 / 整 — they appear inside 主页 / 整个 / 增加 and
/// would mis-fire on "给主页加个卡片" (add a card to the home page).
const DRAW_VERB_CJK: &[&str] = &["画", "绘", "做", "搞", "生成", "设计", "来"];
/// English creation verbs — the page/screen noun must be the thing being
/// *made*. Deliberately excludes the ambiguous "make" / "add" (which read as
/// edits in "make the page blue" / "add a button to the page"), so an English
/// EDIT that merely mentions a page/screen ("change the home page layout",
/// "resize the screen header") is NOT mistaken for a new-screen request.
const DRAW_VERB_EN: &[&str] = &[
"draw",
"create",
"design",
"generate",
"build",
"wireframe",
"mock up",
"mockup",
"sketch",
"prototype",
"lay out",
];
/// Markers that the request points at the CURRENT screen, so it is an edit of
/// the existing frame, not a new one.
const EXISTING_SCREEN_CTX_CJK: &[&str] = &["这个", "这一", "当前", "现有", "此页", "这页", "这屏"];
/// True when the unit being drawn is a whole *page / screen* — "继续画一下
/// search 页面", "再来一个登录页", "continue, add a settings screen". The named
/// list (`is_named_follow_on_screen`) only covers a fixed vocabulary in a
/// single language, so it misses generic or mixed-language phrasings like
/// "search 页面" (English noun + Chinese 页面). The user's rule is broader:
/// drawing a whole page/screen always becomes a NEW top-level frame placed to
/// the right, never rows appended into the current screen.
///
/// Guards against false positives: a section-add ("再加一个区块") keeps
/// appending, and an edit pointed at the current screen ("给这个页面加个按钮",
/// "重新画一下这个页面", "change the home page layout") is not a new screen.
/// CJK requires a creation verb on a page noun; English keys on the determiner
/// nearest the page noun (see [`english_requests_new_screen`]).
pub fn requests_new_whole_screen(prompt: &str) -> bool {
if is_section_add_request(prompt) {
return false;
}
// An edit pointed at the CURRENT CJK screen ("重新画一下这个页面") — the
// creation verb is present but 这个/当前 marks it as the existing one.
if EXISTING_SCREEN_CTX_CJK.iter().any(|k| prompt.contains(k)) {
return false;
}
let cjk_page = ["页面", "页", "屏幕", "屏"]
.iter()
.any(|k| prompt.contains(k));
if cjk_page && DRAW_VERB_CJK.iter().any(|v| prompt.contains(v)) {
return true;
}
english_requests_new_screen(&prompt.to_lowercase())
}
/// Core "this is a NEW screen/design" signal: a creation verb aimed at a
/// page/screen noun. Unlike [`requests_new_whole_screen`] it does NOT veto on
/// section-add phrasing — a full new-page spec that merely mentions the word
/// "section" ("Include a search section…", "Deals of the Week section") still
/// reads as new. Used to stop a stray canvas selection from hijacking a
/// whole-screen design prompt into the modify path (measured: a travel-app
/// design with a node selected routed to modify → M3 flat-JSONL → empty).
pub fn has_new_screen_creation_signal(prompt: &str) -> bool {
let cjk_page = ["页面", "页", "屏幕", "屏"]
.iter()
.any(|k| prompt.contains(k));
if cjk_page && DRAW_VERB_CJK.iter().any(|v| prompt.contains(v)) {
return true;
}
english_requests_new_screen(&prompt.to_lowercase())
}
/// English new-screen detection by the DETERMINER nearest a page/screen noun:
/// an indefinite article ("a / an / another / new") means a NEW page is being
/// made ("make a login page", "draw a checkout page"); a definite/demonstrative
/// ("the / this / that / current / existing") means an EXISTING page is being
/// referenced — an edit or a location ("change the home page", "create a button
/// on the login page" — here the page is where the button goes). Keyword
/// presence alone is too blunt: it both misses "make a login page" (a creation
/// verb the simple list excludes) and misroutes "create a button on the login
/// page" (verb + page noun, but the page is a location, not the object).
fn english_requests_new_screen(lower: &str) -> bool {
const NOUNS: &[&str] = &["page", "screen"];
const DEFINITE: &[&str] = &[
"the", "this", "that", "these", "those", "current", "existing", "same",
];
const INDEFINITE: &[&str] = &["a", "an", "another", "new", "fresh"];
let tokens: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.collect();
let mut saw_noun = false;
for (i, tok) in tokens.iter().enumerate() {
if !NOUNS.contains(tok) {
continue;
}
saw_noun = true;
// The NEAREST determiner within 4 tokens before the noun decides
// (adjectives like "login" / "checkout" sit between it and the noun).
for j in (i.saturating_sub(4)..i).rev() {
if DEFINITE.contains(&tokens[j]) {
return false;
}
if INDEFINITE.contains(&tokens[j]) {
return true;
}
}
}
// No determiner cue near a page noun: a strong creation verb still signals
// a new screen ("draw search page"); otherwise it's not a whole-screen req.
saw_noun && matches_any_word_phrase(lower, DRAW_VERB_EN)
}
fn is_new_screen_veto(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
matches_any_word_phrase(&lower, NEW_SCREEN_EN)
|| NEW_SCREEN_CJK.iter().any(|k| prompt.contains(k))
|| is_named_follow_on_screen(prompt)
|| requests_new_whole_screen(prompt)
}
/// TS `STATUS_BAR_RE = /(status[\s_-]*bar|system[\s_-]*chrome|状态栏|系统栏)/i`.
fn is_status_bar_like_text(text: &str) -> bool {
let lower = text.to_lowercase();
if lower.contains("状态栏") || lower.contains("系统栏") {
return true;
}
separated_pair(&lower, "status", "bar") || separated_pair(&lower, "system", "chrome")
}
/// `<head>[\s_-]*<tail>` scanner.
fn separated_pair(lower: &str, head: &str, tail: &str) -> bool {
let mut start = 0;
while let Some(pos) = lower[start..].find(head) {
let abs = start + pos + head.len();
let rest = lower[abs..].trim_start_matches([' ', '\t', '\n', '\r', '_', '-']);
if rest.starts_with(tail) {
return true;
}
start = start + pos + 1;
}
false
}
type PenNode = jian_ops_schema::node::PenNode;
fn is_frame(node: &PenNode) -> bool {
matches!(node, PenNode::Frame(_))
}
fn is_status_bar_like(node: &PenNode) -> bool {
let name = node.base().name.as_deref().unwrap_or("");
is_status_bar_like_text(&format!("{name} {}", node.id_str()))
}
fn node_label(node: &PenNode) -> String {
node.base()
.name
.clone()
.unwrap_or_else(|| node.id_str().to_string())
}
/// TS `pickContentRoot` — prefer a child frame named like a content
/// root, else the page frame itself.
fn pick_content_root(page: &PenNode) -> (&PenNode, Vec<String>) {
let children: &[PenNode] = page.children().map(Vec::as_slice).unwrap_or(&[]);
let content_frames: Vec<&PenNode> = children
.iter()
.filter(|n| is_frame(n) && !is_status_bar_like(n))
.collect();
const CONTENT_NAME: &[&str] = &["content", "main", "body", "root"];
let candidate = content_frames.iter().find(|f| {
let name = f.base().name.as_deref().unwrap_or("").to_lowercase();
matches_any_word_phrase(&name, CONTENT_NAME)
});
if let Some(candidate) = candidate {
let grand = candidate.children();
let labels = grand
.map(|kids| {
kids.iter()
.filter(|n| is_frame(n) && !is_status_bar_like(n))
.map(node_label)
.collect()
})
.unwrap_or_default();
return (candidate, labels);
}
(page, content_frames.iter().map(|n| node_label(n)).collect())
}
/// TS `detectAppendIntent` against the live editor state. The active
/// page frame is the first `Frame` among the active page's children
/// (TS `pickActivePageFrame` fallback branch — the Rust shell's
/// active page is always a real page entry, never a frame-as-page
/// alias).
pub fn detect_append_intent(state: &EditorState, prompt: &str) -> Option<AppendContext> {
if prompt.trim().is_empty() {
return None;
}
let lower = prompt.to_lowercase();
let has_append = matches_any_word_phrase(&lower, APPEND_EN)
|| APPEND_CJK.iter().any(|k| prompt.contains(k))
|| matches_cjk_add_section(prompt);
if !has_append {
return None;
}
if is_new_screen_veto(prompt) {
return None;
}
let page_frame = state.active_children().iter().find(|n| is_frame(n))?;
let page_has_content = page_frame
.children()
.is_some_and(|kids| kids.iter().any(|c| is_frame(c) && !is_status_bar_like(c)));
if !page_has_content {
return None;
}
let (target, section_labels) = pick_content_root(page_frame);
let width = page_frame.width_px().unwrap_or(375.0);
Some(AppendContext {
target_parent_id: target.id_str().to_string(),
target_width: target.width_px().unwrap_or(width),
existing_section_labels: section_labels,
is_mobile: width <= 480.0,
})
}
/// Design generation should ask the selected LLM to extract a design.md from
/// the current canvas for named follow-on pages (Discover / Orders / Profile,
/// etc.). A document-bound design.md wins, and append mode keeps its
/// append-specific context instead of creating a new sibling screen.
pub fn should_auto_generate_design_md(
state: &EditorState,
prompt: &str,
append_context: Option<&AppendContext>,
) -> bool {
state.doc.design_md.is_none()
&& append_context.is_none()
&& !state.active_children().is_empty()
&& (is_named_follow_on_screen(prompt) || requests_new_whole_screen(prompt))
}
// ---------------------------------------------------------------------------
// Modification plan — port of generateDesignModification's inputs
// ---------------------------------------------------------------------------
/// TS `buildVariableContext` (design-generator.ts:43-72). `None` when
/// the document has no variables. BTreeMap iteration is sorted where
/// TS uses insertion order — content is identical, ordering may not be.
pub fn build_variable_context(state: &EditorState) -> Option<String> {
let vars = state.doc.variables.as_ref().filter(|v| !v.is_empty())?;
let mut lines: Vec<String> = vec![
"DOCUMENT VARIABLES (use \"$name\" to reference, e.g. fill color \"$color-1\"):".into(),
];
for (name, def) in vars {
let kind = variable_kind_label(&def.kind);
match &def.value {
jian_ops_schema::variable::VariableValue::Themed(values) => {
let default_val = values
.first()
.map(|v| scalar_display(&v.value))
.unwrap_or_else(|| "?".into());
lines.push(format!(" - {name} ({kind}): {default_val} [themed]"));
}
jian_ops_schema::variable::VariableValue::Scalar(value) => {
lines.push(format!(" - {name} ({kind}): {}", scalar_display(value)));
}
}
}
if let Some(themes) = state.doc.themes.as_ref().filter(|t| !t.is_empty()) {
let summary = themes
.iter()
.map(|(axis, values)| format!("{axis}: [{}]", values.join(", ")))
.collect::<Vec<_>>()
.join("; ");
lines.push(format!("Themes: {summary}"));
}
Some(lines.join("\n"))
}
fn variable_kind_label(kind: &jian_ops_schema::variable::VariableKind) -> &'static str {
use jian_ops_schema::variable::VariableKind;
match kind {
VariableKind::Color => "color",
VariableKind::Number => "number",
VariableKind::String => "string",
VariableKind::Boolean => "boolean",
}
}
/// JS template-literal rendering of a variable scalar.
fn scalar_display(value: &jian_ops_schema::variable::VariableScalar) -> String {
use jian_ops_schema::variable::VariableScalar;
match value {
VariableScalar::Bool(b) => b.to_string(),
VariableScalar::Str(s) => s.clone(),
VariableScalar::Num(n) => {
if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e15 {
format!("{}", *n as i64)
} else {
format!("{n}")
}
}
}
}
/// Pre-built `generateDesignModification` request inputs.
pub struct ModifyPlan {
/// `CONTEXT NODES + INSTRUCTION (+ variable context)` user message.
pub user_message: String,
/// Maintenance skills (+ design-md style policy) system prompt.
pub system_prompt: String,
}
fn strip_base64_data_uris(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) if s.starts_with("data:") && s.contains(";base64,") => {
*s = "<image>".to_string();
}
serde_json::Value::Array(items) => {
for item in items {
strip_base64_data_uris(item);
}
}
serde_json::Value::Object(map) => {
for item in map.values_mut() {
strip_base64_data_uris(item);
}
}
_ => {}
}
}
pub(crate) fn parse_modify_nodes(
full_response: &str,
) -> Vec<crate::chat_canvas_tools::DesignModificationOp> {
let nodes = op_mcp::script_runner::run_script_to_program(full_response)
.ok()
.map(|program| {
op_mcp::parse_program_objects(&program)
.into_iter()
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !nodes.is_empty() {
return nodes;
}
op_orchestrator::parse::parse_nodes(full_response)
.unwrap_or_default()
.into_iter()
.map(|node| {
(
"null".to_string(),
serde_json::to_value(node).unwrap_or(serde_json::Value::Null),
)
})
.collect()
}
/// Build the modification plan: target selection per
/// `ai-chat-handlers.ts:709-719` (selected nodes, else last frame of
/// the page, else last page child), then the
/// `generateDesignModification` message/prompt assembly. `None` when
/// the page has no usable target (the caller degrades to `new`).
pub fn build_modify_plan(state: &EditorState, instruction: &str) -> Option<ModifyPlan> {
let children = state.active_children();
let mut targets: Vec<&PenNode> = Vec::new();
if !state.selection.set.is_empty() {
for id in &state.selection.set {
if let Some(node) = op_editor_core::walkers::find_node(children, id) {
targets.push(node);
}
}
} else {
let frames: Vec<&PenNode> = children.iter().filter(|n| is_frame(n)).collect();
if let Some(last_frame) = frames.last() {
targets.push(last_frame);
} else if let Some(last) = children.last() {
targets.push(last);
}
}
if targets.is_empty() && children.is_empty() {
return None;
}
let mut context = serde_json::to_value(&targets).ok()?;
strip_base64_data_uris(&mut context);
let context_json = serde_json::to_string(&context).ok()?;
let mut user_message = format!("CONTEXT NODES:\n{context_json}\n\nINSTRUCTION:\n{instruction}");
if let Some(var_context) = build_variable_context(state) {
user_message.push_str("\n\n");
user_message.push_str(&var_context);
}
// Maintenance-phase skills (TS resolveSkills('maintenance', …)).
let has_variables = state
.doc
.variables
.as_ref()
.is_some_and(|vars| !vars.is_empty());
let mut options = op_ai_skills::ResolveOptions::default();
options
.flags
.insert("hasVariables".to_string(), has_variables);
options
.flags
.insert("hasDesignMd".to_string(), state.doc.design_md.is_some());
let ctx = op_ai_skills::resolve_skills(op_ai_skills::Phase::Maintenance, instruction, &options);
let mut system_prompt = ctx
.skills
.iter()
.map(|s| s.content.as_str())
.collect::<Vec<_>>()
.join("\n\n");
if let Some(spec) = state.doc.design_md.as_ref() {
system_prompt.push_str("\n\n");
system_prompt.push_str(&op_orchestrator::build_design_md_style_policy(spec));
}
Some(ModifyPlan {
user_message,
system_prompt,
})
}
// ---------------------------------------------------------------------------
// Router worker
// ---------------------------------------------------------------------------
/// Everything the router worker needs, pre-computed on the UI thread.
pub struct CliTurnPlan {
pub user_text: String,
/// TS `pageChildren.length === 0` (modify degrades to new).
pub page_children_empty: bool,
/// Session-untracked transport for the classification call.
pub classify_provider: Box<dyn ChatProvider>,
/// Chat-session-tracked transport for the plain chat route.
pub chat_provider: Box<dyn ChatProvider>,
/// Session-untracked transport for the design / modify routes.
pub design_provider: Box<dyn ChatProvider>,
/// Fully-assembled plain-chat request (system prompt + history +
/// per-turn knobs + attachments).
pub chat_request: ChatRequest,
/// `generateDesignModification` request; `None` when the page had
/// no modification target (route degrades to new).
pub modify_request: Option<ChatRequest>,
/// Orchestrator request for the new-design route (append context
/// already detected + attached).
pub design_request: DesignRequest,
/// State snapshot for the design route's `RemoteDocSink` mirror.
pub initial_state: EditorState,
/// Host-owned indicator epoch shared with the parked `DesignSession`.
/// The worker registers frame/node indicators under this value and
/// `DesignSession::drop` clears that same run when Done / Stop /
/// New Chat retires the session.
pub indicator_epoch: u64,
pub model: Option<String>,
}
/// TS `ai-chat-handlers.ts:721-722` — the modification progress step.
const MODIFY_STEP: &str =
r#"<step title="Checking guidelines">Analyzing modification request...</step>"#;
const MODIFY_RETRY_REMINDER: &str =
"\n\nCRITICAL: Respond with ONLY I(...) JavaScript statements -- never prose, explanations, or numbered/bulleted lists. If you truly cannot make the change, return an empty program.";
struct ModifyTurnParse {
full_response: String,
nodes: Vec<crate::chat_canvas_tools::DesignModificationOp>,
stream_error: Option<String>,
}
fn applied_modify_nodes_json(
nodes: &[crate::chat_canvas_tools::DesignModificationOp],
) -> Option<String> {
let node_values = nodes
.iter()
.map(|(_, node)| node.clone())
.collect::<Vec<_>>();
if node_values.is_empty() {
return None;
}
serde_json::to_string_pretty(&node_values).ok()
}
fn stream_and_parse_modify_turn(
provider: &dyn ChatProvider,
request: ChatRequest,
) -> ModifyTurnParse {
let mut full_response = String::new();
let mut stream_error: Option<String> = None;
for delta in provider.send(request) {
match delta {
ChatDelta::TextDelta(s) => full_response.push_str(&s),
// TS: thinking chunks are ignored for modification — the
// caller already shows progress.
ChatDelta::Thinking(_) | ChatDelta::ToolUse { .. } => {}
ChatDelta::Error(msg) => {
stream_error = Some(msg);
break;
}
ChatDelta::Done { .. } => break,
}
}
// TS order: parse first; a stream error only surfaces when no
// nodes could be extracted (design-generator.ts:158-165).
let nodes = parse_modify_nodes(&full_response);
ModifyTurnParse {
full_response,
nodes,
stream_error,
}
}
fn with_modify_retry_reminder(mut request: ChatRequest) -> ChatRequest {
request.system_prompt.push_str(MODIFY_RETRY_REMINDER);
request
}
/// The TS degrade rules (`ai-chat-handlers.ts:700-705`): a modify
/// intent on an empty page becomes a new design, and `isModification`
/// additionally requires a usable target (here: a built modify plan —
/// after the empty-page degrade a surviving Modify always has one in
/// practice, so the plan check is a belt-and-braces guard).
fn resolve_route(
classified: DesignIntent,
page_children_empty: bool,
has_modify_plan: bool,
) -> DesignIntent {
match classified {
DesignIntent::Modify if page_children_empty => DesignIntent::New,
DesignIntent::Modify if !has_modify_plan => DesignIntent::New,
other => other,
}
}
/// Run one CLI standard-mode turn end-to-end on the worker thread:
/// classify, then route to chat / modification / design. The caller
/// parked a `ChatSession` on `chat_tx` + the tool channel behind
/// `executor`, and a `DesignSession` on `delta_tx` / `cmd_tx`; the
/// routes not taken drop their senders so the matching pump retires
/// its session.
pub fn run_cli_turn(
plan: CliTurnPlan,
chat_tx: Sender<ChatDelta>,
executor: UiChatToolExecutor,
delta_tx: Sender<DesignDelta>,
cmd_tx: Sender<DesignCmdReq>,
) {
let classified = classify_intent_for_standard_route(
plan.classify_provider.as_ref(),
&plan.initial_state,
&plan.user_text,
plan.model.clone(),
);
let modify_request = plan.modify_request;
let intent = resolve_route(
classified,
plan.page_children_empty,
modify_request.is_some(),
);
match intent {
DesignIntent::Chat => {
drop(delta_tx);
drop(cmd_tx);
drop(executor);
for delta in plan.chat_provider.send(plan.chat_request) {
if chat_tx.send(delta).is_err() {
return; // turn aborted (Stop / New Chat)
}
}
}
DesignIntent::Modify => {
drop(delta_tx);
drop(cmd_tx);
let request = modify_request.expect("checked above");
run_modify_turn(plan.design_provider.as_ref(), request, &chat_tx, &executor);
}
DesignIntent::New => {
// Hold the chat channel open for the duration so the
// trailing bubble keeps its streaming state while the
// design pumps fill it.
let _chat_hold = chat_tx;
drop(executor);
// Share one provider Arc between the design LLM and the
// (flag-gated) vision validator so the real Class-C loop reuses
// the user's selected auth/model.
let provider_arc: Arc<dyn op_ai::chat_provider::ChatProvider> =
Arc::from(plan.design_provider);
let llm = ChatProviderLlmClient::new(provider_arc.clone()).with_model(plan.model);
run_design_worker(
llm,
plan.design_request,
plan.initial_state,
delta_tx,
cmd_tx,
plan.indicator_epoch,
Some(provider_arc),
);
}
}
}
/// The DESIGN_MODIFY route — port of `generateDesignModification` +
/// `extractAndApplyDesignModification` + the handler glue
/// (`ai-chat-handlers.ts:708-741`, `design-generator.ts:95-173`).
pub fn run_modify_turn(
provider: &dyn ChatProvider,
request: ChatRequest,
chat_tx: &Sender<ChatDelta>,
executor: &UiChatToolExecutor,
) {
if chat_tx
.send(ChatDelta::TextDelta(MODIFY_STEP.to_string()))
.is_err()
{
return;
}
let mut parsed = stream_and_parse_modify_turn(provider, request.clone());
if parsed.nodes.is_empty() {
parsed = stream_and_parse_modify_turn(provider, with_modify_retry_reminder(request));
}
let ModifyTurnParse {
full_response,
nodes,
stream_error,
} = parsed;
if !nodes.is_empty() {
let args = serde_json::json!({
"nodes": &nodes,
});
let result = executor.execute(APPLY_MODIFICATION_OP, &args.to_string());
let applied = serde_json::from_str::<serde_json::Value>(&result.content)
.ok()
.and_then(|v| v.get("count").and_then(|c| c.as_u64()))
.unwrap_or(0);
if applied > 0 {
if let Some(json) = applied_modify_nodes_json(&nodes) {
if chat_tx
.send(ChatDelta::TextDelta(format!("\n```json\n{json}\n```")))
.is_err()
{
return;
}
}
// TS `ai-chat-handlers.ts:830-831`.
let _ = chat_tx.send(ChatDelta::TextDelta("\n\n<!-- APPLIED -->".to_string()));
} else if chat_tx
.send(ChatDelta::TextDelta(format!("\n{full_response}")))
.is_err()
{
return;
}
let _ = chat_tx.send(ChatDelta::Done {
stop_reason: StopReason::EndTurn,
});
return;
}
let message = if let Some(err) = stream_error {
err
} else {
"The model returned a description instead of an applyable edit. Try rephrasing (e.g. name the element to add) or run it again."
.to_string()
};
let _ = chat_tx.send(ChatDelta::Error(message));
let _ = chat_tx.send(ChatDelta::Done {
stop_reason: StopReason::Aborted,
});
}
#[cfg(test)]
#[path = "chat_intent_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "chat_intent_selection_tests.rs"]
mod selection_tests;