From e1dd0bc3a389babe79c07784a7d9b1f3de7245f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 19:35:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5=E4=B8=8E?= =?UTF-8?q?=E6=89=98=E7=9B=98=E8=BF=90=E8=A1=8C=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完成/失败/需介入三类系统通知,主窗聚焦静默 - 同类通知五秒合并,用户主动停止不通知 - 托盘菜单与悬浮提示实时显运行中会话数 - 关窗遇运行会话提示后台续跑,每进程一次 - 侧栏常驻徽章显后台运行会话数 --- Cargo.lock | 134 ++++++++++++++++-- src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/ai/agentic/guard.rs | 3 + src-tauri/src/commands/ai/agentic/mod.rs | 69 +++++++++ .../src/commands/ai/agentic/stream_relay.rs | 1 + src-tauri/src/commands/ai/audit/approval.rs | 9 ++ src-tauri/src/commands/ai/mod.rs | 2 + src-tauri/src/commands/ai/notify.rs | 99 +++++++++++++ src-tauri/src/lib.rs | 24 +++- src-tauri/src/tray.rs | 52 +++++-- src/App.vue | 31 ++++ src/components/layout/AppSidebar.vue | 35 ++++- 13 files changed, 441 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/commands/ai/notify.rs diff --git a/Cargo.lock b/Cargo.lock index 0fbcf09..aaed683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-window-state", @@ -2527,6 +2528,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2688,6 +2703,20 @@ dependencies = [ "memoffset", ] +[[package]] +name = "notify-rust" +version = "4.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.16.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4455,7 +4484,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4526,7 +4555,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4649,6 +4678,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4667,7 +4715,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus 5.16.0", ] @@ -4724,7 +4772,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4749,7 +4797,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4802,6 +4850,17 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.18", + "windows 0.62.2", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5846,7 +5905,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5870,7 +5929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5926,11 +5985,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5942,6 +6013,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5976,7 +6056,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -6023,6 +6114,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6172,6 +6273,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -6491,7 +6601,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d6fadc7..ce4de59 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,9 @@ tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-window-state = "2" +# P1 bg 化补完:系统通知。AI 完成/失败/需介入三类事件,主窗隐藏到托盘时补位提醒, +# Rust 侧条件发送(notify.rs):主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默 +tauri-plugin-notification = "2" # Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 tauri-plugin-single-instance = "2" serde.workspace = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2d0858f..d351c18 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -23,6 +23,7 @@ "core:window:allow-set-decorations", "core:webview:allow-create-webview-window", "dialog:default", - "window-state:default" + "window-state:default", + "notification:default" ] } diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index d425139..07a5a46 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -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); } } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 4ee5d65..3fcdf80 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -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, 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>, db: &Arc, @@ -2379,6 +2446,7 @@ pub(crate) async fn teardown_round( app_handle: &AppHandle, loop_epoch_arc: &std::sync::Arc, 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; } diff --git a/src-tauri/src/commands/ai/agentic/stream_relay.rs b/src-tauri/src/commands/ai/agentic/stream_relay.rs index 8812adb..dbe6ba4 100644 --- a/src-tauri/src/commands/ai/agentic/stream_relay.rs +++ b/src-tauri/src/commands/ai/agentic/stream_relay.rs @@ -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; } diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index 308600c..993cce0 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -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; } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index b036ac6..91a748b 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -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; diff --git a/src-tauri/src/commands/ai/notify.rs b/src-tauri/src/commands/ai/notify.rs new file mode 100644 index 0000000..ae53fd1 --- /dev/null +++ b/src-tauri/src/commands/ai/notify.rs @@ -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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e158ec9..c70df3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,9 @@ impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 use df_tunnel::TunnelClient; +/// 关窗进行中提示哨兵:tray-hide-notice 每进程只发一次(反复关窗不重复弹气泡)。 +static CLOSE_NOTICE_SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info) @@ -56,6 +59,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) + // P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默) + .plugin(tauri_plugin_notification::init()) // Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { tray::show_main(app); @@ -509,13 +514,30 @@ pub fn run() { // 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。 // 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行 // 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。 + // P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次), + // 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。 .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() != "main" { return; // 非主窗口放行真正销毁 } api.prevent_close(); // 拦截真正关闭 - let _ = window.hide(); // 隐藏到托盘 + let app = window.app_handle().clone(); + let active = app.state::().conv_states.active_convs().len(); + if active == 0 { + let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为) + return; + } + // 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡 + if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) { + let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "active": active })); + } + // 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染) + let win = window.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let _ = win.hide(); + }); } }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 9a8401b..96c745a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,24 +1,34 @@ -//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。 +//! 托盘常驻:图标 + 菜单[运行态(纯展示)/显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。 //! //! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘 //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 +//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime) use tauri::{ AppHandle, Manager, menu::{Menu, MenuItem}, tray::TrayIconBuilder, }; -/// managed state 保活,防 GC 移除托盘图标。 -/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。 -#[allow(dead_code)] -pub struct TrayHandle(pub tauri::tray::TrayIcon); +use crate::state::AppState; -/// 创建托盘:图标 + 菜单(显示主窗口/退出)。 +/// managed state 保活,防 GC 移除托盘图标。 +/// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip); +/// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。 +pub struct TrayHandle { + /// TrayIcon 本体,主要职责是经 manage 保活防 GC。 + pub tray: tauri::tray::TrayIcon, + /// 运行态菜单项(enabled=false 纯展示),文本由 update_tray_runtime 随会话运行数刷新。 + pub status: MenuItem, +} + +/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。 pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { + // 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护 + let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?; let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &quit])?; + let menu = Menu::with_items(app, &[&status, &show, &quit])?; // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 let icon = app .default_window_icon() @@ -35,10 +45,36 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { _ => {} }) .build(app)?; - app.manage(TrayHandle(tray)); + app.manage(TrayHandle { tray, status }); Ok(()) } +/// 同步托盘运行态:状态项文本 + tooltip 反映当前运行中 AI 会话数。 +/// +/// 挂钩点:guard.rs GeneratingGuard::transition(每次 ConvState 迁移必经且已持 app_handle)。 +/// set_text/set_tooltip 均为同步 API,本函数保持同步,同步上下文直调不引入 await。 +/// AppState / TrayHandle 任一未注入(setup 早期)静默跳过。 +pub fn update_tray_runtime(app: &AppHandle) { + let (Some(handle), Some(state)) = + (app.try_state::(), app.try_state::()) + else { + return; + }; + let n = state.conv_states.active_convs().len(); + let text = if n > 0 { + format!("运行中 {} 个会话", n) + } else { + "无运行中会话".to_string() + }; + let tooltip = if n > 0 { + format!("DevFlow (运行中 {} 个会话)", n) + } else { + "DevFlow (MCP server running)".to_string() + }; + let _ = handle.status.set_text(text); + let _ = handle.tray.set_tooltip(Some(tooltip)); +} + /// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。 pub(crate) fn show_main(app: &AppHandle) { if let Some(w) = app.get_webview_window("main") { diff --git a/src/App.vue b/src/App.vue index de4f995..109576b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -34,6 +34,12 @@
⚠ {{ errorMsg }}
+ + +
{{ trayNoticeMsg }}
+
+ @@ -101,6 +107,12 @@ watch(() => projectStore.error, (err) => { errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500) }) +// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)── +// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用 +const trayNoticeMsg = ref('') +let trayNoticeTimer: ReturnType | null = null +let _unlistenTrayHideNotice: (() => void) | null = null + // ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)── // 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但 // 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。 @@ -208,6 +220,20 @@ onMounted(async () => { } catch (e) { console.error('启动数据变更监听失败:', e) } + // 关窗后台运行提示:主窗关闭被拦截且有会话运行时,后端 emit tray-hide-notice → 全局气泡告知。 + // 包 try/catch 防 Tauri listen 失败 reject onMounted(对齐 startDataChangedListener 模式) + try { + const { listen } = await import('@tauri-apps/api/event') + _unlistenTrayHideNotice = await listen<{ running: number }>('tray-hide-notice', (e) => { + const running = e.payload?.running ?? 0 + if (running <= 0) return + trayNoticeMsg.value = `${running} 个会话将继续在后台运行,托盘图标可随时唤起` + if (trayNoticeTimer) clearTimeout(trayNoticeTimer) + trayNoticeTimer = setTimeout(() => { trayNoticeMsg.value = '' }, 3500) + }) + } catch (e) { + console.error('关窗后台运行提示监听失败:', e) + } // 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。 // 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。 if (route.path !== '/approval-popup') { @@ -274,6 +300,9 @@ onUnmounted(() => { _unlistenPopupReady?.() _stopApprovalPopup = null _unlistenPopupReady = null + _unlistenTrayHideNotice?.() + _unlistenTrayHideNotice = null + if (trayNoticeTimer) clearTimeout(trayNoticeTimer) }) @@ -294,4 +323,6 @@ onUnmounted(() => { } .toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); } +/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */ +.df-toast--info { background: var(--df-accent); } diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 8ae6485..3f966c0 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -51,12 +51,14 @@