feat(editor): selection targets the chat turn and shows in the panel
An active canvas selection now biases intent routing to modify (the selection IS the target — only an explicit new-whole-screen request or a plain chat question escapes it), restoring select-then-ask editing. The panel paints a selected-count chip above the input with a clear affordance, so the armed state is visible. Chat-question CJK keywords keep questions about a selection conversational, and the footer hover math reserves the chip row like paint does.
This commit is contained in:
parent
e891552970
commit
a3540ac2e6
|
|
@ -63,6 +63,9 @@ pub enum AIChatHit {
|
|||
/// Click on a staged-attachment chip — payload is the index
|
||||
/// into `chat.pending_attachments` to drop.
|
||||
RemoveAttachment(usize),
|
||||
/// Click on the selected-count chip's clear target — host clears
|
||||
/// the current canvas selection.
|
||||
ClearSelection,
|
||||
/// Click on a message's thinking-block header — host toggles
|
||||
/// `ChatMessage::thinking_collapsed` for that message index.
|
||||
ToggleThinking(usize),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ pub(crate) const RESIZE_GUTTER: f32 = 4.0;
|
|||
pub(crate) const RESIZE_CORNER: f32 = 12.0;
|
||||
pub(crate) const INPUT_AREA_HEIGHT: f32 = 56.0;
|
||||
pub(crate) const INPUT_TOOLBAR_HEIGHT: f32 = 40.0;
|
||||
pub(crate) const SELECTION_CHIP_ROW_HEIGHT: f32 = 28.0;
|
||||
const SELECTION_CHIP_HEIGHT: f32 = 20.0;
|
||||
const SELECTION_CHIP_PAD_X: f32 = 10.0;
|
||||
const SELECTION_CHIP_CLEAR_W: f32 = 18.0;
|
||||
const SELECTION_CHIP_FONT: f32 = 11.0;
|
||||
#[cfg(test)]
|
||||
const INPUT_BASE_HEIGHT: f32 = INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT;
|
||||
|
||||
|
|
@ -210,6 +215,48 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Height of the selected-count chip row — `0` when no canvas node is selected.
|
||||
pub(crate) fn selection_chip_row_h(&self) -> f32 {
|
||||
if self.selected_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
SELECTION_CHIP_ROW_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_chip_label(&self) -> String {
|
||||
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> {
|
||||
if self.selected_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let label = self.selection_chip_label();
|
||||
let label_w = footer_label_width(&label, SELECTION_CHIP_FONT);
|
||||
let chip_w = (SELECTION_CHIP_PAD_X + label_w + 6.0 + SELECTION_CHIP_CLEAR_W)
|
||||
.min(input_rect.size.x)
|
||||
.max(56.0);
|
||||
Some(Rect {
|
||||
origin: Point2D::new(
|
||||
input_rect.origin.x,
|
||||
input_rect.origin.y + (SELECTION_CHIP_ROW_HEIGHT - SELECTION_CHIP_HEIGHT) / 2.0,
|
||||
),
|
||||
size: Point2D::new(chip_w, SELECTION_CHIP_HEIGHT),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn selection_chip_clear_rect(&self, input_rect: Rect) -> Option<Rect> {
|
||||
self.selection_chip_rect(input_rect).map(|chip| Rect {
|
||||
origin: Point2D::new(
|
||||
chip.origin.x + chip.size.x - SELECTION_CHIP_CLEAR_W,
|
||||
chip.origin.y,
|
||||
),
|
||||
size: Point2D::new(SELECTION_CHIP_CLEAR_W, chip.size.y),
|
||||
})
|
||||
}
|
||||
|
||||
/// Total input-block height, including the attachment row when
|
||||
/// attachments are staged.
|
||||
#[cfg(test)]
|
||||
|
|
@ -232,7 +279,10 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
|
||||
pub(crate) fn input_height_for_width(&self, panel_w: f32) -> f32 {
|
||||
self.input_area_height_for_width(panel_w) + INPUT_TOOLBAR_HEIGHT + self.attachment_row_h()
|
||||
self.selection_chip_row_h()
|
||||
+ self.input_area_height_for_width(panel_w)
|
||||
+ INPUT_TOOLBAR_HEIGHT
|
||||
+ self.attachment_row_h()
|
||||
}
|
||||
|
||||
pub(crate) fn input_area_height_for_rect(&self, rect: Rect) -> f32 {
|
||||
|
|
@ -283,8 +333,10 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
&self.state.available_models,
|
||||
self.model_picker_input.text(),
|
||||
);
|
||||
let toolbar_top =
|
||||
input_rect.origin.y + self.input_area_height_for_rect(rect) + self.attachment_row_h();
|
||||
let toolbar_top = input_rect.origin.y
|
||||
+ self.selection_chip_row_h()
|
||||
+ self.input_area_height_for_rect(rect)
|
||||
+ self.attachment_row_h();
|
||||
let bottom = toolbar_top - 4.0;
|
||||
Rect {
|
||||
origin: Point2D::new(rect.origin.x + PAD, bottom - height),
|
||||
|
|
@ -348,6 +400,41 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_selection_chip(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
widget: &AIChatPlaceholder<'_>,
|
||||
input_rect: Rect,
|
||||
) {
|
||||
let Some(chip) = widget.selection_chip_rect(input_rect) else {
|
||||
return;
|
||||
};
|
||||
let label = widget.selection_chip_label();
|
||||
cx.backend.fill_round_rect(chip, 6.0, theme.muted);
|
||||
let label_layout = TextLayout::single_run(
|
||||
&label,
|
||||
"system-ui",
|
||||
SELECTION_CHIP_FONT,
|
||||
(theme.muted_foreground).to_jian(),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
let baseline_y = chip.origin.y + chip.size.y / 2.0 + SELECTION_CHIP_FONT * 0.35;
|
||||
cx.backend.draw_text(
|
||||
&label_layout,
|
||||
Point2D::new(chip.origin.x + SELECTION_CHIP_PAD_X, baseline_y),
|
||||
);
|
||||
let clear_layout = TextLayout::single_run(
|
||||
"×",
|
||||
"system-ui",
|
||||
SELECTION_CHIP_FONT,
|
||||
(theme.muted_foreground).to_jian(),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
let clear_x = chip.origin.x + chip.size.x - SELECTION_CHIP_CLEAR_W / 2.0 - 3.0;
|
||||
cx.backend
|
||||
.draw_text(&clear_layout, Point2D::new(clear_x, baseline_y));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct FooterLayout {
|
||||
/// Model picker pill — left anchor of the toolbar.
|
||||
|
|
@ -601,8 +688,11 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
self.state.checklist_scroll.offset,
|
||||
);
|
||||
}
|
||||
let input_block_rect = self.input_rect(rect);
|
||||
paint_selection_chip(cx, &self.theme, self, input_block_rect);
|
||||
let selection_h = self.selection_chip_row_h();
|
||||
let input_rect = Rect {
|
||||
origin: Point2D::new(rect.origin.x + PAD, sep_y + 1.0),
|
||||
origin: Point2D::new(rect.origin.x + PAD, sep_y + 1.0 + selection_h),
|
||||
size: Point2D::new(
|
||||
rect.size.x - PAD * 2.0,
|
||||
self.input_area_height_for_rect(rect),
|
||||
|
|
@ -692,3 +782,7 @@ mod tests_paint;
|
|||
#[cfg(test)]
|
||||
#[path = "ai_chat_panel/tests_transcript.rs"]
|
||||
mod tests_transcript;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ai_chat_panel/tests_selected_chip.rs"]
|
||||
mod tests_selected_chip;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
//! Selected-count chip layout, paint, and hit-test tests.
|
||||
|
||||
use super::*;
|
||||
use crate::widgets::ai_chat_hit::AIChatHit;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SelectedChipPaintBackend {
|
||||
texts: Vec<(String, f32, jian_core::scene::Color, Point2D)>,
|
||||
}
|
||||
|
||||
impl crate::RenderBackend for SelectedChipPaintBackend {
|
||||
fn begin_frame(&mut self) {}
|
||||
fn end_frame(&mut self) {}
|
||||
fn fill_rect(&mut self, _: Rect, _: crate::Color) {}
|
||||
fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {}
|
||||
fn draw_text(&mut self, layout: &crate::TextLayout, origin: Point2D) {
|
||||
if let Some(run) = layout.runs().first() {
|
||||
self.texts
|
||||
.push((run.content.clone(), run.font_size, run.color, origin));
|
||||
}
|
||||
}
|
||||
fn clip_rect(&mut self, _: Rect) {}
|
||||
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {}
|
||||
fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) {}
|
||||
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
|
||||
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: crate::Color, _: f32) {}
|
||||
fn save(&mut self) {}
|
||||
fn restore(&mut self) {}
|
||||
fn translate(&mut self, _: Point2D) {}
|
||||
fn resize(&mut self, _: u32, _: u32) {}
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_with_selection(count: usize) -> (EditorState, Rect) {
|
||||
let mut state = EditorState::new();
|
||||
state.selection.set = (0..count)
|
||||
.map(|idx| op_editor_core::NodeId::new(format!("n{idx}")))
|
||||
.collect();
|
||||
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 {
|
||||
backend: &mut backend,
|
||||
};
|
||||
panel.paint(&mut cx, rect);
|
||||
backend
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_zero_has_no_chip_row() {
|
||||
let (state, rect) = panel_with_selection(0);
|
||||
let panel = AIChatPlaceholder::from_editor(&state);
|
||||
|
||||
assert_eq!(panel.selection_chip_row_h(), 0.0);
|
||||
assert_eq!(panel.input_height(), INPUT_BASE_HEIGHT);
|
||||
|
||||
let backend = paint_panel(&panel, rect);
|
||||
assert!(!backend
|
||||
.texts
|
||||
.iter()
|
||||
.any(|(text, _, _, _)| { text.contains("selected") || text.contains("已选择") }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_three_paints_chip_text() {
|
||||
let (state, rect) = panel_with_selection(3);
|
||||
let panel = AIChatPlaceholder::from_editor(&state);
|
||||
|
||||
assert_eq!(panel.selection_chip_row_h(), SELECTION_CHIP_ROW_HEIGHT);
|
||||
assert_eq!(
|
||||
panel.input_height(),
|
||||
INPUT_BASE_HEIGHT + SELECTION_CHIP_ROW_HEIGHT
|
||||
);
|
||||
|
||||
let backend = paint_panel(&panel, rect);
|
||||
assert!(backend
|
||||
.texts
|
||||
.iter()
|
||||
.any(|(text, size, _, _)| text.contains('3') && (*size - 11.0).abs() < 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_chip_clear_hit_returns_clear_selection() {
|
||||
let (state, rect) = panel_with_selection(3);
|
||||
let panel = AIChatPlaceholder::from_editor(&state);
|
||||
let chip = panel.selection_chip_rect(panel.input_rect(rect)).unwrap();
|
||||
let point = Point2D::new(
|
||||
chip.origin.x + chip.size.x - 9.0,
|
||||
chip.origin.y + chip.size.y / 2.0,
|
||||
);
|
||||
|
||||
assert_eq!(panel.hit_test(rect, point), Some(AIChatHit::ClearSelection));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_chip_uses_zh_cn_template() {
|
||||
let (mut state, rect) = panel_with_selection(3);
|
||||
state.editor_ui.locale = op_editor_core::Locale::ZhCn;
|
||||
let panel = AIChatPlaceholder::from_editor(&state);
|
||||
|
||||
let backend = paint_panel(&panel, rect);
|
||||
assert!(backend
|
||||
.texts
|
||||
.iter()
|
||||
.any(|(text, _, _, _)| text == "已选择 3 个"));
|
||||
}
|
||||
|
|
@ -128,7 +128,10 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
if self.parallel_agents_picker_open {
|
||||
let footer = self.footer_layout(rect, input_rect, {
|
||||
let attach_h = self.attachment_row_h();
|
||||
input_rect.origin.y + self.input_area_height_for_rect(rect) + attach_h
|
||||
input_rect.origin.y
|
||||
+ self.selection_chip_row_h()
|
||||
+ self.input_area_height_for_rect(rect)
|
||||
+ attach_h
|
||||
});
|
||||
if let Some(picker) = self.parallel_agents_picker_rect(rect, &footer) {
|
||||
if picker.contains(point) {
|
||||
|
|
@ -151,9 +154,20 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
return Some(AIChatHit::ToggleParallelAgentsPicker);
|
||||
}
|
||||
if (input_rect).contains(point) {
|
||||
let attach_top = input_rect.origin.y + input_area_h;
|
||||
let selection_h = self.selection_chip_row_h();
|
||||
let text_top = input_rect.origin.y + selection_h;
|
||||
let attach_top = text_top + input_area_h;
|
||||
let attach_h = self.attachment_row_h();
|
||||
let toolbar_top = attach_top + attach_h;
|
||||
if selection_h > 0.0 && point.y < text_top {
|
||||
if self
|
||||
.selection_chip_clear_rect(input_rect)
|
||||
.is_some_and(|clear| clear.contains(point))
|
||||
{
|
||||
return Some(AIChatHit::ClearSelection);
|
||||
}
|
||||
return Some(AIChatHit::FocusInput);
|
||||
}
|
||||
if point.y < attach_top {
|
||||
if self.is_streaming() {
|
||||
return Some(AIChatHit::Inside);
|
||||
|
|
@ -162,7 +176,7 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
return Some(AIChatHit::FocusInput);
|
||||
}
|
||||
let text_area = Rect {
|
||||
origin: input_rect.origin,
|
||||
origin: Point2D::new(input_rect.origin.x, text_top),
|
||||
size: Point2D::new(input_rect.size.x, input_area_h),
|
||||
};
|
||||
let offset = crate::widgets::ai_chat_input_text::input_text_offset_at(
|
||||
|
|
@ -428,7 +442,13 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
return None;
|
||||
}
|
||||
let attach_h = self.attachment_row_h();
|
||||
let toolbar_top = input_rect.origin.y + self.input_area_height_for_rect(rect) + attach_h;
|
||||
// The chip row sits at the top of the input block — hover math must
|
||||
// reserve it like paint does, or every footer band shifts up by the
|
||||
// row height whenever a selection is active.
|
||||
let toolbar_top = input_rect.origin.y
|
||||
+ self.selection_chip_row_h()
|
||||
+ self.input_area_height_for_rect(rect)
|
||||
+ attach_h;
|
||||
if point.y < toolbar_top {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,8 +237,14 @@ fn should_launch_direct_modify(state: &EditorState, user_text: &str) -> bool {
|
|||
// A whole-screen draw request ("继续画一下 search 页面") must reach the
|
||||
// design pipeline's new-frame route, never get hijacked into editing the
|
||||
// existing frame in place — even when it also trips the modify classifier.
|
||||
!op_host_services::chat_intent::requests_new_whole_screen(user_text)
|
||||
&& op_host_services::chat_intent::looks_like_modify_request(user_text)
|
||||
if op_host_services::chat_intent::requests_new_whole_screen(user_text) {
|
||||
return false;
|
||||
}
|
||||
let keyword_intent = op_host_services::chat_intent::classify_by_keywords(user_text);
|
||||
let selected_target_instruction = !state.selection.set.is_empty()
|
||||
&& keyword_intent != op_host_services::chat_intent::DesignIntent::Chat;
|
||||
(keyword_intent == op_host_services::chat_intent::DesignIntent::Modify
|
||||
|| selected_target_instruction)
|
||||
&& op_host_services::chat_intent::build_modify_plan(state, user_text).is_some()
|
||||
}
|
||||
|
||||
|
|
@ -756,3 +762,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chat_session_launch_selection_tests.rs"]
|
||||
mod selection_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Selection-biased direct-modify launch tests for builtin / ACP routing.
|
||||
|
||||
use super::*;
|
||||
use op_editor_core::pen_node_ext::PenNodeExt;
|
||||
|
||||
fn frame(
|
||||
id: &str,
|
||||
name: &str,
|
||||
children: Vec<jian_ops_schema::node::PenNode>,
|
||||
) -> jian_ops_schema::node::PenNode {
|
||||
let mut node: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"width": 390,
|
||||
"height": 120,
|
||||
"children": []
|
||||
}))
|
||||
.expect("frame fixture");
|
||||
if let Some(kids) = node.children_mut() {
|
||||
*kids = children;
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn state_with_selected_card() -> EditorState {
|
||||
let mut state = EditorState::new();
|
||||
state.active_children_mut().clear();
|
||||
state.active_children_mut().push(frame(
|
||||
"screen",
|
||||
"Food App Home",
|
||||
vec![frame("popular-card", "Bella Napoli Pizzeria", Vec::new())],
|
||||
));
|
||||
state.set_single_selection(op_editor_core::NodeId::new("popular-card"));
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_with_keywordless_instruction_launches_direct_modify() {
|
||||
let state = state_with_selected_card();
|
||||
|
||||
assert!(
|
||||
should_launch_direct_modify(&state, "给它加一个边框"),
|
||||
"selected existing design + keyword-less edit wording should update in place"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_does_not_hijack_whole_new_screen_or_chat() {
|
||||
let state = state_with_selected_card();
|
||||
|
||||
assert!(
|
||||
!should_launch_direct_modify(&state, "重新画一个首页"),
|
||||
"whole-screen draw request must keep the new-design route"
|
||||
);
|
||||
assert!(
|
||||
!should_launch_direct_modify(&state, "这是什么字体"),
|
||||
"plain chat questions must not become direct modify requests"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_selection_modify_keyword_behavior_is_unchanged() {
|
||||
let mut state = state_with_selected_card();
|
||||
state.clear_selection();
|
||||
|
||||
assert!(
|
||||
should_launch_direct_modify(&state, "修改成饺子"),
|
||||
"explicit modify wording should still launch direct modify without a selection"
|
||||
);
|
||||
}
|
||||
|
|
@ -58,6 +58,35 @@ fn variable_row_input_keeps_resume_time_redraws_active() {
|
|||
assert_eq!(app.host.next_animation_deadline_ms(), Some(740));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_count_chip_clear_click_clears_canvas_selection() {
|
||||
let mut app = DesktopApp::new(None);
|
||||
app.host.editor_state_mut().selection.set = vec![
|
||||
op_editor_core::NodeId::new("n1"),
|
||||
op_editor_core::NodeId::new("n2"),
|
||||
];
|
||||
app.host.editor_state_mut().chat.panel_position = Some((100.0, 100.0));
|
||||
let chat = &app.host.editor_state().chat;
|
||||
let chat_rect = op_editor_ui::Rect::xywh(100.0, 100.0, chat.panel_width, chat.panel_height);
|
||||
let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor(app.host.editor_state());
|
||||
let input = panel.input_rect(chat_rect);
|
||||
let clear_point = (0..160)
|
||||
.flat_map(|dx| (0..28).map(move |dy| (dx, dy)))
|
||||
.map(|(dx, dy)| {
|
||||
op_editor_ui::Point2D::new(input.origin.x + dx as f32, input.origin.y + dy as f32)
|
||||
})
|
||||
.find(|point| {
|
||||
panel.hit_test(chat_rect, *point)
|
||||
== Some(op_editor_ui::widgets::AIChatHit::ClearSelection)
|
||||
})
|
||||
.expect("clear-selection hit point");
|
||||
|
||||
assert!(app
|
||||
.host
|
||||
.apply_click(clear_point.x, clear_point.y, 1200.0, 800.0));
|
||||
assert!(app.host.editor_state().selection.set.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_app_fits_blank_frame_like_ts_canvas_init() {
|
||||
let app = DesktopApp::new(None);
|
||||
|
|
|
|||
|
|
@ -301,6 +301,11 @@ impl WidgetHostNative {
|
|||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::ClearSelection => {
|
||||
self.editor_state.clear_selection();
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::ToggleThinking(idx) => {
|
||||
self.editor_state.chat.toggle_message_thinking(idx);
|
||||
self.mark_dirty();
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ const MODIFY_CJK: &[&str] = &[
|
|||
const CHAT_KEYWORDS: &[&str] = &[
|
||||
"what is", "how do", "explain", "tell me", "help", "why", "can you", "question", "describe",
|
||||
];
|
||||
const CHAT_CJK: &[&str] = &["是什么", "什么", "怎么", "为什么", "解释", "说明", "帮助"];
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
// JS `\w` — ASCII alphanumeric plus underscore.
|
||||
|
|
@ -140,7 +141,8 @@ fn matches_any_word_phrase(text_lower: &str, phrases: &[&str]) -> bool {
|
|||
/// TS `classifyByKeywords` — verbatim rule order.
|
||||
pub fn classify_by_keywords(text: &str) -> DesignIntent {
|
||||
let lower = text.to_lowercase();
|
||||
let chat = matches_any_word_phrase(&lower, CHAT_KEYWORDS);
|
||||
let chat =
|
||||
matches_any_word_phrase(&lower, CHAT_KEYWORDS) || CHAT_CJK.iter().any(|k| text.contains(k));
|
||||
let modify = matches_any_word_phrase(&lower, MODIFY_KEYWORDS)
|
||||
|| MODIFY_CJK.iter().any(|k| text.contains(k));
|
||||
if chat && !modify {
|
||||
|
|
@ -162,6 +164,7 @@ pub fn looks_like_modify_request(text: &str) -> bool {
|
|||
/// prompts; keep those on the modify path before asking the model.
|
||||
pub fn classify_intent_for_standard_route(
|
||||
provider: &dyn ChatProvider,
|
||||
state: &EditorState,
|
||||
text: &str,
|
||||
model: Option<String>,
|
||||
) -> DesignIntent {
|
||||
|
|
@ -173,7 +176,13 @@ pub fn classify_intent_for_standard_route(
|
|||
if requests_new_whole_screen(text) {
|
||||
return DesignIntent::New;
|
||||
}
|
||||
if looks_like_modify_request(text) {
|
||||
let keyword_intent = classify_by_keywords(text);
|
||||
if !state.selection.set.is_empty() && keyword_intent == DesignIntent::Chat {
|
||||
return DesignIntent::Chat;
|
||||
}
|
||||
let selected_target_instruction =
|
||||
!state.selection.set.is_empty() && keyword_intent != DesignIntent::Chat;
|
||||
if keyword_intent == DesignIntent::Modify || selected_target_instruction {
|
||||
return DesignIntent::Modify;
|
||||
}
|
||||
if is_named_follow_on_screen(text) {
|
||||
|
|
@ -839,6 +848,7 @@ pub fn run_cli_turn(
|
|||
) {
|
||||
let classified = classify_intent_for_standard_route(
|
||||
plan.classify_provider.as_ref(),
|
||||
&plan.initial_state,
|
||||
&plan.user_text,
|
||||
plan.model.clone(),
|
||||
);
|
||||
|
|
@ -982,3 +992,7 @@ pub fn run_modify_turn(
|
|||
#[cfg(test)]
|
||||
#[path = "chat_intent_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chat_intent_selection_tests.rs"]
|
||||
mod selection_tests;
|
||||
|
|
|
|||
84
crates/op-host-services/src/chat_intent_selection_tests.rs
Normal file
84
crates/op-host-services/src/chat_intent_selection_tests.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//! Selection-biased standard-route intent tests.
|
||||
|
||||
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, StopReason};
|
||||
use op_editor_core::EditorState;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Scripted;
|
||||
|
||||
impl ChatProvider for Scripted {
|
||||
fn provider_label(&self) -> &str {
|
||||
"scripted"
|
||||
}
|
||||
|
||||
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
|
||||
Box::new(
|
||||
vec![
|
||||
ChatDelta::TextDelta("DESIGN_NEW".to_string()),
|
||||
ChatDelta::Done {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.inspect(|_| std::thread::sleep(Duration::ZERO)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(id: &str, name: &str, children: Vec<PenNode>) -> PenNode {
|
||||
let mut node: PenNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 390.0,
|
||||
"height": 800.0,
|
||||
"children": [],
|
||||
}))
|
||||
.expect("valid frame json");
|
||||
if let Some(kids) = node.children_mut() {
|
||||
*kids = children;
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn state_with_selected_card() -> EditorState {
|
||||
let mut state = EditorState::new();
|
||||
state.active_children_mut().clear();
|
||||
state.active_children_mut().push(frame(
|
||||
"screen",
|
||||
"Home",
|
||||
vec![frame("card", "Selected Card", Vec::new())],
|
||||
));
|
||||
state.set_single_selection(op_editor_core::NodeId::new("card"));
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bias_routes_keywordless_instruction_to_modify() {
|
||||
let provider = Scripted;
|
||||
let state = state_with_selected_card();
|
||||
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, &state, "给它加一个边框", None),
|
||||
DesignIntent::Modify
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bias_does_not_hijack_whole_new_screen_or_chat() {
|
||||
let provider = Scripted;
|
||||
let state = state_with_selected_card();
|
||||
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, &state, "重新画一个首页", None),
|
||||
DesignIntent::New
|
||||
);
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, &state, "这是什么字体", None),
|
||||
DesignIntent::Chat
|
||||
);
|
||||
}
|
||||
|
|
@ -351,14 +351,15 @@ fn whole_screen_draw_overrides_modify_in_standard_route() {
|
|||
// search 页面" has 修改 + 画 + 页面) must route to New, not Modify — else
|
||||
// it edits the existing frame instead of opening a new one.
|
||||
let provider = Scripted::text("DESIGN_MODIFY");
|
||||
let state = state_with_page();
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "修改后重新画一个search 页面", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "修改后重新画一个search 页面", None),
|
||||
DesignIntent::New,
|
||||
"a whole-screen draw must win over the modify classifier"
|
||||
);
|
||||
// But a genuine edit of the current screen still routes to Modify.
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "把这个页面改成深色", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "把这个页面改成深色", None),
|
||||
DesignIntent::Modify,
|
||||
"editing the current screen stays on the modify route"
|
||||
);
|
||||
|
|
@ -370,18 +371,19 @@ fn english_page_edits_stay_on_modify_route() {
|
|||
// for a new-screen draw — it needs a creation verb (draw/create/design/…),
|
||||
// which "change"/"resize"/"make" are not.
|
||||
let provider = Scripted::text("CHAT");
|
||||
let state = state_with_page();
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "change the home page layout", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "change the home page layout", None),
|
||||
DesignIntent::Modify,
|
||||
"editing an existing page is a modify, not a new design"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "resize the screen header", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "resize the screen header", None),
|
||||
DesignIntent::Modify,
|
||||
);
|
||||
// A genuine English page DRAW still routes to New.
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "draw a checkout page", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "draw a checkout page", None),
|
||||
DesignIntent::New,
|
||||
"a creation verb + page noun is a new screen"
|
||||
);
|
||||
|
|
@ -418,8 +420,9 @@ fn whole_screen_draw_requests_llm_design_md_extraction() {
|
|||
#[test]
|
||||
fn named_follow_on_page_forces_new_route_before_llm_classifier() {
|
||||
let provider = Scripted::text("CHAT");
|
||||
let state = state_with_page();
|
||||
assert_eq!(
|
||||
classify_intent_for_standard_route(&provider, "继续画出发现页", None),
|
||||
classify_intent_for_standard_route(&provider, &state, "继续画出发现页", None),
|
||||
DesignIntent::New,
|
||||
"named app pages must not be classified as plain chat or append"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ pub fn stream_standard_turn<W: Write>(
|
|||
|
||||
let classified = crate::chat_intent::classify_intent_for_standard_route(
|
||||
classify_provider.as_ref(),
|
||||
&snapshot,
|
||||
&req.ai.user,
|
||||
model.clone(),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue