修复: 飞书走查六项 + 停靠状态写侧根因解 + agentic/audit 加固

飞书集成走查六项(feishu_channel.rs +420):
1. 审批卡按钮化: on_card_action + handle_card_action(解析 {approval_id,approved}
   → ai_approve) + send_approval_button_card(Card JSON 1.0 双按钮, 失败降级文本卡)
2. 首帧流式提速: AiTextDelta 首个 delta 立即刷占位卡, 不等 3s ticker
3. 断线重连通知: 按 run_loop 时长判据(≥30s 推断开 / <30s 累计 3 次推不稳定),
   新增 notify_monitor()
4. 占位卡带任务原文回显(truncate 60)
5. 白名单配置引导: 后端分步自助引导 + FeishuPanel hintFeishuWhitelist + i18n
6. 审批决议后追加执行结果: spawn_approval_result_watcher 订阅 AiToolCallCompleted
   (匹配 tool_call_id, 60s 超时兜底); 订阅须在 ai_approve 前完成(无订阅者跳过序列化)

停靠(parked)写侧根因解(904):
emit_task_parked(ai/mod.rs) 升级为「写 parked + 发事件 + 通知」三件套——
新增 set_loop_state(conv_id, Some("parked")) fire-and-forget; 8 个业务调用点
(H2 三闸/熔断两路/挂起取消/max_rounds/midstream/fatal + 审批超时) 全经单点。
治「停靠卡继续必报 AI 未在暂停态」(全仓原无写 parked 路径)。

agentic/audit 加固: context_lifecycle/stream_relay/mod 调整 + audit record 完善 +
tool_failure_memory/tool_result_offload/tools/task/settings 同步。

验收: cargo check -p devflow EXIT 0。
This commit is contained in:
lxy
2026-10-09 22:54:19 +08:00
parent b283622c64
commit d7e626a813
18 changed files with 872 additions and 127 deletions
@@ -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
);
}
+26 -1
View File
@@ -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;
}
// #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,6 +2559,9 @@ async fn check_stall_breaker(
);
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
// DRY #1:双写正典(熔断求助卡跨端可见)
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
// 叠加语义打架;开关关则退旧双卡行为。
if !CIRCUIT_BREAKER_PARK_ONLY {
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
@@ -2551,6 +2571,7 @@ async fn check_stall_breaker(
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
@@ -2573,6 +2594,9 @@ async fn check_stall_breaker(
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
// DRY #1:双写正典(熔断求助卡跨端可见)
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
// 叠加语义打架;开关关则退旧双卡行为。
if !CIRCUIT_BREAKER_PARK_ONLY {
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
@@ -2582,6 +2606,7 @@ async fn check_stall_breaker(
conversation_id: Some(conv_id.to_string()),
},
);
}
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
+159 -11
View File
@@ -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<ChatMessage> = 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<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, 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();
if !full_text.is_empty() {
let conv = session.conv(conv_id);
// #906:续传路径已把「中间各段」partial 落库(循环内 push),但「最后一段」
// (预算耗尽时 break 的 text)未落库——故此处始终补最后一段(无重叠,不重复)。
if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.clone());
// B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传
@@ -555,12 +663,18 @@ 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("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
}
// 追加系统提示消息:响应因网络中断不完整(对齐决策 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)。
// 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。
@@ -678,3 +792,37 @@ pub(crate) fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, 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(),
"续传提示不可为空(空提示模型无续传指令)",
);
}
}
+50 -3
View File
@@ -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,10 +1091,10 @@ 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;
}
}
}
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
+10
View File
@@ -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<String, String> {
tool_result_offload::read_offloaded(&path)
}
#[cfg(test)]
mod tests {
use super::*;
+22 -3
View File
@@ -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::<crate::state::AppState>();
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,
@@ -101,11 +101,6 @@ pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<Stri
}
}
/// 拦截条目数(测试用)。
pub fn memory_size() -> usize {
memory().lock().unwrap_or_else(|p| p.into_inner()).len()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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<String, String> {
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) };
+20 -12
View File
@@ -43,15 +43,20 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
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<Database>) {
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() }))
}
);
+25 -17
View File
@@ -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} 字节");
// }
}
// ============================================================
+26
View File
@@ -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<AppVersionInfo, String> {
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(),
})
}
+393 -15
View File
@@ -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<String>,
}
/// 白名单反序列化双形态兼容:数组(正典)或逗号分隔字符串(历史脏数据)。
/// 非法形态(数字/对象等)→ 空 Vec(等价放行所有,不 panic 不报错)
fn de_whitelist<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let v = Option::<serde_json::Value>::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>) -> 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::<crate::state::AppState>().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<Ctx>, client: &'static LarkClient) -> anyhow::Result<()> {
let dispatcher = EventDispatcher::new("", "");
let ctx_msg = 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<Ctx>, 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::<crate::state::AppState>();
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<Ctx>) {
.unwrap_or("")
.replace('\n', " ");
let brief = truncate(&reason, 80);
// 审批卡按钮化(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 {
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<Ctx>) {
}
}
/// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved},
/// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先,
/// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。
async fn handle_card_action(
ctx: Arc<Ctx>,
action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction,
) {
// 按钮约定的 value 形态:{"approval_id": "<tool_call_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::<crate::state::AppState>();
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::<crate::state::AppState>();
// 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<Ctx>,
chat_id: String,
tool_call_id: String,
mut rx: tokio::sync::broadcast::Receiver<serde_json::Value>,
) {
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<serde_json::Value>,直接 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<String> {
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<String> {
@@ -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 思考中」
+15 -6
View File
@@ -84,28 +84,28 @@
:disabled="approving"
@click="onAuthorizeOnce">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthOnce') }}
</button>
<button class="ai-tool-btn ai-tool-btn--session"
:disabled="approving"
@click="onAuthorizeSession">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthSession') }}
</button>
<button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving"
@click="onAuthorizeAlways">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiChat.dirAuthAlways') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onDeny">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
{{ $t('aiChat.dirAuthDeny') }}
</button>
</div>
@@ -114,14 +114,14 @@
:disabled="approving"
@click="onApprove">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
{{ $t('aiTool.approve') }}
</button>
<button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving"
@click="onReject">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
{{ $t('aiTool.reject') }}
</button>
</div>
@@ -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 = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
const ICON_CROSS = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
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;
+25 -4
View File
@@ -43,6 +43,9 @@
></textarea>
</SettingRow>
<!-- 白名单配置引导(2026-10-08):用户不知 open_id 从哪来,给出自助获取路径 -->
<p class="feishu-hint">{{ $t('settings.hintFeishuWhitelist') }}</p>
<div class="actions">
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
{{ $t('settings.feishuSave') }}
@@ -76,7 +79,7 @@ interface FeishuConfig {
enabled: boolean
app_id: string
app_secret: string
/** 逗号分隔 open_id,空 = 放行所有 */
/** 逗号分隔 open_id,空 = 放行所有(表单态;落库时拆为数组) */
whitelist: string
}
@@ -93,11 +96,17 @@ const saving = ref(false)
onMounted(() => {
// KV 值由 store 启动 loadAll 统一 JSON.parse 进缓存,此处同步读;
// 逐字段回退默认,防历史脏数据缺字段时 v-model 绑到 undefined
const saved = appSettings.get<Partial<FeishuConfig>>(FEISHU_CONFIG_KEY, {})
const saved = appSettings.get<Record<string, unknown>>(FEISHU_CONFIG_KEY, {})
form.enabled = saved.enabled === true
form.app_id = typeof saved.app_id === 'string' ? saved.app_id : ''
form.app_secret = typeof saved.app_secret === 'string' ? saved.app_secret : ''
form.whitelist = typeof saved.whitelist === 'string' ? saved.whitelist : ''
// 白名单落库形态为数组(后端契约 Vec<String>);兼容历史字符串脏数据,
// 统一转逗号分隔字符串回填表单
form.whitelist = Array.isArray(saved.whitelist)
? (saved.whitelist as unknown[]).filter((x): x is string => typeof x === 'string').join(',')
: typeof saved.whitelist === 'string'
? saved.whitelist
: ''
})
/** 保存:enabled=true 时 id/secret 必填,不过校验不落库;整体替换写单一 KV 键 */
@@ -106,13 +115,19 @@ async function save() {
emit('toast', t('settings.feishuValidateIncomplete'), 'warning')
return
}
// 白名单落库为数组(后端 FeishuConfig.whitelist: Vec<String> 契约):
// 表单逗号分隔字符串拆分为去空数组,空串 → 空数组(=放行所有)
const whitelist = form.whitelist
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
saving.value = true
try {
await appSettings.set(FEISHU_CONFIG_KEY, {
enabled: form.enabled,
app_id: form.app_id.trim(),
app_secret: form.app_secret.trim(),
whitelist: form.whitelist.trim(),
whitelist,
})
emit('toast', t('settings.feishuSaveOk'), 'success')
} catch (e) {
@@ -134,5 +149,11 @@ async function save() {
resize: vertical;
font-family: var(--df-font-mono, monospace);
}
.feishu-hint {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.5;
color: var(--df-text-tertiary, #888);
}
.actions { display: flex; gap: 8px; margin-top: 8px; }
</style>
+15 -3
View File
@@ -62,10 +62,22 @@ async function loadConversations() {
updated_at: now,
})
}
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
// 恢复上次活跃对话。
// 原实现仅「当前无活跃对话」时恢复,但存在启动竞态:AiChat 先 startListener() 注册事件监听,
// 后 loadConversations()。注册到本行之间若有带 conversation_id 的后端事件到达(后台会话流式/
// AiConvStateChanged/残留事件),useAiEvents.handleEvent 会抢先 state.activeConversationId = convId
// 占位(仅赋 id 不加载 messages)→ 本判断 !state.activeConversationId 为 false → switchConversation
// 被跳过 → 历史消息不加载 → 界面空白,须手动切走再切回才显示(2026-10-08 实锤)。
// 修复:① 无活跃会话 → 恢复 pending(原逻辑);② 活跃会话已存在但历史未加载(竞态占位)
// → 强制 switchConversation(force=true 绕过同会话短路)补加载历史。
// 判据用 loadMoreCursor.convId(switchConversation 成功提交才置为目标 id,竞态占位时为 null)
// 而非 messages.length===0:后者在「占位后恰好收到后台 AiTextDelta 写进 messages」时会漏判。
const pending = appSettings.get<string | null>(activeConvKvKey(), null)
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending)
const target = state.activeConversationId ?? pending
if (target && state.conversations.some(c => c.id === target)
&& loadMoreCursor.convId !== target) {
// 历史未加载才补加载:已加载(convId 已指向目标)不重拉,防清空 in-flight 流式文本。
await switchConversation(target, true)
}
} catch (e) {
// 网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
+20 -1
View File
@@ -150,18 +150,37 @@ async function cleanupTerminatedConversation(convId: string, reason: 'AiComplete
/**
* 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话
* 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。
*
* #906 打扰治理:同会话短时内重复停靠节流。网络抖动期「重发→中断→再弹」会连续触发停靠,
* 卡片反复弹/消造成打扰观感。策略:同一 conv 距上次停靠 < PARKED_REPARK_THROTTLE_MS 时,
* 若当前已有停靠卡(未被用户操作摘除)→ 保留原卡不刷新(避免重渲染/时间戳跳动);若原卡已被
* 用户「继续/停止」摘除(用户已主动处置)→ 仍放行新卡(不能吞掉用户需决策的新终局)。
* 节流仅压「未处置的重复弹」,不吞「用户已处置后的真实新停靠」,安全边界清晰。
*/
const PARKED_REPARK_THROTTLE_MS = 20000
// 上次停靠时刻(per-conv,跨摘卡保留):用于判定「用户已处置后短时再停靠」是否属抖动回环。
const _lastParkedAt = new Map<string, number>()
export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void {
const id = convId || state.activeConversationId
if (!id) return
const now = Date.now()
const existing = pendingTaskParked.get(id)
const lastAt = _lastParkedAt.get(id) ?? 0
// 节流:卡仍在(未处置)+ 距上次停靠极近 → 保留原卡,不刷新(压抖动期重复弹)。
if (existing && now - lastAt < PARKED_REPARK_THROTTLE_MS) {
return
}
_lastParkedAt.set(id, now)
pendingTaskParked.set(id, {
reason,
stopKind: stopKind || undefined,
conversationId: id,
parkedAt: Date.now(),
parkedAt: now,
})
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。
* 异步说明:cleanup 内含仲裁 IPC(ai_is_generating),故本函数 async;handleEvent 以 void 调用,
* 每个终态 case 必须先 await cleanup 拿到仲裁结果再决定下游执行。 */
+2
View File
@@ -80,6 +80,8 @@ export default {
collapse: 'Collapse trace',
roundN: 'Round {n}',
processOnly: 'Process log',
running: 'Running',
taskRef: 'Related task: {name}',
stepDone: '✓ {n} calls done',
stepRunning: 'running {name}',
stepPending: '⏳ {n} pending approval',
+2
View File
@@ -80,6 +80,8 @@ export default {
collapse: '收起轨迹',
roundN: '第 {n} 轮',
processOnly: '过程记录',
running: '进行中',
taskRef: '关联任务:{name}',
stepDone: '✓ {n} 次调用完成',
stepRunning: '正在 {name}',
stepPending: '⏳ {n} 项待审批',