优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)

- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分
- 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls)
  - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转
  - 多条/轮提取,system_prompt 仅注入 active 目标
  - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见)
- 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护
- 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见
- 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite
- 分离窗口默认置顶+置顶状态持久化(刷新后保持)
- header 菜单聚合:清空/压缩上下文收入 ... popout
- LLM 文字重叠防御:delta 重复检测+丢弃
- 置顶图标:星→大头针 pushpin
- 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
This commit is contained in:
2026-07-03 13:07:03 +08:00
parent aa0701a2ea
commit 0dfaeb0f5b
26 changed files with 929 additions and 753 deletions

View File

@@ -0,0 +1,235 @@
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
has_pending: bool,
pending_conv_id: Option<String>,
agent_language: Option<String>,
model_override: Option<String>,
pinned_goals_snapshot: Vec<GoalEntry>,
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
pub async fn try_continue_agent_loop(
app: &AppHandle,
state: &AppState,
conv_id: &str,
start_iteration: usize,
) {
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
let snap = {
let session = state.ai_session.lock().await;
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
let pending_conv_id = session.pending_approvals.values()
.find_map(|a| a.conversation_id.clone());
let conv = session.conv_read(conv_id);
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone());
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
ContinueSnapshot {
is_generating,
has_pending,
pending_conv_id,
agent_language,
model_override,
pinned_goals_snapshot,
}
};
let should_continue = snap.is_generating && !snap.has_pending;
if !should_continue {
if snap.is_generating {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
} else {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
let emit_conv_id = match snap.pending_conv_id.clone() {
Some(cid) => cid,
None => conv_id.to_string(),
};
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
incomplete: None,
conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
return;
}
let provider_config = match get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
let mut session = state.ai_session.lock().await;
match session.conv(conv_id).conv_state.transition_to(ConvState::Idle) {
Ok(ns) => session.conv(conv_id).conv_state = ns,
Err(e2) => tracing::warn!(conv_id = %conv_id, error = %e2, "ConvState→Idle 非法"),
}
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: e, error_type: None,
conversation_id: Some(conv_id.to_string()),
});
return;
}
};
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string();
let system_prompt = build_system_prompt(state, &lang).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
let model_override = snap.model_override.clone();
let still_generating = {
let session = state.ai_session.lock().await;
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
};
if !still_generating {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
incomplete: None,
conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
return;
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
});
}
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
if tool_calls.is_empty() { return vec![]; }
let mut goals: Vec<String> = Vec::new();
for tc in tool_calls.values() {
let goal = match tc.name.as_str() {
"patch_file" | "write_file" | "edit_file" | "append_file" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("修改 {}", f)
}))
}
"read_file" | "file_info" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("查看 {}", f)
}))
}
"search_files" | "grep" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
let s = if q.len() > 25 { &q[..25] } else { q };
format!("搜索 {}", s)
}))
.or_else(|| Some("搜索文件".to_string()))
}
"run_command" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
let s = if c.len() > 25 { &c[..25] } else { c };
format!("执行 {}", s)
}))
.or_else(|| Some("执行命令".to_string()))
}
"create_project" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
}
"create_idea" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
}
"run_workflow" => Some("执行工作流".to_string()),
"delete_file" | "rename_file" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("处理 {}", f)
}))
}
"list_directory" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
}
_ => None,
};
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
}
goals
}
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() { return true; }
const EMPTY_MARKERS: &[&str] = &[
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
];
for marker in EMPTY_MARKERS {
if trimmed.contains(marker) { return true; }
}
const EMPTY_TEXT: &[&str] = &[
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
];
for marker in EMPTY_TEXT {
if trimmed.contains(marker) { return true; }
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_tool_result() {
assert!(is_empty_tool_result(""));
assert!(is_empty_tool_result(" "));
assert!(is_empty_tool_result(r#"{"total":0}"#));
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
assert!(is_empty_tool_result("未找到相关文件"));
assert!(!is_empty_tool_result(r#"{"total":5}"#));
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
}
}