Skip to content
Merged
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
142 changes: 142 additions & 0 deletions crates/aether-win32/src/ai_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ pub struct AiStreamState {
pub truncated: Option<String>,
}

/// 扩写动画阶段
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExpandAnimPhase {
/// 无动画
None,
/// 原文渐隐中
FadeOut,
/// 流式写入新文本中(新文本渐显)
Streaming,
}

/// 后台流式轮询的边沿结果
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DrainEdge {
Expand Down Expand Up @@ -665,6 +676,16 @@ pub struct AiPanel {
pub file_card_regions: Vec<(usize, usize, f32, f32, f32, f32)>,
/// "浏览并选择文件夹"按钮命中区 (x, y, w, h)
pub browse_folder_region: Option<(f32, f32, f32, f32)>,
/// 是否正在进行问题扩写(扩写结果写回输入框而非聊天区)
pub is_expanding: bool,
/// 扩写动画阶段
pub expand_anim_phase: ExpandAnimPhase,
/// 扩写动画进度(0.0 ~ 1.0),用于渐隐/渐显透明度计算
pub expand_anim_progress: f32,
/// 扩写前的原始文本(渐隐阶段显示用)
pub expand_original_text: String,
/// 扩写动画起始时间戳(毫秒),用于计算进度
pub expand_anim_start_ms: u64,
}

/// 在后台线程发起一次流式 AI 请求,把事件写入共享 stream_state。
Expand Down Expand Up @@ -816,6 +837,11 @@ impl AiPanel {
expanded_file_cards: std::collections::HashSet::new(),
file_card_regions: Vec::new(),
browse_folder_region: None,
is_expanding: false,
expand_anim_phase: ExpandAnimPhase::None,
expand_anim_progress: 0.0,
expand_original_text: String::new(),
expand_anim_start_ms: 0,
};
panel.restore_latest_conversation();
panel
Expand Down Expand Up @@ -1483,6 +1509,62 @@ impl AiPanel {
);
}

/// 问题扩写:将当前输入框文本发送给 AI 进行扩写,
/// 扩写结果流式写回输入框(不作为聊天消息)。
/// 动画流程:原文渐隐 → 流式写入新文本(渐显)。
pub fn expand_input(&mut self, settings: &AiSettings) -> Result<String, String> {
let user_input = self.input.trim().to_string();
if user_input.is_empty() {
return Err("请先输入问题,再使用扩写功能".to_string());
}
if self.is_generating {
return Err("正在等待上一次回复,请稍后再试".to_string());
}

self.is_generating = true;
self.is_expanding = true;
self.should_stop.store(false, Ordering::SeqCst);
if let Ok(mut s) = self.stream_state.lock() {
*s = AiStreamState::default();
}

// 保存原文用于渐隐动画,然后清空输入框
self.expand_original_text = user_input.clone();
self.expand_anim_phase = ExpandAnimPhase::FadeOut;
self.expand_anim_progress = 0.0;
self.expand_anim_start_ms = now_millis();
self.input.clear();
self.caret_pos = 0;
// 锁定输入框焦点,防止扩写期间用户编辑
self.input_focused = false;

let system = "你是一个问题扩写助手。用户会给你一个问题或请求,你需要将其扩写为更详细、更完整、更清晰的版本。\
扩写后的文本应该:\n\
1. 保持原始意图不变\n\
2. 补充必要的上下文和细节\n\
3. 使问题更加具体和明确\n\
4. 直接输出扩写后的文本,不要添加任何解释、标记或前缀"
.to_string();
let messages = vec![
ChatMessage {
role: "system".to_string(),
content: system,
},
ChatMessage {
role: "user".to_string(),
content: user_input,
},
];
spawn_ai_stream(
settings.clone(),
messages,
Arc::clone(&self.stream_state),
Arc::clone(&self.should_stop),
);

Ok("正在扩写问题...".to_string())
}

/// 用给定内容替换最后一条助手消息(无则追加);用于把规划器原始清单块替换为可读的执行计划。
pub fn rewrite_last_assistant(&mut self, content: String) {
if let Some(last) = self.messages.last_mut() {
Expand Down Expand Up @@ -1829,6 +1911,66 @@ impl AiPanel {
};
let mut edge = DrainEdge::Pending;
if let Some((partial, reasoning, done, error, truncated)) = delta {
// ===== 扩写模式:流式结果写回输入框 =====
if self.is_expanding {
// 更新动画进度
let elapsed = now_millis().saturating_sub(self.expand_anim_start_ms);
match self.expand_anim_phase {
ExpandAnimPhase::FadeOut => {
// 渐隐阶段:300ms 内完成
const FADE_OUT_MS: u64 = 300;
self.expand_anim_progress = (elapsed as f32 / FADE_OUT_MS as f32).min(1.0);
if self.expand_anim_progress >= 1.0 {
// 渐隐完成,进入流式写入阶段
self.expand_anim_phase = ExpandAnimPhase::Streaming;
self.expand_anim_progress = 0.0;
self.expand_anim_start_ms = now_millis();
}
}
ExpandAnimPhase::Streaming => {
// 流式写入阶段:新文本随 token 到达逐渐显示
// 进度基于已接收文本长度(简单线性增长)
self.expand_anim_progress = 1.0; // 流式阶段直接显示
}
ExpandAnimPhase::None => {}
}

if !partial.is_empty() {
// 首个 token 到达时,确保已进入流式阶段
if self.expand_anim_phase == ExpandAnimPhase::FadeOut {
self.expand_anim_phase = ExpandAnimPhase::Streaming;
self.expand_anim_progress = 1.0;
}
self.input.push_str(&partial);
self.caret_pos = self.input.len();
}
if let Some(err) = error {
self.is_generating = false;
self.is_expanding = false;
self.expand_anim_phase = ExpandAnimPhase::None;
self.expand_anim_progress = 0.0;
self.expand_original_text.clear();
if self.input.is_empty() {
self.input = err;
self.caret_pos = self.input.len();
}
return DrainEdge::Interrupted;
}
if done {
self.is_generating = false;
self.is_expanding = false;
self.expand_anim_phase = ExpandAnimPhase::None;
self.expand_anim_progress = 0.0;
self.expand_original_text.clear();
let trimmed = self.input.trim().to_string();
self.input = trimmed;
self.caret_pos = self.input.len();
edge = DrainEdge::Completed;
}
return edge;
}

// ===== 正常对话模式 =====
// 深度思考(DeepSeek reasoning_content)先于回答到达:单独承载于助手消息的 reasoning
if !reasoning.is_empty() {
if !matches!(self.messages.last(), Some(m) if m.role == AiRole::Assistant) {
Expand Down
191 changes: 122 additions & 69 deletions crates/aether-win32/src/render/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,32 +1268,127 @@ impl EditorState {
.composition
.as_ref()
.is_some_and(|c| !c.is_empty());
let show_placeholder = self.ai_panel.input.is_empty() && !composing;
let input_text = if show_placeholder {
"输入问题..."
} else {
&self.ai_panel.input
};
let input_color: &ID2D1SolidColorBrush = if show_placeholder {
&dim_brush

// ===== 扩写动画渲染 =====
let is_expand_anim = self.ai_panel.is_expanding
&& self.ai_panel.expand_anim_phase != crate::ai_panel::ExpandAnimPhase::None;

if is_expand_anim {
match self.ai_panel.expand_anim_phase {
crate::ai_panel::ExpandAnimPhase::FadeOut => {
// 渐隐阶段:显示原文,透明度从 1.0 渐变为 0.0
let alpha = 1.0 - self.ai_panel.expand_anim_progress;
let fade_color = color_f(0.9, 0.9, 0.9, alpha);
if let Ok(fade_brush) =
self.render_ctx.brush_cache.get_brush(target, &fade_color)
{
let orig_text = &self.ai_panel.expand_original_text;
let orig_wide: Vec<u16> =
orig_text.encode_utf16().chain(Some(0)).collect();
let fade_rect = D2D_RECT_F {
left: text_input_rect.left + 4.0,
top: text_input_y + 8.0,
right: text_input_rect.right - 4.0,
bottom: text_input_y + text_input_h - 4.0,
};
target.DrawText(
&orig_wide,
&msg_format,
&fade_rect,
&fade_brush,
D2D1_DRAW_TEXT_OPTIONS_NONE,
DWRITE_MEASURING_MODE_NATURAL,
);
}
}
crate::ai_panel::ExpandAnimPhase::Streaming => {
// 流式写入阶段:显示已接收的新文本,带轻微渐显效果
let new_text = &self.ai_panel.input;
if !new_text.is_empty() {
// 新文本使用带轻微透明度的白色,营造"写入中"感
let stream_color = color_f(0.9, 0.9, 0.9, 0.92);
if let Ok(stream_brush) =
self.render_ctx.brush_cache.get_brush(target, &stream_color)
{
let new_wide: Vec<u16> =
new_text.encode_utf16().chain(Some(0)).collect();
let stream_rect = D2D_RECT_F {
left: text_input_rect.left + 4.0,
top: text_input_y + 8.0,
right: text_input_rect.right - 4.0,
bottom: text_input_y + text_input_h - 4.0,
};
target.DrawText(
&new_wide,
&msg_format,
&stream_rect,
&stream_brush,
D2D1_DRAW_TEXT_OPTIONS_NONE,
DWRITE_MEASURING_MODE_NATURAL,
);
}
// 流式写入中显示一个闪烁的写入指示器(竖线光标)
let tw = self
.render_ctx
.text_format_cache
.measure_text_width(
new_text,
11.0,
DWRITE_FONT_WEIGHT_NORMAL.0 as u32,
)
.unwrap_or(0.0);
let indicator_x = text_input_rect.left + 4.0 + tw;
// 闪烁效果:基于时间戳
let blink = (crate::ai_panel::now_millis() / 400) % 2 == 0;
if blink {
let indicator_color = color_f(0.0, 0.47, 0.83, 0.8);
if let Ok(ind_brush) = self
.render_ctx
.brush_cache
.get_brush(target, &indicator_color)
{
let ind_rect = D2D_RECT_F {
left: indicator_x,
top: text_input_y + 10.0,
right: indicator_x + 2.0,
bottom: text_input_y + text_input_h - 10.0,
};
target.FillRectangle(&ind_rect, &ind_brush);
}
}
}
}
crate::ai_panel::ExpandAnimPhase::None => {}
}
} else {
text_brush
};
let input_wide: Vec<u16> = input_text.encode_utf16().chain(Some(0)).collect();
let input_text_rect = D2D_RECT_F {
left: text_input_rect.left + 4.0,
top: text_input_y + 8.0,
right: text_input_rect.right - 4.0,
bottom: text_input_y + text_input_h - 4.0,
};
target.DrawText(
&input_wide,
&msg_format,
&input_text_rect,
input_color,
D2D1_DRAW_TEXT_OPTIONS_NONE,
DWRITE_MEASURING_MODE_NATURAL,
);
// ===== 正常输入框渲染 =====
let show_placeholder = self.ai_panel.input.is_empty() && !composing;
let input_text = if show_placeholder {
"输入问题..."
} else {
&self.ai_panel.input
};
let input_color: &ID2D1SolidColorBrush = if show_placeholder {
&dim_brush
} else {
text_brush
};
let input_wide: Vec<u16> = input_text.encode_utf16().chain(Some(0)).collect();
let input_text_rect = D2D_RECT_F {
left: text_input_rect.left + 4.0,
top: text_input_y + 8.0,
right: text_input_rect.right - 4.0,
bottom: text_input_y + text_input_h - 4.0,
};
target.DrawText(
&input_wide,
&msg_format,
&input_text_rect,
input_color,
D2D1_DRAW_TEXT_OPTIONS_NONE,
DWRITE_MEASURING_MODE_NATURAL,
);
}

// IME 合成串(pre-edit text)显示在光标位置之后
if let Some(comp) = &self.ai_panel.composition {
Expand Down Expand Up @@ -1607,30 +1702,9 @@ impl EditorState {
&white_brush,
);

// 麦克风按钮
let mic_btn_size = 24.0f32;
let mic_btn_x = send_btn_x - mic_btn_size - 4.0;
let mic_btn_rect = D2D_RECT_F {
left: mic_btn_x,
top: send_btn_y,
right: mic_btn_x + mic_btn_size,
bottom: send_btn_y + send_btn_size,
};
fill_round_rect(target, &mic_btn_rect, 4.0, &btn_bg_brush);
// 使用 SVG 图标绘制麦克风
self.icons.draw(
target,
crate::icons::IconKind::Mic,
mic_btn_x + 2.0,
send_btn_y + 2.0,
mic_btn_size - 4.0,
send_btn_size - 4.0,
&dim_brush,
);

// 快捷按钮(星星)
// 快捷按钮(星星)——问题扩写
let star_btn_size = 24.0f32;
let star_btn_x = mic_btn_x - star_btn_size - 4.0;
let star_btn_x = send_btn_x - star_btn_size - 4.0;
let star_btn_rect = D2D_RECT_F {
left: star_btn_x,
top: send_btn_y,
Expand All @@ -1648,27 +1722,6 @@ impl EditorState {
send_btn_size - 4.0,
&dim_brush,
);

// 菜单按钮(列表图标)
let menu_btn_size = 24.0f32;
let menu_btn_x = star_btn_x - menu_btn_size - 4.0;
let menu_btn_rect = D2D_RECT_F {
left: menu_btn_x,
top: send_btn_y,
right: menu_btn_x + menu_btn_size,
bottom: send_btn_y + send_btn_size,
};
fill_round_rect(target, &menu_btn_rect, 4.0, &btn_bg_brush);
// 使用 SVG 图标绘制列表/菜单
self.icons.draw(
target,
crate::icons::IconKind::List,
menu_btn_x + 2.0,
send_btn_y + 2.0,
menu_btn_size - 4.0,
menu_btn_size - 4.0,
&dim_brush,
);
}
}
}
Expand Down
Loading
Loading