优化: 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:
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal 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"]}"#));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user