chore: 0.0.8 更新(本地模型、小米 MiMo 供应商、体验优化与多项修复)

This commit is contained in:
crosswk committed 2026-07-08 17:30:26 +08:00
1 parent 241d83762c
commit ff035773e5
68 files changed
+2488 -1926

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# 更新日志
本文件记录 SayIt 各版本的功能变更、Bug 修复与优化。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
变更分类:`新增` / `修复` / `优化` / `变更` / `移除` / `安全`。
---
## [0.0.8] - 2026-07-08
### 新增
- 新增「录音时静音系统声音」开关(默认关闭):按住说话时临时静音系统输出,避免外放声音被麦克风录入,松开即恢复。
- 润色模式支持自定义快捷键切换:为任意润色模式设置组合键(如 Alt+1),全局按下即可切换,切换时悬浮窗即时提示。
- 文本替换支持批量添加:粘贴多行内容(支持逗号、制表符、`=>` 分隔)一次性导入多条规则,已存在的原文会更新。
- 热键支持更多按键:菜单键、Pause、ScrollLock、Insert,便于避开易冲突的按键。
- 免提组合键(如 Alt+字母)设置时增加冲突提示,若已被其他程序占用会提醒更换。
- 关于页面新增「本次更新」板块,展示当前版本的主要更新内容。
- 服务器模式的服务地址增加说明提示(悬停信息图标查看),介绍默认体验服务器的用途与替代方案。
- 本地识别新增 Qwen3-ASR 0.6B 模型:支持多语种、准确度更高,适合对识别质量要求高的场景。
- 本地模型列表新增速度 / 准确度评分与「推荐」标识,方便按需选择合适的模型。
- 本地识别支持热词:使用 Qwen3-ASR 时,热词可提升专有名词与行业术语的识别准确率。
- 云 API 语音识别新增「小米 MiMo」供应商(mimo-v2.5-asr):中英文与多种方言,自动标点,仅需填入 API Key。
- AI 润色新增「小米 MiMo」供应商(mimo-v2.5 / mimo-v2.5-pro),默认使用 mimo-v2.5。
### 优化
- 去除悬浮窗胶囊与卡片的投影,消除窗口边缘出现的灰色残影。
- 标题栏「AI 整理」按钮 hover 时显示当前润色模式;快捷键切换后 AI 整理页高亮实时更新。
- 意见反馈附带更多排错信息(当前应用/窗口/进程、识别参数、客户端环境),随「最后的转录」开关控制是否附带;不含音频与 IP。
- 首次安装直接进入设置向导,不再短暂闪现主页。
- 服务器模式下未连接后端时,按免提键改为显示简短告警,不再弹出无法关闭的悬浮窗。
- 托盘图标提示语更新为「随口说,出色写」。
- 长语音(超过约 30 秒)本地识别自动在停顿处分段处理,避免超长音频卡死或识别异常。
- 本地模型下载更稳更快:支持多下载源自动切换、断点续传,国内网络自动走加速通道。
- 小米 MiMo AI 润色默认关闭深度思考,回复更快。
- AI 供应商点击模型标签即刻生效并保存,无需再点保存按钮。
### 修复
- 修复在 CMD 中运行 Claude Code 等程序时无法插入文本的问题。
- 修复开启「录音时静音系统声音」后,按下热键没有就绪提示音的问题。
- 修复设置向导中更换热键后,测试仍需按默认键才生效的问题。
- 修复设置向导中更换免提热键后,首页提示仍显示旧按键、需切换页面才刷新的问题。
- 修复云 API 语音识别切换供应商时误带入其它供应商 API Key 的问题,各供应商密钥现独立保存。
- 修复 AI 润色实际运行、但未改动文本时,历史记录不显示所用 AI 供应商与模型的问题。
- 修复修改服务地址(含填错地址)后,左下角连接状态未刷新、仍显示已连接的问题。
- 修复同时打开两个客户端导致插入两份重复文本的问题,现改为单实例,二次启动会唤起已有窗口。
- 修复远程桌面等场景下,历史记录音频播放进度条卡住不动的问题。
- 修复切换 AI 供应商后可能仍沿用上一个供应商地址与模型的问题(如选了豆包却调用了其他模型)。
- 修复在 TRAE、Cursor、Windsurf 等编辑器中无法插入文本、只弹出兜底卡片的问题。
- 默认开启麦克风降噪,消除部分机器录音中的低频电流声/底噪。
---
## [0.0.7]
- 已发布基线版本。
+19 -14
View File
@@ -13,6 +13,8 @@
**[下载客户端](https://github.com/crosswk/SayIt/releases/latest)** · **[网页版体验](https://sayitapp.site)** · **[配置文档](docs/)**
**视频介绍:[我做了一个开源 AI 语音输入法——SayIt](https://www.bilibili.com/video/BV1JLTs6REPU/)**(B 站)
</div>
---
@@ -129,7 +131,7 @@ cd backend && uvicorn app.main:app --host 0.0.0.0 --port 8000
- **全局语音输入** — 在任何应用中按下快捷键即可口述,文字自动插入光标位置
- **AI 智能润色** — 口语自动转书面语,去口癖、纠错、分段,Prompt 完全可自定义
- **多种语音识别** — 豆包 ASR、千问 ASR、本地离线识别(sherpa-onnx)
- **多种语音识别** — 豆包 ASR、千问 ASR,以及本地离线识别(SenseVoice、Qwen3-ASR,基于 sherpa-onnx)
- **热词增强** — 自定义专业术语词表,提升识别准确率
- **悬浮窗反馈** — 录音状态、波形动画、处理进度实时可见
- **历史记录** — 所有转录结果本地保存,支持搜索和收藏
@@ -162,15 +164,15 @@ SayIt/
## 技术栈
| 层 | 技术 |
|----|------|
| 桌面客户端 | Tauri v2、React、TypeScript、Tailwind CSS |
| 客户端系统集成 | Rust(全局键盘钩子、剪贴板、SQLite) |
| 后端服务 | Python、FastAPI、WebSocket |
| 语音识别 | Qwen3-ASR + vLLM / 豆包 ASR / 千问 ASR / sherpa-onnx |
| AI 润色 | DeepSeek / 通义千问 / Azure OpenAI / Ollama |
| 部署 | Docker Compose、NVIDIA Container Toolkit |
| 开发 | 整个项目使用 Claude Opus 开发 |
| 层       | 技术                         |
| ----------------| ------------------------------------------------------|
| 桌面客户端   | Tauri v2、React、TypeScript、Tailwind CSS      |
| 客户端系统集成 | Rust(全局键盘钩子、剪贴板、SQLite)         |
| 后端服务    | Python、FastAPI、WebSocket              |
| 语音识别    | Qwen3-ASR + vLLM / 豆包 ASR / 千问 ASR / sherpa-onnx |
| AI 润色    | DeepSeek / 通义千问 / Azure OpenAI / Ollama     |
| 部署      | Docker Compose、NVIDIA Container Toolkit       |
| 开发      | 整个项目使用 Claude Opus 开发            |
## 开发
@@ -197,13 +199,16 @@ cd backend && uvicorn app.main:app --port 8000
## 交流反馈
有任何问题、建议或想法,欢迎扫码加入用户反馈微信群,一起交流:
关注微信公众号获取更新动态,或扫码加入用户反馈微信群一起交流:
<div align="center">
<img src="docs/images/readme/SayIt%20用户反馈微信群.jpg" width="240" alt="SayIt 用户反馈微信群">
*微信扫码加入 SayIt 用户反馈群*
<table>
<tr>
<td align="center"><img src="docs/images/readme/微信公众号.jpg" width="220" alt="SayIt 微信公众号"><br>微信公众号</td>
<td align="center"><img src="docs/images/readme/SayIt%20用户反馈微信群.jpg" width="220" alt="SayIt 用户反馈微信群"><br>用户反馈群</td>
</tr>
</table>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sayit-tauri",
"version": "0.0.7",
"version": "0.0.8",
"description": "SayIt - Voice to Text Desktop App (Tauri)",
"type": "module",
"scripts": {
+406 -5
View File
@@ -150,6 +150,137 @@ dependencies = [
"x11rb",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -262,6 +393,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -497,6 +641,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
@@ -968,6 +1121,33 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.0"
@@ -1024,6 +1204,27 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1225,6 +1426,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1686,6 +1900,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -2785,6 +3005,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -2834,6 +3064,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -3102,6 +3338,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3153,6 +3400,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -3763,7 +4024,7 @@ dependencies = [
[[package]]
name = "sayit"
version = "0.0.7"
version = "0.0.8"
dependencies = [
"base64 0.22.1",
"bzip2",
@@ -3791,6 +4052,7 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"tokio-tungstenite",
@@ -4150,9 +4412,9 @@ dependencies = [
[[package]]
name = "sherpa-onnx"
version = "1.12.34"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273e54bb64c6917f29a0b6abfcce97b2d96fd29a5205a47f3afa65553a152be5"
checksum = "98baa63be165cc1bccd418c210269c68bcdd0c279195ecce0140645bc6e520f4"
dependencies = [
"serde",
"serde_json",
@@ -4161,9 +4423,9 @@ dependencies = [
[[package]]
name = "sherpa-onnx-sys"
version = "1.12.34"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0a75557827c187845eb004afdaab8151460152904c42746c9d28d68b89a44c"
checksum = "21ac2c3342f826069bbf0be3578f9601f3802a1bff50bbced8230a15ec9eed8f"
dependencies = [
"bzip2",
"tar",
@@ -4762,6 +5024,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
@@ -5242,9 +5519,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -5324,6 +5613,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -6745,6 +7045,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.0",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.0",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.42"
@@ -6906,3 +7267,43 @@ checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.0",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow 1.0.0",
]
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "sayit"
version = "0.0.7"
version = "0.0.8"
authors = ["Liu Qianglong", "Claude"]
edition = "2021"
@@ -17,6 +17,7 @@ tauri-plugin-autostart = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
@@ -38,7 +39,7 @@ flate2 = "1"
tar = "0.4"
bzip2 = "0.4"
tokio = { version = "1", features = ["rt"] }
sherpa-onnx = { version = "1.12", default-features = false, features = ["static"] }
sherpa-onnx = { version = "1.13.3", default-features = false, features = ["static"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
@@ -51,4 +52,6 @@ windows = { version = "0.58", features = [
"Win32_System_Memory",
"Win32_System_Com",
"Win32_Graphics_Gdi",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
] }
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
//! 录音期间静音系统输出(默认扬声器)——防止外放的声音被麦克风回采。
//!
//! 只在采集期间静音,松开热键立即恢复到用户原本的静音状态:
//! - `mute_system_output`:记录当前默认输出设备的静音状态,然后静音。
//! - `restore_system_output`:恢复到之前记录的状态(若用户本来就是静音,则保持静音)。
//!
//! 用「保存/恢复原状态」而非「无脑取消静音」,避免录完后把用户原本的静音误开成有声。
#[cfg(windows)]
use once_cell::sync::Lazy;
#[cfg(windows)]
use std::sync::Mutex;
/// 保存的「静音前的原始状态」。None 表示当前未处于我们施加的静音中。
#[cfg(windows)]
static SAVED_MUTE_STATE: Lazy<Mutex<Option<bool>>> = Lazy::new(|| Mutex::new(None));
/// 获取默认渲染端点(扬声器)的音量控制接口,并在其上执行闭包。
#[cfg(windows)]
unsafe fn with_endpoint_volume<F, R>(f: F) -> Result<R, String>
where
F: FnOnce(&windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume) -> Result<R, String>,
{
use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume;
use windows::Win32::Media::Audio::{
eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
let co_init_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
let need_co_uninit = co_init_result.is_ok();
let result = (|| -> Result<R, String> {
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("CoCreateInstance MMDeviceEnumerator: {}", e))?;
let device = enumerator
.GetDefaultAudioEndpoint(eRender, eConsole)
.map_err(|e| format!("GetDefaultAudioEndpoint: {}", e))?;
let endpoint: IAudioEndpointVolume = device
.Activate(CLSCTX_ALL, None)
.map_err(|e| format!("Activate IAudioEndpointVolume: {}", e))?;
f(&endpoint)
})();
if need_co_uninit {
CoUninitialize();
}
result
}
/// 记录当前默认输出设备静音状态并将其静音。
/// 返回 true 表示已处理(Windows),false 表示非 Windows 平台跳过。
#[tauri::command]
pub fn mute_system_output() -> Result<bool, String> {
#[cfg(windows)]
unsafe {
with_endpoint_volume(|ep| {
let was_muted = ep
.GetMute()
.map_err(|e| format!("GetMute: {}", e))?
.as_bool();
// 仅在首次静音时保存原状态,避免重复调用覆盖真实原值
{
let mut saved = SAVED_MUTE_STATE.lock().unwrap();
if saved.is_none() {
*saved = Some(was_muted);
}
}
if !was_muted {
ep.SetMute(true, std::ptr::null())
.map_err(|e| format!("SetMute(true): {}", e))?;
}
crate::commands::system::write_log_line(&format!(
"[RUST] [audio_mute] muted output (prev_muted={})",
was_muted
));
Ok(true)
})
}
#[cfg(not(windows))]
{
Ok(false)
}
}
/// 恢复到 `mute_system_output` 之前记录的静音状态。
/// 若没有记录(未曾静音),则不做任何操作。
#[tauri::command]
pub fn restore_system_output() -> Result<bool, String> {
#[cfg(windows)]
unsafe {
let saved = {
let mut s = SAVED_MUTE_STATE.lock().unwrap();
s.take()
};
match saved {
Some(prev) => with_endpoint_volume(|ep| {
ep.SetMute(windows::Win32::Foundation::BOOL::from(prev), std::ptr::null())
.map_err(|e| format!("SetMute restore: {}", e))?;
crate::commands::system::write_log_line(&format!(
"[RUST] [audio_mute] restored output (mute={})",
prev
));
Ok(true)
}),
None => Ok(false),
}
}
#[cfg(not(windows))]
{
Ok(false)
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod audio;
pub mod audio_mute;
pub mod diagnostics;
pub mod export;
pub mod paste;
+90 -17
View File
@@ -18,32 +18,105 @@ pub fn shortcuts_changed(
// Reconfigure PTT + hands-free keyboard hook
hook.reconfigure(&app, ptt_str, hf_key);
// Re-register hands-free global shortcut (only for combo keys)
use tauri_plugin_global_shortcut::GlobalShortcutExt;
let _ = app.global_shortcut().unregister_all();
// 重新注册所有 global_shortcut(免提组合键 + 各润色模式切换快捷键)
register_all_global_shortcuts(&app, storage.inner());
}
// If hands-free is a combo key (contains '+'), register via global_shortcut
// If it's a single key, it's already handled by the keyboard hook
/// 集中注册所有 global_shortcut:免提组合键 + 各润色模式切换快捷键。
///
/// `unregister_all` 会清空全部已注册的 global_shortcut,因此这两类必须在同一处
/// 一次性重注册,否则会互相覆盖。单键(PTT/免提单键)不走这里,由底层键盘钩子处理。
pub fn register_all_global_shortcuts(app: &AppHandle, storage: &Storage) {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
let gs = app.global_shortcut();
let _ = gs.unregister_all();
// 免提组合键
let hf_val = storage.get("shortcutHandsFree", None);
let hf_key = hf_val.as_str().unwrap_or("AltRight").to_string();
if !hf_key.is_empty() && hf_key.contains('+') {
if let Err(e) = app.global_shortcut().on_shortcut(
hf_key,
move |_app, _shortcut, _event| {
let _ = _app.emit("toggle-hands-free", serde_json::json!({
"source": "globalShortcut",
}));
},
) {
if let Err(e) = gs.on_shortcut(hf_key.as_str(), move |app, _shortcut, _event| {
let _ = app.emit(
"toggle-hands-free",
serde_json::json!({ "source": "globalShortcut" }),
);
}) {
log::warn!("Failed to register hands-free shortcut '{}': {}", hf_key, e);
} else {
log::info!("Re-registered hands-free shortcut: {}", hf_key);
log::info!("Registered hands-free shortcut: {}", hf_key);
}
}
// 润色模式切换快捷键:presetShortcuts = { presetId: 组合键 }
let preset_map = storage.get("presetShortcuts", None);
if let Some(obj) = preset_map.as_object() {
for (preset_id, val) in obj {
let accel = match val.as_str() {
Some(s) if !s.is_empty() && s.contains('+') => s.to_string(),
_ => continue,
};
let pid = preset_id.clone();
let accel_for_log = accel.clone();
if let Err(e) = gs.on_shortcut(accel.as_str(), move |app, _shortcut, event| {
// 只在按下时触发一次,避免松开时重复触发
if event.state == ShortcutState::Pressed {
let _ = app.emit(
"switch-preset",
serde_json::json!({ "presetId": pid.clone() }),
);
}
}) {
log::warn!(
"Failed to register preset shortcut '{}' for '{}': {}",
accel_for_log, preset_id, e
);
}
}
}
}
/// 探测快捷键是否可用。
/// - 单键(不含 '+')由底层键盘钩子处理,任意单键都可用 → 返回 true。
/// - 组合键通过 global_shortcut 试注册探测:注册成功说明系统未被其他
/// (使用 RegisterHotKey 的)程序占用,随即注销并返回 true;失败返回 false。
///
/// 注意:此探测只能发现"用 RegisterHotKey 注册的程序"造成的冲突,
/// 无法发现用底层键盘钩子(如本应用自身、部分输入法/工具)占用的键——
/// 这是 Windows API 的固有限制。
#[tauri::command]
pub fn test_shortcut(_accelerator: String) -> Result<bool, String> {
// In Tauri, we use raw keyboard hooks, so any key is valid
Ok(true)
pub fn test_shortcut(
app: AppHandle,
storage: State<Storage>,
accelerator: String,
) -> Result<bool, String> {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
if !accelerator.contains('+') {
return Ok(true);
}
let gs = app.global_shortcut();
// 先清空本应用自己的全部注册,避免把“自己已占用同一组合键”误判成冲突。
// (直接用 is_registered 判断不可靠:字符串与内部 Shortcut 可能不一致。)
let _ = gs.unregister_all();
let available = match gs.register(accelerator.as_str()) {
Ok(_) => {
let _ = gs.unregister(accelerator.as_str());
true
}
Err(e) => {
log::info!("[shortcut] 组合键 '{}' 注册失败(可能被其他程序占用): {}", accelerator, e);
false
}
};
// 恢复本应用自己的全部注册(免提 + 各预设快捷键)
register_all_global_shortcuts(&app, storage.inner());
Ok(available)
}
/// PTT Lab: start/stop a dedicated keyboard hook for the lab test key (right Ctrl).
+61
View File
@@ -183,6 +183,9 @@ pub fn is_likely_editable_pub(ctx: &context::AppContext) -> bool {
let editable_procs = [
"notepad", "winword", "excel", "powerpnt", "outlook",
"code", "devenv", "idea64",
// VS Code 派生的 AI IDE:焦点常报为 Pane 且无 ValuePattern,
// 与 code/devenv 同类,文本区可编辑,按进程名兜底放行
"trae", "cursor", "windsurf", "kiro",
"chrome", "msedge", "firefox", "opera", "brave",
"teams", "wechat", "dingtalk", "slack",
"windowsterminal", "cmd", "powershell",
@@ -201,6 +204,15 @@ pub fn is_likely_editable_pub(ctx: &context::AppContext) -> bool {
#[cfg(windows)]
const WM_PASTE: u32 = 0x0302;
/// WM_COMMAND = 0x0111
#[cfg(windows)]
const WM_COMMAND: u32 = 0x0111;
/// conhost 系统菜单「粘贴」命令 ID(内置于 conhost)。
/// 参考:0xFFF0=复制 0xFFF1=粘贴 0xFFF2=滚动 0xFFF3=标记 0xFFF5=全选
#[cfg(windows)]
const ID_CONSOLE_PASTE: usize = 0xFFF1;
/// Main injection: try WM_PASTE first, then SendInput Ctrl+V as fallback.
#[cfg(windows)]
unsafe fn do_inject(target: HWND, focus: HWND, text: &str) -> InjectResult {
@@ -216,6 +228,55 @@ unsafe fn do_inject(target: HWND, focus: HWND, text: &str) -> InjectResult {
};
}
// Step 1.5: 经典控制台窗口(conhost,类名 ConsoleWindowClass)——用控制台
// 宿主自带的「粘贴」命令,而不是模拟 Ctrl+V 按键。
//
// 原因:当控制台里运行 TUI 程序(如 Claude Code)时,控制台被切到 raw 模式,
// 合成的 Ctrl+V 按键会被该程序当作普通按键吃掉,不会触发粘贴,导致剪贴板内容
// 根本没插入(但 SendInput 仍返回“成功”,形成假成功)。
// WM_COMMAND + ID_CONSOLE_PASTE 由 conhost 自身处理,不经过子程序的按键流,
// 且会遵循控制台当前输入模式(含 bracketed paste),因此 raw 模式下依然有效。
let target_class = crate::context::read_class_name(target).to_lowercase();
if target_class.contains("consolewindowclass") {
crate::commands::system::write_log_line(
&format!("[RUST] [inject] console paste attempt hwnd={} class={} textLen={}",
target.0 as isize, target_class, text.len())
);
// 先把控制台置前台,确保粘贴落在正确的窗口上
let fg_ok = force_foreground(target);
let mut result_val: usize = 0;
let send_ok = SendMessageTimeoutW(
target,
WM_COMMAND,
windows::Win32::Foundation::WPARAM(ID_CONSOLE_PASTE),
windows::Win32::Foundation::LPARAM(0),
SMTO_ABORTIFHUNG,
2000, // 2 second timeout
Some(&mut result_val),
);
if send_ok.0 != 0 {
crate::commands::system::write_log_line(
&format!("[RUST] [inject] console paste ok hwnd={} fgOk={}", target.0 as isize, fg_ok)
);
return InjectResult {
ok: true,
strategy: Some("console_paste".to_string()),
reason: None,
detail: Some(format!(
"hwnd={} class={} textLen={} fgOk={}",
target.0 as isize, target_class, text.len(), fg_ok
)),
uncertain: false,
};
}
crate::commands::system::write_log_line(
"[RUST] [inject] console paste failed, fallback to SendInput"
);
}
// Step 2: Try WM_PASTE — this is a message sent directly to the target
// window handle, so it works even if the target is not the foreground
// window. Most native Win32 controls (Edit, RichEdit) handle it.
+31 -21
View File
@@ -19,34 +19,44 @@ use windows::Win32::UI::WindowsAndMessaging::{
WM_SYSKEYDOWN, WM_SYSKEYUP, WM_QUIT,
};
/// 单键热键表 —— 单一数据源。
///
/// ⚠️ 必须与前端 `client/src/lib/shortcutKeys.ts` 的 `SINGLE_KEYS` 保持一致
/// (Rust 无法直接引用 TS,新增/修改单键时两边都要改)。
/// `setting` 取值等于 DOM KeyboardEvent.code。
const SINGLE_KEY_TABLE: &[(&str, u32)] = &[
("AltLeft", 0xA4),
("AltRight", 0xA5),
("ControlLeft", 0xA2),
("ControlRight", 0xA3),
("ShiftLeft", 0xA0),
("ShiftRight", 0xA1),
("CapsLock", 0x14),
("Space", 0x20),
("ContextMenu", 0x5D),
("Pause", 0x13),
("ScrollLock", 0x91),
("Insert", 0x2D),
("F1", 0x70), ("F2", 0x71), ("F3", 0x72), ("F4", 0x73),
("F5", 0x74), ("F6", 0x75), ("F7", 0x76), ("F8", 0x77),
("F9", 0x78), ("F10", 0x79), ("F11", 0x7A), ("F12", 0x7B),
];
/// Virtual key code mapping for PTT settings
fn vk_codes_for_setting(setting: &str) -> Vec<u32> {
match setting {
"" => vec![],
"AltLeft" => vec![0xA4],
"AltRight" => vec![0xA5],
"ControlLeft" => vec![0xA2],
"ControlRight" => vec![0xA3],
"ShiftLeft" => vec![0xA0],
"ShiftRight" => vec![0xA1],
"CapsLock" => vec![0x14],
"Space" => vec![0x20],
"F1" => vec![0x70], "F2" => vec![0x71], "F3" => vec![0x72],
"F4" => vec![0x73], "F5" => vec![0x74], "F6" => vec![0x75],
"F7" => vec![0x76], "F8" => vec![0x77], "F9" => vec![0x78],
"F10" => vec![0x79], "F11" => vec![0x7A], "F12" => vec![0x7B],
_ => vec![0xA5], // default: AltRight
if setting.is_empty() {
return vec![];
}
SINGLE_KEY_TABLE
.iter()
.find(|(s, _)| *s == setting)
.map(|(_, vk)| vec![*vk])
.unwrap_or_else(|| vec![0xA5]) // default: AltRight
}
/// Check if a shortcut setting is a single key (handled by hook) vs combo (handled by global_shortcut)
pub fn is_single_key_setting(setting: &str) -> bool {
matches!(setting,
"AltLeft" | "AltRight" | "ControlLeft" | "ControlRight" |
"ShiftLeft" | "ShiftRight" | "CapsLock" | "Space" |
"F1" | "F2" | "F3" | "F4" | "F5" | "F6" |
"F7" | "F8" | "F9" | "F10" | "F11" | "F12"
)
SINGLE_KEY_TABLE.iter().any(|(s, _)| *s == setting)
}
#[allow(dead_code)]
+17 -20
View File
@@ -14,7 +14,7 @@ use storage::Storage;
use window::WindowState;
use keyboard::KeyboardHookManager;
use context::ContextDetector;
use tauri::{Manager, Emitter};
use tauri::Manager;
use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent};
use tauri::menu::{MenuBuilder, MenuItemBuilder};
@@ -169,6 +169,15 @@ fn main() {
let context_detector = ContextDetector::new();
tauri::Builder::default()
// 单实例锁:必须作为第一个插件注册。第二次启动时不新开进程,
// 而是唤起已有窗口,避免两个客户端同时收全局热键、各插一份文本。
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}))
.manage(storage)
.manage(window_state)
.manage(keyboard_hook)
@@ -211,7 +220,7 @@ fn main() {
let _tray = TrayIconBuilder::new()
.icon(icon)
.tooltip("SayIt — 按住说话,松开输入")
.tooltip("SayIt — 随口说,出色写")
.menu(&tray_menu)
.on_menu_event(|app, event| {
match event.id().as_ref() {
@@ -245,26 +254,11 @@ fn main() {
let detector: tauri::State<ContextDetector> = app.state();
detector.start_winevent_hook(app.handle());
// Register hands-free global shortcut (only for combo keys; single keys are handled by keyboard hook)
// Register all global shortcuts (hands-free combo + preset-switch shortcuts).
// Single keys are handled by the keyboard hook, not here.
{
let storage: tauri::State<Storage> = app.state();
let hf_val = storage.get("shortcutHandsFree", None);
let hf_key = hf_val.as_str().unwrap_or("AltRight");
if !hf_key.is_empty() && hf_key.contains('+') {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
if let Err(e) = app.global_shortcut().on_shortcut(
hf_key,
move |_app, _shortcut, _event| {
let _ = _app.emit("toggle-hands-free", serde_json::json!({
"source": "globalShortcut",
}));
},
) {
log::warn!("Failed to register hands-free shortcut '{}': {}", hf_key, e);
} else {
log::info!("Registered hands-free shortcut: {}", hf_key);
}
}
commands::shortcuts::register_all_global_shortcuts(app.handle(), storage.inner());
}
// 首次安装时自动启用开机自启(仅执行一次)
@@ -346,6 +340,9 @@ fn main() {
commands::audio::save_pcm_as_wav,
commands::audio::read_audio_file,
commands::audio::delete_audio_file,
// Audio output mute (录音期间静音系统输出,防回采)
commands::audio_mute::mute_system_output,
commands::audio_mute::restore_system_output,
// Shortcuts
commands::shortcuts::shortcuts_changed,
commands::shortcuts::test_shortcut,
+35 -23
View File
@@ -27,6 +27,15 @@ pub struct ModelInfo {
pub sources: Vec<DownloadSource>,
#[serde(default)]
pub archive_url: Option<String>,
/// 速度评级 0–10(可带小数,10 最快)
#[serde(default)]
pub speed: f32,
/// 准确度评级 0–10(可带小数,10 最准)
#[serde(default)]
pub accuracy: f32,
/// 是否为推荐(默认)模型
#[serde(default)]
pub recommended: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -51,6 +60,11 @@ fn ms(repo: &str, file: &str) -> String {
format!("https://modelscope.cn/models/{}/resolve/master/{}", repo, file)
}
/// Qwen3-ASR ONNX 的 ModelScope 源(zengshuishui/Qwen3-ASR-onnx,含 model_0.6B int8 + 共享 tokenizer)
fn qwen3_ms(file: &str) -> String {
ms("zengshuishui/Qwen3-ASR-onnx", file)
}
pub fn get_available_models() -> Vec<ModelInfo> {
let hf_repo = "csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17";
let ms_repo = "xiaowangge/sherpa-onnx-sense-voice-small";
@@ -60,9 +74,12 @@ pub fn get_available_models() -> Vec<ModelInfo> {
ModelInfo {
id: "sensevoice-small".into(),
name: "SenseVoice Small (INT8)".into(),
description: "228MB · 速度快 · 中/英/日/韩/粤 · 推荐".into(),
description: "内存占用 600M 左右 · 中英日韩粤 · 速度极快,日常首选".into(),
model_type: "sensevoice".into(),
total_size_bytes: 228 * 1024 * 1024,
speed: 8.5,
accuracy: 7.0,
recommended: true,
languages: langs.clone(),
sources: vec![
DownloadSource {
@@ -89,33 +106,28 @@ pub fn get_available_models() -> Vec<ModelInfo> {
],
archive_url: None,
},
// ── Qwen3-ASR 0.6B(speech-LLM,52 语言+方言+热词)──
ModelInfo {
id: "sensevoice-small-fp32".into(),
name: "SenseVoice Small (FP32)".into(),
description: "937MB · 精度更高 · 中/英/日/韩/粤".into(),
model_type: "sensevoice".into(),
total_size_bytes: 937 * 1024 * 1024,
languages: langs,
id: "qwen3-asr-0.6b".into(),
name: "Qwen3-ASR 0.6B (INT8)".into(),
description: "内存占用 2.3G 左右 · 多语种支持 · 速度较慢".into(),
model_type: "qwen3-asr".into(),
total_size_bytes: 940 * 1024 * 1024,
speed: 3.0,
accuracy: 7.5,
recommended: false,
languages: vec!["zh".into(), "en".into(), "yue".into(), "ja".into(), "ko".into()],
// ModelScope 多文件下载(国内原生、无需 GitHub 代理),与官方 tar 同源同模型
sources: vec![
DownloadSource {
source: "ModelScope".into(),
files: vec![
ModelFile { name: "model.onnx".into(), url: ms(ms_repo, "model.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "tokens.txt".into(), url: ms(ms_repo, "tokens.txt"), size_bytes: 0, sha256: None },
],
},
DownloadSource {
source: "HuggingFace".into(),
files: vec![
ModelFile { name: "model.onnx".into(), url: hf(hf_repo, "model.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "tokens.txt".into(), url: hf(hf_repo, "tokens.txt"), size_bytes: 0, sha256: None },
],
},
DownloadSource {
source: "HuggingFace Mirror".into(),
files: vec![
ModelFile { name: "model.onnx".into(), url: hf_mirror(hf_repo, "model.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "tokens.txt".into(), url: hf_mirror(hf_repo, "tokens.txt"), size_bytes: 0, sha256: None },
ModelFile { name: "conv_frontend.onnx".into(), url: qwen3_ms("model_0.6B/conv_frontend.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "encoder.int8.onnx".into(), url: qwen3_ms("model_0.6B/encoder.int8.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "decoder.int8.onnx".into(), url: qwen3_ms("model_0.6B/decoder.int8.onnx"), size_bytes: 0, sha256: None },
ModelFile { name: "tokenizer/merges.txt".into(), url: qwen3_ms("tokenizer/merges.txt"), size_bytes: 0, sha256: None },
ModelFile { name: "tokenizer/vocab.json".into(), url: qwen3_ms("tokenizer/vocab.json"), size_bytes: 0, sha256: None },
ModelFile { name: "tokenizer/tokenizer_config.json".into(), url: qwen3_ms("tokenizer/tokenizer_config.json"), size_bytes: 0, sha256: None },
],
},
],
+81 -35
View File
@@ -57,9 +57,13 @@ pub async fn download_file(
let dest_path = dest_dir.join(file_name);
let temp_path = dest_dir.join(format!("{}.part", file_name));
// 确保目录存在
// 确保目录存在(file_name 可能含子路径,如 tokenizer/merges.txt,需创建其父目录)
std::fs::create_dir_all(dest_dir)
.map_err(|e| format!("创建目录失败: {}", e))?;
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建目录失败: {}", e))?;
}
// 检查是否已有部分下载(断点续传)
let mut downloaded: u64 = 0;
@@ -197,50 +201,49 @@ fn emit_progress(
/// 下载 tar.bz2 压缩包并解压到模型目录
/// 解压时会跳过顶层目录(如 sherpa-onnx-funasr-nano-int8-2025-12-30/)
/// 并跳过 test_wavs/ 目录和 README.md
pub async fn download_and_extract_tar_bz2(
app: AppHandle,
/// 为 GitHub Release 地址生成候选下载列表:国内加速代理优先,直连兜底。
/// 非 GitHub 地址(如 ModelScope)原样返回。
fn build_archive_candidates(url: &str) -> Vec<String> {
if url.starts_with("https://github.com/") {
vec![
format!("https://gh-proxy.com/{}", url),
format!("https://ghfast.top/{}", url),
url.to_string(),
]
} else {
vec![url.to_string()]
}
}
/// 从单个 URL 下载压缩包到 temp_path(支持断点续传)。下载完整返回 Ok。
async fn download_archive_once(
app: &AppHandle,
model_id: &str,
url: &str,
temp_path: &Path,
) -> Result<(), String> {
let dest_dir = model_dir(model_id);
let archive_path = dest_dir.with_extension("tar.bz2");
let temp_path = dest_dir.with_extension("tar.bz2.part");
std::fs::create_dir_all(&dest_dir)
.map_err(|e| format!("创建目录失败: {}", e))?;
// 下载压缩包(支持断点续传)
let mut downloaded: u64 = 0;
if temp_path.exists() {
downloaded = std::fs::metadata(&temp_path)
.map(|m| m.len())
.unwrap_or(0);
}
// 如果已经解压过(目录中有 onnx 文件),跳过下载
if dest_dir.join("encoder_adaptor.int8.onnx").exists()
|| dest_dir.join("model.int8.onnx").exists()
{
emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1);
return Ok(());
}
let mut downloaded: u64 = if temp_path.exists() {
std::fs::metadata(temp_path).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let client = build_http_client()?;
let mut request = client.get(url);
if downloaded > 0 {
request = request.header("Range", format!("bytes={}-", downloaded));
log::info!("Resuming archive download from {} bytes", downloaded);
log::info!("Resuming archive download from {} bytes ({})", downloaded, url);
}
emit_progress(&app, model_id, "archive", downloaded, 0, "downloading", None, 1, 1);
emit_progress(app, model_id, "archive", downloaded, 0, "downloading", None, 1, 1);
let resp = request.send().await
let resp = request
.send()
.await
.map_err(|e| format!("下载请求失败: {}", e))?;
if !resp.status().is_success() && resp.status().as_u16() != 206 {
let msg = format!("下载失败 HTTP {}", resp.status());
emit_progress(&app, model_id, "archive", 0, 0, "failed", Some(&msg), 1, 1);
return Err(msg);
return Err(format!("下载失败 HTTP {}", resp.status()));
}
let content_length = resp.content_length().unwrap_or(0);
@@ -250,7 +253,7 @@ pub async fn download_and_extract_tar_bz2(
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&temp_path)
.open(temp_path)
.map_err(|e| format!("打开文件失败: {}", e))?;
let mut stream = resp.bytes_stream();
@@ -263,18 +266,61 @@ pub async fn download_and_extract_tar_bz2(
downloaded += chunk.len() as u64;
if last_emit.elapsed().as_millis() >= 300 {
emit_progress(&app, model_id, "archive", downloaded, total, "downloading", None, 1, 1);
emit_progress(app, model_id, "archive", downloaded, total, "downloading", None, 1, 1);
last_emit = std::time::Instant::now();
}
}
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
drop(file);
Ok(())
}
pub async fn download_and_extract_tar_bz2(
app: AppHandle,
model_id: &str,
url: &str,
) -> Result<(), String> {
let dest_dir = model_dir(model_id);
let archive_path = dest_dir.with_extension("tar.bz2");
let temp_path = dest_dir.with_extension("tar.bz2.part");
std::fs::create_dir_all(&dest_dir)
.map_err(|e| format!("创建目录失败: {}", e))?;
// 如果已经解压过(目录中有 onnx 文件),跳过下载
// funasr-nano: encoder_adaptor.int8.onnx / paraformer: model.int8.onnx / qwen3-asr: encoder.int8.onnx
if dest_dir.join("encoder_adaptor.int8.onnx").exists()
|| dest_dir.join("model.int8.onnx").exists()
|| dest_dir.join("encoder.int8.onnx").exists()
{
emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1);
return Ok(());
}
// 多源下载:GitHub 地址自动优先走国内加速代理,失败再回退直连。
// 各镜像内容一致,可跨源断点续传(沿用已有 .part)。
let candidates = build_archive_candidates(url);
let mut last_err = String::from("无可用下载源");
let mut ok = false;
for (idx, cand) in candidates.iter().enumerate() {
match download_archive_once(&app, model_id, cand, &temp_path).await {
Ok(()) => { ok = true; break; }
Err(e) => {
log::warn!("archive 源 {}/{} 失败: {}", idx + 1, candidates.len(), e);
last_err = e;
}
}
}
if !ok {
emit_progress(&app, model_id, "archive", 0, 0, "failed", Some(&last_err), 1, 1);
return Err(last_err);
}
std::fs::rename(&temp_path, &archive_path)
.map_err(|e| format!("重命名失败: {}", e))?;
log::info!("Archive downloaded: {} ({} bytes)", model_id, downloaded);
log::info!("Archive downloaded: {}", model_id);
emit_progress(&app, model_id, "extracting", 0, 0, "downloading", None, 1, 1);
// 解压 tar.bz2
@@ -317,7 +363,7 @@ pub async fn download_and_extract_tar_bz2(
// 删除压缩包
std::fs::remove_file(&archive_path).ok();
emit_progress(&app, model_id, "archive", total, total, "completed", None, 1, 1);
emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1);
log::info!("Archive extracted: {}", model_id);
Ok(())
+253 -11
View File
@@ -10,10 +10,15 @@ use sherpa_onnx::{
OfflineFireRedAsrCtcModelConfig,
OfflineFireRedAsrModelConfig,
OfflineFunASRNanoModelConfig,
OfflineParaformerModelConfig,
OfflineQwen3ASRModelConfig,
OfflineRecognizer,
OfflineRecognizerConfig,
OfflineSenseVoiceModelConfig,
OfflineWhisperModelConfig,
SileroVadModelConfig,
VadModelConfig,
VoiceActivityDetector,
};
use super::downloader::model_dir;
@@ -27,6 +32,7 @@ pub struct LocalAsrResult {
pub(crate) struct RecognizerCache {
model_id: String,
language: String,
hotwords: String,
recognizer: OfflineRecognizer,
}
@@ -36,11 +42,13 @@ unsafe impl Send for RecognizerCache {}
static CACHE: Mutex<Option<RecognizerCache>> = Mutex::new(None);
fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> {
/// hotwords 在 recognizer 创建时写入 config(Qwen3),故作为缓存 key 的一部分:
/// 热词变化时重建 recognizer,否则复用。
fn ensure_loaded(model_id: &str, language: &str, hotwords: &str) -> Result<(), String> {
let mut cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?;
if let Some(ref c) = *cache {
if c.model_id == model_id && c.language == language {
if c.model_id == model_id && c.language == language && c.hotwords == hotwords {
return Ok(());
}
}
@@ -53,7 +61,7 @@ fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> {
log::info!("Loading ASR model: {}", model_id);
let start = Instant::now();
let recognizer = create_recognizer(&dir, model_id, language)?;
let recognizer = create_recognizer(&dir, model_id, language, hotwords)?;
log::info!(
"ASR model loaded in {}ms: {} (lang={})",
@@ -64,6 +72,7 @@ fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> {
*cache = Some(RecognizerCache {
model_id: model_id.to_string(),
language: language.to_string(),
hotwords: hotwords.to_string(),
recognizer,
});
Ok(())
@@ -74,11 +83,16 @@ fn create_recognizer(
dir: &Path,
model_id: &str,
language: &str,
hotwords: &str,
) -> Result<OfflineRecognizer, String> {
if model_id.starts_with("whisper-") {
create_whisper(dir, model_id, language)
} else if model_id.starts_with("funasr-nano") {
create_funasr_nano(dir, language)
} else if model_id.starts_with("qwen3-asr") {
create_qwen3_asr(dir, hotwords)
} else if model_id.starts_with("paraformer") {
create_paraformer(dir)
} else if model_id.starts_with("fire-red-asr2-ctc") {
create_fire_red_ctc(dir)
} else if model_id.starts_with("fire-red-asr2-aed") {
@@ -167,6 +181,9 @@ fn create_funasr_nano(
return Err("FunASR Nano tokenizer 目录不存在 (Qwen3-0.6B/)".into());
}
// 注意:sherpa-onnx Rust crate 的 Default 与 CLI 合理默认值不一致
// (Default 的 temperature=1.0、prompt 为空会导致 speech-LLM 输出乱码),
// 必须显式设置贪心采样 + 转写 prompt,与官方 CLI 一致。
let mut config = OfflineRecognizerConfig::default();
config.model_config.num_threads = 4;
config.model_config.funasr_nano = OfflineFunASRNanoModelConfig {
@@ -174,15 +191,76 @@ fn create_funasr_nano(
llm: Some(llm.to_string_lossy().to_string()),
embedding: Some(embedding.to_string_lossy().to_string()),
tokenizer: Some(tokenizer.to_string_lossy().to_string()),
system_prompt: Some("You are a helpful assistant.".into()),
user_prompt: Some("语音转写:".into()),
max_new_tokens: 512,
temperature: 1e-6,
top_p: 0.8,
seed: 42,
itn: 1,
language: Some(if language == "auto" { "".into() } else { language.to_string() }),
max_new_tokens: 200,
..Default::default()
hotwords: None,
};
OfflineRecognizer::create(&config)
.ok_or_else(|| "FunASR Nano 初始化失败".to_string())
}
fn create_paraformer(dir: &Path) -> Result<OfflineRecognizer, String> {
let model = dir.join("model.int8.onnx");
let model = if model.exists() {
model
} else {
let fp32 = dir.join("model.onnx");
if fp32.exists() { fp32 } else { return Err("Paraformer 模型文件不存在 (model.int8.onnx)".into()); }
};
let tokens = dir.join("tokens.txt");
if !tokens.exists() {
return Err("tokens.txt 不存在".into());
}
let mut config = OfflineRecognizerConfig::default();
config.model_config.num_threads = 4;
config.model_config.paraformer = OfflineParaformerModelConfig {
model: Some(model.to_string_lossy().to_string()),
};
config.model_config.tokens = Some(tokens.to_string_lossy().to_string());
OfflineRecognizer::create(&config)
.ok_or_else(|| "Paraformer 初始化失败".to_string())
}
fn create_qwen3_asr(dir: &Path, hotwords: &str) -> Result<OfflineRecognizer, String> {
let conv_frontend = dir.join("conv_frontend.onnx");
let encoder = dir.join("encoder.int8.onnx");
let decoder = dir.join("decoder.int8.onnx");
let tokenizer = dir.join("tokenizer");
if !conv_frontend.exists() || !encoder.exists() || !decoder.exists() {
return Err("Qwen3-ASR 模型文件不完整 (需要 conv_frontend.onnx / encoder.int8.onnx / decoder.int8.onnx)".into());
}
if !tokenizer.exists() {
return Err("Qwen3-ASR tokenizer 目录不存在".into());
}
let mut config = OfflineRecognizerConfig::default();
// Qwen3 是自回归 speech-LLM,大矩阵乘法可吃更多核;比轻量模型多给线程
config.model_config.num_threads = asr_num_threads();
config.model_config.qwen3_asr = OfflineQwen3ASRModelConfig {
conv_frontend: Some(conv_frontend.to_string_lossy().to_string()),
encoder: Some(encoder.to_string_lossy().to_string()),
decoder: Some(decoder.to_string_lossy().to_string()),
tokenizer: Some(tokenizer.to_string_lossy().to_string()),
// 热词:逗号分隔,提升专有名词/术语识别(Qwen3 在创建时写入 config)
hotwords: if hotwords.is_empty() { None } else { Some(hotwords.to_string()) },
max_new_tokens: 256,
..Default::default()
};
OfflineRecognizer::create(&config)
.ok_or_else(|| "Qwen3-ASR 初始化失败".to_string())
}
fn create_fire_red_ctc(dir: &Path) -> Result<OfflineRecognizer, String> {
let model = dir.join("model.int8.onnx");
if !model.exists() {
@@ -227,6 +305,142 @@ fn create_fire_red_aed(dir: &Path) -> Result<OfflineRecognizer, String> {
.ok_or_else(|| "FireRedASR2-AED 初始化失败".to_string())
}
/// ASR 推理线程数:按机器逻辑核数动态取值,下限 2、上限 8。
/// 上限 8 是经验最优:混合架构 CPU(大核+小核)上线程超过 ~8 后,
/// 弱小核参与反而拖慢、线程调度开销上升,实测 8 优于 12/16。
fn asr_num_threads() -> i32 {
std::thread::available_parallelism()
.map(|n| n.get().clamp(2, 8) as i32)
.unwrap_or(4)
}
/// 清洗 Qwen3-ASR 输出:剥离模型自带的模板前缀(如 "language Chinese<asr_text>")。
/// 这是 Qwen3-ASR chat 模板未被 ONNX 导出剥离导致的已知现象(见 sherpa-onnx PR #3399)。
fn clean_asr_text(text: &str) -> String {
let mut t = text;
// 取最后一个 <asr_text> 之后的内容(前面是 "language XXX" 之类的前缀)
if let Some(idx) = t.rfind("<asr_text>") {
t = &t[idx + "<asr_text>".len()..];
}
t.trim().to_string()
}
const SR: usize = 16000;
/// Qwen3 单次推理的音频上限(秒)。超过则用 VAD 切分,避免超长上下文导致内存暴涨/崩溃。
/// ≤此值单次直出(更快、无 VAD 开销);30s 内 0.6B 单次处理稳定不崩。
const QWEN3_MAX_SEC: usize = 30;
/// 切分后每段目标上限(秒)。与服务器端 MAX_CHUNK 一致,配合 VAD 得到 5~15s 段落。
const SEG_MAX_SEC: usize = 15;
/// 拼接分段文本:上一段若非以标点结尾则补空格,减少相邻词粘连。
fn join_segment(out: &mut String, text: &str) {
let t = text.trim();
if t.is_empty() {
return;
}
if !out.is_empty() && !out.ends_with(|c: char| ",。!?、,.!?".contains(c)) {
out.push(' ');
}
out.push_str(t);
}
/// 用 Silero VAD 在静音处切分为语音段(丢弃静音),失败返回 None。
fn vad_segments(samples: &[f32], vad_path: &Path) -> Option<Vec<Vec<f32>>> {
let mut cfg = VadModelConfig::default();
cfg.sample_rate = SR as i32;
cfg.num_threads = 1;
cfg.silero_vad = SileroVadModelConfig {
model: Some(vad_path.to_string_lossy().to_string()),
threshold: 0.5,
min_silence_duration: 0.5,
min_speech_duration: 0.25,
window_size: 512,
max_speech_duration: SEG_MAX_SEC as f32, // 单段硬上限,连续语音也会被切
};
let vad = VoiceActivityDetector::create(&cfg, 30.0)?;
let window = 512usize;
let mut i = 0usize;
let mut segs: Vec<Vec<f32>> = Vec::new();
while i < samples.len() {
let end = (i + window).min(samples.len());
vad.accept_waveform(&samples[i..end]);
i = end;
while let Some(seg) = vad.front() {
segs.push(seg.samples().to_vec());
vad.pop();
}
}
vad.flush();
while let Some(seg) = vad.front() {
segs.push(seg.samples().to_vec());
vad.pop();
}
if segs.is_empty() { None } else { Some(segs) }
}
/// 将相邻语音段合并到 ≤ max_samples,减少推理次数(5~15s 段落)。
fn merge_segments(segs: Vec<Vec<f32>>, max_samples: usize) -> Vec<Vec<f32>> {
let mut out: Vec<Vec<f32>> = Vec::new();
let mut cur: Vec<f32> = Vec::new();
for s in segs {
if s.len() > max_samples {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
out.push(s);
} else if !cur.is_empty() && cur.len() + s.len() > max_samples {
out.push(std::mem::replace(&mut cur, s));
} else {
cur.extend_from_slice(&s);
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
/// 固定窗口切分(VAD 不可用时的兜底),保证长音频不崩。
fn transcribe_fixed_window(samples: &[f32]) -> Result<String, String> {
let chunk = SEG_MAX_SEC * SR;
let mut out = String::new();
let mut start = 0usize;
while start < samples.len() {
let end = (start + chunk).min(samples.len());
let text = transcribe_with_cache(&samples[start..end])?;
join_segment(&mut out, &text);
start = end;
}
Ok(out)
}
/// 长音频转写:仅对自回归 speech-LLM(Qwen3)且 >QWEN3_MAX_SEC 的音频切分。
/// 优先 VAD 静音切分(5~15s),VAD 不可用时退回固定窗口。其他模型单次处理。
fn transcribe_long(model_id: &str, samples: &[f32], vad_path: Option<&Path>) -> Result<String, String> {
let is_llm = model_id.starts_with("qwen3-asr");
if !is_llm || samples.len() <= QWEN3_MAX_SEC * SR {
return transcribe_with_cache(samples);
}
let vad_segs = vad_path
.filter(|p| p.exists())
.and_then(|p| vad_segments(samples, p));
match vad_segs {
Some(segs) => {
let merged = merge_segments(segs, SEG_MAX_SEC * SR);
let mut out = String::new();
for seg in &merged {
let text = transcribe_with_cache(seg)?;
join_segment(&mut out, &text);
}
Ok(out)
}
None => transcribe_fixed_window(samples),
}
}
/// 使用缓存的 recognizer 进行转录
fn transcribe_with_cache(samples: &[f32]) -> Result<String, String> {
let cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?;
@@ -239,25 +453,44 @@ fn transcribe_with_cache(samples: &[f32]) -> Result<String, String> {
.get_result()
.ok_or_else(|| "获取识别结果失败".to_string())?;
Ok(result.text.trim().to_string())
Ok(clean_asr_text(&result.text))
}
#[tauri::command]
pub async fn preload_local_model(model_id: String) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let start = Instant::now();
ensure_loaded(&model_id, "auto")?;
ensure_loaded(&model_id, "auto", "")?;
Ok(format!("模型已加载 ({}ms)", start.elapsed().as_millis()))
})
.await
.map_err(|e| format!("预加载异常: {}", e))?
}
/// 解析 Silero VAD 模型路径:优先打包资源,兜底模型目录。
fn resolve_vad_model(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
use tauri::Manager;
for cand in ["resources/silero_vad.onnx", "silero_vad.onnx"] {
if let Ok(p) = app.path().resolve(cand, tauri::path::BaseDirectory::Resource) {
if p.exists() {
return Some(p);
}
}
}
let alt = super::downloader::models_dir().join("silero_vad.onnx");
if alt.exists() {
return Some(alt);
}
None
}
#[tauri::command]
pub async fn local_transcribe(
app: tauri::AppHandle,
audio_b64: String,
model_id: String,
language: Option<String>,
hotwords: Option<Vec<String>>,
) -> Result<LocalAsrResult, String> {
let pcm_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
@@ -274,12 +507,21 @@ pub async fn local_transcribe(
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
let vad_path = resolve_vad_model(&app);
// 仅 Qwen3 使用热词;其它模型热词 key 置空,避免热词变化触发无谓重建
let hotwords_key = if model_id.starts_with("qwen3-asr") {
hotwords.map(|h| h.join(",")).unwrap_or_default()
} else {
String::new()
};
tokio::task::spawn_blocking(move || {
let lang = language.as_deref().unwrap_or("auto");
ensure_loaded(&model_id, lang)?;
ensure_loaded(&model_id, lang, &hotwords_key)?;
let start = Instant::now();
let text = transcribe_with_cache(&samples)?;
let text = transcribe_long(&model_id, &samples, vad_path.as_deref())?;
Ok(LocalAsrResult {
text,
@@ -293,7 +535,7 @@ pub async fn local_transcribe(
// ── 供 benchmark / test_audio 模块使用的公开接口 ──
pub fn ensure_loaded_pub(model_id: &str, language: &str) -> Result<(), String> {
ensure_loaded(model_id, language)
ensure_loaded(model_id, language, "")
}
pub fn get_cache_lock() -> Result<std::sync::MutexGuard<'static, Option<RecognizerCache>>, String> {
@@ -307,7 +549,7 @@ impl RecognizerCache {
self.recognizer.decode(&stream);
stream
.get_result()
.map(|r| r.text.trim().to_string())
.map(|r| clean_asr_text(&r.text))
.unwrap_or_default()
}
}
+8
View File
@@ -55,6 +55,14 @@ pub fn list_downloaded_models() -> Vec<LocalModelInfo> {
&& model_path.join("llm.int8.onnx").exists()
&& model_path.join("embedding.int8.onnx").exists()
&& model_path.join("Qwen3-0.6B").exists();
} else if model.model_type == "qwen3-asr" {
complete = model_path.join("conv_frontend.onnx").exists()
&& model_path.join("encoder.int8.onnx").exists()
&& model_path.join("decoder.int8.onnx").exists()
&& model_path.join("tokenizer").exists();
} else if model.model_type == "paraformer" {
complete = model_path.join("model.int8.onnx").exists()
&& model_path.join("tokens.txt").exists();
} else if model.model_type == "fire-red-ctc" {
complete = model_path.join("model.int8.onnx").exists()
&& model_path.join("tokens.txt").exists();
@@ -33,7 +33,7 @@ pub async fn polish(
]
});
// 通义千问 Qwen3 系列默认开启思考模式,校对场景不需要
// 通义千问 Qwen3 默认开启思考模式,校对场景不需要
if config.provider == "qwen" {
body.as_object_mut().unwrap().insert(
"enable_thinking".to_string(),
@@ -41,8 +41,9 @@ pub async fn polish(
);
}
// DeepSeek V4 Flash 默认开启 thinking,校对场景关闭以降低延迟
if config.provider == "deepseek" {
// DeepSeek / 小米 MiMo 默认开启思考,校对场景关闭以大幅降低延迟。
// 注意:小米原生端点用 thinking.type=disabled(官方文档),对 enable_thinking 无效。
if config.provider == "deepseek" || config.provider == "mimo" {
body.as_object_mut().unwrap().insert(
"thinking".to_string(),
serde_json::json!({"type": "disabled"}),
@@ -52,10 +53,15 @@ pub async fn polish(
let client = reqwest::Client::new();
let start = Instant::now();
let resp = client
let mut req = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json")
.header("Content-Type", "application/json");
// 小米 MiMo 规范鉴权头为 api-key(同时兼容 Bearer),两个都带最稳妥
if config.provider == "mimo" {
req = req.header("api-key", config.api_key.clone());
}
let resp = req
.json(&body)
.timeout(std::time::Duration::from_secs(60))
.send()
@@ -112,7 +118,7 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult {
);
}
if config.provider == "deepseek" {
if config.provider == "deepseek" || config.provider == "mimo" {
body.as_object_mut().unwrap().insert(
"thinking".to_string(),
serde_json::json!({"type": "disabled"}),
@@ -122,10 +128,14 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult {
let client = reqwest::Client::new();
let start = Instant::now();
let result = client
let mut req = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json")
.header("Content-Type", "application/json");
if config.provider == "mimo" {
req = req.header("api-key", config.api_key.clone());
}
let result = req
.json(&body)
.timeout(std::time::Duration::from_secs(30))
.send()
+177
View File
@@ -0,0 +1,177 @@
// 小米 MiMo ASR — mimo-v2.5-asr
// OpenAI chat/completions 兼容接口,支持 Base64 data URL 音频(wav/mp3)。
// 与千问的差异:鉴权头是 `api-key`(非 Bearer)、无 app_id、body 为 OpenAI chat 结构、
// 响应为 choices[0].message.content(纯字符串)。
use super::types::{AsrProviderConfig, AsrResult, TestResult};
use std::time::Instant;
const API_URL: &str = "https://api.xiaomimimo.com/v1/chat/completions";
/// 将 16kHz 单声道 16-bit PCM 封装为 WAV 容器(MiMo 只接受 wav/mp3,不接受裸 PCM)。
fn pcm_to_wav(pcm: &[u8], sr: u32) -> Vec<u8> {
let ds = pcm.len() as u32;
let mut w = Vec::with_capacity(44 + pcm.len());
w.extend_from_slice(b"RIFF");
w.extend_from_slice(&(36 + ds).to_le_bytes());
w.extend_from_slice(b"WAVEfmt ");
w.extend_from_slice(&16u32.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&sr.to_le_bytes());
w.extend_from_slice(&(sr * 2).to_le_bytes());
w.extend_from_slice(&2u16.to_le_bytes());
w.extend_from_slice(&16u16.to_le_bytes());
w.extend_from_slice(b"data");
w.extend_from_slice(&ds.to_le_bytes());
w.extend_from_slice(pcm);
w
}
/// 从供应商额外配置读取识别语言(auto|zh|en),默认 auto。
fn resolve_language(config: &AsrProviderConfig) -> String {
config
.extra
.get("language")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or("auto")
.to_string()
}
fn build_body(data_url: &str, language: &str) -> serde_json::Value {
serde_json::json!({
"model": "mimo-v2.5-asr",
"messages": [
{
"role": "user",
"content": [
{ "type": "input_audio", "input_audio": { "data": data_url } }
]
}
],
"asr_options": { "language": language }
})
}
/// 解析 OpenAI chat 响应中的转写文本:choices[0].message.content。
/// content 可能是字符串,也可能是分段数组,两种都兼容。
fn extract_text(data: &serde_json::Value) -> String {
let content = data
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"));
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|seg| {
seg.get("text")
.and_then(|t| t.as_str())
.or_else(|| seg.as_str())
})
.collect::<Vec<_>>()
.join(""),
_ => String::new(),
}
}
pub async fn transcribe(
audio_pcm_b64: &str,
sample_rate: u32,
config: &AsrProviderConfig,
_hotwords: &[String],
) -> Result<AsrResult, String> {
let pcm = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
audio_pcm_b64,
)
.map_err(|e| format!("base64 解码失败: {}", e))?;
if pcm.is_empty() {
return Ok(AsrResult { text: String::new(), elapsed_ms: 0 });
}
let wav = pcm_to_wav(&pcm, sample_rate);
let wav_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav);
let data_url = format!("data:audio/wav;base64,{}", wav_b64);
let language = resolve_language(config);
let body = build_body(&data_url, &language);
let client = reqwest::Client::new();
let start = Instant::now();
let resp = client
.post(API_URL)
.header("api-key", &config.api_key)
.header("Content-Type", "application/json")
.json(&body)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("MiMo ASR 错误 {}: {}", status, &body[..body.len().min(300)]));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
Ok(AsrResult { text: extract_text(&data), elapsed_ms })
}
pub async fn test_connection(config: &AsrProviderConfig) -> TestResult {
let silence = vec![0u8; 16000]; // 0.5s 静音
let wav = pcm_to_wav(&silence, 16000);
let wav_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav);
let data_url = format!("data:audio/wav;base64,{}", wav_b64);
let body = build_body(&data_url, "auto");
let client = reqwest::Client::new();
let start = Instant::now();
let result = client
.post(API_URL)
.header("api-key", &config.api_key)
.header("Content-Type", "application/json")
.json(&body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
let elapsed_ms = start.elapsed().as_millis() as u64;
match result {
Ok(resp) if resp.status().is_success() => TestResult {
ok: true,
message: format!("连接成功 ({}ms)", elapsed_ms),
elapsed_ms,
detail: String::new(),
},
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
TestResult {
ok: false,
message: format!("API 错误 {}: {}", status, &body[..body.len().min(100)]),
elapsed_ms,
detail: String::new(),
}
}
Err(e) => TestResult {
ok: false,
message: format!("连接失败: {}", e),
elapsed_ms,
detail: String::new(),
},
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod asr_doubao_stream;
pub mod asr_doubao_realtime;
pub mod asr_qwen;
pub mod asr_qwen_omni;
pub mod asr_mimo;
pub mod asr_qwen_realtime;
pub mod doubao_protocol;
pub mod registry;
+13 -3
View File
@@ -1,14 +1,14 @@
// 供应商注册表 — Tauri commands 入口
use super::types::*;
use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni};
use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni, asr_mimo};
/// 云端 AI 校对(Tauri command)
#[tauri::command]
pub async fn cloud_polish(request: CloudPolishRequest) -> Result<AiResult, String> {
let config = &request.ai_config;
match config.provider.as_str() {
"openai_compat" | "deepseek" | "doubao" | "qwen" => {
"openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => {
ai_openai_compat::polish(
&request.text,
config,
@@ -32,7 +32,7 @@ pub async fn cloud_polish(request: CloudPolishRequest) -> Result<AiResult, Strin
#[tauri::command]
pub async fn test_ai_connection(config: AiProviderConfig) -> Result<TestResult, String> {
match config.provider.as_str() {
"openai_compat" | "deepseek" | "doubao" | "qwen" => {
"openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => {
Ok(ai_openai_compat::test_connection(&config).await)
}
"ollama" => {
@@ -83,6 +83,15 @@ pub async fn cloud_transcribe(request: CloudTranscribeRequest) -> Result<AsrResu
)
.await
}
"mimo" => {
asr_mimo::transcribe(
&request.audio_b64,
request.sample_rate,
config,
&request.hotwords,
)
.await
}
// TODO: "aliyun" => 阿里云 Paraformer(需要文件 URL + 异步轮询,暂未实现)
other => Err(format!("ASR 供应商 \"{}\" 尚未实现", other)),
}
@@ -96,6 +105,7 @@ pub async fn test_asr_connection(config: AsrProviderConfig) -> Result<TestResult
"doubao_v2" => Ok(asr_doubao_stream::test_connection(&config).await),
"qwen" | "aliyun" | "qwen_realtime" => Ok(asr_qwen::test_connection(&config).await),
"qwen_omni" => Ok(asr_qwen_omni::test_connection(&config).await),
"mimo" => Ok(asr_mimo::test_connection(&config).await),
other => Err(format!("ASR 供应商 \"{}\" 尚未实现", other)),
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"productName": "SayIt",
"version": "0.0.7",
"version": "0.0.8",
"identifier": "com.sayit.app",
"build": {
"frontendDist": "../dist",
@@ -44,7 +44,7 @@
"icons/128x128@2x.png"
],
"shortDescription": "语音输入,AI 润色",
"longDescription": "SayIt - 按住说话,松开输入。支持语音识别和 AI 文本润色,让语音输入更高效。",
"longDescription": "SayIt - 随口说,出色写。用说话代替打字,AI 实时把口语变成书面表达。",
"copyright": "© 2026 Liu Qianglong & Claude",
"category": "Productivity",
"windows": {
+10
View File
@@ -15,6 +15,7 @@ import About from './pages/About'
import { initRecorder, cleanup } from './services/recorder'
import { initTheme } from './stores/theme'
import { initAiEnabled } from './stores/aiEnabled'
import { initActivePreset } from './stores/activePreset'
import { getSetting, setSetting } from './services/store'
import { runAutoUpdate } from './features/update/autoUpdate'
import UpdateDialog from './features/update/UpdateDialog'
@@ -22,10 +23,13 @@ import * as bridge from './services/bridge'
export default function App() {
const [showWelcome, setShowWelcome] = useState(false)
// 首次挂载时先不渲染主界面,等 onboarding 检查完成再决定,避免"闪一下主页再进向导"
const [onboardingChecked, setOnboardingChecked] = useState(false)
useEffect(() => {
void initTheme()
void initAiEnabled()
void initActivePreset()
initRecorder()
void runAutoUpdate()
@@ -35,6 +39,7 @@ export default function App() {
if (!onboardedVersion) {
setShowWelcome(true)
}
setOnboardingChecked(true)
})()
return () => cleanup()
@@ -47,6 +52,11 @@ export default function App() {
bridge.notifyShortcutsChanged()
}
// onboarding 检查未完成前只渲染与背景同色的空壳,避免首次安装时主页闪现
if (!onboardingChecked) {
return <div className="h-screen bg-background" />
}
return (
<div className="flex h-screen flex-col">
<TitleBar />
+127 -37
View File
@@ -1,12 +1,13 @@
// 文本替换规则管理组件
import { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Plus, X, List } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
getTextReplacements,
saveTextReplacements,
parseBatchReplacements,
type TextReplacementRule,
} from '@/services/textReplacement'
@@ -18,6 +19,9 @@ export default function TextReplacementSection() {
const [rules, setRules] = useState<TextReplacementRule[]>([])
const [fromInput, setFromInput] = useState('')
const [toInput, setToInput] = useState('')
const [batchMode, setBatchMode] = useState(false)
const [batchText, setBatchText] = useState('')
const [batchHint, setBatchHint] = useState('')
useEffect(() => {
getTextReplacements().then((loaded) => {
@@ -43,6 +47,37 @@ export default function TextReplacementSection() {
setToInput('')
}, [fromInput, toInput])
const importBatch = useCallback(() => {
const parsed = parseBatchReplacements(batchText)
if (parsed.length === 0) {
setBatchHint('没有可导入的规则,请检查格式')
return
}
setRules((prev) => {
const existing = new Set(prev.map((r) => r.from))
const next = [...prev]
let added = 0
let updated = 0
for (const { from, to } of parsed) {
if (existing.has(from)) {
// 已存在同一原文:更新其替换内容
const idx = next.findIndex((r) => r.from === from)
if (idx >= 0 && next[idx].to !== to) {
next[idx] = { ...next[idx], to }
updated++
}
} else {
next.push({ id: createId(), from, to, enabled: true })
existing.add(from)
added++
}
}
setBatchHint(`已导入:新增 ${added} 条${updated > 0 ? `,更新 ${updated} 条` : ''}`)
return next
})
setBatchText('')
}, [batchText])
const removeRule = useCallback((id: string) => {
setRules((prev) => prev.filter((r) => r.id !== id))
}, [])
@@ -76,42 +111,97 @@ export default function TextReplacementSection() {
</p>
{/* 添加新规则 — 两列对齐 */}
<div className="flex items-center gap-2">
<input
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void addRule()
}
}}
placeholder="原文"
className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm"
/>
<input
value={toInput}
onChange={(e) => setToInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void addRule()
}
}}
placeholder="替换为(留空则删除)"
className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm"
/>
<Button
onClick={() => void addRule()}
size="sm"
variant="outline"
disabled={!fromInput.trim()}
className="shrink-0 gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
添加
</Button>
</div>
{!batchMode && (
<div className="flex items-center gap-2">
<input
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void addRule()
}
}}
placeholder="原文"
className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm"
/>
<input
value={toInput}
onChange={(e) => setToInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void addRule()
}
}}
placeholder="替换为(留空则删除)"
className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm"
/>
<Button
onClick={() => void addRule()}
size="sm"
variant="outline"
disabled={!fromInput.trim()}
className="shrink-0 gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
添加
</Button>
<Button
onClick={() => { setBatchMode(true); setBatchHint('') }}
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
>
<List className="h-3.5 w-3.5" />
批量
</Button>
</div>
)}
{/* 批量添加 */}
{batchMode && (
<div className="rounded-lg border border-border p-3">
<p className="mb-2 text-xs text-muted-foreground">
每行一条规则,用逗号、制表符或 <code>=&gt;</code> 分隔原文与替换内容,例如:
<br />
安卓说话,按住说话
<br />
Cloud Code =&gt; Claude Code
<br />
留空替换内容则表示删除该文本。
</p>
<textarea
value={batchText}
onChange={(e) => setBatchText(e.target.value)}
rows={6}
placeholder={'原文,替换为\n原文2 => 替换为2'}
className="w-full resize-y rounded-md border border-input-border bg-input-bg px-3 py-2 text-sm leading-relaxed"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{batchHint}</span>
<div className="flex shrink-0 gap-2">
<Button
onClick={() => { setBatchMode(false); setBatchText(''); setBatchHint('') }}
size="sm"
variant="outline"
>
完成
</Button>
<Button
onClick={() => void importBatch()}
size="sm"
variant="outline"
disabled={!batchText.trim()}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
导入
</Button>
</div>
</div>
</div>
)}
{/* 规则列表 — 两列布局 */}
{rules.length > 0 && (
+4 -1
View File
@@ -2,11 +2,14 @@ import * as bridge from '@/services/bridge'
import { Minus, Square, X, Wand2 } from 'lucide-react'
import appIcon from '@/assets/ico-frame-48x48.png'
import { useAiEnabled } from '@/hooks/useAiEnabled'
import { useActivePreset } from '@/hooks/useActivePreset'
import { toggleAiEnabled } from '@/stores/aiEnabled'
import { Tooltip } from '@/components/ui/tooltip'
export default function TitleBar() {
const aiEnabled = useAiEnabled()
const activePreset = useActivePreset()
const presetName = activePreset.name || '默认'
return (
<div className="flex h-10 items-center justify-between bg-titlebar border-b select-none"
@@ -17,7 +20,7 @@ export default function TitleBar() {
</div>
<div className="flex items-center">
<div className="flex items-center pr-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<Tooltip content={aiEnabled ? 'AI 整理已开启,点击关闭后将输出识别原文' : 'AI 整理已关闭,点击开启后自动整理'}>
<Tooltip content={`润色模式:${presetName}`}>
<button
type="button"
role="switch"
+12 -15
View File
@@ -6,16 +6,10 @@ import { getWorkMode } from '@/services/transcription'
import { healthCheck } from '@/services/api'
import { setPttSuppressed } from '@/services/recorder'
import * as bridge from '@/services/bridge'
import { isSingleKeySetting, getSingleKeyDisplay } from '@/lib/shortcutKeys'
import { refreshPTTSetting } from '@/services/webviewKeyboardFallback'
import appIcon from '@/assets/icon-128.png'
const KEY_MAP: Record<string, string> = {
AltLeft: '左 Alt', AltRight: '右 Alt',
ControlLeft: '左 Ctrl', ControlRight: '右 Ctrl',
ShiftLeft: '左 Shift', ShiftRight: '右 Shift',
MetaLeft: '左 Win', MetaRight: '右 Win',
Space: '空格', CapsLock: 'Caps Lock',
}
/** 简化键盘布局,高亮当前快捷键 */
function KeyboardHint({ activeKey, pressed }: { activeKey: string; pressed?: boolean }) {
const rows: { code: string; label: string; w?: number }[][] = [
@@ -100,7 +94,7 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
getSetting('shortcutHandsFree', 'AltRight').then((k) => {
const key = k as string
setHfKey(key)
setHfLabel(KEY_MAP[key] || key)
setHfLabel(getSingleKeyDisplay(key))
hfKeyRef.current = key
})
const mode = getWorkMode()
@@ -118,7 +112,7 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
const pressedKeyCodeRef = { current: '' } // 当前按住的键
const confirmKey = (code: string) => {
const label = KEY_MAP[code] || code
const label = getSingleKeyDisplay(code)
// 保存到 ref,避免 state 更新触发 effect 重启
hfKeyRef.current = code
settingsDirtyRef.current = true
@@ -143,7 +137,7 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
const onDown = (e: KeyboardEvent) => {
e.preventDefault()
const code = e.code
if (KEY_MAP[code] && pressedKeyCodeRef.current !== code) {
if (isSingleKeySetting(code) && pressedKeyCodeRef.current !== code) {
confirmKey(code)
}
}
@@ -165,7 +159,7 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
const unlistenPttDown = bridge.listen('ptt-down', (event: unknown) => {
const payload = event as { payload?: { pttSetting?: string } }
const setting = payload?.payload?.pttSetting
if (setting && KEY_MAP[setting] && pressedKeyCodeRef.current !== setting) {
if (setting && isSingleKeySetting(setting) && pressedKeyCodeRef.current !== setting) {
confirmKey(setting)
}
})
@@ -189,6 +183,8 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
void (async () => {
await setSetting('shortcutHandsFree', hfKeyRef.current)
bridge.notifyShortcutsChanged()
// 同步刷新 webview 回退缓存,否则向导内(SayIt 窗口聚焦)测试时新键不生效
await refreshPTTSetting()
})()
}
}
@@ -200,13 +196,14 @@ export default function WelcomeGuide({ onComplete }: WelcomeGuideProps) {
const handler = (e: KeyboardEvent) => {
e.preventDefault()
const code = e.code
const label = KEY_MAP[code]
if (label) {
if (isSingleKeySetting(code)) {
setHfKey(code)
setHfLabel(label)
setHfLabel(getSingleKeyDisplay(code))
void (async () => {
await setSetting('shortcutHandsFree', code)
bridge.notifyShortcutsChanged()
// 同步刷新 webview 回退缓存,否则向导内测试时新键不生效
await refreshPTTSetting()
})()
}
setListeningKey(false)
@@ -147,6 +147,9 @@ function HistoryItem({
setDuration(audio.duration)
setAudioReady(true)
}
// 原生 timeupdate 兜底:远程桌面 / 窗口失焦时 requestAnimationFrame 会被降频甚至暂停,
// 导致进度条卡住。timeupdate 是媒体事件,不受 rAF 节流影响,保证进度稳定推进。
audio.ontimeupdate = () => setCurrentTime(audio.currentTime)
audio.onended = () => {
setAudioPlaying(false)
setCurrentTime(0)
@@ -288,7 +291,7 @@ function HistoryItem({
{record.asrProvider && (
<span className="text-xs">ASR: {ASR_PROVIDER_DISPLAY[record.asrProvider] || record.asrProvider}</span>
)}
{record.aiProvider && record.aiProvider !== 'server' && record.llmText && record.llmText !== record.asrText && (
{record.aiProvider && record.aiProvider !== 'server' && record.llmMs > 0 && (
<span className="text-xs">
AI: {record.aiProvider}{record.aiModel ? ` (${record.aiModel})` : ''}
</span>
+8 -2
View File
@@ -6,9 +6,11 @@ interface TooltipProps {
children: ReactNode
className?: string
forceVisible?: boolean
/** 'dark'(默认,短标签)| 'light'(浅色卡片,适合较长说明文字) */
variant?: 'dark' | 'light'
}
export function Tooltip({ content, children, className, forceVisible }: TooltipProps) {
export function Tooltip({ content, children, className, forceVisible, variant = 'dark' }: TooltipProps) {
const [hovered, setHovered] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const triggerRef = useRef<HTMLSpanElement>(null)
@@ -53,7 +55,11 @@ export function Tooltip({ content, children, className, forceVisible }: TooltipP
? createPortal(
<div
ref={tooltipRef}
className="pointer-events-none fixed z-[9999] rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-lg"
className={
variant === 'light'
? 'pointer-events-none fixed z-[9999] max-w-[600px] whitespace-pre-line rounded-lg border border-border bg-card px-3.5 py-2.5 text-left text-xs leading-relaxed text-muted-foreground shadow-lg'
: 'pointer-events-none fixed z-[9999] max-w-xs rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-relaxed text-background shadow-lg'
}
style={{
top: pos ? `${pos.top}px` : '-9999px',
left: pos ? `${pos.left}px` : '-9999px',
@@ -6,12 +6,16 @@ import {
} from '@/services/personalization/store'
import type { AppPromptRule } from '@/services/personalization/types'
import { refreshPreset, refreshRecorderSettings } from '@/services/recorder'
import * as bridge from '@/services/bridge'
import { useActivePreset } from '@/hooks/useActivePreset'
import { refreshActivePreset } from '@/stores/activePreset'
import {
deletePromptPreset,
getActivePresetId,
getPromptPresets,
getPresetShortcuts,
savePromptPreset,
setActivePresetId,
setPresetShortcuts,
type PromptPreset,
} from '@/services/store'
import AIProofreadToggle from './AIProofreadToggle'
@@ -20,20 +24,39 @@ import PromptPresetSection from './PromptPresetSection'
export default function AIInstructionsPage() {
const [presets, setPresets] = useState<PromptPreset[]>([])
const [activePresetId, setActiveId] = useState('intent')
const activePreset = useActivePreset()
const activePresetId = activePreset.id
const [editingPreset, setEditingPreset] = useState<PromptPreset | null>(null)
const [appPromptRules, setAppPromptRules] = useState<AppPromptRule[]>([])
const [presetShortcuts, setPresetShortcutsState] = useState<Record<string, string>>({})
useEffect(() => {
getPromptPresets().then(setPresets)
getActivePresetId().then(setActiveId)
getAppPromptRules().then(setAppPromptRules)
getPresetShortcuts().then(setPresetShortcutsState)
void refreshActivePreset()
}, [])
const handleSetPresetShortcut = async (presetId: string, accel: string) => {
const next: Record<string, string> = { ...presetShortcuts }
if (!accel) {
delete next[presetId]
} else {
// 保证组合键唯一:若其它预设已占用同一组合键,先清除,避免注册冲突
for (const key of Object.keys(next)) {
if (next[key] === accel) delete next[key]
}
next[presetId] = accel
}
setPresetShortcutsState(next)
await setPresetShortcuts(next)
bridge.notifyShortcutsChanged()
}
const handleSelectPreset = async (id: string) => {
setActiveId(id)
await setActivePresetId(id)
await refreshPreset()
await refreshActivePreset()
}
const handleSavePreset = async (preset: PromptPreset) => {
@@ -43,14 +66,25 @@ export default function AIInstructionsPage() {
if (preset.id === activePresetId) {
await refreshPreset()
}
// 名称可能已修改,刷新当前预设状态(标题栏/高亮)
await refreshActivePreset()
}
const handleDeletePreset = async (id: string) => {
await deletePromptPreset(id)
setPresets(await getPromptPresets())
// 清除该预设的快捷键映射,避免残留注册
if (presetShortcuts[id]) {
const next = { ...presetShortcuts }
delete next[id]
setPresetShortcutsState(next)
await setPresetShortcuts(next)
bridge.notifyShortcutsChanged()
}
if (id === activePresetId) {
setActiveId('intent')
await setActivePresetId('intent')
await refreshPreset()
await refreshActivePreset()
}
}
@@ -96,6 +130,7 @@ export default function AIInstructionsPage() {
presets={presets}
activePresetId={activePresetId}
editingPreset={editingPreset}
presetShortcuts={presetShortcuts}
onSelectPreset={handleSelectPreset}
onStartNewPreset={handleNewPreset}
onStartEditing={setEditingPreset}
@@ -103,6 +138,7 @@ export default function AIInstructionsPage() {
onCancelEditing={() => setEditingPreset(null)}
onSavePreset={handleSavePreset}
onDeletePreset={handleDeletePreset}
onSetPresetShortcut={handleSetPresetShortcut}
/>
<AppPromptRulesSection
@@ -16,6 +16,7 @@ const AI_PROVIDERS = [
{ value: 'deepseek', label: 'DeepSeek', urlPlaceholder: 'https://api.deepseek.com', modelPlaceholder: 'deepseek-v4-flash' },
{ value: 'doubao', label: '豆包(火山方舟)', urlPlaceholder: 'https://ark.cn-beijing.volces.com/api/v3', modelPlaceholder: 'doubao-seed-2-0-lite-260215' },
{ value: 'qwen', label: '通义千问', urlPlaceholder: 'https://dashscope.aliyuncs.com/compatible-mode', modelPlaceholder: 'qwen-plus' },
{ value: 'mimo', label: '小米 MiMo', urlPlaceholder: 'https://api.xiaomimimo.com/v1', modelPlaceholder: 'mimo-v2.5-pro' },
{ value: 'ollama', label: 'Ollama', urlPlaceholder: 'http://127.0.0.1:11434', modelPlaceholder: 'qwen2.5:7b' },
]
@@ -103,12 +104,19 @@ export default function AIProviderSection() {
const DEFAULT_MODELS: Record<string, string> = {
deepseek: 'deepseek-v4-flash',
qwen: 'qwen3.6-flash',
mimo: 'mimo-v2.5',
}
// 部分供应商首次使用时种入多个可选模型(默认选第一个)
const DEFAULT_MODEL_LISTS: Record<string, string[]> = {
mimo: ['mimo-v2.5', 'mimo-v2.5-pro'],
}
// 每个供应商的默认 API 地址(新用户首次使用时自动填充)
const DEFAULT_URLS: Record<string, string> = {
deepseek: 'https://api.deepseek.com',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode',
mimo: 'https://api.xiaomimimo.com/v1',
}
// 加载指定供应商的配置
@@ -123,11 +131,17 @@ export default function AIProviderSection() {
if (!url && defaultUrl) {
url = defaultUrl
}
// 新用户首次使用:如果没有任何模型配置,自动填充默认模型
const defaultModel = DEFAULT_MODELS[provider]
if (!model && models.length === 0 && defaultModel) {
model = defaultModel
models = [defaultModel]
// 新用户首次使用:如果没有任何模型配置,自动填充默认模型(支持多个)
if (!model && models.length === 0) {
const defaultList = DEFAULT_MODEL_LISTS[provider]
const defaultModel = DEFAULT_MODELS[provider]
if (defaultList && defaultList.length > 0) {
models = [...defaultList]
model = defaultList[0]
} else if (defaultModel) {
model = defaultModel
models = [defaultModel]
}
}
// 兼容旧数据:如果有 model 但不在 models 列表里,加进去
if (model && !models.includes(model)) {
@@ -138,6 +152,7 @@ export default function AIProviderSection() {
setAiModel(model)
setAiModels(models)
setNewModelInput('')
return { url, key, model }
}
async function loadSettings() {
@@ -148,8 +163,16 @@ export default function AIProviderSection() {
function handleProviderChange(newProvider: string) {
setAiProvider(newProvider)
setAiMessage('')
void setSetting('cloudAi.provider', newProvider)
void loadProviderConfig(newProvider)
void (async () => {
const cfg = await loadProviderConfig(newProvider)
// 立即同步全局 active 配置(provider + 地址/Key/模型),
// 否则切换供应商但未点保存时,实际仍会用上一个供应商的地址与模型
// (典型现象:切到豆包却显示/调用 deepseek-v4-flash)
await setSetting('cloudAi.provider', newProvider)
await setSetting('cloudAi.apiUrl', cfg.url)
await setSetting('cloudAi.apiKey', cfg.key)
await setSetting('cloudAi.model', cfg.model)
})()
}
// 如果输入框有内容,自动添加到模型列表并选中
@@ -208,6 +231,13 @@ export default function AIProviderSection() {
function handleSelectModel(name: string) {
setAiModel(name)
// 点击模型标签立即持久化(该供应商 + 全局),无需再点保存,
// 否则运行时与历史记录仍会用上一次保存的模型。
void (async () => {
await setSetting(aiSettingKey(aiProvider, 'model'), name)
await setSetting('cloudAi.provider', aiProvider)
await setSetting('cloudAi.model', name)
})()
}
async function testConnection() {
@@ -110,6 +110,7 @@ export default function AsrTestSection({ workMode }: { workMode: WorkMode }) {
const ASR_MODEL_ID_MAP: Record<string, string> = {
doubao_v2: 'Doubao-Seed-ASR-2.0',
qwen: 'qwen3-asr-flash',
mimo: 'mimo-v2.5-asr',
qwen_omni_35_plus: 'qwen3.5-omni-plus-realtime',
qwen_omni_35_flash: 'qwen3.5-omni-flash-realtime',
qwen_omni_flash: 'qwen3-omni-flash-realtime',
@@ -15,6 +15,7 @@ const ASR_PROVIDERS = [
{ value: 'qwen_realtime', label: '千问 ASR 流式(qwen3-asr-flash-realtime)' },
{ value: 'qwen_omni_35_plus', label: '千问 3.5 Omni Plus(qwen3.5-omni-plus,ASR+AI)' },
{ value: 'qwen_omni_35_flash', label: '千问 3.5 Omni Flash(qwen3.5-omni-flash,ASR+AI)' },
{ value: 'mimo', label: '小米 MiMo(mimo-v2.5-asr)' },
]
interface TestResult {
@@ -72,6 +73,7 @@ const OMNI_PROMPT_PRESETS = [
// 供应商按平台分组,同平台共享 API Key
function asrKeyGroup(provider: string): string {
if (provider === 'doubao_v2' || provider === 'doubao') return 'doubao'
if (provider === 'mimo') return 'mimo' // 小米 MiMo 用独立 api-key
return 'qwen' // qwen, qwen_omni_flash, qwen_omni_plus 都用百炼 key
}
@@ -87,6 +89,8 @@ function checkAsrKeyFormat(provider: string, key: string): string {
if (k.length !== 32) {
return `豆包 Access Token 通常为 32 位(当前 ${k.length} 位),请确认是否正确`
}
} else if (provider === 'mimo') {
// 小米 MiMo API Key 无公开固定格式约定,不做格式校验
} else {
// 百炼平台 API Key:通常以 sk- 开头
if (!/^sk-/.test(k)) {
@@ -118,20 +122,11 @@ export default function CloudAPISection() {
const [asrMessage, setAsrMessage] = useState('')
const [omniSystemPrompt, setOmniSystemPrompt] = useState(DEFAULT_OMNI_PROMPT)
// 加载指定平台的 ASR key
// 加载指定平台的 ASR key(每个供应商分组独立,不回退到全局,避免带入其它供应商的 key)
async function loadAsrKeys(provider: string) {
const group = asrKeyGroup(provider)
// 先尝试读分组 key,fallback 到旧的全局 key(向后兼容)
const groupKey = await getSetting(`cloudAsr.${group}.apiKey`, '') as string
const groupAppId = await getSetting(`cloudAsr.${group}.appId`, '') as string
if (groupKey) {
setAsrApiKey(groupKey)
setAsrAppId(groupAppId)
} else {
// 向后兼容:读旧的全局 key
setAsrApiKey(await getSetting('cloudAsr.apiKey', '') as string)
setAsrAppId(await getSetting('cloudAsr.appId', '') as string)
}
setAsrApiKey(await getSetting(`cloudAsr.${group}.apiKey`, '') as string)
setAsrAppId(await getSetting(`cloudAsr.${group}.appId`, '') as string)
}
useEffect(() => {
@@ -141,6 +136,16 @@ export default function CloudAPISection() {
async function loadSettings() {
const provider = await getSetting('cloudAsr.provider', 'doubao_v2') as string
setAsrProvider(provider)
// 一次性迁移:老版本只有全局 key,迁到「当前供应商」的分组,升级后不丢 key,也不污染其它供应商
const group = asrKeyGroup(provider)
const existingGroupKey = await getSetting(`cloudAsr.${group}.apiKey`, '') as string
if (!existingGroupKey) {
const legacyKey = await getSetting('cloudAsr.apiKey', '') as string
if (legacyKey) {
await setSetting(`cloudAsr.${group}.apiKey`, legacyKey)
await setSetting(`cloudAsr.${group}.appId`, await getSetting('cloudAsr.appId', '') as string)
}
}
await loadAsrKeys(provider)
setOmniSystemPrompt(await getSetting('cloudAsr.omniSystemPrompt', DEFAULT_OMNI_PROMPT) as string)
}
@@ -154,18 +159,12 @@ export default function CloudAPISection() {
const group = asrKeyGroup(newProvider)
const groupKey = await getSetting(`cloudAsr.${group}.apiKey`, '') as string
const groupAppId = await getSetting(`cloudAsr.${group}.appId`, '') as string
if (groupKey) {
setAsrApiKey(groupKey)
setAsrAppId(groupAppId)
// 自动同步到全局 key,这样 CloudAPIProvider 能读到
await setSetting('cloudAsr.apiKey', groupKey)
await setSetting('cloudAsr.appId', groupAppId)
} else {
const globalKey = await getSetting('cloudAsr.apiKey', '') as string
const globalAppId = await getSetting('cloudAsr.appId', '') as string
setAsrApiKey(globalKey)
setAsrAppId(globalAppId)
}
// 只显示该供应商自己的 key(未配置则为空),并同步到全局供运行时读取,
// 空也要写空,避免把上一个供应商的 key 带过来。
setAsrApiKey(groupKey)
setAsrAppId(groupAppId)
await setSetting('cloudAsr.apiKey', groupKey)
await setSetting('cloudAsr.appId', groupAppId)
})()
}
@@ -226,8 +225,8 @@ export default function CloudAPISection() {
))}
</select>
</div>
{/* 豆包:App ID 在 API Key 前面 */}
{asrProvider !== 'qwen' && !asrProvider.startsWith('qwen_omni') && (
{/* 豆包:App ID 在 API Key 前面(千问/Omni/MiMo 不需要 App ID) */}
{asrProvider !== 'qwen' && asrProvider !== 'mimo' && !asrProvider.startsWith('qwen_omni') && (
<div>
<label className="mb-1 block text-sm text-muted-foreground">App ID</label>
<input
@@ -253,7 +252,7 @@ export default function CloudAPISection() {
<PasswordInput
value={asrApiKey}
onChange={setAsrApiKey}
placeholder={asrProvider === 'doubao_v2' ? '输入火山引擎 Access Token' : '输入百炼平台 API Key'}
placeholder={asrProvider === 'doubao_v2' ? '输入火山引擎 Access Token' : asrProvider === 'mimo' ? '输入小米 MiMo API Key' : '输入百炼平台 API Key'}
className={inputClass}
/>
</div>
@@ -27,6 +27,7 @@ export default function GeneralSettingsPage() {
const [testing, setTesting] = useState(false)
const [volumeLevel, setVolumeLevel] = useState<MicVolumeLevel>('idle')
const [micError, setMicError] = useState('')
const [muteSystemAudio, setMuteSystemAudio] = useState(false)
const [pttKey, setPttKey] = useState('AltLeft')
const [handsFreeKey, setHandsFreeKey] = useState('Alt+L')
const [audioRetentionEnabled, setAudioRetentionEnabled] = useState(true)
@@ -41,6 +42,7 @@ export default function GeneralSettingsPage() {
bridge.getAutoLaunch().then(setAutoLaunch)
getSetting('autoCheckUpdate', true).then((value) => setAutoCheckUpdate(Boolean(value)))
getSetting('selectedMic', '').then(setSelectedMic)
getSetting('muteSystemAudioWhileRecording', false).then((value) => setMuteSystemAudio(Boolean(value)))
getSetting('shortcutPTT', 'AltRight').then((value) => setPttKey(value as string))
getSetting('shortcutHandsFree', 'Alt+L').then((value) => setHandsFreeKey(value as string))
getSetting('audioRetentionEnabled', true).then((value) => setAudioRetentionEnabled(Boolean(value)))
@@ -59,6 +61,7 @@ export default function GeneralSettingsPage() {
const toggleAutoLaunch = async () => { const next = !autoLaunch; setAutoLaunch(next); await bridge.setAutoLaunch(next) }
const toggleAutoCheckUpdate = async () => { const next = !autoCheckUpdate; setAutoCheckUpdate(next); await setSetting('autoCheckUpdate', next) }
const handleMicChange = async (deviceId: string) => { setSelectedMic(deviceId); await setSetting('selectedMic', deviceId); await refreshRecorderSettings() }
const toggleMuteSystemAudio = async () => { const next = !muteSystemAudio; setMuteSystemAudio(next); await setSetting('muteSystemAudioWhileRecording', next); await refreshRecorderSettings() }
const toggleAudioRetention = async () => { const next = !audioRetentionEnabled; setAudioRetentionEnabled(next); await setSetting('audioRetentionEnabled', next) }
const toggleReadySound = async () => { const next = !readySoundEnabled; setReadySoundEnabled(next); await setSetting('readySoundEnabled', next); await refreshRecorderSettings() }
const handleAudioRetentionDaysChange = async (value: number) => { setAudioRetentionDays(value); await setSetting('audioRetentionDays', value) }
@@ -161,7 +164,7 @@ export default function GeneralSettingsPage() {
</Card>
<Card>
<CardContent className="p-6">
<CardContent className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">录音就绪提示音</p>
@@ -169,6 +172,13 @@ export default function GeneralSettingsPage() {
</div>
<Switch checked={readySoundEnabled} onChange={() => void toggleReadySound()} />
</div>
<div className="flex items-center justify-between border-t border-border pt-4">
<div>
<p className="text-sm font-medium">录音时静音系统声音</p>
<p className="text-xs text-muted-foreground">按住说话期间临时静音外放,避免被麦克风录入</p>
</div>
<Switch checked={muteSystemAudio} onChange={() => void toggleMuteSystemAudio()} />
</div>
</CardContent>
</Card>
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { FolderOpen, Copy, Check } from 'lucide-react'
import { FolderOpen, Copy, Check, Zap, CheckCircle2, X, type LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tooltip } from '@/components/ui/tooltip'
@@ -29,6 +29,10 @@ interface ModelInfo {
total_size_bytes: number
languages: string[]
sources: DownloadSource[]
archive_url?: string
speed?: number
accuracy?: number
recommended?: boolean
}
interface LocalModelInfo {
@@ -58,6 +62,23 @@ function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
/** 速度/准确度评级:10 分制进度条(图标 + 单色 + 小数分数) */
function Rating({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: number }) {
const pct = Math.max(0, Math.min(100, (value / 10) * 100))
return (
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-[11px] text-muted-foreground">{label}</span>
<div className="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-foreground/60 transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="shrink-0 text-[11px] font-medium tabular-nums text-foreground/80">
{value.toFixed(1)}
</span>
</div>
)
}
function CopyLink({ url, label }: { url: string; label: string }) {
const [copied, setCopied] = useState(false)
return (
@@ -89,7 +110,15 @@ function OfflineGuideDialog({ models, onClose }: { models: ModelInfo[]; onClose:
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-[520px] max-h-[80vh] overflow-y-auto rounded-xl border bg-card p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="relative w-[520px] max-h-[80vh] overflow-y-auto rounded-xl border bg-card p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
<h3 className="text-base font-semibold">离线下载指引</h3>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
从下方复制链接,在浏览器中下载文件,然后放到对应的模型文件夹中。
@@ -122,8 +151,16 @@ function OfflineGuideDialog({ models, onClose }: { models: ModelInfo[]; onClose:
{/* 模型文件链接 */}
<div className="mt-4 space-y-4">
{models.map((model) => {
const source = model.sources[selectedSource]
if (!source) return null
// 某模型没有当前标签对应的源时,回退到它的第一个源(如 Qwen3 仅 ModelScope)
const source = model.sources[selectedSource] ?? model.sources[0]
const isArchive = !source && !!model.archive_url
if (!source && !isArchive) return null
// GitHub 地址用国内代理加速手动下载
const archiveUrl = model.archive_url
? (model.archive_url.startsWith('https://github.com/')
? `https://gh-proxy.com/${model.archive_url}`
: model.archive_url)
: ''
return (
<div key={model.id}>
<div className="mb-2 flex items-center gap-2">
@@ -140,18 +177,24 @@ function OfflineGuideDialog({ models, onClose }: { models: ModelInfo[]; onClose:
</Tooltip>
</div>
<div className="space-y-1.5">
{source.files.map((file) => (
<CopyLink key={file.name} url={file.url} label={file.name} />
))}
{source ? (
source.files.map((file) => (
<CopyLink key={file.name} url={file.url} label={file.name} />
))
) : (
<>
<p className="text-[11px] leading-relaxed text-muted-foreground">
压缩包,下载后解压到上面的模型文件夹(解压出 .onnx 等文件直接放入该目录即可)。
</p>
<CopyLink url={archiveUrl} label="模型压缩包 (.tar.bz2)" />
</>
)}
</div>
</div>
)
})}
</div>
<div className="mt-5 flex justify-end">
<Button size="sm" variant="outline" onClick={onClose}>关闭</Button>
</div>
</div>
</div>
)
@@ -164,9 +207,9 @@ function OfflineGuide({ models }: { models: ModelInfo[] }) {
<button
type="button"
onClick={() => setOpen(true)}
className="mt-3 text-xs text-muted-foreground transition-colors hover:text-foreground"
className="mt-3 text-xs text-muted-foreground underline decoration-muted-foreground/40 underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/60"
>
无法在线下载?查看离线下载指引
下载太慢或失败?查看手动下载指引
</button>
{open && <OfflineGuideDialog models={models} onClose={() => setOpen(false)} />}
</>
@@ -361,6 +404,9 @@ export default function LocalModeSection() {
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{model.recommended && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">推荐</span>
)}
<span className="text-xs text-muted-foreground">
{model.total_size_bytes > 0 ? formatSize(model.total_size_bytes) : ''}
</span>
@@ -369,6 +415,12 @@ export default function LocalModeSection() {
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">{model.description}</p>
{(model.speed || model.accuracy) && (
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5">
{model.speed ? <Rating icon={Zap} label="速度" value={model.speed} /> : null}
{model.accuracy ? <Rating icon={CheckCircle2} label="准确度" value={model.accuracy} /> : null}
</div>
)}
{isDownloading && progress && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
@@ -1,11 +1,14 @@
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { BUILTIN_PRESETS, type PromptPreset } from '@/services/store'
import { ComboShortcutInput } from './ShortcutInputs'
import { displayAccelerator } from './utils'
export default function PromptPresetSection({
presets,
activePresetId,
editingPreset,
presetShortcuts,
onSelectPreset,
onStartNewPreset,
onStartEditing,
@@ -13,10 +16,12 @@ export default function PromptPresetSection({
onCancelEditing,
onSavePreset,
onDeletePreset,
onSetPresetShortcut,
}: {
presets: PromptPreset[]
activePresetId: string
editingPreset: PromptPreset | null
presetShortcuts: Record<string, string>
onSelectPreset: (id: string) => void
onStartNewPreset: () => void
onStartEditing: (preset: PromptPreset) => void
@@ -24,6 +29,7 @@ export default function PromptPresetSection({
onCancelEditing: () => void
onSavePreset: (preset: PromptPreset) => void
onDeletePreset: (id: string) => void
onSetPresetShortcut: (presetId: string, accelerator: string) => void
}) {
return (
<Card>
@@ -68,6 +74,11 @@ export default function PromptPresetSection({
{preset.builtin && (
<span className="rounded border px-1 text-xs text-muted-foreground">内置</span>
)}
{presetShortcuts[preset.id] && (
<span className="rounded border border-primary/30 bg-primary/5 px-1.5 text-xs text-muted-foreground">
{displayAccelerator(presetShortcuts[preset.id]).join('+')}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{preset.systemPrompt.slice(0, 60)}...</p>
</div>
@@ -130,6 +141,16 @@ export default function PromptPresetSection({
/>
</div>
<div className="border-t border-border/60 pt-3">
<ComboShortcutInput
value={presetShortcuts[editingPreset.id] || ''}
onChange={(accel) => onSetPresetShortcut(editingPreset.id, accel)}
label="快捷键(可选)"
description="设置组合键随时切换到此模式,如 Alt+1、Alt+2(需含修饰键)"
comboOnly
/>
</div>
<div className="flex justify-end gap-2">
{editingPreset.builtin && (
<button
+13 -3
View File
@@ -1,8 +1,10 @@
// 服务器模式配置 — 服务地址 + 连接状态
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tooltip } from '@/components/ui/tooltip'
import {
getBackendBaseUrl,
getDefaultBackendBaseUrl,
@@ -58,17 +60,19 @@ export default function ServerSection() {
setServiceSaving(true)
const next = await persistBackendBaseUrl(normalized)
setBackendBaseUrl(next)
// 地址已变更:无论下方健康检查成功与否,都按新地址强制重连,
// 让左下角连接状态反映新配置(改成错误地址后应显示未连接,而非仍旧"已连接")
reconnectProvider()
} catch (error) {
setServiceMessage(`保存失败:${String(error)}`); setServiceSaving(false); return
}
// 再测试
// 再测试(仅用于给出提示文案,实际连接状态由上面的重连驱动)
try {
const response = await fetch(`${normalized}/healthz`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json() as { status?: string; asr?: boolean; llm?: boolean }
setServiceMessage(`已保存,连接成功:ASR=${payload.asr ? 'on' : 'off'},LLM=${payload.llm ? 'on' : 'off'}`)
reconnectProvider()
} catch (error) {
setServiceMessage(`已保存,但连接失败:${String(error)}`)
} finally {
@@ -83,8 +87,14 @@ export default function ServerSection() {
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">服务地址</h2>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 flex items-center gap-1 text-sm text-muted-foreground">
输入你部署的 SayIt 服务器地址,保存后客户端会自动连接。
<Tooltip
variant="light"
content={'默认地址 sayitapp.site 是作者提供的免费体验服务器,已内置语音识别与 AI 功能,方便快速试用。\n由于服务器运行存在成本,服务不保证长期稳定可用。\n如需稳定使用,建议使用本地识别、接入豆包 API,或自行部署后端服务。'}
>
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground/70 transition-colors hover:text-foreground" />
</Tooltip>
</p>
</div>
</div>
+39 -14
View File
@@ -1,5 +1,5 @@
import * as bridge from '@/services/bridge'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import {
displayAccelerator,
@@ -82,50 +82,68 @@ export function ComboShortcutInput({
onChange,
label,
description,
comboOnly = false,
}: {
value: string
onChange: (value: string) => void
label: string
description: string
/** 仅接受"修饰键+主键"的组合键,拒绝单键/单修饰键(用于预设切换快捷键)。 */
comboOnly?: boolean
}) {
const [recording, setRecording] = useState(false)
const [tempValue, setTempValue] = useState('')
const [conflict, setConflict] = useState(false)
// 一次录制只提交/探测一次:松开组合键会产生多个 keyup,
// 若不加守卫会对同一组合键重复调用 test_shortcut,第二次因“自己刚注册”而误报冲突。
const committingRef = useRef(false)
const handleKeyDown = useCallback((event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
// 优先检查是否为单键(和 PTT 一样的单键列表)
const singleKey = resolveSingleKeyShortcut(event.code)
if (singleKey) {
setTempValue(singleKey)
return
// 非 comboOnly:优先接受单键(如免提用右 Alt)
if (!comboOnly) {
const singleKey = resolveSingleKeyShortcut(event.code)
if (singleKey) {
setTempValue(singleKey)
return
}
}
// 否则尝试组合键
// 组合键:仅当"修饰键 + 主键"时 eventToAccelerator 才返回值;
// 单独按修饰键(Ctrl/Alt/Shift)返回 null,不会被误提交。
const accelerator = eventToAccelerator(event)
if (accelerator) setTempValue(accelerator)
}, [])
}, [comboOnly])
const handleKeyUp = useCallback(() => {
if (!tempValue) return
if (!tempValue || committingRef.current) return
// 如果是单键,直接保存
// 如果是单键:comboOnly 模式拒绝,非 comboOnly 直接保存
const isSingle = resolveSingleKeyShortcut(tempValue) !== undefined
if (isSingle) {
if (comboOnly) return
committingRef.current = true
onChange(tempValue)
setRecording(false)
setTempValue('')
return
}
// 组合键需要验证
// 组合键需要验证(探测是否被其他程序占用)。加守卫避免多次 keyup 重复探测。
committingRef.current = true
bridge.testShortcut(tempValue).then((valid) => {
if (valid) onChange(tempValue)
if (valid) {
onChange(tempValue)
setConflict(false)
} else {
setConflict(true)
}
setRecording(false)
setTempValue('')
})
}, [tempValue, onChange])
}, [tempValue, onChange, comboOnly])
useEffect(() => {
if (!recording) return
@@ -146,7 +164,8 @@ export function ComboShortcutInput({
: displayAccelerator(displayValue)
return (
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
@@ -154,8 +173,10 @@ export function ComboShortcutInput({
<div className="flex items-center gap-2">
<button
onClick={() => {
committingRef.current = false
setRecording(!recording)
setTempValue('')
setConflict(false)
}}
className={`flex items-center gap-1 rounded-md border px-2 py-1.5 text-sm transition-colors ${
recording
@@ -185,6 +206,10 @@ export function ComboShortcutInput({
</button>
)}
</div>
</div>
{conflict && (
<p className="mt-1.5 text-xs text-destructive">该组合键可能已被其他程序占用,请更换后重试</p>
)}
</div>
)
}
+2 -53
View File
@@ -1,63 +1,12 @@
export type OverlayWaveTheme = 'black-white' | 'black-blue' | 'black-rainbow'
const SINGLE_KEY_MAP: Record<string, string> = {
AltLeft: 'AltLeft',
AltRight: 'AltRight',
ControlLeft: 'ControlLeft',
ControlRight: 'ControlRight',
ShiftLeft: 'ShiftLeft',
ShiftRight: 'ShiftRight',
F1: 'F1',
F2: 'F2',
F3: 'F3',
F4: 'F4',
F5: 'F5',
F6: 'F6',
F7: 'F7',
F8: 'F8',
F9: 'F9',
F10: 'F10',
F11: 'F11',
F12: 'F12',
CapsLock: 'CapsLock',
Space: 'Space',
}
const SINGLE_KEY_DISPLAY: Record<string, string> = {
AltLeft: '左 Alt',
AltRight: '右 Alt',
ControlLeft: '左 Ctrl',
ControlRight: '右 Ctrl',
ShiftLeft: '左 Shift',
ShiftRight: '右 Shift',
F1: 'F1',
F2: 'F2',
F3: 'F3',
F4: 'F4',
F5: 'F5',
F6: 'F6',
F7: 'F7',
F8: 'F8',
F9: 'F9',
F10: 'F10',
F11: 'F11',
F12: 'F12',
CapsLock: 'CapsLock',
Space: '空格',
}
// 单键映射已收敛到 @/lib/shortcutKeys(单一数据源),这里透传导出以保持既有引用不变。
export { resolveSingleKeyShortcut, getSingleKeyDisplay } from '@/lib/shortcutKeys'
export function cleanMicLabel(label: string): string {
return label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim()
}
export function resolveSingleKeyShortcut(code: string): string | undefined {
return SINGLE_KEY_MAP[code]
}
export function getSingleKeyDisplay(value: string): string {
return SINGLE_KEY_DISPLAY[value] || value
}
export function eventToAccelerator(event: KeyboardEvent): string | null {
const parts: string[] = []
if (event.ctrlKey || event.metaKey) parts.push('CommandOrControl')
@@ -0,0 +1,22 @@
// 本次版本更新亮点(关于页面展示)。
// 每次发版时更新 version 与 items,保持与 CHANGELOG 同步。
// version 需与打包版本一致,关于页面仅在与当前版本匹配时展示,避免串版。
//
// 每条写成一句话:前半句说明功能,后半句补充解释,读起来更顺。
export interface ReleaseHighlights {
version: string
items: string[]
}
export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
version: '0.0.8',
items: [
'本地识别新增 Qwen3-ASR 模型,离线也能获得更准的多语种识别',
'润色模式支持快捷键切换,设置组合键即可随时一键切换',
'文本替换支持批量添加,可粘贴多行一次导入多条规则',
'录音时可静音系统声音,避免外放被麦克风录进去',
'云端识别与 AI 润色新增小米 MiMo,填入 API Key 即可使用',
'多项体验优化与修复,涵盖悬浮窗、热键、设置向导与本地识别',
],
}
+6
View File
@@ -0,0 +1,6 @@
import { useSyncExternalStore } from 'react'
import { subscribeActivePreset, getActivePresetSnapshot } from '@/stores/activePreset'
export function useActivePreset() {
return useSyncExternalStore(subscribeActivePreset, getActivePresetSnapshot)
}
@@ -35,6 +35,7 @@ describe('resolveAsrDisplayModel', () => {
it('映射已知供应商', () => {
expect(resolveAsrDisplayModel('doubao_v2')).toBe('Doubao-Seed-ASR-2.0')
expect(resolveAsrDisplayModel('qwen')).toBe('qwen3-asr-flash')
expect(resolveAsrDisplayModel('mimo')).toBe('mimo-v2.5-asr')
expect(resolveAsrDisplayModel('qwen_omni_35_plus')).toBe('qwen3.5-omni-plus-realtime')
})
+1
View File
@@ -24,6 +24,7 @@ export function resolveQwenOmniModel(provider: string): string | undefined {
const ASR_DISPLAY_MODEL_MAP: Record<string, string> = {
doubao_v2: 'Doubao-Seed-ASR-2.0',
qwen: 'qwen3-asr-flash',
mimo: 'mimo-v2.5-asr',
...QWEN_OMNI_MODEL_MAP,
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 单键热键定义 —— 单一数据源(Single Source of Truth)。
*
* 过去单键的映射散落在四处(Rust 钩子、webview 回退、设置 utils、向导 KEY_MAP),
* 且已互相不一致,加一个键要改四处、极易漏。此文件统一 TS 侧的定义,其它模块一律引用这里。
*
* ⚠️ Rust 端 `client/src-tauri/src/keyboard/mod.rs` 的 `SINGLE_KEY_TABLE`
* (vk_codes_for_setting / is_single_key_setting)无法直接引用 TS,
* 新增/修改单键时务必同步那张表。
*
* 约定:`setting` 的取值直接等于 DOM `KeyboardEvent.code`,因此
* setting ↔ code 是恒等映射,无需再单独维护一张 code 表。
*/
export interface SingleKeyDef {
/** 存入设置、也等于 DOM KeyboardEvent.code */
setting: string
/** Windows 虚拟键码(供 webview 回退补发事件用) */
vk: number
/** 中文显示名 */
label: string
}
export const SINGLE_KEYS: SingleKeyDef[] = [
// 修饰键(左右分开)
{ setting: 'AltLeft', vk: 0xa4, label: '左 Alt' },
{ setting: 'AltRight', vk: 0xa5, label: '右 Alt' },
{ setting: 'ControlLeft', vk: 0xa2, label: '左 Ctrl' },
{ setting: 'ControlRight', vk: 0xa3, label: '右 Ctrl' },
{ setting: 'ShiftLeft', vk: 0xa0, label: '左 Shift' },
{ setting: 'ShiftRight', vk: 0xa1, label: '右 Shift' },
// 常见低冲突键
{ setting: 'CapsLock', vk: 0x14, label: 'Caps Lock' },
{ setting: 'Space', vk: 0x20, label: '空格' },
{ setting: 'ContextMenu', vk: 0x5d, label: '菜单键' },
{ setting: 'Pause', vk: 0x13, label: 'Pause' },
{ setting: 'ScrollLock', vk: 0x91, label: 'ScrollLock' },
{ setting: 'Insert', vk: 0x2d, label: 'Insert' },
// 功能键
{ setting: 'F1', vk: 0x70, label: 'F1' },
{ setting: 'F2', vk: 0x71, label: 'F2' },
{ setting: 'F3', vk: 0x72, label: 'F3' },
{ setting: 'F4', vk: 0x73, label: 'F4' },
{ setting: 'F5', vk: 0x74, label: 'F5' },
{ setting: 'F6', vk: 0x75, label: 'F6' },
{ setting: 'F7', vk: 0x76, label: 'F7' },
{ setting: 'F8', vk: 0x77, label: 'F8' },
{ setting: 'F9', vk: 0x78, label: 'F9' },
{ setting: 'F10', vk: 0x79, label: 'F10' },
{ setting: 'F11', vk: 0x7a, label: 'F11' },
{ setting: 'F12', vk: 0x7b, label: 'F12' },
]
/** setting → 虚拟键码 */
export const SETTING_TO_VK: Record<string, number> = Object.fromEntries(
SINGLE_KEYS.map((k) => [k.setting, k.vk]),
)
const SINGLE_KEY_DISPLAY: Record<string, string> = Object.fromEntries(
SINGLE_KEYS.map((k) => [k.setting, k.label]),
)
/** 是否为受支持的单键设置 */
export function isSingleKeySetting(setting: string): boolean {
return setting in SETTING_TO_VK
}
/** 若 DOM code 对应一个受支持的单键,返回其 setting(= code),否则 undefined */
export function resolveSingleKeyShortcut(code: string): string | undefined {
return isSingleKeySetting(code) ? code : undefined
}
/** setting → DOM code(恒等,未知返回空串) */
export function settingToCode(setting: string): string {
return isSingleKeySetting(setting) ? setting : ''
}
/** 单键显示名(未知原样返回) */
export function getSingleKeyDisplay(value: string): string {
return SINGLE_KEY_DISPLAY[value] || value
}
+16 -3
View File
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Copy, Check } from 'lucide-react'
import { addRuntimeEvent } from '../services/debugLog'
type OverlayState = 'waiting' | 'listening' | 'thinking' | 'fallback' | 'error'
type OverlayState = 'waiting' | 'listening' | 'thinking' | 'fallback' | 'error' | 'toast'
type OverlayWaveTheme = 'black-white' | 'black-blue' | 'black-rainbow'
interface OverlayPayload {
@@ -17,6 +17,7 @@ interface OverlayPayload {
fallbackReason?: string
errorMessage?: string
warning?: string
toastText?: string
}
const DEFAULT_BAR_COUNT = 24
@@ -69,6 +70,7 @@ export default function Overlay() {
const [barCount, setBarCount] = useState(DEFAULT_BAR_COUNT)
const [fallbackText, setFallbackText] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [toastText, setToastText] = useState('')
const [copied, setCopied] = useState(false)
const [thinkingDuration, setThinkingDuration] = useState(0)
const [warning, setWarning] = useState('')
@@ -133,6 +135,9 @@ export default function Overlay() {
if (typeof payload.errorMessage === 'string') {
setErrorMessage(payload.errorMessage)
}
if (typeof payload.toastText === 'string') {
setToastText(payload.toastText)
}
if (typeof payload.warning === 'string') {
setWarning(payload.warning)
}
@@ -179,7 +184,7 @@ export default function Overlay() {
<div className="pointer-events-none flex h-full items-end justify-center pb-4">
{state === 'fallback' ? (
<div
className="pointer-events-auto flex w-full max-w-[520px] flex-col rounded-xl border px-4 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.25)]"
className="pointer-events-auto flex w-full max-w-[520px] flex-col rounded-xl border px-4 py-4"
style={{
background: 'var(--overlay-bg)',
color: 'var(--overlay-text)',
@@ -214,7 +219,7 @@ export default function Overlay() {
</div>
) : (
<div
className="flex items-center rounded-full border px-4 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.35)]"
className="flex items-center rounded-full border px-4 py-2"
style={{
background: 'var(--overlay-bg)',
color: 'var(--overlay-text)',
@@ -294,6 +299,14 @@ export default function Overlay() {
<span className="text-xs text-red-400">{errorMessage || '出错了'}</span>
</div>
)}
{state === 'toast' && (
<div className="flex items-center gap-2">
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--overlay-text)' }}>
{toastText}
</span>
</div>
)}
</div>
)}
</div>
+17
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Github, Download, Loader2, CheckCircle } from 'lucide-react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { useUpdateStatus } from '@/features/update/useUpdateStatus'
import { RELEASE_HIGHLIGHTS } from '@/features/update/releaseHighlights'
import appIcon from '@/assets/icon-128.png'
function formatTimestamp(value: number | null) {
@@ -56,6 +57,7 @@ export default function About() {
{/* 更新 */}
<div className="mt-5 border-t border-border pt-5">
<h3 className="mb-3 text-sm font-medium">软件更新</h3>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
{updateStatusText && (
@@ -108,6 +110,21 @@ export default function About() {
</div>
</div>
</div>
{/* 本次更新 */}
{RELEASE_HIGHLIGHTS.version === currentVersion && RELEASE_HIGHLIGHTS.items.length > 0 && (
<div className="mt-6">
<h3 className="mb-3 text-sm font-medium">本次更新 · v{RELEASE_HIGHLIGHTS.version}</h3>
<ul className="space-y-1.5">
{RELEASE_HIGHLIGHTS.items.map((item, index) => (
<li key={index} className="flex gap-2 text-sm text-muted-foreground">
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/50" />
<span>{item}</span>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
</div>
+3 -2
View File
@@ -227,6 +227,7 @@ async function reprocessViaCloudApi(
/** 本地模式重新识别:调用 local_transcribe + 可选 cloud_polish,与 LocalProvider 一致 */
async function reprocessViaLocal(
chunk: ArrayBuffer,
hotwords: string[],
aiEnabled: boolean,
systemPrompt: string | undefined,
): Promise<ReprocessResult> {
@@ -237,7 +238,7 @@ async function reprocessViaLocal(
const language = await getSetting('localAsr.language', 'auto') as string
const asrResult = await invoke<{ text: string; elapsed_ms: number }>('local_transcribe', {
audioB64, modelId, language,
audioB64, modelId, language, hotwords,
})
const asrText = asrResult.text
const asrMs = asrResult.elapsed_ms
@@ -420,7 +421,7 @@ export default function History() {
if (workMode === 'cloud_api') {
result = await reprocessViaCloudApi(chunk, hotwords, Boolean(aiEnabled), systemPrompt)
} else if (workMode === 'local') {
result = await reprocessViaLocal(chunk, Boolean(aiEnabled), systemPrompt)
result = await reprocessViaLocal(chunk, hotwords, Boolean(aiEnabled), systemPrompt)
} else {
result = await reprocessViaServer(chunk, hotwords, Boolean(aiEnabled), systemPrompt, clientMeta)
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Mic, Clock, Type, Zap } from 'lucide-react'
import { getStats, type Stats, getSetting } from '@/services/store'
import { SHORTCUTS_CHANGED_EVENT } from '@/services/bridge'
import FeedbackSection from '@/components/FeedbackSection'
export default function Home() {
@@ -10,7 +11,12 @@ export default function Home() {
useEffect(() => {
getStats().then(setStats)
getSetting('shortcutHandsFree', 'AltRight').then((value) => setHandsFreeKey(value as string))
const loadHandsFreeKey = () =>
getSetting('shortcutHandsFree', 'AltRight').then((value) => setHandsFreeKey(value as string))
void loadHandsFreeKey()
// 快捷键变化时(向导 / 设置页修改)实时刷新首页提示,无需切换路由
window.addEventListener(SHORTCUTS_CHANGED_EVENT, loadHandsFreeKey)
return () => window.removeEventListener(SHORTCUTS_CHANGED_EVENT, loadHandsFreeKey)
}, [])
// Format time display
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { applyReplacements, type TextReplacementRule } from '../textReplacement'
import { applyReplacements, parseBatchReplacements, type TextReplacementRule } from '../textReplacement'
function rule(from: string, to: string, enabled = true): TextReplacementRule {
return { id: '1', from, to, enabled }
@@ -43,3 +43,50 @@ describe('applyReplacements', () => {
expect(applyReplacements('', [rule('a', 'b')])).toBe('')
})
})
describe('parseBatchReplacements', () => {
it('英文逗号分隔', () => {
expect(parseBatchReplacements('安卓说话,按住说话')).toEqual([
{ from: '安卓说话', to: '按住说话' },
])
})
it('中文逗号分隔', () => {
expect(parseBatchReplacements('安卓说话,按住说话')).toEqual([
{ from: '安卓说话', to: '按住说话' },
])
})
it('制表符分隔(从表格粘贴)', () => {
expect(parseBatchReplacements('Cloud Code\tClaude Code')).toEqual([
{ from: 'Cloud Code', to: 'Claude Code' },
])
})
it('=> 与 -> 分隔', () => {
expect(parseBatchReplacements('a => b\nc -> d')).toEqual([
{ from: 'a', to: 'b' },
{ from: 'c', to: 'd' },
])
})
it('多行、忽略空行、去除首尾空白', () => {
const input = ' 原文1, 替换1 \n\n原文2,替换2\n'
expect(parseBatchReplacements(input)).toEqual([
{ from: '原文1', to: '替换1' },
{ from: '原文2', to: '替换2' },
])
})
it('无分隔符的行:替换为留空(删除)', () => {
expect(parseBatchReplacements('嗯')).toEqual([{ from: '嗯', to: '' }])
})
it('取最靠前的分隔符,替换内容可含其它逗号', () => {
expect(parseBatchReplacements('abc,a, b')).toEqual([{ from: 'abc', to: 'a, b' }])
})
it('原文为空的行忽略', () => {
expect(parseBatchReplacements(',只有替换')).toEqual([])
})
})
+3 -1
View File
@@ -492,7 +492,9 @@ export async function startCapture(
audio: {
channelCount: 1,
echoCancellation: false,
noiseSuppression: false,
// 默认开启降噪:部分机器麦克风底噪大(低频电流声),关闭降噪会原样录入。
// 回声消除/自动增益保持关闭,避免影响 ASR 音频。
noiseSuppression: true,
autoGainControl: false,
...(deviceId ? { deviceId: { exact: deviceId } } : {}),
},
+19
View File
@@ -95,6 +95,18 @@ export function copyText(text: string) {
return invoke('copy_text', { text })
}
// ─── 系统输出静音(录音期间防回采)───
/** 记录当前默认输出设备静音状态并将其静音。返回是否已处理。 */
export function muteSystemOutput() {
return invoke<boolean>('mute_system_output')
}
/** 恢复到 muteSystemOutput 之前记录的静音状态。 */
export function restoreSystemOutput() {
return invoke<boolean>('restore_system_output')
}
export function appendDebugLog(payload: unknown) {
invoke('append_debug_log', { payload })
}
@@ -166,8 +178,15 @@ export function saveExportBundle(payload: {
// ─── Shortcuts ───
/** 前端内部广播:快捷键设置已变化,供页面(如首页提示)实时刷新显示。 */
export const SHORTCUTS_CHANGED_EVENT = 'sayit:shortcuts-changed'
export function notifyShortcutsChanged() {
invoke('shortcuts_changed')
// 同时在前端广播,让依赖快捷键显示的页面无需切换路由即可刷新
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(SHORTCUTS_CHANGED_EVENT))
}
}
export function testShortcut(accelerator: string) {
+2 -1
View File
@@ -20,6 +20,7 @@ export const DEFAULTS: Record<string, unknown> = {
// ── 麦克风 ──
selectedMic: '', // 设备 ID,空字符串 = 系统默认
muteSystemAudioWhileRecording: false, // 按住说话期间静音系统其他声音(防外放被麦克风回采)。默认关闭
// ── AI 校对 ──
aiEnabled: true, // 是否开启 AI 校对。可选: true | false
@@ -32,7 +33,7 @@ export const DEFAULTS: Record<string, unknown> = {
'cloudAi.model': '',
// ── ASR(云 API)──
'cloudAsr.provider': 'doubao_v2', // 可选: 'doubao_v2' | 'qwen_realtime' | 'qwen_omni' | 'qwen_omni_turbo'
'cloudAsr.provider': 'doubao_v2', // 可选: 'doubao_v2' | 'qwen' | 'qwen_realtime' | 'qwen_omni' | 'qwen_omni_turbo' | 'mimo'
'cloudAsr.apiKey': '',
'cloudAsr.appId': '', // 豆包需要
'cloudAsr.omniSystemPrompt': '', // 千问 Omni 模式的 system prompt
+53 -2
View File
@@ -10,10 +10,28 @@ export interface FeedbackPayload {
app_version: string
timestamp: string
feedback_text: string
// 转录 + 该次会话的排错信息(仅在用户保留转录时发送)
transcript: {
asr_text: string
ai_text: string
duration_sec: number
// 会话排错信息
app_name?: string
app_id?: string
window_title?: string
process_name?: string
window_class?: string
asr_ms?: number
llm_ms?: number
audio_duration_sec?: number
char_count?: number
is_empty?: boolean
asr_provider?: string
ai_provider?: string
ai_model?: string
prompt_summary?: string
auto_applied_hotwords?: string[]
record_timestamp?: number
} | null
context: {
work_mode: string
@@ -25,6 +43,12 @@ export interface FeedbackPayload {
ai_preset_name: string
ai_system_prompt: string
ai_prompt_append: string
// 客户端环境(非个人信息,用于排错;不含 IP)
platform: string
os_version: string
system_locale: string
cpu_cores: number
memory_mb: number
}
}
@@ -40,7 +64,9 @@ export async function getLastTranscript(): Promise<HistoryRecord | null> {
}
/** 收集当前配置上下文 */
async function collectContext(): Promise<FeedbackPayload['context']> {
async function collectContext(
clientInfo: Awaited<ReturnType<typeof getClientRuntimeInfo>>,
): Promise<FeedbackPayload['context']> {
const workMode = getWorkMode()
const aiEnabled = await getSetting('aiEnabled', false) as boolean
const aiProvider = await getSetting('cloudAi.provider', '') as string
@@ -72,6 +98,11 @@ async function collectContext(): Promise<FeedbackPayload['context']> {
ai_preset_name: activePreset.name,
ai_system_prompt: activePreset.systemPrompt.slice(0, 5000),
ai_prompt_append: (aiPromptAppend || '').slice(0, 5000),
platform: clientInfo.platform || '',
os_version: clientInfo.osVersion || '',
system_locale: clientInfo.systemLocale || '',
cpu_cores: clientInfo.cpuCores || 0,
memory_mb: clientInfo.memoryMb || 0,
}
}
@@ -91,7 +122,7 @@ export async function submitFeedback(feedbackText: string, options?: { includeTr
// 收集信息
const clientInfo = await getClientRuntimeInfo()
const lastRecord = includeTranscript ? await getLastTranscript() : null
const context = await collectContext()
const context = await collectContext(clientInfo)
const payload: FeedbackPayload = {
machine_id: clientInfo.deviceId,
@@ -103,6 +134,26 @@ export async function submitFeedback(feedbackText: string, options?: { includeTr
asr_text: (lastRecord.asrText || '').slice(0, 5000),
ai_text: (lastRecord.llmText || '').slice(0, 5000),
duration_sec: lastRecord.durationSec || 0,
// 该次会话的排错信息
app_name: lastRecord.appName || undefined,
app_id: lastRecord.appId || undefined,
window_title: lastRecord.windowTitle || undefined,
process_name: lastRecord.processName || undefined,
window_class: lastRecord.windowClass || undefined,
asr_ms: lastRecord.asrMs || undefined,
llm_ms: lastRecord.llmMs || undefined,
audio_duration_sec: lastRecord.audioDurationSec || undefined,
char_count: lastRecord.charCount || undefined,
is_empty: lastRecord.isEmpty || undefined,
asr_provider: lastRecord.asrProvider || undefined,
ai_provider: lastRecord.aiProvider || undefined,
ai_model: lastRecord.aiModel || undefined,
prompt_summary: lastRecord.promptSummary || undefined,
auto_applied_hotwords:
lastRecord.autoAppliedHotwords && lastRecord.autoAppliedHotwords.length > 0
? lastRecord.autoAppliedHotwords
: undefined,
record_timestamp: lastRecord.timestamp || undefined,
}
: null,
context,
@@ -193,6 +193,21 @@ export class OverlayService {
bridge.hideOverlay()
}
/** 快捷键切换润色模式后,用悬浮窗短暂提示当前模式名,约 1.6s 后自动隐藏。 */
showPresetSwitched(name: string) {
bridge.updateOverlay({
state: 'toast',
toastText: `已切换到「${name}」`,
...this.getCommonPayload(),
})
bridge.showOverlay()
this.clearFallbackHideTimer()
this.fallbackHideId = setTimeout(() => {
bridge.hideOverlay()
this.clearFallbackHideTimer()
}, 1600)
}
/** 显示错误信息,几秒后自动隐藏 */
showError(message: string) {
bridge.updateOverlay({
@@ -6,8 +6,10 @@ import {
getActivePresetId,
getPromptPresets,
getSetting,
setActivePresetId,
type PromptPreset,
} from '../store'
import { setActivePresetKnown } from '../../stores/activePreset'
import { addRuntimeEvent } from '../debugLog'
import { saveRecordingAudio } from '../audioFileService'
import {
@@ -107,6 +109,12 @@ export class RecorderOrchestrator {
private lastPTTUpUsedModifier = false
private cachedMicId = ''
/** 是否在录音期间静音系统输出(防外放被麦克风回采)。默认关闭。 */
private cachedMuteSystemAudio = false
/** 标记本次录音是否已施加系统静音,用于配对恢复。 */
private systemMuteApplied = false
/** 延迟静音定时器(等提示音播完再静音)。 */
private systemMuteTimerId: ReturnType<typeof setTimeout> | null = null
private cachedPresets: PromptPreset[] = []
private cachedActivePresetId = 'intent'
private cachedAiEnabled = true
@@ -150,6 +158,85 @@ export class RecorderOrchestrator {
return this.state
}
/** 若已开启「录音时静音系统声音」,在就绪提示音播放之后再静音系统输出。
* 延迟略长于提示音时长(~150ms),避免把提示音也一起静掉。 */
private scheduleSystemMuteIfEnabled() {
if (!this.cachedMuteSystemAudio) return
this.clearSystemMuteTimer()
this.systemMuteTimerId = setTimeout(() => {
this.systemMuteTimerId = null
// 若此时已不在录音(短按等),不再静音,避免残留
if (this.state !== 'recording') return
this.applySystemMute()
}, 250)
}
private applySystemMute() {
if (this.systemMuteApplied) return
this.systemMuteApplied = true
void bridge.muteSystemOutput().catch((e) => {
this.systemMuteApplied = false
addRuntimeEvent('warn', 'recorder', '静音系统输出失败', { error: String(e) })
})
}
private clearSystemMuteTimer() {
if (this.systemMuteTimerId) {
clearTimeout(this.systemMuteTimerId)
this.systemMuteTimerId = null
}
}
/** 恢复系统输出到静音前的状态,并清除挂起的静音定时器。 */
private restoreSystemMuteIfNeeded() {
this.clearSystemMuteTimer()
if (!this.systemMuteApplied) return
this.systemMuteApplied = false
void bridge.restoreSystemOutput().catch((e) => {
addRuntimeEvent('warn', 'recorder', '恢复系统输出失败', { error: String(e) })
})
}
/** 未就绪时的简短提示文案(按当前工作模式区分)。 */
private notReadyMessage(): string {
switch (this.provider.mode) {
case 'server':
return '服务器未连接'
case 'local':
return '模型未就绪'
default:
return '服务未就绪'
}
}
/** 通过快捷键切换当前润色模式,并在空闲时用悬浮窗提示。 */
private async handlePresetSwitch(presetId: string) {
try {
// 优先用已缓存的预设列表,避免 IPC 等待导致的延时
let target = this.cachedPresets.find((p) => p.id === presetId)
if (!target) {
const presets = await getPromptPresets()
target = presets.find((p) => p.id === presetId)
this.cachedPresets = presets
}
if (!target) {
addRuntimeEvent('warn', 'recorder', '切换润色模式失败:未找到预设', { presetId })
return
}
// 立即生效:更新录音器缓存 + 通知 UI + 悬浮窗提示(均无需等待 IPC)
this.cachedActivePresetId = presetId
setActivePresetKnown(presetId, target.name)
if (this.state === 'idle') {
this.overlayService.showPresetSwitched(target.name)
}
addRuntimeEvent('info', 'recorder', '快捷键切换润色模式', { presetId, name: target.name })
// 持久化放到后台,不阻塞 UI 反馈
void setActivePresetId(presetId)
} catch (error) {
addRuntimeEvent('error', 'recorder', '切换润色模式异常', { error: String(error) })
}
}
/** 临时禁用/启用 PTT(用于欢迎向导热键确认步骤) */
setPttSuppressed(suppressed: boolean) {
this.pttSuppressed = suppressed
@@ -163,6 +250,13 @@ export class RecorderOrchestrator {
await this.refreshRuntimeSettings()
this.ensureConnection()
// 快捷键切换润色模式(由 Rust global_shortcut 触发)
void bridge.listen('switch-preset', (event: unknown) => {
const payload = (event as { payload?: { presetId?: string } })?.payload
const presetId = payload?.presetId
if (presetId) void this.handlePresetSwitch(presetId)
})
bridge.onPTTDown((payload) => {
this.notePTTDown(payload)
this.logPTTEvent('down', payload)
@@ -257,6 +351,7 @@ export class RecorderOrchestrator {
async refreshRuntimeSettings() {
const [
micId,
muteSystemAudio,
presets,
activePresetId,
aiEnabled,
@@ -264,6 +359,7 @@ export class RecorderOrchestrator {
userStats,
] = await Promise.all([
getSetting('selectedMic', ''),
getSetting('muteSystemAudioWhileRecording', false),
getPromptPresets(),
getActivePresetId(),
getSetting('aiEnabled', false),
@@ -272,6 +368,7 @@ export class RecorderOrchestrator {
])
this.cachedMicId = String(micId || '')
this.cachedMuteSystemAudio = Boolean(muteSystemAudio)
this.cachedPresets = presets
this.cachedActivePresetId = activePresetId
this.cachedAiEnabled = Boolean(aiEnabled)
@@ -311,8 +408,13 @@ export class RecorderOrchestrator {
}
}
/** 工作模式切换后重新连接 provider */
/** 工作模式或服务地址变更后强制重连 provider。
* 先断开旧连接再按新配置连接,确保连接状态与新地址一致(否则改错地址后仍显示"已连接")。
* 录音进行中不强制断开,避免打断当前会话。 */
reconnectProvider() {
if (this.state === 'idle') {
this.provider.disconnect()
}
this.ensureConnection()
}
@@ -369,6 +471,8 @@ export class RecorderOrchestrator {
}
this.overlayService.stopListeningTicker()
this.overlayService.resetWarnings()
// 安全网:若仍处于我们施加的系统静音中,确保恢复
this.restoreSystemMuteIfNeeded()
this.startRecordingLock = false
this.pendingStopWhileStarting = false
this.handsFreeMode = false
@@ -705,6 +809,16 @@ export class RecorderOrchestrator {
addRuntimeEvent('info', 'recorder', '开始录音请求已忽略', { state: this.state, locked: this.startRecordingLock })
return
}
// 未就绪(如 server 模式后端未连接):给出告警,不进入录音,避免悬浮窗卡住关不掉
if (!this.provider.isReady()) {
this.handsFreeMode = false
addRuntimeEvent('warn', 'recorder', '未就绪,忽略开始录音', { mode: this.provider.mode })
this.overlayService.showError(this.notReadyMessage())
this.ensureConnection()
return
}
this.startRecordingLock = true
this.pendingStopWhileStarting = false
this.timedOutProcessingContext = null
@@ -913,6 +1027,8 @@ export class RecorderOrchestrator {
}
this.startRecordingLock = false
this.overlayService.startListeningTicker()
// 就绪提示音已触发,稍后再静音系统输出(避免把提示音一起静掉)
this.scheduleSystemMuteIfEnabled()
armHandsFreeTimer()
// Check if PTT up arrived while we were initializing
@@ -927,6 +1043,8 @@ export class RecorderOrchestrator {
resolveCaptureReady!()
this.startRecordingLock = false
this.pendingStopWhileStarting = false
// 录音启动失败也要恢复系统输出,避免系统一直静音
this.restoreSystemMuteIfNeeded()
addRuntimeEvent('error', 'recorder', '开始录音失败', { error: String(error) })
try { await stopCapture() } catch { /* ignore */ }
this.provider.stop({ pttHoldMs: elapsedSecFromPerf(this.recordStartPerf) * 1000 })
@@ -965,6 +1083,9 @@ export class RecorderOrchestrator {
addRuntimeEvent('error', 'recorder', '停止采集失败', { error: String(error) })
}
// 采集已停止,恢复系统输出到静音前的状态
this.restoreSystemMuteIfNeeded()
const audioDur = this.getAudioDurationSec()
const pttHoldMs = elapsedSecFromPerf(this.recordStartPerf) * 1000
const wallTimeSec = pttHoldMs / 1000
@@ -1162,9 +1283,14 @@ export class RecorderOrchestrator {
private buildHistoryMetadata(promptResolution?: PromptResolution | null) {
const resolved = promptResolution || this.currentPromptResolution || undefined
const ctx = this.currentActiveAppContext
return {
appId: resolved?.appId,
appName: resolved?.appName,
// 录音时聚焦窗口的原始信息(用于反馈排错)
windowTitle: ctx?.windowTitle || undefined,
processName: ctx?.processName || undefined,
windowClass: ctx?.windowClass || undefined,
promptPresetId: resolved?.preset.id,
promptPresetName: resolved?.preset.name,
promptRuleId: resolved?.matchedRule?.id,
@@ -1193,6 +1319,7 @@ export class RecorderOrchestrator {
const ASR_MODEL_ID_MAP: Record<string, string> = {
doubao_v2: 'Doubao-Seed-ASR-2.0',
qwen: 'qwen3-asr-flash',
mimo: 'mimo-v2.5-asr',
qwen_omni_35_plus: 'qwen3.5-omni-plus-realtime',
qwen_omni_35_flash: 'qwen3.5-omni-flash-realtime',
qwen_omni_flash: 'qwen3-omni-flash-realtime',
+14
View File
@@ -27,6 +27,10 @@ export interface HistoryRecord {
audioFilePath?: string
appId?: string
appName?: string
// 录音时聚焦窗口的原始信息(用于反馈排错)
windowTitle?: string
processName?: string
windowClass?: string
promptPresetId?: string
promptPresetName?: string
promptRuleId?: string
@@ -329,6 +333,16 @@ export async function setActivePresetId(id: string): Promise<void> {
await api().storeSet('activePresetId', id)
}
// 润色模式切换快捷键:presetId -> 组合键(如 "Alt+1")。独立于 PromptPreset 存储,
// 便于 Rust 端直接读取并注册(内置预设定义在 TS,不在 store 中)。
export async function getPresetShortcuts(): Promise<Record<string, string>> {
return ((await api().storeGet('presetShortcuts')) as Record<string, string>) || {}
}
export async function setPresetShortcuts(map: Record<string, string>): Promise<void> {
await api().storeSet('presetShortcuts', map)
}
export async function getActivePreset(): Promise<PromptPreset> {
const id = await getActivePresetId()
const all = await getPromptPresets()
+46
View File
@@ -32,6 +32,52 @@ export async function saveTextReplacements(rules: TextReplacementRule[]): Promis
await setSetting(STORAGE_KEY, rules)
}
/** 批量文本解析出的规则草稿(尚未分配 id/enabled) */
export interface ParsedReplacement {
from: string
to: string
}
/**
* 解析批量输入文本为替换规则草稿。
* - 每行一条规则;空行忽略。
* - 分隔符支持:制表符(从表格粘贴)、`=>`、`->`、中文逗号「,」、英文逗号「,」。
* 取整行中最靠前出现的分隔符切分:分隔符前为「原文」,其后(含其余分隔符)为「替换为」。
* - 无分隔符的行:整行作为「原文」,替换为留空(即删除该文本)。
* - 原文为空的行忽略。
*/
export function parseBatchReplacements(input: string): ParsedReplacement[] {
const separators = ['\t', '=>', '->', ',', ',']
const result: ParsedReplacement[] = []
for (const rawLine of input.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
let sepIndex = -1
let sepLen = 0
for (const sep of separators) {
const i = line.indexOf(sep)
if (i >= 0 && (sepIndex === -1 || i < sepIndex)) {
sepIndex = i
sepLen = sep.length
}
}
let from = line
let to = ''
if (sepIndex >= 0) {
from = line.slice(0, sepIndex).trim()
to = line.slice(sepIndex + sepLen).trim()
}
if (!from) continue
result.push({ from, to })
}
return result
}
/** 对文本应用所有启用的替换规则 */
export function applyReplacements(text: string, rules: TextReplacementRule[]): string {
let result = text
@@ -39,6 +39,7 @@ export class LocalProvider extends BufferedProvider {
audioB64,
modelId: await getSetting('localAsr.modelId', 'sensevoice-small'),
language: await getSetting('localAsr.language', 'auto'),
hotwords: this.startOpts?.hotwords ?? [],
})
asrText = result.text
asrMs = result.elapsed_ms
+27 -5
View File
@@ -52,6 +52,24 @@ let ws: WebSocket | null = null
let callbacks: WSCallbacks = {}
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let intentionalClose = false
// --- 重连退避 ---
let reconnectAttempts = 0
const RECONNECT_BASE_MS = 3000
const RECONNECT_MAX_MS = 30_000
// 服务端限流类关闭码(1013 服务器满、4029 该 IP 并发超限):退避更久,避免重连风暴
const RECONNECT_LIMIT_MIN_MS = 15_000
const LIMIT_CLOSE_CODES = new Set([1013, 4029])
/** 计算下次重连延迟:指数退避 + ±20% 抖动;限流码时至少退避 RECONNECT_LIMIT_MIN_MS */
function computeReconnectDelayMs(closeCode?: number): number {
const exp = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempts, RECONNECT_MAX_MS)
const base = closeCode !== undefined && LIMIT_CLOSE_CODES.has(closeCode)
? Math.max(exp, RECONNECT_LIMIT_MIN_MS)
: exp
const jitter = base * 0.2 * (Math.random() * 2 - 1)
return Math.max(1000, Math.round(base + jitter))
}
let sessionStarted = false
let audioDropWarned = false
@@ -161,6 +179,7 @@ export function connect(cbs: WSCallbacks): Promise<void> {
socket.onopen = () => {
clearTimeout(timeout)
updateGlobalState('connected')
reconnectAttempts = 0
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
@@ -238,14 +257,16 @@ export function connect(cbs: WSCallbacks): Promise<void> {
ws = null
updateGlobalState('disconnected')
if (!intentionalClose) {
addRuntimeEvent('warn', 'websocket', `连接关闭 code=${ev.code} reason=${ev.reason || '-'}`)
}
endSessionIfNeeded()
if (!intentionalClose) {
reconnectTimer = setTimeout(() => connect(callbacks), 3000)
const delay = computeReconnectDelayMs(ev.code)
addRuntimeEvent('warn', 'websocket', `连接关闭 code=${ev.code} reason=${ev.reason || '-'},${Math.round(delay / 1000)}s 后重连`, {
code: ev.code,
attempt: reconnectAttempts + 1,
})
reconnectAttempts++
reconnectTimer = setTimeout(() => connect(callbacks), delay)
}
}
})
@@ -253,6 +274,7 @@ export function connect(cbs: WSCallbacks): Promise<void> {
export function disconnect() {
intentionalClose = true
reconnectAttempts = 0
stopHeartbeat()
if (reconnectTimer) {
clearTimeout(reconnectTimer)
+3 -27
View File
@@ -11,31 +11,7 @@
import { emit } from '@tauri-apps/api/event'
import { getSetting } from './store'
// PTT setting 值直接对应 DOM KeyboardEvent.code
const SETTING_TO_CODE: Record<string, string> = {
AltLeft: 'AltLeft',
AltRight: 'AltRight',
ControlLeft: 'ControlLeft',
ControlRight: 'ControlRight',
ShiftLeft: 'ShiftLeft',
ShiftRight: 'ShiftRight',
CapsLock: 'CapsLock',
Space: 'Space',
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
}
const SETTING_TO_VK: Record<string, number> = {
AltLeft: 0xA4, AltRight: 0xA5,
ControlLeft: 0xA2, ControlRight: 0xA3,
ShiftLeft: 0xA0, ShiftRight: 0xA1,
CapsLock: 0x14, Space: 0x20,
F1: 0x70, F2: 0x71, F3: 0x72, F4: 0x73,
F5: 0x74, F6: 0x75, F7: 0x76, F8: 0x77,
F9: 0x78, F10: 0x79, F11: 0x7A, F12: 0x7B,
}
import { SETTING_TO_VK, settingToCode } from '@/lib/shortcutKeys'
// PTT Lab 固定使用右 Ctrl
const PTT_LAB_CODE = 'ControlRight'
@@ -161,7 +137,7 @@ export async function refreshPTTSetting() {
pttSetting = 'AltLeft'
console.warn('[webview-kb] failed to load PTT setting, using fallback:', error)
}
pttCode = SETTING_TO_CODE[pttSetting] || ''
pttCode = settingToCode(pttSetting)
pttKeyDown = false
try {
@@ -170,7 +146,7 @@ export async function refreshPTTSetting() {
} catch {
hfSetting = 'AltRight'
}
hfCode = SETTING_TO_CODE[hfSetting] || ''
hfCode = settingToCode(hfSetting)
hfKeyDown = false
console.log('[webview-kb] PTT setting refreshed:', pttSetting, '→ code:', pttCode)
+70
View File
@@ -0,0 +1,70 @@
// 当前润色模式(active preset)的全局响应式状态。
// 让 AI 整理设置页高亮、标题栏状态等多处共享同一状态:
// 无论是页面点击切换,还是全局快捷键切换,都能实时同步刷新。
// 采用 useSyncExternalStore 模式(与 aiEnabled 一致,无外部依赖)。
//
// 注意:此 store 只依赖 services/store,不依赖 services/recorder,避免循环引用。
// 录音器缓存的刷新由调用方(页面/orchestrator)各自负责。
import { getActivePresetId, getPromptPresets } from '@/services/store'
type Listener = () => void
export interface ActivePresetState {
id: string
name: string
}
let current: ActivePresetState = { id: 'intent', name: '' }
let initialized = false
const listeners = new Set<Listener>()
function emitChange() {
for (const listener of listeners) listener()
}
async function resolve(id: string): Promise<ActivePresetState> {
try {
const presets = await getPromptPresets()
const found = presets.find((p) => p.id === id)
return { id, name: found?.name || '' }
} catch {
return { id, name: '' }
}
}
/** 应用启动时调用,读取初始激活预设 */
export async function initActivePreset(): Promise<void> {
if (initialized) return
initialized = true
const id = await getActivePresetId()
current = await resolve(id)
emitChange()
}
export function getActivePresetSnapshot(): ActivePresetState {
return current
}
export function subscribeActivePreset(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/** 用已知的 id/name 立即更新(无 IPC,供快捷键切换即时刷新 UI) */
export function setActivePresetKnown(id: string, name: string): void {
if (id !== current.id || name !== current.name) {
current = { id, name }
emitChange()
}
}
/** 重新从持久化状态读取当前预设(切换、改名后调用),有变化才通知订阅者 */
export async function refreshActivePreset(): Promise<void> {
const id = await getActivePresetId()
const next = await resolve(id)
if (next.id !== current.id || next.name !== current.name) {
current = next
emitChange()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

-341
View File
@@ -1,341 +0,0 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass, field, fields
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .config import Config
from .telemetry import TelemetryService
_UI_PATH = Path(__file__).resolve().parents[2] / "web" / "admin.html"
_SECURITY = HTTPBasic(auto_error=False)
def _config(request: Request) -> Config:
cfg = getattr(request.app.state, "config", None)
if cfg is None:
raise HTTPException(status_code=503, detail="config not initialized")
return cfg
def _telemetry(request: Request) -> TelemetryService:
service = getattr(request.app.state, "telemetry", None)
if service is None:
raise HTTPException(status_code=503, detail="telemetry service not initialized")
return service
def _require_admin(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(_SECURITY),
) -> None:
cfg = _config(request)
if not cfg.admin.enabled:
raise HTTPException(status_code=404, detail="admin disabled")
if not cfg.admin.password:
raise HTTPException(status_code=503, detail="admin password not configured")
username_ok = credentials is not None and secrets.compare_digest(credentials.username, cfg.admin.username)
password_ok = credentials is not None and secrets.compare_digest(credentials.password, cfg.admin.password)
if username_ok and password_ok:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="admin authentication required",
headers={"WWW-Authenticate": 'Basic realm="SayIt Admin"'},
)
# ---------------------------------------------------------------------------
# Shared filter parameters
# ---------------------------------------------------------------------------
@dataclass
class _FilterParams:
"""Common query filters shared across admin endpoints."""
from_: str | None = None
to: str | None = None
user_id: str | None = None
user_name: str | None = None
app: str | None = None
process_name: str | None = None
client_ip: str | None = None
status: str | None = None
node_id: str | None = None
asr_model: str | None = None
llm_model: str | None = None
ai_enabled: str | None = None
source: str | None = None
def to_dict(self) -> dict[str, str | None]:
d = {f.name.rstrip("_"): getattr(self, f.name) for f in fields(self)}
# dataclass field is "from_" but dict key must be "from"
if "from_" in d:
d["from"] = d.pop("from_")
return d
def _parse_filters(request: Request) -> _FilterParams:
"""Build *_FilterParams* from query string — single source of truth."""
q = request.query_params
return _FilterParams(
from_=q.get("from"),
to=q.get("to"),
user_id=q.get("user_id"),
user_name=q.get("user_name"),
app=q.get("app"),
process_name=q.get("process_name"),
client_ip=q.get("client_ip"),
status=q.get("status"),
node_id=q.get("node_id"),
asr_model=q.get("asr_model"),
llm_model=q.get("llm_model"),
ai_enabled=q.get("ai_enabled"),
source=q.get("source"),
)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(_require_admin)])
# Serve admin UI without auth (login is handled client-side)
_public_router = APIRouter(prefix="/admin", tags=["admin"])
@_public_router.get("")
@_public_router.get("/")
async def admin_ui() -> FileResponse:
return FileResponse(_UI_PATH)
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@router.get("/api/overview")
async def overview(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).get_overview(f.to_dict()))
@router.get("/api/sessions")
async def sessions(
request: Request,
f: _FilterParams = Depends(_parse_filters),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
sort: str | None = Query(default=None),
) -> JSONResponse:
return JSONResponse(_telemetry(request).list_sessions(f.to_dict(), limit=limit, offset=offset, sort=sort))
@router.get("/api/sessions/{session_id}")
async def session_detail(request: Request, session_id: str) -> JSONResponse:
detail = _telemetry(request).get_session_detail(session_id)
if not detail:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(detail)
@router.get("/api/metrics/by-app")
async def metrics_by_app(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).metric_by_group(f.to_dict(), "process_name"))
@router.get("/api/metrics/by-user")
async def metrics_by_user(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).metric_by_group(f.to_dict(), "user_id"))
@router.get("/api/metrics/by-model")
async def metrics_by_model(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
d = f.to_dict()
return JSONResponse({
"asr": _telemetry(request).metric_by_group(d, "asr_model"),
"llm": _telemetry(request).metric_by_group(d, "llm_model"),
})
@router.get("/api/metrics/timeline")
async def metrics_timeline(
request: Request,
f: _FilterParams = Depends(_parse_filters),
bucket: str = Query(default="day"),
) -> JSONResponse:
return JSONResponse(_telemetry(request).timeline(f.to_dict(), "hour" if bucket == "hour" else "day"))
@router.get("/api/metrics/duration-distribution")
async def duration_distribution(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).duration_distribution(f.to_dict()))
@router.get("/api/metrics/percentiles")
async def metrics_percentiles(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).get_percentiles(f.to_dict()))
@router.get("/api/service-logs")
async def service_logs(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
level: str | None = Query(default=None),
from_: str | None = Query(default=None, alias="from"),
to: str | None = Query(default=None),
q: str | None = Query(default=None),
) -> JSONResponse:
return JSONResponse(_telemetry(request).list_service_logs(limit=limit, level=level, from_=from_, to=to, q=q))
@router.get("/api/system-info")
async def system_info(request: Request) -> JSONResponse:
cfg = _config(request)
llm = cfg.llm
provider = llm.provider.lower() if llm.enabled else None
model_map = {"openai": llm.openai_model, "azure": llm.azure_deployment, "groq": llm.groq_model, "ollama": llm.ollama_model}
web_llm = cfg.web_demo.llm
web_provider = web_llm.provider.lower() if web_llm.enabled else None
web_model_map = {"openai": web_llm.openai_model, "azure": web_llm.azure_deployment, "groq": web_llm.groq_model, "ollama": web_llm.ollama_model}
return JSONResponse({
"asr_engine": cfg.asr.engine,
"asr_model": cfg.asr.model,
"llm": {"enabled": llm.enabled, "provider": provider, "model": model_map.get(provider, "-") if provider else "-"},
"web_demo_llm": {"enabled": web_llm.enabled, "provider": web_provider, "model": web_model_map.get(web_provider, "-") if web_provider else "-"},
"web_demo": {"enabled": cfg.web_demo.enabled, "max_duration_sec": cfg.web_demo.max_duration_sec, "max_concurrency_per_ip": cfg.web_demo.max_concurrency_per_ip},
"node_id": cfg.telemetry.node_id,
"deployment_mode": cfg.telemetry.deployment_mode,
"resources": _collect_resources(),
})
def _collect_resources() -> dict:
"""Collect CPU / memory / disk / GPU metrics."""
import psutil
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
boot = psutil.boot_time()
uptime_sec = int(__import__("time").time() - boot)
info: dict = {
"cpu_percent": psutil.cpu_percent(interval=0),
"cpu_count": psutil.cpu_count(),
"mem_total_gb": round(mem.total / (1 << 30), 1),
"mem_used_gb": round(mem.used / (1 << 30), 1),
"mem_percent": mem.percent,
"disk_total_gb": round(disk.total / (1 << 30), 1),
"disk_used_gb": round(disk.used / (1 << 30), 1),
"disk_percent": disk.percent,
"uptime_sec": uptime_sec,
"gpus": [],
}
try:
import subprocess
r = subprocess.run(
["nvidia-smi", "--query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
for line in r.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 6:
info["gpus"].append({
"index": int(parts[0]),
"name": parts[1],
"util_percent": int(parts[2]),
"mem_used_mb": int(parts[3]),
"mem_total_mb": int(parts[4]),
"temp_c": int(parts[5]),
})
except Exception:
pass
return info
# ---------------------------------------------------------------------------
# Protected operational endpoints (moved from public API)
# ---------------------------------------------------------------------------
@router.get("/api/healthz-details")
async def healthz_details(request: Request) -> JSONResponse:
cfg = _config(request)
from .main import asr_engine, llm_engine, web_demo_llm_engine, _active_ws_count, _get_telemetry
return JSONResponse({
"status": "ok",
"asr": asr_engine is not None,
"asr_engine": cfg.asr.engine,
"asr_model": cfg.asr.model,
"llm": cfg.llm.enabled and llm_engine is not None,
"web_demo": {
"enabled": cfg.web_demo.enabled,
"llm": cfg.web_demo.llm.enabled and web_demo_llm_engine is not None,
"max_duration_sec": cfg.web_demo.max_duration_sec,
"max_concurrency_per_ip": cfg.web_demo.max_concurrency_per_ip,
},
"telemetry": _get_telemetry().health_summary() if _get_telemetry() else None,
"active_connections": _active_ws_count,
})
@router.get("/api/hotwords")
async def get_hotwords(request: Request) -> JSONResponse:
from .main import asr_engine
return JSONResponse(asr_engine.get_hotwords() if asr_engine else [])
@router.put("/api/hotwords")
async def put_hotwords(request: Request, payload: list[str] = Body(default=[])) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.set_hotwords(payload))
@router.get("/api/asr-tuning")
async def get_asr_tuning(request: Request) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.get_batching())
@router.put("/api/asr-tuning")
async def put_asr_tuning(request: Request, payload: dict = Body(default={})) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.set_batching(
batch_max_size=payload.get("batch_max_size"),
batch_wait_ms=payload.get("batch_wait_ms"),
))
@router.get("/api/feedback")
async def list_feedback(
request: Request,
t: TelemetryService = Depends(_telemetry),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> JSONResponse:
import json as _json
db = t._db
p = db.dialect.placeholder
rows = db.fetch_all(
f"SELECT * FROM feedback ORDER BY created_at DESC LIMIT {p} OFFSET {p}",
(limit, offset),
)
total = db.fetch_one("SELECT COUNT(*) as cnt FROM feedback")
items = []
for r in rows:
item = dict(r)
for k in ("transcript_json", "context_json"):
if item.get(k):
try:
item[k] = _json.loads(item[k])
except Exception:
pass
items.append(item)
return JSONResponse({"items": items, "total": (total or {}).get("cnt", 0)})
+83 -43
View File
@@ -17,14 +17,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .admin import _public_router as admin_public_router, router as admin_router
from .asr import ASREngine
from .config import load_config
from .db import Database
from .diagnostics import create_upload_token, router as diagnostics_router
from .llm import LLMEngine
from .logging_setup import attach_database_log_handler, bind_log_context, configure_logging, reset_log_context
from .recorder import Recorder
from .ratelimit import RateLimitMiddleware
from .releases import read_public_download, read_release_manifest, resolve_release_file
from .telemetry import TelemetryService
@@ -36,15 +34,22 @@ logger = logging.getLogger("sayit")
asr_engine: ASREngine | None = None
llm_engine: LLMEngine | None = None
web_demo_llm_engine: LLMEngine | None = None
recorder: Recorder | None = None
database: Database | None = None
telemetry_service: TelemetryService | None = None
_web_demo_active_by_ip: dict[str, int] = defaultdict(int)
_active_ws_count: int = 0
_active_ws_ids: set[str] = set()
_ws_by_ip: dict[str, int] = defaultdict(int)
# 每个真实客户端 IP -> 该 IP 当前存活连接的 cid 集合(用集合而非整数,计数由存活连接派生,永不漂移)
_ws_by_ip: dict[str, set[str]] = defaultdict(set)
_MAX_WS_TOTAL = 200
_MAX_WS_PER_IP = 10
# 接收空闲超时:客户端每 30s 发一次 ping;超过此时长无任何消息即视为死连接并回收,
# 避免半开/异常掉线的连接长期占用 per-IP 配额(不再依赖 OS TCP 超时,可能长达几分钟)。
_WS_IDLE_TIMEOUT_SEC = 90
# WebSocket 关闭码:不同拒绝/关闭原因用不同 code,客户端与日志据此区分排查
_WS_CLOSE_SERVER_FULL = 1013 # 服务器整体到达容量上限(标准“稍后再试”)
_WS_CLOSE_PER_IP_LIMIT = 4029 # 该客户端 IP 并发连接过多(对应 HTTP 429 语义)
_WS_CLOSE_IDLE = 4000 # 服务端回收空闲/疑似死连接
_MAX_PCM_BYTES = 10 * 1024 * 1024 # 10MB ≈ 5 min of 16kHz 16-bit mono
@@ -59,15 +64,13 @@ def _llm_model_name(profile) -> str:
@asynccontextmanager
async def _lifespan(app: FastAPI):
global asr_engine, llm_engine, web_demo_llm_engine, recorder, database, telemetry_service
global asr_engine, llm_engine, web_demo_llm_engine, database, telemetry_service
database = Database(cfg.telemetry.db_path, backend=cfg.telemetry.db_backend)
database.initialize()
attach_database_log_handler(database)
telemetry_service = TelemetryService(cfg, database)
app.state.telemetry = telemetry_service
recorder = Recorder(cfg)
logger.info("Telemetry ready db=%s", cfg.telemetry.db_path)
try:
@@ -108,7 +111,6 @@ async def _lifespan(app: FastAPI):
llm_status = f"{cfg.llm.provider} / {_llm_model_name(cfg.llm)}" if cfg.llm.enabled and llm_engine else "disabled"
web_llm_status = f"{cfg.web_demo.llm.provider} / {_llm_model_name(cfg.web_demo.llm)}" if cfg.web_demo.llm.enabled and web_demo_llm_engine else "disabled"
demo_status = f"enabled (LLM: {web_llm_status})" if cfg.web_demo.enabled else "disabled"
admin_pw_warning = " (⚠ default password)" if cfg.admin.password == "sayit" else ""
logger.info(
"\n"
@@ -118,17 +120,13 @@ async def _lifespan(app: FastAPI):
" │ ASR: %-34s │\n"
" │ LLM: %-34s │\n"
" │ Demo: %-34s │\n"
" │ Admin: %-34s │\n"
" │ HTTP: %-34s │\n"
" └──────────────────────────────────────────┘",
f"{cfg.asr.engine} ({cfg.asr.model})",
llm_status,
demo_status,
f"{cfg.admin.username}:***{admin_pw_warning}",
f":{cfg.server.port}",
)
if cfg.admin.password == "sayit":
logger.warning("Admin is using the DEFAULT password. Set SAYIT_ADMIN_PASSWORD to change it.")
if cfg.llm.enabled and not llm_engine:
logger.warning("LLM is configured but failed to initialize. Check API keys and provider settings.")
@@ -147,8 +145,6 @@ async def _lifespan(app: FastAPI):
app = FastAPI(title="SayIt", lifespan=_lifespan)
app.state.config = cfg
app.include_router(diagnostics_router)
app.include_router(admin_public_router)
app.include_router(admin_router)
app.add_middleware(
CORSMiddleware,
@@ -323,6 +319,22 @@ def _client_ip(ws: WebSocket) -> tuple[str | None, str | None]:
return direct, forwarded_for
def _client_ip_key(direct: str | None, forwarded: str | None) -> str:
"""用于“按 IP 限流/计数”的真实客户端 IP。
ALB 后 ws.client.host 恒为 ALB 内网地址(所有用户相同),必须取 X-Forwarded-For
的真实客户端 IP;且 XFF 可能是 "client, proxy1, ..." 列表,取最左第一个(与 diagnostics 一致)。
注意:最左值理论上可被客户端伪造。单层可信 ALB 场景足够;若前置更多不可信代理,
应按“可信代理跳数”从右侧取值,避免被伪造成他人 IP 定向占额。
"""
if forwarded:
first = forwarded.split(",")[0].strip()
if first:
return first
return (direct or "unknown").strip() or "unknown"
def _is_web_demo(ws: WebSocket) -> bool:
return (ws.query_params.get("client") or "").strip().lower() == "web_demo"
@@ -577,34 +589,31 @@ async def ws_transcribe(ws: WebSocket):
telemetry = _get_telemetry()
is_web_demo = _is_web_demo(ws)
client_ip_direct, client_ip_forwarded = _client_ip(ws)
ws_ip_key = (client_ip_direct or "unknown").strip() or "unknown"
ws_ip_key = _client_ip_key(client_ip_direct, client_ip_forwarded)
# Connection limits
# 先 accept,再用带专属 close code 的关闭来拒绝:
# 若在 accept 之前 close,框架会以 HTTP 403 拒绝握手,客户端只能看到通用错误(1006),无法区分原因。
await ws.accept()
# Connection limits(拒绝时不计数、也不进入下方的 try/finally)
if _active_ws_count >= _MAX_WS_TOTAL:
await ws.close(1013, "server at capacity")
logger.warning("ws rejected (server full): active=%d cid=%s", _active_ws_count, cid)
await ws.close(_WS_CLOSE_SERVER_FULL, "server at capacity")
reset_log_context(connection_tokens)
return
if _ws_by_ip[ws_ip_key] >= _MAX_WS_PER_IP:
await ws.close(1013, "too many connections from this IP")
if len(_ws_by_ip.get(ws_ip_key, ())) >= _MAX_WS_PER_IP:
logger.warning(
"ws rejected (per-ip limit): ip=%s count=%d cid=%s",
ws_ip_key, len(_ws_by_ip.get(ws_ip_key, ())), cid,
)
await ws.close(_WS_CLOSE_PER_IP_LIMIT, "too many connections from this IP")
reset_log_context(connection_tokens)
return
if telemetry:
telemetry.record_connection_event(cid, "ws_connected", None)
await ws.accept()
_active_ws_ids.add(cid)
_active_ws_count = len(_active_ws_ids)
_ws_by_ip[ws_ip_key] += 1
await ws.send_json(
{
"type": "ready",
"connection_id": cid,
"asr": asr_engine is not None,
"llm": _selected_llm_engine(is_web_demo) is not None,
"client": "web_demo" if is_web_demo else "desktop",
}
)
# 计数注册与 ready 发送移入下方 try,确保任何异常都会经 finally 释放(避免计数泄漏)。
pcm_buffers: list[bytes] = []
pcm_total_bytes: int = 0
@@ -675,10 +684,6 @@ async def ws_transcribe(ws: WebSocket):
session_id = None
return
rec_path = None
if telemetry:
telemetry.record_debug_audio(current_session_id, cid, rec_path)
audio = np.frombuffer(all_pcm, dtype=np.int16).astype(np.float32) / 32768.0
if len(audio) / 16000.0 < 0.3:
if telemetry:
@@ -746,6 +751,7 @@ async def ws_transcribe(ws: WebSocket):
return
llm_debug = result.get("llm_debug", {}) if isinstance(result.get("llm_debug"), dict) else {}
if telemetry:
telemetry.record_pipeline_result(
current_session_id,
@@ -782,6 +788,18 @@ async def ws_transcribe(ws: WebSocket):
"asr_engine": cfg.asr.engine,
"asr_model": "FireRedASR2-AED-TensorRT" if cfg.asr.engine == "firered" else cfg.asr.model,
}
# Audit log: metadata only (no transcript text persisted)
logger.info(
"audit ip=%s user=%s process=%s dur=%.1fs asr_ms=%d llm_ms=%d asr_len=%d llm_len=%d",
client_ip_forwarded or client_ip_direct or "-",
client_meta.get("user_id") or client_meta.get("user_name") or "-",
app_context.get("process_name") or "-",
result.get("duration_sec", 0),
result.get("asr_ms", 0),
result.get("llm_ms", 0),
len(result.get("asr_text", "")),
len(result.get("llm_text", "")),
)
if cmp:
msg["compare_no_hotwords"] = cmp
await ws.send_json(msg)
@@ -793,8 +811,29 @@ async def ws_transcribe(ws: WebSocket):
session_id = None
try:
# 在 try 内注册连接计数,保证任何异常(含 accept 后立即断开)都会经 finally 释放
_active_ws_ids.add(cid)
_active_ws_count = len(_active_ws_ids)
_ws_by_ip[ws_ip_key].add(cid)
await ws.send_json(
{
"type": "ready",
"connection_id": cid,
"asr": asr_engine is not None,
"llm": _selected_llm_engine(is_web_demo) is not None,
"client": "web_demo" if is_web_demo else "desktop",
}
)
while True:
msg = await ws.receive()
try:
msg = await asyncio.wait_for(ws.receive(), timeout=_WS_IDLE_TIMEOUT_SEC)
except asyncio.TimeoutError:
logger.info("ws idle %ds, reaping cid=%s ip=%s", _WS_IDLE_TIMEOUT_SEC, cid, ws_ip_key)
try:
await ws.close(_WS_CLOSE_IDLE, "idle timeout")
except RuntimeError:
pass
break
if msg["type"] == "websocket.disconnect":
break
raw = msg.get("bytes")
@@ -825,7 +864,7 @@ async def ws_transcribe(ws: WebSocket):
await ws.send_json({"type": "done"})
continue
if is_web_demo and not web_demo_slot_acquired:
if not _try_acquire_web_demo_slot(client_ip_direct):
if not _try_acquire_web_demo_slot(ws_ip_key):
await ws.send_json(
{
"type": "error",
@@ -898,14 +937,15 @@ async def ws_transcribe(ws: WebSocket):
finally:
_active_ws_ids.discard(cid)
_active_ws_count = len(_active_ws_ids)
if _ws_by_ip.get(ws_ip_key, 0) <= 1:
_ws_by_ip.pop(ws_ip_key, None)
else:
_ws_by_ip[ws_ip_key] -= 1
conns = _ws_by_ip.get(ws_ip_key)
if conns is not None:
conns.discard(cid)
if not conns:
_ws_by_ip.pop(ws_ip_key, None)
if telemetry:
telemetry.mark_disconnected(session_id, cid)
if web_demo_slot_acquired:
_release_web_demo_slot(client_ip_direct)
_release_web_demo_slot(ws_ip_key)
_clear_session_context()
logger.info("ws disconnected")
reset_log_context(connection_tokens)
-27
View File
@@ -1,27 +0,0 @@
"""Recording manager: disabled – audio is not persisted server-side."""
from __future__ import annotations
import logging
from .config import Config
logger = logging.getLogger("sayit.recorder")
class Recorder:
"""No-op recorder. Audio files are never saved on the backend."""
def __init__(self, config: Config) -> None:
pass
def save(self, pcm_bytes: bytes, connection_id: str) -> None:
return None
def list_recordings(self) -> list[dict]:
return []
def get_path(self, name: str) -> None:
return None
def cleanup_expired(self, retention_days: int) -> int:
return 0
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
import base64
import shutil
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app.admin import router as admin_router, _public_router as admin_public_router
from backend.app.config import Config, TelemetryConfig, AdminConfig, LoggingConfig
from backend.app.db import Database
from backend.app.telemetry import TelemetryService
class AdminRouterTests(unittest.TestCase):
def setUp(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp(prefix="sayit-admin-test-"))
self.cfg = Config(
telemetry=TelemetryConfig(
enabled=True,
db_path=str(self._tmpdir / "sayit.sqlite3"),
),
logging=LoggingConfig(
file=str(self._tmpdir / "backend.log"),
),
admin=AdminConfig(
enabled=True,
username="admin",
password="secret",
),
)
self.db = Database(self.cfg.telemetry.db_path)
self.db.initialize()
self.telemetry = TelemetryService(self.cfg, self.db)
self.app = FastAPI()
self.app.state.config = self.cfg
self.app.state.telemetry = self.telemetry
self.app.include_router(admin_public_router)
self.app.include_router(admin_router)
self.client = TestClient(self.app)
def tearDown(self) -> None:
self.db.close()
shutil.rmtree(self._tmpdir, ignore_errors=True)
def _auth_headers(self) -> dict[str, str]:
token = base64.b64encode(b"admin:secret").decode("ascii")
return {"Authorization": f"Basic {token}"}
def test_admin_requires_basic_auth(self) -> None:
# Admin API endpoints require auth
response = self.client.get("/admin/api/overview")
self.assertEqual(response.status_code, 401)
self.assertIn("Basic", response.headers.get("www-authenticate", ""))
def test_admin_ui_and_api_work_with_auth(self) -> None:
# Admin UI page is served without auth (login handled client-side)
ui = self.client.get("/admin")
self.assertIn(ui.status_code, (200, 404)) # 404 if admin.html not at expected path in test env
overview = self.client.get("/admin/api/overview", headers=self._auth_headers())
self.assertEqual(overview.status_code, 200)
self.assertEqual(overview.json()["total_sessions"], 0)
def test_service_logs_endpoint_returns_rows(self) -> None:
self.db.execute(
"""
INSERT INTO service_logs(created_at, level, logger_name, node_id, session_id, connection_id, message, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(1234567890, "WARNING", "sayit.test", "node-1", "sid-1", "cid-1", "example warning", "{}"),
)
response = self.client.get("/admin/api/service-logs", headers=self._auth_headers())
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["message"], "example warning")
if __name__ == "__main__":
unittest.main()
-425
View File
@@ -1,425 +0,0 @@
/* ===== SayIt Admin v3 — Impeccable redesign ===== */
/* --- 浅色(与 Tauri 客户端 light 主题一致) --- */
:root, [data-theme="light"] {
--background: 0 0% 100%; --foreground: 240 10% 3.9%;
--card: 0 0% 100%; --card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --input: 240 5.9% 90%;
--ring: 240 5.9% 10%; --radius: 0.5rem;
--sidebar-bg: 0 0% 100%; --sidebar-border: 240 5.9% 90%;
--sidebar-item-active-bg: 240 4.8% 95.9%; --sidebar-item-hover-bg: 240 4.8% 97.9%;
--sidebar-text: 240 4% 46%; --sidebar-text-active: 240 10% 3.9%;
--input-bg: 0 0% 100%; --input-border: 240 5.9% 90%;
--input-focus-border: 240 5.9% 10%; --input-placeholder: 240 4% 65%;
--success: 142 76% 36%; --success-foreground: 0 0% 100%;
--warning: 38 92% 50%; --warning-foreground: 0 0% 100%;
--info: 199 89% 48%; --info-foreground: 0 0% 100%;
}
/* 深色(与 Tauri 客户端 dark 主题一致) */
[data-theme="dark"] {
--background: 0 0% 12%; --foreground: 0 0% 90%;
--card: 0 0% 15%; --card-foreground: 0 0% 90%;
--primary: 210 60% 58%; --primary-foreground: 0 0% 100%;
--secondary: 0 0% 18%; --secondary-foreground: 0 0% 88%;
--muted: 0 0% 18%; --muted-foreground: 0 0% 65%;
--accent: 0 0% 20%; --accent-foreground: 0 0% 90%;
--destructive: 0 60% 55%; --destructive-foreground: 0 0% 95%;
--border: 0 0% 22%; --input: 0 0% 22%;
--ring: 210 60% 58%; --radius: 0.5rem;
--sidebar-bg: 0 0% 10%; --sidebar-border: 0 0% 18%;
--sidebar-item-active-bg: 0 0% 18%; --sidebar-item-hover-bg: 0 0% 16%;
--sidebar-text: 0 0% 60%; --sidebar-text-active: 0 0% 92%;
--input-bg: 0 0% 13%; --input-border: 0 0% 22%;
--input-focus-border: 210 60% 58%; --input-placeholder: 0 0% 45%;
--success: 120 40% 55%; --success-foreground: 0 0% 100%;
--warning: 40 80% 60%; --warning-foreground: 0 0% 10%;
--info: 210 60% 58%; --info-foreground: 0 0% 100%;
}
/* Claude Style 暖色(与 Tauri 客户端 claude 主题一致) */
[data-theme="claude"] {
--background: 40 33% 97%; --foreground: 25 8% 20%;
--card: 40 33% 98%; --card-foreground: 25 8% 20%;
--primary: 22 85% 38%; --primary-foreground: 40 33% 97%;
--secondary: 37 22% 90%; --secondary-foreground: 25 8% 20%;
--muted: 37 22% 90%; --muted-foreground: 25 6% 50%;
--accent: 37 22% 90%; --accent-foreground: 25 8% 20%;
--destructive: 0 72% 51%; --destructive-foreground: 0 0% 100%;
--border: 37 18% 84%; --input: 37 18% 84%;
--ring: 22 85% 38%; --radius: 0.5rem;
--sidebar-bg: 37 22% 92%; --sidebar-border: 37 18% 84%;
--sidebar-item-active-bg: 22 25% 86%; --sidebar-item-hover-bg: 37 22% 88%;
--sidebar-text: 25 6% 50%; --sidebar-text-active: 25 8% 20%;
--input-bg: 40 33% 98%; --input-border: 37 18% 84%;
--input-focus-border: 22 85% 38%; --input-placeholder: 25 6% 62%;
--success: 152 60% 36%; --success-foreground: 0 0% 100%;
--warning: 38 85% 48%; --warning-foreground: 30 20% 12%;
--info: 210 65% 50%; --info-foreground: 0 0% 100%;
}
/* --- P0.2 排版:5 级字号 + tabular-nums --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
background: hsl(var(--background)); color: hsl(var(--foreground));
font-size: 14px; line-height: 1.6;
}
/* type scale: 12 / 13 / 14(base) / 17 / 22 */
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.text-lg { font-size: 17px; }
.text-xl { font-size: 22px; }
/* --- P0.3 间距 token (4pt base) --- */
/* 4 8 12 16 20 24 32 48 */
/* --- P2.7 动效 token --- */
:root {
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--dur-fast: 120ms;
--dur-normal: 200ms;
}
/* --- 登录 --- */
.login-mask {
position: fixed; inset: 0; z-index: 999;
background: hsl(var(--background));
display: flex; align-items: center; justify-content: center;
}
.login-box {
width: 340px; padding: 32px;
background: hsl(var(--card)); border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.login-box h2 { font-size: 22px; font-weight: 700; margin-bottom: 20px; }
.login-box .field { margin-bottom: 16px; }
.login-box .err { color: hsl(var(--destructive)); font-size: 13px; margin-bottom: 12px; min-height: 18px; }
/* --- 布局 --- */
.layout { display: flex; height: 100vh; }
.sidebar {
width: 192px; flex-shrink: 0;
background: hsl(var(--sidebar-bg)); border-right: 1px solid hsl(var(--sidebar-border));
display: flex; flex-direction: column; padding: 16px 0;
}
.sidebar-brand { padding: 0 16px 20px; font-size: 17px; font-weight: 700; color: hsl(var(--foreground)); }
.sidebar-brand small { display: block; font-size: 12px; font-weight: 400; color: hsl(var(--muted-foreground)); margin-top: 2px; }
.sidebar-nav { flex: 1; display: flex; flex-direction: column; gap: 2px; padding: 0 8px; }
.sidebar-nav a {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border-radius: 8px; text-decoration: none; font-size: 13px;
color: hsl(var(--sidebar-text));
transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.sidebar-nav a:hover { background: hsl(var(--sidebar-item-hover-bg)); color: hsl(var(--sidebar-text-active)); }
.sidebar-nav a.active { background: hsl(var(--sidebar-item-active-bg)); color: hsl(var(--sidebar-text-active)); font-weight: 600; }
.sidebar-nav a:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; border-radius: 8px; }
.sidebar-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-footer {
padding: 12px 12px 0; border-top: 1px solid hsl(var(--sidebar-border));
display: flex; gap: 4px; align-items: center;
}
.theme-btn {
background: none; border: 1px solid hsl(var(--border)); border-radius: 6px;
padding: 3px 8px; font-size: 11px; color: hsl(var(--muted-foreground)); cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
}
.theme-btn:hover { background: hsl(var(--accent)); color: hsl(var(--accent-foreground)); }
.theme-btn:active { transform: scale(0.95); }
.theme-btn.active { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: transparent; }
/* 主内容 */
.main {
flex: 1; overflow-y: auto; padding: 32px;
animation: fadeIn var(--dur-normal) var(--ease-out);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
.main::-webkit-scrollbar { width: 6px; }
.main::-webkit-scrollbar-track { background: transparent; }
.main::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 3px; }
.page-header { margin-bottom: 24px; }
.page-header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.page-body { max-width: 1100px; }
/* --- P1.4 交互状态 --- */
/* 统一控件高度:正常 32px,小型 28px */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
border-radius: 8px; padding: 0 14px; height: 32px; font: inherit; font-size: 13px;
cursor: pointer; white-space: nowrap;
transition: all var(--dur-fast) var(--ease-out);
}
.btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
.btn:active { transform: scale(0.96); }
.btn-primary {
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--primary));
}
.btn-primary:hover { background: hsl(var(--primary) / .88); }
.btn-outline {
background: transparent; color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
}
.btn-outline:hover { background: hsl(var(--accent)); border-color: hsl(var(--accent)); }
/* 快捷时间 — 小型 28px */
.time-presets { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.time-presets .btn { padding: 0 10px; height: 28px; font-size: 12px; }
.time-presets .btn.active {
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
/* 工具栏 */
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 20px; }
.toolbar .field { min-width: 0; }
.toolbar input[type="datetime-local"], .toolbar select { width: 160px; }
.toolbar-actions { display: flex; gap: 6px; align-items: center; }
/* 表单 — 与 .btn 同高 32px */
input, select {
background: hsl(var(--input-bg)); border: 1px solid hsl(var(--input-border));
border-radius: 8px; padding: 0 10px; height: 32px; font: inherit; font-size: 13px;
color: hsl(var(--foreground)); width: 100%;
transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
input::placeholder { color: hsl(var(--input-placeholder)); }
input:focus, select:focus { outline: none; border-color: hsl(var(--input-focus-border)); box-shadow: 0 0 0 3px hsl(var(--ring) / .12); }
.field { display: flex; flex-direction: column; gap: 4px; }
.field > span { font-size: 12px; color: hsl(var(--muted-foreground)); font-weight: 500; }
/* --- P1.5 仪表盘:非对称布局 --- */
.dash-status { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; }
.status-card {
background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 10px;
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
}
.status-card .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.status-card .dot.ok { background: hsl(var(--success)); }
.status-card .dot.off { background: hsl(var(--muted-foreground)); }
.status-card .info { flex: 1; min-width: 0; }
.status-card .info .name { font-size: 13px; font-weight: 600; }
.status-card .info .detail { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-metrics {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px;
margin-bottom: 24px;
}
.metric-card {
border-radius: 10px; padding: 14px 16px;
border: 1px solid hsl(var(--border)); background: hsl(var(--card));
}
.metric-card .label { font-size: 12px; color: hsl(var(--muted-foreground)); font-weight: 500; }
.metric-card .value {
font-size: 24px; font-weight: 700; margin-top: 4px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.dash-bottom { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
/* --- P1.6 减少卡片嵌套 --- */
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: hsl(var(--foreground)); }
.card {
background: hsl(var(--card)); border: 1px solid hsl(var(--border));
border-radius: 10px; padding: 16px 18px;
}
.card h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
/* 表格 */
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 9px 10px; font-size: 13px; border-bottom: 1px solid hsl(var(--border)); vertical-align: top; }
th { color: hsl(var(--muted-foreground)); font-weight: 600; font-size: 12px; }
tr.clickable { cursor: pointer; transition: background var(--dur-fast) var(--ease-out); }
tr.clickable:hover, tr.drill:hover { background: hsl(var(--accent)); }
tr.clickable.active { background: hsl(var(--sidebar-item-active-bg)); }
tr.drill { cursor: pointer; transition: background var(--dur-fast) var(--ease-out); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: hsl(var(--foreground)); }
/* 徽章 */
.badge { display: inline-block; border-radius: 6px; padding: 2px 8px; font-size: 12px; white-space: nowrap; font-weight: 500; }
.badge-success { background: hsl(var(--success) / .12); color: hsl(var(--success)); }
.badge-warning { background: hsl(var(--warning) / .12); color: hsl(var(--warning)); }
.badge-error { background: hsl(var(--destructive) / .12); color: hsl(var(--destructive)); }
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
/* 分页 */
.pagination { display: flex; align-items: center; justify-content: center; gap: 4px; margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground)); }
.pagination button, .pagination .pg {
background: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border));
border-radius: 6px; padding: 0 10px; height: 28px; font-size: 12px; cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
}
.pagination button:disabled { opacity: .35; cursor: default; }
.pagination button:not(:disabled):hover, .pagination .pg:hover { background: hsl(var(--accent)); }
.pagination .pg.active { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
.pagination button:focus-visible, .pagination .pg:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
.pagination span { font-size: 12px; padding: 0 2px; }
/* 两栏 */
.split { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); gap: 16px; align-items: start; }
/* 详情 */
.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin: 12px 0; }
.detail-item { background: hsl(var(--background)); border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 8px 10px; }
.detail-item .k { font-size: 11px; color: hsl(var(--muted-foreground)); font-weight: 500; }
.detail-item .v { margin-top: 3px; font-size: 13px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
/* 时间线 — 结构化 */
.timeline-v { position: relative; padding-left: 20px; margin-top: 8px; max-height: 520px; overflow: auto; }
.timeline-v::before { content: ''; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: hsl(var(--border)); }
.tl-item { position: relative; padding-bottom: 16px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-dot { position: absolute; left: -20px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: hsl(var(--muted-foreground)); border: 2px solid hsl(var(--background)); }
.tl-item.tl-error .tl-dot { background: hsl(var(--destructive)); }
.tl-body { background: hsl(var(--secondary)); border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 8px 12px; }
.tl-item.tl-error .tl-body { border-color: hsl(var(--destructive) / .3); background: hsl(var(--destructive) / .04); }
.tl-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tl-label { font-size: 13px; font-weight: 600; }
.tl-time { font-size: 11px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
.tl-details { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px 12px; }
.tl-kv { font-size: 12px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
.tl-kv .k { font-weight: 500; color: hsl(var(--foreground)); margin-right: 4px; }
/* Top 列表 */
.top-list { list-style: none; display: grid; gap: 6px; }
.top-list li {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border: 1px solid hsl(var(--border)); border-radius: 8px;
background: hsl(var(--card)); font-size: 13px;
}
.top-list li strong { font-variant-numeric: tabular-nums; }
/* 日志 */
.log-item { border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 10px 14px; background: hsl(var(--card)); margin-bottom: 8px; }
.log-item .meta { font-size: 12px; color: hsl(var(--muted-foreground)); margin-bottom: 4px; }
.log-item .msg { font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
.log-item.log-error { border-color: hsl(var(--destructive) / .3); background: hsl(var(--destructive) / .04); }
.log-item.log-error .meta { color: hsl(var(--destructive)); }
.log-item.log-warn { border-color: hsl(var(--warning) / .3); }
/* 系统信息 */
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.info-card { background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 10px; padding: 20px; }
.info-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; }
.info-row { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; font-size: 13px; border-bottom: 1px solid hsl(var(--border)); }
.info-row:last-child { border-bottom: none; }
.info-row .k { color: hsl(var(--muted-foreground)); flex-shrink: 0; }
.info-row .v { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.info-row .v[title] { cursor: help; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 3px; text-decoration-color: hsl(var(--border)); }
/* 统计 */
.analytics-section { margin-bottom: 24px; }
.analytics-section h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
/* 提示 */
.notice { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; display: none; }
.notice.error { display: block; background: hsl(var(--destructive) / .08); color: hsl(var(--destructive)); border: 1px solid hsl(var(--destructive) / .15); }
/* 趋势条形图 */
.bar-chart { display: flex; align-items: flex-end; gap: 3px; height: 72px; padding-bottom: 22px; padding-top: 18px; }
.bar-chart .bar {
flex: 1; background: hsl(var(--primary)); border-radius: 3px 3px 0 0;
min-width: 4px; max-width: 48px; position: relative; cursor: default;
transition: opacity var(--dur-fast) var(--ease-out);
}
.bar-chart .bar:hover { opacity: .7; }
.bar-chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
.bar-chart .bar-top { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 10px; color: hsl(var(--foreground)); font-weight: 600; white-space: nowrap; }
/* 加载 spinner */
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 50%; animation: spin .6s linear infinite; vertical-align: middle; margin-right: 8px; }
.loading-msg { color: hsl(var(--muted-foreground)); font-size: 13px; padding: 32px 0; }
/* 空状态 */
.empty-state { text-align: center; padding: 32px 16px; color: hsl(var(--muted-foreground)); font-size: 13px; }
.empty-state .hint { margin-top: 6px; font-size: 12px; }
.hidden { display: none !important; }
/* ===== 移动端适配 ===== */
/* 汉堡按钮(仅移动端显示) */
.mobile-header {
display: none; position: sticky; top: 0; z-index: 50;
background: hsl(var(--sidebar-bg)); border-bottom: 1px solid hsl(var(--sidebar-border));
padding: 10px 16px; align-items: center; justify-content: space-between;
}
.mobile-header .brand { font-size: 15px; font-weight: 700; }
.hamburger {
background: none; border: none; color: hsl(var(--foreground)); cursor: pointer; padding: 4px;
display: flex; align-items: center; justify-content: center;
}
.hamburger svg { width: 22px; height: 22px; }
/* 侧边栏遮罩 */
.sidebar-overlay { display: none; position: fixed; inset: 0; z-index: 90; background: rgba(0,0,0,.35); }
@media (max-width: 768px) {
.layout { flex-direction: column; }
.mobile-header { display: flex; }
/* 侧边栏变为抽屉 */
.sidebar {
position: fixed; top: 0; bottom: 0; z-index: 100;
width: 220px;
transform: translateX(-100%);
transition: transform var(--dur-normal) var(--ease-out);
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.open { display: block; }
/* 主内容 */
.main { padding: 16px; }
.page-header { margin-bottom: 16px; }
.page-header h1 { font-size: 18px; }
.page-body { max-width: 100%; }
/* 仪表盘 */
.dash-status { grid-template-columns: 1fr; }
.dash-metrics { grid-template-columns: repeat(2, 1fr); }
.dash-bottom { grid-template-columns: 1fr; }
/* 会话管理:split 变单列 */
.split { grid-template-columns: 1fr; }
/* 表格横向滚动 */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 600px; }
/* 详情网格单列 */
.detail-grid { grid-template-columns: 1fr; }
/* 工具栏 */
.toolbar { gap: 8px; }
.toolbar input[type="datetime-local"], .toolbar select { width: 140px; }
/* 系统信息 */
.info-grid { grid-template-columns: 1fr; }
/* 登录 */
.login-box { width: calc(100vw - 32px); max-width: 340px; padding: 24px; }
}
@media (max-width: 480px) {
.dash-metrics { grid-template-columns: 1fr 1fr; }
.metric-card .value { font-size: 20px; }
.time-presets .btn { padding: 4px 8px; font-size: 11px; }
.toolbar input[type="datetime-local"], .toolbar select { width: 120px; }
}
/* Feedback detail */
.fb-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.fb-tag { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 10px; border-radius: 6px; background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.fb-tag .k { color: hsl(var(--muted-foreground)); font-weight: 500; }
.fb-section { margin-bottom: 14px; }
.fb-label { font-size: 12px; font-weight: 600; color: hsl(var(--muted-foreground)); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .3px; }
.fb-body { font-size: 13px; line-height: 1.7; color: hsl(var(--foreground)); white-space: pre-wrap; word-break: break-word; }
.fb-scroll { max-height: 180px; overflow-y: auto; padding: 10px 14px; background: hsl(var(--muted)); border-radius: 8px; }
.fb-mono { font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 12px; line-height: 1.6; }
-34
View File
@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SayIt Admin</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png">
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link href="/fonts/nunito.css" rel="stylesheet">
<link rel="stylesheet" href="/admin.css">
</head>
<body>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<div class="layout">
<div class="mobile-header">
<span class="brand"><img src="/logo-72.png" alt="" width="22" height="22" style="vertical-align:middle;margin-right:6px;border-radius:4px"><span style="font-family:'Nunito',sans-serif;font-weight:800">SayIt</span> Admin</span>
<button class="hamburger" id="menu-btn" aria-label="菜单">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand"><img src="/logo-72.png" alt="SayIt" width="28" height="28" style="vertical-align:middle;margin-right:8px;border-radius:6px"><span style="font-family:'Nunito',sans-serif;font-weight:800">SayIt</span><small>管理后台</small></div>
<div class="sidebar-nav"></div>
<div class="sidebar-footer"></div>
</nav>
<div class="main"></div>
</div>
<script src="/admin.js"></script>
</body>
</html>
-587
View File
@@ -1,587 +0,0 @@
/* SayIt Admin v4 */
;(function () {
'use strict'
const $ = (s, c) => (c || document).querySelector(s)
const $$ = (s, c) => [...(c || document).querySelectorAll(s)]
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML }
function fmtDuration(ms) {
if (!ms && ms !== 0) return '-'
const sec = ms / 1000
if (sec < 60) return `${sec.toFixed(1)}s`
const min = sec / 60
if (min < 60) return `${Math.floor(min)}m${Math.round(sec % 60)}s`
return `${Math.floor(min / 60)}h${Math.round(min % 60)}m`
}
function fmtMs(ms) { if (!ms && ms !== 0) return '-'; return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms` }
function fmtTime(ts) { if (!ts) return '-'; return new Date(ts).toLocaleString('zh-CN', { hour12: false }) }
function pct(v) { return `${((v || 0) * 100).toFixed(1)}%` }
function shortModel(m) { if (!m) return '-'; const p = m.split('/'); return p[p.length - 1] }
function cmp(cur, prev, key) {
if (!prev || !prev[key]) return ''
const diff = cur[key] - prev[key]
if (diff === 0) return ''
const p = prev[key] ? Math.round(Math.abs(diff) / prev[key] * 100) : 0
return diff > 0 ? ` <span style="color:hsl(var(--success));font-size:11px" title="较上一同等时段">↑${p}%</span>` : ` <span style="color:hsl(var(--destructive));font-size:11px" title="较上一同等时段">↓${p}%</span>`
}
const LOADING = '<div class="loading-msg"><span class="spinner"></span>加载中…</div>'
const EMPTY = (msg, hint) => `<div class="empty-state">${msg}${hint ? `<div class="hint">${hint}</div>` : ''}</div>`
/* Auth */
let _auth = sessionStorage.getItem('sayit-admin-auth') || localStorage.getItem('sayit-admin-auth') || ''
async function api(path, params) {
if (!_auth) throw new Error('未登录')
const qs = new URLSearchParams()
if (params) Object.entries(params).forEach(([k, v]) => { if (v != null && v !== '') qs.set(k, String(v)) })
const q = qs.toString(); const url = q ? `${path}?${q}` : path
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 15000)
let res
try {
res = await fetch(url, { headers: { Accept: 'application/json', Authorization: _auth }, signal: ctrl.signal })
} catch (e) {
if (e.name === 'AbortError') throw new Error('请求超时,请检查网络连接')
throw new Error('网络连接失败,请检查服务器状态')
} finally { clearTimeout(timer) }
if (res.status === 401) { sessionStorage.removeItem('sayit-admin-auth'); localStorage.removeItem('sayit-admin-auth'); _auth = ''; showLogin('会话已过期,请重新登录'); throw new Error('401') }
if (!res.ok) throw new Error(`请求失败 (${res.status}),请稍后重试`)
return res.json()
}
function statusBadge(s) {
const c = s === 'success' ? 'badge-success' : (s === 'failed' ? 'badge-error' : 'badge-warning')
return `<span class="badge ${c}">${s}</span>`
}
/* CSV export helper */
function downloadCSV(filename, headers, rows) {
const escape = v => { const s = String(v ?? ''); return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s }
const lines = [headers.map(escape).join(','), ...rows.map(r => r.map(escape).join(','))]
const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click()
URL.revokeObjectURL(a.href)
}
/* Theme */
const THEMES = [{id:'light',label:'浅色'},{id:'dark',label:'深色'},{id:'claude',label:'暖色'}]
function applyTheme(id) {
document.documentElement.dataset.theme = id
localStorage.setItem('sayit-admin-theme', id)
$$('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === id))
}
/* Time presets */
const TIME_PRESETS = [
{ label: '1小时', hours: 1 }, { label: '6小时', hours: 6 },
{ label: '今天', hours: -1 }, { label: '7天', hours: 168 },
{ label: '15天', hours: 360 }, { label: '全部', hours: 0 },
]
function timePresetHTML(prefix) {
return `<div class="time-presets" id="${prefix}-presets">${TIME_PRESETS.map((p, i) =>
`<button class="btn btn-outline${i === 3 ? ' active' : ''}" data-hours="${p.hours}">${p.label}</button>`
).join('')}</div><label class="field"><span>开始</span><input id="${prefix}-from" type="datetime-local"></label><label class="field"><span>结束</span><input id="${prefix}-to" type="datetime-local"></label>`
}
function bindTimePresets(prefix, cb) {
const c = $(`#${prefix}-presets`); if (!c) return
c.addEventListener('click', e => {
const btn = e.target.closest('button'); if (!btn) return
$$('button', c).forEach(b => b.classList.remove('active')); btn.classList.add('active')
const h = Number(btn.dataset.hours), range = presetToRange(h)
const f = $(`#${prefix}-from`), t = $(`#${prefix}-to`)
if (f) f.value = range.from ? toLocal(range.from) : ''; if (t) t.value = ''
cb()
})
const clearActive = () => $$('button', c).forEach(b => b.classList.remove('active'))
$(`#${prefix}-from`)?.addEventListener('input', clearActive)
$(`#${prefix}-to`)?.addEventListener('input', clearActive)
}
function presetToRange(h) {
if (h === 0) return {}
const now = new Date()
if (h === -1) return { from: new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString() }
return { from: new Date(now.getTime() - h * 3600000).toISOString() }
}
function toLocal(iso) { return iso ? new Date(new Date(iso).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16) : '' }
function getTimeRange(prefix) {
const p = {}
const f = $(`#${prefix}-from`)?.value; if (f) p.from = new Date(f).toISOString()
const t = $(`#${prefix}-to`)?.value; if (t) p.to = new Date(t).toISOString()
return p
}
/* URL hash state (#/sessions?status=failed&user_id=xxx) */
function hashParams() {
const idx = location.hash.indexOf('?')
if (idx < 0) return {}
return Object.fromEntries(new URLSearchParams(location.hash.slice(idx + 1)))
}
function setHashParams(params) {
const h = location.hash.replace(/\?.*$/, '')
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null && v !== '') qs.set(k, v) })
const s = qs.toString()
history.replaceState(null, '', s ? `${h}?${s}` : h)
}
function restoreField(id, key, params) {
const el = $(`#${id}`); if (el && params[key]) el.value = params[key]
}
/* Icons */
const IC = {
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>',
sessions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/></svg>',
analytics: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
feedback: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
}
/* Router */
const ROUTES = [
{ hash: '', label: '仪表盘', icon: 'dashboard', render: renderDashboard },
{ hash: 'sessions', label: '会话管理', icon: 'sessions', render: renderSessions },
{ hash: 'analytics', label: '统计分析', icon: 'analytics', render: renderAnalytics },
{ hash: 'feedback', label: '用户反馈', icon: 'feedback', render: renderFeedback },
{ hash: 'logs', label: '服务日志', icon: 'logs', render: renderLogs },
{ hash: 'system', label: '系统信息', icon: 'system', render: renderSystem },
]
let _autoRefreshTimer = null
let _logTailTimer = null
function _clearTimers() { clearInterval(_autoRefreshTimer); _autoRefreshTimer = null; clearInterval(_logTailTimer); _logTailTimer = null }
function navigate() {
if (!_auth) return
_clearTimers()
const h = location.hash.replace(/^#\/?/, '').replace(/\?.*$/, '')
const route = ROUTES.find(r => r.hash === h) || ROUTES[0]
$$('.sidebar-nav a').forEach(a => a.classList.toggle('active', a.dataset.route === route.hash))
const main = $('.main'); main.innerHTML = LOADING
route.render(main).catch(err => {
if (String(err) === 'Error: 401') return
main.innerHTML = `<div class="notice error" style="display:block">${esc(String(err))}</div>`
})
}
/* Login */
function showLogin(msg) {
let m = $('.login-mask')
if (!m) {
m = document.createElement('div'); m.className = 'login-mask'
m.innerHTML = `<div class="login-box"><h2 style="font-family:'Nunito',sans-serif;font-weight:800">SayIt Admin</h2><div class="err" id="login-err"></div><div class="field"><span>用户名</span><input id="login-user" autocomplete="username"></div><div class="field"><span>密码</span><input id="login-pass" type="password" autocomplete="current-password"></div><button class="btn btn-primary" style="width:100%;margin-top:20px" id="login-btn">登录</button></div>`
document.body.appendChild(m)
$('#login-btn').addEventListener('click', doLogin)
$('#login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() })
$('#login-user').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-pass').focus() })
}
m.classList.remove('hidden')
if (msg) $('#login-err').textContent = msg
setTimeout(() => $('#login-user')?.focus(), 50)
}
async function doLogin() {
const u = $('#login-user').value.trim(), p = $('#login-pass').value
if (!u || !p) { $('#login-err').textContent = '请输入用户名和密码'; return }
const h = 'Basic ' + btoa(u + ':' + p)
try {
const res = await fetch('/admin/api/overview', { headers: { Accept: 'application/json', Authorization: h } })
if (res.status === 401) { $('#login-err').textContent = '用户名或密码错误'; return }
if (!res.ok) { $('#login-err').textContent = '无法连接服务器'; return }
} catch (e) { $('#login-err').textContent = '网络连接失败,请检查服务器状态'; return }
_auth = h; sessionStorage.setItem('sayit-admin-auth', h); localStorage.setItem('sayit-admin-auth', h)
$('.login-mask').classList.add('hidden'); navigate()
}
/* Boot */
function boot() {
applyTheme(localStorage.getItem('sayit-admin-theme') || 'light')
$('.sidebar-nav').innerHTML = ROUTES.map(r =>
`<a href="#/${r.hash}" data-route="${r.hash}">${IC[r.icon]}<span>${r.label}</span></a>`
).join('')
$('.sidebar-footer').innerHTML = THEMES.map(t =>
`<button class="theme-btn" data-theme="${t.id}">${t.label}</button>`
).join('')
$$('.theme-btn').forEach(b => b.addEventListener('click', () => applyTheme(b.dataset.theme)))
// 退出按钮插入到 sidebar 底部
const logoutDiv = document.createElement('div')
logoutDiv.style.cssText = 'padding:8px 12px 0'
logoutDiv.innerHTML = '<button class="btn btn-outline" style="width:100%;font-size:12px;height:28px" id="logout-btn">退出登录</button>'
$('#sidebar').appendChild(logoutDiv)
$('#logout-btn').addEventListener('click', () => { sessionStorage.removeItem('sayit-admin-auth'); localStorage.removeItem('sayit-admin-auth'); _auth = ''; showLogin() })
applyTheme(localStorage.getItem('sayit-admin-theme') || 'light')
/* 移动端汉堡菜单 */
const sidebar = $('#sidebar'), overlay = $('#sidebar-overlay')
const closeSidebar = () => { sidebar.classList.remove('open'); overlay.classList.remove('open') }
$('#menu-btn')?.addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('open') })
overlay?.addEventListener('click', closeSidebar)
$('.sidebar-nav').addEventListener('click', () => { if (window.innerWidth <= 768) closeSidebar() })
window.addEventListener('hashchange', navigate)
if (!_auth) { showLogin(); return }
navigate()
}
document.addEventListener('DOMContentLoaded', boot)
/* === Dashboard === */
async function renderDashboard(el) {
el.innerHTML = `<div class="page-header"><h1>仪表盘</h1></div><div class="page-body">
<div id="d-status" class="dash-status"></div>
<div class="toolbar">${timePresetHTML('d')}<div class="toolbar-actions"><label class="field" style="flex-direction:row;align-items:center;gap:6px"><span>自动刷新</span><select id="d-auto" style="width:80px"><option value="0">关闭</option><option value="30">30s</option><option value="60" selected>60s</option></select></label><button class="btn btn-outline" id="d-apply">查询</button></div></div>
<div id="d-metrics" class="dash-metrics" style="margin-top:16px"></div>
<div class="dash-bottom"><div class="card"><h3>趋势</h3><div id="d-trend"></div></div><div class="card"><h3>最近异常</h3><ul class="top-list" id="d-errors"></ul></div></div>
</div>`
const loadData = async () => {
const range = getTimeRange('d')
const ov = await api('/admin/api/overview', range)
let prevOv = null
if (range.from) {
const fromDate = new Date(range.from), dur = Date.now() - fromDate.getTime()
try { prevOv = await api('/admin/api/overview', { from: new Date(fromDate.getTime() - dur).toISOString(), to: range.from }) } catch(e) {}
}
$('#d-metrics').innerHTML = [
metricCard('会话数', ov.total_sessions + cmp(ov, prevOv, 'total_sessions')),
metricCard('总音频', fmtDuration(ov.total_audio_ms) + cmp(ov, prevOv, 'total_audio_ms')),
metricCard('总按住', fmtDuration(ov.total_hold_ms)),
metricCard('平均 ASR', fmtMs(ov.avg_asr_ms)),
metricCard('平均 LLM', fmtMs(ov.avg_llm_ms)),
metricCard('空结果率', pct(ov.empty_result_rate)), metricCard('错误率', pct(ov.error_rate)),
].join('')
/* 最近异常会话 */
try {
const errData = await api('/admin/api/sessions', { ...range, status: 'failed', limit: 5, offset: 0 })
const items = errData.items || []
$('#d-errors').innerHTML = items.length ? items.map(i =>
`<li class="clickable" data-sid="${i.session_id}"><span>${fmtTime(i.started_at)} · ${esc(i.error_code || i.status)}</span><span>${esc(i.process_name || '-')}</span></li>`
).join('') : EMPTY('暂无异常', '一切正常运行中')
$$('li.clickable', $('#d-errors')).forEach(li => li.addEventListener('click', () => {
location.hash = `#/sessions?status=failed`
}))
} catch(e) { $('#d-errors').innerHTML = EMPTY('加载失败') }
await loadTrend()
}
/* 趋势图跟随时间过滤器 */
const loadTrend = async () => {
const range = getTimeRange('d')
const from = range.from ? new Date(range.from) : new Date(Date.now() - 15 * 86400000)
const spanH = (Date.now() - from.getTime()) / 3600000
const bucket = spanH <= 72 ? 'hour' : 'day'
const tl = await api('/admin/api/metrics/timeline', { bucket, from: from.toISOString(), to: range.to || undefined })
renderBarChart($('#d-trend'), tl)
}
await loadTrend()
const [health, sys] = await Promise.all([api('/admin/api/healthz-details'), api('/admin/api/system-info')])
const llm = sys.llm || {}, wd = health.web_demo || {}, res = sys.resources || {}
const gpuCard = (res.gpus || []).length ? statusCard('GPU', true, `${res.gpus[0].util_percent}% · ${res.gpus[0].mem_used_mb}/${res.gpus[0].mem_total_mb}MB · ${res.gpus[0].temp_c}°C`) : ''
$('#d-status').innerHTML = [
statusCard('ASR', health.asr, shortModel(health.asr_model)),
statusCard('LLM', health.llm, llm.provider ? `${llm.provider} / ${llm.model || '-'}` : ''),
statusCard('WebSocket', true, `${health.active_connections || 0} 个活跃连接`),
statusCard('CPU/内存', true, `CPU ${res.cpu_percent ?? '-'}% · 内存 ${res.mem_percent ?? '-'}%`),
gpuCard,
].filter(Boolean).join('')
bindTimePresets('d', loadData)
$('#d-apply')?.addEventListener('click', loadData)
/* 自动刷新 */
const setupAutoRefresh = () => {
clearInterval(_autoRefreshTimer); _autoRefreshTimer = null
const sec = Number($('#d-auto')?.value || 0)
if (sec > 0) _autoRefreshTimer = setInterval(loadData, sec * 1000)
}
$('#d-auto')?.addEventListener('change', setupAutoRefresh)
setupAutoRefresh()
await loadData()
}
function statusCard(name, ok, detail) {
return `<div class="status-card"><div class="dot ${ok ? 'ok' : 'off'}"></div><div class="info"><div class="name">${name}</div>${detail ? `<div class="detail" title="${esc(detail)}">${esc(detail)}</div>` : ''}</div></div>`
}
function metricCard(label, value) {
return `<div class="metric-card"><div class="label">${label}</div><div class="value">${value}</div></div>`
}
function renderBarChart(el, data) {
if (!data || !data.length) { el.innerHTML = EMPTY('暂无趋势数据', '选择更大的时间范围查看'); return }
const max = Math.max(...data.map(d => d.sessions), 1)
el.innerHTML = `<div class="bar-chart">${data.map(d => {
const h = Math.max(4, (d.sessions / max) * 100), lbl = (d.bucket || '').slice(5)
return `<div class="bar" style="height:${h}%" title="${d.bucket}: ${d.sessions} 会话"><span class="bar-top">${d.sessions}</span><span class="bar-label">${lbl}</span></div>`
}).join('')}</div>`
}
/* === Sessions === */
let SS = { offset: 0, limit: 30, sel: null, sort: 'time_desc' }
async function renderSessions(el) {
SS = { offset: 0, limit: 30, sel: null, sort: 'time_desc' }
el.innerHTML = `<div class="page-header"><h1>会话管理</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('s')}</div>
<div class="toolbar">
<label class="field"><span>用户</span><input id="s-user" placeholder="user id" style="width:110px"></label>
<label class="field"><span>应用</span><input id="s-app" placeholder="process" style="width:110px"></label>
<label class="field"><span>状态</span><select id="s-status" style="width:110px"><option value="">全部</option><option value="success">success</option><option value="empty_result">empty_result</option><option value="failed">failed</option><option value="disconnected">disconnected</option><option value="short_audio">short_audio</option><option value="empty_audio">empty_audio</option></select></label>
<label class="field"><span>AI</span><select id="s-ai" style="width:80px"><option value="">全部</option><option value="true">启用</option><option value="false">未启用</option></select></label>
<label class="field"><span>来源</span><select id="s-source" style="width:100px"><option value="">全部</option><option value="live">实时录音</option><option value="history_reprocess">重新识别</option></select></label>
<div class="toolbar-actions"><button class="btn btn-outline" id="s-export">导出 CSV</button><button class="btn btn-outline" id="s-apply">查询</button></div>
</div>
<div class="split">
<div><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><span class="section-title" style="margin:0">会话列表</span><span id="s-count" style="font-size:12px;color:hsl(var(--muted-foreground))"></span></div><div id="s-table"></div><div class="pagination" id="s-pager"></div></div>
<div class="card"><h3>会话详情</h3><div id="s-detail">${EMPTY('选择左侧会话查看详情')}</div></div>
</div>
</div>`
/* Restore filters from URL hash */
const hp = hashParams()
restoreField('s-user', 'user_id', hp)
restoreField('s-app', 'app', hp)
restoreField('s-status', 'status', hp)
restoreField('s-ai', 'ai_enabled', hp)
restoreField('s-source', 'source', hp)
$('#s-apply').addEventListener('click', () => { SS.offset = 0; loadSessions() })
$('#s-export').addEventListener('click', exportSessions)
bindTimePresets('s', () => { SS.offset = 0; loadSessions() })
await loadSessions()
}
function sessFilters() {
const p = getTimeRange('s')
const u = $('#s-user')?.value; if (u) p.user_id = u
const a = $('#s-app')?.value; if (a) p.app = a
const s = $('#s-status')?.value; if (s) p.status = s
const ai = $('#s-ai')?.value; if (ai) p.ai_enabled = ai
const src = $('#s-source')?.value; if (src) p.source = src
/* Persist to URL */
setHashParams({ user_id: u, app: a, status: s, ai_enabled: ai, source: src })
return p
}
async function exportSessions() {
try {
const data = await api('/admin/api/sessions', { ...sessFilters(), limit: 200, offset: 0 })
const items = data.items || []
if (!items.length) return
const headers = ['时间','用户','IP','应用','按住(ms)','音频(ms)','ASR(ms)','LLM(ms)','状态','来源']
const rows = items.map(i => [fmtTime(i.started_at), i.user_name||i.user_id||'web', i.forwarded_for||i.client_ip||'', i.process_name||'', i.ptt_hold_ms||'', i.audio_duration_ms||'', i.asr_ms||'', i.llm_ms||'', i.status, i.source||'live'])
downloadCSV('sessions.csv', headers, rows)
} catch(e) {}
}
async function loadSessions() {
const data = await api('/admin/api/sessions', { ...sessFilters(), limit: SS.limit, offset: SS.offset, sort: SS.sort })
const items = data.items || [], total = data.total || 0
$('#s-count').textContent = `${total} 条`
const sortIcon = (col) => { if (!SS.sort.startsWith(col)) return ''; return SS.sort.endsWith('_desc') ? ' ↓' : ' ↑' }
if (items.length) {
$('#s-table').innerHTML = `<div class="table-scroll"><table><thead><tr><th class="sortable" data-col="time">时间${sortIcon('time')}</th><th>用户</th><th>IP</th><th>应用</th><th class="sortable" data-col="audio">按住/音频${sortIcon('audio')}</th><th class="sortable" data-col="asr">ASR/LLM${sortIcon('asr')}</th><th>状态</th></tr></thead><tbody>${items.map(i => {
const ip = i.forwarded_for || i.client_ip || '-'
const user = i.user_name || i.user_id || '<span style="color:hsl(var(--muted-foreground))">web</span>'
const srcTag = i.source === 'history_reprocess' ? ' <span class="badge badge-muted" style="font-size:10px;padding:1px 5px">重识</span>' : ''
return `<tr class="clickable${SS.sel === i.session_id ? ' active' : ''}" data-sid="${i.session_id}"><td>${fmtTime(i.started_at)}</td><td>${user}</td><td>${esc(ip)}</td><td>${esc(i.process_name || '-')}</td><td>${fmtDuration(i.ptt_hold_ms)} / ${fmtDuration(i.audio_duration_ms)}</td><td>${fmtMs(i.asr_ms)} / ${i.llm_enabled ? fmtMs(i.llm_ms) : '-'}</td><td>${statusBadge(i.status)}${srcTag}</td></tr>`
}).join('')}</tbody></table></div>`
} else { $('#s-table').innerHTML = EMPTY('当前筛选条件下暂无会话', '尝试调整时间范围或筛选条件') }
/* Pagination with page numbers (#12) */
const tp = Math.ceil(total / SS.limit), cp = Math.floor(SS.offset / SS.limit) + 1
if (tp > 1) {
let pages = ''
const addPage = (n) => { pages += `<button class="pg ${n===cp?'active':''}" data-page="${n}">${n}</button>` }
addPage(1)
if (cp > 3) pages += '<span>…</span>'
for (let i = Math.max(2, cp - 1); i <= Math.min(tp - 1, cp + 1); i++) addPage(i)
if (cp < tp - 2) pages += '<span>…</span>'
if (tp > 1) addPage(tp)
$('#s-pager').innerHTML = `<button ${cp<=1?'disabled':''} id="s-prev">上一页</button>${pages}<button ${cp>=tp?'disabled':''} id="s-next">下一页</button>`
$$('.pg', $('#s-pager')).forEach(b => b.addEventListener('click', () => { SS.offset = (Number(b.dataset.page) - 1) * SS.limit; loadSessions() }))
} else { $('#s-pager').innerHTML = '' }
$('#s-prev')?.addEventListener('click', () => { SS.offset = Math.max(0, SS.offset - SS.limit); loadSessions() })
$('#s-next')?.addEventListener('click', () => { SS.offset += SS.limit; loadSessions() })
$$('tr.clickable', $('#s-table')).forEach(tr => tr.addEventListener('click', () => loadDetail(tr.dataset.sid)))
/* Sort by clicking column headers */
$$('th.sortable', $('#s-table')).forEach(th => th.addEventListener('click', () => {
const col = th.dataset.col
SS.sort = SS.sort === `${col}_desc` ? `${col}_asc` : `${col}_desc`
SS.offset = 0; loadSessions()
}))
if (items.length && !SS.sel) loadDetail(items[0].session_id)
}
async function loadDetail(sid) {
SS.sel = sid
$$('tr.clickable', $('#s-table')).forEach(tr => tr.classList.toggle('active', tr.dataset.sid === sid))
const d = await api(`/admin/api/sessions/${sid}`)
const ip = d.forwarded_for || d.client_ip || '-'
const startEvt = (d.events || []).find(e => e.event_type === 'session_started')
const cm = (startEvt && startEvt.payload && startEvt.payload.client_meta) || {}
/* #5 — Grouped detail sections */
const userInfo = [['用户', d.user_name || d.user_id || '-'],['设备', d.hostname || '-'],['设备 ID', d.device_id || '-'],['客户端', `${d.client_version || '-'} / ${d.platform || '-'}`],['系统', cm.os_version || '-'],['语言', cm.system_locale || '-']]
const netInfo = [['公网 IP', ip],['内网 IP', d.local_ip || '-']]
const sessInfo = [['来源', d.source === 'history_reprocess' ? '重新识别' : '实时录音'],['应用', d.process_name || '-'],['窗口', d.window_title || '-'],['模型', `${shortModel(d.asr_model)} / ${d.llm_model || '-'}`],['节点', d.node_id || '-'],['状态', d.status || '-'],['错误', d.error_code ? `${d.error_code}: ${d.error_message || ''}` : '-']]
const timing = [['PTT', fmtMs(d.ptt_hold_ms)],['音频', fmtMs(d.audio_duration_ms)],['VAD', fmtMs(d.vad_ms)],['排队', fmtMs(d.queue_wait_ms)],['ASR推理', fmtMs(d.infer_ms)],['Batch', fmtMs(d.batch_exec_ms)],['ASR总', fmtMs(d.asr_ms)],['LLM总', d.llm_enabled ? fmtMs(d.llm_ms) : '-']]
const detailGroup = (title, pairs) => `<div class="section-title" style="margin-top:14px">${title}</div><div class="detail-grid">${pairs.map(([k, v]) => `<div class="detail-item"><div class="k">${k}</div><div class="v">${esc(String(v))}</div></div>`).join('')}</div>`
/* #2 — Structured event timeline */
const LABELS = {session_started:'会话开始',session_stopped:'录音停止',asr_completed:'ASR 完成',llm_completed:'LLM 完成',session_finished:'会话结束',session_failed:'会话失败',session_status:'状态变更',ws_connected:'连接建立',ws_disconnected:'连接断开',audio_saved:'音频保存'}
const eventsHTML = (d.events || []).map(e => {
const p = e.payload || {}, label = LABELS[e.event_type] || e.event_type
const isErr = e.event_type === 'session_failed'
const details = Object.keys(p).length ? Object.entries(p).map(([k,v]) => `<span class="tl-kv"><span class="k">${esc(k)}</span> ${esc(typeof v === 'object' ? JSON.stringify(v) : String(v))}</span>`).join('') : ''
return `<div class="tl-item${isErr ? ' tl-error' : ''}"><div class="tl-dot"></div><div class="tl-body"><div class="tl-head"><span class="tl-label">${label}</span><span class="tl-time">${fmtTime(e.event_time)}</span></div>${details ? `<div class="tl-details">${details}</div>` : ''}</div></div>`
}).join('') || EMPTY('暂无事件')
$('#s-detail').innerHTML = `
${detailGroup('用户信息', userInfo)}
${detailGroup('网络信息', netInfo)}
${detailGroup('会话信息', sessInfo)}
${detailGroup('耗时拆解', timing)}
<div class="section-title" style="margin-top:14px">事件时间线</div>
<div class="timeline-v">${eventsHTML}</div>`
}
/* === Analytics === */
async function renderAnalytics(el) {
el.innerHTML = `<div class="page-header"><h1>统计分析</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('a')}<div class="toolbar-actions"><button class="btn btn-outline" id="a-apply">查询</button></div></div>
<div id="a-content">${LOADING}</div></div>`
const load = async () => {
const r = getTimeRange('a')
const [byApp, byUser, byModel, durDist] = await Promise.all([api('/admin/api/metrics/by-app', r), api('/admin/api/metrics/by-user', r), api('/admin/api/metrics/by-model', r), api('/admin/api/metrics/duration-distribution', r)])
$('#a-content').innerHTML = aDurDist(durDist) + aBarChart(byApp) + aTable('按应用', byApp, 'app') + aTable('按用户', byUser, 'user_id') + aTable('按 ASR 模型', byModel.asr || []) + aTable('按 LLM 模型', byModel.llm || []) + await aPerfSection(r)
/* #7 drill-down: click row to jump to sessions */
$$('tr.drill', $('#a-content')).forEach(tr => tr.addEventListener('click', () => {
const key = tr.dataset.drillKey, val = tr.dataset.drillVal
if (key && val) location.hash = `#/sessions?${key}=${encodeURIComponent(val)}`
}))
}
$('#a-apply').addEventListener('click', load); bindTimePresets('a', load); await load()
}
function aDurDist(rows) {
if (!rows || !rows.length) return ''
const total = rows.reduce((s, r) => s + r.count, 0)
if (!total) return ''
const max = Math.max(...rows.map(r => r.count), 1)
return `<div class="analytics-section"><h2>音频时长分布</h2><div class="card" style="padding:20px 18px 12px"><div class="bar-chart" style="height:80px">${rows.map(r => {
const h = Math.max(3, (r.count / max) * 100)
const p = Math.round(r.count / total * 100)
return `<div class="bar" style="height:${h}%" title="${r.label}: ${r.count} 条 (${p}%)"><span class="bar-top">${r.count}</span><span class="bar-label">${r.label}</span></div>`
}).join('')}</div><div style="text-align:center;margin-top:24px;font-size:12px;color:hsl(var(--muted-foreground))">共 ${total} 条会话</div></div></div>`
}
async function aPerfSection(range) {
try {
const d = await api('/admin/api/metrics/percentiles', range)
if (!d || !d.asr_p50) return ''
return `<div class="analytics-section"><h2>性能分位数</h2><table><thead><tr><th>指标</th><th>P50</th><th>P95</th><th>P99</th></tr></thead><tbody><tr><td>ASR 耗时</td><td>${fmtMs(d.asr_p50)}</td><td>${fmtMs(d.asr_p95)}</td><td>${fmtMs(d.asr_p99)}</td></tr><tr><td>LLM 耗时</td><td>${fmtMs(d.llm_p50)}</td><td>${fmtMs(d.llm_p95)}</td><td>${fmtMs(d.llm_p99)}</td></tr></tbody></table></div>`
} catch(e) { return '' }
}
function aBarChart(rows) {
if (!rows || !rows.length) return ''
const max = Math.max(...rows.map(r => r.sessions), 1)
return `<div class="analytics-section"><h2>应用分布</h2><div class="card" style="padding:20px 18px 12px"><div class="bar-chart" style="height:80px">${rows.slice(0, 10).map(r => {
const h = Math.max(3, (r.sessions / max) * 100)
return `<div class="bar" style="height:${h}%" title="${esc(r.label)}: ${r.sessions} 会话"><span class="bar-top">${r.sessions}</span><span class="bar-label">${esc(r.label).slice(0, 10)}</span></div>`
}).join('')}</div></div></div>`
}
function aTable(title, rows, drillKey) {
if (!rows || !rows.length) return `<div class="analytics-section"><h2>${title}</h2>${EMPTY('暂无数据')}</div>`
return `<div class="analytics-section"><h2>${title}</h2><table><thead><tr><th>名称</th><th>会话</th><th>总音频</th><th>平均ASR</th><th>平均LLM</th><th>失败</th></tr></thead><tbody>${rows.map(r =>
`<tr${drillKey ? ` class="drill clickable" data-drill-key="${drillKey}" data-drill-val="${esc(r.label || '')}"` : ''}><td>${esc(r.label || '-')}</td><td>${r.sessions}</td><td>${fmtDuration(r.total_audio_ms)}</td><td>${fmtMs(r.avg_asr_ms)}</td><td>${fmtMs(r.avg_llm_ms)}</td><td>${r.failed_sessions || 0}</td></tr>`
).join('')}</tbody></table></div>`
}
/* === Feedback === */
async function renderFeedback(el) {
el.innerHTML = `<div class="page-header"><h1>用户反馈</h1></div><div class="page-body">
<div class="toolbar"><div class="toolbar-actions"><button class="btn btn-outline" id="fb-refresh">刷新</button></div></div>
<div id="fb-list">${LOADING}</div>
<div id="fb-detail" class="card" style="display:none;margin-top:16px;overflow:hidden"></div></div>`
let _page = 0, _limit = 20
const load = async () => {
const data = await api('/admin/api/feedback', { limit: _limit, offset: _page * _limit })
const items = data.items || [], total = data.total || 0
if (!items.length) { $('#fb-list').innerHTML = EMPTY('暂无反馈'); return }
const rows = items.map(i => {
const t = new Date(i.created_at).toLocaleString('zh-CN', { hour12: false })
const tr = i.transcript_json
const hasTranscript = tr && (tr.asr_text || tr.ai_text)
return `<tr class="clickable" data-id="${i.id}"><td>${t}</td><td>${esc(i.machine_id).slice(0,16)}</td><td>${esc(i.app_version || '-')}</td><td>${esc(i.feedback_text).slice(0,80)}</td><td>${hasTranscript ? '有' : '-'}</td><td>${esc(i.client_ip || '-')}</td></tr>`
}).join('')
const pages = Math.ceil(total / _limit)
$('#fb-list').innerHTML = `<div class="table-wrap"><table><thead><tr><th>时间</th><th>设备</th><th>版本</th><th>反馈内容</th><th>转录</th><th>IP</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="pagination"><span>共 ${total} 条</span>${pages > 1 ? Array.from({length: pages}, (_, i) => `<button class="btn btn-outline btn-sm ${i === _page ? 'active' : ''}" data-p="${i}">${i+1}</button>`).join('') : ''}</div>`
$$('#fb-list tr.clickable').forEach(tr => tr.addEventListener('click', () => showDetail(items.find(i => String(i.id) === tr.dataset.id))))
$$('#fb-list .pagination button').forEach(b => b.addEventListener('click', () => { _page = Number(b.dataset.p); load() }))
}
const showDetail = (item) => {
if (!item) return
const d = $('#fb-detail'); d.style.display = 'block'
const tr = item.transcript_json, ctx = item.context_json
const t = new Date(item.created_at).toLocaleString('zh-CN', { hour12: false })
const metaItems = [
['设备', item.machine_id], ['版本', item.app_version || '-'], ['IP', item.client_ip || '-'], ['时间', t],
]
if (ctx) {
metaItems.push(['模式', ctx.work_mode || '-'])
metaItems.push(['ASR', `${ctx.asr_provider || '-'} / ${ctx.asr_model || '-'}`])
if (ctx.ai_enabled) metaItems.push(['AI', `${ctx.ai_provider || '-'} / ${ctx.ai_model || '-'}`])
if (ctx.ai_preset_name) metaItems.push(['润色', ctx.ai_preset_name])
}
if (tr && tr.duration_sec) metaItems.push(['时长', tr.duration_sec.toFixed(1) + 's'])
let html = `<div class="fb-meta">${metaItems.map(([k, v]) => `<span class="fb-tag"><span class="k">${esc(k)}</span>${esc(v)}</span>`).join('')}</div>`
html += `<div class="fb-section"><div class="fb-label">反馈内容</div><div class="fb-body">${esc(item.feedback_text)}</div></div>`
if (tr && tr.asr_text) html += `<div class="fb-section"><div class="fb-label">ASR 原文</div><div class="fb-body fb-scroll">${esc(tr.asr_text)}</div></div>`
if (tr && tr.ai_text) html += `<div class="fb-section"><div class="fb-label">AI 润色</div><div class="fb-body fb-scroll">${esc(tr.ai_text)}</div></div>`
if (ctx && ctx.ai_system_prompt) html += `<div class="fb-section"><div class="fb-label">System Prompt</div><div class="fb-body fb-scroll fb-mono">${esc(ctx.ai_system_prompt)}</div></div>`
d.innerHTML = html
d.scrollIntoView({ behavior: 'smooth' })
}
$('#fb-refresh').addEventListener('click', () => { _page = 0; load() })
await load()
}
/* === Logs === */
async function renderLogs(el) {
el.innerHTML = `<div class="page-header"><h1>服务日志</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('l')}<label class="field"><span>级别</span><select id="l-level" style="width:100px"><option value="">全部</option><option value="ERROR">ERROR</option><option value="WARNING">WARNING</option><option value="INFO">INFO</option></select></label><label class="field"><span>搜索</span><input id="l-search" placeholder="关键词" style="width:120px"></label><label class="field"><span>条数</span><select id="l-limit" style="width:70px"><option value="50">50</option><option value="100">100</option><option value="200">200</option></select></label><div class="toolbar-actions"><button class="btn btn-outline" id="l-tail">实时</button><button class="btn btn-outline" id="l-export">导出</button><button class="btn btn-outline" id="l-refresh">刷新</button></div></div>
<div id="l-list">${LOADING}</div></div>`
let _tailing = false
const load = async () => {
const p = { limit: $('#l-limit').value }; const lv = $('#l-level').value; if (lv) p.level = lv
const lr = getTimeRange('l'); if (lr.from) p.from = lr.from; if (lr.to) p.to = lr.to
const lq = $('#l-search')?.value?.trim(); if (lq) p.q = lq
const data = await api('/admin/api/service-logs', p)
$('#l-list').innerHTML = (data || []).map(i =>
`<div class="log-item ${i.level === "ERROR" ? "log-error" : i.level === "WARNING" ? "log-warn" : ""}"><div class="meta">${fmtTime(i.created_at)} · ${esc(i.level)} · ${esc(i.logger_name || '')}</div><div class="msg">${esc(i.message || '-')}</div></div>`
).join('') || EMPTY('暂无日志', '服务运行后将自动记录')
return data
}
const toggleTail = () => {
_tailing = !_tailing
const btn = $('#l-tail')
if (_tailing) {
btn.classList.add('btn-primary'); btn.classList.remove('btn-outline'); btn.textContent = '停止'
_logTailTimer = setInterval(load, 5000)
} else {
btn.classList.remove('btn-primary'); btn.classList.add('btn-outline'); btn.textContent = '实时'
clearInterval(_logTailTimer); _logTailTimer = null
}
}
const exportLogs = async () => {
try {
const data = await api('/admin/api/service-logs', { limit: 200 })
if (!data?.length) return
downloadCSV('logs.csv', ['时间','级别','来源','消息'], data.map(i => [fmtTime(i.created_at), i.level, i.logger_name||'', i.message||'']))
} catch(e) {}
}
$('#l-refresh').addEventListener('click', load); $('#l-level').addEventListener('change', load)
$('#l-tail').addEventListener('click', toggleTail)
$('#l-export').addEventListener('click', exportLogs)
bindTimePresets('l', load); await load()
}
/* === System === */
async function renderSystem(el) {
el.innerHTML = `<div class="page-header"><h1>系统信息</h1></div><div class="page-body"><div class="info-grid" id="sys-grid">${LOADING}</div></div>`
const [h, s] = await Promise.all([api('/admin/api/healthz-details'), api('/admin/api/system-info')])
const wd = s.web_demo || {}, llm = s.llm || {}, wllm = s.web_demo_llm || {}, tel = h.telemetry || {}, res = s.resources || {}
const gpus = res.gpus || []
const uptimeStr = res.uptime_sec ? fmtDuration(res.uptime_sec * 1000) : '-'
const cards = [
infoCard('ASR 引擎', [['状态', h.asr ? '已加载' : '未加载'],['引擎', s.asr_engine || '-'],['模型', shortModel(s.asr_model)]]),
infoCard('LLM 引擎', [['状态', llm.enabled ? '已启用' : '未启用'],['Provider', llm.provider || '-'],['模型', llm.model || '-']]),
infoCard('Web Demo', [['启用', wd.enabled ? '是' : '否'],['LLM', wllm.enabled ? `${wllm.provider} / ${wllm.model}` : '未启用'],['最大时长', `${wd.max_duration_sec || '-'}s`],['单IP并发', wd.max_concurrency_per_ip || '-']]),
infoCard('部署信息', [['节点', s.node_id || '-'],['模式', s.deployment_mode || '-'],['运行时间', uptimeStr],['遥测', tel.enabled ? '已启用' : '未启用'],['数据库', shortPath(tel.db_path)],['日志', shortPath(tel.log_file)]]),
infoCard('CPU / 内存', [['CPU 使用率', `${res.cpu_percent ?? '-'}%`],['CPU 核心', res.cpu_count || '-'],['内存', `${res.mem_used_gb ?? '-'} / ${res.mem_total_gb ?? '-'} GB (${res.mem_percent ?? '-'}%)`],['磁盘', `${res.disk_used_gb ?? '-'} / ${res.disk_total_gb ?? '-'} GB (${res.disk_percent ?? '-'}%)`]]),
]
gpus.forEach(g => {
cards.push(infoCard(`GPU ${g.index} — ${g.name}`, [['利用率', `${g.util_percent}%`],['显存', `${g.mem_used_mb} / ${g.mem_total_mb} MB`],['温度', `${g.temp_c}°C`]]))
})
$('#sys-grid').innerHTML = cards.join('')
}
function shortPath(p) { if (!p) return '-'; const parts = p.split('/'); return parts.length > 3 ? '…/' + parts.slice(-2).join('/') : p }
function infoCard(title, rows) {
return `<div class="info-card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="info-row"><span class="k">${k}</span><span class="v" title="${esc(String(v))}">${esc(String(v))}</span></div>`
).join('')}</div>`
}
})()