chore: 0.0.8 更新(本地模型、小米 MiMo 供应商、体验优化与多项修复)
This commit is contained in:
1 parent
241d83762c
commit
ff035773e5
68 files changed
+2488
-1926
No files matched your search
@@ -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]
|
||||
|
||||
- 已发布基线版本。
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+406
-5
@@ -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",
|
||||
]
|
||||
@@ -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.
@@ -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,4 +1,5 @@
|
||||
pub mod audio;
|
||||
pub mod audio_mute;
|
||||
pub mod diagnostics;
|
||||
pub mod export;
|
||||
pub mod paste;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>=></code> 分隔原文与替换内容,例如:
|
||||
<br />
|
||||
安卓说话,按住说话
|
||||
<br />
|
||||
Cloud Code => 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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 即可使用',
|
||||
'多项体验优化与修复,涵盖悬浮窗、热键、设置向导与本地识别',
|
||||
],
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -492,7 +492,9 @@ export async function startCapture(
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
// 默认开启降噪:部分机器麦克风底噪大(低频电流声),关闭降噪会原样录入。
|
||||
// 回声消除/自动增益保持关闭,避免影响 ASR 音频。
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {}),
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
})()
|
||||
Reference in new issue
Block a user