From c76e77bd3c6a365da39901016487b52dbccb64c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 1 Aug 2026 22:26:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=E5=85=B3=E4=B8=8D=E4=BA=86(None=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=A1=A5emit+=E6=81=A2=E5=A4=8D=E5=AF=B9=E8=B4=A6)=20?= =?UTF-8?q?+=20obscura=E6=A3=80=E6=B5=8B=E5=8C=BA=E5=88=86=E6=9C=AA?= =?UTF-8?q?=E8=A3=85/=E5=A4=B1=E8=B4=A5(=E9=98=B2=E8=AF=AF=E5=BC=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai/commands/chat.rs | 49 ++++++- .../src/commands/ai/commands/conversation.rs | 127 +++++++++++++++--- src-tauri/src/commands/ai/fetch_url.rs | 8 +- src/components/ToolResultBody.vue | 13 +- 4 files changed, 163 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index c9d1bd0..f6f6932 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -521,19 +521,36 @@ pub async fn ai_approve( // F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错) // BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把 // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。 - match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { + let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { Ok(Some(rec)) => { if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" { + // BUG-260618-11 + 关闭/拒绝无效根因修复(2026-08): + // 内存无此 pending(remove 返 None)且审计表已落定(completed/rejected/failed)时, + // 说明该审批**已处理但前端状态未收敛**——典型路径:对话 pending_approvals JSON 列 + // 残留陈旧条目(recovered 后内存与 DB 不同步 / 跨会话恢复竞态),前端 ApprovalPopup + // /ToolCard 显示挂起卡,用户点拒绝 → 本分支原 return Ok 不 emit 任何事件 → + // 主窗口 pendingApprovals 永不清(useAiEvents 仅 AiApprovalResult 事件驱动清)→ + // 卡死"关不了"。修复:即便已处理也 emit AiApprovalResult{approved:false}, + // 前端 useAiEvents:556 filter p.id !== event.id 收敛清掉(幂等,重复 emit 无害)。 + // conv_id 取审计记录的 conversation_id(无则 None,前端按 id 过滤不依赖 conv_id)。 + let ev = AiChatEvent::AiApprovalResult { + id: tool_call_id.clone(), + approved: false, + conversation_id: rec.conversation_id.clone(), + }; + let _ = app.emit("ai-chat-event", ev.clone()); + let _ = state.ai_event_bus.publish_event(ev); return Ok(format!("已处理({})", rec.status)); } + rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错 } - Ok(None) => { /* 真无记录,落到下方 return Err 未找到挂起审批 */ } + Ok(None) => { String::new() /* 真无记录,落到下方 return Err 未找到挂起审批 */ } Err(e) => { tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e); return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e)); } - } - return Err(format!("未找到挂起的审批: {}", tool_call_id)); + }; + return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status)); } }; // 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。 @@ -879,7 +896,29 @@ pub async fn ai_authorize_dir( let mut session = state.ai_session.lock().await; let v = match session.pending_approvals.remove(&tool_call_id) { Some(a) => a, - None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)), + None => { + // 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。 + drop(session); + // 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复): + // path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留 + // 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端 + // pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时 + // emit AiApprovalResult{approved:false} 让前端收敛(幂等)。 + match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { + Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => { + let ev = AiChatEvent::AiApprovalResult { + id: tool_call_id.clone(), + approved: false, + conversation_id: rec.conversation_id.clone(), + }; + let _ = app.emit("ai-chat-event", ev.clone()); + let _ = state.ai_event_bus.publish_event(ev); + return Ok(format!("已处理({})", rec.status)); + } + _ => {} + } + return Err(format!("未找到路径授权挂起: {}", tool_call_id)); + } }; let __hold864 = __lock_t864.elapsed(); if __hold864 > std::time::Duration::from_millis(30) { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 67cd42f..1b37c52 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -379,29 +379,122 @@ pub async fn ai_conversation_switch( // 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals) // 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。 - if let Some(pending_json) = &record.pending_approvals { - if pending_json != "{}" && !pending_json.is_empty() { - if let Ok(restored) = serde_json::from_str::< - std::collections::HashMap - >(pending_json) { - let restored_count = restored.len(); - // 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的) - session.pending_approvals.extend(restored); - tracing::info!( - "切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)", - conversation_id, restored_count - ); + // + // 陈旧过滤根因修复(2026-08,治"审批待办关不了"): + // 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**—— + // 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态), + // 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。 + // 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡, + // 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目, + // 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。 + let restored_pending: std::collections::HashMap = + if let Some(pending_json) = &record.pending_approvals { + if pending_json != "{}" && !pending_json.is_empty() { + match serde_json::from_str::< + std::collections::HashMap + >(pending_json) { + Ok(m) => m, + Err(_) => { + tracing::warn!( + "切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}", + conversation_id, &pending_json.chars().take(200).collect::() + ); + std::collections::HashMap::new() + } + } } else { - tracing::warn!( - "切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}", - conversation_id, &pending_json.chars().take(200).collect::() - ); + std::collections::HashMap::new() } - } + } else { + std::collections::HashMap::new() + }; + // 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的) + session.pending_approvals.extend(restored_pending.clone()); + if !restored_pending.is_empty() { + tracing::info!( + "切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)", + conversation_id, restored_pending.len() + ); } // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) drop(session); + // 审计核对 + 即时修剪:对每条恢复的 tool_call_id 查 ai_tool_executions, + // 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除, + // 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。 + // 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。 + // 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销)。 + if !restored_pending.is_empty() { + let mut stale_ids: Vec = Vec::new(); + for tc_id in restored_pending.keys() { + match state.ai_tool_executions.find_by_tool_call_id(tc_id).await { + Ok(Some(rec)) => { + // pending = 真挂起(首次或重启恢复未决策),保留。其余落定态 = 陈旧。 + if rec.status != "pending" { + stale_ids.push(tc_id.clone()); + } + } + Ok(None) => { + // 审计无记录但 JSON 列有 —— 数据异常(老库 / 手改 DB)。保守保留(不删), + // 信任前端审批流会处理;若确属脏数据,用户拒绝后经修复1的 emit 路径收敛。 + } + Err(e) => { + tracing::warn!( + "切换对话 {} 审计核对 tool_call_id={} 失败(保守保留该条): {}", + conversation_id, tc_id, e + ); + } + } + } + if !stale_ids.is_empty() { + // 从内存 pending_approvals 移除陈旧条目(短 lock retain) + { + let mut session = state.ai_session.lock().await; + let to_remove: Vec = stale_ids.clone(); + session.pending_approvals.retain(|k, _| !to_remove.contains(k)); + } + tracing::info!( + "切换对话 {} 审计核对剔除 {} 条陈旧审批(已落定,从 pending_approvals 内存+JSON 列移除)", + conversation_id, stale_ids.len() + ); + // 即时修剪 JSON 列:回写仅含存活条目的快照,根治下次切回再恢复陈旧。 + // 取存活集合(原恢复集合减陈旧)的当前内存态重新序列化(若期间被其他 IPC 改动,以内存为准)。 + let surviving_json = { + let session = state.ai_session.lock().await; + let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session + .pending_approvals + .iter() + .filter(|(_, a)| a.conversation_id.as_deref() == Some(&conversation_id)) + .collect(); + serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string()) + }; + // update_full 需读现 record(防覆盖期间其他字段被改),仅改 pending_approvals 列。 + match state.ai_conversations.get_by_id(&conversation_id).await { + Ok(Some(mut rec)) => { + rec.pending_approvals = Some(surviving_json); + if let Err(e) = state.ai_conversations.update_full(&rec).await { + tracing::warn!( + "切换对话 {} 修剪 pending_approvals JSON 列失败(非阻断,下次核对再修): {}", + conversation_id, e + ); + } + } + Ok(None) => { + tracing::warn!( + "切换对话 {} 修剪 pending_approvals JSON 列时对话已不存在(跳过)", + conversation_id + ); + } + Err(e) => { + tracing::warn!( + "切换对话 {} 修剪 pending_approvals JSON 列读对话失败(非阻断): {}", + conversation_id, e + ); + } + } + } + } + // 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。 // ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。 if need_title_regen { diff --git a/src-tauri/src/commands/ai/fetch_url.rs b/src-tauri/src/commands/ai/fetch_url.rs index 8aec5cc..2e57ec8 100644 --- a/src-tauri/src/commands/ai/fetch_url.rs +++ b/src-tauri/src/commands/ai/fetch_url.rs @@ -248,7 +248,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result< None => { // 未装:回退静态 + hint。 let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; - fallback["render_mode"] = json!("static_fallback"); + fallback["render_mode"] = json!("obscura_not_installed"); fallback["hint"] = json!(OBSCURA_HINT); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); @@ -277,7 +277,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result< Ok(Err(e)) => { tracing::warn!("obscura spawn 失败({}),回退静态", e); let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; - fallback["render_mode"] = json!("static_fallback"); + fallback["render_mode"] = json!("obscura_failed"); fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT)); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); @@ -285,7 +285,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result< Err(_) => { tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS); let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?; - fallback["render_mode"] = json!("static_fallback"); + fallback["render_mode"] = json!("obscura_failed"); fallback["hint"] = json!(format!("obscura 渲染超时({}s),已用静态模式。{}", OBSCURA_TIMEOUT_SECS, OBSCURA_HINT)); fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); return Ok(fallback); @@ -362,7 +362,7 @@ fn find_obscura() -> Option { let out = c.output(); let _ = tx.send(out); }); - match rx.recv_timeout(Duration::from_secs(5)) { + match rx.recv_timeout(Duration::from_secs(15)) { Ok(Ok(o)) if o.status.success() => return Some(cand.to_string()), _ => continue, } diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index 5c0e71f..e628fcf 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -185,7 +185,7 @@
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered') - : parsed?.render_mode === 'static_fallback' ? $t('aiTool.fetchUrlStatic') + : parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback') : $t('aiTool.fetchUrlStatic') }} {{ parsed?.url }} @@ -309,19 +309,16 @@ const grepHitLabel = computed(() => { }) /** - * fetch_url obscura 未装检测(render=true 但回退静态): - * 后端 render=true 且 obscura 未装/失败/超时 → result.render_mode='static_fallback' + hint 含 obscura 引导。 - * 此 computed 供模板显"AI 帮装"按钮 + obscura 未装提示。 - * 检测条件:render_mode==='static_fallback'(后端保证此值仅 render 分支回退时设,普通静态是 'static')。 - * 双重确认 hint 含 obscura(防御后端未来改字段名),任一命中即视为未装。 + * fetch_url obscura 未装检测(render=true 但 obscura 未装): + * 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。 + * 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。 */ const obscuraMissing = computed(() => { if (props.tc.name !== 'fetch_url') return false const r = parsed.value if (!r) return false const mode = typeof r.render_mode === 'string' ? r.render_mode : '' - const hint = typeof r.hint === 'string' ? r.hint : '' - return mode === 'static_fallback' || hint.toLowerCase().includes('obscura') + return mode === 'obscura_not_installed' }) /**