diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index 97519bc..2447ed8 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -6,7 +6,7 @@ //! //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; -//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); +//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ push(摘要 system 尾部追加); //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! //! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 @@ -54,7 +54,7 @@ const PROTECT_COUNT: usize = 6; /// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 /// /// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) -/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 +/// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。 /// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 /// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 const KEYWORD_FALLBACK_ENABLED: bool = true; @@ -252,7 +252,7 @@ pub(super) async fn maybe_auto_compress( match compress_outcome { Ok(Some(summary)) => { - // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 + // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。 // conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv, // is_compressing 标志随 conv 销毁无意义,直接 return。 { @@ -262,8 +262,11 @@ pub(super) async fn maybe_auto_compress( } let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); - // NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定 - conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); + // NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条 + // 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效 + // (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定 + // 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。 + conv.messages.push(ChatMessage::system(&summary.nl_summary)); conv.messages.set_compressing(false); conv.working_context.sync_after_compress(&summary.json_card); } @@ -271,7 +274,7 @@ pub(super) async fn maybe_auto_compress( conv_id = %conv_id, iteration, pre_tokens = pre_compress_tokens, - "[ai] 自动压缩成功(后台),摘要已插首位" + "[ai] 自动压缩成功(后台),摘要已尾部追加" ); // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 @@ -290,13 +293,13 @@ pub(super) async fn maybe_auto_compress( } } Err(e) => { - // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 + // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。 // // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); - // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; + // - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点; // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) @@ -337,9 +340,9 @@ pub(super) async fn maybe_auto_compress( /// /// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径 /// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate -/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。 +/// (标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。 /// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为 -/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。 +/// "[ai] {log_head}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)")。 /// /// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。 async fn keyword_fallback_commit( @@ -363,7 +366,10 @@ async fn keyword_fallback_commit( if keyword_fallback.is_empty() { false } else { - conv.messages.insert_at(0, ChatMessage::system(keyword_fallback)); + // 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节, + // 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。 + // 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。 + conv.messages.push(ChatMessage::system(keyword_fallback)); true } }; @@ -371,7 +377,7 @@ async fn keyword_fallback_commit( tracing::info!( conv_id = %conv_id, pre_tokens, - "[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)", + "[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)", log_head ); } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 5ff6056..fa103f5 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -341,6 +341,19 @@ pub const CIRCUIT_BREAKER_ENABLED: bool = true; /// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。 pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true; +/// 熔断呈现形态开关(#904 附项,2026-10-09):熔断同轮双卡叠加治理。 +/// +/// 背景:熔断路径原同时 emit 两张卡——求助卡 AiHelpRequired(换思路/授权路径/人工接管) +/// + 停靠卡 AiTaskParked(继续/归档),同轮叠加语义打架且文案冗长;且停靠卡「继续」 +/// 正是熔断后的正典恢复入口(ai_continue_loop → recover_parked_inner)。 +/// +/// true(默认):熔断只发停靠一张卡——AiTaskParked 为唯一呈现,reason 内联原求助卡的 +/// 关键信息(失败样本/路径),前端停靠卡即含恢复指引,零双卡叠加。 +/// false(回退):保留旧行为(求助卡 + 停靠卡双发),用于排障/对比。 +/// 注:本开关仅在 CIRCUIT_BREAKER_HELP_EVENT=true 时生效;后者=false 时求助卡本就是 +/// AiError 错误气泡,与停靠卡不同族,不构成双卡叠加,维持原样。 +pub const CIRCUIT_BREAKER_PARK_ONLY: bool = true; + // 占位配对完整性开关(解 400 orphan)。 // // 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。 @@ -2261,12 +2274,16 @@ async fn process_round_tools( if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { return ToolsOutcome::Exit; } - emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); + // #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡 + // 同轮叠加语义打架;求助卡关键信息(失败样本)内联进停靠卡 reason。开关关则退旧双卡。 + if !CIRCUIT_BREAKER_PARK_ONLY { + emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); + } // 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡, // 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。 crate::commands::ai::emit_task_parked( app_handle, conv_id, - &format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count), + &format!("连续同类失败 {} 次已熔断(最近错误: {}),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count, sample_key), "circuit_breaker", ); return ToolsOutcome::Exit; @@ -2542,15 +2559,19 @@ async fn check_stall_breaker( ); let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径"); // DRY #1:双写正典(熔断求助卡跨端可见) - emit_ai_chat_event( - app_handle, - AiChatEvent::AiHelpRequired { - reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc), - context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(), - options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], - conversation_id: Some(conv_id.to_string()), - }, - ); + // #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮 + // 叠加语义打架;开关关则退旧双卡行为。 + if !CIRCUIT_BREAKER_PARK_ONLY { + emit_ai_chat_event( + app_handle, + AiChatEvent::AiHelpRequired { + reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc), + context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(), + options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], + conversation_id: Some(conv_id.to_string()), + }, + ); + } // P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默) crate::commands::ai::notify::notify_session_event( app_handle, @@ -2573,15 +2594,19 @@ async fn check_stall_breaker( "[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count ); // DRY #1:双写正典(熔断求助卡跨端可见) - emit_ai_chat_event( - app_handle, - AiChatEvent::AiHelpRequired { - reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count), - context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(), - options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], - conversation_id: Some(conv_id.to_string()), - }, - ); + // #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮 + // 叠加语义打架;开关关则退旧双卡行为。 + if !CIRCUIT_BREAKER_PARK_ONLY { + emit_ai_chat_event( + app_handle, + AiChatEvent::AiHelpRequired { + reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count), + context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(), + options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], + conversation_id: Some(conv_id.to_string()), + }, + ); + } // P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默) crate::commands::ai::notify::notify_session_event( app_handle, diff --git a/src-tauri/src/commands/ai/agentic/stream_relay.rs b/src-tauri/src/commands/ai/agentic/stream_relay.rs index 13e7cdf..8b9f080 100644 --- a/src-tauri/src/commands/ai/agentic/stream_relay.rs +++ b/src-tauri/src/commands/ai/agentic/stream_relay.rs @@ -40,6 +40,27 @@ use super::{check_retry_exhausted, resolve_model_with_override}; use super::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since}; use super::super::event_bus::emit_ai_chat_event; +// ============================================================ +// MidStream 短重试/续传预算(看板 #906,打扰治理) +// ============================================================ +// +// 背景:网络抖动(如 u-switch 抖动期)导致流中途断,原路径(MidStreamSaved)直接 +// emit_task_parked 停靠——无 Fatal 家族的 ROUND_REENTRY_MAX 重入预算,一次抖动即弹 +// 「响应流中断/任务已停靠」卡并进行中任务终止,用户实测频繁打扰。 +// +// 本层对齐 Fatal 重入语义,但采用「续传」而非「重发」:partial 已作为 assistant 消息 +// 入库,续传请求 = 原请求 + partial(assistant)+ 续传 user 提示,模型从断点继续; +// 成功则本轮转为 Continue(不弹卡、不保文停靠),全部耗尽才落回原保文+停靠路径。 +// 退避比 Fatal 短(5s/15s):midstream 断多为瞬态网络抖动,且 partial 已落库风险低, +// 快速恢复优于长等;仅 2 次预算,避免抖动期长时间静默。 +pub(crate) const MIDSTREAM_REENTRY_MAX: usize = 2; +pub(crate) const MIDSTREAM_REENTRY_BACKOFF_SECS: [u64; MIDSTREAM_REENTRY_MAX] = [5, 15]; + +/// 续传提示(合成 user 消息,不落库):告知模型上一条回复因网络中断被截断,请从断点继续。 +/// 不要求重述已完成内容(避免重复),只补完剩余部分。 +pub(crate) const MIDSTREAM_CONTINUE_PROMPT: &str = + "[系统] 上一条回复因网络中断被截断,请从中断处继续补全剩余内容;不要重述已输出的部分,直接接着写。"; + // ============================================================ // 单 Provider 流式结果 + fallback 辅助 // ============================================================ @@ -334,7 +355,7 @@ pub(crate) async fn stream_round( // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 // 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, // 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。 - let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + // #906:该预算现于 'midstream 循环内每次(重)入刷新(续传重入获全新 30s),故此处不再声明。 // 外层 for candidate 包裹 stream_one_provider。 // 顺序:[primary, ...candidates](空池兜底时仅 primary)。 @@ -375,7 +396,21 @@ pub(crate) async fn stream_round( ); } - let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = { + // ── MidStream 短重试/续传预算(看板 #906)── + // 包裹 candidate 循环:流中途断(incomplete)时,若预算未耗尽则「续传」重试—— + // 把已接收 partial 作为 assistant 消息追加进请求(模型从断点继续),而非整轮重发 + // (整轮重发会与前端已渲染的 partial 重复)。全部耗尽才落回原保文+停靠路径。 + // 续传请求消息:首次 = 原请求;每次续传追加 [partial(assistant), 续传提示(user)]。 + // 注:partial 同时 push 进 session(见下方成功/耗尽两分支),保证落库与前端一致 + // (前端 flush 后 partial 占一个气泡,续传文本占下一个气泡)。 + let mut cont_messages: Vec = messages.to_vec(); + let mut midstream_attempt: usize = 0; + + let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = 'midstream: loop { + // 每次(重)入 midstream 循环刷新 30s 流前重试预算(对齐 Fatal 重入「每 per-permit 重取 + // 重入获全新 30s 预算」语义)。 + let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option, bool)> = None; // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 @@ -414,7 +449,7 @@ pub(crate) async fn stream_round( let stream_outcome = stream_one_provider( candidate, - messages, + &cont_messages, tool_defs, app_handle, stop_flag, @@ -471,7 +506,7 @@ pub(crate) async fn stream_round( } } - match outcome { + let round_result = match outcome { Some(r) => r, None => { // 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 @@ -492,7 +527,76 @@ pub(crate) async fn stream_round( save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await; return RoundOutcome::Fatal { error: err_msg }; } + }; + + // ── MidStream 短重试/续传判定(看板 #906)── + // 仅当本轮 incomplete(流中途断)且用户未点停时进入;否则直接交回主框架。 + let (text, tool_calls, usage, round_incomplete, rc, truncated) = round_result; + if !(round_incomplete && !stop_flag.load(Ordering::SeqCst)) { + break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated); } + + // 用户停优先:网络断与用户点停并发时,停靠语义让位停止路径(原行为保留)。 + // 预算耗尽:落回原保文+停靠路径(交回主框架)。 + if midstream_attempt >= MIDSTREAM_REENTRY_MAX { + break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated); + } + + // ── 续传重试 ── + // ① 已接收 partial 落库为 assistant 消息(每段都落:续传轮的 partial 是「本次续传段」, + // 与前端每次 flush 出的气泡一一对应,保证落库与前端视图一致)。 + let partial_trimmed = text.trim().to_string(); + if !partial_trimmed.is_empty() { + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + let mut msg = ChatMessage::assistant(&partial_trimmed); + msg.model = Some(resolved_model.clone()); + msg.reasoning_content = None; + conv.messages.push(msg); + } else { + tracing::warn!(stale_conv = %conv_id, "[ai] MidStream 续传前 conv 已删除,放弃续传"); + break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated); + } + } + + // ② 本段 usage 累加(计费口径:每段都真实调用了 provider,不累加会少计中间段成本)。 + // 置于 conv 存在性校验之后:conv 已删时上方已 break(不累加),避免与终局保文分支重复计。 + // 用与主链同一派生点 round_usage_effective(prompt 估算兜底口径一致)。 + let (attempt_usage, _) = round_usage_effective(&usage, estimated_prompt); + tokens.add_usage(&attempt_usage); + + midstream_attempt += 1; + let delay = MIDSTREAM_REENTRY_BACKOFF_SECS[midstream_attempt - 1]; + tracing::warn!( + conv_id = %conv_id, + attempt = midstream_attempt, + max_attempts = MIDSTREAM_REENTRY_MAX, + delay_secs = delay, + text_len = partial_trimmed.len(), + "[ai] 流中途断,{}s 后从断点续传({}/{})", + delay, midstream_attempt, MIDSTREAM_REENTRY_MAX, + ); + + // ③ 通知前端:flush 当前 partial 到独立气泡,续传文本渲染到新气泡(AiAgentRound + // 触发前端 flushCurrentText + 新建 assistant 气泡)。round 传 0 = 仅 flush 语义 + // (前端 round>0 才更新进度/轮次,0 不跳进度条,对齐 helpers.rs 续跑 round:0 用法)。 + // 注:iteration==0 时 prepare_round_inputs 不发 AiAgentRound,故此处显式补发。 + emit_ai_chat_event(app_handle, AiChatEvent::AiAgentRound { + round: 0, + conversation_id: Some(conv_id.to_string()), + }); + + // ④ 构造续传请求:原请求 + 本次 partial(assistant)+ 续传提示(user)。 + // 半截 reasoning 不回传(对齐 B-260617-16 MidStream 保文决策:残缺推理回传 + // reasoning 模型可能引发 thinking 块错配,且对续写无增益)。 + *last_reasoning_content = None; + cont_messages.push(ChatMessage::assistant(&partial_trimmed)); + cont_messages.push(ChatMessage::user(MIDSTREAM_CONTINUE_PROMPT)); + + // ⑤ 退避后重入 candidate 循环(刷新 30s 流前重试预算;候选链沿用,饱和降级计数跨轮保留)。 + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + continue 'midstream; }; // G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底), @@ -512,10 +616,12 @@ pub(crate) async fn stream_round( // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收, // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 - // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 + // 不走 AiError(非异常中断,已有可用文本)。 + // #906 变更:续传预算耗尽后才到达此处(预算内已由上方 'midstream 循环续传,成功则走 + // Continue 不落此)。续传路径已把「中间各段」partial 落库(循环内 push),此处补「最后一段」 + // + 系统提示(最后一段未在循环内 push,无重叠)。 // 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径, - // 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。 - // partial 文本仍由下方 push_assistant_message(~1485)保文不丢。 + // 落入下方 stop_flag 检查走停止路径(用户意图优先)。 if incomplete && !stop_flag.load(Ordering::SeqCst) { // 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生 let usage = round_usage_full; @@ -536,8 +642,10 @@ pub(crate) async fn stream_round( } // 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库 let full_text = full_text.trim(); + let conv = session.conv(conv_id); + // #906:续传路径已把「中间各段」partial 落库(循环内 push),但「最后一段」 + // (预算耗尽时 break 的 text)未落库——故此处始终补最后一段(无重叠,不重复)。 if !full_text.is_empty() { - let conv = session.conv(conv_id); let mut msg = ChatMessage::assistant(full_text); msg.model = Some(resolved_model.clone()); // B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传 @@ -555,11 +663,17 @@ pub(crate) async fn stream_round( // 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态 msg.is_estimated = Some(round_is_estimated); conv.messages.push(msg); - // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) - let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); - notice.model = Some(resolved_model.clone()); - conv.messages.push(notice); } + // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)。 + // #906:续传也失败时提示文案含「已自动重试」,让用户理解已尽力。 + let notice_text = if midstream_attempt > 0 { + "⚠ 响应因网络中断不完整(已自动续传重试仍未恢复),以上为已接收的部分内容。可重新发送或点继续以获取完整回复。" + } else { + "⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。" + }; + let mut notice = ChatMessage::system(notice_text); + notice.model = Some(resolved_model.clone()); + conv.messages.push(notice); } // 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。 @@ -678,3 +792,37 @@ pub(crate) fn merge_volatile_tail(messages: &mut Vec, tail: &str) { } messages.push(ChatMessage::user(tail)); } + +// MidStream 短重试/续传预算(看板 #906)契约:退避表长须覆盖重入上限(取值索引 +// attempt-1 不越界)且单调递增(快速恢复语义:midstream 断多为瞬态网络抖动,退避比 +// Fatal 家族短)。续传提示须非空(合成 user 消息,空提示模型无续传指令)。 +#[cfg(test)] +mod midstream_reentry_tests { + use super::{MIDSTREAM_CONTINUE_PROMPT, MIDSTREAM_REENTRY_BACKOFF_SECS, MIDSTREAM_REENTRY_MAX}; + + #[test] + fn backoff_schedule_covers_max_attempts_and_ascending() { + assert_eq!( + MIDSTREAM_REENTRY_BACKOFF_SECS.len(), + MIDSTREAM_REENTRY_MAX, + "退避表长须覆盖续传上限(取值索引 attempt-1 不越界)", + ); + for w in MIDSTREAM_REENTRY_BACKOFF_SECS.windows(2) { + assert!(w[0] < w[1], "退避须单调递增: {:?}", w); + } + // 处方锚定:5s/15s(短退避,抖动期快速恢复;显著短于 Fatal 家族 60/300/900) + assert_eq!(MIDSTREAM_REENTRY_BACKOFF_SECS, [5, 15]); + assert!( + MIDSTREAM_REENTRY_BACKOFF_SECS[0] < 60, + "midstream 首退避须短于 Fatal 家族首退避(60s),体现快速恢复语义", + ); + } + + #[test] + fn continue_prompt_non_empty() { + assert!( + !MIDSTREAM_CONTINUE_PROMPT.trim().is_empty(), + "续传提示不可为空(空提示模型无续传指令)", + ); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 90cc936..875b8ae 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -15,6 +15,7 @@ use super::event_bus::emit_ai_chat_event; use crate::state::{SettingsNorm, log_slow_lock}; use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; use super::tools::entity_resolve; +use crate::commands::ai::is_destructive_command; // utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。 // 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块 @@ -253,6 +254,23 @@ pub(crate) async fn execute_with_heartbeat( use std::sync::atomic::{AtomicBool, Ordering}; use tokio::time::Duration; + // ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ── + // 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃), + // 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。 + // 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。 + if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) { + tracing::warn!( + conv_id = %conv_id, + tool = %name, + tool_call_id = %tool_call_id, + "[failure-memory] 同调用连败达标,执行前拦截" + ); + return Err(anyhow::anyhow!(blocked)); + } + // record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。 + // 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。 + let args_for_memory = args.clone(); + // ── W-h1 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ── // 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly // 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案 @@ -411,7 +429,7 @@ pub(crate) async fn execute_with_heartbeat( // 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) / // 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。 // exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。 - tokio::select! { + let exec_result = tokio::select! { result = &mut exec_fut => result, _ = tokio::time::sleep(Duration::from_secs(outer_secs)) => { tracing::error!( @@ -432,7 +450,17 @@ pub(crate) async fn execute_with_heartbeat( ); Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason)) } + }; + + // ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ── + // 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数, + // 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。 + match &exec_result { + Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None), + Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())), } + + exec_result } // ============================================================ @@ -1032,8 +1060,27 @@ pub(crate) async fn process_tool_calls( // 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。 if !is_sub_agent { if let Some(b) = &boundary { - if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) { + // all 档 = 用户显式完全接管(L3 最高授权),优先于任务边界: + // 授权语义上"显式全放权"应覆盖"默认收权边界",否则用户开 all 反被边界拦死 + // (功能反转缺陷:越授权越跑不动)。all 档下不在此层拦截,落到下方 + // handle_approval_tool 走正常判定链(其内 all 档放行 High)。 + // 唯一保留的安全线:破坏性命令(all 档也不放行,纵深防御独立于风险等级)。 + if auto_exec_mode == "all" { + let destructive = draft.name == "run_command" + && args.get("command").and_then(|v| v.as_str()) + .map(is_destructive_command).unwrap_or(false); + if destructive { + deny_boundary_tool( + session_arc, conv_id, &draft, tools_arc, &audit_repo, + "boundary-risk", "破坏性命令恒拒(all 档亦不放行,纵深防御)", + current_message_id, app_handle, + ).await; + continue; + } + // 非破坏性:all 档放行,不 continue,落到 handle_approval_tool + } else if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) { boundary_auto.push((draft, args, risk_level)); + continue; } else { let reason = if risk_level == RiskLevel::High { "工具风险 High 超出任务风险上限".to_string() @@ -1044,8 +1091,8 @@ pub(crate) async fn process_tool_calls( session_arc, conv_id, &draft, tools_arc, &audit_repo, "boundary-risk", &reason, current_message_id, app_handle, ).await; + continue; } - continue; } } // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 1e3b04b..32852bb 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -491,6 +491,16 @@ pub async fn tool_failure_stats( Ok(aggregate_tool_stats(rows)) } +/// 读回外置工具结果原文(D2a 大结果外置的读取侧入口)。 +/// +/// 审计记录中 result 超 8KB 时被外置为句柄 JSON(含 path 字段),DB 只留句柄。 +/// 前端审计面板展示历史时,遇句柄需按 path 读回原文全文——本 IPC 即该入口。 +/// 过期(>90 天归档清理)后文件不在,返回带语义的错误提示(降级不 panic)。 +#[tauri::command] +pub async fn ai_read_offloaded_result(path: String) -> Result { + tool_result_offload::read_offloaded(&path) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 7cfca05..a7405be 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -1421,20 +1421,39 @@ pub(crate) async fn execute_run_workflow_for_tool( // 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点 // ============================================================ -/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。 +/// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一「写 parked + 发事件 + 通知」三件套。 /// -/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+ +/// #904 修:原实现只发事件+通知,漏写持久层 loop_state=parked —— 前端据此渲染停靠卡 +/// 「继续」按钮,但点继续走 ai_continue_loop 非活跃分流时查 loop_state 不匹配 → 报 +/// 「AI 未在暂停态」(全仓此前唯一写 parked 的路径是应用重启 V49 残留扫描)。现将写入 +/// 收敛进本单点,调用方无需再自行落库。 +/// +/// 调用方职责(本函数不做,保持单点语义):调用前完成「保存现场(落库)+ /// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有 /// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。 /// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。 pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) { + // ① 写持久层 loop_state=parked(停靠卡「继续」→ ai_continue_loop 分流 → recover_parked_inner + // 的判据来源;fire-and-forget 不阻塞事件/通知链路,失败仅 warn)。 + { + use tauri::Manager; + let state = app.state::(); + let repo = state.ai_conversations.clone(); + let cid = conv_id.to_string(); + tauri::async_runtime::spawn(async move { + if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await { + tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败"); + } + }); + } + // ② 发停靠事件(前端渲染停靠卡+继续按钮)。 let ev = AiChatEvent::AiTaskParked { conversation_id: Some(conv_id.to_string()), reason: reason.to_string(), stop_kind: Some(stop_kind.to_string()), }; event_bus::emit_ai_chat_event(app, ev); - // 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组 + // ③ 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组 // (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。 notify::notify_session_event( app, diff --git a/src-tauri/src/commands/ai/tool_failure_memory.rs b/src-tauri/src/commands/ai/tool_failure_memory.rs index 01d0929..fce7ff5 100644 --- a/src-tauri/src/commands/ai/tool_failure_memory.rs +++ b/src-tauri/src/commands/ai/tool_failure_memory.rs @@ -101,11 +101,6 @@ pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option usize { - memory().lock().unwrap_or_else(|p| p.into_inner()).len() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/ai/tool_result_offload.rs b/src-tauri/src/commands/ai/tool_result_offload.rs index fcd5862..254066c 100644 --- a/src-tauri/src/commands/ai/tool_result_offload.rs +++ b/src-tauri/src/commands/ai/tool_result_offload.rs @@ -5,10 +5,11 @@ //! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。 //! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧 //! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。 -//! - D2d 归档:cleanup_expired(90 天)删除过期外置文件(句柄留 DB,文件没了读时 -//! 降级提示「已过期清理」);调用点挂 unattended 引擎每日一轮。 -//! - 历史数据默认保留禁清理(全局规则)——cleanup 只删「本模块自己写的外置文件」 -//! (目录专有,含 marker 头校验),不碰任何 DB 行,原始库零风险。 +//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。 +//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留 +//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。 +//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。 +//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。 use std::path::PathBuf; use std::sync::OnceLock; @@ -34,7 +35,9 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String { /// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。 pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024; -/// D2d 归档阈值(定案 2026-10-06):外置文件 90 天后可清理。 +/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。 +/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。 +#[allow(dead_code)] pub const ARCHIVE_AFTER_DAYS: u64 = 90; /// 句柄 JSON 字段名(读取降级判据)。 @@ -76,14 +79,21 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str) handle.to_string() } -/// 读外置文件(前端/查询侧展开句柄用)。过期清理后降级提示。 +/// 读外置文件(前端/查询侧展开句柄用)。文件缺失时返回带语义的错误(降级不 panic)。 +/// +/// 注:90 天归档清理已停用(2026-10-09 用户决策,外置文件全部保留),故此路径 +/// 正常应恒可读;报错仅当文件被外部误删或路径失效。 pub fn read_offloaded(path: &str) -> Result { - std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(可能已过 90 天归档清理): {e}")) + std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}")) } -/// D2d:清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。 +/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。 /// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删 /// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。 +/// +/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。 +/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。 +#[allow(dead_code)] pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) { let root = base.join("tool-results"); let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) }; diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs index 1618065..ecea9c2 100644 --- a/src-tauri/src/commands/ai/tools/task.rs +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -43,15 +43,20 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), args => { let repo = df_storage::crud::TaskRepo::new(&db); - let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { - repo.query("project_id", pid).await? - } else { - repo.list_all().await? + // 走 list_by_query(与 IPC/MCP 同源):恒带 `deleted_at IS NULL AND archived = 0`, + // 回收站/归档任务不进结果。 + // + // 修复背景:此前用 repo.query("project_id", pid) / repo.list_all(),二者均无 + // 软删/归档过滤 → AI 工具能看到回收站任务,但 advance_task 的 CAS SQL 带 + // `deleted_at IS NULL AND archived = 0` 必败 → 报「已变更(并发推进或旁路修改)」 + // 误导性错误(真实原因是任务在回收站)。改用 list_by_query 后,AI 看到的任务 + // 与可推进的任务集合一致,消除「看得见点不动」。 + let q = df_storage::crud::TaskQuery { + project_id: args.get("project_id").and_then(|v| v.as_str()).map(String::from), + status: args.get("status").and_then(|v| v.as_str()).map(String::from), + ..Default::default() }; - // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled/deferred - if let Some(status) = args.get("status").and_then(|v| v.as_str()) { - tasks.retain(|t| t.status.as_str() == status); - } + let tasks = repo.list_by_query(&q).await?; let total = tasks.len(); let offset = args["offset"].as_u64().unwrap_or(0) as usize; let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; @@ -253,11 +258,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { schema: object_schema(vec![("project_id", "string", false)]), args => { let repo = df_storage::crud::TaskRepo::new(&db); - let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { - repo.query("project_id", pid).await? - } else { - repo.list_all().await? + // 走 list_by_query(与 list_tasks 同源):恒带 `deleted_at IS NULL AND archived = 0`, + // 计数与可见任务集合一致(此前 list_all/query 无过滤,计数含回收站/归档任务, + // 与 list_tasks 结果数不符,分页策略判断失真)。 + let q = df_storage::crud::TaskQuery { + project_id: args.get("project_id").and_then(|v| v.as_str()).map(String::from), + ..Default::default() }; + let tasks = repo.list_by_query(&q).await?; Ok(serde_json::json!({ "total": tasks.len() })) } ); diff --git a/src-tauri/src/commands/ai/unattended.rs b/src-tauri/src/commands/ai/unattended.rs index 7c022df..a979762 100644 --- a/src-tauri/src/commands/ai/unattended.rs +++ b/src-tauri/src/commands/ai/unattended.rs @@ -424,26 +424,34 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) { } // 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志 run_self_monitor(app, cfg).await; - // D2d 归档清理:每日一次(挂钟桶去重),清 90 天前外置派生文件(不碰 DB 行) + // 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。 + // 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测 + // 保留,未来若需恢复只改此处。 run_daily_offload_cleanup(app).await; } -/// D2d 每日归档清理(挂钟桶去重:同一天只跑一次;失败静默,下一天重试)。 -async fn run_daily_offload_cleanup(app: &AppHandle) { - use std::sync::atomic::AtomicU64; - static LAST_DAY: AtomicU64 = AtomicU64::new(0); - let day = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() / 86400) - .unwrap_or(0); - if LAST_DAY.swap(day, Ordering::SeqCst) == day { - return; // 今日已跑 - } - let data_dir = app.path().app_data_dir().unwrap_or_default(); - let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir); - if removed > 0 { - tracing::info!("[D2d] 归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节"); - } +/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。 +/// +/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体 +/// 与单测均未删。当前为空转,不触碰任何外置文件。 +async fn run_daily_offload_cleanup(_app: &AppHandle) { + // 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。 + // 用户决策改为全部保留,故摘除调用点,函数保留为空转。 + // + // use std::sync::atomic::AtomicU64; + // static LAST_DAY: AtomicU64 = AtomicU64::new(0); + // let day = std::time::SystemTime::now() + // .duration_since(std::time::UNIX_EPOCH) + // .map(|d| d.as_secs() / 86400) + // .unwrap_or(0); + // if LAST_DAY.swap(day, Ordering::SeqCst) == day { + // return; // 今日已跑 + // } + // let data_dir = _app.path().app_data_dir().unwrap_or_default(); + // let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir); + // if removed > 0 { + // tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节"); + // } } // ============================================================ diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 12a3f02..5530898 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -259,3 +259,29 @@ pub async fn resolve_conflict( crate::commands::ai::event_bus::emit_ai_chat_event(&app_handle, ev); Ok(()) } + +/// 应用版本信息(编译期注入,单一源头)。 +/// +/// 关于页 / 更新检查消费。字段: +/// - `version`: `env!("CARGO_PKG_VERSION")`,编译时展开为 Cargo.toml 的 version, +/// 与 tauri.conf.json 的 version 保持同源(L0 决策:双源对齐)。 +/// - `build_time` / `git_hash`: 由构建脚本经 `option_env!` 注入;未注入时回落 `"dev"` +/// (本地 cargo check / 开发构建无注入,不阻断)。 +/// +/// 返回结构预留扩张位(设计 §四 L0「扩张性」):未来 df-im-gateway 等独立组件 +/// 走各自版本线时,可增 `components: []` 数组,不破坏现有消费方。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct AppVersionInfo { + pub version: String, + pub build_time: String, + pub git_hash: String, +} + +#[tauri::command] +pub async fn app_get_version() -> Result { + Ok(AppVersionInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + build_time: option_env!("DF_BUILD_TIME").unwrap_or("dev").to_string(), + git_hash: option_env!("DF_GIT_HASH").unwrap_or("dev").to_string(), + }) +} diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index cbd61a4..2f81fc5 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -13,7 +13,9 @@ use std::sync::Arc; use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; use tauri::Manager; -use larksuite_oapi_sdk_rs::card::v1::{Card, CardDocument, Element, Markdown as MdElement}; +use larksuite_oapi_sdk_rs::card::v1::{ + Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header, Markdown as MdElement, +}; use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy, SendInput}; use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption}; use serde::Deserialize; @@ -68,11 +70,36 @@ struct FeishuConfig { app_id: String, #[serde(default)] app_secret: String, - /// 空 = 放行所有(首启抓 open_id 回填);open_id 按应用隔离,严禁跨应用搬运 - #[serde(default)] + /// 空 = 放行所有(首启抓 open_id 回填);open_id 按应用隔离,严禁跨应用搬运。 + /// 兼容历史字符串形态(逗号分隔):前端旧版曾存字符串,解析失败会静默沿用旧缓存 + /// 致白名单改动不生效,故此处 deserialize_with 双形态兼容(数组优先,字符串拆分) + #[serde(default, deserialize_with = "de_whitelist")] whitelist: Vec, } +/// 白名单反序列化双形态兼容:数组(正典)或逗号分隔字符串(历史脏数据)。 +/// 非法形态(数字/对象等)→ 空 Vec(等价放行所有,不 panic 不报错) +fn de_whitelist<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + let v = Option::::deserialize(deserializer)?; + Ok(match v { + Some(serde_json::Value::Array(arr)) => arr + .into_iter() + .filter_map(|x| x.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + .collect(), + Some(serde_json::Value::String(s)) => s + .split(',') + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect(), + _ => Vec::new(), + }) +} + /// settings 值归一:前端 JSON.stringify 落库带引号,去首尾引号(SettingsNorm 正典同款) fn norm(raw: Option) -> String { raw.map(|s| s.trim().trim_matches('"').to_string()) @@ -161,13 +188,38 @@ pub fn start_if_enabled(app: tauri::AppHandle) { // 与 run_loop 的按连接重建无关) tauri::async_runtime::spawn(run_approval_notifier(ctx.clone())); // 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死 + // 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。 + // 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻), + // 其后的退出即真断线,推「重新连接」通知;连续失败≥3 推告警。通知目标取 + // df-monitor-chat-id(运维通道,与监控告警同键;未配则仅落日志) let mut backoff = 2u64; + let mut consecutive_failures = 0u32; loop { - if let Err(e) = run_loop(ctx.clone(), leaked).await { - tracing::error!("[feishu] 长连接退出,{}s 后重连: {e}", backoff); - feishu_log(&format!("长连接断开,{}s 后重连: {e}", backoff)); + let started = tokio::time::Instant::now(); + let run_result = run_loop(ctx.clone(), leaked).await; + let ran_secs = started.elapsed().as_secs(); + let was_connected = ran_secs >= 30; + if was_connected { + // 曾成功连接 → 本次退出是真断线,重置失败计数并推重连通知 + consecutive_failures = 0; + let reason = match &run_result { + Err(e) => format!("{e}"), + Ok(_) => "连接正常关闭".to_string(), + }; + tracing::warn!("[feishu] 长连接断开(运行 {ran_secs}s),{}s 后重连: {reason}", backoff); + feishu_log(&format!("长连接断开(运行 {ran_secs}s),{}s 后重连: {reason}", backoff)); + notify_monitor(&ctx, &format!("⚠️ 飞书渠道连接断开,{backoff}s 后自动重连中…")).await; } else { - tracing::info!("[feishu] 长连接正常退出,{}s 后重连", backoff); + consecutive_failures += 1; + let reason = match &run_result { + Err(e) => format!("{e}"), + Ok(_) => "连接立即关闭".to_string(), + }; + tracing::error!("[feishu] 长连接快速退出(运行 {ran_secs}s,连续失败 {consecutive_failures}),{}s 后重连: {reason}", backoff); + feishu_log(&format!("长连接快速退出(运行 {ran_secs}s,连续失败 {consecutive_failures}),{}s 后重连: {reason}", backoff)); + if consecutive_failures == 3 { + notify_monitor(&ctx, "⚠️ 飞书渠道连接不稳定(连续 3 次快速断开),后台持续重连中,请检查网络或凭证配置。").await; + } } tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; backoff = (backoff * 2).min(300); @@ -175,10 +227,24 @@ pub fn start_if_enabled(app: tauri::AppHandle) { }); } +/// 运维通知:推给 df-monitor-chat-id 配置的飞书群(与监控告警同键)。未配/发送失败 +/// 仅落日志,不阻塞重连循环。重连通知是锦上添花,失败静默 +async fn notify_monitor(ctx: &Ctx, text: &str) { + let db = ctx.app.state::().db.clone(); + let repo = SettingsRepo::new(&db); + let chat_id = norm(repo.get("df-monitor-chat-id").await.ok().flatten()); + if chat_id.is_empty() { + feishu_log(&format!("[notify] 未配置 df-monitor-chat-id,仅落日志: {text}")); + return; + } + let _ = send_markdown_card(&ctx.client, "", &chat_id, text).await; +} + async fn run_loop(ctx: Arc, client: &'static LarkClient) -> anyhow::Result<()> { let dispatcher = EventDispatcher::new("", ""); - let ctx_msg = ctx.clone(); - let ctx_ready = ctx.clone(); +let ctx_msg = ctx.clone(); + let ctx_ready = ctx.clone(); + let ctx_card = ctx.clone(); // D3 看门狗与单次连接生命周期绑定:run_loop 退出时 abort,不逐重连累积 let watchdog = tauri::async_runtime::spawn(run_liveness_watchdog(ctx.clone())); // 新连接建立即视为存活起点(内部断线重连由 on_ready 再刷新) @@ -203,6 +269,15 @@ async fn run_loop(ctx: Arc, client: &'static LarkClient) -> anyhow::Result< Ok(()) } }) + .on_card_action(move |action| { + let ctx = ctx_card.clone(); + async move { + tauri::async_runtime::spawn(async move { + handle_card_action(ctx, action).await; + }); + Ok(()) + } + }) .build(); tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)"); let result = channel @@ -304,11 +379,12 @@ async fn handle_message( // D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳 ctx.last_alive.store(now_secs(), Ordering::SeqCst); if msg.message_type != "text" { - // #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈 + // #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。 + // 补替代路径引导:用户发图/文件往往是想让 AI 处理内容,直接告知改用文字描述 let _ = reply_markdown_card( &ctx.client, &msg.message_id, - "📎 暂仅支持文本消息,图片/文件等媒体能力规划中", + "📎 暂仅支持文本消息,图片/文件等媒体能力规划中。\n\n如需处理图片/文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。", ) .await; return; @@ -340,10 +416,14 @@ async fn handle_message( // 白名单(D2 热加载):空=放行;非空且不在内 → 提示并附自己的 open_id(首启自助回填, // 免翻日志)。每消息现读 KV,设置面板改白名单即生效免重启 + // 2026-10-08 配置引导优化:提示改为分步自助引导(原仅附 open_id,用户不知填哪) let whitelist = read_whitelist(&ctx).await; if !whitelist.is_empty() && !whitelist.iter().any(|w| w == &open_id) { - let hint = format!("⛔ 你不在白名单内。你的 open_id:`{open_id}`(发给管理员回填后重试)"); - reply_message(&ctx.client, &message_id, &chat_id, &hint).await; + let hint = format!( + "⛔ 你不在白名单内,无法使用。\n\n**你的 open_id**:\n`{open_id}`\n\n**开通步骤**:\n1. 复制上方 open_id\n2. 桌面端「设置 → 飞书集成 → 白名单」粘贴(多个用逗号分隔)\n3. 保存后重发消息即可(免重启)" + ); + // 用 markdown 卡片(非 reply_message 纯文本),让 **粗体**/`code`/列表 正确渲染 + let _ = reply_markdown_card(&ctx.client, &message_id, &hint).await; return; } @@ -452,14 +532,39 @@ async fn handle_message( if let Some(tc_id) = pending { let approved = text == "批准"; feishu_log(&format!("审批决议 chat={chat_id} tc={tc_id} approved={approved}")); + // 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过), + // 再调 ai_approve,后 spawn watcher 消费 AiToolCallCompleted 拿结果摘要 + let result_rx = state.ai_event_bus.subscribe(); let reply = - match crate::commands::ai::ai_approve(ctx.app.clone(), state, tc_id, approved).await + match crate::commands::ai::ai_approve(ctx.app.clone(), state, tc_id.clone(), approved).await { + // 幂等已处理(重复决议/陈旧挂起):ai_approve 返 Ok("已处理(...)"),不误报 + Ok(msg) if msg.starts_with("已处理") => { + format!("ℹ️ 该审批已处理过({msg}),无需重复操作。") + } Ok(_) if approved => "✅ 已批准,任务继续执行。".to_string(), Ok(_) => "🚫 已拒绝,该工具调用终止,任务按拒绝结果继续。".to_string(), Err(e) => format!("⚠️ 审批处理失败:{}", truncate(&e, 160)), }; + // 多条挂起提示:本会话仍有其他待审批时追加提醒,防用户以为已全批 + // (state 已被 ai_approve move,重新取句柄) + let remaining = match conv_id { + Some(ref cid) => { + let st = ctx.app.state::(); + count_pending_approvals(&st, cid).await + } + None => 0, + }; + let reply = if remaining > 0 { + format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「批准」或「拒绝」。") + } else { + reply + }; reply_message(&ctx.client, &message_id, &chat_id, &reply).await; + // 仅批准且决议成功时订阅执行结果(拒绝不执行工具,无结果可言;已处理幂等跳过) + if approved && !reply.starts_with('⚠') && !reply.starts_with('ℹ') { + spawn_approval_result_watcher(ctx.clone(), chat_id.clone(), tc_id, result_rx); + } return; } } @@ -489,8 +594,12 @@ async fn handle_message( feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80))); // 立即确认(交互丝滑):占位改用单行卡片并记下其 message_id,完成后原地更新为结果, // 占位与结果合一不刷屏;占位 reply 失败则置空,完成路径回退现状(新发卡片) + // 2026-10-08 占位卡带任务原文回显:原固定「🤔 已接收,处理中…」用户不知在做什么, + // 到受理卡之间(per-chat 锁排队 + spawn)有一段无内容可对证期;回显摘要让用户即时确认 + // 消息已被正确接收。text 此后会被 move 进 spawn_agentic_session,此处先取摘要 + let placeholder_text = format!("🤔 已接收,处理中…\n任务:{}", truncate(&text, 60)); let placeholder_id = - reply_markdown_card(&ctx.client, &message_id, "🤔 已接收,处理中…").await; + reply_markdown_card(&ctx.client, &message_id, &placeholder_text).await; // per-chat 串行:同会话消息排队(防通知桥单槽被后条覆盖致前条串扰) let chat_lock = { let mut locks = ctx.chat_locks.lock().await; @@ -776,12 +885,22 @@ async fn run_approval_notifier(ctx: Arc) { .unwrap_or("") .replace('\n', " "); let brief = truncate(&reason, 80); - let card = if brief.is_empty() { - format!("⏸ 需审批: `{name}`。回复「批准」或「拒绝」继续") + // 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片, + // 按钮 value 携带 {approval_id, approved},回调经 on_card_action → + // handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。 + // 文本「批准/拒绝」通路保留作降级(旧卡片/按钮回调异常时仍可用)。 + let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or(""); + if tc_id.is_empty() { + // 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复「批准/拒绝」) + let card = if brief.is_empty() { + format!("⏸ 需审批: `{name}`。回复「批准」或「拒绝」继续") + } else { + format!("⏸ 需审批: `{name}` {brief}。回复「批准」或「拒绝」继续") + }; + send_markdown_card(&ctx.client, "", &chat_id, &card).await; } else { - format!("⏸ 需审批: `{name}` {brief}。回复「批准」或「拒绝」继续") - }; - send_markdown_card(&ctx.client, "", &chat_id, &card).await; + send_approval_button_card(&ctx.client, &chat_id, tc_id, name, &brief).await; + } feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}")); } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, @@ -790,6 +909,229 @@ async fn run_approval_notifier(ctx: Arc) { } } +/// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved}, +/// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先, +/// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。 +async fn handle_card_action( + ctx: Arc, + action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction, +) { + // 按钮约定的 value 形态:{"approval_id": "", "approved": true|false} + let approval_id = action + .action_value + .get("approval_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + if approval_id.is_empty() { + tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略"); + return; + } + let approved = action + .action_value + .get("approved") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // chat_id 兜底:SDK 归一化未带 open_chat_id 时,从 approval_id 反查 conv → chat_map + // 反查绑定 chat,防 send_markdown_card(client, "", "", ...) 发到空 chat 静默失败 + let mut chat_id = action.open_chat_id.clone().unwrap_or_default(); + if chat_id.is_empty() { + let state = ctx.app.state::(); + if let Some(cid) = approval_conversation_id(&state, &approval_id).await { + chat_id = ctx + .chat_map + .lock() + .await + .iter() + .find(|(_, c)| c.as_str() == cid) + .map(|(k, _)| k.clone()) + .unwrap_or_default(); + if !chat_id.is_empty() { + feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}")); + } + } + } + if chat_id.is_empty() { + // 仍拿不到落点:决议照常执行(不能因投递无门而丢决议),但记日志便于排查 + tracing::warn!("[feishu] 卡片回调 chat_id 缺失且反查失败,回执无法投递 tc={approval_id}"); + feishu_log(&format!("卡片回调 chat_id 缺失且反查失败 tc={approval_id}")); + } + // 白名单校验(与 handle_message 同口径):卡片按钮回调此前无校验,非白名单用户 + // 若能看到审批卡(被转发/群内他人),点按钮可直接批准工具执行,白名单形同虚设。 + // 取回调发起者 open_id 校验;取不到(空)时保守拒绝(宁拒勿放) + let operator_open_id = action.operator_open_id.clone().unwrap_or_default(); + let whitelist = read_whitelist(&ctx).await; + if !whitelist.is_empty() && !whitelist.iter().any(|w| w == &operator_open_id) { + feishu_log(&format!( + "卡片回调白名单拒绝 chat={chat_id} operator={operator_open_id}" + )); + if !chat_id.is_empty() { + let _ = send_markdown_card( + &ctx.client, + "", + &chat_id, + "⛔ 你不在白名单内,无权审批。请在桌面端「设置 → 飞书集成 → 白名单」添加你的 open_id。", + ) + .await; + } + return; + } + feishu_log(&format!( + "审批按钮回调 chat={chat_id} tc={approval_id} approved={approved}" + )); + let state = ctx.app.state::(); + // 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过) + let result_rx = state.ai_event_bus.subscribe(); + // 结果回执发新消息(不依赖 NormalizedCardAction 的 open_message_id 字段, + // 该字段在长连接归一化结构中的存在性未经 SDK 源码证实;open_chat_id 已由网关实现验证) + let reply = match crate::commands::ai::ai_approve( + ctx.app.clone(), + state, + approval_id.clone(), + approved, + ) + .await + { + // 幂等已处理(重复点击/陈旧卡片):ai_approve 返 Ok("已处理(...)"),不能误报「已批准」 + Ok(msg) if msg.starts_with("已处理") => { + format!("ℹ️ 该审批已处理过({msg}),无需重复操作。") + } + Ok(_) if approved => "✅ 已批准,任务继续执行。".to_string(), + Ok(_) => "🚫 已拒绝,该工具调用终止,任务按拒绝结果继续。".to_string(), + Err(e) => format!("⚠️ 审批处理失败:{}", truncate(&e, 160)), + }; + let _ = send_markdown_card(&ctx.client, "", &chat_id, &reply).await; + // 仅批准且决议成功时订阅执行结果(拒绝不执行工具,无结果可言); + // 已处理幂等路径也跳过(工具早已执行完,结果无从取) + if approved && !reply.starts_with('⚠') && !reply.starts_with('ℹ') { + spawn_approval_result_watcher(ctx.clone(), chat_id.clone(), approval_id, result_rx); + } +} + +/// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted +/// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具 +/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时静默放弃)。 +/// +/// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。 +fn spawn_approval_result_watcher( + ctx: Arc, + chat_id: String, + tool_call_id: String, + mut rx: tokio::sync::broadcast::Receiver, +) { + tauri::async_runtime::spawn(async move { + let timeout = std::time::Duration::from_secs(60); + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); + return; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(v)) => { + if v.get("type").and_then(|t| t.as_str()) != Some("AiToolCallCompleted") { + continue; + } + if v.get("id").and_then(|i| i.as_str()) != Some(tool_call_id.as_str()) { + continue; + } + // result 是 serde_json::Value,提取文本摘要(字符串直用,对象转 JSON 截断) + let summary = match v.get("result") { + Some(serde_json::Value::String(s)) => truncate(s, 500), + Some(other) => truncate(&other.to_string(), 500), + None => "(无输出)".to_string(), + }; + let text = format!("📋 工具执行结果:\n{summary}"); + let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await; + feishu_log(&format!("[approval-result] 已回推结果 tc={tool_call_id}")); + return; + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, + Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return, + Err(_) => { + feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}")); + return; + } + } + } + }); +} + +/// 审批按钮卡片(2026-10-08):标题 + 详情 + 「同意/拒绝」按钮。 +/// 按钮 value 携带 {approval_id, approved},点击触发 card.action.trigger 事件, +/// 经 on_card_action → handle_card_action → ai_approve。发送失败降级文本卡(回复「批准/拒绝」)。 +async fn send_approval_button_card( + client: &LarkClient, + chat_id: &str, + approval_id: &str, + tool_name: &str, + brief: &str, +) { + let detail = if brief.is_empty() { + format!("⏸ 需审批: `{tool_name}`") + } else { + format!("⏸ 需审批: `{tool_name}`\n\n{brief}") + }; + // 按钮 value 需为 SDK 的 JsonValue(其实现了 From,直接 into 转换) + let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() }; + let actions = Action::new() + .action(ActionComponent::Button( + Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意")) + .button_type(ButtonType::Primary) + .value(to_json_value(serde_json::json!({ + "approval_id": approval_id, + "approved": true + }))), + )) + .action(ActionComponent::Button( + Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝")) + .button_type(ButtonType::Danger) + .value(to_json_value(serde_json::json!({ + "approval_id": approval_id, + "approved": false + }))), + )); + let card = Card::new() + .header(Header::new("🔐 工具审批请求")) + .element(Element::Div(Div::new( + larksuite_oapi_sdk_rs::card::v1::Text::lark_md(md_to_lark_friendly(&detail)), + ))) + .element(Element::Action(actions)); + let document = match CardDocument::new(card) { + Ok(d) => d, + Err(e) => { + tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}"); + let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); + send_markdown_card(client, "", chat_id, &fallback).await; + return; + } + }; + let content = serde_json::to_string(document.card()).unwrap_or_default(); + let input = SendInput { + receive_id: Some(chat_id.to_string()), + card: Some(content), + ..Default::default() + }; + match client + .channel_messaging() + .send(&input, &RequestOption::default()) + .await + { + Ok(sent) if !sent.message_id.is_empty() => { + feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id)); + } + Ok(_) => { + tracing::warn!("[feishu] 审批按钮卡发送未返回 message_id"); + } + Err(e) => { + tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}"); + let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续"); + send_markdown_card(client, "", chat_id, &fallback).await; + } + } +} + /// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。 /// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受) async fn latest_pending_approval( @@ -810,6 +1152,31 @@ async fn latest_pending_approval( .map(|(k, _)| k.clone()) } +/// 按 tool_call_id 反查其所属 conversation_id(卡片回调 chat_id 缺失时反查补齐用) +async fn approval_conversation_id( + state: &crate::state::AppState, + tool_call_id: &str, +) -> Option { + let session = state.ai_session.lock().await; + session + .pending_approvals + .get(tool_call_id) + .and_then(|a| a.conversation_id.clone()) +} + +/// 该 conv 挂起审批计数(多条挂起提示用):决议一条后仍有 N 条时提醒用户继续 +async fn count_pending_approvals( + state: &crate::state::AppState, + conv_id: &str, +) -> usize { + let session = state.ai_session.lock().await; + session + .pending_approvals + .iter() + .filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id)) + .count() +} + /// D2 白名单热加载:每消息现读 KV,设置面板改白名单即生效免重启;读取/解析失败沿用 /// 上次缓存——拒收口径随缓存,不因 DB 抖动放大为误放行;成功时回写缓存供降级使用 async fn read_whitelist(ctx: &Ctx) -> Vec { @@ -900,6 +1267,18 @@ async fn run_stream_refresher( if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) { acc.push_str(delta); acc_chars += delta.chars().count(); + // 首帧提速(2026-10-08):首个 delta 到达即立即刷占位卡, + // 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期 + // 才见首字,感知停顿明显。仅首帧走即时刷(acc_chars 首次>painted), + // 后续仍按 3s 节流守飞书 QPS 配额 + if painted_chars == 0 && acc_chars > 0 { + if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) { + let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens)); + if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await { + painted_chars = acc_chars; + } + } + } } } // 轮次推进:立即标脏强制下一 tick 刷卡(纯工具轮无文本也有"活着"信号) @@ -1664,7 +2043,6 @@ mod tests { assert!(out2.starts_with("💓 **仍在跑:第 4 轮 · 最近动作 `9 步工具调用` · 静默 95s**\n\n")); } - #[test] #[test] fn preview_heartbeat_no_round_no_steps() { // 13a-2 静默且零步零轮(纯 LLM 长思考):轮次回退「启动」,动作回退「LLM 思考中」 diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index a3d30f3..00f9da4 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -84,28 +84,28 @@ :disabled="approving" @click="onAuthorizeOnce"> - + {{ $t('aiChat.dirAuthOnce') }} @@ -114,14 +114,14 @@ :disabled="approving" @click="onApprove"> - + {{ $t('aiTool.approve') }} @@ -180,6 +180,10 @@ import type { AiToolCallInfo } from '@/api/types' import ConfirmDialog from './ConfirmDialog.vue' import ToolResultBody from './ToolResultBody.vue' +// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素) +const ICON_CHECK = '' +const ICON_CROSS = '' + const { t } = useI18n() const props = defineProps<{ @@ -511,6 +515,11 @@ const visibleArgs = computed(() => { .ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; } .ai-tool-btn--approve:disabled:hover { background: var(--df-success-bg); color: var(--df-success); border-color: var(--df-border); } .ai-tool-btn--reject:disabled:hover { background: var(--df-danger-bg); color: var(--df-danger); border-color: var(--df-border); } +.ai-tool-btn-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 12px; height: 12px; flex-shrink: 0; +} +.ai-tool-btn-icon :deep(svg) { display: block; } .ai-tool-btn-spinner { width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite; diff --git a/src/components/settings/FeishuPanel.vue b/src/components/settings/FeishuPanel.vue index 712a231..c240938 100644 --- a/src/components/settings/FeishuPanel.vue +++ b/src/components/settings/FeishuPanel.vue @@ -43,6 +43,9 @@ > + +

{{ $t('settings.hintFeishuWhitelist') }}

+