修复: 飞书走查六项 + 停靠状态写侧根因解 + 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:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
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<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(),
|
||||
"续传提示不可为空(空提示模型无续传指令)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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() }))
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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} 字节");
|
||||
// }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
+399
-21
@@ -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_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<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);
|
||||
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<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 思考中」
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 断开时原静默失败,列表可能突空用户不知原因。
|
||||
|
||||
@@ -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 拿到仲裁结果再决定下游执行。 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -80,6 +80,8 @@ export default {
|
||||
collapse: '收起轨迹',
|
||||
roundN: '第 {n} 轮',
|
||||
processOnly: '过程记录',
|
||||
running: '进行中',
|
||||
taskRef: '关联任务:{name}',
|
||||
stepDone: '✓ {n} 次调用完成',
|
||||
stepRunning: '正在 {name}',
|
||||
stepPending: '⏳ {n} 项待审批',
|
||||
|
||||
Reference in New Issue
Block a user