优化: 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:
@@ -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"]}"#));
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,17 @@ use crate::state::{AppState, LlmConcurrency};
|
||||
use super::audit::process_tool_calls;
|
||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
#[allow(unused_imports)]
|
||||
use super::knowledge_inject::inject_knowledge_into_prompt;
|
||||
#[allow(unused_imports)]
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||
#[allow(unused_imports)]
|
||||
use super::ToolCallDraft;
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -105,8 +110,9 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
// ============================================================
|
||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||
//
|
||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
||||
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
|
||||
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
|
||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||
@@ -114,10 +120,12 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
||||
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
|
||||
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
|
||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
|
||||
pub const GOAL_PIN_ENABLED: bool = true;
|
||||
|
||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||
@@ -272,6 +280,10 @@ mod knowledge_lifecycle;
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
pub mod command_lock;
|
||||
pub(crate) mod helpers;
|
||||
pub(crate) use helpers::try_continue_agent_loop;
|
||||
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||
pub(crate) use helpers::is_empty_tool_result;
|
||||
|
||||
// ============================================================
|
||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||
@@ -798,19 +810,26 @@ pub(crate) async fn run_agentic_loop(
|
||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||
//
|
||||
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
|
||||
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
|
||||
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
|
||||
// 新目标加入时自动标记之前的 active 为 completed。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goals: Vec<String> = {
|
||||
let goals: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if !goals.is_empty() {
|
||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
||||
let g = g.trim();
|
||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
||||
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
|
||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||
if !active_goals.is_empty() {
|
||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||
let trimmed = g.text.trim();
|
||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
@@ -830,7 +849,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
@@ -848,9 +867,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||
|
||||
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||
// 关闭时退单 Agent(等价原行为);开启时分解意图→Plan,dispatch+merge→合并产出落回主对话
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||
let pinned_goals_snapshot: Vec<String> = {
|
||||
// 只取 text(前端不需要状态信息)
|
||||
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
@@ -1444,7 +1463,55 @@ pub(crate) async fn run_agentic_loop(
|
||||
msg.reasoning_content = last_reasoning_content.clone();
|
||||
session.conv(&conv_id).messages.push(msg);
|
||||
}
|
||||
}
|
||||
// G1 目标提取: 从工具调用推理目标描述。
|
||||
// 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。
|
||||
// 无工具调用时不提取(已有目标保持不变,该轮无新目标)。
|
||||
if GOAL_PIN_ENABLED {
|
||||
// 从工具调用推理目标(本轮可能多个工具,全部提取)
|
||||
let new_goals = infer_goal_from_tool_calls(&tool_calls_acc);
|
||||
if !new_goals.is_empty() {
|
||||
let goal_conv = session.conv(&conv_id);
|
||||
let mut added = false;
|
||||
for g in &new_goals {
|
||||
// 去重:文本不在已有目标中(包含关系检查)
|
||||
let is_dup = goal_conv.pinned_goals.iter().any(|e|
|
||||
e.text.contains(g.as_str()) || g.contains(&e.text)
|
||||
);
|
||||
if !is_dup {
|
||||
goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone()));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
// 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务)
|
||||
if added && goal_conv.pinned_goals.len() > new_goals.len() {
|
||||
let active_until = goal_conv.pinned_goals.len() - new_goals.len();
|
||||
for entry in goal_conv.pinned_goals.iter_mut().take(active_until) {
|
||||
if matches!(entry.status, super::GoalStatus::Active) {
|
||||
entry.status = super::GoalStatus::Completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 超上限时保留 active,淘汰最早 completed
|
||||
if goal_conv.pinned_goals.len() > MAX_GOALS {
|
||||
let active: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||
.filter(|g| matches!(g.status, super::GoalStatus::Active))
|
||||
.cloned().collect();
|
||||
if active.len() <= MAX_GOALS {
|
||||
let keep = MAX_GOALS - active.len();
|
||||
let completed: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||
.filter(|g| matches!(g.status, super::GoalStatus::Completed))
|
||||
.rev().take(keep).cloned().collect();
|
||||
let mut merged = active;
|
||||
merged.extend(completed.into_iter().rev());
|
||||
goal_conv.pinned_goals = merged;
|
||||
} else {
|
||||
goal_conv.pinned_goals.truncate(MAX_GOALS);
|
||||
}
|
||||
}
|
||||
}
|
||||
// L3 [UPDATE_GOALS] 标记:LLM 可主动修正目标列表(意图增强/纠偏)。
|
||||
// 后续独立 PR 实现:解析 [UPDATE_GOALS]...[END] 块并替换 pinned_goals。
|
||||
}
|
||||
|
||||
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
@@ -1472,7 +1539,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
|
||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||
#[allow(unused_assignments)]
|
||||
if !has_tool_calls { converged = true; break; }
|
||||
let _ = converged;
|
||||
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = {
|
||||
@@ -1637,7 +1706,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
String::new()
|
||||
} else {
|
||||
let goal_summary = goals.iter()
|
||||
.map(|g| g.trim())
|
||||
.map(|g| g.text.trim())
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
@@ -1784,353 +1853,4 @@ pub(crate) async fn run_agentic_loop(
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
///
|
||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
||||
/// 序列化为紧凑 JSON,无空格):
|
||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
||||
///
|
||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0",
|
||||
"\"entries\":[]",
|
||||
"\"matches\":[]",
|
||||
"\"results\":[]",
|
||||
"\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无匹配",
|
||||
"没有匹配",
|
||||
"未匹配",
|
||||
"未发现",
|
||||
"无记录",
|
||||
"No matches",
|
||||
"no matches",
|
||||
"0 results",
|
||||
"0 matches",
|
||||
"没有数据",
|
||||
"没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
///
|
||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
||||
/// 各 return 点的语义判断:
|
||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
||||
/// (streaming=true 由 AiCompleted 清理)
|
||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
||||
/// (前端审批态 watchdog 已 clear,不卡)
|
||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
||||
///
|
||||
/// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。
|
||||
/// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending)
|
||||
/// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。
|
||||
/// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id,
|
||||
/// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被
|
||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
||||
pub(crate) async fn try_continue_agent_loop(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
||||
// 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。
|
||||
//
|
||||
// F-260616-09 B 批2(设计 §4.4):conv_id 改显式入参(替代从 pending/active 推断),
|
||||
// has_pending 按 conv_id 过滤(决策 e 真并发准备);generating/agent_language/model_override
|
||||
// 读 per_conv(批2 桥接期与顶层等价,per_conv 未建 fallback 顶层)。
|
||||
// conv_id 来源:调用方 ai_approve 传 approval.conversation_id;ai_continue_loop/ai_stop_loop
|
||||
// 传 IPC 参数 conversation_id。
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
// path_auth 审批链阶段1:has_pending 改调 session_state(conv_id) 收敛状态机判定,
|
||||
// 替代手写 path_auth+risk 两表 any 合并(mod.rs 已统一封装)。
|
||||
// 阶段3a 单真相源合并后:两表合一进 pending_approvals,session_state 单表 any 判定。
|
||||
// 两表语义不变——任一类挂起都阻塞续跑(agentic loop 在 path_auth 挂起时也已 return 等待,
|
||||
// 漏任一会致 loop 误续跑空转)。
|
||||
//
|
||||
// 兜底/快速回退:若需切回手写 has_pending,原双表组合保留如下(改一行即可):
|
||||
// let has_pending = session.pending_approvals.values()
|
||||
// .any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
||||
// pending_conv_id 保留(should_continue=false 路径的 emit conv_id 回退逻辑)。
|
||||
// 阶段3a:单表 find_map(原两表合一)。
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
||||
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 {
|
||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
||||
if snap.is_generating {
|
||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
||||
// R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals),
|
||||
// 仅当无任何审批(has_pending=false 且 generating=false)时回退入参 conv_id(批2 显式参数)。
|
||||
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());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
||||
),
|
||||
}
|
||||
drop(session);
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(),
|
||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
||||
error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
// F-09 B 批2:续生成路径 conv_id 用入参(显式,不再从 active 推断),语言/override 读 per_conv 快照。
|
||||
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();
|
||||
|
||||
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口。
|
||||
// P1 修复(审批恢复路径缺知识注入):try_continue 续跑轮此前用裸 build_system_prompt,
|
||||
// 不调 build_knowledge_context 致续跑轮丢知识库上下文。现与 chat.rs 四处同款走 helper。
|
||||
// 同消息取 text+id(②口径修复):原 last_user_text 过滤 is_active / user_message_id 走
|
||||
// last_user_message_id 不过滤 is_active,末条 user 压缩后两值取自不同消息;helper 单次
|
||||
// 反向扫描同一条消息取两值。复用上方已 clone 的 knowledge_config 快照(避免重复加锁)。
|
||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||
|
||||
|
||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
// BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。
|
||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
||||
// is_active() 与原 generating 语义一致。
|
||||
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 {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
||||
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());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session
|
||||
/// 致续跑判断基于过时快照的 TOCTOU 竞态。
|
||||
///
|
||||
/// F-260616-09 B 批2:active_conversation_id 字段移除(改用入参 conv_id,不再从 snap 读)。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
||||
pinned_goals_snapshot: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// G2 is_empty_tool_result 单测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_empty_string() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_whitespace() {
|
||||
assert!(is_empty_tool_result(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_total_zero() {
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_entries_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_matches_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_results_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_files_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_chinese_no_match() {
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_english_no_match() {
|
||||
assert!(is_empty_tool_result("No matches found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result() {
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result_with_content() {
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user