Skip to content
Merged

Dev #107

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions crates/aether-win32/src/ai_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ pub enum AiRole {
User,
Assistant,
System,
/// Agent 工具结果回喂:不作为用户消息显示,UI 渲染为简洁的工具卡片
Tool,
}

/// 流式响应的共享状态
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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<String, String> {
self.agent_iter_count = 0;
Expand Down Expand Up @@ -1347,6 +1371,9 @@ impl AiPanel {

/// Agent 工具结果回喂:把终端命令输出作为上下文再次发起请求,驱动
/// 「推理 → 执行 → 结果回喂 → 继续推理」循环。受最大轮次限制防止无限回环。
///
/// 与 `send_message_internal` 的区别:工具结果以 `AiRole::Tool` 消息记录,
/// 不作为用户气泡显示,避免用户困惑。
pub fn continue_agent_with_tool_result(
&mut self,
settings: &AiSettings,
Expand All @@ -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 两条消息发起**聚焦**流式请求,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/aether-win32/src/ai_warm_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,13 +500,15 @@ fn role_to_str(role: &AiRole) -> &'static str {
AiRole::User => "user",
AiRole::Assistant => "assistant",
AiRole::System => "system",
AiRole::Tool => "tool",
}
}

fn str_to_role(s: &str) -> AiRole {
match s {
"user" => AiRole::User,
"assistant" => AiRole::Assistant,
"tool" => AiRole::Tool,
_ => AiRole::System,
}
}
Expand Down
19 changes: 18 additions & 1 deletion crates/aether-win32/src/editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = &[
Expand Down
66 changes: 41 additions & 25 deletions crates/aether-win32/src/render/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<u16> = 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<u16> = 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):独立分类、可折叠展示。
// 与"回答"、"操作卡片"分开,视觉上弱化(紫灰、缩进、左强调条)。
Expand Down Expand Up @@ -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(),
)]
Expand Down Expand Up @@ -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
};
Expand All @@ -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
};
Expand All @@ -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;
Expand Down Expand Up @@ -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<u16> = format!("{}: {}", role, content)
Expand Down
59 changes: 29 additions & 30 deletions crates/aether-win32/src/render/editor_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 合成串,渲染合成串文本 + 下划线,光标隐藏
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading