diff --git a/crates/aether-win32/src/ai_panel.rs b/crates/aether-win32/src/ai_panel.rs index b1b4fc0..dec4bcf 100644 --- a/crates/aether-win32/src/ai_panel.rs +++ b/crates/aether-win32/src/ai_panel.rs @@ -155,6 +155,8 @@ pub enum AiRole { User, Assistant, System, + /// Agent 工具结果回喂:不作为用户消息显示,UI 渲染为简洁的工具卡片 + Tool, } /// 流式响应的共享状态 @@ -386,7 +388,21 @@ impl AiConversation { // 生成完成:自动折叠思考块,保持界面整洁 if let Some(last) = self.messages.last_mut() { last.stop_reasoning_timer(); - if last.role == AiRole::Assistant && last.reasoning.is_some() { + // 修复:当 content 为空但 reasoning 非空时(DeepSeek 思考模式简单问答场景), + // 将 reasoning 内容作为正常回答显示,而非放在"深度思考"区域 + if last.role == AiRole::Assistant + && last.content.trim().is_empty() + && last + .reasoning + .as_ref() + .is_some_and(|r| !r.trim().is_empty()) + { + last.content = last.reasoning.take().unwrap_or_default(); + last.reasoning = None; + last.reasoning_collapsed = false; + last.reasoning_ms = None; + last.reasoning_started_ms = None; + } else if last.role == AiRole::Assistant && last.reasoning.is_some() { last.reasoning_collapsed = true; } } @@ -1313,6 +1329,14 @@ impl AiPanel { self.sync_hot_data(); } + /// 添加工具结果消息(Agent 自续回喂):不作为用户气泡显示, + /// UI 渲染为简洁的工具操作卡片,语义上属于 agent 内部步骤。 + pub fn add_tool_message(&mut self, content: String) { + self.messages.push(AiMessage::new(AiRole::Tool, content)); + self.stick_to_bottom = true; + self.sync_hot_data(); + } + /// 发送消息(AI-H01: 非阻塞 — HTTP 调用在后台线程执行,结果通过 stream_state 流式返回) pub fn send_message(&mut self, settings: &AiSettings) -> Result { self.agent_iter_count = 0; @@ -1347,6 +1371,9 @@ impl AiPanel { /// Agent 工具结果回喂:把终端命令输出作为上下文再次发起请求,驱动 /// 「推理 → 执行 → 结果回喂 → 继续推理」循环。受最大轮次限制防止无限回环。 + /// + /// 与 `send_message_internal` 的区别:工具结果以 `AiRole::Tool` 消息记录, + /// 不作为用户气泡显示,避免用户困惑。 pub fn continue_agent_with_tool_result( &mut self, settings: &AiSettings, @@ -1358,7 +1385,36 @@ impl AiPanel { return Err(format!("已达最大自动执行轮次({})", MAX_AGENT_ITERATIONS)); } self.agent_iter_count += 1; - self.send_message_internal(settings, feedback, mode, None) + + if feedback.is_empty() { + return Err("工具结果为空".to_string()); + } + if self.is_generating { + return Err("正在等待上一次回复,请稍后再试".to_string()); + } + + // 工具结果以 Tool 角色记录(不显示为用户气泡) + self.add_tool_message(feedback.clone()); + self.is_generating = true; + self.should_stop.store(false, Ordering::SeqCst); + if let Ok(mut s) = self.stream_state.lock() { + *s = AiStreamState::default(); + } + + let settings = settings.clone(); + let context = String::new(); + let mut messages = build_chat_prompt(&settings, &context, mode); + let input_budget = settings + .max_input_tokens + .map(|v| v as usize) + .unwrap_or(24000); + messages.extend(Self::history_to_chat_messages(&self.messages, input_budget)); + let stream_state = Arc::clone(&self.stream_state); + let should_stop = Arc::clone(&self.should_stop); + + spawn_ai_stream(settings, messages, stream_state, should_stop); + + Ok("Agent 续跑已提交".to_string()) } /// 逐任务 worker 调用:以给定的 system/user 两条消息发起**聚焦**流式请求, @@ -1558,6 +1614,7 @@ impl AiPanel { .into_iter() .map(|m| match m.role { AiRole::User => ChatMessage::user(m.content.clone()), + AiRole::Tool => ChatMessage::user(m.content.clone()), _ => ChatMessage { role: "assistant".to_string(), content: m.content.clone(), @@ -1778,7 +1835,21 @@ impl AiPanel { // 生成完成:自动折叠思考块,保持界面整洁 if let Some(last) = self.messages.last_mut() { last.stop_reasoning_timer(); - if last.role == AiRole::Assistant && last.reasoning.is_some() { + // 修复:当 content 为空但 reasoning 非空时(DeepSeek 思考模式简单问答场景), + // 将 reasoning 内容作为正常回答显示,而非放在"深度思考"区域 + if last.role == AiRole::Assistant + && last.content.trim().is_empty() + && last + .reasoning + .as_ref() + .is_some_and(|r| !r.trim().is_empty()) + { + last.content = last.reasoning.take().unwrap_or_default(); + last.reasoning = None; + last.reasoning_collapsed = false; + last.reasoning_ms = None; + last.reasoning_started_ms = None; + } else if last.role == AiRole::Assistant && last.reasoning.is_some() { last.reasoning_collapsed = true; } } diff --git a/crates/aether-win32/src/ai_warm_data.rs b/crates/aether-win32/src/ai_warm_data.rs index af9f89e..56e1f85 100644 --- a/crates/aether-win32/src/ai_warm_data.rs +++ b/crates/aether-win32/src/ai_warm_data.rs @@ -500,6 +500,7 @@ fn role_to_str(role: &AiRole) -> &'static str { AiRole::User => "user", AiRole::Assistant => "assistant", AiRole::System => "system", + AiRole::Tool => "tool", } } @@ -507,6 +508,7 @@ fn str_to_role(s: &str) -> AiRole { match s { "user" => AiRole::User, "assistant" => AiRole::Assistant, + "tool" => AiRole::Tool, _ => AiRole::System, } } diff --git a/crates/aether-win32/src/editor/mod.rs b/crates/aether-win32/src/editor/mod.rs index b1a39a8..bc2a42f 100644 --- a/crates/aether-win32/src/editor/mod.rs +++ b/crates/aether-win32/src/editor/mod.rs @@ -1157,7 +1157,24 @@ fn language_to_lsp_id(lang: Language) -> &'static str { } } -impl EditorState {} +impl EditorState { + /// 将客户区逻辑坐标转换为屏幕物理坐标(用于 IME 候选窗口定位) + /// 逻辑坐标先乘以 dpi_scale 得到客户区物理坐标,再通过 ClientToScreen 转为屏幕坐标 + pub(crate) fn client_to_screen(&self, logical_x: f32, logical_y: f32) -> (i32, i32) { + use windows::Win32::Foundation::POINT; + use windows::Win32::Graphics::Gdi::ClientToScreen; + let physical_x = (logical_x * self.dpi_scale) as i32; + let physical_y = (logical_y * self.dpi_scale) as i32; + let mut pt = POINT { + x: physical_x, + y: physical_y, + }; + unsafe { + let _ = ClientToScreen(self.hwnd, &mut pt); + } + (pt.x, pt.y) + } +} /// 已知二进制文件扩展名(黑名单) const BINARY_EXTENSIONS: &[&str] = &[ diff --git a/crates/aether-win32/src/render/ai.rs b/crates/aether-win32/src/render/ai.rs index 2e46be0..ccae371 100644 --- a/crates/aether-win32/src/render/ai.rs +++ b/crates/aether-win32/src/render/ai.rs @@ -85,6 +85,14 @@ impl EditorState { Ok(b) => b, Err(_) => return, }; + let tool_bg_brush = match self + .render_ctx + .brush_cache + .get_brush(target, &color_f(0.13, 0.14, 0.16, 1.0)) + { + Ok(b) => b, + Err(_) => return, + }; let input_bg_brush = match self .render_ctx .brush_cache @@ -666,29 +674,32 @@ impl EditorState { continue; } let is_user = msg.role == crate::ai_panel::AiRole::User; + let is_tool = msg.role == crate::ai_panel::AiRole::Tool; - // 角色标签 - let label = if is_user { "你" } else { "AI" }; - let label_color: &ID2D1SolidColorBrush = - if is_user { &accent_brush } else { &green_brush }; - if msg_y + label_h >= chat_top && msg_y <= chat_bottom { - let label_wide: Vec = label.encode_utf16().chain(Some(0)).collect(); - let label_rect = D2D_RECT_F { - left: content_left + 4.0, - top: msg_y, - right: content_right, - bottom: msg_y + label_h, - }; - target.DrawText( - &label_wide, - &small_format, - &label_rect, - label_color, - D2D1_DRAW_TEXT_OPTIONS_NONE, - DWRITE_MEASURING_MODE_NATURAL, - ); + // 角色标签(Tool 消息不显示角色标签,渲染为简洁的工具结果行) + if !is_tool { + let label = if is_user { "你" } else { "AI" }; + let label_color: &ID2D1SolidColorBrush = + if is_user { &accent_brush } else { &green_brush }; + if msg_y + label_h >= chat_top && msg_y <= chat_bottom { + let label_wide: Vec = label.encode_utf16().chain(Some(0)).collect(); + let label_rect = D2D_RECT_F { + left: content_left + 4.0, + top: msg_y, + right: content_right, + bottom: msg_y + label_h, + }; + target.DrawText( + &label_wide, + &small_format, + &label_rect, + label_color, + D2D1_DRAW_TEXT_OPTIONS_NONE, + DWRITE_MEASURING_MODE_NATURAL, + ); + } + msg_y += label_h; } - msg_y += label_h; // 思考过程(DeepSeek 深度思考 reasoning_content):独立分类、可折叠展示。 // 与"回答"、"操作卡片"分开,视觉上弱化(紫灰、缩进、左强调条)。 @@ -818,8 +829,8 @@ impl EditorState { // 将消息拆为渲染项:文本/代码段 + AI 文件/命令操作卡片。 // 助手消息里的 <<<<<<< FILE/RUN >>>>>>> 标记转为清晰的操作卡片,隐藏原始标记; - // 用户消息无标记,整体作为一段文本。 - let display_blocks = if is_user { + // 用户消息无标记,整体作为一段文本;Tool 消息同样整体作为一段文本。 + let display_blocks = if is_user || is_tool { vec![crate::ai_agent::AgentDisplayBlock::Text( msg.content.clone(), )] @@ -1174,6 +1185,8 @@ impl EditorState { &code_bg_brush } else if is_user { &user_bg_brush + } else if is_tool { + &tool_bg_brush } else { &assistant_bg_brush }; @@ -1187,6 +1200,8 @@ impl EditorState { let seg_fg: &ID2D1SolidColorBrush = if *is_code { &code_text_brush + } else if is_tool { + &dim_brush } else { text_brush }; @@ -1196,8 +1211,8 @@ impl EditorState { }; target.DrawTextLayout(origin, &layout, seg_fg, D2D1_DRAW_TEXT_OPTIONS_NONE); - // 代码块添加"保存为文件"按钮 - if *is_code && !is_user && !seg_text.is_empty() { + // 代码块添加"保存为文件"按钮(仅 AI 助手消息) + if *is_code && !is_user && !is_tool && !seg_text.is_empty() { let save_btn_w = 60.0f32; let save_btn_h = 18.0f32; let save_btn_x = content_right - save_btn_w - 4.0; @@ -2106,6 +2121,7 @@ impl EditorState { crate::ai_panel::AiRole::User => "我", crate::ai_panel::AiRole::Assistant => "AI", crate::ai_panel::AiRole::System => "系统", + crate::ai_panel::AiRole::Tool => "工具", }; let content: String = msg.content.trim().chars().take(40).collect(); let line: Vec = format!("{}: {}", role, content) diff --git a/crates/aether-win32/src/render/editor_view.rs b/crates/aether-win32/src/render/editor_view.rs index 92078ff..a033431 100644 --- a/crates/aether-win32/src/render/editor_view.rs +++ b/crates/aether-win32/src/render/editor_view.rs @@ -511,14 +511,12 @@ impl EditorState { let line_h_logical = 14.0; let term_x_logical = term_region.x + 8.0 + prefix_x_logical; let term_y_logical = term_region.y + 24.0 + t_row as f32 * line_h_logical; - self.ime.set_composition_window_position( - (term_x_logical * self.dpi_scale) as i32, - (term_y_logical * self.dpi_scale) as i32, - ); - self.ime.set_candidate_window_position( - (term_x_logical * self.dpi_scale) as i32, - ((term_y_logical + line_h_logical) * self.dpi_scale) as i32, - ); + // 转换为屏幕坐标(IME API 需要屏幕坐标) + let (comp_x, comp_y) = self.client_to_screen(term_x_logical, term_y_logical); + let (cand_x, cand_y) = + self.client_to_screen(term_x_logical, term_y_logical + line_h_logical); + self.ime.set_composition_window_position(comp_x, comp_y); + self.ime.set_candidate_window_position(cand_x, cand_y); } else if self.file_tree_input.is_some() { let sidebar = self.layout.sidebar_region(); // 候选窗口跟随树内输入行(几何与渲染共用 file_tree_input_row_geom) @@ -532,16 +530,21 @@ impl EditorState { .map(|i| i.value.chars().count()) .unwrap_or(0); let ft_cursor_x = sidebar.x + text_left_rel + value_chars as f32 * 6.0 * s; - self.ime.set_candidate_window_position( - (ft_cursor_x * self.dpi_scale) as i32, - ((sidebar.y + top_rel + row_h) * self.dpi_scale) as i32, - ); + // 转换为屏幕坐标(IME API 需要屏幕坐标) + let (cand_x, cand_y) = + self.client_to_screen(ft_cursor_x, sidebar.y + top_rel + row_h); + self.ime.set_candidate_window_position(cand_x, cand_y); } } else if self.ai_panel.input_focused { // AI 面板输入框聚焦时,IME 候选窗口定位到 AI 输入框 + // 位置计算与 render/ai.rs 中的输入框渲染保持一致 let rp = self.layout.right_panel_region(); - let ai_input_y = rp.y + rp.height - 40.0 + 7.0; // 输入框顶部 + padding - let ai_value_x = rp.x + 12.0 + 8.0; // margin + padding + let margin = 12.0f32; + let input_area_h = 80.0f32; + let input_y = rp.y + rp.height - input_area_h; // 输入区域顶部 + let text_input_y = input_y + 6.0; // 文本输入框顶部(与 ai.rs 一致) + let text_input_h = 36.0f32; + let ai_value_x = rp.x + margin + 8.0 + 4.0; // margin + input_margin + padding let ai_input_width = self .render_ctx .text_format_cache @@ -552,23 +555,19 @@ impl EditorState { ) .unwrap_or(0.0); let ai_cursor_x = ai_value_x + ai_input_width; - self.ime.set_composition_window_position( - (ai_cursor_x * self.dpi_scale) as i32, - (ai_input_y * self.dpi_scale) as i32, - ); - self.ime.set_candidate_window_position( - (ai_cursor_x * self.dpi_scale) as i32, - ((ai_input_y + 24.0) * self.dpi_scale) as i32, - ); + // 转换为屏幕坐标(IME API 需要屏幕坐标) + // 合成窗口在文本输入框顶部,候选窗口在文本输入框下方 + let (comp_x, comp_y) = self.client_to_screen(ai_cursor_x, text_input_y); + let (cand_x, cand_y) = + self.client_to_screen(ai_cursor_x, text_input_y + text_input_h); + self.ime.set_composition_window_position(comp_x, comp_y); + self.ime.set_candidate_window_position(cand_x, cand_y); } else { - self.ime.set_composition_window_position( - (cursor_x * self.dpi_scale) as i32, - (cursor_y * self.dpi_scale) as i32, - ); - self.ime.set_candidate_window_position( - (cursor_x * self.dpi_scale) as i32, - ((cursor_y + line_height) * self.dpi_scale) as i32, - ); + // 转换为屏幕坐标(IME API 需要屏幕坐标) + let (comp_x, comp_y) = self.client_to_screen(cursor_x, cursor_y); + let (cand_x, cand_y) = self.client_to_screen(cursor_x, cursor_y + line_height); + self.ime.set_composition_window_position(comp_x, comp_y); + self.ime.set_candidate_window_position(cand_x, cand_y); } if cursor_y >= y && cursor_y <= y + height && self.content.caret_visible { // P0-2: 若存在 IME 合成串,渲染合成串文本 + 下划线,光标隐藏 diff --git a/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs b/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs index 23f81ee..2b4595b 100644 --- a/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs +++ b/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs @@ -390,7 +390,11 @@ pub(super) unsafe fn lbd_right_panel( continue; } let is_user = msg.role == crate::ai_panel::AiRole::User; - msg_y += label_h; + let is_tool = msg.role == crate::ai_panel::AiRole::Tool; + // Tool 消息无角色标签行 + if !is_tool { + msg_y += label_h; + } // 按 ``` 代码围栏拆分 let mut segments: Vec<(bool, String)> = Vec::new();