新增: 后台会话系统通知与托盘运行态
- 完成/失败/需介入三类系统通知,主窗聚焦静默 - 同类通知五秒合并,用户主动停止不通知 - 托盘菜单与悬浮提示实时显运行中会话数 - 关窗遇运行会话提示后台续跑,每进程一次 - 侧栏常驻徽章显后台运行会话数
This commit is contained in:
@@ -104,6 +104,9 @@ impl GeneratingGuard {
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
emit_ai_chat_event(&self.app_handle, ev);
|
||||
// P1 bg 化:迁移成功即同步托盘运行态(读 active_convs 快照刷菜单项/tooltip,
|
||||
// set_text/set_tooltip 均同步 API,此处保持同步不引入 await)
|
||||
crate::tray::update_tray_runtime(&self.app_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,6 +935,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
&loop_epoch_arc, my_epoch,
|
||||
true,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
@@ -1063,6 +1064,15 @@ pub(crate) async fn run_agentic_loop(
|
||||
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
|
||||
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
let emit_usage = tokens_snapshot(&tokens);
|
||||
// P1 bg 化:达轮次上限属需介入(等用户点继续或停止),系统通知补位(条件发送)。
|
||||
// 本出口 teardown 通知关(false)防同一事件双弹。
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
&app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::NeedAction,
|
||||
&conv_id,
|
||||
"已达最大轮次",
|
||||
"AI 循环未收敛已暂停,等待继续或停止指令",
|
||||
);
|
||||
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
|
||||
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
|
||||
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
|
||||
@@ -1078,6 +1088,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
&loop_epoch_arc, my_epoch,
|
||||
false,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
@@ -1131,6 +1142,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
last_round_estimated,
|
||||
None, None, true,
|
||||
&pinned_goals_snapshot,
|
||||
true,
|
||||
).await;
|
||||
}
|
||||
|
||||
@@ -1210,6 +1222,7 @@ async fn prepare_round_inputs(
|
||||
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
|
||||
// 统一走 teardown_round:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
|
||||
// (stale 时 teardown_round 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
|
||||
// 用户主动 stop 不发系统通知(false)
|
||||
teardown_round(
|
||||
session_arc, db, conv_id,
|
||||
Some(&save_usage), None,
|
||||
@@ -1223,6 +1236,7 @@ async fn prepare_round_inputs(
|
||||
pinned_goals_snapshot,
|
||||
app_handle,
|
||||
loop_epoch_arc, my_epoch,
|
||||
false,
|
||||
).await;
|
||||
return PreparedRound::Exit;
|
||||
}
|
||||
@@ -1542,6 +1556,7 @@ async fn finalize_round_output(
|
||||
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
|
||||
let emit_usage = tokens_snapshot(tokens);
|
||||
// 统一走 teardown_round:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
|
||||
// 用户主动 stop 不发系统通知(false)
|
||||
teardown_round(
|
||||
session_arc, db, conv_id,
|
||||
Some(&save_usage), Some(resolved_model),
|
||||
@@ -1554,6 +1569,7 @@ async fn finalize_round_output(
|
||||
pinned_goals_snapshot,
|
||||
app_handle,
|
||||
loop_epoch_arc, my_epoch,
|
||||
false,
|
||||
).await;
|
||||
return RoundEpilogue::Exit;
|
||||
}
|
||||
@@ -1799,6 +1815,14 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count
|
||||
}
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev);
|
||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::NeedAction,
|
||||
conv_id,
|
||||
"疑似死循环已熔断",
|
||||
&format!("连续同类失败 {} 次,已停止,需人工介入", max_count),
|
||||
);
|
||||
}
|
||||
|
||||
// ── check_stall_breaker: G2 探索熔断检查(扁平抽自原嵌套 6 层块) ──
|
||||
@@ -1929,6 +1953,14 @@ async fn check_stall_breaker(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::NeedAction,
|
||||
conv_id,
|
||||
"疑似自毁循环已熔断",
|
||||
&format!("对 {} 反复写删 {} 轮,已停止,需人工介入", path_desc, new_loop_count),
|
||||
);
|
||||
return (true, new_count, new_warned, new_loop_count, new_loop_warned);
|
||||
}
|
||||
|
||||
@@ -1951,6 +1983,14 @@ async fn check_stall_breaker(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::NeedAction,
|
||||
conv_id,
|
||||
"疑似卡住已熔断",
|
||||
&format!("连续 {} 次重复同类工具调用,已停止,需人工介入", new_count),
|
||||
);
|
||||
(true, new_count, new_warned, new_loop_count, new_loop_warned)
|
||||
}
|
||||
|
||||
@@ -2064,6 +2104,15 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||
let brief: String = error.chars().take(160).collect();
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::Failed,
|
||||
conv_id,
|
||||
"任务失败",
|
||||
&brief,
|
||||
);
|
||||
}
|
||||
|
||||
// ── emit_ai_completed_once: AiCompleted 事件 emit+publish 双写 helper(对齐 emit_fatal_error 模式) ──
|
||||
@@ -2088,6 +2137,11 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
|
||||
// 防占位 tool_result 残留 messages 下次发送喂给 LLM。仅清目标 conv(retain 保留其他 conv)。
|
||||
//
|
||||
// pinned_goals:前端直接读取刷新(G1 目标钉扎),原样透传快照(不 clone,借用调用方)。
|
||||
//
|
||||
// notify(P1 bg 化):true 时 emit 后发完成类系统通知。本 helper 是所有完成出口的统一
|
||||
// emit 单点(teardown_round 各出口 + 正常完成直调),通知收敛此处避免双写;
|
||||
// 用户主动 stop 出口(teardown_round 转发)传 false 关闭。通知内部条件发送
|
||||
// (notify.rs:主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默)。
|
||||
async fn emit_ai_completed_once(
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
@@ -2097,6 +2151,7 @@ async fn emit_ai_completed_once(
|
||||
publish_incomplete: Option<bool>,
|
||||
do_publish: bool,
|
||||
pinned_goals: &[super::GoalEntry],
|
||||
notify: bool,
|
||||
) {
|
||||
// G2.2:清审批残留收敛此处(所有正常出口都经本 helper 单点 emit)。锁内纯写改短 lock 段。
|
||||
{
|
||||
@@ -2123,6 +2178,16 @@ async fn emit_ai_completed_once(
|
||||
completed_event(usage, is_estimated, publish_incomplete, conv_id, pinned_goals),
|
||||
);
|
||||
}
|
||||
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
|
||||
if notify {
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::Completed,
|
||||
conv_id,
|
||||
"任务完成",
|
||||
"AI 回复已就绪,点击托盘图标查看",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
|
||||
@@ -2360,6 +2425,8 @@ pub(crate) fn usage_delta_since(
|
||||
// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档)
|
||||
// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit,
|
||||
// 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。
|
||||
// - notify(P1 bg 化):透传 emit_ai_completed_once 的系统通知开关。完成类出口传 true,
|
||||
// 用户主动 stop 出口传 false 不打扰(stale 出口在 epoch 守卫处已提前返回,天然不通知)。
|
||||
pub(crate) async fn teardown_round(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
@@ -2379,6 +2446,7 @@ pub(crate) async fn teardown_round(
|
||||
app_handle: &AppHandle,
|
||||
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
my_epoch: u64,
|
||||
notify: bool,
|
||||
) {
|
||||
// F1 并发 epoch 收尾守卫:旧 loop(被 force_send/新 loop 接管,epoch 不匹配)不 save/reset/emit,
|
||||
// 只 disarm guard 退出——owner loop 负责收尾,防「双完成 + 双 AiCompleted + guard 互踩」。
|
||||
@@ -2405,6 +2473,7 @@ pub(crate) async fn teardown_round(
|
||||
app_handle, conv_id, emit_usage, emit_estimated,
|
||||
emit_incomplete, publish_incomplete, do_publish,
|
||||
pinned_goals,
|
||||
notify,
|
||||
).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -569,6 +569,7 @@ pub(crate) async fn stream_round(
|
||||
pinned_goals_snapshot,
|
||||
app_handle,
|
||||
loop_epoch_arc, my_epoch,
|
||||
true,
|
||||
).await;
|
||||
return RoundOutcome::MidStreamSaved;
|
||||
}
|
||||
|
||||
@@ -215,6 +215,15 @@ pub(super) async fn insert_pending_approval(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
// P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
|
||||
// 审批场景前端已有 ApprovalOverlay 与 approval-popup,通知只补用户不在场(托盘化)场景。
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
&app_handle,
|
||||
crate::commands::ai::notify::NotifyKind::NeedAction,
|
||||
conv_id,
|
||||
"等待工具审批",
|
||||
&format!("工具 {} 已暂停,批准后继续执行", draft.name),
|
||||
);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ pub mod download_file;
|
||||
pub(crate) mod generate_image;
|
||||
pub mod get_app_config;
|
||||
pub mod knowledge_inject;
|
||||
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
|
||||
pub(crate) mod notify;
|
||||
pub mod prompt;
|
||||
pub mod provider_pool;
|
||||
pub mod remote_bridge;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! 系统通知(P1 bg 化补完):主窗隐藏到托盘时,关键 AI 事件经 OS 通知补位提醒。
|
||||
//!
|
||||
//! 三类事件:完成 / 失败 / 需介入。防风暴三则:
|
||||
//! 1. 主窗可见且聚焦时不发。审批场景已有 ApprovalOverlay 与 approval-popup 双机制,
|
||||
//! 系统通知只补「用户切走/托盘化」场景,用户在场时前端事件流已足够。
|
||||
//! 2. 同类事件最短间隔 5 秒。窗口期内到达只计数不发送,下次实际发送时把被抑制数
|
||||
//! 合并进正文,避免多会话并发收尾时通知刷屏。
|
||||
//! 3. 所有发送失败静默(let _ =)。通知是锦上添花,绝不侵入 AI 主链路错误传播。
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
/// 同类事件两次系统通知的最短间隔。
|
||||
const MIN_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 通知类别。同时决定节流分组:同类共享同一条间隔记录,互不挤占。
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum NotifyKind {
|
||||
/// 轮次收尾完成(含 Coordinator 合并、MidStream 保文等完成出口)
|
||||
Completed,
|
||||
/// Fatal / 重试耗尽等失败
|
||||
Failed,
|
||||
/// 审批挂起 / 达轮次上限 / 熔断求助等需要用户介入
|
||||
NeedAction,
|
||||
}
|
||||
|
||||
impl NotifyKind {
|
||||
/// 节流表下标(per-kind 独立计数)。
|
||||
fn slot(self) -> usize {
|
||||
match self {
|
||||
NotifyKind::Completed => 0,
|
||||
NotifyKind::Failed => 1,
|
||||
NotifyKind::NeedAction => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同类事件节流表:per-kind 记 (上次发送时刻, 窗口期内被抑制的到达数)。
|
||||
static THROTTLE: Mutex<[Option<(Instant, usize)>; 3]> = Mutex::new([None, None, None]);
|
||||
|
||||
/// 条件发送会话事件系统通知。
|
||||
///
|
||||
/// 触发点:emit_ai_completed_once 完成出口 / emit_fatal_error / 审批挂起 /
|
||||
/// 达轮次上限 / 熔断求助。见模块文档防风暴三则。
|
||||
pub(crate) fn notify_session_event(
|
||||
app: &AppHandle,
|
||||
kind: NotifyKind,
|
||||
conv_id: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) {
|
||||
// 防风暴 1:主窗可见且聚焦 = 用户在场,跳过
|
||||
if is_main_visible_and_focused(app) {
|
||||
return;
|
||||
}
|
||||
// 防风暴 2:同类 5 秒窗口内只计数,返回;窗口外正常发送并把被抑制数带回合并
|
||||
let suppressed = {
|
||||
// 锁中毒(持锁线程 panic)不应永久禁用通知,拿回内部数据继续
|
||||
let mut slots = THROTTLE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let now = Instant::now();
|
||||
let entry = &mut slots[kind.slot()];
|
||||
if let Some((last, _)) = *entry {
|
||||
if now.duration_since(last) < MIN_INTERVAL {
|
||||
if let Some((_, count)) = entry.as_mut() {
|
||||
*count += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let suppressed = entry.as_ref().map_or(0, |(_, count)| *count);
|
||||
*entry = Some((now, 0));
|
||||
suppressed
|
||||
};
|
||||
// 窗口期合并:被抑制的同类事件并入本次正文一次说清
|
||||
let body = if suppressed > 0 {
|
||||
format!("{}\n另有 {} 条同类会话动态已合并提醒", body, suppressed)
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
tracing::debug!(conv_id = %conv_id, "[notify] 系统通知: {}", title);
|
||||
// 防风暴 3:失败静默,不向上传播
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(&body)
|
||||
.show();
|
||||
}
|
||||
|
||||
/// 主窗是否可见且聚焦(用户正盯着主窗)。
|
||||
/// 主窗不存在(异常)按不在场处理,允许通知兜底。
|
||||
fn is_main_visible_and_focused(app: &AppHandle) -> bool {
|
||||
app.get_webview_window("main")
|
||||
.map(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Reference in New Issue
Block a user