fix(ai): support script local edit apply
This commit is contained in:
parent
08b5be80b4
commit
4d9fabd6d1
|
|
@ -8,7 +8,7 @@ budget: 2000
|
|||
category: base
|
||||
---
|
||||
|
||||
You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
|
||||
You are a Design Modification Engine. Your job is to UPDATE existing PenNodes or ADD new PenNodes based on user instructions.
|
||||
|
||||
INPUT:
|
||||
|
||||
|
|
@ -17,20 +17,34 @@ INPUT:
|
|||
|
||||
OUTPUT:
|
||||
|
||||
- A JSON code block containing ONLY the modified PenNodes.
|
||||
- You MUST return the nodes with the SAME IDs as the input.
|
||||
- You MAY add/remove children if implied.
|
||||
- Output ONLY a JavaScript program for `batch_design` script-gen. Do not wrap it in markdown fences.
|
||||
- Emit one `I(parent, {node})` call per node that should be applied. The parent argument declares your intent.
|
||||
- To ADD a new element into an existing node, call `I("<existingParentId>", { ...new node... })`. The new node MUST have NO `id`. Emit ONLY the new element(s). Do NOT re-emit existing siblings. Use the real id of the existing container from CONTEXT NODES as the parent.
|
||||
- ADD example: `I("n217", {type:"frame", name:"Progress Bar", width:220, height:8, children:[]});`
|
||||
- To MODIFY or REGENERATE an existing node, call `I(null, {id:"<existingId>", ...the COMPLETE new version...})`. Emit the whole node. EVERY element must appear EXACTLY ONCE; never keep an old element at the top level AND also copy it into a new sub-container. If you restructure, MOVE elements into the new containers, do not clone them.
|
||||
- MODIFY / REGENERATE example: `I(null, {id:"n217", type:"frame", name:"Player", children:[{id:"n218", type:"text", name:"Title", content:"Updated"}]});`
|
||||
- You MAY include modified existing nodes (with the same IDs) and new nodes (with no IDs) in the same program when the user asks for both.
|
||||
- You MAY include children inside a node when needed.
|
||||
|
||||
RULES:
|
||||
|
||||
- PRESERVE IDs: The most important rule. If you return a node with a new ID, it will be treated as a new object. To update, you MUST match the input ID.
|
||||
- PARTIAL UPDATES: You can return the full node object with updated fields.
|
||||
- ADD NEW CONTENT: The instruction may ask to add a new element, section, bar, or other content. For adds, do NOT invent an ID; return a full new node object without `id` under the existing parent id.
|
||||
- COMPLETE REPLACEMENTS: For modify/regenerate, return the complete replacement node with its existing `id`.
|
||||
- DO NOT CHANGE UNRELATED PROPS: If the user says "change color", do not change the x/y position unless necessary.
|
||||
- DESIGN VARIABLES: When the user message includes a DOCUMENT VARIABLES section, prefer "$variableName" references over hardcoded values for matching properties. Only reference listed variables.
|
||||
- SCRIPT SYNTAX: Use the same `I(parent, obj)` syntax as the design agent. `parent` is `null` for modify/regenerate replacement, or an existing container id for add insertion. `I(...)` returns the inserted id string for newly inserted nodes.
|
||||
- PROPS: Node objects start with `type` (`"frame"`, `"text"`, `"rectangle"`, `"ellipse"`, `"path"`, `"icon_font"`) and use camelCase props such as `cornerRadius`, `fontSize`, `fontWeight`, `justifyContent`, `alignItems`, and `clipContent`.
|
||||
- SCRIPT LIMIT: Inside a script, `C`, `U`, `D`, `M`, `R`, `G`, and `console` are NO-OP stubs. They do not copy, update, delete, move, replace, fill, or log anything. `I(parent, obj)` is the ONLY call with real effect.
|
||||
- IMAGE SRC: Image `src` must stay as the existing value, or use a new `imageSearchQuery`. Never emit a base64 blob.
|
||||
- NO PROSE: Never answer with prose, an explanation, or a numbered/bulleted list such as "1. ...". Those cannot be applied and cause a hard failure.
|
||||
- EMPTY FALLBACK: If you cannot make the change, return an empty JavaScript program rather than prose.
|
||||
|
||||
RESPONSE FORMAT:
|
||||
|
||||
1. <step title="Checking guidelines">...</step>
|
||||
2. <step title="Design">...</step>
|
||||
3. `json [...nodes] `
|
||||
4. A very brief 1-sentence confirmation.
|
||||
Return only JavaScript statements like:
|
||||
|
||||
I("hero", {type:"text", name:"New Label", content:"Hello", fontSize:16});
|
||||
I(null, {id:"hero", type:"frame", name:"Hero", children:[{id:"title", type:"text", name:"Title", content:"Hello"}]});
|
||||
|
||||
Do not include steps, explanations, confirmations, markdown fences, or numbered/bulleted lists.
|
||||
|
|
|
|||
|
|
@ -334,6 +334,28 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_edit_skill_outputs_script_gen_protocol() {
|
||||
let skill = get_skill_by_name("local-edit").expect("local-edit skill must be registered");
|
||||
|
||||
assert!(
|
||||
skill.content.contains("JavaScript program"),
|
||||
"local-edit must request a script-gen JavaScript program"
|
||||
);
|
||||
assert!(
|
||||
skill.content.contains("I(parent") || skill.content.contains("I(null"),
|
||||
"local-edit must teach the I(parent, obj) call syntax"
|
||||
);
|
||||
assert!(
|
||||
skill.content.contains("NO-OP") || skill.content.contains("no-op"),
|
||||
"local-edit must explain script no-op stubs"
|
||||
);
|
||||
assert!(
|
||||
!skill.content.contains("json` code block"),
|
||||
"local-edit must not ask for the retired flat JSON code block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_agent_prompt_mentions_style_fetch() {
|
||||
let prompt = design_agent_system_prompt();
|
||||
|
|
|
|||
|
|
@ -80,10 +80,11 @@ pub struct Theme {
|
|||
/// surface (matches the TS app's `oklch(0.145 0 0)` ≈ `#252525`
|
||||
/// canvas tone, dialed back a touch for OP's chrome).
|
||||
pub canvas_surface: Color,
|
||||
/// Medium-gray fill for user message bubbles in the AI chat
|
||||
/// transcript (#27 style: distinct from the near-black panel bg,
|
||||
/// readable against white text). Dark ≈ #3a3a45, light ≈ #d0d0d8.
|
||||
/// Fill for user message bubbles in the AI chat transcript.
|
||||
/// Follows the overall neutral shell: a distinct graphite chip
|
||||
/// in both themes, with dedicated foreground for contrast.
|
||||
pub user_bubble: Color,
|
||||
pub user_bubble_foreground: Color,
|
||||
/// Success green for the ✓ ring on completed tool/step cards
|
||||
/// (#27 reference: ~#3FB950, matches GitHub's success green).
|
||||
pub status_success: Color,
|
||||
|
|
@ -99,32 +100,32 @@ impl Theme {
|
|||
/// hex parity isn't a goal, semantic parity is.
|
||||
pub const fn dark() -> Self {
|
||||
Self {
|
||||
background: rgb(0x0a, 0x0a, 0x0a),
|
||||
background: rgb(0x12, 0x12, 0x12),
|
||||
foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
card: rgb(0x14, 0x14, 0x14),
|
||||
card: rgb(0x1e, 0x1e, 0x1e),
|
||||
card_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
popover: rgb(0x1a, 0x1a, 0x1a),
|
||||
popover: rgb(0x18, 0x18, 0x18),
|
||||
popover_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
primary: rgb(0x3b, 0x82, 0xf6),
|
||||
primary_foreground: rgb(0xff, 0xff, 0xff),
|
||||
muted: rgb(0x1f, 0x1f, 0x1f),
|
||||
muted_foreground: rgb(0x9a, 0x9a, 0x9a),
|
||||
border: rgb(0x26, 0x26, 0x26),
|
||||
accent: rgb(0x26, 0x26, 0x26),
|
||||
muted: rgb(0x27, 0x27, 0x27),
|
||||
muted_foreground: rgb(0xa3, 0xa3, 0xa3),
|
||||
border: rgb(0x31, 0x31, 0x31),
|
||||
accent: rgb(0x2d, 0x2d, 0x2d),
|
||||
accent_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
destructive: rgb(0xef, 0x44, 0x44),
|
||||
destructive_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
secondary: rgb(0x26, 0x26, 0x26),
|
||||
secondary: rgb(0x2a, 0x2a, 0x2a),
|
||||
secondary_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
input: rgb(0x2a, 0x2a, 0x2a),
|
||||
input: rgb(0x34, 0x34, 0x34),
|
||||
ring: rgb(0x3b, 0x82, 0xf6),
|
||||
button_hover: rgba(0xff, 0xff, 0xff, 0.06),
|
||||
row_selected: rgb(0x26, 0x26, 0x26),
|
||||
row_selected_primary: rgba(0x3b, 0x82, 0xf6, 0.18),
|
||||
// Matches TS CANVAS_BACKGROUND_DARK (#1a1a1a).
|
||||
canvas_surface: rgb(0x1a, 0x1a, 0x1a),
|
||||
// Medium-gray: visible against white text, lighter than the dark panel.
|
||||
user_bubble: rgb(0x3a, 0x3a, 0x45),
|
||||
row_selected: rgb(0x30, 0x30, 0x30),
|
||||
row_selected_primary: rgba(0x3b, 0x82, 0xf6, 0.22),
|
||||
// Pencil-like dark canvas, kept slightly warmer/lighter than the shell.
|
||||
canvas_surface: rgb(0x1b, 0x1b, 0x1b),
|
||||
user_bubble: rgb(0x50, 0x52, 0x60),
|
||||
user_bubble_foreground: rgb(0xff, 0xff, 0xff),
|
||||
// GitHub-style success green ~#3FB950 for completed ✓ rings.
|
||||
status_success: rgb(0x3f, 0xb9, 0x50),
|
||||
// #FFD93D — warm yellow for the ⚡ speed chip icon + label.
|
||||
|
|
@ -137,33 +138,32 @@ impl Theme {
|
|||
/// Step 5 without another schema change.
|
||||
pub const fn light() -> Self {
|
||||
Self {
|
||||
background: rgb(0xff, 0xff, 0xff),
|
||||
foreground: rgb(0x0a, 0x0a, 0x0a),
|
||||
card: rgb(0xff, 0xff, 0xff),
|
||||
card_foreground: rgb(0x0a, 0x0a, 0x0a),
|
||||
background: rgb(0xef, 0xef, 0xef),
|
||||
foreground: rgb(0x1d, 0x1d, 0x1f),
|
||||
card: rgb(0xf7, 0xf7, 0xf7),
|
||||
card_foreground: rgb(0x1d, 0x1d, 0x1f),
|
||||
popover: rgb(0xff, 0xff, 0xff),
|
||||
popover_foreground: rgb(0x0a, 0x0a, 0x0a),
|
||||
popover_foreground: rgb(0x1d, 0x1d, 0x1f),
|
||||
primary: rgb(0x3b, 0x82, 0xf6),
|
||||
primary_foreground: rgb(0xff, 0xff, 0xff),
|
||||
muted: rgb(0xf5, 0xf5, 0xf5),
|
||||
muted_foreground: rgb(0x73, 0x73, 0x73),
|
||||
border: rgb(0xe5, 0xe5, 0xe5),
|
||||
accent: rgb(0xf5, 0xf5, 0xf5),
|
||||
accent_foreground: rgb(0x0a, 0x0a, 0x0a),
|
||||
muted: rgb(0xe9, 0xe9, 0xe9),
|
||||
muted_foreground: rgb(0x68, 0x68, 0x6d),
|
||||
border: rgb(0xd8, 0xd8, 0xda),
|
||||
accent: rgb(0xe8, 0xe8, 0xea),
|
||||
accent_foreground: rgb(0x1d, 0x1d, 0x1f),
|
||||
destructive: rgb(0xef, 0x44, 0x44),
|
||||
destructive_foreground: rgb(0xfa, 0xfa, 0xfa),
|
||||
secondary: rgb(0xf5, 0xf5, 0xf5),
|
||||
secondary_foreground: rgb(0x0a, 0x0a, 0x0a),
|
||||
input: rgb(0xe5, 0xe5, 0xe5),
|
||||
secondary: rgb(0xea, 0xea, 0xec),
|
||||
secondary_foreground: rgb(0x1d, 0x1d, 0x1f),
|
||||
input: rgb(0xe1, 0xe1, 0xe4),
|
||||
ring: rgb(0x3b, 0x82, 0xf6),
|
||||
button_hover: rgba(0x00, 0x00, 0x00, 0.06),
|
||||
row_selected: rgb(0xe5, 0xe5, 0xe5),
|
||||
row_selected_primary: rgba(0x3b, 0x82, 0xf6, 0.15),
|
||||
// Matches TS CANVAS_BACKGROUND_LIGHT (#e5e5e5) — a clear grey so the
|
||||
// white frame/page reads against the canvas instead of vanishing.
|
||||
canvas_surface: rgb(0xe5, 0xe5, 0xe5),
|
||||
// Light mode user bubble: a cool lavender-gray, legible on dark text.
|
||||
user_bubble: rgb(0xd0, 0xd0, 0xd8),
|
||||
row_selected: rgb(0xe4, 0xe4, 0xe7),
|
||||
row_selected_primary: rgba(0x3b, 0x82, 0xf6, 0.18),
|
||||
// Pencil-like light canvas, distinct from side panels but not stark white.
|
||||
canvas_surface: rgb(0xf3, 0xf3, 0xf3),
|
||||
user_bubble: rgb(0x5f, 0x62, 0x70),
|
||||
user_bubble_foreground: rgb(0xff, 0xff, 0xff),
|
||||
// Success green is the same in both themes.
|
||||
status_success: rgb(0x3f, 0xb9, 0x50),
|
||||
// Slightly muted gold for light backgrounds (same hue as dark).
|
||||
|
|
@ -200,6 +200,34 @@ mod tests {
|
|||
assert_eq!(d.primary.b, l.primary.b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_theme_uses_pencil_like_neutral_shell() {
|
||||
let t = Theme::light();
|
||||
assert!(t.background.r > 0.90 && t.background.r < 0.97);
|
||||
assert!(t.canvas_surface.r > t.background.r);
|
||||
assert!(t.card.r > t.background.r);
|
||||
assert!(t.border.r < t.card.r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_uses_pencil_like_neutral_shell() {
|
||||
let t = Theme::dark();
|
||||
assert!(t.background.r > 0.05 && t.background.r < t.card.r);
|
||||
assert!(t.canvas_surface.r > t.background.r);
|
||||
assert!(t.border.r > t.card.r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_user_bubble_is_a_contrasting_graphite_chip() {
|
||||
let t = Theme::light();
|
||||
assert!(
|
||||
t.user_bubble.r < 0.45 && t.user_bubble.g < 0.45 && t.user_bubble.b < 0.50,
|
||||
"light user bubble should read as a graphite chip: {:?}",
|
||||
t.user_bubble
|
||||
);
|
||||
assert_eq!(t.user_bubble_foreground, Color::WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_dark() {
|
||||
let t = Theme::default();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,61 @@ pub(crate) fn input_text_offset_at(
|
|||
))
|
||||
}
|
||||
|
||||
pub(crate) fn input_caret_rect(
|
||||
input: &TextInputState,
|
||||
input_rect: Rect,
|
||||
input_area_h: f32,
|
||||
) -> Rect {
|
||||
let text_rect = input_text_area_rect(input_rect, input.text(), input_area_h);
|
||||
let mut backend = MeasureOnlyBackend;
|
||||
let text_width = (text_rect.size.x - INPUT_TEXT_X_PAD * 2.0).max(0.0);
|
||||
let lines = TextArea::layout_lines(&mut backend, input.text(), INPUT_FONT, text_width);
|
||||
let visible_count = INPUT_MAX_LINES.min(lines.len()).max(1);
|
||||
let caret = jian_core::text_input::prev_char_boundary(input.text(), input.caret());
|
||||
let caret_line = caret_line_index(&lines, caret).unwrap_or(0);
|
||||
let visible_start = if lines.len() <= visible_count {
|
||||
0
|
||||
} else {
|
||||
caret_line
|
||||
.saturating_add(1)
|
||||
.saturating_sub(visible_count)
|
||||
.min(lines.len() - visible_count)
|
||||
};
|
||||
let line_i = caret_line.clamp(visible_start, visible_start + visible_count - 1);
|
||||
let line = &lines[line_i];
|
||||
let rel = caret.saturating_sub(line.start).min(line.text.len());
|
||||
let rel = jian_core::text_input::prev_char_boundary(&line.text, rel);
|
||||
let x = backend.measure_text_family(&line.text[..rel], INPUT_FONT, "Inter");
|
||||
let y = text_rect.origin.y + TEXT_AREA_PAD_Y + (line_i - visible_start) as f32 * line_height();
|
||||
Rect::xywh(
|
||||
text_rect.origin.x + INPUT_TEXT_X_PAD + x,
|
||||
y,
|
||||
1.5,
|
||||
INPUT_FONT + 3.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn line_height() -> f32 {
|
||||
INPUT_FONT * 1.35
|
||||
}
|
||||
|
||||
fn caret_line_index(
|
||||
lines: &[jian_widgets::components::text_area::TextLine],
|
||||
caret: usize,
|
||||
) -> Option<usize> {
|
||||
lines.iter().enumerate().position(|(i, line)| {
|
||||
if line.start == line.end {
|
||||
return caret == line.start;
|
||||
}
|
||||
let end_is_soft_wrap = lines.get(i + 1).is_some_and(|next| next.start == line.end);
|
||||
if end_is_soft_wrap {
|
||||
caret >= line.start && caret < line.end
|
||||
} else {
|
||||
caret >= line.start && caret <= line.end
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn paint_input_text_area(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::{Point2D, Rect, TextLayout};
|
|||
use jian_core::text_input::TextInputState;
|
||||
use jian_widgets::components::select::SelectState;
|
||||
use op_editor_core::chat::ChatState;
|
||||
use op_editor_core::EditorState;
|
||||
use op_editor_core::{EditorState, PenNodeExt};
|
||||
|
||||
pub const AI_CHAT_WIDTH: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_WIDTH;
|
||||
pub const AI_CHAT_HEIGHT: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_HEIGHT;
|
||||
|
|
@ -103,6 +103,9 @@ pub struct AIChatPlaceholder<'a> {
|
|||
/// Kept for future affordances; paint tests verify it is seeded correctly.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) selected_count: usize,
|
||||
/// Text painted in the selection chip: selected node name for a
|
||||
/// single resolved selection, count label for multi-selection.
|
||||
pub(crate) selected_label: Option<String>,
|
||||
/// Model-picker dropdown interaction state.
|
||||
pub model_picker: &'a SelectState,
|
||||
/// Text state for the model-picker search query.
|
||||
|
|
@ -178,6 +181,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
label_tip_select_elements: translate(ui, "ai.tipSelectElements").to_string(),
|
||||
label_no_models: translate(ui, "ai.noModelsConnected").to_string(),
|
||||
selected_count: state.selection_count(),
|
||||
selected_label: selection_chip_label_for_state(state),
|
||||
model_picker: &ui.chat_model_picker,
|
||||
model_picker_input: &ui.chat_model_picker_input,
|
||||
design_hover: ui.chat_design_block_hover,
|
||||
|
|
@ -225,8 +229,10 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
|
||||
fn selection_chip_label(&self) -> String {
|
||||
op_i18n::translate(self.locale, "common.selected")
|
||||
.replace("{{count}}", &self.selected_count.to_string())
|
||||
self.selected_label.clone().unwrap_or_else(|| {
|
||||
op_i18n::translate(self.locale, "common.selected")
|
||||
.replace("{{count}}", &self.selected_count.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn selection_chip_rect(&self, input_rect: Rect) -> Option<Rect> {
|
||||
|
|
@ -398,6 +404,54 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
size: Point2D::new(rect.size.x - PAD * 2.0, input_h),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_text_rect(&self, rect: Rect) -> Rect {
|
||||
let input_block = self.input_rect(rect);
|
||||
Rect {
|
||||
origin: Point2D::new(
|
||||
input_block.origin.x,
|
||||
input_block.origin.y + self.selection_chip_row_h(),
|
||||
),
|
||||
size: Point2D::new(input_block.size.x, self.input_area_height_for_rect(rect)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_caret_rect(&self, rect: Rect) -> Rect {
|
||||
let input_text = self.input_text_rect(rect);
|
||||
crate::widgets::ai_chat_input_text::input_caret_rect(
|
||||
&self.state.input,
|
||||
input_text,
|
||||
input_text.size.y,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_chip_label_for_state(state: &EditorState) -> Option<String> {
|
||||
match state.selection_count() {
|
||||
0 => None,
|
||||
1 => {
|
||||
let id = state.selection.set.first()?;
|
||||
if !id.is_real() {
|
||||
return None;
|
||||
}
|
||||
let node = op_editor_core::walkers::find_node(state.active_children(), id);
|
||||
Some(
|
||||
node.and_then(|node| {
|
||||
node.base()
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| id.as_str().to_string()),
|
||||
)
|
||||
}
|
||||
count => Some(
|
||||
op_i18n::translate(state.editor_ui.locale, "common.selected")
|
||||
.replace("{{count}}", &count.to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_selection_chip(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,23 @@ fn examples_grid_has_four_cards() {
|
|||
assert_eq!(example_cards(op_editor_core::Locale::EnUs).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_example_is_music_streaming_home_prompt() {
|
||||
let en = example_cards(op_editor_core::Locale::EnUs);
|
||||
assert_eq!(
|
||||
en[1].title,
|
||||
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
|
||||
);
|
||||
assert_eq!(en[1].prompt, en[1].title);
|
||||
|
||||
let zh = example_cards(op_editor_core::Locale::ZhCn);
|
||||
assert_eq!(
|
||||
zh[1].title,
|
||||
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
|
||||
);
|
||||
assert_eq!(zh[1].prompt, zh[1].title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_editor_tracks_selection_count_for_toolbar() {
|
||||
let mut s = EditorState::new();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,31 @@ fn panel_with_selection(count: usize) -> (EditorState, Rect) {
|
|||
(state, rect)
|
||||
}
|
||||
|
||||
fn panel_with_named_selection() -> (EditorState, Rect) {
|
||||
let mut state = EditorState::new();
|
||||
state.doc.children = vec![jian_ops_schema::node::PenNode::Rectangle(
|
||||
jian_ops_schema::node::RectangleNode {
|
||||
base: jian_ops_schema::node::PenNodeBase {
|
||||
id: "album".to_string(),
|
||||
name: Some("Album Art".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
container: jian_ops_schema::node::ContainerProps::default(),
|
||||
children: None,
|
||||
state: None,
|
||||
bindings: None,
|
||||
events: None,
|
||||
lifecycle: None,
|
||||
semantics: None,
|
||||
gestures: None,
|
||||
route: None,
|
||||
},
|
||||
)];
|
||||
state.set_single_selection(op_editor_core::NodeId::new("album"));
|
||||
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
|
||||
(state, rect)
|
||||
}
|
||||
|
||||
fn paint_panel(panel: &AIChatPlaceholder<'_>, rect: Rect) -> SelectedChipPaintBackend {
|
||||
let mut backend = SelectedChipPaintBackend::default();
|
||||
let mut cx = PaintCx {
|
||||
|
|
@ -84,6 +109,22 @@ fn selected_count_three_paints_chip_text() {
|
|||
.any(|(text, size, _, _)| text.contains('3') && (*size - 11.0).abs() < 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_selection_chip_paints_node_name_instead_of_count() {
|
||||
let (state, rect) = panel_with_named_selection();
|
||||
let panel = AIChatPlaceholder::from_editor(&state);
|
||||
|
||||
let backend = paint_panel(&panel, rect);
|
||||
assert!(backend
|
||||
.texts
|
||||
.iter()
|
||||
.any(|(text, size, _, _)| text == "Album Art" && (*size - 11.0).abs() < 1e-4));
|
||||
assert!(!backend
|
||||
.texts
|
||||
.iter()
|
||||
.any(|(text, _, _, _)| text == "1 selected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_chip_clear_hit_returns_clear_selection() {
|
||||
let (state, rect) = panel_with_selection(3);
|
||||
|
|
|
|||
|
|
@ -301,9 +301,9 @@ mod tests {
|
|||
fn make_examples() -> [ExampleCard; 4] {
|
||||
[
|
||||
ExampleCard {
|
||||
title: "Technical dashboard web app for a utilities company".into(),
|
||||
title: "Design a dark-themed music streaming mobile app home screen".into(),
|
||||
subtitle: "".into(),
|
||||
prompt: "Technical dashboard web app for a utilities company".into(),
|
||||
prompt: "Design a dark-themed music streaming mobile app home screen".into(),
|
||||
emoji: "",
|
||||
},
|
||||
ExampleCard {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ use super::ai_chat_transcript_completion::{
|
|||
completion_card_rect, paint_completion_card, parse_completion_summary, CompletionSummary,
|
||||
};
|
||||
use super::ai_chat_transcript_design::{
|
||||
extract_design_json_blocks, paint_design_block, place_design_blocks, DesignBlock,
|
||||
applied_design_block_label, extract_design_json_blocks, paint_design_block,
|
||||
place_design_blocks, DesignBlock,
|
||||
};
|
||||
pub(crate) use super::ai_chat_transcript_hit::{transcript_hit, TranscriptHit};
|
||||
use super::ai_chat_transcript_paint_parts::{paint_action_step, paint_collapsible};
|
||||
|
|
@ -243,6 +244,7 @@ fn build_item(
|
|||
if design_applied {
|
||||
for block in &mut pending_design_blocks {
|
||||
block.applied = true;
|
||||
block.label = applied_design_block_label(locale, block.element_count);
|
||||
}
|
||||
}
|
||||
// No streaming "Generating design..." placeholder card — the fixed
|
||||
|
|
@ -684,7 +686,7 @@ pub(crate) fn paint_transcript_with_selection(
|
|||
// #27 restyle: user bubble = medium-gray (theme.user_bubble),
|
||||
// assistant text = plain (no bubble background).
|
||||
let (bg, fg) = match item.role {
|
||||
ChatRole::User => (theme.user_bubble, theme.foreground),
|
||||
ChatRole::User => (theme.user_bubble, theme.user_bubble_foreground),
|
||||
ChatRole::Assistant => (theme.muted, theme.foreground),
|
||||
};
|
||||
if bubble.typing {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub(crate) struct DesignBlock {
|
|||
pub element_count: usize,
|
||||
pub label: String,
|
||||
pub streaming: bool,
|
||||
pub applied: bool,
|
||||
pub code: String,
|
||||
pub copy_visible: bool,
|
||||
pub code_lines: Vec<String>,
|
||||
|
|
@ -115,9 +116,108 @@ fn finish_code_block(
|
|||
|
||||
fn is_design_json(code: &str) -> bool {
|
||||
let trimmed = code.trim_start();
|
||||
(trimmed.starts_with('[') || trimmed.starts_with('{'))
|
||||
&& code.contains("\"type\"")
|
||||
&& code.contains("\"id\"")
|
||||
if !(trimmed.starts_with('[') || trimmed.starts_with('{')) || !code.contains("\"type\"") {
|
||||
return false;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(code) {
|
||||
return value_contains_design_node(&value);
|
||||
}
|
||||
extract_json_objects(trimmed)
|
||||
.map(|objects| objects.iter().any(value_contains_design_node))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn value_contains_design_node(value: &serde_json::Value) -> bool {
|
||||
match value {
|
||||
serde_json::Value::Array(items) => items.iter().any(value_contains_design_node),
|
||||
serde_json::Value::Object(object) => object_is_design_node(object),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_is_design_node(object: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
let Some(node_type) = object.get("type").and_then(serde_json::Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
object.contains_key("_parent")
|
||||
|| is_known_design_node_type(node_type)
|
||||
|| has_design_props(object)
|
||||
}
|
||||
|
||||
fn is_known_design_node_type(node_type: &str) -> bool {
|
||||
let normalized = node_type
|
||||
.chars()
|
||||
.filter(|ch| *ch != '_' && *ch != '-' && !ch.is_whitespace())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect::<String>();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"frame"
|
||||
| "group"
|
||||
| "rectangle"
|
||||
| "rect"
|
||||
| "ellipse"
|
||||
| "line"
|
||||
| "polygon"
|
||||
| "path"
|
||||
| "text"
|
||||
| "textinput"
|
||||
| "textarea"
|
||||
| "image"
|
||||
| "iconfont"
|
||||
| "select"
|
||||
| "switch"
|
||||
| "checkbox"
|
||||
| "slider"
|
||||
| "radiogroup"
|
||||
| "numberinput"
|
||||
| "progress"
|
||||
| "tabs"
|
||||
| "ref"
|
||||
)
|
||||
}
|
||||
|
||||
fn has_design_props(object: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
const DESIGN_PROPS: &[&str] = &[
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"children",
|
||||
"fill",
|
||||
"stroke",
|
||||
"cornerRadius",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textAlign",
|
||||
"content",
|
||||
"opacity",
|
||||
"rotation",
|
||||
"src",
|
||||
"points",
|
||||
"layoutMode",
|
||||
"primaryAxisSizingMode",
|
||||
"counterAxisSizingMode",
|
||||
];
|
||||
object
|
||||
.keys()
|
||||
.any(|key| DESIGN_PROPS.contains(&key.as_str()))
|
||||
}
|
||||
|
||||
pub(crate) fn applied_design_block_label(
|
||||
locale: op_editor_core::Locale,
|
||||
element_count: usize,
|
||||
) -> String {
|
||||
let prefix = op_i18n::translate(locale, "ai.modificationApplied");
|
||||
let unit = if element_count == 1 {
|
||||
op_i18n::translate(locale, "ai.designElement")
|
||||
} else {
|
||||
op_i18n::translate(locale, "ai.designElements")
|
||||
};
|
||||
format!("{prefix} · {element_count} {unit}")
|
||||
}
|
||||
|
||||
fn design_element_count(code: &str) -> usize {
|
||||
|
|
@ -387,6 +487,7 @@ pub(crate) fn place_design_blocks(
|
|||
element_count: pending.element_count,
|
||||
label: pending.label,
|
||||
streaming: pending.streaming,
|
||||
applied: pending.applied,
|
||||
code: pending.code,
|
||||
copy_visible: hovered_index == Some(index),
|
||||
code_lines,
|
||||
|
|
@ -418,7 +519,11 @@ pub(crate) fn paint_design_block(cx: &mut PaintCx<'_>, theme: &Theme, block: &De
|
|||
cx.backend.fill_round_rect(icon_bg_rect, 8.0, icon_bg);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
Icon::Wand2,
|
||||
if block.applied {
|
||||
Icon::Check
|
||||
} else {
|
||||
Icon::Wand2
|
||||
},
|
||||
Point2D::new(
|
||||
icon_bg_rect.origin.x + (DESIGN_ICON_BG - DESIGN_ICON_SIZE) / 2.0,
|
||||
icon_bg_rect.origin.y + (DESIGN_ICON_BG - DESIGN_ICON_SIZE) / 2.0,
|
||||
|
|
|
|||
|
|
@ -663,6 +663,63 @@ Applied to canvas."#,
|
|||
assert!(!visible_text.contains(r#""type":"Frame""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_applied_modify_json_without_ids_renders_localized_folded_card() {
|
||||
let mut message = ChatMessage::assistant(
|
||||
r#"```json
|
||||
[{"type":"text","name":"Caption","content":"Updated"}]
|
||||
```
|
||||
<!-- APPLIED -->"#,
|
||||
);
|
||||
|
||||
let items = build_transcript(
|
||||
std::slice::from_ref(&message),
|
||||
body(),
|
||||
op_editor_core::Locale::ZhCn,
|
||||
);
|
||||
let block = &items[0].design_blocks[0];
|
||||
|
||||
assert_eq!(items[0].design_blocks.len(), 1);
|
||||
assert_eq!(block.element_count, 1);
|
||||
assert_eq!(block.label, "已修改 · 1 元素");
|
||||
assert!(block.apply.is_none(), "applied cards must not offer Apply");
|
||||
assert!(!block.expanded, "applied cards are folded by default");
|
||||
|
||||
message.design_block_expanded_overrides = vec![Some(true)];
|
||||
let expanded = build_transcript(
|
||||
std::slice::from_ref(&message),
|
||||
body(),
|
||||
op_editor_core::Locale::ZhCn,
|
||||
);
|
||||
let block = &expanded[0].design_blocks[0];
|
||||
assert!(block.expanded, "applied cards remain expandable");
|
||||
assert!(block.body.size.y > 0.0);
|
||||
assert!(
|
||||
block.apply.is_none(),
|
||||
"expanded applied cards still omit Apply"
|
||||
);
|
||||
assert!(block.code_lines.iter().any(|line| line.contains("Caption")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_plain_json_with_type_is_not_a_design_block() {
|
||||
let message = ChatMessage::assistant(
|
||||
r#"```json
|
||||
{"id":"event-1","type":"audit","payload":{"ok":true}}
|
||||
```"#,
|
||||
);
|
||||
|
||||
let items = build_transcript(
|
||||
std::slice::from_ref(&message),
|
||||
body(),
|
||||
op_editor_core::Locale::EnUs,
|
||||
);
|
||||
|
||||
assert!(items[0].design_blocks.is_empty());
|
||||
let visible_text = items[0].bubble.as_ref().unwrap().lines.join("\n");
|
||||
assert!(visible_text.contains(r#""type":"audit""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_design_json_block_reserves_body_and_surfaces_code_like_ts() {
|
||||
let mut message = ChatMessage::assistant(
|
||||
|
|
@ -682,6 +739,10 @@ fn expanded_design_json_block_reserves_body_and_surfaces_code_like_ts() {
|
|||
assert!(block.expanded);
|
||||
assert!(block.rect.size.y > 32.0);
|
||||
assert!(block.body.size.y > 0.0);
|
||||
assert!(
|
||||
block.apply.is_some(),
|
||||
"generation cards keep the Apply button"
|
||||
);
|
||||
assert!(
|
||||
(block.body.origin.y - (block.header.origin.y + block.header.size.y + 4.0)).abs() < 1e-4,
|
||||
"TS expanded design cards put the JSON preview in a separate mt-1 body box"
|
||||
|
|
|
|||
|
|
@ -128,10 +128,9 @@ fn drain_tool_requests(
|
|||
continue;
|
||||
}
|
||||
if req.name == op_host_services::chat_intent::APPLY_MODIFICATION_OP {
|
||||
let nodes = serde_json::from_str::<serde_json::Value>(&req.args_json)
|
||||
.ok()
|
||||
.and_then(|v| v.get("nodes").and_then(|n| n.as_array().cloned()))
|
||||
.unwrap_or_default();
|
||||
let nodes = op_host_services::chat_canvas_tools::parse_design_modification_ops_arg(
|
||||
&req.args_json,
|
||||
);
|
||||
let (count, mutated) =
|
||||
op_host_services::chat_canvas_tools::apply_design_modification(state, &nodes);
|
||||
if mutated {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
//! - `apply_ime_commit` clears the preedit and lands the committed
|
||||
//! string through `apply_text` char-by-char, so every focus branch
|
||||
//! + per-field filter (numeric / hex drafts) applies unchanged.
|
||||
//! - `ime_anchor_rect` resolves the focused input's screen rect for
|
||||
//! - `ime_anchor_rect` resolves the focused input's caret rect for
|
||||
//! `set_ime_cursor_area`. v1 coverage: the chat input (precise).
|
||||
|
||||
use op_editor_ui::Rect;
|
||||
|
|
@ -87,8 +87,8 @@ impl WidgetHostNative {
|
|||
consumed
|
||||
}
|
||||
|
||||
/// Focused-input rect for candidate-window anchoring. v1: chat
|
||||
/// input only; `None` = fallback.
|
||||
/// Focused-input caret rect for candidate-window anchoring. v1:
|
||||
/// chat input only; `None` = fallback.
|
||||
pub fn ime_anchor_rect(&mut self, viewport_w: f32, viewport_h: f32) -> Option<Rect> {
|
||||
if self.editor_state.chat.focused {
|
||||
let chat_rect = self.ai_chat_rect(viewport_w, viewport_h)?;
|
||||
|
|
@ -96,7 +96,7 @@ impl WidgetHostNative {
|
|||
&self.editor_state,
|
||||
self.now_ms,
|
||||
);
|
||||
return Some(chat.input_rect(chat_rect));
|
||||
return Some(chat.input_caret_rect(chat_rect));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -191,4 +191,29 @@ mod tests {
|
|||
h.editor_state_mut().chat.focused = false;
|
||||
assert!(h.ime_anchor_rect(1200.0, 800.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_ime_anchor_tracks_input_caret() {
|
||||
let mut h = host();
|
||||
h.editor_state_mut().chat.focused = true;
|
||||
h.editor_state_mut().chat.set_input_text("abcd");
|
||||
h.editor_state_mut().chat.set_input_caret(0, 0);
|
||||
let start = h
|
||||
.ime_anchor_rect(1200.0, 800.0)
|
||||
.expect("chat focus should yield ime anchor");
|
||||
|
||||
h.editor_state_mut().chat.set_input_caret(3, 0);
|
||||
let after_three = h
|
||||
.ime_anchor_rect(1200.0, 800.0)
|
||||
.expect("chat focus should yield ime anchor");
|
||||
|
||||
assert!(
|
||||
after_three.origin.x > start.origin.x + 12.0,
|
||||
"expected IME anchor to move with caret: start={start:?}, after={after_three:?}"
|
||||
);
|
||||
assert!(
|
||||
after_three.size.x <= 4.0,
|
||||
"IME anchor should describe the caret, not the whole input: {after_three:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,16 @@
|
|||
//! executes via [`execute_chat_tool`] against the canonical state.
|
||||
|
||||
use op_ai::chat_provider::{ChatToolDef, ChatToolResult};
|
||||
use op_editor_core::EditorState;
|
||||
use op_editor_core::{EditorState, PenNodeExt};
|
||||
pub use op_editor_host_core::chat::{chat_tool_channel, ChatToolRequest, UiChatToolExecutor};
|
||||
use op_mcp::{ToolRegistry, ToolResponse};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// TS `maxTurns` for the chat agent loop (`ai-chat-handlers.ts:254`).
|
||||
pub const MAX_TOOL_TURNS: usize = 20;
|
||||
|
||||
pub type DesignModificationOp = (String, serde_json::Value);
|
||||
|
||||
/// The chat tool subset — the TS CRUD set (`getCrudToolDefs`) with the
|
||||
/// TS `TOOL_AUTH_MAP` auth levels. Design-pipeline tools
|
||||
/// (`generate_design` / `plan_layout` / `batch_insert`) are excluded:
|
||||
|
|
@ -111,13 +114,19 @@ pub fn execute_chat_tool(
|
|||
name: &str,
|
||||
args_json: &str,
|
||||
) -> (ChatToolResult, bool) {
|
||||
let Some(_level) = chat_tool_level(name) else {
|
||||
return (
|
||||
error_result(format!("tool not available in chat: {name}")),
|
||||
false,
|
||||
);
|
||||
let registry = if name == "replace_node" {
|
||||
let mut registry = ToolRegistry::default();
|
||||
registry.register(Box::new(op_mcp::replace_node_snapshot()));
|
||||
registry
|
||||
} else {
|
||||
let Some(_level) = chat_tool_level(name) else {
|
||||
return (
|
||||
error_result(format!("tool not available in chat: {name}")),
|
||||
false,
|
||||
);
|
||||
};
|
||||
chat_tool_registry(state, name)
|
||||
};
|
||||
let registry = chat_tool_registry(state, name);
|
||||
execute_with_registry(state, name, args_json, registry)
|
||||
}
|
||||
|
||||
|
|
@ -197,14 +206,13 @@ fn error_result(message: String) -> ChatToolResult {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply a DESIGN_MODIFY result to the live document — port of TS
|
||||
/// `extractAndApplyDesignModification` (design-canvas-ops.ts:589-618):
|
||||
/// nodes whose `id` already exists are updated in place; unknown ids
|
||||
/// are inserted under the active page's primary frame (TS
|
||||
/// `getActivePagePrimaryFrameId`, design-canvas-ops.ts:86-94), or the
|
||||
/// page root when the page has no frame. Each node dispatches through
|
||||
/// [`execute_chat_tool`] (`update_node` / `insert_node`) so validation
|
||||
/// matches the MCP path. Returns `(applied_count, mutated)`.
|
||||
/// Apply a DESIGN_MODIFY result to the live document. Top-level nodes
|
||||
/// whose `id` already exists replace that whole subtree; unknown or
|
||||
/// id-less nodes insert as new top-level elements under the active
|
||||
/// page's primary frame (TS `getActivePagePrimaryFrameId`,
|
||||
/// design-canvas-ops.ts:86-94). Inserts and replacements dispatch
|
||||
/// through MCP tool validation before applying commands. Returns
|
||||
/// `(applied_count, mutated)`.
|
||||
///
|
||||
/// Documented divergence: TS wraps the loop in one history batch;
|
||||
/// here every node is its own undo step — the same granularity the
|
||||
|
|
@ -212,55 +220,234 @@ fn error_result(message: String) -> ChatToolResult {
|
|||
/// (design_session.rs `BeginUndoBatch` TODO).
|
||||
pub fn apply_design_modification(
|
||||
state: &mut EditorState,
|
||||
nodes: &[serde_json::Value],
|
||||
nodes: &[DesignModificationOp],
|
||||
) -> (usize, bool) {
|
||||
use op_editor_core::pen_node_ext::PenNodeExt;
|
||||
|
||||
let mut count = 0usize;
|
||||
let mut mutated = false;
|
||||
for node in nodes {
|
||||
let id = node.get("id").and_then(|v| v.as_str()).map(str::to_string);
|
||||
let exists = id.as_deref().is_some_and(|id| {
|
||||
op_editor_core::walkers::find_node(
|
||||
state.active_children(),
|
||||
&op_editor_core::NodeId::new(id),
|
||||
)
|
||||
.is_some()
|
||||
});
|
||||
let (tool, args) = if exists {
|
||||
(
|
||||
"update_node",
|
||||
serde_json::json!({ "nodeId": id, "data": node }),
|
||||
)
|
||||
for (parent, node) in nodes {
|
||||
let id = node.get("id").and_then(|v| v.as_str());
|
||||
let parent_exists = parent != "null" && node_exists(state, parent);
|
||||
let (applied, did_mutate) = if parent_exists {
|
||||
insert_modify_subtree(state, node, Some(parent.as_str()))
|
||||
} else if parent == "null" && id.is_some_and(|id| node_exists(state, id)) {
|
||||
replace_modify_subtree(state, node, id.expect("checked above"))
|
||||
} else {
|
||||
// TS: parent the implied-new node to the active page's
|
||||
// primary frame; null parent falls to the page root.
|
||||
let parent = state
|
||||
.active_children()
|
||||
.iter()
|
||||
.find(|n| matches!(n, jian_ops_schema::node::PenNode::Frame(_)))
|
||||
.map(|n| n.id_str().to_string());
|
||||
let mut args = serde_json::json!({ "data": node });
|
||||
if let Some(parent) = parent {
|
||||
args["parent"] = serde_json::Value::String(parent);
|
||||
}
|
||||
("insert_node", args)
|
||||
insert_modify_subtree(state, node, None)
|
||||
};
|
||||
let (result, did_mutate) = execute_chat_tool(state, tool, &args.to_string());
|
||||
if did_mutate {
|
||||
mutated = true;
|
||||
}
|
||||
if !result.is_error {
|
||||
count += 1;
|
||||
} else {
|
||||
// Best-effort apply (TS loop never aborts): log the
|
||||
// per-node failure for diagnosis and continue.
|
||||
eprintln!("[AI] design modification {tool} failed: {}", result.content);
|
||||
}
|
||||
count += applied;
|
||||
mutated |= did_mutate;
|
||||
}
|
||||
(count, mutated)
|
||||
}
|
||||
|
||||
pub fn parse_design_modification_ops_arg(args_json: &str) -> Vec<DesignModificationOp> {
|
||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(args_json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(nodes) = args.get("nodes") else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_value::<Vec<DesignModificationOp>>(nodes.clone()).unwrap_or_else(|_| {
|
||||
nodes
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|node| ("null".to_string(), node))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_modify_subtree(
|
||||
state: &mut EditorState,
|
||||
node: &serde_json::Value,
|
||||
node_id: &str,
|
||||
) -> (usize, bool) {
|
||||
let target_id = op_editor_core::NodeId::new(node_id);
|
||||
let Some(path) = node_index_path(state.active_children(), &target_id) else {
|
||||
return (0, false);
|
||||
};
|
||||
let mut preserved_ids = HashSet::new();
|
||||
if let Some(existing) = op_editor_core::walkers::find_node(state.active_children(), &target_id)
|
||||
{
|
||||
collect_subtree_ids(existing, &mut preserved_ids);
|
||||
}
|
||||
|
||||
let mut incoming = node.clone();
|
||||
backfill_placeholder_image_srcs(&mut incoming, state);
|
||||
let args = serde_json::json!({
|
||||
"nodeId": node_id,
|
||||
"data": incoming,
|
||||
"drop_children": true
|
||||
});
|
||||
let (result, mutated) = execute_chat_tool(state, "replace_node", &args.to_string());
|
||||
if !result.is_error {
|
||||
if let Some(replaced) = node_mut_at_path(state.active_children_mut(), &path) {
|
||||
let mut used = HashSet::new();
|
||||
restore_existing_subtree_ids(replaced, node, &preserved_ids, &mut used);
|
||||
}
|
||||
return (1, mutated);
|
||||
}
|
||||
eprintln!(
|
||||
"[AI] design modification replace_node failed: {}",
|
||||
result.content
|
||||
);
|
||||
(0, mutated)
|
||||
}
|
||||
|
||||
fn node_index_path(
|
||||
nodes: &[jian_ops_schema::node::PenNode],
|
||||
target: &op_editor_core::NodeId,
|
||||
) -> Option<Vec<usize>> {
|
||||
fn walk(
|
||||
nodes: &[jian_ops_schema::node::PenNode],
|
||||
target: &op_editor_core::NodeId,
|
||||
path: &mut Vec<usize>,
|
||||
) -> bool {
|
||||
for (idx, node) in nodes.iter().enumerate() {
|
||||
path.push(idx);
|
||||
if node.id_str() == target.as_str() {
|
||||
return true;
|
||||
}
|
||||
if let Some(children) = node.children() {
|
||||
if walk(children, target, path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
let mut path = Vec::new();
|
||||
walk(nodes, target, &mut path).then_some(path)
|
||||
}
|
||||
|
||||
fn node_mut_at_path<'a>(
|
||||
nodes: &'a mut [jian_ops_schema::node::PenNode],
|
||||
path: &[usize],
|
||||
) -> Option<&'a mut jian_ops_schema::node::PenNode> {
|
||||
let (idx, rest) = path.split_first()?;
|
||||
let node = nodes.get_mut(*idx)?;
|
||||
if rest.is_empty() {
|
||||
return Some(node);
|
||||
}
|
||||
node.children_mut()
|
||||
.and_then(|children| node_mut_at_path(children, rest))
|
||||
}
|
||||
|
||||
fn collect_subtree_ids(node: &jian_ops_schema::node::PenNode, ids: &mut HashSet<String>) {
|
||||
ids.insert(node.id_str().to_string());
|
||||
if let Some(children) = node.children() {
|
||||
for child in children {
|
||||
collect_subtree_ids(child, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_existing_subtree_ids(
|
||||
node: &mut jian_ops_schema::node::PenNode,
|
||||
incoming: &serde_json::Value,
|
||||
preserved_ids: &HashSet<String>,
|
||||
used: &mut HashSet<String>,
|
||||
) {
|
||||
if let Some(id) = incoming
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|id| preserved_ids.contains(*id) && used.insert((*id).to_string()))
|
||||
{
|
||||
node.base_mut().id = id.to_string();
|
||||
}
|
||||
|
||||
let incoming_children = incoming.get("children").and_then(|v| v.as_array());
|
||||
if let (Some(children), Some(incoming_children)) = (node.children_mut(), incoming_children) {
|
||||
for (child, incoming_child) in children.iter_mut().zip(incoming_children) {
|
||||
restore_existing_subtree_ids(child, incoming_child, preserved_ids, used);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backfill_placeholder_image_srcs(incoming: &mut serde_json::Value, state: &EditorState) {
|
||||
match incoming {
|
||||
serde_json::Value::Object(obj) => {
|
||||
let replacement = obj
|
||||
.get("src")
|
||||
.and_then(|src| (src.as_str() == Some("<image>")).then_some(()))
|
||||
.and_then(|_| obj.get("id").and_then(|id| id.as_str()))
|
||||
.and_then(|id| existing_real_image_src(state, id));
|
||||
if let Some(src) = replacement {
|
||||
obj.insert("src".into(), serde_json::Value::String(src));
|
||||
}
|
||||
for value in obj.values_mut() {
|
||||
backfill_placeholder_image_srcs(value, state);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
backfill_placeholder_image_srcs(item, state);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_real_image_src(state: &EditorState, id: &str) -> Option<String> {
|
||||
let node = op_editor_core::walkers::find_node(
|
||||
state.active_children(),
|
||||
&op_editor_core::NodeId::new(id),
|
||||
)?;
|
||||
let jian_ops_schema::node::PenNode::Image(image) = node else {
|
||||
return None;
|
||||
};
|
||||
let src = image.src.as_str();
|
||||
is_real_image_src(src).then(|| src.to_string())
|
||||
}
|
||||
|
||||
fn is_real_image_src(src: &str) -> bool {
|
||||
src.starts_with("data:") || src.starts_with("http://") || src.starts_with("https://")
|
||||
}
|
||||
|
||||
fn insert_modify_subtree(
|
||||
state: &mut EditorState,
|
||||
node: &serde_json::Value,
|
||||
parent_id: Option<&str>,
|
||||
) -> (usize, bool) {
|
||||
let mut args = serde_json::json!({ "data": node });
|
||||
if let Some(parent) = parent_id
|
||||
.map(str::to_string)
|
||||
.or_else(|| primary_frame_id(state))
|
||||
{
|
||||
args["parent"] = serde_json::Value::String(parent);
|
||||
}
|
||||
let (result, mutated) = execute_chat_tool(state, "insert_node", &args.to_string());
|
||||
if !result.is_error {
|
||||
return (1, mutated);
|
||||
}
|
||||
eprintln!(
|
||||
"[AI] design modification insert_node failed: {}",
|
||||
result.content
|
||||
);
|
||||
(0, mutated)
|
||||
}
|
||||
|
||||
fn node_exists(state: &EditorState, id: &str) -> bool {
|
||||
op_editor_core::walkers::find_node(state.active_children(), &op_editor_core::NodeId::new(id))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn primary_frame_id(state: &EditorState) -> Option<String> {
|
||||
use op_editor_core::PenNodeExt;
|
||||
|
||||
state
|
||||
.active_children()
|
||||
.iter()
|
||||
.find(|n| matches!(n, jian_ops_schema::node::PenNode::Frame(_)))
|
||||
.map(|n| n.id_str().to_string())
|
||||
}
|
||||
|
||||
/// Build a registry carrying only the requested chat tool — the same
|
||||
/// snapshot-per-call discipline as `mcp_serve::rebuild_registry`, but
|
||||
/// scoped to the chat subset.
|
||||
|
|
@ -388,6 +575,160 @@ mod tests {
|
|||
assert_eq!(v["success"], serde_json::Value::Bool(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modification_replaces_existing_tree_and_backfills_image_src() {
|
||||
use op_editor_core::{walkers::find_node, NodeId, PenNodeExt};
|
||||
|
||||
let mut state = EditorState::new();
|
||||
state.active_children_mut().clear();
|
||||
state.active_children_mut().push(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "n100",
|
||||
"name": "Before",
|
||||
"x": -100.0,
|
||||
"y": 0.0,
|
||||
"width": 80.0,
|
||||
"height": 80.0,
|
||||
"children": []
|
||||
}))
|
||||
.expect("valid before node"),
|
||||
);
|
||||
state.active_children_mut().push(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "n217",
|
||||
"name": "Mini Player",
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 320.0,
|
||||
"height": 180.0,
|
||||
"children": [
|
||||
{
|
||||
"type": "image",
|
||||
"id": "n218",
|
||||
"name": "Cover Image",
|
||||
"src": "data:image/png;base64,REALIMAGE",
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 80.0,
|
||||
"height": 80.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "n220",
|
||||
"name": "Song Title",
|
||||
"content": "Original Title",
|
||||
"x": 90.0,
|
||||
"y": 0.0,
|
||||
"width": 180.0,
|
||||
"height": 24.0
|
||||
}
|
||||
]
|
||||
}))
|
||||
.expect("valid frame node"),
|
||||
);
|
||||
state.active_children_mut().push(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "n300",
|
||||
"name": "After",
|
||||
"x": 400.0,
|
||||
"y": 0.0,
|
||||
"width": 80.0,
|
||||
"height": 80.0,
|
||||
"children": []
|
||||
}))
|
||||
.expect("valid after node"),
|
||||
);
|
||||
|
||||
let nodes = vec![(
|
||||
"null".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "n217",
|
||||
"name": "Mini Player Rewritten",
|
||||
"children": [
|
||||
{
|
||||
"type": "image",
|
||||
"id": "n218",
|
||||
"name": "Cover Image Rewritten",
|
||||
"src": "<image>",
|
||||
"width": 10.0,
|
||||
"height": 10.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "n220",
|
||||
"name": "Song Title Rewritten",
|
||||
"content": "B"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"name": "Progress Bar",
|
||||
"width": 220.0,
|
||||
"height": 8.0,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}),
|
||||
)];
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(mutated);
|
||||
assert_eq!(state.active_children()[0].id_str(), "n100");
|
||||
assert_eq!(state.active_children()[1].id_str(), "n217");
|
||||
assert_eq!(state.active_children()[2].id_str(), "n300");
|
||||
let mini_player = find_node(state.active_children(), &NodeId::new("n217"))
|
||||
.expect("existing mini player remains");
|
||||
let mini_json = serde_json::to_value(mini_player).expect("mini player serializes");
|
||||
assert_eq!(
|
||||
mini_json["name"],
|
||||
serde_json::json!("Mini Player Rewritten")
|
||||
);
|
||||
let children = mini_player.children().expect("mini player children");
|
||||
assert_eq!(children.len(), 3);
|
||||
assert_eq!(children[0].id_str(), "n218");
|
||||
assert_eq!(children[1].id_str(), "n220");
|
||||
assert_eq!(children[2].base().name.as_deref(), Some("Progress Bar"));
|
||||
|
||||
let image =
|
||||
find_node(state.active_children(), &NodeId::new("n218")).expect("cover image remains");
|
||||
let image_json = serde_json::to_value(image).expect("image serializes");
|
||||
assert_eq!(
|
||||
image_json["name"],
|
||||
serde_json::json!("Cover Image Rewritten")
|
||||
);
|
||||
assert_eq!(
|
||||
image_json["src"],
|
||||
serde_json::json!("data:image/png;base64,REALIMAGE")
|
||||
);
|
||||
assert_eq!(image_json["width"], serde_json::json!(10.0));
|
||||
|
||||
let title =
|
||||
find_node(state.active_children(), &NodeId::new("n220")).expect("title remains");
|
||||
let title_json = serde_json::to_value(title).expect("title serializes");
|
||||
assert_eq!(
|
||||
title_json["name"],
|
||||
serde_json::json!("Song Title Rewritten")
|
||||
);
|
||||
assert_eq!(title_json["content"], serde_json::json!("B"));
|
||||
|
||||
fn count_id(nodes: &[jian_ops_schema::node::PenNode], id: &str) -> usize {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
usize::from(node.id_str() == id)
|
||||
+ node.children().map(|kids| count_id(kids, id)).unwrap_or(0)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
assert_eq!(count_id(state.active_children(), "n217"), 1);
|
||||
assert_eq!(count_id(state.active_children(), "n218"), 1);
|
||||
assert_eq!(count_id(state.active_children(), "n220"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_executor_round_trips_through_the_channel() {
|
||||
// Worker side blocks on the ack while the "UI thread" (this
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@
|
|||
//! - 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 reuses `op_orchestrator::parse::parse_nodes`
|
||||
//! (a superset of TS `extractJsonFromResponse` for messy LLM
|
||||
//! output, but typed: a response of partial patch objects without
|
||||
//! a `type` field fails to parse where untyped TS would accept it).
|
||||
//! - 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;
|
||||
|
|
@ -736,6 +736,51 @@ pub struct ModifyPlan {
|
|||
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
|
||||
|
|
@ -762,7 +807,9 @@ pub fn build_modify_plan(state: &EditorState, instruction: &str) -> Option<Modif
|
|||
return None;
|
||||
}
|
||||
|
||||
let context_json = serde_json::to_string(&targets).ok()?;
|
||||
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");
|
||||
|
|
@ -837,6 +884,62 @@ pub struct CliTurnPlan {
|
|||
/// 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`
|
||||
|
|
@ -938,39 +1041,20 @@ pub fn run_modify_turn(
|
|||
{
|
||||
return;
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// TS order: parse first; a stream error only surfaces when no
|
||||
// nodes could be extracted (design-generator.ts:158-165).
|
||||
let nodes = op_orchestrator::parse::parse_nodes(&full_response).unwrap_or_default();
|
||||
let ModifyTurnParse {
|
||||
full_response,
|
||||
nodes,
|
||||
stream_error,
|
||||
} = parsed;
|
||||
if !nodes.is_empty() {
|
||||
// TS: accumulated = rawResponse (the transcript's design-block
|
||||
// renderer shows the JSON as an applyable card).
|
||||
if chat_tx
|
||||
.send(ChatDelta::TextDelta(format!("\n{full_response}")))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let args = serde_json::json!({
|
||||
"nodes": nodes
|
||||
.iter()
|
||||
.map(|n| serde_json::to_value(n).unwrap_or(serde_json::Value::Null))
|
||||
.collect::<Vec<_>>(),
|
||||
"nodes": &nodes,
|
||||
});
|
||||
let result = executor.execute(APPLY_MODIFICATION_OP, &args.to_string());
|
||||
let applied = serde_json::from_str::<serde_json::Value>(&result.content)
|
||||
|
|
@ -978,8 +1062,21 @@ pub fn run_modify_turn(
|
|||
.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,
|
||||
|
|
@ -990,20 +1087,8 @@ pub fn run_modify_turn(
|
|||
let message = if let Some(err) = stream_error {
|
||||
err
|
||||
} else {
|
||||
// TS parse-failure error text, verbatim.
|
||||
let trimmed = full_response.trim();
|
||||
let hint = if trimmed.is_empty() {
|
||||
"The model returned an empty response.".to_string()
|
||||
} else {
|
||||
let preview: String = trimmed.chars().take(150).collect();
|
||||
let ellipsis = if full_response.chars().count() > 150 {
|
||||
"…"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("Model output: \"{preview}{ellipsis}\"")
|
||||
};
|
||||
format!("Could not parse design nodes from model response. {hint}")
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Tests for the CLI standard-mode intent router (GAP #33).
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, StopReason};
|
||||
use op_ai::chat_provider::{ChatDelta, ChatHistoryRole, ChatProvider, ChatRequest, StopReason};
|
||||
use op_editor_core::EditorState;
|
||||
use op_orchestrator::DesignRequest;
|
||||
|
||||
|
|
@ -140,6 +141,50 @@ impl ChatProvider for Scripted {
|
|||
}
|
||||
}
|
||||
|
||||
struct ScriptedSequence {
|
||||
responses: Mutex<VecDeque<Vec<ChatDelta>>>,
|
||||
requests: Mutex<Vec<ChatRequest>>,
|
||||
}
|
||||
|
||||
impl ScriptedSequence {
|
||||
fn text(responses: &[&str]) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(responses.iter().map(|s| scripted_text_deltas(s)).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<ChatRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatProvider for ScriptedSequence {
|
||||
fn provider_label(&self) -> &str {
|
||||
"scripted-sequence"
|
||||
}
|
||||
|
||||
fn send(&self, request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let deltas = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("scripted response for provider call");
|
||||
Box::new(deltas.into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn scripted_text_deltas(s: &str) -> Vec<ChatDelta> {
|
||||
vec![
|
||||
ChatDelta::TextDelta(s.to_string()),
|
||||
ChatDelta::Done {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -213,6 +258,20 @@ fn rect(id: &str, name: &str) -> PenNode {
|
|||
.expect("valid rectangle json")
|
||||
}
|
||||
|
||||
fn image(id: &str, name: &str, src: &str) -> PenNode {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "image",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"src": src,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 120.0,
|
||||
"height": 80.0,
|
||||
}))
|
||||
.expect("valid image json")
|
||||
}
|
||||
|
||||
/// Page frame with a status bar + two content sections.
|
||||
fn state_with_page() -> EditorState {
|
||||
let mut state = EditorState::new();
|
||||
|
|
@ -230,6 +289,29 @@ fn state_with_page() -> EditorState {
|
|||
state
|
||||
}
|
||||
|
||||
fn count_node_id(nodes: &[PenNode], id: &str) -> usize {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
usize::from(node.id_str() == id)
|
||||
+ node
|
||||
.children()
|
||||
.map(|kids| count_node_id(kids, id))
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn modify_op(parent: &str, node: serde_json::Value) -> (String, serde_json::Value) {
|
||||
(parent.to_string(), node)
|
||||
}
|
||||
|
||||
fn modification_pairs_from_args(args_json: &str) -> Vec<(String, serde_json::Value)> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(args_json).expect("valid apply args");
|
||||
serde_json::from_value(value.get("nodes").cloned().expect("nodes array"))
|
||||
.expect("nodes are serialized parent/object pairs")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Append-intent detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -562,6 +644,78 @@ fn modify_plan_targets_selection_when_present() {
|
|||
assert!(plan.user_message.contains("\n\nINSTRUCTION:\nmake it red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_plan_strips_base64_data_uris_from_context_nodes() {
|
||||
let image_data_uri = "data:image/png;base64,AAAABBBBCCCC";
|
||||
let fill_data_uri = "data:image/jpeg;base64,DDDDEEEEFFFF";
|
||||
let mut image_fill_rect: PenNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "rectangle",
|
||||
"id": "fill-card",
|
||||
"name": "Image Fill Card",
|
||||
"x": 0.0,
|
||||
"y": 100.0,
|
||||
"width": 120.0,
|
||||
"height": 80.0,
|
||||
"fill": [
|
||||
{ "type": "image", "url": fill_data_uri, "mode": "crop" },
|
||||
{ "type": "solid", "color": "$color-1" }
|
||||
],
|
||||
}))
|
||||
.expect("valid rectangle with image fill json");
|
||||
image_fill_rect.base_mut().explain = Some("keep metadata".into());
|
||||
|
||||
let mut state = EditorState::new();
|
||||
state.active_children_mut().clear();
|
||||
state.active_children_mut().push(frame(
|
||||
"page-1",
|
||||
"Home",
|
||||
375.0,
|
||||
vec![
|
||||
image("hero-photo", "Hero Photo", image_data_uri),
|
||||
image_fill_rect,
|
||||
],
|
||||
));
|
||||
|
||||
let plan = build_modify_plan(&state, "make it warmer").expect("plan");
|
||||
let context_json = plan
|
||||
.user_message
|
||||
.strip_prefix("CONTEXT NODES:\n")
|
||||
.and_then(|rest| rest.split_once("\n\nINSTRUCTION:\n").map(|(ctx, _)| ctx))
|
||||
.expect("context section");
|
||||
let context: serde_json::Value =
|
||||
serde_json::from_str(context_json).expect("valid context json");
|
||||
|
||||
assert_eq!(
|
||||
context
|
||||
.pointer("/0/children/0/src")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("<image>")
|
||||
);
|
||||
assert_eq!(
|
||||
context
|
||||
.pointer("/0/children/1/fill/0/url")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("<image>")
|
||||
);
|
||||
assert_eq!(
|
||||
context
|
||||
.pointer("/0/children/1/fill/1/color")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("$color-1")
|
||||
);
|
||||
assert_eq!(
|
||||
context
|
||||
.pointer("/0/children/1/explain")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("keep metadata")
|
||||
);
|
||||
assert!(
|
||||
!plan.user_message.contains("AAAABBBBCCCC") && !plan.user_message.contains("DDDDEEEEFFFF"),
|
||||
"base64 blobs must not be sent to the model: {}",
|
||||
plan.user_message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_plan_falls_back_to_last_frame_then_last_child() {
|
||||
// No selection → last top-level frame.
|
||||
|
|
@ -610,26 +764,40 @@ fn modify_plan_is_none_for_an_empty_page() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn apply_modification_updates_existing_and_inserts_new() {
|
||||
fn apply_modification_replaces_existing_and_inserts_unknown_top_level() {
|
||||
let mut state = state_with_page();
|
||||
let nodes = vec![
|
||||
// Existing id → update in place.
|
||||
serde_json::json!({ "id": "hero", "name": "Hero Updated" }),
|
||||
// Existing id -> whole-node replacement.
|
||||
modify_op(
|
||||
"null",
|
||||
serde_json::json!({
|
||||
"id": "hero",
|
||||
"type": "frame",
|
||||
"name": "Hero Updated",
|
||||
"width": 375.0,
|
||||
"height": 200.0,
|
||||
"children": []
|
||||
}),
|
||||
),
|
||||
// Unknown id → insert under the primary frame (canonical
|
||||
// TextNode carries `content`).
|
||||
serde_json::json!({
|
||||
"id": "fresh-1",
|
||||
"type": "text",
|
||||
"name": "New Caption",
|
||||
"content": "Hello",
|
||||
}),
|
||||
modify_op(
|
||||
"null",
|
||||
serde_json::json!({
|
||||
"id": "fresh-1",
|
||||
"type": "text",
|
||||
"name": "New Caption",
|
||||
"content": "Hello",
|
||||
}),
|
||||
),
|
||||
];
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
assert_eq!(count, 2, "both nodes applied");
|
||||
assert_eq!(count, 2, "replace existing plus insert unknown top-level");
|
||||
assert!(mutated);
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("Hero Updated"), "existing node updated");
|
||||
assert!(doc.contains("Hero Updated"), "existing node is replaced");
|
||||
assert!(doc.contains("New Caption"), "new node inserted");
|
||||
assert_eq!(count_node_id(state.active_children(), "hero"), 1);
|
||||
// The insert landed inside the page frame, not at the page root.
|
||||
let page = state
|
||||
.active_children()
|
||||
|
|
@ -637,6 +805,14 @@ fn apply_modification_updates_existing_and_inserts_new() {
|
|||
.find(|n| n.id_str() == "page-1")
|
||||
.unwrap();
|
||||
let kids = page.children().unwrap();
|
||||
assert!(
|
||||
kids.iter().any(|k| k.id_str() == "hero"
|
||||
&& k.base()
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n == "Hero Updated")),
|
||||
"existing node remains in the primary frame"
|
||||
);
|
||||
assert!(
|
||||
kids.iter()
|
||||
.any(|k| k.base().name.as_deref().is_some_and(|n| n == "New Caption")),
|
||||
|
|
@ -644,6 +820,78 @@ fn apply_modification_updates_existing_and_inserts_new() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modification_adds_under_declared_existing_parent_without_touching_siblings() {
|
||||
use op_editor_core::{walkers::find_node, NodeId};
|
||||
|
||||
let mut state = EditorState::new();
|
||||
state.active_children_mut().clear();
|
||||
state.active_children_mut().push(frame(
|
||||
"n217",
|
||||
"Player",
|
||||
320.0,
|
||||
vec![rect("n218", "Track Info"), rect("n220", "Actions")],
|
||||
));
|
||||
let before_parent = find_node(state.active_children(), &NodeId::new("n217")).unwrap();
|
||||
let before_children = before_parent.children().unwrap();
|
||||
let before_n218 = serde_json::to_value(&before_children[0]).unwrap();
|
||||
let before_n220 = serde_json::to_value(&before_children[1]).unwrap();
|
||||
|
||||
let nodes = vec![modify_op(
|
||||
"n217",
|
||||
serde_json::json!({
|
||||
"type": "frame",
|
||||
"name": "Progress Bar",
|
||||
"width": 220.0,
|
||||
"height": 8.0,
|
||||
"children": []
|
||||
}),
|
||||
)];
|
||||
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(mutated);
|
||||
let parent = find_node(state.active_children(), &NodeId::new("n217")).unwrap();
|
||||
assert_eq!(parent.base().name.as_deref(), Some("Player"));
|
||||
let children = parent.children().unwrap();
|
||||
assert_eq!(children.len(), 3);
|
||||
assert_eq!(children[0].id_str(), "n218");
|
||||
assert_eq!(children[1].id_str(), "n220");
|
||||
assert_eq!(children[2].base().name.as_deref(), Some("Progress Bar"));
|
||||
assert_eq!(serde_json::to_value(&children[0]).unwrap(), before_n218);
|
||||
assert_eq!(serde_json::to_value(&children[1]).unwrap(), before_n220);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modification_inserts_idless_null_parent_under_primary_frame() {
|
||||
let mut state = state_with_page();
|
||||
let nodes = vec![modify_op(
|
||||
"null",
|
||||
serde_json::json!({
|
||||
"type": "text",
|
||||
"name": "Loose Label",
|
||||
"content": "Hello"
|
||||
}),
|
||||
)];
|
||||
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(mutated);
|
||||
let page = state
|
||||
.active_children()
|
||||
.iter()
|
||||
.find(|n| n.id_str() == "page-1")
|
||||
.unwrap();
|
||||
let kids = page.children().unwrap();
|
||||
assert!(
|
||||
kids.iter()
|
||||
.any(|k| k.base().name.as_deref().is_some_and(|n| n == "Loose Label")),
|
||||
"idless null-parent node inserts under the active page primary frame"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_cli_turn routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -673,6 +921,330 @@ fn drain_chat(rx: &mpsc::Receiver<ChatDelta>) -> Vec<ChatDelta> {
|
|||
out
|
||||
}
|
||||
|
||||
fn run_modify_turn_with_apply(
|
||||
response: &str,
|
||||
) -> (
|
||||
Vec<ChatDelta>,
|
||||
EditorState,
|
||||
Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let provider = Scripted::text(response);
|
||||
let (chat_tx, chat_rx) = mpsc::channel();
|
||||
let (executor, tool_rx) = chat_tool_channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
run_modify_turn(&provider, ChatRequest::default(), &chat_tx, &executor);
|
||||
});
|
||||
|
||||
let req = tool_rx
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("modify route forwards the apply op");
|
||||
assert_eq!(req.name, APPLY_MODIFICATION_OP);
|
||||
let nodes = modification_pairs_from_args(&req.args_json);
|
||||
let mut state = state_with_page();
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
assert_eq!(count, 1);
|
||||
assert!(mutated);
|
||||
req.ack
|
||||
.send(op_ai::chat_provider::ChatToolResult {
|
||||
content: serde_json::json!({ "success": true, "count": count }).to_string(),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let deltas = drain_chat(&chat_rx);
|
||||
worker.join().unwrap();
|
||||
(deltas, state, nodes)
|
||||
}
|
||||
|
||||
fn run_modify_turn_with_sequence_apply(
|
||||
provider: Arc<ScriptedSequence>,
|
||||
request: ChatRequest,
|
||||
) -> (
|
||||
Vec<ChatDelta>,
|
||||
EditorState,
|
||||
Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let (chat_tx, chat_rx) = mpsc::channel();
|
||||
let (executor, tool_rx) = chat_tool_channel();
|
||||
let worker_provider = Arc::clone(&provider);
|
||||
let worker = std::thread::spawn(move || {
|
||||
run_modify_turn(worker_provider.as_ref(), request, &chat_tx, &executor);
|
||||
});
|
||||
|
||||
let req = tool_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("modify route forwards the apply op");
|
||||
assert_eq!(req.name, APPLY_MODIFICATION_OP);
|
||||
let nodes = modification_pairs_from_args(&req.args_json);
|
||||
let mut state = state_with_page();
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
assert_eq!(count, 1);
|
||||
assert!(mutated);
|
||||
req.ack
|
||||
.send(op_ai::chat_provider::ChatToolResult {
|
||||
content: serde_json::json!({ "success": true, "count": count }).to_string(),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let deltas = drain_chat(&chat_rx);
|
||||
worker.join().unwrap();
|
||||
(deltas, state, nodes)
|
||||
}
|
||||
|
||||
fn retry_test_request() -> ChatRequest {
|
||||
ChatRequest {
|
||||
system_prompt: "base modify system prompt".into(),
|
||||
user_message: "CONTEXT NODES: []\n\nINSTRUCTION: change the hero".into(),
|
||||
history: vec![
|
||||
(ChatHistoryRole::User, "previous user turn".into()),
|
||||
(ChatHistoryRole::Assistant, "previous assistant turn".into()),
|
||||
],
|
||||
max_output_tokens: 1234,
|
||||
model: Some("glm-test-model".into()),
|
||||
..ChatRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_retry_request(mut request: ChatRequest) -> ChatRequest {
|
||||
request.system_prompt.push_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.",
|
||||
);
|
||||
request
|
||||
}
|
||||
|
||||
fn text_delta_count(deltas: &[ChatDelta], needle: &str) -> usize {
|
||||
deltas
|
||||
.iter()
|
||||
.filter(|d| matches!(d, ChatDelta::TextDelta(s) if s.contains(needle)))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn expected_applied_json_delta(nodes: &[(String, serde_json::Value)]) -> ChatDelta {
|
||||
let node_values = nodes
|
||||
.iter()
|
||||
.map(|(_, node)| node.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let json = serde_json::to_string_pretty(&node_values).unwrap();
|
||||
ChatDelta::TextDelta(format!("\n```json\n{json}\n```"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_script_response_applies_nodes_and_marks_applied() {
|
||||
let response = r##"
|
||||
I(null, {
|
||||
id:"hero",
|
||||
type:"frame",
|
||||
name:"Hero Rewritten",
|
||||
children:[{type:"text", name:"Progress Label", content:"0:42"}]
|
||||
});
|
||||
"##;
|
||||
|
||||
let (deltas, state, nodes) = run_modify_turn_with_apply(response);
|
||||
|
||||
assert_eq!(nodes[0].0, "null");
|
||||
assert_eq!(nodes[0].1["id"], serde_json::json!("hero"));
|
||||
assert_eq!(
|
||||
deltas[0],
|
||||
ChatDelta::TextDelta(
|
||||
r#"<step title="Checking guidelines">Analyzing modification request...</step>"#.into()
|
||||
)
|
||||
);
|
||||
assert_eq!(deltas[1], expected_applied_json_delta(&nodes));
|
||||
assert_eq!(
|
||||
deltas[2],
|
||||
ChatDelta::TextDelta("\n\n<!-- APPLIED -->".into())
|
||||
);
|
||||
assert!(matches!(deltas[3], ChatDelta::Done { .. }));
|
||||
let transcript_text = deltas
|
||||
.iter()
|
||||
.filter_map(|delta| match delta {
|
||||
ChatDelta::TextDelta(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
assert!(!transcript_text.contains("I(null"));
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("Progress Label"));
|
||||
assert!(doc.contains("Hero Rewritten"));
|
||||
assert_eq!(count_node_id(state.active_children(), "hero"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_retries_prose_once_then_applies_script() {
|
||||
let prose = "I can change the hero by making it clearer and more direct.";
|
||||
let response = r##"
|
||||
I(null, {
|
||||
id:"hero",
|
||||
type:"frame",
|
||||
name:"Hero Retry Applied",
|
||||
children:[{type:"text", name:"Retry Label", content:"Applied"}]
|
||||
});
|
||||
"##;
|
||||
let provider = Arc::new(ScriptedSequence::text(&[prose, response]));
|
||||
let request = retry_test_request();
|
||||
|
||||
let (deltas, state, nodes) =
|
||||
run_modify_turn_with_sequence_apply(Arc::clone(&provider), request.clone());
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"empty first parse gets exactly one retry"
|
||||
);
|
||||
assert_eq!(requests[0], request);
|
||||
assert_eq!(requests[1], expected_retry_request(request));
|
||||
assert_eq!(nodes[0].0, "null");
|
||||
assert_eq!(nodes[0].1["id"], serde_json::json!("hero"));
|
||||
assert_eq!(
|
||||
text_delta_count(&deltas, MODIFY_STEP),
|
||||
1,
|
||||
"retry must not stack a second modify progress step"
|
||||
);
|
||||
assert_eq!(
|
||||
text_delta_count(&deltas, prose),
|
||||
0,
|
||||
"discarded first prose attempt must stay out of the transcript"
|
||||
);
|
||||
assert!(
|
||||
deltas
|
||||
.iter()
|
||||
.any(|d| matches!(d, ChatDelta::TextDelta(s) if s.contains("<!-- APPLIED -->"))),
|
||||
"successful retry must use the normal applied marker"
|
||||
);
|
||||
assert!(
|
||||
!deltas.iter().any(|d| matches!(d, ChatDelta::Error(_))),
|
||||
"successful retry must not emit the friendly recovery error"
|
||||
);
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("Hero Retry Applied"));
|
||||
assert!(doc.contains("Retry Label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_retries_prose_once_then_surfaces_friendly_recovery_error() {
|
||||
let prose_1 = "I would make the selected card red.";
|
||||
let prose_2 = "Here are the changes I would make: use a stronger accent color.";
|
||||
let provider = Arc::new(ScriptedSequence::text(&[prose_1, prose_2]));
|
||||
let request = retry_test_request();
|
||||
let (chat_tx, chat_rx) = mpsc::channel();
|
||||
let (executor, tool_rx) = chat_tool_channel();
|
||||
|
||||
run_modify_turn(provider.as_ref(), request.clone(), &chat_tx, &executor);
|
||||
|
||||
assert!(
|
||||
tool_rx.try_recv().is_err(),
|
||||
"double-prose responses must not dispatch an apply op"
|
||||
);
|
||||
let deltas = drain_chat(&chat_rx);
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 2, "retry is capped at one extra call");
|
||||
assert_eq!(requests[0], request);
|
||||
assert_eq!(requests[1], expected_retry_request(request));
|
||||
assert_eq!(
|
||||
text_delta_count(&deltas, MODIFY_STEP),
|
||||
1,
|
||||
"retry must not stack a second modify progress step"
|
||||
);
|
||||
assert_eq!(text_delta_count(&deltas, prose_1), 0);
|
||||
assert_eq!(text_delta_count(&deltas, prose_2), 0);
|
||||
let errors: Vec<_> = deltas
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
ChatDelta::Error(msg) => Some(msg.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
errors,
|
||||
vec![
|
||||
"The model returned a description instead of an applyable edit. Try rephrasing (e.g. name the element to add) or run it again."
|
||||
]
|
||||
);
|
||||
assert!(matches!(
|
||||
deltas.last(),
|
||||
Some(ChatDelta::Done {
|
||||
stop_reason: StopReason::Aborted
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_script_response_does_not_retry() {
|
||||
let response = r##"
|
||||
I(null, {
|
||||
id:"hero",
|
||||
type:"frame",
|
||||
name:"Hero First Attempt",
|
||||
children:[{type:"text", name:"First Attempt Label", content:"Applied"}]
|
||||
});
|
||||
"##;
|
||||
let provider = Arc::new(ScriptedSequence::text(&[
|
||||
response,
|
||||
"this second response must never be requested",
|
||||
]));
|
||||
let request = retry_test_request();
|
||||
|
||||
let (deltas, state, nodes) =
|
||||
run_modify_turn_with_sequence_apply(Arc::clone(&provider), request.clone());
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 1, "valid first script must not retry");
|
||||
assert_eq!(requests[0], request);
|
||||
assert_eq!(nodes[0].1["id"], serde_json::json!("hero"));
|
||||
assert_eq!(text_delta_count(&deltas, MODIFY_STEP), 1);
|
||||
assert!(deltas
|
||||
.iter()
|
||||
.any(|d| matches!(d, ChatDelta::TextDelta(s) if s.contains("<!-- APPLIED -->"))));
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("Hero First Attempt"));
|
||||
assert!(doc.contains("First Attempt Label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_flat_json_response_still_applies_via_fallback() {
|
||||
let response = r##"[{"id":"flat-new","type":"text","name":"Flat Caption","content":"Hello"}]"##;
|
||||
|
||||
let (deltas, state, nodes) = run_modify_turn_with_apply(response);
|
||||
|
||||
assert_eq!(nodes[0].0, "null");
|
||||
assert_eq!(deltas[1], expected_applied_json_delta(&nodes));
|
||||
assert!(
|
||||
deltas
|
||||
.iter()
|
||||
.any(|d| matches!(d, ChatDelta::TextDelta(s) if s.contains("<!-- APPLIED -->"))),
|
||||
"modify route must emit the applied marker"
|
||||
);
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("Flat Caption"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_modify_turn_prose_response_surfaces_friendly_recovery_error() {
|
||||
let provider = Scripted::text("sorry, I cannot help with that");
|
||||
let (chat_tx, chat_rx) = mpsc::channel();
|
||||
let (executor, tool_rx) = chat_tool_channel();
|
||||
|
||||
run_modify_turn(&provider, ChatRequest::default(), &chat_tx, &executor);
|
||||
|
||||
assert!(
|
||||
tool_rx.try_recv().is_err(),
|
||||
"prose responses must not dispatch an apply op"
|
||||
);
|
||||
let error = drain_chat(&chat_rx)
|
||||
.into_iter()
|
||||
.find_map(|d| match d {
|
||||
ChatDelta::Error(msg) => Some(msg),
|
||||
_ => None,
|
||||
})
|
||||
.expect("parse failure surfaces an error");
|
||||
assert_eq!(
|
||||
error,
|
||||
"The model returned a description instead of an applyable edit. Try rephrasing (e.g. name the element to add) or run it again."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_turn_chat_route_streams_provider_deltas() {
|
||||
let plan = CliTurnPlan {
|
||||
|
|
@ -712,9 +1284,11 @@ fn cli_turn_chat_route_streams_provider_deltas() {
|
|||
|
||||
#[test]
|
||||
fn cli_turn_modify_route_applies_nodes_and_marks_applied() {
|
||||
let response = r##"[{"id":"hero","type":"frame","name":"Hero Red","fill":[{"type":"solid","color":"#ff0000"}]}]"##;
|
||||
let response = r##"
|
||||
I("hero", {type:"text", name:"CLI Caption", content:"Added"});
|
||||
"##;
|
||||
let plan = CliTurnPlan {
|
||||
user_text: "make the hero red".into(),
|
||||
user_text: "add a caption".into(),
|
||||
page_children_empty: false,
|
||||
classify_provider: Box::new(Scripted::text("DESIGN_MODIFY")),
|
||||
chat_provider: Box::new(Scripted::text("unused")),
|
||||
|
|
@ -738,11 +1312,7 @@ fn cli_turn_modify_route_applies_nodes_and_marks_applied() {
|
|||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("modify route forwards the apply op");
|
||||
assert_eq!(req.name, APPLY_MODIFICATION_OP);
|
||||
let nodes = serde_json::from_str::<serde_json::Value>(&req.args_json)
|
||||
.unwrap()
|
||||
.get("nodes")
|
||||
.and_then(|n| n.as_array().cloned())
|
||||
.unwrap();
|
||||
let nodes = modification_pairs_from_args(&req.args_json);
|
||||
let mut state = state_with_page();
|
||||
let (count, mutated) = apply_design_modification(&mut state, &nodes);
|
||||
assert_eq!(count, 1);
|
||||
|
|
@ -756,22 +1326,24 @@ fn cli_turn_modify_route_applies_nodes_and_marks_applied() {
|
|||
|
||||
let deltas = drain_chat(&chat_rx);
|
||||
worker.join().unwrap();
|
||||
// Step → raw response → APPLIED marker → Done.
|
||||
// Step → fenced design JSON → APPLIED marker → Done.
|
||||
assert_eq!(
|
||||
deltas[0],
|
||||
ChatDelta::TextDelta(
|
||||
r#"<step title="Checking guidelines">Analyzing modification request...</step>"#.into()
|
||||
)
|
||||
);
|
||||
assert_eq!(deltas[1], ChatDelta::TextDelta(format!("\n{response}")));
|
||||
assert_eq!(deltas[1], expected_applied_json_delta(&nodes));
|
||||
assert_eq!(
|
||||
deltas[2],
|
||||
ChatDelta::TextDelta("\n\n<!-- APPLIED -->".into())
|
||||
);
|
||||
assert!(matches!(deltas[3], ChatDelta::Done { .. }));
|
||||
// The doc was recolored through the apply path.
|
||||
let doc = serde_json::to_string(&state.doc).unwrap().to_lowercase();
|
||||
assert!(doc.contains("#ff0000"));
|
||||
assert_eq!(nodes[0].0, "hero");
|
||||
// The new node was inserted under the existing hero through the apply path.
|
||||
let doc = serde_json::to_string(&state.doc).unwrap();
|
||||
assert!(doc.contains("CLI Caption"));
|
||||
assert_eq!(count_node_id(state.active_children(), "hero"), 1);
|
||||
// Design channels dropped.
|
||||
assert!(delta_rx.recv().is_err());
|
||||
assert!(cmd_rx.recv().is_err());
|
||||
|
|
@ -824,7 +1396,7 @@ fn cli_turn_modify_keyword_overrides_new_classifier_reply() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cli_turn_modify_parse_failure_surfaces_ts_error() {
|
||||
fn cli_turn_modify_parse_failure_surfaces_friendly_recovery_error() {
|
||||
let plan = CliTurnPlan {
|
||||
user_text: "make the hero red".into(),
|
||||
page_children_empty: false,
|
||||
|
|
@ -852,9 +1424,9 @@ fn cli_turn_modify_parse_failure_surfaces_ts_error() {
|
|||
_ => None,
|
||||
})
|
||||
.expect("parse failure surfaces an error");
|
||||
assert!(
|
||||
error.starts_with("Could not parse design nodes from model response. Model output: "),
|
||||
"got: {error}"
|
||||
assert_eq!(
|
||||
error,
|
||||
"The model returned a description instead of an applyable edit. Try rephrasing (e.g. name the element to add) or run it again."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,19 +346,13 @@ fn stream_modify_route<W: Write>(
|
|||
}
|
||||
}
|
||||
|
||||
let nodes = op_orchestrator::parse::parse_nodes(&full_response).unwrap_or_default();
|
||||
let nodes = crate::chat_intent::parse_modify_nodes(&full_response);
|
||||
if !nodes.is_empty() {
|
||||
write_delta_event(out, &format!("\n{full_response}"))?;
|
||||
let node_values = nodes
|
||||
.iter()
|
||||
.map(|n| serde_json::to_value(n).unwrap_or(Value::Null))
|
||||
.collect::<Vec<_>>();
|
||||
let (applied, version) = {
|
||||
let mut guard = state.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let (count, mutated) = crate::chat_canvas_tools::apply_design_modification(
|
||||
&mut guard.editor,
|
||||
&node_values,
|
||||
);
|
||||
let (count, mutated) =
|
||||
crate::chat_canvas_tools::apply_design_modification(&mut guard.editor, &nodes);
|
||||
let version = if mutated {
|
||||
guard.version += 1;
|
||||
Some(guard.version)
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"ai.noModelsConnected" => "No models connected",
|
||||
"ai.searchModels" => "Search models...",
|
||||
"ai.noModelsFound" => "No models found",
|
||||
"ai.modificationApplied" => "Modified",
|
||||
"ai.designElement" => "element",
|
||||
"ai.designElements" => "elements",
|
||||
"ai.quickAction.loginScreen" => "Design a mobile login screen",
|
||||
"ai.quickAction.loginScreenPrompt" => "Design a modern mobile login screen with email input, password input, login button, and social login options",
|
||||
"ai.quickAction.foodApp" => "Food app homepage",
|
||||
|
|
@ -282,8 +285,12 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"ai.quickAction.bottomNavPrompt" => "Design a mobile app bottom navigation bar with 5 tabs: Home, Search, Add, Messages, Profile",
|
||||
"ai.quickAction.colorPalette" => "Suggest a color palette for my app",
|
||||
"ai.quickAction.colorPalettePrompt" => "Suggest a modern color palette for a pet care app",
|
||||
"ai.quickAction.dashboard" => "Technical dashboard web app for a utilities company",
|
||||
"ai.quickAction.dashboardPrompt" => "Technical dashboard web app for a utilities company",
|
||||
"ai.quickAction.dashboard" => {
|
||||
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
|
||||
}
|
||||
"ai.quickAction.dashboardPrompt" => {
|
||||
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
|
||||
}
|
||||
"ai.quickAction.travelApp" => "Travel booking mobile app explore page",
|
||||
"ai.quickAction.travelAppPrompt" => {
|
||||
"Design a travel booking mobile app explore page. Include a search section with \"Where to?\" input, date picker chips, and guest count. Popular destinations as horizontal scrollable cards with destination photos, city names and starting prices. \"Deals of the Week\" section with 2 featured deal cards showing discount badges. Recently viewed section with 2 compact cards. Bottom tab bar (Explore, Wishlists, Trips, Messages, Profile). Warm, inviting design with orange accents."
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"ai.noModelsConnected" => "未连接模型",
|
||||
"ai.searchModels" => "搜索模型...",
|
||||
"ai.noModelsFound" => "未找到匹配的模型",
|
||||
"ai.modificationApplied" => "已修改",
|
||||
"ai.designElement" => "元素",
|
||||
"ai.designElements" => "元素",
|
||||
"ai.quickAction.loginScreen" => "设计一个移动端登录页面",
|
||||
"ai.quickAction.loginScreenPrompt" => {
|
||||
"设计一个现代的移动端登录页面,包含邮箱输入框、密码输入框、登录按钮和社交登录选项"
|
||||
|
|
@ -286,8 +289,12 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"ai.quickAction.colorPalette" => "为我的应用推荐配色方案",
|
||||
"ai.quickAction.colorPalettePrompt" => "为一个宠物护理应用推荐一套现代配色方案",
|
||||
"ai.quickAction.dashboard" => "面向公用事业公司的技术仪表板 Web 应用",
|
||||
"ai.quickAction.dashboardPrompt" => "面向公用事业公司的技术仪表板 Web 应用",
|
||||
"ai.quickAction.dashboard" => {
|
||||
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
|
||||
}
|
||||
"ai.quickAction.dashboardPrompt" => {
|
||||
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
|
||||
}
|
||||
"ai.quickAction.travelApp" => "旅行预订 App 发现页",
|
||||
"ai.quickAction.travelAppPrompt" => {
|
||||
"设计一个旅行预订App发现页。包含搜索区\"想去哪?\"输入框、日期选择标签、人数选择。热门目的地横向滑动卡片(目的地照片、城市名、起步价),\"本周特惠\"区2张特惠卡片带折扣标签,最近浏览区2张紧凑卡片。底部导航栏(发现、收藏、行程、消息、个人)。温暖友好的设计,橙色点缀。"
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"ai.noModelsConnected" => "尚未連線模型",
|
||||
"ai.searchModels" => "搜尋模型...",
|
||||
"ai.noModelsFound" => "未找到匹配的模型",
|
||||
"ai.modificationApplied" => "已修改",
|
||||
"ai.designElement" => "元素",
|
||||
"ai.designElements" => "元素",
|
||||
"ai.quickAction.loginScreen" => "設計行動裝置登入頁面",
|
||||
"ai.quickAction.loginScreenPrompt" => {
|
||||
"設計一個現代的行動裝置登入頁面,包含電子郵件輸入框、密碼輸入框、登入按鈕和社群登入選項"
|
||||
|
|
|
|||
114
crates/op-mcp/src/batch_program_objects.rs
Normal file
114
crates/op-mcp/src/batch_program_objects.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Public extraction helper for modify-script replay.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Extract raw `I(parent, object)` payloads from a recorded batch-design program.
|
||||
///
|
||||
/// Unlike the normal `batch_design` apply path, this keeps author-provided ids
|
||||
/// inside the object tree untouched. Modify uses that to diff against the live
|
||||
/// document before deciding which subtrees are truly new.
|
||||
pub fn parse_program_objects(program: &str) -> Vec<(String, Value)> {
|
||||
crate::batch_design::split_operations(program)
|
||||
.into_iter()
|
||||
.filter_map(|line| parse_program_object(&line))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_program_object(line: &str) -> Option<(String, Value)> {
|
||||
let trimmed = line.trim().trim_end_matches(';').trim();
|
||||
let call = match crate::batch_design::find_top_level_char(trimmed, '=') {
|
||||
Some(eq) => trimmed[eq + 1..].trim(),
|
||||
None => trimmed,
|
||||
};
|
||||
if !call.starts_with("I(") || !call.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
let body = &call[2..call.len() - 1];
|
||||
let comma = crate::batch_design::find_top_level_char(body, ',')?;
|
||||
let parent = parent_label(body[..comma].trim())?;
|
||||
let object = serde_json::from_str::<Value>(body[comma + 1..].trim()).ok()?;
|
||||
if object.is_object() {
|
||||
Some((parent, object))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_label(raw: &str) -> Option<String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty()
|
||||
|| matches!(
|
||||
raw,
|
||||
"null" | "undefined" | "0" | "\"\"" | "''" | "\"null\"" | "'null'"
|
||||
)
|
||||
{
|
||||
return Some("null".to_string());
|
||||
}
|
||||
if raw.starts_with('"') {
|
||||
return serde_json::from_str::<String>(raw).ok();
|
||||
}
|
||||
if raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2 {
|
||||
return Some(raw[1..raw.len() - 1].to_string());
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn program_object_extraction_preserves_real_ids() {
|
||||
let program = r#"
|
||||
I(null, {"id":"n217","type":"frame","name":"Mini Player","children":[{"id":"n217-row","type":"frame","name":"Controls","children":[]},{"type":"frame","name":"Progress Bar","children":[]}]});
|
||||
"#;
|
||||
|
||||
let objects = super::parse_program_objects(program);
|
||||
|
||||
assert_eq!(objects.len(), 1);
|
||||
assert_eq!(objects[0].0, "null");
|
||||
assert_eq!(objects[0].1["id"], serde_json::json!("n217"));
|
||||
assert_eq!(
|
||||
objects[0].1.pointer("/children/0/id"),
|
||||
Some(&serde_json::json!("n217-row"))
|
||||
);
|
||||
assert_eq!(
|
||||
objects[0].1.pointer("/children/1/name"),
|
||||
Some(&serde_json::json!("Progress Bar"))
|
||||
);
|
||||
assert!(objects[0].1.pointer("/children/1/id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "script")]
|
||||
fn script_program_object_extraction_preserves_real_ids() {
|
||||
let script = r#"
|
||||
I(null, {
|
||||
id: "n217",
|
||||
type: "frame",
|
||||
name: "Mini Player",
|
||||
children: [
|
||||
{ id: "n217-row", type: "frame", name: "Controls", children: [] },
|
||||
{ type: "frame", name: "Progress Bar", children: [] }
|
||||
]
|
||||
});
|
||||
"#;
|
||||
|
||||
let program = crate::script_runner::run_script_to_program(script).expect("script runs");
|
||||
let objects = super::parse_program_objects(&program);
|
||||
|
||||
assert_eq!(objects.len(), 1);
|
||||
assert_eq!(objects[0].0, "null");
|
||||
assert_eq!(objects[0].1["id"], serde_json::json!("n217"));
|
||||
assert_eq!(
|
||||
objects[0].1.pointer("/children/0/id"),
|
||||
Some(&serde_json::json!("n217-row"))
|
||||
);
|
||||
assert_eq!(
|
||||
objects[0].1.pointer("/children/1/name"),
|
||||
Some(&serde_json::json!("Progress Bar"))
|
||||
);
|
||||
assert!(
|
||||
objects[0].1.pointer("/children/1/id").is_none(),
|
||||
"new child must remain id-less for modify diffing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ mod batch_layered;
|
|||
mod batch_layered_tests;
|
||||
mod batch_page;
|
||||
mod batch_program;
|
||||
pub mod batch_program_objects;
|
||||
#[cfg(test)]
|
||||
mod batch_program_tests;
|
||||
pub mod bulk_vars;
|
||||
|
|
@ -149,6 +150,7 @@ pub use batch_design::{
|
|||
};
|
||||
pub use batch_design_result::{batch_design_snapshot, BatchDesign};
|
||||
pub use batch_get::{batch_get_snapshot, BatchGet};
|
||||
pub use batch_program_objects::parse_program_objects;
|
||||
pub use bulk_vars::{
|
||||
get_variables_snapshot, set_themes_snapshot, set_variables_snapshot, GetVariables, SetThemes,
|
||||
SetVariables,
|
||||
|
|
|
|||
Loading…
Reference in a new issue