diff --git a/Cargo.lock b/Cargo.lock index 5ff155b37..2fe2d4ea2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -408,8 +414,10 @@ dependencies = [ name = "bongo-cat" version = "1.1.0" dependencies = [ + "form_urlencoded", "fs_extra", "gilrs", + "log", "rdev", "serde", "serde_json", @@ -423,6 +431,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-global-shortcut", + "tauri-plugin-http", "tauri-plugin-locale", "tauri-plugin-log", "tauri-plugin-macos-permissions", @@ -433,6 +442,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tiny_http", ] [[package]] @@ -663,6 +673,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -675,6 +696,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -745,10 +772,29 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -832,6 +878,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -982,6 +1037,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deranged" version = "0.5.8" @@ -1140,6 +1201,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1223,6 +1293,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1769,8 +1848,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1792,10 +1873,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1997,6 +2081,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2129,6 +2232,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2139,6 +2248,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2162,6 +2272,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2182,9 +2293,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2683,6 +2796,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2701,6 +2820,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -3788,6 +3913,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3808,6 +3939,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -3838,6 +3979,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3876,7 +4073,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -3890,6 +4087,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3928,6 +4136,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -3946,6 +4160,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4074,6 +4297,49 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -4257,6 +4523,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4315,6 +4582,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4561,6 +4834,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -4640,7 +4925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4923,6 +5208,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5040,7 +5346,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -5195,6 +5501,7 @@ dependencies = [ name = "tauri-plugin-custom-window" version = "0.1.0" dependencies = [ + "objc2-app-kit", "serde", "tauri", "tauri-nspanel", @@ -5222,9 +5529,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", @@ -5240,7 +5547,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", ] @@ -5259,6 +5566,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-http" +version = "2.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + [[package]] name = "tauri-plugin-locale" version = "2.0.1" @@ -5417,7 +5748,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -5698,6 +6029,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5799,6 +6142,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -6410,6 +6768,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" @@ -6475,6 +6843,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6672,6 +7049,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md b/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md new file mode 100644 index 000000000..c65665cb6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md @@ -0,0 +1,1513 @@ +# 猫咪头顶 AI 对话气泡 Implementation Plan + +> **状态:✅ 已全部实现并通过评审(分支 `feat/ai-chat-bubble`)。** Task 1–9 全部完成,逐任务两段式评审 + 整支最终评审通过。 +> +> **最终评审发现的 Critical 已修:** chat 窗口缺 `core:window:allow-show/hide` 权限(`daf2f96`)。 +> +> **联机(真机)验证时另发现并修复(计划/静态评审看不出,需 GUI 才暴露):** +> - `0f28e65` `main.currentMonitor()` 报 `TypeError`——`currentMonitor` 是 `@tauri-apps/api/window` 的独立函数,不是 `WebviewWindow`/`Window` 的方法;改用 `availableMonitors()` 按物理边界判定猫所在显示器。 +> - `0e0171f` 气泡文字竖排(每行一个字)——气泡是 flex 子项,`width:max-content` 不能阻止 flex 收缩到 min-content;改为「带 padding 的 block wrapper(`w-max max-w-80 p-3`)」,横向排版、到上限再换行,padding(被测量计入)替代 margin 预留阴影。 +> - `33bcf13` debug 构建下为 chat 窗口开 DevTools。 +> - App.vue 的 `unhandledrejection` 处理改为输出 Error 的 name/message/stack(原来 `JSON.stringify(Error)` → `{}` 把错误吞了)。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Live2D 猫咪头顶用一个独立透明穿透窗口展示自动消失的对话气泡,文字由前端 `say()` 或本地 HTTP 接口推送,三端通用。 + +**Architecture:** 新增一个独立的 `chat` 窗口(`/chat` 路由),它是气泡生命周期的唯一权威:监听全局 `show-chat` 事件 → 渲染文字 → 量出真实尺寸 → 动态 `setSize` 自身 → 定位到猫咪正上方 → 淡入 → 定时淡出。主窗口几乎零改动(仅 macOS 把已有的 move/resize 重发改成广播,好让 chat 收到几何变化)。配置集中在新 pinia store `ai`,通过 `@tauri-store/pinia` 跨窗口同步。进程外推送由内嵌的 `tiny_http` 本地 HTTP server 提供,复用同一个 `show-chat` 事件。 + +**Tech Stack:** Tauri 2 + Vue 3 (` + + +``` + +- [x] **Step 4: 跑起来确认 chat 窗口已注册(不报错、main 仍正常)** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +Expected: app 正常启动,猫咪正常显示;无 "window label chat" 相关报错。chat 窗口 `visible:false` 所以看不到,正常。确认无误后 `Ctrl-C` 退出。 + +- [x] **Step 5: Commit** + +```bash +git add src-tauri/tauri.conf.json src/router/index.ts src/pages/chat/index.vue +git commit -m "feat(ai): register chat window, route and page skeleton" +``` + +--- + +## Task 5: chat 页面完整生命周期 + +**Files:** +- Modify: `src/pages/chat/index.vue`(全量替换 Task 4 的骨架) + +**Interfaces:** +- Consumes: `useAiStore()`(Task 1)、`computeBubblePosition`(Task 2)、`LISTEN_KEY.SHOW_CHAT` / `WINDOW_LABEL.MAIN`(Task 3)。 +- Produces: 监听 `show-chat {text, duration?}` 的完整气泡渲染/测量/定位/计时/动画。 + +- [x] **Step 1: 全量替换 chat 页面** + +把 `src/pages/chat/index.vue` 整个替换为: + +```vue + + + + + +``` + +> 说明:`bubbleRef` 外层用 `m-3`(margin)为阴影预留空间,`getBoundingClientRect` 不含 box-shadow,靠 margin 让窗口尺寸留白,窗口透明所以留白不可见。三角用 border 画,`top-full` 贴在气泡底部正中指向猫咪。 + +- [x] **Step 2: 手动验证基本展示(借后续 DEBUG 测试区前,先用临时招呼验证)** + +临时验证:在 `src/pages/chat/index.vue` 的 `onMounted` 末尾临时加一行 `setTimeout(() => showChat({ text: '你好呀~测试一条比较长的文字看看换行' }), 2000)`,然后: + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +Expected: 启动 ~2 秒后,猫咪头顶冒出气泡,3 秒后淡出消失。位置在猫正上方居中。 + +确认后 **删除这行临时代码**。`Ctrl-C` 退出。 + +- [x] **Step 3: lint** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` +Expected: 无 `chat/index.vue` 相关报错。 + +- [x] **Step 4: Commit** + +```bash +git add src/pages/chat/index.vue +git commit -m "feat(ai): full chat bubble lifecycle (measure, size, position, timer, fade)" +``` + +--- + +## Task 6: macOS NSPanel + move/resize 广播 + +**Files:** +- Modify: `src-tauri/src/core/setup/macos.rs` + +**Interfaces:** +- Consumes: `tauri.conf.json` 里 label 为 `chat` 的窗口(Task 4)。 +- Produces: + - chat 窗口转为 NSPanel,与猫同 level(Dock)+ 同 collection behavior,`non_activating` / `can_become_key=false`。 + - 主窗口的 `tauri://move` / `tauri://resize` 改为 **广播**(`emit`),使 chat 能收到主窗口几何变化。 + +- [x] **Step 1: macos.rs 签名加 chat 窗口参数 + 改广播 + 建 chat panel** + +修改 `src-tauri/src/core/setup/macos.rs`。 + +(a) 改 `platform` 签名,新增 `chat_window` 参数(在 `_preference_window` 之后): + +```rust +pub fn platform( + app_handle: &AppHandle, + main_window: WebviewWindow, + _preference_window: WebviewWindow, + chat_window: WebviewWindow, +) { +``` + +(b) 把 `emit_position` 内的 `emit_to(target, ...)` 改成广播 `emit`,并把 resize 分支里给 main 的 `emit_to` 也改成广播。替换原 `fn emit_position` 与 `window_did_resize` 两处: + +原: +```rust + fn emit_position(window: &WebviewWindow) { + let target = EventTarget::labeled(MAIN_WINDOW_LABEL); + + if let Ok(position) = window.outer_position() { + let _ = window.emit_to(target, WINDOW_MOVED_EVENT, position); + } + } + + let resize_window = main_window.clone(); + handler.window_did_resize(move |_| { + emit_position(&resize_window); + + let target = EventTarget::labeled(MAIN_WINDOW_LABEL); + + if let Ok(size) = resize_window.inner_size() { + let _ = resize_window.emit_to(target, WINDOW_RESIZED_EVENT, size); + } + }); +``` + +改为: +```rust + // 广播给所有窗口(含 chat),使 chat 能跟随主窗口移动/缩放 + fn emit_position(window: &WebviewWindow) { + if let Ok(position) = window.outer_position() { + let _ = window.emit(WINDOW_MOVED_EVENT, position); + } + } + + let resize_window = main_window.clone(); + handler.window_did_resize(move |_| { + emit_position(&resize_window); + + if let Ok(size) = resize_window.inner_size() { + let _ = resize_window.emit(WINDOW_RESIZED_EVENT, size); + } + }); +``` + +> `window.emit(...)` 走 `Emitter` trait(已 `use tauri::Emitter`),广播到所有 webview。focus/blur 两处保持 `emit_to(main)` 不动(只有 main 需要)。`EventTarget` import 若变为未使用,保留即可(focus/blur 仍用到)。 + +(c) 在 `panel.set_event_handler(Some(handler.as_ref()));` 之前,新增 chat 窗口转 NSPanel 的代码: + +```rust + // chat 窗口:与猫同 level + 同 collection behavior,永不抢焦点 + if let Ok(chat_panel) = chat_window.to_panel::() { + chat_panel.set_level(PanelLevel::Dock.value()); + + chat_panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); + + chat_panel.set_collection_behavior( + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into(), + ); + } + // ponytail: 同层 order-front(chat 创建晚于 main,show 时在猫之上);若层级不准再抬高 PanelLevel +``` + +- [x] **Step 2: setup/mod.rs 与 common.rs 传入 chat 窗口** + +修改 `src-tauri/src/core/setup/mod.rs`,`default` 签名加 `chat_window`,并透传: + +```rust +pub fn default( + app_handle: &AppHandle, + main_window: WebviewWindow, + preference_window: WebviewWindow, + chat_window: WebviewWindow, +) { + #[cfg(debug_assertions)] + main_window.open_devtools(); + + platform( + app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); +} +``` + +修改 `src-tauri/src/core/setup/common.rs`,给非 mac 平台的 `platform` 加同名参数(不使用): + +```rust +pub fn platform( + _app_handle: &AppHandle, + _main_window: WebviewWindow, + _preference_window: WebviewWindow, + _chat_window: WebviewWindow, +) { +} +``` + +- [x] **Step 3: lib.rs 取 chat 窗口并传入 setup** + +修改 `src-tauri/src/lib.rs`,在 `let preference_window = ...` 之后、`setup::default(...)` 调用处: + +```rust + let chat_window = app.get_webview_window("chat").unwrap(); + + setup::default( + &app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); +``` + +- [x] **Step 4: 编译验证** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat/src-tauri && cargo build` +Expected: 编译通过(warnings 可接受)。 + +- [x] **Step 5: macOS 上手动验证层级 + 跟随** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev`(在 macOS 上) +临时在 chat 页 onMounted 加 `setTimeout(() => showChat({ text: '层级测试', duration: 0 }), 1500)`(`duration:0` 常驻便于观察),验证: +- 气泡浮在猫之上、不被裁切。 +- 拖动猫咪 → 气泡跟随(允许极轻微延迟)。 + +确认后删除临时代码,`Ctrl-C` 退出。 + +- [x] **Step 6: Commit** + +```bash +git add src-tauri/src/core/setup/macos.rs src-tauri/src/core/setup/mod.rs src-tauri/src/core/setup/common.rs src-tauri/src/lib.rs +git commit -m "feat(ai): macos chat NSPanel + broadcast main move/resize to chat" +``` + +--- + +## Task 7: AI 设置 tab + i18n + +**Files:** +- Create: `src/pages/preference/components/ai/index.vue` +- Modify: `src/pages/preference/index.vue`(import + menus) +- Modify: `src/locales/zh-CN.json` / `zh-TW.json` / `en-US.json` / `vi-VN.json` / `pt-BR.json` + +**Interfaces:** +- Consumes: `useAiStore()`(Task 1)、`say()`(Task 3)、`ProList` / `ProListItem`。 +- Produces: preference 窗口新增「AI」tab,含全部配置 + DEBUG 测试区。 + +- [x] **Step 1: 5 个语言包补 key** + +每个文件在 `pages.preference` 对象内加一个 `ai` 子对象,并在 `pages.main` 内加 `greeting`。 + +`src/locales/zh-CN.json` — `pages.main` 加 `"greeting": "你好呀~"`;`pages.preference` 加: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "气泡设置", + "enabled": "启用气泡", + "duration": "默认展示时长", + "textColor": "文字颜色", + "fontSize": "文字大小", + "bgColor": "气泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 接口", + "httpEnabled": "启用 HTTP 接口", + "httpPort": "监听端口", + "httpToken": "校验 Token", + "debug": "调试模式", + "testText": "测试文本", + "testShow": "展示" + }, + "hints": { + "enabled": "总开关,关闭后所有气泡都不显示。", + "http": "开启一个本地 HTTP 接口,供外部工具(如 curl)推送气泡。仅监听 127.0.0.1。", + "httpRestart": "改动端口/开关/Token 后需重启应用生效。", + "debug": "开启后展开下方测试区,可手动触发气泡用于验证。" + } + } +``` + +`src/locales/zh-TW.json` — `pages.main.greeting`: `"你好呀~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "氣泡設定", + "enabled": "啟用氣泡", + "duration": "預設顯示時長", + "textColor": "文字顏色", + "fontSize": "文字大小", + "bgColor": "氣泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 介面", + "httpEnabled": "啟用 HTTP 介面", + "httpPort": "監聽連接埠", + "httpToken": "驗證 Token", + "debug": "除錯模式", + "testText": "測試文字", + "testShow": "顯示" + }, + "hints": { + "enabled": "總開關,關閉後所有氣泡都不顯示。", + "http": "開啟一個本機 HTTP 介面,供外部工具(如 curl)推送氣泡。僅監聽 127.0.0.1。", + "httpRestart": "變更連接埠/開關/Token 後需重新啟動應用程式才會生效。", + "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。" + } + } +``` + +`src/locales/en-US.json` — `pages.main.greeting`: `"Hi there~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Bubble settings", + "enabled": "Enable bubble", + "duration": "Default duration", + "textColor": "Text color", + "fontSize": "Font size", + "bgColor": "Bubble color", + "bgOpacity": "Background opacity", + "http": "HTTP endpoint", + "httpEnabled": "Enable HTTP endpoint", + "httpPort": "Listen port", + "httpToken": "Auth token", + "debug": "Debug mode", + "testText": "Test text", + "testShow": "Show" + }, + "hints": { + "enabled": "Master switch. When off, no bubble is shown.", + "http": "Expose a local HTTP endpoint so external tools (e.g. curl) can push bubbles. Bound to 127.0.0.1 only.", + "httpRestart": "Changing port/switch/token requires an app restart to take effect.", + "debug": "Expands the test area below for manually triggering a bubble." + } + } +``` + +`src/locales/vi-VN.json` — `pages.main.greeting`: `"Xin chào~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Cài đặt bong bóng", + "enabled": "Bật bong bóng", + "duration": "Thời lượng mặc định", + "textColor": "Màu chữ", + "fontSize": "Cỡ chữ", + "bgColor": "Màu nền bong bóng", + "bgOpacity": "Độ trong suốt nền", + "http": "Giao diện HTTP", + "httpEnabled": "Bật giao diện HTTP", + "httpPort": "Cổng lắng nghe", + "httpToken": "Token xác thực", + "debug": "Chế độ gỡ lỗi", + "testText": "Văn bản thử", + "testShow": "Hiển thị" + }, + "hints": { + "enabled": "Công tắc tổng. Khi tắt, không bong bóng nào hiển thị.", + "http": "Mở một giao diện HTTP cục bộ để công cụ ngoài (vd: curl) đẩy bong bóng. Chỉ lắng nghe 127.0.0.1.", + "httpRestart": "Đổi cổng/công tắc/token cần khởi động lại ứng dụng để có hiệu lực.", + "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công." + } + } +``` + +`src/locales/pt-BR.json` — `pages.main.greeting`: `"Olá~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Configurações do balão", + "enabled": "Ativar balão", + "duration": "Duração padrão", + "textColor": "Cor do texto", + "fontSize": "Tamanho da fonte", + "bgColor": "Cor do balão", + "bgOpacity": "Opacidade do fundo", + "http": "Endpoint HTTP", + "httpEnabled": "Ativar endpoint HTTP", + "httpPort": "Porta de escuta", + "httpToken": "Token de autenticação", + "debug": "Modo de depuração", + "testText": "Texto de teste", + "testShow": "Mostrar" + }, + "hints": { + "enabled": "Interruptor geral. Quando desligado, nenhum balão é exibido.", + "http": "Expõe um endpoint HTTP local para ferramentas externas (ex.: curl) enviarem balões. Vinculado apenas a 127.0.0.1.", + "httpRestart": "Alterar porta/interruptor/token exige reiniciar o app para ter efeito.", + "debug": "Expande a área de teste abaixo para disparar um balão manualmente." + } + } +``` + +> 校验 JSON 合法:`node -e "require('./src/locales/zh-CN.json')"`(对 5 个文件各跑一次,不报错即合法)。 + +- [x] **Step 2: 写 AI 设置组件** + +Create `src/pages/preference/components/ai/index.vue`: + +```vue + + + +``` + +> `ColorPicker` 用 antdv-next 自带。`// ponytail`: 若该版本无 `ColorPicker` 导出,回退 ``。 + +- [x] **Step 3: preference 页加 AI tab** + +修改 `src/pages/preference/index.vue`。import 区加: + +```ts +import Ai from './components/ai/index.vue' +``` + +在 `menus` 数组里、`shortcut` 与 `about` 之间加一项: + +```ts + { + key: 'ai', + label: t('pages.preference.ai.title'), + icon: 'i-solar:chat-round-bold', + component: Ai, + }, +``` + +- [x] **Step 4: 跑起来验证设置页 + DEBUG 测试** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +打开偏好设置(托盘/右键菜单)→「AI」tab: +- 改文字颜色/底色/透明度/字号控件正常。 +- 打开 DEBUG → 输入文本点「展示」→ 猫咪头顶冒出气泡。 +- 改字号后再次展示,气泡尺寸自适应、仍居中贴猫头顶。 +- 打开 HTTP 开关 → 出现端口/Token/curl 示例与「需重启」提示。 + +确认后 `Ctrl-C` 退出。 + +- [x] **Step 5: lint** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` +Expected: 无相关报错。 + +- [x] **Step 6: Commit** + +```bash +git add src/pages/preference/components/ai/index.vue src/pages/preference/index.vue src/locales +git commit -m "feat(ai): add AI settings tab with debug test area and i18n" +``` + +--- + +## Task 8: 模型加载后打招呼 + +**Files:** +- Modify: `src/pages/main/index.vue` + +**Interfaces:** +- Consumes: `say()`(Task 3)、`pages.main.greeting`(Task 7)。 +- Produces: 首次模型加载完成后调用一次 `say(greeting)`(受 `ai.enabled` 控制,由 chat 页判断)。 + +- [x] **Step 1: 主页面加首次招呼** + +修改 `src/pages/main/index.vue`。 + +import 区加: + +```ts +import { say } from '@/composables/useChat' +``` + +在 ` + + +``` + +实现时若 `DateRangePicker` 的 v-model 值类型报错,以组件实际 Props 类型为准调整 `range` 的类型标注(值可能是 Dayjs 或字符串,`dayjs()` 二者都能包)。 + +- [ ] **Step 3: 在 Chat 标签页加入口** + +修改 `src/pages/preference/components/chat/index.vue`: + +imports 区加: + +```txt +import HistoryModal from './components/history-modal/index.vue' +``` + +`const testText = ref('你好呀~')` 之后加: + +```txt +const historyVisible = ref(false) +``` + +第一个 `` 内、`enabled` 那个 ProListItem 之后加: + +```txt + + + +``` + +模板最末尾(最后一个 `` 之后)加: + +```txt + +``` + +(`Button` 已在该文件的 antdv-next import 里。) + +- [ ] **Step 4: 手动验证(spec 的验收路径)** + +Run: `pnpm tauri dev`,依次验证: + +1. 设置 → Chat → 「历史消息」→「查看」打开弹窗,能看到 Task 4 验证时的记录,时间格式 `YYYY-MM-DD HH:mm:ss`,最新在前。 +2. 调试「展示」发一条 → 列表出现 `已展示 / 内部`。 +3. 关闭总开关再发一条 → 气泡不弹,列表出现 `未展示 / 内部`。 +4. 开启 HTTP 接口(需重启生效)后 `curl "http://127.0.0.1:7800/say?text=hello-from-http"` → 列表出现 `已展示 / HTTP`。 +5. 状态/来源/日期筛选各试一次,结果正确;选“今天到今天”应包含今天的消息(闭区间)。 +6. 发一条长文本(如 200 字),列表单行省略,点「详情」二级弹窗显示完整文本且换行保留。 +7. 重启应用,历史仍在。 + +Expected: 全部通过 + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/preference/components/chat src/locales +git commit -m "feat(chat): add message history modal with status/source/date filters" +``` diff --git a/docs/superpowers/plans/2026-07-10-bark-client.md b/docs/superpowers/plans/2026-07-10-bark-client.md new file mode 100644 index 000000000..dd08a4f99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-bark-client.md @@ -0,0 +1,950 @@ +# Bark 客户端支持 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** BongoCat 注册到自托管 htnanako/bark-server(SSE fork),实时接收 Bark 推送并以聊天气泡展示、记入 chat-history。 + +**Architecture:** 逻辑全部在前端(Vue 3)实现:`fetch POST /register` 注册换 device_key/stream_token;`fetch` + ReadableStream 手写极简 SSE 解析连接 `/events/{key}`(不发 Last-Event-ID → 服务端不回放);Web Crypto 解密 AES-CBC/GCM;消息 emit 现有 `show-chat` 事件(`source: 'bark'`),复用 chat 窗口的展示与历史记录路径。**fetch 必须用 `@tauri-apps/plugin-http` 的实现**(fork 服务端无 CORS 中间件,webview 原生 fetch 跨域被拦截;plugin-http 走 Rust 侧发请求不受 CORS 限制,响应体是真 ReadableStream,SSE 可用)。Rust 端改动仅限该插件的注册与权限。 + +**Tech Stack:** Vue 3 + Pinia(tauri-plugin-pinia 持久化/跨窗口同步)、antdv-next、@tauri-apps/api event、@tauri-apps/plugin-http、Web Crypto、tsx(自检脚本)。 + +**Spec:** `docs/superpowers/specs/2026-07-10-bark-client-design.md` + +## Global Constraints + +- 包管理器:pnpm(仓库 preinstall 强制);新依赖**仅限**官方 `@tauri-apps/plugin-http`(npm)+ `tauri-plugin-http`(crate)——CORS 所迫;不加 vitest,纯逻辑用 `npx tsx` 跑 assert 自检脚本 +- **Rust 端(src-tauri)改动仅限 plugin-http 注册**:`Cargo.toml` 依赖、`lib.rs` 一行 `.plugin(...)`、`capabilities/default.json` 权限;不写任何业务 Rust 代码 +- 不支持:消息回放/Last-Event-ID、AES-ECB、markdown/url/icon/level 等富字段、系统通知、多实例 +- locales 五个文件(en-US、pt-BR、vi-VN、zh-CN、zh-TW)必须同步补齐,模板里不出现硬编码文案 +- 代码注释风格跟随仓库现状(中文、说明约束而非复述代码);刻意简化标 `// ponytail:` +- 提交信息用 conventional commits,scope 用 `chat`(与现有 `feat(chat):` 一致) +- 每个任务完成后运行 `pnpm lint`(仓库无 typecheck 脚本,eslint 是唯一静态门禁) + +--- + +### Task 1: 纯逻辑工具 `src/utils/bark.ts`(SSE 解析 + 解密 + 文本映射) + +**Files:** +- Create: `src/utils/bark.ts` +- Test: `scripts/barkSelfCheck.ts`(assert 自检,`npx tsx` 运行) + +**Interfaces:** +- Consumes: 无(纯函数,零依赖) +- Produces(Task 3 依赖): + - `interface BarkCryptoConfig { mode: 'cbc' | 'gcm', key: string, iv: string }` + - `interface SSEEvent { event: string, data: string, id?: string }` + - `interface BarkNotification { title?: string, subtitle?: string, body?: string, payload?: Record }` + - `createSSEParser(): (chunk: string) => SSEEvent[]`(增量解析,跨 chunk 缓冲) + - `decryptBark(ciphertext: string, config: BarkCryptoConfig, ivOverride?: string): Promise>` + - `resolveBarkText(notification: BarkNotification, cryptoConfig?: BarkCryptoConfig): Promise` + +- [ ] **Step 1: 写自检脚本(此时必然失败)** + +创建 `scripts/barkSelfCheck.ts`: + +```ts +import assert from 'node:assert' + +import { createSSEParser, decryptBark, resolveBarkText } from '../src/utils/bark' + +async function main() { + // --- SSE 解析:心跳注释行、事件跨 chunk、多行 data、\r\n 归一化 --- + const feed = createSSEParser() + + assert.deepStrictEqual(feed(': ping\n\nevent: noti'), [], '心跳行应被忽略,半个事件应留在缓冲区') + + const events = feed('fication\nid: 1\ndata: {"a":\ndata: 1}\n\n') + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].event, 'notification') + assert.strictEqual(events[0].id, '1') + assert.strictEqual(events[0].data, '{"a":\n1}', '多行 data 用 \\n 拼接') + + const crlf = feed('event: ready\r\ndata: {}\r\n\r\n') + assert.strictEqual(crlf.length, 1) + assert.strictEqual(crlf[0].event, 'ready') + + // --- 解密:用 Node webcrypto 加密再解回(CBC / GCM),坏密文拒绝 --- + const encoder = new TextEncoder() + const keyStr = '0123456789abcdef' // 16 字符 = AES-128 + const ivCbc = 'abcdefghijklmnop' // CBC 16 字符 + const ivGcm = 'abcdefghijkl' // GCM 12 字符 + const plain = JSON.stringify({ title: 'hi', body: 'there' }) + const toB64 = (buf: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(buf))) + + const cbcKey = await crypto.subtle.importKey('raw', encoder.encode(keyStr), 'AES-CBC', false, ['encrypt']) + const cbcB64 = toB64(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: encoder.encode(ivCbc) }, cbcKey, encoder.encode(plain))) + const cbcOut = await decryptBark(cbcB64, { mode: 'cbc', key: keyStr, iv: ivCbc }) + assert.strictEqual(cbcOut.title, 'hi') + + const gcmKey = await crypto.subtle.importKey('raw', encoder.encode(keyStr), 'AES-GCM', false, ['encrypt']) + const gcmB64 = toB64(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: encoder.encode(ivGcm) }, gcmKey, encoder.encode(plain))) + const gcmOut = await decryptBark(gcmB64, { mode: 'gcm', key: keyStr, iv: ivGcm }) + assert.strictEqual(gcmOut.body, 'there') + + await assert.rejects(decryptBark('AAAAAAAA', { mode: 'gcm', key: keyStr, iv: ivGcm }), '坏密文必须抛错') + + // --- 文本映射:payload 优先、key 不区分大小写、title+body 换行拼接 --- + assert.strictEqual(await resolveBarkText({ title: 'a', body: 'b' }), 'a\nb') + assert.strictEqual(await resolveBarkText({ title: 'x', payload: { Title: 'a', Body: 'b' } }), 'a\nb', 'payload 覆盖顶层字段') + assert.strictEqual(await resolveBarkText({ body: 'only' }), 'only', '无 title 时不出现多余换行') + assert.strictEqual(await resolveBarkText({}), '', '空消息返回空串') + + // 加密 payload 端到端;payload 内 iv 覆盖本地配置 + const encText = await resolveBarkText({ payload: { ciphertext: cbcB64 } }, { mode: 'cbc', key: keyStr, iv: ivCbc }) + assert.strictEqual(encText, 'hi\nthere') + const encIvOverride = await resolveBarkText({ payload: { ciphertext: cbcB64, iv: ivCbc } }, { mode: 'cbc', key: keyStr, iv: 'wrongwrongwrongw' }) + assert.strictEqual(encIvOverride, 'hi\nthere', 'payload.iv 应覆盖本地 iv') + + // 有 ciphertext 但未配密钥 → 抛错(上层丢弃该条) + await assert.rejects(resolveBarkText({ payload: { ciphertext: cbcB64 } })) + + console.log('bark self-check OK') +} + +main() +``` + +- [ ] **Step 2: 运行自检确认失败** + +Run: `npx tsx scripts/barkSelfCheck.ts` +Expected: FAIL — `Cannot find module '../src/utils/bark'` + +- [ ] **Step 3: 实现 `src/utils/bark.ts`** + +```ts +export interface BarkCryptoConfig { + mode: 'cbc' | 'gcm' + key: string // ASCII,16/24/32 字符对应 AES-128/192/256 + iv: string // ASCII,CBC 16 字符 / GCM 12 字符 +} + +export interface SSEEvent { + event: string + data: string + id?: string +} + +export interface BarkNotification { + title?: string + subtitle?: string + body?: string + payload?: Record +} + +// 极简增量 SSE 解析器:feed(chunk) 返回本次凑齐的完整事件,未凑齐的留在缓冲区。 +// ponytail: 只实现本项目用到的子集——event/data/id 字段、`:` 心跳注释行、\r\n 归一化;不做 retry 字段 +export function createSSEParser() { + let buffer = '' + + return function feed(chunk: string): SSEEvent[] { + buffer += chunk.replace(/\r\n/g, '\n') + + // 事件以空行分隔;split 后最后一段是未完成的残帧,放回缓冲区 + const frames = buffer.split('\n\n') + buffer = frames.pop() ?? '' + + const events: SSEEvent[] = [] + + for (const frame of frames) { + const event: SSEEvent = { event: 'message', data: '' } + const dataLines: string[] = [] + + for (const line of frame.split('\n')) { + if (!line || line.startsWith(':')) continue + + const colon = line.indexOf(':') + if (colon === -1) continue + + const field = line.slice(0, colon) + // SSE 规范:冒号后紧跟的单个空格不属于值 + const value = line.startsWith(' ', colon + 1) ? line.slice(colon + 2) : line.slice(colon + 1) + + if (field === 'event') event.event = value + else if (field === 'data') dataLines.push(value) + else if (field === 'id') event.id = value + } + + event.data = dataLines.join('\n') + + if (event.data || event.event !== 'message') { + events.push(event) + } + } + + return events + } +} + +// Bark AES 解密:ciphertext 为 base64,key/iv 为 ASCII 字符串(与 Bark 客户端约定一致); +// GCM 为 combined 模式(tag 附在密文尾部,正好是 Web Crypto 的默认行为)。结果必须是 JSON。 +export async function decryptBark(ciphertext: string, config: BarkCryptoConfig, ivOverride?: string): Promise> { + const encoder = new TextEncoder() + const algorithm = config.mode === 'gcm' ? 'AES-GCM' : 'AES-CBC' + const data = Uint8Array.from(atob(ciphertext), char => char.charCodeAt(0)) + const iv = encoder.encode(ivOverride ?? config.iv) + const key = await crypto.subtle.importKey('raw', encoder.encode(config.key), algorithm, false, ['decrypt']) + const plain = await crypto.subtle.decrypt({ name: algorithm, iv }, key, data) + + return JSON.parse(new TextDecoder().decode(plain)) +} + +// notification 事件 → 气泡文本。payload 字段优先于顶层字段,key 不区分大小写(Bark 约定); +// payload 含 ciphertext 时先解密——未配置密钥则抛错,由调用方丢弃该条。 +export async function resolveBarkText(notification: BarkNotification, cryptoConfig?: BarkCryptoConfig): Promise { + const lower = (obj: Record) => + Object.fromEntries(Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value])) + + let payload = lower(notification.payload ?? {}) + + if (typeof payload.ciphertext === 'string') { + if (!cryptoConfig?.key) { + throw new Error('encrypted message but no crypto key configured') + } + + const ivOverride = typeof payload.iv === 'string' ? payload.iv : undefined + payload = lower(await decryptBark(payload.ciphertext, cryptoConfig, ivOverride)) + } + + const title = typeof payload.title === 'string' ? payload.title : notification.title ?? '' + const body = typeof payload.body === 'string' ? payload.body : notification.body ?? '' + + return [title, body].filter(Boolean).join('\n') +} +``` + +- [ ] **Step 4: 运行自检确认通过** + +Run: `npx tsx scripts/barkSelfCheck.ts` +Expected: 输出 `bark self-check OK`,退出码 0 + +- [ ] **Step 5: lint 并提交** + +```bash +pnpm lint +git add src/utils/bark.ts scripts/barkSelfCheck.ts +git commit -m "feat(chat): add bark SSE parser, AES decrypt and text mapping utils" +``` + +--- + +### Task 2: store 扩展 + 历史 source 类型 + 筛选项 + locales + +**Files:** +- Modify: `src/stores/chat.ts` +- Modify: `src/utils/chatHistory.ts:7` +- Modify: `src/pages/preference/components/chat/components/history-modal/index.vue:26-29` +- Modify: `src/locales/en-US.json`、`src/locales/pt-BR.json`、`src/locales/vi-VN.json`、`src/locales/zh-CN.json`、`src/locales/zh-TW.json` + +**Interfaces:** +- Consumes: 无 +- Produces(Task 3/4 依赖): + - `chatStore.bark: { enabled: boolean, serverUrl: string, deviceKey: string, streamToken: string, cryptoMode: 'cbc' | 'gcm', cryptoKey: string, cryptoIv: string }` + - `chatStore.barkStatus: BarkStatus`,`export type BarkStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'unauthorized'`(从 `@/stores/chat` 导出) + - `ChatMessage['source']` 联合类型含 `'bark'` + - locale key:`pages.preference.chat.labels.bark*`、`hints.bark*`、`barkStatus.*`、`history.bark` + +- [ ] **Step 1: 扩展 `src/stores/chat.ts`** + +在 `ChatStore` 接口 `ai` 字段后新增(`barkStatus` 独立于 `bark` 对象——它由 chat 窗口高频写入,若放进 `bark` 会触发 Task 4 的配置 watch 造成重连死循环): + +```ts +export type BarkStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'unauthorized' + +export interface ChatStore { + ai: { + // …原有字段不动… + } + bark: { + enabled: boolean + serverUrl: string + deviceKey: string + streamToken: string + cryptoMode: 'cbc' | 'gcm' + cryptoKey: string + cryptoIv: string + } + barkStatus: BarkStatus +} +``` + +在 `useChatStore` 的 setup 里(`ai` 的 reactive 之后)新增,并加入返回值: + +```ts +const bark = reactive({ + enabled: false, + serverUrl: '', + deviceKey: '', + streamToken: '', + cryptoMode: 'cbc', + cryptoKey: '', + cryptoIv: '', +}) + +// 连接状态由 chat 窗口写入、设置页只读展示;持久化的旧值无意义,chat 窗口挂载时会重置 +const barkStatus = ref('idle') +``` + +```ts +return { + ai, + bark, + barkStatus, +} +``` + +同时把文件顶部 `import { reactive, watch } from 'vue'` 改为 `import { reactive, ref, watch } from 'vue'`。 + +- [ ] **Step 2: `src/utils/chatHistory.ts` source 类型加 `'bark'`** + +```ts + source: 'http' | 'internal' | 'bark' +``` + +- [ ] **Step 3: history-modal 筛选项加 Bark** + +`src/pages/preference/components/chat/components/history-modal/index.vue` 的 `sourceOptions`: + +```ts +const sourceOptions = computed(() => [ + { value: 'http', label: t('pages.preference.chat.history.http') }, + { value: 'internal', label: t('pages.preference.chat.history.internal') }, + { value: 'bark', label: t('pages.preference.chat.history.bark') }, +]) +``` + +(表格 source 列用的是 `$t(\`…history.${record.source}\`)` 动态 key,加 locale 即自动生效,无需改模板。) + +- [ ] **Step 4: 五个 locale 文件补文案** + +每个文件的 `pages.preference.chat` 下:`labels` 与 `hints` 追加键、新增 `barkStatus` 同级对象、`history` 追加 `bark` 键。 + +`src/locales/zh-CN.json`: + +```json +"labels": { + "bark": "Bark 推送", + "barkEnabled": "启用 Bark", + "barkServerUrl": "服务器地址", + "barkRegister": "注册设备", + "barkDeviceKey": "设备 Key", + "barkStatus": "连接状态", + "barkCryptoKey": "解密密钥", + "barkCryptoIv": "IV", + "barkCryptoMode": "加密模式" +}, +"hints": { + "bark": "连接自托管 htnanako/bark-server(SSE),实时接收 Bark 推送并弹出气泡。断线期间的消息不补收。", + "barkRegister": "首次使用先注册获取设备 Key;用它向服务器推送消息。", + "barkCrypto": "留空则不解密;收到加密消息且未配置密钥时丢弃该条。" +}, +"barkStatus": { + "idle": "未启用", + "connecting": "连接中", + "connected": "已连接", + "reconnecting": "重连中", + "unauthorized": "需要重新注册" +}, +"history": { "bark": "Bark" } +``` + +`src/locales/zh-TW.json`: + +```json +"labels": { + "bark": "Bark 推送", + "barkEnabled": "啟用 Bark", + "barkServerUrl": "伺服器位址", + "barkRegister": "註冊裝置", + "barkDeviceKey": "裝置 Key", + "barkStatus": "連線狀態", + "barkCryptoKey": "解密金鑰", + "barkCryptoIv": "IV", + "barkCryptoMode": "加密模式" +}, +"hints": { + "bark": "連接自架 htnanako/bark-server(SSE),即時接收 Bark 推送並彈出氣泡。斷線期間的訊息不補收。", + "barkRegister": "首次使用先註冊取得裝置 Key;用它向伺服器推送訊息。", + "barkCrypto": "留空則不解密;收到加密訊息且未設定金鑰時捨棄該條。" +}, +"barkStatus": { + "idle": "未啟用", + "connecting": "連線中", + "connected": "已連線", + "reconnecting": "重連中", + "unauthorized": "需要重新註冊" +}, +"history": { "bark": "Bark" } +``` + +`src/locales/en-US.json`: + +```json +"labels": { + "bark": "Bark Push", + "barkEnabled": "Enable Bark", + "barkServerUrl": "Server URL", + "barkRegister": "Register Device", + "barkDeviceKey": "Device Key", + "barkStatus": "Connection Status", + "barkCryptoKey": "Decryption Key", + "barkCryptoIv": "IV", + "barkCryptoMode": "Cipher Mode" +}, +"hints": { + "bark": "Connect to a self-hosted htnanako/bark-server via SSE to receive Bark pushes as chat bubbles. Messages sent while offline are not replayed.", + "barkRegister": "Register first to get a device key, then use it to push messages to the server.", + "barkCrypto": "Leave empty to skip decryption; encrypted messages are dropped when no key is configured." +}, +"barkStatus": { + "idle": "Idle", + "connecting": "Connecting", + "connected": "Connected", + "reconnecting": "Reconnecting", + "unauthorized": "Re-register required" +}, +"history": { "bark": "Bark" } +``` + +`src/locales/pt-BR.json`: + +```json +"labels": { + "bark": "Push Bark", + "barkEnabled": "Ativar Bark", + "barkServerUrl": "URL do servidor", + "barkRegister": "Registrar dispositivo", + "barkDeviceKey": "Chave do dispositivo", + "barkStatus": "Status da conexão", + "barkCryptoKey": "Chave de descriptografia", + "barkCryptoIv": "IV", + "barkCryptoMode": "Modo de cifra" +}, +"hints": { + "bark": "Conecte-se a um htnanako/bark-server auto-hospedado via SSE para receber pushes do Bark como balões. Mensagens enviadas offline não são reenviadas.", + "barkRegister": "Registre primeiro para obter a chave do dispositivo; use-a para enviar mensagens ao servidor.", + "barkCrypto": "Deixe vazio para não descriptografar; mensagens criptografadas são descartadas sem chave configurada." +}, +"barkStatus": { + "idle": "Inativo", + "connecting": "Conectando", + "connected": "Conectado", + "reconnecting": "Reconectando", + "unauthorized": "Necessário registrar novamente" +}, +"history": { "bark": "Bark" } +``` + +`src/locales/vi-VN.json`: + +```json +"labels": { + "bark": "Đẩy Bark", + "barkEnabled": "Bật Bark", + "barkServerUrl": "Địa chỉ máy chủ", + "barkRegister": "Đăng ký thiết bị", + "barkDeviceKey": "Khóa thiết bị", + "barkStatus": "Trạng thái kết nối", + "barkCryptoKey": "Khóa giải mã", + "barkCryptoIv": "IV", + "barkCryptoMode": "Chế độ mã hóa" +}, +"hints": { + "bark": "Kết nối tới htnanako/bark-server tự triển khai qua SSE để nhận thông báo Bark dưới dạng bong bóng. Tin nhắn gửi khi ngoại tuyến sẽ không được phát lại.", + "barkRegister": "Đăng ký trước để nhận khóa thiết bị; dùng nó để gửi tin nhắn tới máy chủ.", + "barkCrypto": "Để trống nếu không giải mã; tin nhắn mã hóa sẽ bị bỏ qua khi chưa cấu hình khóa." +}, +"barkStatus": { + "idle": "Chưa bật", + "connecting": "Đang kết nối", + "connected": "Đã kết nối", + "reconnecting": "Đang kết nối lại", + "unauthorized": "Cần đăng ký lại" +}, +"history": { "bark": "Bark" } +``` + +(以上均为**追加**到现有对象,不动已有键;JSON 里注意逗号。) + +- [ ] **Step 5: lint 并提交** + +```bash +pnpm lint +git add src/stores/chat.ts src/utils/chatHistory.ts src/pages/preference/components/chat/components/history-modal/index.vue src/locales/ +git commit -m "feat(chat): add bark config store, history source type and locales" +``` + +--- + +### Task 3: 接入 plugin-http + `src/composables/useBark.ts`(注册 + SSE 连接循环) + +**Files:** +- Modify: `package.json`(pnpm add)、`src-tauri/Cargo.toml`(cargo add)、`src-tauri/src/lib.rs`、`src-tauri/capabilities/default.json` +- Create: `src/composables/useBark.ts` + +**Interfaces:** +- Consumes: Task 1 的 `createSSEParser`/`resolveBarkText`/`BarkCryptoConfig`/`SSEEvent`;Task 2 的 `chatStore.bark`/`chatStore.barkStatus`;现有 `LISTEN_KEY.SHOW_CHAT`(`src/constants/index.ts`) +- Produces(Task 4 依赖): `useBark(): { register: () => Promise, connect: () => void, disconnect: () => void }` + - `register()` 失败时 throw(Error.message 给 UI 展示),成功时把 `deviceKey`/`streamToken` 写入 store + - `connect()` 幂等:先断开旧连接;未启用或未注册时只归位状态不连接 + - 连接状态通过 `chatStore.barkStatus` 对外暴露(跨窗口同步到设置页) + +- [ ] **Step 1: 安装并注册 plugin-http** + +```bash +pnpm add @tauri-apps/plugin-http +cargo add tauri-plugin-http --manifest-path src-tauri/Cargo.toml +``` + +`src-tauri/src/lib.rs`:在现有 `.plugin(tauri_plugin_pinia::init())` 一行之后插入: + +```rust + .plugin(tauri_plugin_http::init()) +``` + +`src-tauri/capabilities/default.json` 的 `permissions` 数组追加(bark 服务器地址由用户配置,scope 放开 http/https): + +```json +{ + "identifier": "http:default", + "allow": [ + { "url": "http://**" }, + { "url": "https://**" } + ] +} +``` + +- [ ] **Step 2: 编译确认插件接入无误** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: 编译通过,无错误。 + +- [ ] **Step 3: 实现 `src/composables/useBark.ts`** + +注意首行 import:**必须**用 plugin-http 的 fetch(fork 服务端无 CORS 头,webview 原生 fetch 会被拦截)。 + +```ts +import { emit } from '@tauri-apps/api/event' +import { fetch } from '@tauri-apps/plugin-http' + +import type { BarkCryptoConfig, SSEEvent } from '@/utils/bark' + +import { LISTEN_KEY } from '@/constants' +import { useChatStore } from '@/stores/chat' +import { createSSEParser, resolveBarkText } from '@/utils/bark' + +// 与 bark-macOS 客户端一致的设备身份;服务端内置的 macos_sse provider 按这些值匹配 +const REGISTER_IDENTITY = { + device_token: null, + platform: 'macos', + app_id: 'me.fin.bark.macos', + provider_id: 'macos_sse', + topic: 'me.fin.bark.macos', +} + +const BACKOFF_SECONDS = [1, 2, 5, 10, 20, 30] + +// 模块级单例:SSE 连接只存在于 chat 窗口(应用生命周期内常驻);设置页仅调用 register() +let controller: AbortController | undefined +let attempt = 0 + +export function useBark() { + const chatStore = useChatStore() + + function base() { + return chatStore.bark.serverUrl.replace(/\/+$/, '') + } + + /** 注册设备:首次 device_key 留空由服务端签发;重复注册同 key 复用 stream_token */ + async function register() { + const res = await fetch(`${base()}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...REGISTER_IDENTITY, + device_key: chatStore.bark.deviceKey || undefined, + }), + }) + + const json = await res.json().catch(() => ({})) + + if (!res.ok) { + // 409 = 设备身份冲突;清空本地 key 重新注册可恢复 + throw new Error(json.message ?? `HTTP ${res.status}`) + } + + const data = json.data ?? json + const key = data.device_key ?? data.key + + if (!key || !data.stream_token) { + throw new Error('response missing device_key / stream_token') + } + + chatStore.bark.deviceKey = key + chatStore.bark.streamToken = data.stream_token + } + + function disconnect() { + controller?.abort() + controller = undefined + chatStore.barkStatus = 'idle' + } + + function connect() { + disconnect() + + const { enabled, deviceKey, streamToken } = chatStore.bark + if (!enabled || !deviceKey || !streamToken) return + + attempt = 0 + controller = new AbortController() + void runLoop(controller) + } + + async function runLoop(ctrl: AbortController) { + while (!ctrl.signal.aborted) { + try { + chatStore.barkStatus = attempt > 0 ? 'reconnecting' : 'connecting' + + // 不发 Last-Event-ID:服务端不回放,只收实时消息(设计决策:不补收离线消息) + const url = `${base()}/events/${chatStore.bark.deviceKey}?stream_token=${encodeURIComponent(chatStore.bark.streamToken)}` + const res = await fetch(url, { + headers: { Accept: 'text/event-stream' }, + signal: ctrl.signal, + }) + + if (res.status === 401 || res.status === 403) { + // token 失效:停止重连,避免无效循环打服务器;用户重新注册后 watch 会重建连接 + chatStore.barkStatus = 'unauthorized' + return + } + + if (!res.ok || !res.body) { + throw new Error(`http ${res.status}`) + } + + chatStore.barkStatus = 'connected' + attempt = 0 + + const feed = createSSEParser() + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + + for (const event of feed(value)) { + await handleEvent(event) + } + } + + // 服务端关闭连接(含同 key 被新连接踢下线)→ 走重连 + throw new Error('stream closed') + } catch { + if (ctrl.signal.aborted) return + + chatStore.barkStatus = 'reconnecting' + + // 指数退避 + 随机抖动,避免服务端重启时惊群 + const seconds = BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)] + attempt += 1 + await new Promise(resolve => setTimeout(resolve, seconds * 1000 + Math.random() * 800)) + } + } + } + + async function handleEvent(event: SSEEvent) { + if (event.event !== 'notification') return + + try { + const notification = JSON.parse(event.data) + // 解密配置每条消息实时读取,改密钥无需重连 + const cryptoConfig: BarkCryptoConfig | undefined = chatStore.bark.cryptoKey + ? { mode: chatStore.bark.cryptoMode, key: chatStore.bark.cryptoKey, iv: chatStore.bark.cryptoIv } + : undefined + const text = await resolveBarkText(notification, cryptoConfig) + + if (!text) return + + await emit(LISTEN_KEY.SHOW_CHAT, { text, source: 'bark' }) + } catch (error) { + // 解密失败 / 非法 JSON:丢弃该条,不影响连接 + console.warn('[bark] drop message:', error) + } + } + + return { register, connect, disconnect } +} +``` + +- [ ] **Step 4: lint 并提交** + +```bash +pnpm lint +git add package.json pnpm-lock.yaml src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/lib.rs src-tauri/capabilities/default.json src/composables/useBark.ts +git commit -m "feat(chat): add useBark composable with register and SSE connect loop via plugin-http" +``` + +--- + +### Task 4: chat 窗口接线 + 设置页 Bark 卡片 + +**Files:** +- Modify: `src/pages/chat/index.vue`(payload 类型 + 挂载连接 + 配置 watch) +- Create: `src/pages/preference/components/chat/components/bark-settings/index.vue` +- Modify: `src/pages/preference/components/chat/index.vue`(引入卡片) + +**Interfaces:** +- Consumes: Task 3 的 `useBark()`;Task 2 的 store 字段与 locale key +- Produces: 完整可用的功能面(消息展示自动复用现有 `showChat` 单点路径,无新分支) + +- [ ] **Step 1: `src/pages/chat/index.vue` 接线** + +`ShowChatPayload` 的 source 类型(第 22 行)改为: + +```ts + source?: 'http' | 'bark' +``` + +script 顶部 import 区新增: + +```ts +import { useBark } from '@/composables/useBark' +``` + +`const chatHistoryStore = …` 之后新增: + +```ts +const { connect: connectBark, disconnect: disconnectBark } = useBark() +``` + +现有 `onMounted` 末尾追加一行: + +```ts + connectBark() +``` + +现有 `onUnmounted` 内追加一行: + +```ts + disconnectBark() +``` + +文件末尾(现有 fontSize watch 之后)新增配置 watch——只 watch 连接参数,解密配置逐条实时读不需重连;500ms 防抖避免输入服务器地址时逐键重连: + +```ts +// bark 连接参数变更后重建连接;防抖吸收设置页逐键输入 +let barkReconnectTimer: ReturnType | undefined +watch( + () => [chatStore.bark.enabled, chatStore.bark.serverUrl, chatStore.bark.deviceKey, chatStore.bark.streamToken], + () => { + clearTimeout(barkReconnectTimer) + barkReconnectTimer = setTimeout(connectBark, 500) + }, +) +``` + +- [ ] **Step 2: 创建 `src/pages/preference/components/chat/components/bark-settings/index.vue`** + +```vue + + + +``` + +- [ ] **Step 3: 设置页引入卡片** + +`src/pages/preference/components/chat/index.vue`:import 区加 + +```ts +import BarkSettings from './components/bark-settings/index.vue' +``` + +模板中 HTTP 的 ``(调试 ProList 之前)后插入: + +```html + +``` + +- [ ] **Step 4: 启动应用冒烟** + +Run: `pnpm tauri dev` +Expected: 设置页 Chat 标签出现"Bark 推送"卡片;开关默认关;打开后显示服务器地址/注册/状态(未启用→连接中/重连中,因为还没注册成功,无崩溃报错即可)。 + +- [ ] **Step 5: lint 并提交** + +```bash +pnpm lint +git add src/pages/chat/index.vue src/pages/preference/components/chat/ +git commit -m "feat(chat): wire bark SSE client into chat window and preference page" +``` + +--- + +### Task 5: 端到端手动验证 + 手动测试脚本 + +**Files:** +- Create: `scripts/bark-manual.sh`(参照 `scripts/bubble-http.sh` 先例) + +**Interfaces:** +- Consumes: 用户已部署的 htnanako/bark-server 地址;Task 4 完成的 UI +- Produces: 可重复执行的手动验证脚本与验证结论 + +- [ ] **Step 1: 创建 `scripts/bark-manual.sh`** + +```bash +#!/usr/bin/env bash +# BongoCat Bark 客户端手动验证:向 htnanako/bark-server 推送明文/加密消息 +# +# 用法: +# ./scripts/bark-manual.sh # 明文 +# ./scripts/bark-manual.sh # 追加 AES-128-CBC 加密消息 +# +# device_key 在 BongoCat 设置页 Chat → Bark 推送里注册后显示。 +set -euo pipefail + +SERVER=${1:?usage: bark-manual.sh [aes128key(16char)] [iv(16char)]} +DEVICE_KEY=${2:?missing device_key} + +echo "--- 明文消息 ---" +curl -fsS "$SERVER/$DEVICE_KEY/测试标题/来自 bark-manual 的正文" && echo " <- ok(气泡应显示:测试标题 换行 正文)" + +if [[ $# -ge 4 ]]; then + KEY=$3 + IV=$4 + [[ ${#KEY} -eq 16 && ${#IV} -eq 16 ]] || { echo "key/iv 必须都是 16 字符(AES-128-CBC)"; exit 1; } + + echo "--- 加密消息(AES-128-CBC)---" + PLAIN='{"title":"加密标题","body":"加密正文"}' + CIPHER=$(printf %s "$PLAIN" | openssl enc -aes-128-cbc -K "$(printf %s "$KEY" | xxd -p)" -iv "$(printf %s "$IV" | xxd -p)" | base64) + curl -fsS -G "$SERVER/$DEVICE_KEY" --data-urlencode "ciphertext=$CIPHER" --data-urlencode "iv=$IV" && echo " <- ok(需在设置页配置相同的 CBC 密钥/IV)" +fi +``` + +之后 `chmod +x scripts/bark-manual.sh`。 + +- [ ] **Step 2: 端到端验证(对用户已部署的服务器)** + +依次执行并记录结果: + +1. `pnpm tauri dev` 启动应用 → 设置页填服务器地址 → 点"注册设备" → 出现设备 Key,状态变"已连接" +2. `./scripts/bark-manual.sh ` → 气泡弹出"测试标题\n来自 bark-manual 的正文",历史 Modal 中该条 source 为 Bark +3. 设置页配置 CBC 密钥(16 字符)+ IV(16 字符)→ `./scripts/bark-manual.sh ` → 弹出解密后的"加密标题\n加密正文" +4. 断网/杀掉与服务器的连接(或重启服务器)→ 状态变"重连中",恢复后回"已连接";断线期间推的消息**不**弹出(无回放) +5. 关闭"启用气泡"总开关 → 推消息不弹泡但历史里记为"未展示";关闭"启用 Bark"→ 状态回"未启用" + +Expected: 全部符合。任何一步不符 → 用 superpowers:systematic-debugging 排查后修复再验。 + +- [ ] **Step 3: 提交** + +```bash +git add scripts/bark-manual.sh +git commit -m "chore(chat): add bark manual test script" +``` diff --git a/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md new file mode 100644 index 000000000..6ca74de79 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md @@ -0,0 +1,251 @@ +# 设计:猫咪头顶 AI 对话气泡(独立附着窗口) + +- 日期:2026-06-29 +- 状态:已确认,待实现 +- 方案:B(动态尺寸窗口) + +## 1. 目标与背景 + +在桌宠(Live2D 猫咪)头顶展示一个对话气泡,显示动态文字。 + +- 文字来源:**由代码/事件主动推送**。两类生产者:前端 `say()`(任意位置可调用)+ 本地 HTTP 接口(进程外 bash/curl/任意工具推送,见 6.1);后续接一言、句库、交互触发都建在它之上。 +- 消失行为:**定时自动消失**,接口可传 `duration=0` 表示常驻。 +- 平台:**三端都要**(macOS / Windows / Linux),macOS 用 NSPanel 处理层级。 + +**为什么必须独立窗口**:主窗口尺寸紧贴模型且 `overflow-hidden`,气泡渲染在主窗口里会被裁切。独立窗口才能伸到猫咪上方边界之外。 + +## 2. 架构与权责 + +``` +say(text, duration) ← 任意位置可调用的推送 API + └─ emit 'show-chat'(广播到所有窗口) + └─▶ chat 窗口(/chat,独立透明穿透窗口)= 唯一生命周期权威 + ① 渲染文字 → 测量气泡真实尺寸 + ② setSize(自身 = 气泡尺寸) ← 方案 B 的动态尺寸 + ③ reposition(摆到猫咪正上方居中) + ④ show + 淡入 + ⑤ duration>0 → 计时 → 淡出 + hide +主窗口 move/resize → 通知 chat → chat reposition() +``` + +权责收敛原则:**chat 窗口自己管全部生命周期**(尺寸、定位、计时、动画),主窗口几乎零改动,从而把方案 B 的跨窗口同步坑降到最小。 + +主窗口仅有的改动:在 macOS 把已有的 `tauri://move` / `tauri://resize` 从「只发给 main」改成广播,好让 chat 也能收到。Windows/Linux 上 chat 直接用 `getByLabel('main').onMoved/onResized` 原生监听,无需改主窗口。 + +## 3. 新增窗口(`src-tauri/tauri.conf.json`) + +在 `windows` 数组新增: + +```jsonc +{ + "label": "chat", + "url": "index.html/#/chat", + "width": 200, "height": 100, // 初始值,运行时被动态 setSize 覆盖 + "visible": false, + "transparent": true, + "decorations": false, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "maximizable": false, + "focus": false +} +``` + +路由(`src/router`):新增 `/chat` → `src/pages/chat/index.vue`。 + +## 4. chat 页面 `src/pages/chat/index.vue` + +- **挂载时** `appWindow.setIgnoreCursorEvents(true)`(整窗鼠标穿透,透明空白区点不到)。 +- **气泡 UI**:圆角矩形 + 朝下小三角(指向猫咪),底部居中锚定,文字向上换行;`` 做淡入淡出。 +- 样式从 `useAiStore()` 读取并绑到 `:style`:`textColor` / `fontSize` / `bgColor`+`bgOpacity`(合成 `rgba` 作气泡背景填充,窗口本身始终透明)。 +- **监听 `show-chat {text, duration?}`**(`duration` 单位毫秒,可选): + 0. `!aiStore.ai.enabled` → 直接忽略(**总开关唯一生效点**,所有生产者共用) + 1. 写入 `text` → `await nextTick()` + 2. `bubbleEl.getBoundingClientRect()` 量出 `w/h`(CSS 逻辑像素) + 3. `appWindow.setSize(new LogicalSize(w + 阴影留白, h + 三角高))` + 4. `reposition()` → `appWindow.show()` → 触发淡入 + 5. `ms = duration ?? aiStore.ai.duration * 1000`(**默认时长唯一兜底点**);重置计时器:`ms > 0` 时 `ms` 毫秒后淡出并 `hide()`;`=0` 常驻 +- **监听主窗口几何变化** → 可见时 `reposition()`。 +- **样式变化(字号等)且气泡可见时** → 重新测量 → `setSize` → `reposition`(字号改变会改变尺寸)。 + +## 5. 定位(纯函数,可测)`src/utils/chatPosition.ts` + +```ts +// 全部用物理像素计算 +computeBubblePosition(main{x,y,w,h}, bubble{w,h}, screen{x,y,w,h}, gap) { + let x = main.x + (main.w - bubble.w) / 2 // 水平居中于猫 + let y = main.y - bubble.h - gap // 放猫咪正上方 + x = clamp(x, screen.x, screen.x + screen.w - bubble.w) // 不超出屏幕 + if (y < screen.y) y = main.y + main.h + gap // 上方没空间 → 翻到下方 + return { x, y } +} +``` + +**DPI / 多屏处理(方案 B 的关键坑)**: +- `getBoundingClientRect` 是逻辑像素;`outerPosition` / 显示器 bounds 是物理像素。 +- 定位前把 bubble 尺寸 × `scaleFactor` 转物理;`setSize` 用 `LogicalSize`、`setPosition` 用 `PhysicalPosition`。 +- `screen` 取**猫当前所在显示器**:用主窗口 `currentMonitor()` 拿该显示器的 position/size/scaleFactor(不是主屏),bubble 尺寸 × 该显示器 scaleFactor 转物理再算。 + +## 6. 推送 API `src/composables/useChat.ts` + +```ts +export function say(text: string, duration?: number) { + emit('show-chat', { text, duration }) // duration 单位毫秒;undefined 时由 chat 页兜底默认值 +} +``` + +- 全局 `emit` 广播,chat 窗口接收。任意页面/composable 直接 `say('你好~')`。 +- **总开关 / 默认时长不在这里判断**,统一由 chat 页处理(见第 4 节 step 0 / 5),保证前端 `say()` 与 HTTP 接口两个生产者行为一致。 +- 两类生产者最终都只是发同一个 `show-chat` 事件: + - 前端任意位置:`say()` + - 进程外(bash / curl / 任意工具):HTTP 接口(见 6.1) + +## 6.1 HTTP 外部推送接口(方案②) + +进程内事件够不着外部。内嵌一个**本地 HTTP server**作为进程外入口,让 `curl` / 任意工具直接推送。 + +**依赖**:`tiny_http`(极轻量、无需 async 运行时,单独 std 线程跑阻塞循环;比 axum 省)。加入 workspace `Cargo.toml`:`tiny_http = "0.12"`。 + +**模块**:新增 `src-tauri/src/core/server.rs`,在 `setup` 阶段按配置启动。 + +``` +GET http://127.0.0.1:/say?text=&duration=<秒,可选>&token=<可选> +``` + +- 启动时读 `aiStore` 配置:`httpEnabled` / `httpPort` / `httpToken`。 +- 在独立线程 `std::thread::spawn` 跑 `tiny_http::Server::http("127.0.0.1:")` 阻塞循环。 +- 收到请求 → 解析 query: + - `httpToken` 非空时校验 `token` 不匹配 → `401` + - `text` 缺失 → `400` + - `duration` 给了就 `秒 → 毫秒`;没给则**不带** `duration` 字段(让 chat 页用默认值);`0` 表示常驻 + - 校验通过 → `app_handle.emit("show-chat", { text, duration })` → 返回 `200 ok` +- **复用同一个 `show-chat` 事件**:总开关 / 默认时长 / 定位 / 动画全部由 chat 页统一处理,HTTP 侧只管解析转发。 + +调用示例: + +```bash +# 简单 +curl "http://127.0.0.1:7800/say?text=%E4%BD%A0%E5%A5%BD%E5%91%80" +# 自动 urlencode + 指定 5 秒 + token +curl -G "http://127.0.0.1:7800/say" \ + --data-urlencode "text=你好呀~" \ + --data "duration=5" --data "token=abc123" +``` + +**安全(信任边界,不可省)**: +- **只绑 `127.0.0.1`**,不监听外网。 +- 开关默认 **关闭**(`httpEnabled=false`):开一个监听端口是用户应主动同意的行为;在「AI」设置里显式开启。 +- 可选 `httpToken`:同机其它进程/用户也能访问 localhost,需要更强隔离时填 token 校验。默认空=仅靠 localhost。 +- `// ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启)`。 + +## 7. macOS NSPanel(`src-tauri/src/core/setup/macos.rs`) + +- 取 `chat` 窗口 → `to_panel()`,与猫**同 level(Dock)+ 同 collection behavior**(跟随空间、全屏辅助),`non_activating`、`can_become_key=false`(永不抢焦点)。 +- chat 创建晚于 main,show 时 order front 即在猫之上。`// ponytail: 同层 order-front;若层级不准再抬高 PanelLevel`。 +- 把现有 `emit_position` / resize 的 `emit_to(main)` 改成广播(emit),使 chat 能收到主窗口移动/缩放。 + +## 8. 独立设置项「AI」 + +新 tab:`preference/index.vue` 的 `menus` 加一项 +`{ key:'ai', label:'AI', icon:'i-solar:chat-round-bold', component: Ai }`, +新建 `src/pages/preference/components/ai/index.vue`。 + +新建独立 store `src/stores/ai.ts`(所有气泡配置集中在此,通过 `@tauri-store/pinia` 跨窗口同步): + +```ts +ai: { + enabled: boolean // 总开关,默认 true + duration: number // 默认展示秒数,默认 3 + textColor: string // 文字颜色,默认 '#333' + fontSize: number // 文字大小(px),默认 14 + bgColor: string // 气泡底色,默认 '#fff' + bgOpacity: number // 底色透明度 0-100,默认 90 + debug: boolean // DEBUG 开关,默认 false + // —— HTTP 外部接口(见 6.1)—— + httpEnabled: boolean // HTTP 接口开关,默认 false(安全:默认不开端口) + httpPort: number // 监听端口,默认 7800 + httpToken: string // 可选校验 token,默认 ''(空=不校验) +} +``` + +AI 设置页(用现有 `ProList`/`ProListItem` + antdv-next 控件): +- **总开关** `enabled`(Switch) +- **默认秒数** `duration`(InputNumber + `s` 后缀) +- **文字颜色** `textColor`(ColorPicker)/ **文字大小** `fontSize`(InputNumber 或 Slider) +- **气泡底色** `bgColor`(ColorPicker)/ **透明度** `bgOpacity`(Slider 0-100) +- **HTTP 接口**子区:`httpEnabled`(Switch)/ `httpPort`(InputNumber)/ `httpToken`(Input.Password,可空);开启时展示一条可复制的 `curl` 示例;旁注「改动后需重启生效」。 +- **DEBUG** `debug`(Switch);开启后**展开测试区**: + - 文本输入框 + 「展示」按钮 → 调 `say(inputText)` 立即在猫咪头顶展示 + - 这块同时充当定位的手动验证工具(见第 9 节) + +ColorPicker 用 antdv-next 自带;若缺失则回退原生 ``(`// ponytail`)。 + +i18n:5 个语言包补 key(zh-CN / zh-TW / en-US / vi-VN / pt-BR)。 + +## 9. 验证 —— 定位为重点 + +### (a) 纯函数 `chatPosition.ts` 单测(assert 自检,无框架,覆盖所有分支) + +1. 正常居中:猫在屏幕中央 → x 居中、y 在上方 +2. 贴左边缘 → x 夹到 `screen.x` +3. 贴右边缘 → x 夹到 `screen.x + screen.w - bubble.w` +4. 贴顶部、上方放不下 → 翻转到猫咪下方 +5. 气泡比猫宽 → 仍以猫中心对齐 +6. 气泡比屏幕还宽 → 夹取后不越界(取 `screen.x`) +7. 多显示器:猫在副屏(负坐标/偏移)→ 用猫所在显示器的 bounds 夹取 +8. DPI=2:逻辑尺寸 × scaleFactor 后物理坐标正确 + +### (b) DPI / 多屏的真实数据来源 + +定位时用主窗口 `currentMonitor()` 拿到**猫当前所在显示器**的 position/size/scaleFactor,而不是主屏——这是多屏正确的关键。气泡尺寸(逻辑 px)× 该显示器 scaleFactor 转物理再算。 + +### (c) 手动验证清单(借 DEBUG 测试区逐项过) + +- 短文 / 长文 / 多行换行 → 尺寸自适应且始终底边居中贴猫头顶 +- 把猫拖到屏幕四边 + 四角 → 不裁切、贴边自动夹取、顶部不够时翻到下方 +- 改猫咪 `scale` → 气泡跟随重新定位 +- 跨显示器拖动、不同 DPI 的两块屏 → 位置/尺寸正确 +- 拖动猫咪时气泡跟随(允许极轻微延迟) + +### (d) 可见验证触发 + +模型加载完成后 `say(t('greeting'))` 打个招呼(受 `ai.enabled` 控制),启动即可看到气泡浮在猫咪头顶。 + +### (e) HTTP 接口验证 + +- 关闭 `httpEnabled` → 端口不监听(`curl` 连不上)。 +- 开启后重启 → `curl ".../say?text=hi"` 返回 `200` 且猫咪头顶冒泡。 +- 缺 `text` → `400`;设了 `httpToken` 且不带/错 token → `401`。 +- 绑定确认:只在 `127.0.0.1` 可达,外网 IP 连不上。 +- `duration=0` → 常驻;`duration=5` → 5 秒后消失。 + +## 10. 改动文件一览 + +新增: +- `src/pages/chat/index.vue` +- `src/utils/chatPosition.ts`(+ 自检) +- `src/composables/useChat.ts` +- `src/stores/ai.ts` +- `src/pages/preference/components/ai/index.vue` + +- `src-tauri/src/core/server.rs`(HTTP 外部推送,tiny_http) + +修改: +- `src-tauri/tauri.conf.json`(新增 chat 窗口) +- `src/router`(新增 /chat 路由) +- `src-tauri/src/core/setup/macos.rs`(chat NSPanel + move/resize 改广播) +- `src-tauri/src/core/mod.rs` + `lib.rs`(setup 阶段启动 HTTP server) +- `src-tauri/Cargo.toml`(+ `tiny_http`) +- `src/pages/preference/index.vue`(menus 加 AI tab) +- `src/locales/*`(5 个语言包补 key) +- `src/pages/main/index.vue`(模型加载后调一次 `say` 打招呼) + +## 11. 已知简化(ponytail) + +- macOS chat 与猫同 NSPanel level + order-front;若层级不准再抬高 `PanelLevel`。 +- 拖动猫咪时气泡用 JS 重定位有极轻微跟随延迟;完全消除需原生子窗口(大量平台代码),不值。 +- HTTP 接口用 `tiny_http` 单线程阻塞循环(够用),不引入 axum/tokio server 栈。 +- HTTP 改端口/开关/token 后需重启 app 生效,不做配置热重载。 +- HTTP 仅 `GET /say`,不做 REST/多路由/POST body(YAGNI,curl 一行就够)。 diff --git a/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md b/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md new file mode 100644 index 000000000..e9298d7c2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md @@ -0,0 +1,138 @@ +# 气泡 HTTP 接口:控制接口 + 消息接口 + +**日期:** 2026-06-30 +**状态:** 设计已批准,待实现 + +## 背景 + +气泡的 HTTP 接口已存在:`src-tauri/src/core/server.rs` 用 `tiny_http` 在 +`127.0.0.1:{httpPort}` 上监听,目前仅有 `GET /say?text=&duration=&token=`, +通过 `show-chat` 事件把文字推送到 chat 窗口。设置页 +(`src/pages/preference/components/ai/index.vue`)已有 HTTP 开关、端口、Token, +以及一行 curl 示例。 + +本设计把接口拆成两类,并补全设置页的调用说明。 + +## 目标 + +1. **消息接口**(扩展现有 `GET /say`):单条气泡可携带**临时、一次性**的样式覆盖 + (展示时长、文字颜色、文字大小、气泡底色、底色透明度),不影响已保存的设置。 +2. **控制接口**(新增 `GET /config`):修改**已保存的默认值**,持久化到磁盘, + 并实时同步到设置页 UI。 +3. **设置页调用说明**:HTTP 区块展开后内联展示两类接口的完整调用介绍。 + +## 非目标 + +- 不引入新的 HTTP 框架(继续用 `tiny_http`,仅 GET + query 参数,不解析 POST body)。 +- 不通过 HTTP 控制 `enabled` 主开关、`httpPort`/`httpToken`/`debug` 等元配置。 +- 不做鉴权之外的访问控制(仍只监听 127.0.0.1)。 + +## 接口设计 + +参数名直接复用 `aiStore.ai.*` 字段名,使「设置项 ↔ API 参数」一一对应。 + +### 消息接口 `GET /say` + +| 参数 | 必填 | 说明 | +| ----------- | ---- | ------------------------------ | +| `text` | 是 | 气泡文字(非空) | +| `token` | 否 | 服务端设置了 Token 时必填 | +| `duration` | 否 | 本条气泡展示秒数(一次性覆盖) | +| `textColor` | 否 | 文字颜色 hex(一次性覆盖) | +| `fontSize` | 否 | 文字大小 px(一次性覆盖) | +| `bgColor` | 否 | 气泡底色 hex(一次性覆盖) | +| `bgOpacity` | 否 | 底色透明度 0–100(一次性覆盖) | + +覆盖仅作用于这一条气泡,不写入保存的设置;下一条气泡若不带覆盖则回到默认值。 +成功返回 `200 OK`。 + +示例: + +``` +curl "http://127.0.0.1:7800/say?text=hi&textColor=%23ff0000&fontSize=20&duration=5" +``` + +### 控制接口 `GET /config` + +| 参数 | 必填 | 说明 | +| ----------- | ---- | ------------------------- | +| `token` | 否 | 服务端设置了 Token 时必填 | +| `duration` | 否 | 默认展示秒数 | +| `textColor` | 否 | 文字颜色 hex | +| `fontSize` | 否 | 文字大小 px | +| `bgColor` | 否 | 气泡底色 hex | +| `bgOpacity` | 否 | 底色透明度 0–100 | + +- 带任意设值参数:校验后持久化为新默认值,并实时同步到设置页;返回更新后的配置 JSON。 +- 不带任何设值参数:仅返回当前配置 JSON(用于查询/验证)。 + +示例: + +``` +curl "http://127.0.0.1:7800/config?bgColor=%23000000&bgOpacity=80" +curl "http://127.0.0.1:7800/config" # 读取当前配置 +``` + +## 数据流 + +### 消息(临时覆盖) + +``` +GET /say?text=...&textColor=...&... + → server.rs 校验参数 + → emit "show-chat" { text, duration?, textColor?, fontSize?, bgColor?, bgOpacity? } + → chat/index.vue showChat(): 把携带的覆盖存入局部 ref(每次先重置) + → bubbleStyle 取 override ?? aiStore.ai.* + → 气泡按本条覆盖渲染,设置不变 +``` + +`show-chat` 事件 payload 从 `{text, duration}` 扩展为附带可选样式覆盖字段。 + +### 控制(持久 + 同步) + +``` +GET /config?bgColor=...&... + → server.rs 校验参数 + → emit "update-config" { 部分配置 } + → chat/index.vue 监听 update-config,将值赋给 aiStore.ai.* + → saveOnChange 持久化到磁盘 + → @tauri-store/pinia 跨窗口同步,设置页实时更新 + → 返回更新后的配置 JSON +``` + +写入走「始终存活的 chat 窗口」:chat 窗口在启动时创建、仅隐藏,其 JS 持续运行, +已持有 `aiStore` 并已监听事件,因此由它统一写 store,再由 pinia 插件同步到设置页 +并落盘。不在 Rust 侧直接写 store,避免与前端为数据源的模型冲突。 + +## 校验 + +服务端在 emit 前校验,非法输入返回 `400` 加简短中文说明(当前服务仅有 404/401/200): + +- `textColor` / `bgColor`:必须是合法 hex(`#rgb` 或 `#rrggbb`)。 +- `fontSize`:8–64。 +- `bgOpacity`:0–100。 +- `duration`:≥ 0。 +- `/say` 的 `text`:必填且非空。 + +Token 校验逻辑不变:服务端设置了 Token 且请求 Token 不匹配 → `401`。 + +## 设置页调用说明 + +`src/pages/preference/components/ai/index.vue` 的 HTTP 区块在启用后, +内联展开一块说明面板,包含: + +- 两类接口(消息 / 控制)的区别(一次性 vs 持久)。 +- 参数表(名称、是否必填、含义、取值范围)。 +- 可复制的 curl 示例,端口/Token 用当前实际值插值。 + +新增 i18n 字符串挂在 `ai.http.*` 下(zh-CN 及其它已存在 locale)。 + +## 涉及文件 + +- `src-tauri/src/core/server.rs`:拆出 `/say` 与 `/config` 处理;参数校验;400 响应; + `/config` 返回配置 JSON。 +- `src/pages/chat/index.vue`:`showChat` 支持一次性样式覆盖;新增 `update-config` 监听写 store。 +- `src/pages/preference/components/ai/index.vue`:内联调用说明面板。 +- `src/constants/index.ts`:新增 `update-config` 事件 key(`LISTEN_KEY`)。 +- `src/locales/*.json`:新增 `ai.http.*` 文案。 +- 事件 payload 类型(`show-chat` 扩展、`update-config` 新增)所在的类型定义处。 diff --git a/docs/superpowers/specs/2026-07-08-chat-history-design.md b/docs/superpowers/specs/2026-07-08-chat-history-design.md new file mode 100644 index 000000000..8b3f81663 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-chat-history-design.md @@ -0,0 +1,78 @@ +# Chat 历史消息设计 + +日期:2026-07-08 +分支:feat/ai-chat-bubble + +## 背景与目标 + +Chat 气泡消息目前只展示、不存储:消息来自前端 `say()`(内部调用,如设置页调试测试)和 HTTP `/say` 接口(Rust 发 `show-chat` 事件),气泡窗口展示后即丢弃。 + +目标:记录每条消息的历史,并在偏好设置的 Chat 标签页提供查询与展示界面。 + +## 需求 + +1. Chat 标签页新增「历史消息」按钮,点击后在设置窗口内弹出 Modal。 +2. 查询条件:消息状态(已展示/未展示)、来源(HTTP/内部)、日期范围。 +3. 列表展示:时间(精确到秒)、状态、来源、消息内容;内容过长时单行省略,每行提供「详情」按钮,点击后二级 Modal 展示完整消息。 +4. 历史重启后保留,限制最近 500 条,超出淘汰最旧。 + +## 架构 + +记录点选在气泡窗口(`src/pages/chat/index.vue`)的 `showChat` 入口——所有来源的消息都汇聚于此,且总开关判断在这里,是唯一同时知道「消息内容」和「是否真的展示了」的位置。落盘与跨窗口同步复用现有 `@tauri-store/pinia`(`saveOnChange: true`),设置窗口直接读同一 store。 + +``` +say() ──────────────┐ + ├─ show-chat 事件 ─→ 气泡窗口 showChat() +Rust /say (source: ─┘ ├─ push 到 chatHistory store(自动落盘+跨窗口同步) + "http") └─ 现有展示逻辑(enabled 判断 → 气泡) + +设置窗口 Chat 标签页 ─「历史消息」按钮 → Modal(筛选 + Table)← 读 chatHistory store +``` + +## 组件 + +### 1. 数据模型与存储 — 新建 `src/stores/chatHistory.ts` + +独立于配置 store(`src/stores/chat.ts`),避免每条消息触发配置文件重写。 + +```ts +interface ChatMessage { + time: number // Date.now() 时间戳,展示时格式化到秒 + text: string + status: 'shown' | 'skipped' // skipped = 总开关关闭时收到 + source: 'http' | 'internal' +} +// state: { history: ChatMessage[] },上限 500 条,push 超出时移除最旧 +``` + +在 `App.vue` 与其他 store 一致地调用 `$tauri.start()`。 + +### 2. 来源标记 + +- Rust `ShowChatPayload`(`src-tauri/src/core/server.rs`)增加 `source: "http"` 字段。 +- 前端 `say()` 不带 source;气泡窗口收到 payload 时 `source ?? 'internal'`。 + +### 3. 记录点 — `src/pages/chat/index.vue` + +`showChat` 入口处先记录(status 由 `chatStore.ai.enabled` 决定为 `shown` 或 `skipped`),再走现有展示逻辑。记录不影响展示流程的任何现有行为。 + +### 4. 历史消息界面 — `src/pages/preference/components/chat/index.vue` + +Chat 标签页新增「历史消息」入口(ProListItem + Button),点击打开 antdv-next `Modal`: + +- 筛选行:状态 `Select`(全部/已展示/未展示)+ 来源 `Select`(全部/HTTP/内部)+ 日期 `RangePicker`。 +- 过滤在前端内存完成(最多 500 条,无需后端查询)。日期范围为闭区间:开始日 00:00:00 至 结束日 23:59:59。 +- `Table` 列:时间(`YYYY-MM-DD HH:mm:ss`)、状态、来源、消息内容(单行省略)、操作(「详情」按钮 → 二级 Modal 展示完整文本,`whitespace-pre-wrap`)。 +- 表格分页每页 20 条,按时间倒序(最新在前)。 +- 文案走 i18n,补齐现有语言文件对应词条。 + +## 错误处理与边界 + +- 500 条上限是主要保护;写入失败不影响气泡展示(记录与展示互不阻塞)。 +- 空历史/过滤无结果:Table 自带 empty 状态。 +- 不做删除/清空功能(后续需要时加一个「清空」按钮即可,本期不做)。 + +## 测试 + +- 存储裁剪(超 500 淘汰最旧)与过滤逻辑抽为纯函数,各配小单测(参照 `chatPosition.test.ts` 的形式)。 +- 手动验证:调试测试按钮与 HTTP curl 各发一条,总开关开/关各一次,确认状态与来源记录正确、重启后历史仍在。 diff --git a/docs/superpowers/specs/2026-07-10-bark-client-design.md b/docs/superpowers/specs/2026-07-10-bark-client-design.md new file mode 100644 index 000000000..623f2408f --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-bark-client-design.md @@ -0,0 +1,95 @@ +# Bark 客户端支持 — 设计文档 + +日期:2026-07-10 +分支:feat/ai-chat-bubble + +## 目标 + +BongoCat 作为 Bark 客户端,注册到自托管的 **htnanako/bark-server**(fork,带 SSE 通道), +实时接收推送消息并以聊天气泡展示、记入 chat-history。 + +不支持原版 finb/bark-server(仅 APNs 投递,桌面端无通道)与官方公共服务 api.day.app。 + +## 需求决策(已确认) + +| 决策点 | 结论 | +|---|---| +| 目标服务端 | htnanako/bark-server fork(用户已部署) | +| 展示方式 | 只弹气泡:`title\nbody` 拼纯文本走现有 show-chat 路径,记入 chat-history(source: `bark`);不发系统通知 | +| 断线回放 | 不回放:不发送 Last-Event-ID,断线期间消息直接丢弃 | +| 加密消息 | 支持 AES CBC/GCM(Web Crypto 原生),不支持 ECB | +| 实现位置 | 前端 fetch-SSE(方案 A),但 fetch 经官方 `@tauri-apps/plugin-http` 发出(见下) | +| CORS | fork 服务端(Fiber)无 CORS 中间件,webview 原生 fetch 跨域被拦截;改用 plugin-http(请求走 Rust 侧,不受 CORS 限制,响应体为真 ReadableStream,SSE 可用) | + +## 服务端协议(htnanako fork) + +- `POST /register`,JSON body: + `{ device_key: <已有则复用,首次为空>, platform: "macos", app_id: "me.fin.bark.macos", provider_id: "macos_sse", topic: "me.fin.bark.macos" }` + → 响应 `data`: `{ key/device_key, stream_token, ... }`。同 key 重复注册复用 stream_token;身份冲突返回 409。 +- `GET /events/{device_key}?stream_token=...`,`Accept: text/event-stream`。 + SSE 事件:`event: ready`(连上时)、`event: notification`(推送本体);每 25s 一行 `: ping` 心跳。 + 带 Last-Event-ID 时回放最多 200 条历史 —— 本设计**不发送**该头,故无回放。 +- notification 的 `data:` 为 JSON:`{ id, device_key, title, subtitle, body, payload{...}, created_at }`。 + 加密消息 payload 含 `ciphertext`(base64)与可选 `iv`。 + +## 架构与组件 + +### 新增 + +1. **`src/composables/useBark.ts`**(核心,约 100 行) + - `register()`:fetch POST /register,成功后把 `deviceKey`/`streamToken` 写入 store + - `connect()` / `disconnect()`:fetch + ReadableStream 手写极简 SSE 解析(按 `\n\n` 分帧, + 跳过 `:` 注释行,只处理 `event: notification`),AbortController 管理生命周期 + - 重连:指数退避 `[1,2,5,10,20,30]s` + 随机抖动,无限重试 + - 解密:payload 含 `ciphertext` → Web Crypto 按 store 配置(模式/key/IV)解密 → JSON + - 出口:`title\nbody`(无 title 只取 body)emit `show-chat`,payload `source: 'bark'` + +2. **设置页 Bark 卡片**(`src/pages/preference/components/chat/` 下): + 启用开关、服务器地址、注册按钮(展示 device key 供复制)、加密配置(模式 CBC/GCM、key、IV)、 + 连接状态指示(已连接 / 重连中 / 需重新注册 / 未启用)。 + +### 修改 + +- `src/stores/chat.ts`:新增 `bark: { enabled, serverUrl, deviceKey, streamToken, crypto: { mode, key, iv } }` + (随现有 tauri-store 自动持久化、跨窗口同步) +- `src/utils/chatHistory.ts`:`ChatMessage.source` 联合类型加 `'bark'`;历史 Modal 筛选项同步 +- `src/pages/chat/index.vue`:挂载时 `bark.enabled` 则 `connect()`(SSE 挂在常驻 chat 窗口); + watch bark 配置变更时断开重建 +- locales 五个语言文件补文案 + +### 数据流 + +``` +bark-server SSE → useBark 解析/解密 → emit show-chat (source:'bark') + → 现有 showChat():记 chat-history + 总开关判断 + 弹气泡 +``` + +bark 消息不走任何特殊展示分支,与 `/say` 完全同路。 + +Rust 端改动仅限 plugin-http 接入:`Cargo.toml` 加 `tauri-plugin-http`、`lib.rs` 一行 +`.plugin(tauri_plugin_http::init())`、`capabilities/default.json` 加 `http:default` 权限 +(url scope `http://**` + `https://**`);npm 侧加 `@tauri-apps/plugin-http`。 +`useBark` 用 `import { fetch } from '@tauri-apps/plugin-http'` 替代原生 fetch,其余不变。 + +## 错误处理 + +- **注册失败**:按钮旁展示错误;409 提示清空本地 key 重新注册;失败不写状态 +- **SSE 断线**:指数退避无限重连(上限 30s + 抖动),状态显示"重连中" +- **同 key 被踢**(fork 同 key 单连接,新踢旧):走重连路径;双实例互踢为已知限制,注明单实例使用,不做处理 +- **401/403(stream_token 失效)**:停止重连,状态显示"需要重新注册" +- **解密失败 / 非法 JSON / 空消息**:丢弃该条,console.warn,不影响连接 +- **配置变更 / 关闭开关**:AbortController 干净断开,需要时重建 + +## 测试 + +- SSE 分帧解析器抽纯函数 + 最小单测(跨 chunk 帧边界、心跳行、多行 data) +- 解密纯函数 + 单测(CBC/GCM 正例各一 + 坏密文一例) +- 端到端手动验证:docker 起 htnanako/bark-server → 注册 → curl 推送 → 气泡出现且 + 历史 source=bark;断线重连、加密消息各验一次(补一份 bark 手动测试说明, + 参照现有 /say 手动测试脚本先例) + +## 明确不做(YAGNI) + +- 消息回放 / 离线补收 +- AES-ECB、markdown 渲染、url/icon/level/sound 等 Bark 富字段 +- 系统通知、Rust 端实现、多实例支持 diff --git a/docs/superpowers/specs/2026-07-10-menu-chat-tab-shortcut-design.md b/docs/superpowers/specs/2026-07-10-menu-chat-tab-shortcut-design.md new file mode 100644 index 000000000..d84a003de --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-menu-chat-tab-shortcut-design.md @@ -0,0 +1,48 @@ +# 右键菜单直达聊天设置 — 设计 + +日期:2026-07-10 +分支:feat/ai-chat-bubble + +## 目标 + +在猫咪右键菜单(及托盘菜单,两者共享 `getBaseMenu`)中新增一个"聊天设置"菜单项,点击后打开偏好设置窗口并直接定位到"聊天"Tab。 + +## 背景 + +- 右键/托盘菜单定义在 `src/composables/useAppMenu.ts`。 +- 偏好设置窗口在应用启动时创建并常驻(隐藏),Tab 由 `src/pages/preference/index.vue` 的本地 `current` 索引控制,目前无法从外部指定。 +- 代码库已有跨窗口事件模式:`LISTEN_KEY.SHOW_CHAT`、`UPDATE_CONFIG` 等通过 Tauri emit/listen 通信。 + +## 方案 + +沿用现有跨窗口事件模式(已否决 URL query 方案——偏好窗口 URL 固定不重载;已否决持久化 store 方案——一次性导航信号不该落盘)。 + +### 改动点 + +1. **`src/constants/index.ts`** + 新增 `LISTEN_KEY.NAVIGATE_PREFERENCE_TAB = 'navigate-preference-tab'`。 + +2. **`src/composables/useAppMenu.ts`** — `getBaseMenu` + 在"偏好设置..."之后新增菜单项: + - 文案:`t('composables.useAppMenu.labels.chatSetting')`("聊天设置") + - action:`showWindow(WINDOW_LABEL.PREFERENCE)` 后 `emit(LISTEN_KEY.NAVIGATE_PREFERENCE_TAB, 'chat')` + +3. **`src/pages/preference/index.vue`** + 新增 `useTauriListen(LISTEN_KEY.NAVIGATE_PREFERENCE_TAB, ...)`: + `const index = menus.value.findIndex(m => m.key === payload)`,`index >= 0` 时 `current = index`(-1 时忽略)。 + +4. **国际化** — 5 个 locale 文件(zh-CN、zh-TW、en-US、pt-BR、vi-VN)新增 + `composables.useAppMenu.labels.chatSetting`: + - zh-CN:聊天设置 + - zh-TW:聊天設定 + - en-US:Chat Settings + - pt-BR:Configurações de chat + - vi-VN:Cài đặt trò chuyện + +## 错误处理 + +- payload 不匹配任何 tab key 时静默忽略(findIndex -1 守卫),无其他新错误路径。 + +## 验证 + +手动:右键猫咪 →"聊天设置"→ 偏好设置窗口打开且定位到聊天 Tab;托盘菜单同样生效;偏好窗口已打开但停在其他 Tab 时点击也能切换过去。 diff --git a/package.json b/package.json index 490e28594..0596b5844 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-global-shortcut": "^2.3.1", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-os": "^2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75d1ed750..94b71682e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tauri-apps/plugin-global-shortcut': specifier: ^2.3.1 version: 2.3.1 + '@tauri-apps/plugin-http': + specifier: ^2.5.9 + version: 2.5.9 '@tauri-apps/plugin-log': specifier: ^2.8.0 version: 2.8.0 @@ -1581,6 +1584,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + '@tauri-apps/cli-darwin-arm64@2.10.1': resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} @@ -1672,6 +1678,9 @@ packages: '@tauri-apps/plugin-global-shortcut@2.3.1': resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==} + '@tauri-apps/plugin-http@2.5.9': + resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} + '@tauri-apps/plugin-log@2.8.0': resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} @@ -6236,6 +6245,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.1': {} + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true @@ -6303,6 +6314,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-http@2.5.9': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-log@2.8.0': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/scripts/bark-manual.sh b/scripts/bark-manual.sh new file mode 100755 index 000000000..e4fc427ca --- /dev/null +++ b/scripts/bark-manual.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# BongoCat Bark 客户端手动验证:向 htnanako/bark-server 推送明文/加密消息 +# +# 用法: +# ./scripts/bark-manual.sh # 明文 +# ./scripts/bark-manual.sh # 追加 AES-128-CBC 加密消息 +# +# device_key 在 BongoCat 设置页 Chat → Bark 推送里注册后显示。 +set -euo pipefail + +SERVER=${1:?usage: bark-manual.sh [aes128key(16char)] [iv(16char)]} +DEVICE_KEY=${2:?missing device_key} + +echo "--- 明文消息 ---" +curl -fsS "$SERVER/$DEVICE_KEY/测试标题/来自 bark-manual 的正文" && echo " <- ok(气泡应显示:测试标题 换行 正文)" + +if [[ $# -ge 4 ]]; then + KEY=$3 + IV=$4 + [[ ${#KEY} -eq 16 && ${#IV} -eq 16 ]] || { echo "key/iv 必须都是 16 字符(AES-128-CBC)"; exit 1; } + + echo "--- 加密消息(AES-128-CBC)---" + PLAIN='{"title":"加密标题","body":"加密正文"}' + CIPHER=$(printf %s "$PLAIN" | openssl enc -aes-128-cbc -K "$(printf %s "$KEY" | xxd -p)" -iv "$(printf %s "$IV" | xxd -p)" | base64) + curl -fsS -G "$SERVER/$DEVICE_KEY" --data-urlencode "ciphertext=$CIPHER" --data-urlencode "iv=$IV" && echo " <- ok(需在设置页配置相同的 CBC 密钥/IV)" +fi diff --git a/scripts/barkSelfCheck.ts b/scripts/barkSelfCheck.ts new file mode 100644 index 000000000..6fa404b79 --- /dev/null +++ b/scripts/barkSelfCheck.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert' + +import { createSSEParser, decryptBark, resolveBarkText } from '../src/utils/bark' + +async function main() { + // --- SSE 解析:心跳注释行、事件跨 chunk、多行 data、\r\n 归一化 --- + const feed = createSSEParser() + + assert.deepStrictEqual(feed(': ping\n\nevent: noti'), [], '心跳行应被忽略,半个事件应留在缓冲区') + + const events = feed('fication\nid: 1\ndata: {"a":\ndata: 1}\n\n') + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].event, 'notification') + assert.strictEqual(events[0].id, '1') + assert.strictEqual(events[0].data, '{"a":\n1}', '多行 data 用 \\n 拼接') + + const crlf = feed('event: ready\r\ndata: {}\r\n\r\n') + assert.strictEqual(crlf.length, 1) + assert.strictEqual(crlf[0].event, 'ready') + + // --- 解密:用 Node webcrypto 加密再解回(CBC / GCM),坏密文拒绝 --- + const encoder = new TextEncoder() + const keyStr = '0123456789abcdef' // 16 字符 = AES-128 + const ivCbc = 'abcdefghijklmnop' // CBC 16 字符 + const ivGcm = 'abcdefghijkl' // GCM 12 字符 + const plain = JSON.stringify({ title: 'hi', body: 'there' }) + const toB64 = (buf: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(buf))) + + const cbcKey = await crypto.subtle.importKey('raw', encoder.encode(keyStr), 'AES-CBC', false, ['encrypt']) + const cbcB64 = toB64(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: encoder.encode(ivCbc) }, cbcKey, encoder.encode(plain))) + const cbcOut = await decryptBark(cbcB64, { mode: 'cbc', key: keyStr, iv: ivCbc }) + assert.strictEqual(cbcOut.title, 'hi') + + const gcmKey = await crypto.subtle.importKey('raw', encoder.encode(keyStr), 'AES-GCM', false, ['encrypt']) + const gcmB64 = toB64(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: encoder.encode(ivGcm) }, gcmKey, encoder.encode(plain))) + const gcmOut = await decryptBark(gcmB64, { mode: 'gcm', key: keyStr, iv: ivGcm }) + assert.strictEqual(gcmOut.body, 'there') + + await assert.rejects(decryptBark('AAAAAAAA', { mode: 'gcm', key: keyStr, iv: ivGcm }), '坏密文必须抛错') + + // --- 文本映射:payload 优先、key 不区分大小写、title+body 换行拼接 --- + assert.strictEqual(await resolveBarkText({ title: 'a', body: 'b' }), 'a\nb') + assert.strictEqual(await resolveBarkText({ title: 'x', payload: { Title: 'a', Body: 'b' } }), 'a\nb', 'payload 覆盖顶层字段') + assert.strictEqual(await resolveBarkText({ body: 'only' }), 'only', '无 title 时不出现多余换行') + assert.strictEqual(await resolveBarkText({}), '', '空消息返回空串') + + // 加密 payload 端到端;payload 内 iv 覆盖本地配置 + const encText = await resolveBarkText({ payload: { ciphertext: cbcB64 } }, { mode: 'cbc', key: keyStr, iv: ivCbc }) + assert.strictEqual(encText, 'hi\nthere') + const encIvOverride = await resolveBarkText({ payload: { ciphertext: cbcB64, iv: ivCbc } }, { mode: 'cbc', key: keyStr, iv: 'wrongwrongwrongw' }) + assert.strictEqual(encIvOverride, 'hi\nthere', 'payload.iv 应覆盖本地 iv') + + // 有 ciphertext 但未配密钥 → 抛错(上层丢弃该条) + await assert.rejects(resolveBarkText({ payload: { ciphertext: cbcB64 } })) + + console.log('bark self-check OK') +} + +main() diff --git a/scripts/bubble-http.sh b/scripts/bubble-http.sh new file mode 100755 index 000000000..f8c763b24 --- /dev/null +++ b/scripts/bubble-http.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# 手工模拟气泡 HTTP 接口请求。 +# 消息接口 /say :推一条气泡,可带「一次性」样式覆盖,不改保存的设置。 +# 控制接口 /config:写入「持久」默认值(实时同步设置页),无参数时回读当前配置。 +# +# 前置:在「设置 → AI」开启「启用 HTTP 接口」,改动端口/Token 后需重启 app。 +# +# 用法: +# [BUBBLE_PORT=7800] [BUBBLE_TOKEN=xxx] ./scripts/bubble-http.sh <命令> [参数...] +# +# 命令: +# say [文本] 推一条默认样式气泡(默认「你好呀~」) +# say-styled [文本] 推一条带一次性样式覆盖的气泡(红字/大号/深色底/4s) +# set k=v [k=v...] /config 写默认值,例:set bgColor=#000000 bgOpacity=80 fontSize=18 +# get /config 回读当前配置(JSON) +# bad 故意越界(fontSize=999),预期 400 +# demo 依次跑:say → set → get → say(看持久生效)→ bad +# +# 颜色里的 # 不用手动转义,脚本用 curl --data-urlencode 处理。 +set -euo pipefail + +PORT="${BUBBLE_PORT:-7800}" +TOKEN="${BUBBLE_TOKEN:-}" +BASE="http://127.0.0.1:${PORT}" + +# req [k=v ...]:GET 请求,自动 URL 编码参数并附带 token(若设置),打印响应体 + 状态码 +req() { + local path="$1"; shift + local args=(-sS -G "${BASE}${path}" -w $'\n[HTTP %{http_code}]\n') + local kv + for kv in "$@"; do + args+=(--data-urlencode "$kv") + done + [ -n "$TOKEN" ] && args+=(--data-urlencode "token=${TOKEN}") + curl "${args[@]}" +} + +usage() { + sed -n '2,27p' "$0" +} + +cmd="${1:-help}" +shift || true + +case "$cmd" in + say) + req /say "text=${1:-你好呀~}" + ;; + say-styled) + req /say "text=${1:-红色大字}" "textColor=#ff0000" "fontSize=28" "bgColor=#000000" "bgOpacity=70" "duration=4" + ;; + set) + [ "$#" -gt 0 ] || { echo "用法:set k=v [k=v...],例:set bgColor=#000000 bgOpacity=80" >&2; exit 2; } + req /config "$@" + ;; + get) + req /config + ;; + bad) + req /say "text=x" "fontSize=999" + ;; + demo) + echo "== 1. /say 默认样式 ==" + req /say "text=demo 默认" + echo "== 2. /config 写默认值(底色黑、透明度 80、字号 18)==" + req /config "bgColor=#000000" "bgOpacity=80" "fontSize=18" + echo "== 3. /config 回读 ==" + req /config + echo "== 4. /say 不带样式,应使用刚写入的新默认值 ==" + req /say "text=demo 持久生效" + echo "== 5. 越界参数,预期 400 ==" + req /say "text=x" "fontSize=999" || true + ;; + help|-h|--help) + usage + ;; + *) + echo "未知命令:$cmd" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cfd8ea4d5..e8c2b331c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,11 +36,15 @@ tauri-plugin-macos-permissions = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" fs_extra = "1" +tiny_http = "0.12" +form_urlencoded = "1" +log = "0.4" tauri-plugin-clipboard-manager = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-locale = "2" rdev = { git = "https://github.com/kunkunsh/rdev" } gilrs = { git = "https://github.com/ayangweb/gilrs", default-features = false, features = ["xinput"] } +tauri-plugin-http = "2.5.9" [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel.workspace = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d8ee94f97..6e75ef75f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,9 @@ "core:window:allow-set-ignore-cursor-events", "core:window:allow-set-decorations", "core:window:allow-set-position", + "core:window:allow-current-monitor", + "core:window:allow-show", + "core:window:allow-hide", "core:window:allow-set-theme", "core:window:allow-set-title", "admin-status:default", @@ -49,6 +52,17 @@ "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister", - "locale:default" + "locale:default", + { + "identifier": "http:default", + "allow": [ + { + "url": "http://**" + }, + { + "url": "https://**" + } + ] + } ] } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 2e56f7730..623d43623 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod device; pub mod gamepad; pub mod prevent_default; +pub mod server; pub mod setup; diff --git a/src-tauri/src/core/server.rs b/src-tauri/src/core/server.rs new file mode 100644 index 000000000..56064d162 --- /dev/null +++ b/src-tauri/src/core/server.rs @@ -0,0 +1,330 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_pinia::ManagerExt; +use tiny_http::{Method, Response, Server}; + +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct AiConfig { + http_enabled: bool, + http_port: u16, + http_token: String, +} + +impl Default for AiConfig { + fn default() -> Self { + Self { + http_enabled: false, + http_port: 7800, + http_token: String::new(), + } + } +} + +// /config 无参数时回读的完整气泡配置(与 store 默认值保持一致) +#[derive(Serialize, Deserialize, Clone)] +#[serde(default, rename_all = "camelCase")] +struct AiPublicConfig { + enabled: bool, + duration: u64, + text_color: String, + font_size: u32, + bg_color: String, + bg_opacity: u32, +} + +// keep in sync with src/stores/ai.ts defaults +impl Default for AiPublicConfig { + fn default() -> Self { + Self { + enabled: true, + duration: 3, + text_color: "#333".into(), + font_size: 14, + bg_color: "#fff".into(), + bg_opacity: 90, + } + } +} + +// 可选样式覆盖:/say 一次性、/config 持久共用同一组字段。 +// duration 单位为「秒」(与设置项一致);/say 发事件时再转毫秒。 +#[derive(Serialize, Clone, Default, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +struct Overrides { + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ShowChatPayload { + text: String, + // 毫秒;chat 页 `ms = duration ?? 默认 * 1000` + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, + // 消息来源;前端 say() 不带此字段,气泡窗口按 internal 兜底 + source: String, +} + +fn is_hex_color(value: &str) -> bool { + match value.strip_prefix('#') { + Some(hex) => (hex.len() == 3 || hex.len() == 6) && hex.bytes().all(|b| b.is_ascii_hexdigit()), + None => false, + } +} + +// 解析并校验可选样式参数;任一非法返回错误说明。duration 保持「秒」。 +fn parse_overrides(params: &HashMap) -> Result { + let mut out = Overrides::default(); + + if let Some(raw) = params.get("duration") { + let value = raw + .parse::() + .map_err(|_| "duration 必须是非负整数(秒)".to_string())?; + out.duration = Some(value); + } + + if let Some(raw) = params.get("textColor") { + if !is_hex_color(raw) { + return Err("textColor 必须是 hex 颜色(如 #ff0000)".into()); + } + out.text_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("fontSize") { + let value = raw + .parse::() + .map_err(|_| "fontSize 必须是整数".to_string())?; + if !(8..=64).contains(&value) { + return Err("fontSize 必须在 8–64 之间".into()); + } + out.font_size = Some(value); + } + + if let Some(raw) = params.get("bgColor") { + if !is_hex_color(raw) { + return Err("bgColor 必须是 hex 颜色(如 #ffffff)".into()); + } + out.bg_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("bgOpacity") { + let value = raw + .parse::() + .map_err(|_| "bgOpacity 必须是整数".to_string())?; + if value > 100 { + return Err("bgOpacity 必须在 0–100 之间".into()); + } + out.bg_opacity = Some(value); + } + + Ok(out) +} + +// ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启) +pub fn start(app_handle: &AppHandle) { + // 读持久化的 ai 配置(store id 与 key 均为 "ai");无文件时取默认(关闭) + let config: AiConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + + if !config.http_enabled { + return; + } + + let handle = app_handle.clone(); + let addr = format!("127.0.0.1:{}", config.http_port); + let token = config.http_token; + + // ponytail: tiny_http 单线程阻塞循环,够用;不引入 axum/tokio + std::thread::spawn(move || { + let server = match Server::http(&addr) { + Ok(server) => server, + Err(err) => { + log::error!("chat http server failed to bind {addr}: {err}"); + return; + } + }; + + log::info!("chat http server listening on {addr}"); + + for request in server.incoming_requests() { + handle_request(&handle, &token, request); + } + }); +} + +fn respond(request: tiny_http::Request, status: u16, body: &str) { + let _ = request.respond(Response::from_string(body).with_status_code(status)); +} + +fn respond_json(request: tiny_http::Request, body: String) { + let header = tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("static header"); + let _ = request.respond(Response::from_string(body).with_header(header)); +} + +fn handle_request(app_handle: &AppHandle, token: &str, request: tiny_http::Request) { + let url = request.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((url.as_str(), "")); + let path = path.to_string(); + + if request.method() != &Method::Get { + return respond(request, 404, "not found"); + } + + let params: HashMap = form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + + // token 非空时校验(两个接口共用) + if !token.is_empty() && params.get("token").map(String::as_str) != Some(token) { + return respond(request, 401, "unauthorized"); + } + + match path.as_str() { + "/say" => handle_say(app_handle, ¶ms, request), + "/config" => handle_config(app_handle, ¶ms, request), + _ => respond(request, 404, "not found"), + } +} + +fn handle_say(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { + let text = match params.get("text") { + Some(text) if !text.is_empty() => text.clone(), + _ => return respond(request, 400, "missing text"), + }; + + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; + + let payload = ShowChatPayload { + text, + duration: overrides.duration.map(|seconds| seconds * 1000), + text_color: overrides.text_color, + font_size: overrides.font_size, + bg_color: overrides.bg_color, + bg_opacity: overrides.bg_opacity, + source: "http".into(), + }; + + let _ = app_handle.emit("show-chat", payload); + + respond(request, 200, "ok"); +} + +fn handle_config(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; + + // 无 setter 参数 → 回读当前配置 + if overrides == Overrides::default() { + let current: AiPublicConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + let body = serde_json::to_string(¤t).unwrap_or_else(|_| "null".into()); + return respond_json(request, body); + } + + if app_handle.get_webview_window("chat").is_none() { + return respond(request, 503, "chat window not ready"); + } + + let _ = app_handle.emit("update-config", &overrides); + + let body = serde_json::json!({ "applied": overrides }).to_string(); + respond_json(request, body); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_color_accepts_3_and_6_digits() { + assert!(is_hex_color("#fff")); + assert!(is_hex_color("#ffffff")); + assert!(is_hex_color("#FF0000")); + } + + #[test] + fn hex_color_rejects_bad_input() { + assert!(!is_hex_color("fff")); // 缺 # + assert!(!is_hex_color("#ff")); // 长度错 + assert!(!is_hex_color("#gggggg")); // 非 hex + assert!(!is_hex_color("red")); + } + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn parses_valid_overrides() { + let out = parse_overrides(¶ms(&[ + ("duration", "5"), + ("textColor", "#ff0000"), + ("fontSize", "20"), + ("bgColor", "#000"), + ("bgOpacity", "80"), + ])) + .unwrap(); + assert_eq!(out.duration, Some(5)); + assert_eq!(out.text_color.as_deref(), Some("#ff0000")); + assert_eq!(out.font_size, Some(20)); + assert_eq!(out.bg_color.as_deref(), Some("#000")); + assert_eq!(out.bg_opacity, Some(80)); + } + + #[test] + fn empty_params_give_default_overrides() { + assert_eq!(parse_overrides(¶ms(&[])).unwrap(), Overrides::default()); + } + + #[test] + fn rejects_out_of_range_and_bad_values() { + assert!(parse_overrides(¶ms(&[("fontSize", "4")])).is_err()); + assert!(parse_overrides(¶ms(&[("fontSize", "999")])).is_err()); + assert!(parse_overrides(¶ms(&[("bgOpacity", "101")])).is_err()); + assert!(parse_overrides(¶ms(&[("textColor", "blue")])).is_err()); + assert!(parse_overrides(¶ms(&[("duration", "-1")])).is_err()); + } + + #[test] + fn say_payload_serializes_http_source() { + let payload = ShowChatPayload { + text: "hi".into(), + duration: None, + text_color: None, + font_size: None, + bg_color: None, + bg_opacity: None, + source: "http".into(), + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains(r#""source":"http""#)); + } +} diff --git a/src-tauri/src/core/setup/common.rs b/src-tauri/src/core/setup/common.rs index 6c4b072bd..91916c4c8 100644 --- a/src-tauri/src/core/setup/common.rs +++ b/src-tauri/src/core/setup/common.rs @@ -4,5 +4,6 @@ pub fn platform( _app_handle: &AppHandle, _main_window: WebviewWindow, _preference_window: WebviewWindow, + _chat_window: WebviewWindow, ) { } diff --git a/src-tauri/src/core/setup/macos.rs b/src-tauri/src/core/setup/macos.rs index d61b1d4a9..100a650ec 100644 --- a/src-tauri/src/core/setup/macos.rs +++ b/src-tauri/src/core/setup/macos.rs @@ -17,6 +17,14 @@ tauri_panel! { } }) + panel!(NsChatPanel { + config: { + is_floating_panel: true, + can_become_key_window: false, + can_become_main_window: false + } + }) + panel_event!(NsPanelEventHandler { window_did_become_key(notification: &NSNotification) -> (), window_did_resign_key(notification: &NSNotification) -> (), @@ -29,6 +37,7 @@ pub fn platform( app_handle: &AppHandle, main_window: WebviewWindow, _preference_window: WebviewWindow, + chat_window: WebviewWindow, ) { let _ = app_handle.plugin(tauri_nspanel::init()); @@ -64,11 +73,10 @@ pub fn platform( let _ = blur_window.emit_to(target, WINDOW_BLUR_EVENT, true); }); + // 广播给所有窗口(含 chat),使 chat 能跟随主窗口移动/缩放 fn emit_position(window: &WebviewWindow) { - let target = EventTarget::labeled(MAIN_WINDOW_LABEL); - if let Ok(position) = window.outer_position() { - let _ = window.emit_to(target, WINDOW_MOVED_EVENT, position); + let _ = window.emit(WINDOW_MOVED_EVENT, position); } } @@ -76,10 +84,8 @@ pub fn platform( handler.window_did_resize(move |_| { emit_position(&resize_window); - let target = EventTarget::labeled(MAIN_WINDOW_LABEL); - if let Ok(size) = resize_window.inner_size() { - let _ = resize_window.emit_to(target, WINDOW_RESIZED_EVENT, size); + let _ = resize_window.emit(WINDOW_RESIZED_EVENT, size); } }); @@ -88,5 +94,21 @@ pub fn platform( emit_position(&move_window); }); + // chat 窗口:与猫同 level + 同 collection behavior,永不抢焦点 + if let Ok(chat_panel) = chat_window.to_panel::() { + chat_panel.set_level(PanelLevel::Dock.value()); + + chat_panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); + + chat_panel.set_collection_behavior( + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into(), + ); + } + // ponytail: 同层 order-front(chat 创建晚于 main,show 时在猫之上);若层级不准再抬高 PanelLevel + panel.set_event_handler(Some(handler.as_ref())); } diff --git a/src-tauri/src/core/setup/mod.rs b/src-tauri/src/core/setup/mod.rs index d761c7a81..ebf71c03d 100644 --- a/src-tauri/src/core/setup/mod.rs +++ b/src-tauri/src/core/setup/mod.rs @@ -16,9 +16,18 @@ pub fn default( app_handle: &AppHandle, main_window: WebviewWindow, preference_window: WebviewWindow, + chat_window: WebviewWindow, ) { #[cfg(debug_assertions)] main_window.open_devtools(); - platform(app_handle, main_window.clone(), preference_window.clone()); + #[cfg(debug_assertions)] + chat_window.open_devtools(); + + platform( + app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8fd07874..1f4202790 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,7 +23,16 @@ pub fn run() { let preference_window = app.get_webview_window(PREFERENCE_WINDOW_LABEL).unwrap(); - setup::default(&app_handle, main_window.clone(), preference_window.clone()); + let chat_window = app.get_webview_window("chat").unwrap(); + + setup::default( + &app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); + + core::server::start(&app_handle); Ok(()) }) @@ -39,6 +48,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_pinia::init()) + .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(prevent_default::init()) .plugin(tauri_plugin_single_instance::init( diff --git a/src-tauri/src/plugins/window/Cargo.toml b/src-tauri/src/plugins/window/Cargo.toml index 51956d543..74d670627 100644 --- a/src-tauri/src/plugins/window/Cargo.toml +++ b/src-tauri/src/plugins/window/Cargo.toml @@ -16,6 +16,7 @@ tauri-plugin.workspace = true [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel.workspace = true +objc2-app-kit = "0.3" [target."cfg(target_os = \"windows\")".dependencies] windows.workspace = true \ No newline at end of file diff --git a/src-tauri/src/plugins/window/build.rs b/src-tauri/src/plugins/window/build.rs index fa3487662..9bdb782e3 100644 --- a/src-tauri/src/plugins/window/build.rs +++ b/src-tauri/src/plugins/window/build.rs @@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[ "hide_window", "set_always_on_top", "set_taskbar_visibility", + "set_multi_screen_follow", ]; fn main() { diff --git a/src-tauri/src/plugins/window/permissions/default.toml b/src-tauri/src/plugins/window/permissions/default.toml index 4c72b735e..ffbb3b1e8 100644 --- a/src-tauri/src/plugins/window/permissions/default.toml +++ b/src-tauri/src/plugins/window/permissions/default.toml @@ -2,4 +2,4 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility"] +permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility", "allow-set-multi-screen-follow"] diff --git a/src-tauri/src/plugins/window/src/commands/linux.rs b/src-tauri/src/plugins/window/src/commands/linux.rs index d8c5567e2..91610c280 100644 --- a/src-tauri/src/plugins/window/src/commands/linux.rs +++ b/src-tauri/src/plugins/window/src/commands/linux.rs @@ -31,3 +31,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Linux 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/commands/macos.rs b/src-tauri/src/plugins/window/src/commands/macos.rs index 90c510cad..36fb1c8ff 100644 --- a/src-tauri/src/plugins/window/src/commands/macos.rs +++ b/src-tauri/src/plugins/window/src/commands/macos.rs @@ -1,14 +1,51 @@ #![allow(deprecated)] use crate::MAIN_WINDOW_LABEL; +use objc2_app_kit::NSWindowCollectionBehavior; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Runtime, WebviewWindow, command}; use tauri_nspanel::{CollectionBehavior, ManagerExt, PanelLevel}; +// 多屏跟随开关;开启后去掉 `.stationary()`,让 NSPanel 可在不同屏幕之间移动。 +static MULTI_SCREEN_FOLLOW: AtomicBool = AtomicBool::new(false); + enum MacOSPanelStatus { Show, Hide, SetAlwaysOnTop(bool), } +fn show_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } +} + +fn hide_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } +} + fn is_main_window(window: &WebviewWindow) -> bool { window.label() == MAIN_WINDOW_LABEL } @@ -27,24 +64,12 @@ fn set_macos_panel( MacOSPanelStatus::Show => { panel.show(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .can_join_all_spaces() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(show_collection_behavior()); } MacOSPanelStatus::Hide => { panel.hide(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .move_to_active_space() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(hide_collection_behavior()); } MacOSPanelStatus::SetAlwaysOnTop(always_on_top) => { if always_on_top { @@ -106,3 +131,24 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(app_handle: AppHandle, visible: bool) { let _ = app_handle.set_dock_visibility(visible); } + +#[command] +pub async fn set_multi_screen_follow( + app_handle: AppHandle, + window: WebviewWindow, + enabled: bool, +) { + if !is_main_window(&window) { + return; + } + + MULTI_SCREEN_FOLLOW.store(enabled, Ordering::SeqCst); + + let app_handle_clone = app_handle.clone(); + + let _ = app_handle.run_on_main_thread(move || { + if let Ok(panel) = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL) { + panel.set_collection_behavior(show_collection_behavior()); + } + }); +} diff --git a/src-tauri/src/plugins/window/src/commands/windows.rs b/src-tauri/src/plugins/window/src/commands/windows.rs index a9170b846..151db2dd2 100644 --- a/src-tauri/src/plugins/window/src/commands/windows.rs +++ b/src-tauri/src/plugins/window/src/commands/windows.rs @@ -82,3 +82,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Windows 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/lib.rs b/src-tauri/src/plugins/window/src/lib.rs index 94266e6a5..0f849da8f 100644 --- a/src-tauri/src/plugins/window/src/lib.rs +++ b/src-tauri/src/plugins/window/src/lib.rs @@ -14,6 +14,7 @@ pub fn init() -> TauriPlugin { commands::hide_window, commands::set_always_on_top, commands::set_taskbar_visibility, + commands::set_multi_screen_follow, ]) .build() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dee1a1363..39f4f6e68 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,6 +33,21 @@ "minWidth": 800, "minHeight": 600, "skipTaskbar": true + }, + { + "label": "chat", + "url": "index.html/#/chat", + "width": 200, + "height": 100, + "visible": false, + "transparent": true, + "decorations": false, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "maximizable": false, + "focus": false } ], "security": { diff --git a/src/App.vue b/src/App.vue index 321c4fb60..ac4a13588 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,8 @@ import { getAntdLocale } from './locales/index.ts' import { hideWindow, showWindow } from './plugins/window' import { useAppStore } from './stores/app' import { useCatStore } from './stores/cat' +import { useChatStore } from './stores/chat' +import { useChatHistoryStore } from './stores/chatHistory' import { useGeneralStore } from './stores/general' import { useModelStore } from './stores/model' import { useShortcutStore } from './stores/shortcut.ts' @@ -27,6 +29,8 @@ const modelStore = useModelStore() const catStore = useCatStore() const generalStore = useGeneralStore() const shortcutStore = useShortcutStore() +const chatStore = useChatStore() +const chatHistoryStore = useChatHistoryStore() const appWindow = getCurrentWebviewWindow() const { isRestored, restoreState } = useWindowState() const { darkAlgorithm, defaultAlgorithm } = theme @@ -42,6 +46,8 @@ onMounted(async () => { await generalStore.$tauri.start() await generalStore.init() await shortcutStore.$tauri.start() + await chatStore.$tauri.start() + await chatHistoryStore.$tauri.start() await restoreState() }) @@ -62,7 +68,15 @@ useTauriListen(LISTEN_KEY.HIDE_WINDOW, ({ payload }) => { }) useEventListener('unhandledrejection', ({ reason }) => { - const message = isString(reason) ? reason : JSON.stringify(reason) + let message: string + + if (isString(reason)) { + message = reason + } else if (reason instanceof Error) { + message = `${reason.name}: ${reason.message}\n${reason.stack ?? ''}` + } else { + message = JSON.stringify(reason) + } error(message) }) diff --git a/src/composables/useAppMenu.ts b/src/composables/useAppMenu.ts index 98741ba49..8202dc3a5 100644 --- a/src/composables/useAppMenu.ts +++ b/src/composables/useAppMenu.ts @@ -1,9 +1,10 @@ +import { emit } from '@tauri-apps/api/event' import { CheckMenuItem, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu' import { exit, relaunch } from '@tauri-apps/plugin-process' import { range } from 'es-toolkit' import { useI18n } from 'vue-i18n' -import { WINDOW_LABEL } from '@/constants' +import { LISTEN_KEY, WINDOW_LABEL } from '@/constants' import { showWindow } from '@/plugins/window' import { useCatStore } from '@/stores/cat' import { isMac } from '@/utils/platform' @@ -67,6 +68,13 @@ export function useAppMenu() { accelerator: isMac ? 'Cmd+,' : '', action: () => showWindow(WINDOW_LABEL.PREFERENCE), }), + MenuItem.new({ + text: t('composables.useAppMenu.labels.chatSetting'), + action: () => { + showWindow(WINDOW_LABEL.PREFERENCE) + emit(LISTEN_KEY.NAVIGATE_PREFERENCE_TAB, 'chat') + }, + }), MenuItem.new({ text: catStore.window.visible ? t('composables.useAppMenu.labels.hideCat') : t('composables.useAppMenu.labels.showCat'), action: () => { diff --git a/src/composables/useBark.ts b/src/composables/useBark.ts new file mode 100644 index 000000000..3482aba9a --- /dev/null +++ b/src/composables/useBark.ts @@ -0,0 +1,151 @@ +import { emit } from '@tauri-apps/api/event' +import { fetch } from '@tauri-apps/plugin-http' + +import type { BarkCryptoConfig, SSEEvent } from '@/utils/bark' + +import { LISTEN_KEY } from '@/constants' +import { useChatStore } from '@/stores/chat' +import { createSSEParser, resolveBarkText } from '@/utils/bark' + +// 与 bark-macOS 客户端一致的设备身份;服务端内置的 macos_sse provider 按这些值匹配 +const REGISTER_IDENTITY = { + device_token: null, + platform: 'macos', + app_id: 'me.fin.bark.macos', + provider_id: 'macos_sse', + topic: 'me.fin.bark.macos', +} + +const BACKOFF_SECONDS = [1, 2, 5, 10, 20, 30] + +// 模块级单例:SSE 连接只存在于 chat 窗口(应用生命周期内常驻);设置页仅调用 register() +let controller: AbortController | undefined +let attempt = 0 + +export function useBark() { + const chatStore = useChatStore() + + function base() { + return chatStore.bark.serverUrl.replace(/\/+$/, '') + } + + /** 注册设备:首次 device_key 留空由服务端签发;重复注册同 key 复用 stream_token */ + async function register() { + const res = await fetch(`${base()}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...REGISTER_IDENTITY, + device_key: chatStore.bark.deviceKey || undefined, + }), + }) + + const json = await res.json().catch(() => ({})) + + if (!res.ok) { + // 409 = 设备身份冲突;清空本地 key 重新注册可恢复 + throw new Error(json.message ?? `HTTP ${res.status}`) + } + + const data = json.data ?? json + const key = data.device_key ?? data.key + + if (!key || !data.stream_token) { + throw new Error('response missing device_key / stream_token') + } + + chatStore.bark.deviceKey = key + chatStore.bark.streamToken = data.stream_token + } + + function disconnect() { + controller?.abort() + controller = undefined + chatStore.barkStatus = 'idle' + } + + function connect() { + disconnect() + + const { enabled, deviceKey, streamToken } = chatStore.bark + if (!enabled || !deviceKey || !streamToken) return + + attempt = 0 + controller = new AbortController() + void runLoop(controller) + } + + async function runLoop(ctrl: AbortController) { + while (!ctrl.signal.aborted) { + try { + chatStore.barkStatus = attempt > 0 ? 'reconnecting' : 'connecting' + + // 不发 Last-Event-ID:服务端不回放,只收实时消息(设计决策:不补收离线消息) + const url = `${base()}/events/${chatStore.bark.deviceKey}?stream_token=${encodeURIComponent(chatStore.bark.streamToken)}` + const res = await fetch(url, { + headers: { Accept: 'text/event-stream' }, + signal: ctrl.signal, + }) + + if (res.status === 401 || res.status === 403) { + // token 失效:停止重连,避免无效循环打服务器;用户重新注册后配置 watch 会重建连接 + chatStore.barkStatus = 'unauthorized' + return + } + + if (!res.ok || !res.body) { + throw new Error(`http ${res.status}`) + } + + chatStore.barkStatus = 'connected' + attempt = 0 + + const feed = createSSEParser() + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + + for (const event of feed(value)) { + await handleEvent(event) + } + } + + // 服务端关闭连接(含同 key 被新连接踢下线)→ 走重连 + throw new Error('stream closed') + } catch { + if (ctrl.signal.aborted) return + + chatStore.barkStatus = 'reconnecting' + + // 指数退避 + 随机抖动,避免服务端重启时惊群 + const seconds = BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)] + attempt += 1 + await new Promise(resolve => setTimeout(resolve, seconds * 1000 + Math.random() * 800)) + } + } + } + + async function handleEvent(event: SSEEvent) { + if (event.event !== 'notification') return + + try { + const notification = JSON.parse(event.data) + // 解密配置每条消息实时读取,改密钥无需重连 + const cryptoConfig: BarkCryptoConfig | undefined = chatStore.bark.cryptoKey + ? { mode: chatStore.bark.cryptoMode, key: chatStore.bark.cryptoKey, iv: chatStore.bark.cryptoIv } + : undefined + const text = await resolveBarkText(notification, cryptoConfig) + + if (!text) return + + await emit(LISTEN_KEY.SHOW_CHAT, { text, source: 'bark' }) + } catch (error) { + // 解密失败 / 非法 JSON:丢弃该条,不影响连接 + console.warn('[bark] drop message:', error) + } + } + + return { register, connect, disconnect } +} diff --git a/src/composables/useChat.ts b/src/composables/useChat.ts new file mode 100644 index 000000000..28a51b9c3 --- /dev/null +++ b/src/composables/useChat.ts @@ -0,0 +1,13 @@ +import { emit } from '@tauri-apps/api/event' + +import { LISTEN_KEY } from '@/constants' + +/** + * 全局广播一条气泡。任意页面/composable 可调用。 + * 总开关 / 默认时长 / 定位 / 动画全部由 chat 页统一处理(见 src/pages/chat/index.vue)。 + * @param text 气泡文本 + * @param duration 毫秒;省略时由 chat 页用默认时长兜底;0 表示常驻。 + */ +export function say(text: string, duration?: number) { + return emit(LISTEN_KEY.SHOW_CHAT, { text, duration }) +} diff --git a/src/composables/useMultiScreenFollow.ts b/src/composables/useMultiScreenFollow.ts new file mode 100644 index 000000000..f1580d045 --- /dev/null +++ b/src/composables/useMultiScreenFollow.ts @@ -0,0 +1,103 @@ +import type { Monitor } from '@tauri-apps/api/window' + +import { PhysicalPosition } from '@tauri-apps/api/dpi' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { cursorPosition, monitorFromPoint } from '@tauri-apps/api/window' +import { useIntervalFn } from '@vueuse/core' +import { watch } from 'vue' + +import { setMultiScreenFollow } from '@/plugins/window' +import { useCatStore } from '@/stores/cat' +import { useGeneralStore } from '@/stores/general' +import { isMac } from '@/utils/platform' + +const POLL_INTERVAL_MS = 800 + +function monitorKey(mon: Monitor) { + return `${mon.position.x},${mon.position.y},${mon.size.width},${mon.size.height}` +} + +export function useMultiScreenFollow() { + if (!isMac) return + + const generalStore = useGeneralStore() + const catStore = useCatStore() + const appWindow = getCurrentWebviewWindow() + + // 记忆每个屏幕上窗口最后停留的相对偏移(相对屏幕原点)。 + // 大屏 → 小屏时偏移会被裁剪到小屏边界,若直接用这个被裁剪过的偏移再换算回大屏, + // 用户最初的位置就会丢失。缓存能在回到原屏幕时恢复用户实际设定的位置。 + const monitorOffsets = new Map() + + const tick = async () => { + if (!catStore.window.visible) return + + const [winPos, winSize, cursor, scaleFactor] = await Promise.all([ + appWindow.outerPosition(), + appWindow.outerSize(), + cursorPosition(), + appWindow.scaleFactor(), + ]) + + const cursorLogical = cursor.toLogical(scaleFactor) + const winCenterLogical = new PhysicalPosition( + winPos.x + Math.floor(winSize.width / 2), + winPos.y + Math.floor(winSize.height / 2), + ).toLogical(scaleFactor) + + const [cursorMon, winMon] = await Promise.all([ + monitorFromPoint(cursorLogical.x, cursorLogical.y), + monitorFromPoint(winCenterLogical.x, winCenterLogical.y), + ]) + + if (!cursorMon || !winMon) return + + // 始终更新当前所在屏幕的偏移记忆,捕捉用户在屏内手动拖动后的最新位置。 + monitorOffsets.set(monitorKey(winMon), { + x: winPos.x - winMon.position.x, + y: winPos.y - winMon.position.y, + }) + + const sameMonitor + = winMon.position.x === cursorMon.position.x + && winMon.position.y === cursorMon.position.y + && winMon.size.width === cursorMon.size.width + && winMon.size.height === cursorMon.size.height + + if (sameMonitor) return + + // 优先使用目标屏幕的历史偏移;首次进入则沿用源屏幕的偏移作为初值。 + const remembered = monitorOffsets.get(monitorKey(cursorMon)) + const offsetX = remembered?.x ?? (winPos.x - winMon.position.x) + const offsetY = remembered?.y ?? (winPos.y - winMon.position.y) + + const minX = cursorMon.position.x + const maxX = cursorMon.position.x + cursorMon.size.width - winSize.width + const minY = cursorMon.position.y + const maxY = cursorMon.position.y + cursorMon.size.height - winSize.height + + const targetX = Math.max(minX, Math.min(cursorMon.position.x + offsetX, maxX)) + const targetY = Math.max(minY, Math.min(cursorMon.position.y + offsetY, maxY)) + + if (targetX === winPos.x && targetY === winPos.y) return + + await appWindow.setPosition(new PhysicalPosition(targetX, targetY)) + } + + const { pause, resume } = useIntervalFn(tick, POLL_INTERVAL_MS, { immediate: false }) + + watch( + () => generalStore.app.multiScreenFollow, + async (enabled) => { + await setMultiScreenFollow(enabled) + + if (enabled) { + resume() + } else { + monitorOffsets.clear() + pause() + } + }, + { immediate: true }, + ) +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 2ebadd537..ec2e02840 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -10,6 +10,9 @@ export const LISTEN_KEY = { GAMEPAD_CHANGED: 'gamepad-changed', START_MOTION: 'start-motion', SET_EXPRESSION: 'set-expression', + SHOW_CHAT: 'show-chat', + UPDATE_CONFIG: 'update-config', + NAVIGATE_PREFERENCE_TAB: 'navigate-preference-tab', } export const INVOKE_KEY = { @@ -30,4 +33,5 @@ export const LANGUAGE = { export const WINDOW_LABEL = { MAIN: 'main', PREFERENCE: 'preference', + CHAT: 'chat', } as const diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 15172d14e..12fa4fdc7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Hi there~", "hints": { "redrawing": "Redrawing...", "switching": "Switching..." @@ -50,6 +51,7 @@ "launchOnStartup": "Launch on Startup", "showTaskbarIcon": "Show Taskbar Icon", "showTrayIcon": "Show Tray Icon", + "multiScreenFollow": "Follow Active Display", "appearanceSettings": "Appearance Settings", "themeMode": "Theme Mode", "language": "Language", @@ -67,6 +69,7 @@ "hints": { "showTaskbarIcon": "When enabled, the window can be captured via OBS Studio.", "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", + "multiScreenFollow": "When enabled, the cat automatically moves to whichever display the cursor is on.", "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", "inputMonitoringPermissionGuide": "Input Monitoring permission is required for the app to receive keyboard and mouse events from the system.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. If BongoCat already appears in the list, select it and click the \"-\" button to remove it.\n3. Click the \"+\" button to find and add BongoCat.\n4. Restart the app for the permission to take effect.", "administratorPermission": "Running the app as administrator helps capture some system-level keys and input events more reliably.", @@ -129,6 +132,70 @@ "alwaysOnTop": "Toggle whether the cat window stays on top." } }, + "chat": { + "title": "Chat", + "labels": { + "basic": "Bubble settings", + "enabled": "Enable bubble", + "duration": "Default duration", + "textColor": "Text color", + "fontSize": "Font size", + "bgColor": "Bubble color", + "bgOpacity": "Background opacity", + "http": "HTTP endpoint", + "httpEnabled": "Enable HTTP endpoint", + "httpPort": "Listen port", + "httpToken": "Auth token", + "debug": "Debug mode", + "testText": "Test text", + "testShow": "Show", + "httpDocs": "API reference", + "history": "Message history", + "bark": "Bark Push", + "barkEnabled": "Enable Bark", + "barkServerUrl": "Server URL", + "barkRegister": "Register Device", + "barkDeviceKey": "Device Key", + "barkStatus": "Connection Status", + "barkCryptoKey": "Decryption Key", + "barkCryptoIv": "IV", + "barkCryptoMode": "Cipher Mode" + }, + "hints": { + "enabled": "Master switch. When off, no bubble is shown.", + "http": "Expose a local HTTP endpoint so external tools (e.g. curl) can push bubbles. Bound to 127.0.0.1 only.", + "httpRestart": "Changing port/switch/token requires an app restart to take effect.", + "debug": "Expands the test area below for manually triggering a bubble.", + "httpDocs": "Style params on /say apply to a single bubble only; /config writes the saved defaults.", + "history": "Keeps the latest 500 bubble messages, preserved across restarts.", + "bark": "Connect to a self-hosted htnanako/bark-server via SSE to receive Bark pushes as chat bubbles. Messages sent while offline are not replayed.", + "barkRegister": "Register first to get a device key, then use it to push messages to the server.", + "barkCrypto": "Leave empty to skip decryption; encrypted messages are dropped when no key is configured." + }, + "history": { + "view": "View", + "time": "Time", + "status": "Status", + "source": "Source", + "content": "Content", + "action": "Action", + "detail": "Detail", + "shown": "Shown", + "skipped": "Skipped", + "http": "HTTP", + "internal": "Internal", + "filterStatus": "All statuses", + "filterSource": "All sources", + "bark": "Bark" + }, + "barkStatus": { + "idle": "Idle", + "connecting": "Connecting", + "connected": "Connected", + "reconnecting": "Reconnecting", + "unauthorized": "Re-register required" + } + }, "about": { "title": "About", "labels": { @@ -178,6 +245,7 @@ "useAppMenu": { "labels": { "preference": "Preferences...", + "chatSetting": "Chat Settings", "hideCat": "Hide Cat", "showCat": "Show Cat", "passThrough": "Pass Through", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index d1c10fb15..57c708cab 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Olá~", "hints": { "redrawing": "Redimensionando...", "switching": "Alternando..." @@ -50,6 +51,7 @@ "launchOnStartup": "Iniciar na inicialização", "showTaskbarIcon": "Mostrar ícone na barra de tarefas", "showTrayIcon": "Mostrar ícone na bandeja", + "multiScreenFollow": "Seguir o monitor ativo", "appearanceSettings": "Configurações de aparência", "themeMode": "Tema", "language": "Idiomas", @@ -67,6 +69,7 @@ "hints": { "showTaskbarIcon": "Uma vez ativado, você pode capturar a janela via OBS Studio.", "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", + "multiScreenFollow": "Quando ativado, o BongoCat se move automaticamente para o monitor onde está o cursor.", "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", "inputMonitoringPermissionGuide": "A permissão de Monitoramento de Entrada é necessária para que o aplicativo receba eventos de teclado e mouse do sistema.\n\nPassos:\n1. Abra as Configurações do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Se o BongoCat já estiver na lista, selecione-o e clique no botão \"-\" para removê-lo.\n3. Clique no botão \"+\" para localizar e adicionar o BongoCat.\n4. Reinicie o aplicativo para que a permissão entre em vigor.", "administratorPermission": "Executar o aplicativo como administrador ajuda a capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade.", @@ -129,6 +132,70 @@ "alwaysOnTop": "Alternar se a janela do gato permanece no topo." } }, + "chat": { + "title": "Chat", + "labels": { + "basic": "Configurações do balão", + "enabled": "Ativar balão", + "duration": "Duração padrão", + "textColor": "Cor do texto", + "fontSize": "Tamanho da fonte", + "bgColor": "Cor do balão", + "bgOpacity": "Opacidade do fundo", + "http": "Endpoint HTTP", + "httpEnabled": "Ativar endpoint HTTP", + "httpPort": "Porta de escuta", + "httpToken": "Token de autenticação", + "debug": "Modo de depuração", + "testText": "Texto de teste", + "testShow": "Mostrar", + "httpDocs": "Referência da API", + "history": "Histórico de mensagens", + "bark": "Push Bark", + "barkEnabled": "Ativar Bark", + "barkServerUrl": "URL do servidor", + "barkRegister": "Registrar dispositivo", + "barkDeviceKey": "Chave do dispositivo", + "barkStatus": "Status da conexão", + "barkCryptoKey": "Chave de descriptografia", + "barkCryptoIv": "IV", + "barkCryptoMode": "Modo de cifra" + }, + "hints": { + "enabled": "Interruptor geral. Quando desligado, nenhum balão é exibido.", + "http": "Expõe um endpoint HTTP local para ferramentas externas (ex.: curl) enviarem balões. Vinculado apenas a 127.0.0.1.", + "httpRestart": "Alterar porta/interruptor/token exige reiniciar o app para ter efeito.", + "debug": "Expande a área de teste abaixo para disparar um balão manualmente.", + "httpDocs": "Os parâmetros de estilo de /say afetam apenas um balão; /config grava os padrões salvos.", + "history": "Mantém as últimas 500 mensagens de balão, preservadas entre reinicializações.", + "bark": "Conecte-se a um htnanako/bark-server auto-hospedado via SSE para receber pushes do Bark como balões. Mensagens enviadas offline não são reenviadas.", + "barkRegister": "Registre primeiro para obter a chave do dispositivo; use-a para enviar mensagens ao servidor.", + "barkCrypto": "Deixe vazio para não descriptografar; mensagens criptografadas são descartadas sem chave configurada." + }, + "history": { + "view": "Ver", + "time": "Hora", + "status": "Status", + "source": "Origem", + "content": "Conteúdo", + "action": "Ação", + "detail": "Detalhes", + "shown": "Exibida", + "skipped": "Ignorada", + "http": "HTTP", + "internal": "Interno", + "filterStatus": "Todos os status", + "filterSource": "Todas as origens", + "bark": "Bark" + }, + "barkStatus": { + "idle": "Inativo", + "connecting": "Conectando", + "connected": "Conectado", + "reconnecting": "Reconectando", + "unauthorized": "Necessário registrar novamente" + } + }, "about": { "title": "Sobre", "labels": { @@ -178,6 +245,7 @@ "useAppMenu": { "labels": { "preference": "Preferências...", + "chatSetting": "Configurações de chat", "hideCat": "Ocultar Gato", "showCat": "Mostrar Gato", "passThrough": "Janela Transparente", diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index fd32d4e5a..4d71f65e6 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Xin chào~", "hints": { "redrawing": "Đang đổi kích thước...", "switching": "Đang chuyển đổi..." @@ -50,6 +51,7 @@ "launchOnStartup": "Khởi động cùng hệ thống", "showTaskbarIcon": "Hiện biểu tượng trên thanh tác vụ (icon taskbar)", "showTrayIcon": "Hiện biểu tượng trên khay hệ thống (tray)", + "multiScreenFollow": "Theo màn hình hiện tại", "appearanceSettings": "Cài đặt giao diện", "themeMode": "Giao diện", "language": "Ngôn ngữ", @@ -67,6 +69,7 @@ "hints": { "showTaskbarIcon": "Bật để có thể quay cửa sổ qua OBS.", "showTrayIcon": "Bật để hiện biểu tượng ứng dụng trên khay hệ thống.", + "multiScreenFollow": "Khi bật, BongoCat sẽ tự động di chuyển sang màn hình đang đặt con trỏ chuột.", "inputMonitoringPermission": "Bật quyền giám sát để nhận sự kiện bàn phím và chuột từ hệ thống nhằm phản hồi thao tác của bạn.", "inputMonitoringPermissionGuide": "Quyền Giám sát đầu vào là cần thiết để ứng dụng có thể nhận các sự kiện bàn phím và chuột từ hệ thống.\n\nCác bước thực hiện:\n1. Mở Cài đặt hệ thống → Quyền riêng tư & Bảo mật → Giám sát đầu vào.\n2. Nếu BongoCat đã có trong danh sách, hãy chọn nó và nhấn nút \"-\" để xóa.\n3. Nhấn nút \"+\" để tìm và thêm BongoCat.\n4. Khởi động lại ứng dụng để quyền chính thức có hiệu lực.", "administratorPermission": "Chạy ứng dụng với quyền quản trị viên giúp bắt một số phím và sự kiện nhập liệu cấp hệ thống ổn định hơn.", @@ -129,6 +132,70 @@ "alwaysOnTop": "Bật/Tắt luôn giữ cửa sổ mèo trên cùng." } }, + "chat": { + "title": "Chat", + "labels": { + "basic": "Cài đặt bong bóng", + "enabled": "Bật bong bóng", + "duration": "Thời lượng mặc định", + "textColor": "Màu chữ", + "fontSize": "Cỡ chữ", + "bgColor": "Màu nền bong bóng", + "bgOpacity": "Độ trong suốt nền", + "http": "Giao diện HTTP", + "httpEnabled": "Bật giao diện HTTP", + "httpPort": "Cổng lắng nghe", + "httpToken": "Token xác thực", + "debug": "Chế độ gỡ lỗi", + "testText": "Văn bản thử", + "testShow": "Hiển thị", + "httpDocs": "Hướng dẫn gọi API", + "history": "Lịch sử tin nhắn", + "bark": "Đẩy Bark", + "barkEnabled": "Bật Bark", + "barkServerUrl": "Địa chỉ máy chủ", + "barkRegister": "Đăng ký thiết bị", + "barkDeviceKey": "Khóa thiết bị", + "barkStatus": "Trạng thái kết nối", + "barkCryptoKey": "Khóa giải mã", + "barkCryptoIv": "IV", + "barkCryptoMode": "Chế độ mã hóa" + }, + "hints": { + "enabled": "Công tắc tổng. Khi tắt, không bong bóng nào hiển thị.", + "http": "Mở một giao diện HTTP cục bộ để công cụ ngoài (vd: curl) đẩy bong bóng. Chỉ lắng nghe 127.0.0.1.", + "httpRestart": "Đổi cổng/công tắc/token cần khởi động lại ứng dụng để có hiệu lực.", + "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công.", + "httpDocs": "Tham số kiểu dáng của /say chỉ áp dụng cho một bong bóng; /config ghi vào giá trị mặc định đã lưu.", + "history": "Lưu 500 tin nhắn bong bóng gần nhất, giữ lại sau khi khởi động lại.", + "bark": "Kết nối tới htnanako/bark-server tự triển khai qua SSE để nhận thông báo Bark dưới dạng bong bóng. Tin nhắn gửi khi ngoại tuyến sẽ không được phát lại.", + "barkRegister": "Đăng ký trước để nhận khóa thiết bị; dùng nó để gửi tin nhắn tới máy chủ.", + "barkCrypto": "Để trống nếu không giải mã; tin nhắn mã hóa sẽ bị bỏ qua khi chưa cấu hình khóa." + }, + "history": { + "view": "Xem", + "time": "Thời gian", + "status": "Trạng thái", + "source": "Nguồn", + "content": "Nội dung", + "action": "Thao tác", + "detail": "Chi tiết", + "shown": "Đã hiển thị", + "skipped": "Đã bỏ qua", + "http": "HTTP", + "internal": "Nội bộ", + "filterStatus": "Tất cả trạng thái", + "filterSource": "Tất cả nguồn", + "bark": "Bark" + }, + "barkStatus": { + "idle": "Chưa bật", + "connecting": "Đang kết nối", + "connected": "Đã kết nối", + "reconnecting": "Đang kết nối lại", + "unauthorized": "Cần đăng ký lại" + } + }, "about": { "title": "Giới thiệu", "labels": { @@ -178,6 +245,7 @@ "useAppMenu": { "labels": { "preference": "Tùy chỉnh...", + "chatSetting": "Cài đặt trò chuyện", "hideCat": "Ẩn Mèo", "showCat": "Hiện Mèo", "passThrough": "Click xuyên", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index e1a3e4dcc..f111146f0 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "你好呀~", "hints": { "redrawing": "重绘中...", "switching": "切换中..." @@ -50,6 +51,7 @@ "launchOnStartup": "开机自启动", "showTaskbarIcon": "显示任务栏图标", "showTrayIcon": "显示托盘图标", + "multiScreenFollow": "跟随当前屏幕", "appearanceSettings": "外观设置", "themeMode": "主题模式", "language": "语言", @@ -67,6 +69,7 @@ "hints": { "showTaskbarIcon": "启用后,即可通过 OBS Studio 捕获窗口。", "showTrayIcon": "启用后,在系统托盘中显示应用图标。", + "multiScreenFollow": "启用后,BongoCat 会自动移动到鼠标所在的显示器。", "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", "inputMonitoringPermissionGuide": "开启输入监控权限后,应用才能接收系统的键盘和鼠标事件,从而响应你的操作。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 若列表中已存在 BongoCat,请先选中它,点击「-」按钮将其删除。\n3. 点击「+」按钮,找到并添加 BongoCat 应用。\n4. 重启应用,以确保权限正式生效。", "administratorPermission": "以管理员身份运行应用,可以更稳定地捕获部分系统级按键与输入事件。", @@ -129,6 +132,70 @@ "alwaysOnTop": "切换猫咪窗口是否置顶。" } }, + "chat": { + "title": "Chat", + "labels": { + "basic": "气泡设置", + "enabled": "启用气泡", + "duration": "默认展示时长", + "textColor": "文字颜色", + "fontSize": "文字大小", + "bgColor": "气泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 接口", + "httpEnabled": "启用 HTTP 接口", + "httpPort": "监听端口", + "httpToken": "校验 Token", + "debug": "调试模式", + "testText": "测试文本", + "testShow": "展示", + "httpDocs": "调用说明", + "history": "历史消息", + "bark": "Bark 推送", + "barkEnabled": "启用 Bark", + "barkServerUrl": "服务器地址", + "barkRegister": "注册设备", + "barkDeviceKey": "设备 Key", + "barkStatus": "连接状态", + "barkCryptoKey": "解密密钥", + "barkCryptoIv": "IV", + "barkCryptoMode": "加密模式" + }, + "hints": { + "enabled": "总开关,关闭后所有气泡都不显示。", + "http": "开启一个本地 HTTP 接口,供外部工具(如 curl)推送气泡。仅监听 127.0.0.1。", + "httpRestart": "改动端口/开关/Token 后需重启应用生效。", + "debug": "开启后展开下方测试区,可手动触发气泡用于验证。", + "httpDocs": "消息接口 /say 的样式参数仅对本条气泡生效;控制接口 /config 写入默认值并持久保存。", + "history": "记录最近 500 条气泡消息,重启后保留。", + "bark": "连接自托管 htnanako/bark-server(SSE),实时接收 Bark 推送并弹出气泡。断线期间的消息不补收。", + "barkRegister": "首次使用先注册获取设备 Key;用它向服务器推送消息。", + "barkCrypto": "留空则不解密;收到加密消息且未配置密钥时丢弃该条。" + }, + "history": { + "view": "查看", + "time": "时间", + "status": "状态", + "source": "来源", + "content": "内容", + "action": "操作", + "detail": "详情", + "shown": "已展示", + "skipped": "未展示", + "http": "HTTP", + "internal": "内部", + "filterStatus": "全部状态", + "filterSource": "全部来源", + "bark": "Bark" + }, + "barkStatus": { + "idle": "未启用", + "connecting": "连接中", + "connected": "已连接", + "reconnecting": "重连中", + "unauthorized": "需要重新注册" + } + }, "about": { "title": "关于", "labels": { @@ -178,6 +245,7 @@ "useAppMenu": { "labels": { "preference": "偏好设置...", + "chatSetting": "聊天设置", "hideCat": "隐藏猫咪", "showCat": "显示猫咪", "passThrough": "窗口穿透", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index db82dd4b9..b36dc02a3 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "你好呀~", "hints": { "redrawing": "重繪中…", "switching": "切換中…" @@ -50,6 +51,7 @@ "launchOnStartup": "開機自動啟動", "showTaskbarIcon": "顯示工作列圖示", "showTrayIcon": "顯示托盤圖示", + "multiScreenFollow": "跟隨目前螢幕", "appearanceSettings": "外觀設定", "themeMode": "主題模式", "language": "語言", @@ -67,6 +69,7 @@ "hints": { "showTaskbarIcon": "啟用後,即可透過 OBS Studio 擷取視窗。", "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", + "multiScreenFollow": "啟用後,BongoCat 會自動移動到滑鼠所在的螢幕。", "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", "inputMonitoringPermissionGuide": "開啟輸入監控權限後,應用程式才能接收系統的鍵盤和滑鼠事件,進而回應你的操作。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 若清單中已存在 BongoCat,請先選取它,點擊「-」按鈕將其刪除。\n3. 點擊「+」按鈕,找到並新增 BongoCat 應用程式。\n4. 重新啟動應用程式,以確保權限正式生效。", "administratorPermission": "以系統管理員身分執行應用程式,可以更穩定地擷取部分系統層級按鍵與輸入事件。", @@ -129,6 +132,70 @@ "alwaysOnTop": "切換貓咪視窗是否置頂。" } }, + "chat": { + "title": "Chat", + "labels": { + "basic": "氣泡設定", + "enabled": "啟用氣泡", + "duration": "預設顯示時長", + "textColor": "文字顏色", + "fontSize": "文字大小", + "bgColor": "氣泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 介面", + "httpEnabled": "啟用 HTTP 介面", + "httpPort": "監聽連接埠", + "httpToken": "驗證 Token", + "debug": "除錯模式", + "testText": "測試文字", + "testShow": "顯示", + "httpDocs": "呼叫說明", + "history": "歷史訊息", + "bark": "Bark 推送", + "barkEnabled": "啟用 Bark", + "barkServerUrl": "伺服器位址", + "barkRegister": "註冊裝置", + "barkDeviceKey": "裝置 Key", + "barkStatus": "連線狀態", + "barkCryptoKey": "解密金鑰", + "barkCryptoIv": "IV", + "barkCryptoMode": "加密模式" + }, + "hints": { + "enabled": "總開關,關閉後所有氣泡都不顯示。", + "http": "開啟一個本機 HTTP 介面,供外部工具(如 curl)推送氣泡。僅監聽 127.0.0.1。", + "httpRestart": "變更連接埠/開關/Token 後需重新啟動應用程式才會生效。", + "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。", + "httpDocs": "訊息介面 /say 的樣式參數僅對本條氣泡生效;控制介面 /config 寫入預設值並持久保存。", + "history": "記錄最近 500 則氣泡訊息,重新啟動後保留。", + "bark": "連接自架 htnanako/bark-server(SSE),即時接收 Bark 推送並彈出氣泡。斷線期間的訊息不補收。", + "barkRegister": "首次使用先註冊取得裝置 Key;用它向伺服器推送訊息。", + "barkCrypto": "留空則不解密;收到加密訊息且未設定金鑰時捨棄該條。" + }, + "history": { + "view": "檢視", + "time": "時間", + "status": "狀態", + "source": "來源", + "content": "內容", + "action": "操作", + "detail": "詳情", + "shown": "已顯示", + "skipped": "未顯示", + "http": "HTTP", + "internal": "內部", + "filterStatus": "全部狀態", + "filterSource": "全部來源", + "bark": "Bark" + }, + "barkStatus": { + "idle": "未啟用", + "connecting": "連線中", + "connected": "已連線", + "reconnecting": "重連中", + "unauthorized": "需要重新註冊" + } + }, "about": { "title": "關於", "labels": { @@ -178,6 +245,7 @@ "useAppMenu": { "labels": { "preference": "偏好設定…", + "chatSetting": "聊天設定", "hideCat": "隱藏貓咪", "showCat": "顯示貓咪", "passThrough": "視窗穿透", diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue new file mode 100644 index 000000000..480fd876b --- /dev/null +++ b/src/pages/chat/index.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/src/pages/main/index.vue b/src/pages/main/index.vue index 712f59746..c331f61c3 100644 --- a/src/pages/main/index.vue +++ b/src/pages/main/index.vue @@ -11,11 +11,14 @@ import { useDebounceFn, useEventListener } from '@vueuse/core' import { round } from 'es-toolkit' import { nth } from 'es-toolkit/compat' import { onMounted, onUnmounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' import { useAppMenu } from '@/composables/useAppMenu' +import { say } from '@/composables/useChat' import { useDevice } from '@/composables/useDevice' import { useGamepad } from '@/composables/useGamepad' import { useModel } from '@/composables/useModel' +import { useMultiScreenFollow } from '@/composables/useMultiScreenFollow' import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY } from '@/constants' import { hideWindow, setAlwaysOnTop, setTaskbarVisibility, showWindow } from '@/plugins/window' @@ -35,10 +38,14 @@ const catStore = useCatStore() const { getBaseMenu, getExitMenu } = useAppMenu() const modelStore = useModelStore() const generalStore = useGeneralStore() +const { t } = useI18n() +let greeted = false const resizing = ref(false) const backgroundImagePath = ref() const { stickActive } = useGamepad() +useMultiScreenFollow() + onMounted(startListening) onUnmounted(handleDestroy) @@ -84,6 +91,11 @@ watch(() => modelStore.currentModel, async (model) => { } modelStore.modelReady = true + + if (!greeted) { + greeted = true + say(t('pages.main.greeting')) + } }, { deep: true, immediate: true }) watch([() => catStore.window.scale, modelSize], async ([scale, modelSize]) => { diff --git a/src/pages/preference/components/chat/components/bark-settings/index.vue b/src/pages/preference/components/chat/components/bark-settings/index.vue new file mode 100644 index 000000000..2803bdaec --- /dev/null +++ b/src/pages/preference/components/chat/components/bark-settings/index.vue @@ -0,0 +1,111 @@ + + + diff --git a/src/pages/preference/components/chat/components/history-modal/index.vue b/src/pages/preference/components/chat/components/history-modal/index.vue new file mode 100644 index 000000000..f39fc0e47 --- /dev/null +++ b/src/pages/preference/components/chat/components/history-modal/index.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/pages/preference/components/chat/index.vue b/src/pages/preference/components/chat/index.vue new file mode 100644 index 000000000..e32f4a34c --- /dev/null +++ b/src/pages/preference/components/chat/index.vue @@ -0,0 +1,189 @@ + + + diff --git a/src/pages/preference/components/general/index.vue b/src/pages/preference/components/general/index.vue index 3a0e863b3..27b6f12ce 100644 --- a/src/pages/preference/components/general/index.vue +++ b/src/pages/preference/components/general/index.vue @@ -51,6 +51,14 @@ watch(() => generalStore.app.autostart, async (value) => { > + + + + diff --git a/src/pages/preference/index.vue b/src/pages/preference/index.vue index 1520be135..f84623c89 100644 --- a/src/pages/preference/index.vue +++ b/src/pages/preference/index.vue @@ -5,7 +5,9 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import UpdateApp from '@/components/update-app/index.vue' +import { useTauriListen } from '@/composables/useTauriListen' import { useTray } from '@/composables/useTray' +import { LISTEN_KEY } from '@/constants' import { useAppStore } from '@/stores/app' import { useGeneralStore } from '@/stores/general' import { useModelStore } from '@/stores/model' @@ -13,6 +15,7 @@ import { isMac } from '@/utils/platform' import About from './components/about/index.vue' import Cat from './components/cat/index.vue' +import Chat from './components/chat/index.vue' import General from './components/general/index.vue' import Model from './components/model/index.vue' import Shortcut from './components/shortcut/index.vue' @@ -54,6 +57,12 @@ const menus = computed(() => [ icon: 'i-solar:keyboard-bold', component: Shortcut, }, + { + key: 'chat', + label: t('pages.preference.chat.title'), + icon: 'i-solar:chat-round-bold', + component: Chat, + }, { key: 'about', label: t('pages.preference.about.title'), @@ -61,6 +70,14 @@ const menus = computed(() => [ component: About, }, ]) + +useTauriListen(LISTEN_KEY.NAVIGATE_PREFERENCE_TAB, ({ payload }) => { + const index = menus.value.findIndex(item => item.key === payload) + + if (index >= 0) { + current.value = index + } +})