新增: 后台会话系统通知与托盘运行态
- 完成/失败/需介入三类系统通知,主窗聚焦静默 - 同类通知五秒合并,用户主动停止不通知 - 托盘菜单与悬浮提示实时显运行中会话数 - 关窗遇运行会话提示后台续跑,每进程一次 - 侧栏常驻徽章显后台运行会话数
This commit is contained in:
Generated
+122
-12
@@ -872,6 +872,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
@@ -2527,6 +2528,20 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2688,6 +2703,20 @@ dependencies = [
|
|||||||
"memoffset",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -4455,7 +4484,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -4526,7 +4555,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4649,6 +4678,25 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
@@ -4667,7 +4715,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"zbus 5.16.0",
|
"zbus 5.16.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4724,7 +4772,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4749,7 +4797,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4802,6 +4850,17 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5846,7 +5905,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -5870,7 +5929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5926,11 +5985,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -5942,6 +6013,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -5976,7 +6056,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -6023,6 +6114,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -6172,6 +6273,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -6491,7 +6601,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ tauri = { version = "2", features = ["tray-icon"] }
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
# P1 bg 化补完:系统通知。AI 完成/失败/需介入三类事件,主窗隐藏到托盘时补位提醒,
|
||||||
|
# Rust 侧条件发送(notify.rs):主窗可见聚焦不发 + 同类 5 秒节流 + 失败静默
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
|
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"window-state:default"
|
"window-state:default",
|
||||||
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ impl GeneratingGuard {
|
|||||||
};
|
};
|
||||||
if let Some(ev) = ev {
|
if let Some(ev) = ev {
|
||||||
emit_ai_chat_event(&self.app_handle, 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,
|
&pinned_goals_snapshot,
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&loop_epoch_arc, my_epoch,
|
&loop_epoch_arc, my_epoch,
|
||||||
|
true,
|
||||||
).await;
|
).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1063,6 +1064,15 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
|
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
|
||||||
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||||
let emit_usage = tokens_snapshot(&tokens);
|
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) +
|
// 统一走 teardown_round:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
|
||||||
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
|
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为**
|
||||||
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
|
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
|
||||||
@@ -1078,6 +1088,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
&pinned_goals_snapshot,
|
&pinned_goals_snapshot,
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&loop_epoch_arc, my_epoch,
|
&loop_epoch_arc, my_epoch,
|
||||||
|
false,
|
||||||
).await;
|
).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1131,6 +1142,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
last_round_estimated,
|
last_round_estimated,
|
||||||
None, None, true,
|
None, None, true,
|
||||||
&pinned_goals_snapshot,
|
&pinned_goals_snapshot,
|
||||||
|
true,
|
||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,6 +1222,7 @@ async fn prepare_round_inputs(
|
|||||||
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
|
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
|
||||||
// 统一走 teardown_round:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
|
// 统一走 teardown_round:save(Some usage, None model) + spawn_title + emit(None,None,publish=true)
|
||||||
// (stale 时 teardown_round 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
|
// (stale 时 teardown_round 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
|
||||||
|
// 用户主动 stop 不发系统通知(false)
|
||||||
teardown_round(
|
teardown_round(
|
||||||
session_arc, db, conv_id,
|
session_arc, db, conv_id,
|
||||||
Some(&save_usage), None,
|
Some(&save_usage), None,
|
||||||
@@ -1223,6 +1236,7 @@ async fn prepare_round_inputs(
|
|||||||
pinned_goals_snapshot,
|
pinned_goals_snapshot,
|
||||||
app_handle,
|
app_handle,
|
||||||
loop_epoch_arc, my_epoch,
|
loop_epoch_arc, my_epoch,
|
||||||
|
false,
|
||||||
).await;
|
).await;
|
||||||
return PreparedRound::Exit;
|
return PreparedRound::Exit;
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1556,7 @@ async fn finalize_round_output(
|
|||||||
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
|
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
|
||||||
let emit_usage = tokens_snapshot(tokens);
|
let emit_usage = tokens_snapshot(tokens);
|
||||||
// 统一走 teardown_round:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
|
// 统一走 teardown_round:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
|
||||||
|
// 用户主动 stop 不发系统通知(false)
|
||||||
teardown_round(
|
teardown_round(
|
||||||
session_arc, db, conv_id,
|
session_arc, db, conv_id,
|
||||||
Some(&save_usage), Some(resolved_model),
|
Some(&save_usage), Some(resolved_model),
|
||||||
@@ -1554,6 +1569,7 @@ async fn finalize_round_output(
|
|||||||
pinned_goals_snapshot,
|
pinned_goals_snapshot,
|
||||||
app_handle,
|
app_handle,
|
||||||
loop_epoch_arc, my_epoch,
|
loop_epoch_arc, my_epoch,
|
||||||
|
false,
|
||||||
).await;
|
).await;
|
||||||
return RoundEpilogue::Exit;
|
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);
|
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 层块) ──
|
// ── check_stall_breaker: G2 探索熔断检查(扁平抽自原嵌套 6 层块) ──
|
||||||
@@ -1929,6 +1953,14 @@ async fn check_stall_breaker(
|
|||||||
conversation_id: Some(conv_id.to_string()),
|
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);
|
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()),
|
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)
|
(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),
|
error_type: Some(ErrorType::Network),
|
||||||
conversation_id: Some(conv_id.to_string()),
|
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 模式) ──
|
// ── 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)。
|
// 防占位 tool_result 残留 messages 下次发送喂给 LLM。仅清目标 conv(retain 保留其他 conv)。
|
||||||
//
|
//
|
||||||
// pinned_goals:前端直接读取刷新(G1 目标钉扎),原样透传快照(不 clone,借用调用方)。
|
// 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(
|
async fn emit_ai_completed_once(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
@@ -2097,6 +2151,7 @@ async fn emit_ai_completed_once(
|
|||||||
publish_incomplete: Option<bool>,
|
publish_incomplete: Option<bool>,
|
||||||
do_publish: bool,
|
do_publish: bool,
|
||||||
pinned_goals: &[super::GoalEntry],
|
pinned_goals: &[super::GoalEntry],
|
||||||
|
notify: bool,
|
||||||
) {
|
) {
|
||||||
// G2.2:清审批残留收敛此处(所有正常出口都经本 helper 单点 emit)。锁内纯写改短 lock 段。
|
// 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),
|
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 内部去重用,字段口径见其文档)。
|
/// 构造 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 文档)
|
// - 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_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit,
|
||||||
// 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。
|
// 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。
|
||||||
|
// - notify(P1 bg 化):透传 emit_ai_completed_once 的系统通知开关。完成类出口传 true,
|
||||||
|
// 用户主动 stop 出口传 false 不打扰(stale 出口在 epoch 守卫处已提前返回,天然不通知)。
|
||||||
pub(crate) async fn teardown_round(
|
pub(crate) async fn teardown_round(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
db: &Arc<Database>,
|
db: &Arc<Database>,
|
||||||
@@ -2379,6 +2446,7 @@ pub(crate) async fn teardown_round(
|
|||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
|
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||||
my_epoch: u64,
|
my_epoch: u64,
|
||||||
|
notify: bool,
|
||||||
) {
|
) {
|
||||||
// F1 并发 epoch 收尾守卫:旧 loop(被 force_send/新 loop 接管,epoch 不匹配)不 save/reset/emit,
|
// F1 并发 epoch 收尾守卫:旧 loop(被 force_send/新 loop 接管,epoch 不匹配)不 save/reset/emit,
|
||||||
// 只 disarm guard 退出——owner loop 负责收尾,防「双完成 + 双 AiCompleted + guard 互踩」。
|
// 只 disarm guard 退出——owner loop 负责收尾,防「双完成 + 双 AiCompleted + guard 互踩」。
|
||||||
@@ -2405,6 +2473,7 @@ pub(crate) async fn teardown_round(
|
|||||||
app_handle, conv_id, emit_usage, emit_estimated,
|
app_handle, conv_id, emit_usage, emit_estimated,
|
||||||
emit_incomplete, publish_incomplete, do_publish,
|
emit_incomplete, publish_incomplete, do_publish,
|
||||||
pinned_goals,
|
pinned_goals,
|
||||||
|
notify,
|
||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -569,6 +569,7 @@ pub(crate) async fn stream_round(
|
|||||||
pinned_goals_snapshot,
|
pinned_goals_snapshot,
|
||||||
app_handle,
|
app_handle,
|
||||||
loop_epoch_arc, my_epoch,
|
loop_epoch_arc, my_epoch,
|
||||||
|
true,
|
||||||
).await;
|
).await;
|
||||||
return RoundOutcome::MidStreamSaved;
|
return RoundOutcome::MidStreamSaved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ pub(super) async fn insert_pending_approval(
|
|||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
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;
|
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(crate) mod generate_image;
|
||||||
pub mod get_app_config;
|
pub mod get_app_config;
|
||||||
pub mod knowledge_inject;
|
pub mod knowledge_inject;
|
||||||
|
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
|
||||||
|
pub(crate) mod notify;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod provider_pool;
|
pub mod provider_pool;
|
||||||
pub mod remote_bridge;
|
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)
|
||||||
|
}
|
||||||
+23
-1
@@ -28,6 +28,9 @@ impl tracing_subscriber::fmt::time::FormatTime for LocalTimer {
|
|||||||
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
||||||
use df_tunnel::TunnelClient;
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
|
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
|
||||||
@@ -56,6 +59,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
// P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默)
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
|
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
tray::show_main(app);
|
tray::show_main(app);
|
||||||
@@ -509,13 +514,30 @@ pub fn run() {
|
|||||||
// 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
|
// 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
|
||||||
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
|
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
|
||||||
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
|
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
|
||||||
|
// P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次),
|
||||||
|
// 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
if window.label() != "main" {
|
if window.label() != "main" {
|
||||||
return; // 非主窗口放行真正销毁
|
return; // 非主窗口放行真正销毁
|
||||||
}
|
}
|
||||||
api.prevent_close(); // 拦截真正关闭
|
api.prevent_close(); // 拦截真正关闭
|
||||||
let _ = window.hide(); // 隐藏到托盘
|
let app = window.app_handle().clone();
|
||||||
|
let active = app.state::<AppState>().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!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
+44
-8
@@ -1,24 +1,34 @@
|
|||||||
//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
|
//! 托盘常驻:图标 + 菜单[运行态(纯展示)/显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
|
||||||
//!
|
//!
|
||||||
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
|
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
|
||||||
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
|
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
|
||||||
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
|
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
|
||||||
|
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Manager,
|
AppHandle, Manager,
|
||||||
menu::{Menu, MenuItem},
|
menu::{Menu, MenuItem},
|
||||||
tray::TrayIconBuilder,
|
tray::TrayIconBuilder,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// managed state 保活,防 GC 移除托盘图标。
|
use crate::state::AppState;
|
||||||
/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct TrayHandle(pub tauri::tray::TrayIcon);
|
|
||||||
|
|
||||||
/// 创建托盘:图标 + 菜单(显示主窗口/退出)。
|
/// 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<tauri::Wry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。
|
||||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
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 show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||||
let quit = MenuItem::with_id(app, "quit", "退出", 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 提示配置
|
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
|
||||||
let icon = app
|
let icon = app
|
||||||
.default_window_icon()
|
.default_window_icon()
|
||||||
@@ -35,10 +45,36 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
app.manage(TrayHandle(tray));
|
app.manage(TrayHandle { tray, status });
|
||||||
Ok(())
|
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::<TrayHandle>(), app.try_state::<AppState>())
|
||||||
|
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(单实例二次启动 / 托盘菜单共用)。
|
/// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。
|
||||||
pub(crate) fn show_main(app: &AppHandle) {
|
pub(crate) fn show_main(app: &AppHandle) {
|
||||||
if let Some(w) = app.get_webview_window("main") {
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
|||||||
+31
@@ -34,6 +34,12 @@
|
|||||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
||||||
|
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
|
||||||
|
<transition name="toast">
|
||||||
|
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
||||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
||||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||||
@@ -101,6 +107,12 @@ watch(() => projectStore.error, (err) => {
|
|||||||
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
|
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
|
||||||
|
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
|
||||||
|
const trayNoticeMsg = ref('')
|
||||||
|
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let _unlistenTrayHideNotice: (() => void) | null = null
|
||||||
|
|
||||||
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
||||||
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
||||||
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
||||||
@@ -208,6 +220,20 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('启动数据变更监听失败:', 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)。
|
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
|
||||||
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
||||||
if (route.path !== '/approval-popup') {
|
if (route.path !== '/approval-popup') {
|
||||||
@@ -274,6 +300,9 @@ onUnmounted(() => {
|
|||||||
_unlistenPopupReady?.()
|
_unlistenPopupReady?.()
|
||||||
_stopApprovalPopup = null
|
_stopApprovalPopup = null
|
||||||
_unlistenPopupReady = null
|
_unlistenPopupReady = null
|
||||||
|
_unlistenTrayHideNotice?.()
|
||||||
|
_unlistenTrayHideNotice = null
|
||||||
|
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -294,4 +323,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
|
.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); }
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
|
||||||
|
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
|
||||||
|
.df-toast--info { background: var(--df-accent); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -51,12 +51,14 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<!-- AI 面板切换按钮 -->
|
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
|
||||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
|
||||||
<span class="nav-link-icon ai-spark-icon">
|
<span class="nav-link-icon ai-spark-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||||
|
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
|
||||||
|
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
|
||||||
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||||
</button>
|
</button>
|
||||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
|
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
|
||||||
@@ -83,6 +85,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useAiStore } from '@/stores/ai'
|
import { useAiStore } from '@/stores/ai'
|
||||||
import { useKnowledgeStore } from '@/stores/knowledge'
|
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
import { convStates } from '@/composables/ai/aiShared'
|
||||||
|
|
||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const knowledgeStore = useKnowledgeStore()
|
const knowledgeStore = useKnowledgeStore()
|
||||||
@@ -135,6 +138,17 @@ onBeforeUnmount(() => {
|
|||||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||||
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
||||||
|
|
||||||
|
// 全局运行中会话数(AI 面板关闭或切到 Tasks/Ideas 等页面时,后台生成在 footer 常驻按钮上仍可见)。
|
||||||
|
// 真相源 convStates(reactive Map,消费后端 AiConvStateChanged,写入在 isCurrent 守卫之前故后台会话也更新),
|
||||||
|
// 非终止三态计入,与 stores/ai.ts isGenerating 桥接语义一致;computed 内遍历 Map 即建立响应式依赖
|
||||||
|
const runningConvCount = computed(() => {
|
||||||
|
let n = 0
|
||||||
|
for (const s of convStates.values()) {
|
||||||
|
if (s === 'generating' || s === 'stopping' || s === 'compressed') n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
const primaryNav = [
|
const primaryNav = [
|
||||||
{ path: '/dashboard', label: 'nav.overview', badge: null,
|
{ path: '/dashboard', label: 'nav.overview', badge: null,
|
||||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
|
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
|
||||||
@@ -349,6 +363,23 @@ const secondaryNav = [
|
|||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 收缩态运行中标识:绿点钉在按钮右上角(展开态走 nav-link-badge 数字徽章,无空间放数字)。
|
||||||
|
脉冲范式与 ConversationSidebar 的 ai-conv-gen-dot 同源 */
|
||||||
|
.ai-running-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 6px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-accent);
|
||||||
|
animation: ai-sidebar-gen-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ai-sidebar-gen-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
/* — Footer — */
|
/* — Footer — */
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user