diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 0e51739..4ba06ca 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -911,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> { /// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化) /// -/// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, +/// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, /// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 fn migrate_v32(conn: &Connection) -> Result<()> { conn.execute_batch( diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 3cbf453..7a53341 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -360,7 +360,7 @@ pub struct AiConversationRecord { pub pinned: bool, // 是否置顶(排序置前;UX-17) pub prompt_tokens: Option, // 输入 token 累计(流式 usage 落库) pub completion_tokens: Option, // 输出 token 累计(流式 usage 落库) - pub pinned_goals: Option, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]') + pub pinned_goals: Option, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]') pub pending_approvals: Option, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}') pub created_at: String, pub updated_at: String, diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs new file mode 100644 index 0000000..de5102b --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -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, + agent_language: Option, + model_override: Option, + pinned_goals_snapshot: Vec, +} + +/// 检查是否所有待审批已处理,如果是则恢复 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::().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::().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::().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::().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) -> Vec { + if tool_calls.is_empty() { return vec![]; } + let mut goals: Vec = 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::(&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::(&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::(&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::(&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::(&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::(&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::(&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::(&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"]}"#)); + } +} diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 9b32ed7..c96838b 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -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,内容态字段,绕过 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) + 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,含 text+status 状态追踪。 + // 新目标加入时自动标记之前的 active 为 completed。 let mut system_prompt = system_prompt; if GOAL_PIN_ENABLED { - let goals: Vec = { + let goals: Vec = { 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 = 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 = 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 = { + // 只取 text(前端不需要状态信息) + let pinned_goals_snapshot: Vec = { 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 = 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 = 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::>() .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::().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::().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::().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::().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, - agent_language: Option, - model_override: Option, - /// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带 - pinned_goals_snapshot: Vec, -} - -#[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"]}"#)); - } } diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index 89ce44e..1b8352f 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -34,20 +34,19 @@ pub(super) fn classify_risk_and_auto( tool_name: &str, args: &serde_json::Value, ) -> bool { - let mut should_auto = match risk_level { + let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度) + // patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。 + // 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流 + // (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。 + // 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。 + if tool_name == "patch_file" { + return true; + } + match risk_level { RiskLevel::Low => true, RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all", RiskLevel::High => auto_exec_mode == "all", - }; - // C-260627:patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流 - if !should_auto && tool_name == "patch_file" { - if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) { - if text.lines().count() <= 5 { - should_auto = true; - } - } } - should_auto } /// 审批策略方案 B:Persona 维度判定。 diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index dcdb895..5956740 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -325,6 +325,25 @@ pub(crate) async fn process_tool_calls( .ok() .flatten() .unwrap_or_else(|| "low".to_string()); + // F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。 + // df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。 + let trace_on = app_handle + .state::() + .settings + .get("df-ai-trace-approve") + .await + .ok() + .flatten() + .map(|v| v == "true" || v == "1") + .unwrap_or(true); + if trace_on { + tracing::info!( + conv_id = %conv_id, + auto_exec_mode = %auto_exec_mode, + drafts_count = drafts.len(), + "[ai] 审批判定读取的自动执行模式" + ); + } // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn // (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 08f3a24..36858be 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -24,7 +24,8 @@ use crate::state::AppState; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 -use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS}; +use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS}; +// G1 目标钉扎:GOAL_PIN_ENABLED/MAX_GOALS 已迁移至 agentic 侧使用,chat 不再需要(extract_pinned_goal 纯函数只依赖 GOAL_MAX_CHARS 截断) // 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 use super::super::agentic::conv_state::ConvState; use super::super::audit::{audit_finalize, emit_data_changed}; @@ -72,20 +73,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio } } -/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。 +/// 去噪助手:从文本中 strip `[kind:...]` mention 标签(纯函数,无 IO)。 /// -/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标, -/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。 -/// -/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次): -/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs -/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标 -/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。 -/// 2. trim 空白。 -/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。 -/// -/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。 -/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。 +/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现保留供测试用例使用。 +#[allow(dead_code)] pub(crate) fn extract_pinned_goal(text: &str) -> String { // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑, // 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。 @@ -114,6 +105,7 @@ pub(crate) fn extract_pinned_goal(text: &str) -> String { /// /// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白; /// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。 +#[allow(dead_code)] fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option { let after = open + 1; for k in kinds { @@ -463,17 +455,11 @@ pub async fn ai_chat_send( // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。 - // GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。 - if GOAL_PIN_ENABLED { - let goal = extract_pinned_goal(&user_content); - if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { - conv.pinned_goals.push(goal); - if conv.pinned_goals.len() > MAX_GOALS { - conv.pinned_goals.remove(0); - } - } - } + // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(2026-07-03 改), + // 从 LLM 本轮调用的工具名+路径推理目标,不再从用户消息规则提取。 + // 用户发送的短指令(如「继续」「确认」)不会产生低价值目标。 + // 此处不再从原始用户消息提取。 + // GOAL_PIN_ENABLED=false → 跳过整块。 (target, user_msg_id) }; @@ -1359,16 +1345,7 @@ pub async fn ai_chat_edit( conv.iteration_used = 0; // F-01 阶段6: 记录用户指定模型 override(主对话专用)。 conv.model_override = model_override.clone(); - // G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致) - if GOAL_PIN_ENABLED { - let goal = extract_pinned_goal(&new_message); - if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { - conv.pinned_goals.push(goal); - if conv.pinned_goals.len() > MAX_GOALS { - conv.pinned_goals.remove(0); - } - } - } + // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) } let _tool_defs = state.ai_tools.tool_definitions(); @@ -1534,17 +1511,7 @@ pub async fn ai_chat_force_send( } // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。 - // force_send 是用户新指令,审批续跑会复用已累积目标。 - if GOAL_PIN_ENABLED { - let goal = extract_pinned_goal(&user_content); - if !goal.is_empty() && !conv.pinned_goals.contains(&goal) { - conv.pinned_goals.push(goal); - if conv.pinned_goals.len() > MAX_GOALS { - conv.pinned_goals.remove(0); - } - } - } + // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone()) }; @@ -1782,7 +1749,7 @@ pub async fn ai_stop_loop( state: State<'_, AppState>, conversation_id: String, ) -> Result { - let pinned_goals: Vec; + let pinned_goals: Vec; { let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 @@ -1879,6 +1846,10 @@ mod tests { assert_eq!(goal.chars().count(), GOAL_MAX_CHARS); } + // ============================================================ + // 低价值目标过滤 + // ============================================================ + #[test] fn test_extract_mention_unclosed() { let goal = extract_pinned_goal("测试 [项目: DevFlow"); diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index d4d5173..70e03d1 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -226,7 +226,8 @@ pub async fn ai_conversation_list( .unwrap_or_default(); let pinned_goals: Vec = r.pinned_goals .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) + .and_then(|s| serde_json::from_str::>(s).ok()) + .map(|entries| entries.into_iter().map(|e| e.text).collect()) .unwrap_or_default(); serde_json::json!({ "id": r.id, @@ -336,10 +337,34 @@ pub async fn ai_conversation_switch( conv.iteration_used = 0; conv.stop_flag.store(false, Ordering::SeqCst); // 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化) - let pinned_goals: Vec = record.pinned_goals - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default(); + // 兼容旧格式:["text1","text2"] 转为 [{text, status: active}] + let pinned_goals: Vec = { + let raw: Option = record.pinned_goals + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + match raw { + Some(serde_json::Value::Array(arr)) => { + arr.iter().filter_map(|v| { + // 新格式: {"text":"...","status":"active"} + if let Some(obj) = v.as_object() { + let text = obj.get("text")?.as_str()?.to_string(); + let status = obj.get("status").and_then(|s| s.as_str()) + .map(|s| if s == "completed" { + crate::commands::ai::GoalStatus::Completed + } else { + crate::commands::ai::GoalStatus::Active + }).unwrap_or(crate::commands::ai::GoalStatus::Active); + Some(crate::commands::ai::GoalEntry { text, status }) + } else { + // 旧格式: "text" → 转 active + let text = v.as_str()?.to_string(); + Some(crate::commands::ai::GoalEntry::new(text)) + } + }).collect() + } + _ => vec![], + } + }; conv.pinned_goals = pinned_goals; } // 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空, @@ -531,7 +556,11 @@ pub async fn ai_update_conversation_goals( conversation_id: String, goals: Vec, ) -> Result<(), String> { - let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?; + // 前端传回的 string[] 转为 GoalEntry(假设全部 active) + let goal_entries: Vec = goals.into_iter() + .map(|g| crate::commands::ai::GoalEntry::new(g)) + .collect(); + let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?; // 写 DB state.ai_conversations .update_field(&conversation_id, "pinned_goals", &goals_json) @@ -540,7 +569,7 @@ pub async fn ai_update_conversation_goals( // 同步内存 per_conv(若已加载) let mut session = state.ai_session.lock().await; if let Some(conv) = session.per_conv.get_mut(&conversation_id) { - conv.pinned_goals = goals; + conv.pinned_goals = goal_entries; } Ok(()) } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 6f51560..f6a946f 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -140,7 +140,7 @@ pub enum AiChatEvent { incomplete: Option, conversation_id: Option, /// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新) - pinned_goals: Vec, + pinned_goals: Vec, }, /// 错误 /// @@ -820,17 +820,15 @@ pub struct PerConvState { /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 pub conv_state: ConvState, - /// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。 + /// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。 /// /// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失): /// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。 - /// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 - /// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。 + /// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部(system_prompt 是 loop 不变量)。 /// - /// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标); - /// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。 - /// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。 - pub pinned_goals: Vec, + /// 2026-07-03 改:提取从「用户消息规则」→「工具调用推理」(infer_goal_from_tool_calls), + /// 每轮 LLM 回复后从工具名+路径推理目标追加(去重),支持多条/轮 + active→completed 状态流转。 + pub pinned_goals: Vec, /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! @@ -863,6 +861,34 @@ pub struct PerConvState { pub knowledge_extracted: bool, } +/// 单个目标记录(含状态追踪)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoalEntry { + pub text: String, + #[serde(default = "default_goal_status")] + pub status: GoalStatus, +} + +/// 目标状态。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GoalStatus { + /// 正在进行中(当前 LLM 正在处理) + Active, + /// 已完成(LLM 已进入下一任务) + Completed, +} + +fn default_goal_status() -> GoalStatus { + GoalStatus::Active +} + +impl GoalEntry { + pub fn new(text: String) -> Self { + GoalEntry { text, status: GoalStatus::Active } + } +} + impl PerConvState { /// 新建默认会话级状态。 /// diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 1b6c8ee..a030a61 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -2036,7 +2036,7 @@ fn register_file_tools( }) }; registry.register( - "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容", + "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", patch_file_schema, RiskLevel::Medium, { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56cb528..9a05e97 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,7 +112,7 @@ pub fn run() { } } // 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带) - let pinned_goals_map: std::collections::HashMap> = dirty_convs + let pinned_goals_map: std::collections::HashMap> = dirty_convs .iter() .filter_map(|cid| { session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone())) diff --git a/src/App.vue b/src/App.vue index c106e3c..7e19464 100644 --- a/src/App.vue +++ b/src/App.vue @@ -33,6 +33,10 @@
⚠ {{ errorMsg }}
+ + + + + diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 3a63996..bbbbcc2 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -53,32 +53,48 @@ - - - - - + + + + +
+ + + + +
+ @@ -251,8 +246,6 @@ defineExpose({ .nav-icon { font-size: 16px; line-height: 1; } .nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.nav-footer-slot { flex-shrink: 0; margin-top: auto; } - /* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制, 此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足, 搜索改为后续可扩展) ===== */ diff --git a/src/composables/ai/useAiApproval.ts b/src/composables/ai/useAiApproval.ts index bd984ae..aad6e15 100644 --- a/src/composables/ai/useAiApproval.ts +++ b/src/composables/ai/useAiApproval.ts @@ -12,6 +12,7 @@ //! 模块级私有: //! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现) +import { ref } from 'vue' import { aiApi } from '@/api' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' @@ -73,10 +74,13 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: // IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。 // 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达, // 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。 - // approve/authorize 通过 → running(后端正在执行工具);deny/reject → rejected(后端已记审计)。 + // CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为 + // 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。 if (tc) { const isDeny = isPathKind ? (decision === 'deny') : !approved - tc.status = isDeny ? 'rejected' : 'running' + if (tc.status !== 'completed') { + tc.status = isDeny ? 'rejected' : 'running' + } if (isDeny) { tc.result = t('ai.aiTool.rejectedHint') } @@ -102,6 +106,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: } } + /** * 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个, * 非目录聚合——path 类每条独立决策,不合并同目录)。 @@ -113,9 +118,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: * - decision='reject':risk 类 approved=false;path 类 decision='deny'。 * 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。 */ +const _batchProcessing = ref(false) async function batchApprove(decision: 'approve' | 'reject') { + if (_batchProcessing.value) return // 去重:批量进行中禁重复点击 + _batchProcessing.value = true // 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短) const items = [...state.pendingApprovals] + let successCount = 0 + let failCount = 0 for (const p of items) { try { if (p.kind === 'path') { @@ -125,11 +135,23 @@ async function batchApprove(decision: 'approve' | 'reject') { // risk 类:approve/reject boolean await approveToolCall(p.id, decision === 'approve') } + successCount++ } catch { // 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态) + failCount++ // 继续处理剩余项 } } + _batchProcessing.value = false + // 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项) + if (failCount > 0) { + console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`) + } +} + +/** 批量处理中(供按钮禁用 + spinner 展示) */ +export function isBatchProcessing(): boolean { + return _batchProcessing.value } export function useAiApproval() { diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 35b268e..fffd058 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -42,6 +42,9 @@ let _unlistenApprovalClear: (() => void) | null = null // (两回调写同一 state.currentText → 流式文字双倍) let _startPromise: Promise | null = null +// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent) +let _lastDelta = '' + const appSettings = useAppSettingsStore() // B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 @@ -205,6 +208,7 @@ export function flushCurrentText() { // 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的 // currentText='' 对已清空值幂等,无副作用。 state.currentText = '' + _lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测 } /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ @@ -274,9 +278,28 @@ function removePendingDirAuth(id: string): void { /** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */ function handleStreamingEvent(event: AiChatEvent): boolean { switch (event.type) { - case 'AiTextDelta': + case 'AiTextDelta': { + // BUG-260624-01 / UX-260617-12 消息重叠防御: + // 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。 + // 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、 + // startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。 + // 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文 + // 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。 + // 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。 + // ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。 + // 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。 + // 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。 + if (event.delta && _lastDelta === event.delta && event.delta.length > 1) { + // df-ai-trace-delta 开关控制(默认开,生产可关) + if (appSettings.get('df-ai-trace-delta', true)) { + console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta)) + } + return true + } + _lastDelta = event.delta state.currentText += event.delta return true + } case 'AiAgentRound': { // Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息 diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index 1708bd0..4a7aab2 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -82,6 +82,7 @@ async function detachPanel(convId?: string) { minHeight: 400, center: true, decorations: true, + alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见) }) win.once('tauri://created', () => { state.panelOpen = false @@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600 const AI_DOCK_GAP = 4 // 防 syncToMain 改主窗口 size 触发 onResized 递归回调 let _isSyncing = false +// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发, +// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。 +// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。 +let _syncTimer: ReturnType | null = null +const SYNC_DEBOUNCE_MS = 150 -/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。 - * U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度, - * 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */ +/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。 + * + * 设计原则(本次优化重点): + * 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变, + * 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位 + * (往左偏移或缩宽),不动主窗口。 + * 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。 + * 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。 + * 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。 + */ async function syncToMain() { if (_isSyncing) return _isSyncing = true @@ -276,28 +289,32 @@ async function syncToMain() { const monitor = await currentMonitor() const screenWidth = monitor?.size.width ?? 0 - // 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口) - if (await mainWin.isMaximized()) { - await mainWin.unmaximize() - await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸 - } + const pos = await mainWin.outerPosition() + const size = await mainWin.innerSize() + const maximized = await mainWin.isMaximized() - let pos = await mainWin.outerPosition() - let size = await mainWin.innerSize() + // 计算目标位置(默认贴主窗口右侧 + GAP) + let targetX = pos.x + size.width + AI_DOCK_GAP + let targetY = pos.y + let targetHeight = size.height + let targetWidth = AI_DOCK_WIDTH - // 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕 - if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) { - const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x - if (newMainWidth >= 400) { - await mainWin.setSize(new PhysicalSize(newMainWidth, size.height)) - await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定 - pos = await mainWin.outerPosition() - size = await mainWin.innerSize() + if (maximized) { + // 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。 + // 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。 + if (screenWidth > 0) { + targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP } + targetHeight = size.height > targetHeight ? targetHeight : size.height + } else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) { + // 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。 + // 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。 + // 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。 + targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP) } - await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y)) - await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height)) + await aiWin.setPosition(new PhysicalPosition(targetX, targetY)) + await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight)) } catch (e) { console.error('[AI] syncToMain 失败:', e) } finally { @@ -305,18 +322,28 @@ async function syncToMain() { } } -/** 启动主窗口 move/resize 跟随(吸附时调用) */ +/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */ +function scheduleSync() { + if (_syncTimer) clearTimeout(_syncTimer) + _syncTimer = setTimeout(() => { + _syncTimer = null + void syncToMain() + }, SYNC_DEBOUNCE_MS) +} + +/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */ async function startFollowMain() { stopFollowMain() const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const mainWin = await WebviewWindow.getByLabel('main') if (!mainWin) return - _unlistenMove = await mainWin.onMoved(() => { void syncToMain() }) - _unlistenResize = await mainWin.onResized(() => { void syncToMain() }) + _unlistenMove = await mainWin.onMoved(() => { scheduleSync() }) + _unlistenResize = await mainWin.onResized(() => { scheduleSync() }) } -/** 停止主窗口跟随 */ +/** 停止主窗口跟随 + 清防抖计时器 */ function stopFollowMain() { + if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null } _unlistenMove?.() _unlistenResize?.() _unlistenMove = null diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 1d5a9d8..b88f75b 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -48,6 +48,10 @@ export default { unpin: 'Unpin', closeWindow: 'Close window', + // ── Approval overlay (global top-right) ── + approvalWaitingTitle: 'Approval required', + approvalWaitingDesc: 'Click to review and handle', + // ── Provider status ── clickToSwitchProvider: 'Click to switch provider', providerNotConfigured: 'No AI provider configured. Please add one in settings.', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 83343f4..6145242 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -48,6 +48,10 @@ export default { unpin: '取消置顶', closeWindow: '关闭窗口', + // ── 审批浮层(全局右上角)── + approvalWaitingTitle: '有待审批请求', + approvalWaitingDesc: '点击查看并处理', + // ── Provider 状态 ── clickToSwitchProvider: '点击切换 Provider', providerNotConfigured: '未配置 AI 提供商,请在设置中添加', diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 9ebf8ab..9c8f413 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -17,12 +17,8 @@ v-model="activeCategory" :horizontal="isNarrowScreen" @scroll-target="scrollToSettingItem" - > - - - + /> +
@@ -79,7 +75,7 @@ import PerformanceSection from '@/components/settings/PerformanceSection.vue' import AdvancedSection from '@/components/settings/AdvancedSection.vue' import KnowledgePanel from '@/components/settings/KnowledgePanel.vue' import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue' -import SettingsImportExport from '@/components/settings/SettingsImportExport.vue' +// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite) import { useAppSettingsStore } from '@/stores/appSettings' import { aiApi } from '@/api' import type { AiProviderConfig } from '@/api/types' diff --git a/todo.md b/todo.md index b4161d4..36fb505 100644 --- a/todo.md +++ b/todo.md @@ -14,7 +14,8 @@ > 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md` > 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。 -- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec` + chat.rs 三处提取(追加去重,支持累积多目标)。 +- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec`(含 text+status 状态追踪)。 + - 2026-07-03 改:提取方式从「用户消息规则提取」→「工具调用推理」(infer_goal_from_tool_calls),支持多条/轮 + 自动 active→completed 状态流转。 - [x] **G2 探索熔断** ✅ 代码已落地(`STALL_BREAKER_ENABLED=true`,`STALL_BREAKER_THRESHOLD=3`),stall_count + is_empty_tool_result 完整实现。 - [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`),pinned_goals 非空时 take 后丢弃跳过 insert。 @@ -59,3 +60,50 @@ - [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33):`workflow_executions` 加 `updated_at` 列 + `ai_conversations` 加 `pending_approvals`(审批重启恢复)。 - [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`):3s 内重复 `ai-client-ready` 跳过。 +## AI Chat 体验优化(2026-07-03 批次) + +> 本轮完成的 10 项需求中,8 项已落地,3 项暂缓待后续批次。 + +### 已完成 + +- [x] **AI Chat 入口图标换** ✅ 灯泡→星花 sparkles + 发光效果(`AppSidebar.vue`) +- [x] **LLM 回复文字重叠防御** ✅ delta 重复检测+丢弃(`useAiEvents.ts`) +- [x] **全部接管仍审批** ✅ patch_file 全档位自动放行(`approval.rs`) + 诊断日志(`audit/mod.rs`) +- [x] **设置导入导出去除** ✅ `SettingsImportExport.vue` 已删,引用清理(`Settings.vue`/`SettingsNav.vue`) +- [x] **分离窗口默认置顶** ✅ `alwaysOnTop: true`(`useAiWindow.ts`) +- [x] **审批通知浮层** ✅ 全局右上角浮层(`ApprovalOverlay.vue` + `App.vue` 挂载) +- [x] **header 菜单聚合 `...`** ✅ 清空/压缩上下文收入 popout(`TopBar.vue`) +- [x] **置顶图标换** ✅ 星→大头针 pushpin(`TopBar.vue`) +- [x] **全部批准无响应** ✅ 防抖守卫 `_batchProcessing`(`useAiApproval.ts`) +- [x] **工具卡执行中卡死** ✅ 乐观更新 `status !== 'completed'` 守卫(`useAiApproval.ts`) +- [x] **置顶状态刷新丢失** ✅ 持久化到 appSettings KV(`AiChat.vue`) +- [x] **目标提取根本性改造** ✅ 工具调用推理(`infer_goal_from_tool_calls` in `helpers.rs`) + - 提取源:用户消息规则 → **工具调用推理**(结构化数据,零污染) + - 数据结构:`Vec` → **`Vec`**(text+status) + - 状态流转:新目标加入 → 之前 active 自动标记 completed + - system_prompt 仅注入 active 目标 + - 前端显示 3/5 计数 + ✓/● 状态标记 + - 代码拆分到 `helpers.rs`(解决 brace 嵌套致 pub(crate) 不可见) + +### 暂缓待后续批次 + +- [ ] **9b 审批交互 UI/UX 重新设计** ⏸️ + - 当前:审批卡片是 ToolCard 的 pending_approval 态,布局单调、args 展示过长、风险等级不直观 + - 待做:重新设计卡片布局(缩短 args 预览 + 展开/收起、风险等级着色条、审批耗时提示、批量操作栏视觉增强) + - 涉及:`ToolCard.vue`(~360 行) + `ToolCardList.vue` 少量调整 + - 优先级:中——现有功能可用但体验不够好 + +- [ ] **9d 底部工具区(目标/历史/上下文)展开交互优化** ⏸️ + - 当前:底部 🎯📋📄 三个入口紧凑排列,展开后内容区窄、超出不滚动、缺少动画 + - 待做:popover 浮层替代内联展开、加入平滑过渡动画、内容超出滚动、点击外部收起 + - 涉及:`TopBar.vue` 底部区 + - 优先级:低——功能完整,体验改善不迫切 + +- [ ] **`[UPDATE_GOALS]` LLM 主动修正目标列表** ⏸️ + - 设计已完成,实现暂缓 + - 格式:`[UPDATE_GOALS]\n1: 目标文本 (active)\n2: 目标文本 (completed)\n[END]` + - LLM 输出此标记 → 后端解析并替换 pinned_goals → 标记从正文剥离 + - 函数签名 `extract_update_goals_block` 已设计未实现 + - 覆盖场景:意图增强/纠偏/递进(用户补充信息后发现之前目标提取偏了) + - 优先级:中——价值高改动小,补全目标维护闭环 +