Compare commits
37 Commits
8952bb14d2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 90860bcf61 | |||
| 5df99ced7b | |||
| 8617a0e708 | |||
| 0c625a0c9a | |||
| 0ac4aa31c8 | |||
| 2f202b82b7 | |||
| b0e201a16f | |||
| 15ab383603 | |||
| 9069145de6 | |||
| 1c037db0c2 | |||
| a3052ad6db | |||
| dc20fd3502 | |||
| 4fd7e7997e | |||
| 706b1375ce | |||
| 8fc82813d3 | |||
| 6810d1ca13 | |||
| 4877d54a1c | |||
| 1bb044a3d7 | |||
| a0c4ca2307 | |||
| b066dd187b | |||
| a64d9907c3 | |||
| 38dcd21352 | |||
| ab1dd9160c | |||
| 4d830c0e56 | |||
| 9c290e19ab | |||
| 417dabb127 | |||
| 2a6e14b976 | |||
| 5770b7007c | |||
| 1ded747a0c | |||
| 0cdea53f8d | |||
| c52dd6b336 | |||
| 8d1ebf02e3 | |||
| 98d827f5c3 | |||
| c8dece351c | |||
| fbe064253c | |||
| 2507629b4c | |||
| 96a170f719 |
1
.chat_req.json
Normal file
1
.chat_req.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"text":"hello","session_id":"e2e"}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,3 +3,10 @@ target/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
# 模型文件不进 git,只保留下载链接在 model_manager.rs 清单里
|
||||||
|
model_download/
|
||||||
|
model_store/
|
||||||
|
# 测试脚本
|
||||||
|
.test_*.sh
|
||||||
|
.chat_req.json
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 你的角色
|
## 你的角色
|
||||||
|
|
||||||
你是副 CEO,与主 CEO(陈逸飞/Claude Opus 4.6)**权限完全相同**。
|
你是副 CEO,与主 CEO(陈逸飞/Claude Opus 4.6)**权限完全相同**。
|
||||||
所有规则、团队、状态、kilo 模板均以 `CLAUDE.md` 为准。
|
所有规则、团队、状态、派发模板均以 `CLAUDE.md` 为准。
|
||||||
|
|
||||||
## 额外权限(主 CEO 同样拥有)
|
## 额外权限(主 CEO 同样拥有)
|
||||||
- ✅ 执行失败升级协议(L1-L4)
|
- ✅ 执行失败升级协议(L1-L4)
|
||||||
@@ -13,13 +13,11 @@
|
|||||||
- ✅ 拒绝无证据交付
|
- ✅ 拒绝无证据交付
|
||||||
|
|
||||||
## 资源限制
|
## 资源限制
|
||||||
- **kilo 进程总数上限 12 个**(含你自己)
|
- **并行子代理总数上限 12 个**(含你自己)
|
||||||
- 启动新 kilo 前先 `ps aux | grep kilo` 检查进程数
|
|
||||||
|
|
||||||
## 监督职责
|
## 监督职责
|
||||||
1. 每 60 秒检查一次(循环 10 次后退出)
|
1. 每 60 秒检查一次(循环 10 次后退出)
|
||||||
2. 每次检查:
|
2. 每次检查:
|
||||||
- 进程数: `ps aux | grep kilo`
|
|
||||||
- 新 commit: `git log --oneline -3`
|
- 新 commit: `git log --oneline -3`
|
||||||
- 编译: `export PATH=... && cargo check`
|
- 编译: `export PATH=... && cargo check`
|
||||||
- 改动: `git status --short`
|
- 改动: `git status --short`
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# CEO持续监督循环脚本
|
|
||||||
|
|
||||||
WORKDIR="/home/showen/Showen/ShowenV2"
|
|
||||||
LOG_FILE="/tmp/ceo_loop.log"
|
|
||||||
CHECK_INTERVAL=1800 # 30分钟 = 1800秒
|
|
||||||
|
|
||||||
echo "CEO监督循环启动 - $(date)" | tee -a "$LOG_FILE"
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
echo "" | tee -a "$LOG_FILE"
|
|
||||||
echo "========================================" | tee -a "$LOG_FILE"
|
|
||||||
echo "CEO检查时间: $(date)" | tee -a "$LOG_FILE"
|
|
||||||
echo "========================================" | tee -a "$LOG_FILE"
|
|
||||||
|
|
||||||
# 运行CEO检查
|
|
||||||
cd "$WORKDIR"
|
|
||||||
kilo run -m claude/claude-opus-4-6 --auto --dir "$WORKDIR" "你是陈逸飞,ShowenV2的CEO。
|
|
||||||
|
|
||||||
这是定期监督检查。请执行以下操作:
|
|
||||||
|
|
||||||
1. 读取 TEAM_CHAT.md 最新内容,查看团队更新
|
|
||||||
2. 检查 git log,看是否有新提交
|
|
||||||
3. 检查运行中的 kilo 进程(ps aux | grep kilo)
|
|
||||||
4. 评估当前进展和问题
|
|
||||||
5. 在 TEAM_CHAT.md 记录本次检查结果
|
|
||||||
6. 如有需要,给团队新的指示
|
|
||||||
|
|
||||||
记住你的角色:
|
|
||||||
- ✅ 评审结果,给出方向
|
|
||||||
- ✅ 识别阻塞,提供决策
|
|
||||||
- ❌ 不亲自写代码
|
|
||||||
- ❌ 不微观管理
|
|
||||||
|
|
||||||
完成后提交 git commit。" 2>&1 | tee -a "$LOG_FILE"
|
|
||||||
|
|
||||||
echo "" | tee -a "$LOG_FILE"
|
|
||||||
echo "CEO检查完成,等待 $CHECK_INTERVAL 秒..." | tee -a "$LOG_FILE"
|
|
||||||
sleep "$CHECK_INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -120,9 +120,9 @@ agent 连续失败时按以下等级升级压力和强制动作:
|
|||||||
- 新功能必须附带测试。
|
- 新功能必须附带测试。
|
||||||
- P0 问题必须当天修复。
|
- P0 问题必须当天修复。
|
||||||
|
|
||||||
## kilo 使用规范
|
## 任务派发规范(Agent 子代理)
|
||||||
- 不读大 diff,优先阅读必要文件和局部上下文。
|
- 不读大 diff,优先阅读必要文件和局部上下文。
|
||||||
- 命令越简单越好,减少复杂链式操作。
|
- 指令越简单越好,减少复杂链式操作。
|
||||||
- **派发任务时必须在消息中注入能动性期望和验证要求**(参考派发模板)。
|
- **派发任务时必须在消息中注入能动性期望和验证要求**(参考派发模板)。
|
||||||
|
|
||||||
## 执行纪律
|
## 执行纪律
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 恢复步骤
|
## 恢复步骤
|
||||||
|
|
||||||
1. 读 `CLAUDE.md`(Claude Code 自动加载) — CEO 身份 + 团队 + 规则 + 状态 + kilo 模板
|
1. 读 `CLAUDE.md`(Claude Code 自动加载) — CEO 身份 + 团队 + 规则 + 状态 + 派发模板
|
||||||
2. 检查 `.showen/TEAM_CHAT.md` — 团队最新动态
|
2. 检查 `.showen/TEAM_CHAT.md` — 团队最新动态
|
||||||
3. 按需读 `souls/chen-yifei.md` — CEO 深层经验
|
3. 按需读 `souls/chen-yifei.md` — CEO 深层经验
|
||||||
4. 其他文件按 `CLAUDE.md` 文件导航表按需加载
|
4. 其他文件按 `CLAUDE.md` 文件导航表按需加载
|
||||||
|
|||||||
@@ -2571,3 +2571,443 @@ P0 问题已解决,DevicePlugin 现已完全集成到 ShowenV2 运行时!
|
|||||||
项目判断:
|
项目判断:
|
||||||
- 当前文档已足够支撑 Flutter 技术方案设计、UI 细化与开发排期。
|
- 当前文档已足够支撑 Flutter 技术方案设计、UI 细化与开发排期。
|
||||||
- 后续需要在正式开发前冻结客户端共享数据模型,并安排一次 API/WebSocket/BLE 联调校准。
|
- 后续需要在正式开发前冻结客户端共享数据模型,并安排一次 API/WebSocket/BLE 联调校准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[2026-07-02] 周雅婷(测试工程师) → 陈逸飞(CEO): M1.2 目标机验收
|
||||||
|
|
||||||
|
**设备 HEAD**: 5770b7007c35f283adb462ca53517f02faab95b1 (chore: remove unused Config type alias, add deny_unknown_fields to remaining config structs) — 已从 Windows master 经 git bundle 同步(原落后 20 提交,fast-forward,无冲突;设备本地未提交改动仅 configs/dog_state_machine.json,未触碰)
|
||||||
|
|
||||||
|
**cargo check --workspace --all-targets**: 编译通过(Finished dev profile, 1m 22s),但有 3 个 warning,全部在新测试文件(src/ 零 warning):
|
||||||
|
- tests/m1_2_http.rs:141 — method `sender` is never used
|
||||||
|
- tests/m1_2_dynamic_plugin.rs:14 — unused imports: `Destination` and `Envelope`
|
||||||
|
- tests/m1_2_dynamic_plugin.rs:87 — function `manifest_with_caps` is never used
|
||||||
|
|
||||||
|
**cargo test --workspace --no-fail-fast** 统计(/tmp/m12_acceptance3.log):
|
||||||
|
```
|
||||||
|
Running unittests src/lib.rs (showen_example_plugin)
|
||||||
|
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.01s
|
||||||
|
Running unittests src/lib.rs (showen_plugin_sdk)
|
||||||
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
Running unittests src/lib.rs (showen_v2)
|
||||||
|
test result: ok. 105 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s
|
||||||
|
Running unittests src/main.rs (showen_v2)
|
||||||
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
Running tests/m1_2_dynamic_plugin.rs
|
||||||
|
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||||
|
Running tests/m1_2_http.rs
|
||||||
|
test result: FAILED. 11 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||||
|
Running tests/m1_2_service_manager.rs
|
||||||
|
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||||
|
Doc-tests showen_plugin_sdk
|
||||||
|
test result: ok. 0 passed; 0 failed; 13 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
||||||
|
Doc-tests showen_v2
|
||||||
|
test result: ok. 0 passed; 0 failed; 4 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
```
|
||||||
|
**总计: 146 passed / 1 failed / 17 ignored (doc-tests)**(注:m1_2_http 实际 12 个测试,非记录中的 9 个)
|
||||||
|
|
||||||
|
**唯一失败测试**: `test_empty_playlist_does_not_panic` (tests/m1_2_http.rs)
|
||||||
|
```
|
||||||
|
thread 'test_empty_playlist_does_not_panic' (8196) panicked at tests/m1_2_http.rs:102:53:
|
||||||
|
test config should load: playlist 不能为空
|
||||||
|
```
|
||||||
|
根因分析(未修改,仅上报):该测试用空 playlist 构造配置并期望 `AppConfig::from_file` 成功,但 src/core/config.rs:334 的校验 `bail!("playlist 不能为空")` 自 init 提交(23f4d46)起就存在——空 playlist 配置从设计上就无法通过加载。属测试前提错误(测试从未在任何机器实跑过),非产品回归。修复归属请 CEO 派人裁决(改测试预期 Err 或放宽校验)。
|
||||||
|
|
||||||
|
**过程事件**: 首轮 cargo test 因设备磁盘满失败(ld: final link failed: No space left on device,根分区 98%)。已清理项目自身构建产物:target/debug/incremental (1.6G) + 3月14日过期 deps 大文件 (~640M) + 同步用 bundle,恢复至 2.7G 可用后重跑成功。未动项目外任何文件;服务所用 target/release/showen_v2 未受影响。
|
||||||
|
|
||||||
|
**现场恢复**: showen_v2.service 已重启,is-active=active,单实例 (pid 10192, target/release/showen_v2)。
|
||||||
|
|
||||||
|
**第二轮(返工后)[2026-07-03] 周雅婷 → 陈逸飞(CEO): M1.2 最终验收 — 全绿**
|
||||||
|
|
||||||
|
变更文件(赵雨薇/张明远返工,Windows 未提交版本,经 scp 覆盖到设备,sha256 两端一致):
|
||||||
|
- tests/m1_2_http.rs (b09cc3f0...) — test_empty_playlist_does_not_panic 重写为 test_empty_playlist_rejected + 删死代码 sender()
|
||||||
|
- tests/m1_2_dynamic_plugin.rs (aa75dfc6...) — 删未用 import 和 manifest_with_caps
|
||||||
|
|
||||||
|
**cargo check --workspace --all-targets**: Finished dev profile in 1m 31s,`grep -cE "^warning" /tmp/m12_acceptance4.log` = **0(零 warning)**
|
||||||
|
|
||||||
|
**cargo test --workspace**(/tmp/m12_acceptance4.log,Finished test profile 7m 58s):
|
||||||
|
```
|
||||||
|
Running unittests src/lib.rs (showen_example_plugin)
|
||||||
|
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.00s
|
||||||
|
Running unittests src/lib.rs (showen_plugin_sdk)
|
||||||
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
Running unittests src/lib.rs (showen_v2)
|
||||||
|
test result: ok. 105 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s
|
||||||
|
Running unittests src/main.rs (showen_v2)
|
||||||
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
Running tests/m1_2_dynamic_plugin.rs
|
||||||
|
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
|
||||||
|
Running tests/m1_2_http.rs
|
||||||
|
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
|
||||||
|
Running tests/m1_2_service_manager.rs
|
||||||
|
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
|
||||||
|
Doc-tests showen_plugin_sdk
|
||||||
|
test result: ok. 0 passed; 0 failed; 13 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
Doc-tests showen_v2
|
||||||
|
test result: ok. 0 passed; 0 failed; 4 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||||
|
```
|
||||||
|
**总计: 147 passed / 0 failed / 17 ignored(doc-tests),test_empty_playlist_rejected ... ok(log 208 行)**
|
||||||
|
|
||||||
|
现场恢复:showen_v2.service 重启后 is-active=active,单实例 (pid 12896, target/release)。
|
||||||
|
磁盘:开跑前 1001M,清 incremental(958M) 后 2.0G,跑完剩 1.2G。
|
||||||
|
|
||||||
|
⚠️ 注意:设备上两个测试文件当前与 git HEAD (5770b700) **不一致**(scp 的未提交返工版)。Windows 提交后需再同步设备(bundle 或重新 scp),否则下次设备端 git 操作会看到脏文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-07-03] 王浩然(网络工程师) → 陈逸飞(CEO): M1.2 风险3 消息链路核查报告
|
||||||
|
|
||||||
|
纯代码审计,零代码改动。范围:`src/core/message.rs` 的 Message enum 全部 17 个变体 + plugin-sdk 镜像。判定标准:生产代码中"至少一个构造点 + 一个匹配点";测试文件构造不算生产者。
|
||||||
|
|
||||||
|
### 一、全量核查表(17/17 变体)
|
||||||
|
|
||||||
|
路由背景:ServiceManager 主循环 `src/core/service_manager.rs:356-383`,按 Destination 分三路:Plugin(id)→deliver_to_plugin(:370)、Broadcast→broadcast_message(:373)、Manager→handle_manager_message(:376)。示例插件 plugins/example-plugin 是动态参考模板,其消费点单独标注。
|
||||||
|
|
||||||
|
| # | 变体 | 生产者(生产代码) | 消费者(生产代码) | 判定 |
|
||||||
|
|---|------|------------------|------------------|------|
|
||||||
|
| 1 | PlayerCommand | src/core/dispatch.rs:32-52(BLE 文本命令,经 src/plugins/ble/gatt.rs:120-124 实际发送);src/plugins/http/routes.rs:236,247,260,276,298,331 | src/plugins/video/mod.rs:145;示例插件 lib.rs:517 | ✅ 闭环 |
|
||||||
|
| 2 | PlayerStatus | src/plugins/video/mod.rs:54, 330 | src/plugins/http/mod.rs:334;Manager 转发 service_manager.rs:770;示例插件 lib.rs:520 | ✅ 闭环 |
|
||||||
|
| 3 | Trigger | src/core/dispatch.rs:67;src/plugins/http/routes.rs:347,362 | src/plugins/video/mod.rs:187;示例插件 lib.rs:523 | ✅ 闭环 |
|
||||||
|
| 4 | StateChanged | src/plugins/video/mod.rs:76, 351 | src/plugins/http/mod.rs:346;Manager 转发 service_manager.rs:773;示例插件 lib.rs:526 | ✅ 闭环 |
|
||||||
|
| 5 | ScreenLockRequest | src/plugins/video/mod.rs:156, 167 | src/plugins/screen/mod.rs:104;示例插件 lib.rs:532 | ✅ 闭环 |
|
||||||
|
| 6 | CursorVisibility | **无**(仅测试构造 src/core/tests.rs:1521) | src/plugins/screen/mod.rs:111;示例插件 lib.rs:535 | ⚠️ 有消费无生产 |
|
||||||
|
| 7 | WifiCommand | src/core/dispatch.rs:75-100(BLE 经 gatt.rs:120-124 发送);src/plugins/http/routes.rs:966 | src/plugins/wifi/mod.rs:321 | ✅ 闭环 |
|
||||||
|
| 8 | WifiResult | src/plugins/wifi/mod.rs:97 | Manager 转发 service_manager.rs:767;src/plugins/ble/mod.rs:120;src/plugins/http/mod.rs:333;示例插件 lib.rs:541 | ✅ 闭环 |
|
||||||
|
| 9 | **WifiProvisioned** | **无**(仅测试构造 src/core/tests.rs:1534) | Manager 转发臂 service_manager.rs:782(死路径,永不触发);示例插件 lib.rs:544 | ❌ 有消费无生产(Phase 2 占位) |
|
||||||
|
| 10 | ConfigReloaded | src/core/service_manager.rs:792 | src/plugins/http/mod.rs:340;src/plugins/video/mod.rs:197;示例插件 lib.rs:554 | ✅ 闭环 |
|
||||||
|
| 11 | ConfigReloadRequest | src/core/dispatch.rs:107(BLE config_reload);src/plugins/http/routes.rs:492, 782 | src/core/service_manager.rs:785 | ✅ 闭环 |
|
||||||
|
| 12 | Shutdown | **初始生产者无**(仅测试注入;dynamic_plugin.rs:348 亦在 #[cfg(test)] 内);Manager 转发 service_manager.rs:765 | ble/mod.rs:117;screen/mod.rs:112;http/mod.rs:358;video/mod.rs:223;device/mod.rs:139;示例插件 lib.rs:566 | ⚠️ 消费者齐全,生产环境无初始触发者(见延伸发现①) |
|
||||||
|
| 13 | PluginReady | src/plugins/video/mod.rs:136;src/plugins/http/mod.rs:305;src/plugins/ble/gatt.rs:343;示例插件 lib.rs:169 | Manager 转发 service_manager.rs:799;src/plugins/http/mod.rs:357(BLE ready 门控) | ✅ 闭环 |
|
||||||
|
| 14 | DeviceCommand | src/plugins/screen/mod.rs:30, 41, 52;示例插件 lib.rs:227 | src/plugins/device/mod.rs:119 | ✅ 闭环 |
|
||||||
|
| 15 | DeviceResponse | src/plugins/device/mod.rs:134(Broadcast) | 核心静态插件**无消费者**(screen 发命令后不读响应);仅示例插件 lib.rs:576 | ⚠️ 弱闭环(见延伸发现②) |
|
||||||
|
| 16 | **DeviceEvent** | **无**(device/mod.rs 全文件不构造;DeviceBackend trait backend.rs:52-99 只有 name/init/handle_command/capabilities/shutdown 五个方法,架构上无事件上报通道) | 示例插件 lib.rs:606 | ❌ 有消费无生产(Phase 2 占位) |
|
||||||
|
| 17 | Custom | http/routes.rs:1873(plugin_switch), 1908(plugin_install), 1944(send_plugin_command,被 enable:1822 / disable:1833 / rollback:1844-1851 / check-updates:1925-1933 共用);service_manager.rs:752(plugin_states 广播);示例插件多处(lib.rs:211,273,307…) | service_manager.rs:803-875(6 种 kind 全处理);http/mod.rs:359(plugin_states);示例插件 lib.rs:609 | ✅ 闭环 |
|
||||||
|
|
||||||
|
plugin-sdk 镜像(plugin-sdk/src/lib.rs:70-149)与主库 17 个变体一一对应,JSON 兼容,无脱节。
|
||||||
|
|
||||||
|
### 二、WifiProvisioned / DeviceEvent 专项结论
|
||||||
|
|
||||||
|
**WifiProvisioned = 有消费无生产,判定 Phase 2 占位,非回归缺陷。**
|
||||||
|
- 定义:src/core/message.rs:45;SDK 镜像 plugin-sdk/src/lib.rs:116。
|
||||||
|
- 全仓生产代码零构造点。wifi 插件 connect 成功只发 `WifiResult`(wifi/mod.rs:94-98),BLE 配网链路是 gatt.rs:120 → dispatch.rs → `WifiCommand::Connect`,均不产生 WifiProvisioned。
|
||||||
|
- 消费侧:service_manager.rs:782 的转发臂是死代码路径(上游无人发);示例插件 lib.rs:544 在白等。
|
||||||
|
- **风险点**:SDK 文档 plugin-sdk/src/lib.rs:522 教第三方插件开发者监听此消息——当前版本永不触发,会误导开发者。
|
||||||
|
- 建议:入 Phase 2 backlog——wifi 插件 connect_network 成功后取 ssid+ip 补发;短期在 SDK 文档注明"当前版本未触发"。不建议删(BLE 配网完成通知是合理设计意图)。
|
||||||
|
|
||||||
|
**DeviceEvent = 有消费无生产,判定 Phase 2 占位,非回归缺陷。**
|
||||||
|
- 定义:src/core/message.rs:62, 221-234(6 个事件变体);SDK 镜像 lib.rs:139, 273。
|
||||||
|
- DevicePlugin(device/mod.rs 全文件)只处理 DeviceCommand→回 DeviceResponse(:119-137),从不构造 DeviceEvent。更根本的是 DeviceBackend trait(backend.rs:52-99)没有任何事件轮询/回调接口——**生产者在架构层面还不存在**,与 CLAUDE.md "DevicePlugin 阶段三(触摸/音频/多平台)" 规划一致。
|
||||||
|
- 消费侧:仅示例插件 lib.rs:606。
|
||||||
|
- 建议:标 Phase 2 backlog,待 DeviceBackend 增加事件通道时接入;不删(README、docs/DEVICE_PLUGIN_DESIGN.md:92 已承诺该接口)。
|
||||||
|
- DeviceCommand/DeviceResponse 一族其余两个:DeviceCommand 闭环 ✅;DeviceResponse 见延伸发现②。
|
||||||
|
|
||||||
|
### 三、与赵雨薇旁证结论的异同(tests/m1_2_http.rs:6-18)
|
||||||
|
|
||||||
|
| 她的结论 | 核查结果 |
|
||||||
|
|---------|---------|
|
||||||
|
| enable/disable 闭环已修 | ✅ **证实**。routes.rs:1822/1833 → Custom → service_manager.rs:805/814 → set_plugin_enabled(:401-430) → broadcast_plugin_states(:750) → http/mod.rs:359 读回。全链路通。 |
|
||||||
|
| plugin_rollback/switch/install "仅发送消息,ServiceManager 侧仅打印日志,尚无完整业务逻辑" | ❌ **已过时,推翻**。三者现均为完整实现:rollback_plugin(service_manager.rs:432-447,真实回滚)、switch_plugin_version(:617-663,版本切换+热替换)、install_plugin(:665-728,repo 下载+registry 更新+加载)。张明远 #25 修复(handle_manager_message Custom 分支)已落地,她的注释写于修复前。**建议 M1.2 收尾时顺手更新 tests/m1_2_http.rs:10,17-18 的过时注释**(本次未动)。 |
|
||||||
|
| plugin_check_updates 同上"仅打印日志" | ⚠️ **部分成立**。check_plugin_updates(:730-748) 会真实查询 repo.check_update,但结果只 println(:736-743),不回传 HTTP/不广播——调用方只收到"命令已发送"(routes.rs:1949),拿不到检查结果。结果回传链路确实断在日志,属功能不完整(非崩溃性缺陷),建议 Phase 2 把结果并入 plugin_states 或单独 Custom 广播。 |
|
||||||
|
| WifiProvisioned/DeviceEvent(她未核查) | 本次补齐:均为"有消费无生产(Phase 2 占位)",非回归缺陷,与 docs/M1.2_TEST_PLAN.md:17 的风险预判一致。 |
|
||||||
|
|
||||||
|
### 四、延伸发现(主动排查,均不阻塞 M1.2)
|
||||||
|
|
||||||
|
① **Shutdown 无生产环境初始触发者**:main.rs Ctrl+C handler(:117-120) 只置 AtomicBool,不注入 Message::Shutdown;HTTP 也无 shutdown 路由。而 manager.run() 阻塞在 rx.recv()(:361),Ctrl+C 后要等**下一条任意消息**到达才能退出主循环。行为性瑕疵,建议 Phase 2 加"信号→Shutdown 消息"桥接。
|
||||||
|
② **DeviceResponse 弱闭环**:device 广播响应(:134),但核心静态插件无人读——screen 发 DeviceCommand(:30,41,52) 后不看结果,`DeviceResponse::Error`(:125) 广播后无人记录,设备层故障静默。建议至少在一处记录 Error 响应,或在 screen 注明 fire-and-forget 语义。
|
||||||
|
③ **CursorVisibility 有消费无生产**:screen/mod.rs:111 在等一条没人发的消息;实际光标控制链路已改走 screen→DeviceCommand::SetCursorVisible(:52)→device。建议:删变体或补 HTTP/dispatch 生产者(低优先级,倾向 Phase 2 一并清理)。
|
||||||
|
|
||||||
|
### 五、总判定
|
||||||
|
|
||||||
|
17 个变体中:**11 个闭环 ✅;WifiProvisioned/DeviceEvent 为 Phase 2 占位 ❌(非回归缺陷);Shutdown/DeviceResponse/CursorVisibility 弱链 ⚠️(行为性瑕疵,已给建议);Custom 六种 plugin_* kind 全部闭环(修正赵雨薇过时结论)**。风险3 可关闭:无"消息发出去没人处理"的黑洞(除示例性 Custom heartbeat 外),断链项全部是已规划的 Phase 2 占位,且每条均有 file:line 可复核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-07-03] 赵雨薇 → 陈逸飞:设备磁盘清理
|
||||||
|
|
||||||
|
老板批准的目标机磁盘清理已完成。**96% → 44%(可用 1.2G → 15G),共回收约 13.4G**。showen_v2.service 全程未受影响(清理前后同一 PID 12896)。
|
||||||
|
|
||||||
|
### 清理明细
|
||||||
|
|
||||||
|
| 目录 | 删前大小 | 依赖检查结论 | 结果 |
|
||||||
|
|------|---------|-------------|------|
|
||||||
|
| ~/.gradle | 2.8G | 无 gradle/flutter/dart 进程;systemd/autostart/crontab 无引用 | ✅ 已删 |
|
||||||
|
| ~/Android | 2.4G | 同上 | ✅ 已删 |
|
||||||
|
| ~/flutter-sdk | 2.2G | 同上 | ✅ 已删 |
|
||||||
|
| ~/.pub-cache | 214M | 同上 | ✅ 已删 |
|
||||||
|
| ~/.dartServer | 63M | 同上 | ✅ 已删 |
|
||||||
|
| target/debug | 3.5G | 无进程使用 debug 产物;只删 debug,release(526M)未碰 | ✅ 已删 |
|
||||||
|
| ~/.nvm | 1.1G | 无 node 进程;systemd/autostart 无 node/nvm 引用;设备未装 cron(crontab 命令不存在) | ✅ 已删(条件满足) |
|
||||||
|
| /var/cache/apt | 392M | apt-get clean 官方缓存清理 | ✅ 392M → 24K |
|
||||||
|
| /var/log/journal | 1.5G | journalctl --vacuum-size=100M(只清归档日志) | ✅ 1.5G → 89M |
|
||||||
|
|
||||||
|
### df 前后对比
|
||||||
|
|
||||||
|
```
|
||||||
|
前: /dev/mmcblk0p3 28G 26G 1.2G 96% /
|
||||||
|
后: /dev/mmcblk0p3 28G 12G 15G 44% /
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务健康证据(清理后)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ systemctl --user is-active showen_v2.service
|
||||||
|
active
|
||||||
|
$ pgrep -af "showen_v2 --config" # 过滤 ssh 自身后
|
||||||
|
12896 /home/showen/Showen/ShowenV2/target/release/showen_v2 --config /home/showen/Showen/ShowenV2/configs/dog_state_machine.json # 单实例,与清理前同 PID
|
||||||
|
$ ls -la target/release/showen_v2
|
||||||
|
-rwxr-xr-x 2 showen showen 11508080 6月 3 2026 showen_v2 # 大小/日期与清理前一致
|
||||||
|
```
|
||||||
|
|
||||||
|
红线目录抽查完好:~/.rustup、~/.cargo、~/.claude、~/.config、~/autostart_backup_20260702、Showen/ShowenV2/configs 均在。无跳过项(清单全部执行)。
|
||||||
|
|
||||||
|
备注:CLAUDE.md 待处理#6 的"磁盘长期 9x% 满"本次已解除;时钟慢一年(NTP 失效)仍未处理,属另案。两轮验收被迫清 target/debug/incremental 的问题以后不会再发生——debug 产物已整体移除,下次目标机跑 test 会重新编译 debug(首次耗时略长,属预期)。
|
||||||
|
|
||||||
|
## [2026-07-03] 赵雨薇 → 陈逸飞:设备开发工具清除(追加指令)
|
||||||
|
|
||||||
|
老板追加指令执行完毕:设备上后装开发工具(Claude Code / VSCode / Cursor 及同类残留)已全部清除。**44% → 35%(再回收约 2.7G)**;两轮累计 **96% → 35%,可用 1.2G → 18G**。服务全程无感知(仍为同一 PID 12896)。
|
||||||
|
|
||||||
|
### 清除明细
|
||||||
|
|
||||||
|
| 项目 | 位置 | 大小 | 处理方式 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| Claude Code 配置/数据 | ~/.claude + ~/.claude.json | 131M + 12K | rm(claude 可执行文件随上一轮 ~/.nvm 已灭,which claude 为空) |
|
||||||
|
| VSCode 主程序 | dpkg 包 `code` 1.110.0 | 489M (Installed-Size) | sudo apt-get purge -y,dpkg -l 确认无残留 |
|
||||||
|
| VSCode 用户数据 | ~/.vscode + ~/.config/Code | 20K + 18M | rm |
|
||||||
|
| Cursor 主程序 | dpkg 包 `cursor` 2.6.14 | 786M (Installed-Size) | sudo apt-get purge -y |
|
||||||
|
| Cursor 用户数据 | ~/.config/Cursor | 4.2M | rm(~/.cursor、AppImage、/opt 均不存在) |
|
||||||
|
| Cursor apt 源 | /etc/apt/sources.list.d/cursor.sources + /usr/share/keyrings/anysphere.gpg | — | rm(防止 apt update 再拉取;Signed-By 指向确认过才删) |
|
||||||
|
| kilo 残留 | ~/.local/share/kilo | **1.2G**(bin 952M node_modules/kotlin-ls/rg + kilo.db 104M + snapshot 76M) | rm。判断依据:kilo 机制老板 2026-07-02 已明令废除,PATH 无 kilo 可执行,无任何引用。**注意:kilo.db 任务历史一并删除,特此声明** |
|
||||||
|
| opentui 残留 | ~/.local/share/opentui | 6.1M | rm(kilo CLI 配套 TUI 的 tree-sitter 缓存,同属废除机制) |
|
||||||
|
| kotlin daemon 残留 | ~/.local/share/kotlin | 8K | rm(上一轮已删 Android/Gradle 环境的日志残留) |
|
||||||
|
|
||||||
|
依赖检查证据:`pgrep -af "claude|vscode|cursor|code-server|kilo|opentui"` 过滤 ssh 自匹配后为空;systemd/autostart 无任何引用;~/.cache 下无 Code/Cursor/claude 缓存;purge 后 `apt-get autoremove --dry-run` 无 Remv 条目(无孤儿依赖)。
|
||||||
|
|
||||||
|
### 存疑未删清单
|
||||||
|
|
||||||
|
| 名称 | 大小 | 判断 |
|
||||||
|
|------|------|------|
|
||||||
|
| ~/.local/share/flatpak | 8K | 仅一个空 db,flatpak 二进制未安装;非编辑器/IDE,8K 无收益,未动 |
|
||||||
|
| ~/.local/share/kate | — | KDE 自带编辑器组件,属 KDE 桌面红线,未动 |
|
||||||
|
|
||||||
|
### df 对比与服务健康
|
||||||
|
|
||||||
|
```
|
||||||
|
本轮前: /dev/mmcblk0p3 28G 12G 15G 44% /
|
||||||
|
本轮后: /dev/mmcblk0p3 28G 9.3G 18G 35% /
|
||||||
|
$ systemctl --user is-active showen_v2.service → active
|
||||||
|
$ pgrep -af "showen_v2 --config"(滤自匹配)→ 12896 单实例(与清理前同 PID,未重启)
|
||||||
|
$ ls -la target/release/showen_v2 → -rwxr-xr-x 2 showen showen 11508080 6月3 2026(完好)
|
||||||
|
```
|
||||||
|
|
||||||
|
红线抽查:~/.rustup、~/.cargo、~/.config(除 Code/Cursor 子目录)、~/autostart_backup_20260702、Showen/ShowenV2/ 均完好。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-07-03] 王浩然 → 陈逸飞:设备时钟修复(待处理 #6 时钟部分,闭环)
|
||||||
|
|
||||||
|
### 根因(证据齐全,两个叠加)
|
||||||
|
|
||||||
|
1. **systemd-timesyncd 从未启用**:`systemctl status` = `disabled; inactive (dead)`,`journalctl -u systemd-timesyncd` = `-- No entries --`(一次都没跑过)。设备上无 chrony/ntpd,唯一时间服务就没开。
|
||||||
|
2. **RTC 芯片 hym8563 内容失效**:开机 dmesg `rtc-hym8563 14-0051: no valid clock/calendar values available` + `hctosys: unable to read the hardware clock`;`hwclock -r` 报 `ioctl(RTC_RD_TIME) 无效的参数`。芯片掉电后 VL 标志置位,内容作废——开机无法从硬件钟恢复,NTP 又没开,时间永远停在内核 fallback(2025-06-26)。
|
||||||
|
3. **排除"NTP 源不可达"假设**:启用后 debian pool 实测可达(193.182.111.142,延迟 167ms),但为可靠性仍换国内源(aliyun 延迟 20ms)。
|
||||||
|
4. 旁证:/etc/adjtime 记录 last calibration ≈ 2026-03-07,说明设备历史上有过正确时间,掉电丢失。
|
||||||
|
|
||||||
|
### 改动清单(只动时间配置,未碰其他任何东西)
|
||||||
|
|
||||||
|
| 动作 | 命令/文件 |
|
||||||
|
|------|----------|
|
||||||
|
| 手动拨时 | `sudo date -s "2026-07-03 14:54:06"`(timedatectl 因 RTC 读失败不可用,用 date -s 破局) |
|
||||||
|
| 修复 RTC | `sudo hwclock -w` —— 写入即清除 hym8563 失效标志,此后 `hwclock -r` 恢复可读,芯片没坏 |
|
||||||
|
| NTP 配置 | 重写 /etc/systemd/timesyncd.conf:`NTP=ntp.aliyun.com ntp1.aliyun.com`,`FallbackNTP=cn.pool.ntp.org time1.cloud.tencent.com` |
|
||||||
|
| 启用服务 | `sudo timedatectl set-ntp true` + restart → enabled + active(持久化) |
|
||||||
|
|
||||||
|
### 验证证据
|
||||||
|
|
||||||
|
- 修复后 `timedatectl`:`System clock synchronized: yes / NTP service: active / RTC time 与 UTC 一致`;`timesync-status`:Server 203.107.6.88 (ntp.aliyun.com),offset -673us
|
||||||
|
- **apt 复活**:`sudo apt-get update` exit 0,清华源+radxa 源全部 HTTPS 拉取成功,零证书报错(只刷新索引,未装任何包)
|
||||||
|
- **重启持久化**:`sudo reboot` 后 40s 回归。开机 dmesg:`rtc-hym8563: setting system clock to 2026-07-03T07:03:04 UTC`——**开机第一秒时间即正确**,不再依赖网络校时;timesyncd enabled+active 自启并再次同步 aliyun
|
||||||
|
- 服务健康:`ps aux | grep "[s]howen_v2 --config"` 单实例(count=1)+ `systemctl --user is-active showen_v2.service` = active
|
||||||
|
|
||||||
|
### 遗留风险(如实记录,无需现在处理)
|
||||||
|
|
||||||
|
- **RTC 备份电池未验证**:热重启 RTC 保持了时间,但若整机长时间断电且无备份电池,RTC 会再次失效。此时开机时间错,需等网络可用后 NTP 自动拉回(timesyncd 已持久化,会自愈)。影响:断电冷启动到联网前的窗口期内时间不可信。
|
||||||
|
- 时钟前跳一年副作用:journal 时间线出现断层(2025-06-26 → 2026-07-03)属正常现象;磁盘 9x% 满是独立问题(本轮已由清理任务缓解至 35%),与时钟无关。
|
||||||
|
|
||||||
|
**待处理 #6 的时钟部分可关闭。** 未 commit(老板管提交)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[2026-07-03] 周雅婷(测试工程师) → 陈逸飞(CEO): M1.2 设备同步完成
|
||||||
|
|
||||||
|
老板提交 2a6e14b 后的设备仓库同步 + 形式验证:
|
||||||
|
|
||||||
|
1. **丢弃 scp 旧版**:设备 `git checkout -- tests/` 清掉 07-03 两轮验收期间 scp 的未提交测试文件
|
||||||
|
2. **bundle 同步 + ff 合并**:5770b70 → 2a6e14b(1 个提交,19 文件,含两个返工测试文件 + 全部文档),预检确认 configs/dog_state_machine.json 不在 diff 内、纯祖先关系
|
||||||
|
3. **设备最终状态**:
|
||||||
|
```
|
||||||
|
$ git log -1 --format="%H %an %s"
|
||||||
|
2a6e14b976e38348ced120cbafdd0d1c376c00bc XiuChengWu feat: Update project documentation and workflow to reflect changes in task dispatching and testing processes
|
||||||
|
$ git status --short
|
||||||
|
M configs/dog_state_machine.json ← 仅剩设备本地改动,未触碰
|
||||||
|
```
|
||||||
|
4. **cargo check --workspace --all-targets 形式验证**(/tmp/m12_synccheck.log,未停服务):
|
||||||
|
```
|
||||||
|
Checking showen_v2 v0.2.0 (/home/showen/Showen/ShowenV2)
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3m 57s
|
||||||
|
===CHECK_OK===
|
||||||
|
```
|
||||||
|
`grep -cE "^warning" /tmp/m12_synccheck.log` = **0(零 warning)**
|
||||||
|
|
||||||
|
设备环境确认:时钟已正常(2026-07-03 15:07 CST),磁盘 18G 可用(35%)。showen_v2.service 单实例运行中(pid 1180, target/release)。
|
||||||
|
|
||||||
|
**M1.2 设备侧收尾完成,只差老板打 tag。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-07-03] 张婉琳 → 陈逸飞:M2.1 PRD 完成
|
||||||
|
|
||||||
|
`docs/M2.1_PRD.md` v1.0 草案成文(未 commit,老板管提交)。范围:功能一 角色内容包切换 / 功能二 语音对话回合(App=话筒喇叭,设备=大脑和脸)/ 功能三 模型管理(老板 07-03 补充需求,已并入)/ NFR / V1 边界 / 17 条可演示验收 / 风险与素材方案建议 / V1→V2→V3 Roadmap。
|
||||||
|
|
||||||
|
**写前已核实的事实**:角色切换可完全复用现有 `GET /api/config/available` + `POST /api/config/switch`(routes.rs:411/448,含验证+热重载+失败不落盘),App 侧无语音能力(clients/flutter/PRD.md 确认),dog/cat 内容包结构确认(configs/*.json)。
|
||||||
|
|
||||||
|
**关键决策点(需 CEO/老板拍板)**:
|
||||||
|
1. **素材方案**:数字人/歌姬各需 idle/talk/greet/sleep 四状态片段(1920×1080/H.264/深底/首尾帧一致)。建议 A=AI 视频生成先试产(推荐)/ B=外包 / C=老板提供现成素材——需拍板
|
||||||
|
2. **角色元信息落地**:内容包新增 `character` 块(方案 A,推荐)受 deny_unknown_fields 影响需内核改 schema vs sidecar manifest(方案 B)——架构评审首议题,交王思远
|
||||||
|
3. **模型档位**:0.5B vs 2B 以张明远 spike 实测定;PRD 延迟/内存指标全部标注"待 spike 校准",未编造数字
|
||||||
|
4. **模型镜像源**:国内静态清单+自建镜像/OSS,清单维护责任人未定——需拍板
|
||||||
|
5. **歌姬不真唱**:V1 是"会聊天的歌姬人设",歌声合成明确排除,另立项——请确认口径
|
||||||
|
6. 中文优先还是中英双语;对话历史仅存手机本地(设备零留存,隐私卖点)——请确认
|
||||||
|
|
||||||
|
**老板补充需求已落位**:功能三 模型管理(清单/下载进度+校验+断点/切换/删除/存储配额/档位守门),设计动机写明 A733/4G 仅为最低档,验收第 14 条点名"换模型不重烧录/不重部署"。产品行为参照既有 plugin_repo/version_manager 模式,架构细节留给王思远。
|
||||||
|
|
||||||
|
## [2026-07-03] 张婉琳 → 陈逸飞:M2.1 PRD 补充二落位(客户端矩阵)
|
||||||
|
|
||||||
|
老板补充二("clients里的安卓app别忘了改,还有设备的网页控制端")已并入 `docs/M2.1_PRD.md`,新增独立第 5 章「客户端矩阵」,后续章节顺延(非功能→6 / 边界→7 / 验收→8 / 风险→9 / Roadmap→10,交叉引用已全部修正)。要点:
|
||||||
|
|
||||||
|
1. **双客户端同为交付对象**:安卓 App(clients/flutter,前文已覆盖)+ 设备内嵌 Web 控制端(routes.rs:1961 WEB_UI_HTML,`/` 直接服务)——Web 端必须新增角色切换 + 模型管理 + 文字对话三块界面
|
||||||
|
2. **Web 语音约束如实写明**:getUserMedia 需 HTTPS 安全上下文,设备纯 HTTP → V1 Web 不做录音,改做**文字输入对话**(文字→LLM→文字/TTS 音频,浏览器播音不受限),多出一个可演示入口且 LLM 链路可独立于 ASR 调试;自签 HTTPS 不入 V1
|
||||||
|
3. **功能×客户端对照表**(§5.3):角色切换/模型管理两端必做;录音对话 App 独占;文字对话 Web 必做、App 增强项;存量监控控制两端已有不动
|
||||||
|
4. **验收补 3 条**(#17 Web 切角色两端状态互通 / #18 Web 模型管理全流程 / #19 Web 文字对话+talk 联动+互斥),总验收 20 条
|
||||||
|
5. **风险表补 #11**:核实后修正一处事实——WEB_UI_HTML 实为 routes.rs:1961-2384 约 420 行高密度单行 HTML/JS("2000+ 行"是 routes.rs 全文件行数 2384);可维护性风险成立,建议王思远评审议 include_str! 拆独立 .html 或静态资源目录
|
||||||
|
|
||||||
|
未 commit(老板管提交)。
|
||||||
|
|
||||||
|
## [2026-07-03] 张婉琳 → 陈逸飞:M2.1 PRD 补充三落位(声音全部走客户端)
|
||||||
|
|
||||||
|
老板补充三("现在设备的扬声器麦克风没插入,尽量让对话和播放声音从网页和手机app实现")已并入 `docs/M2.1_PRD.md`,改动点位:
|
||||||
|
|
||||||
|
1. **§3.1 新增"声音归属铁律"**:TTS 回复音频一律返回发起请求的客户端播放(App/Web),设备本地不播放、不依赖任何音频硬件;设备角色收敛为 ASR/LLM/TTS 计算 + 画面状态联动。§3 标题与 §1 定位同步改为"客户端是话筒和喇叭";§3.3 画面联动、§6 稳定性行的"手机/App"措辞统一为"客户端(App/Web)"
|
||||||
|
2. **§5.2 重写**:Web 回复音频播放升为 **V1 必做**(浏览器 audio 无安全上下文限制,设备无扬声器时 Web 场景靠浏览器出声兜底);Web 录音降为**评估项/V1.5 增强候选**——技术路径已写明(自签 HTTPS 后 getUserMedia 可用,代价一次证书警告),不作 V1 验收硬指标
|
||||||
|
3. **§5.3 对照表重排**:拆出"录音(语音输入)"(App ✅ / Web 🔍 评估项)与"回复音频播放"(两端 ✅ 必做)两行
|
||||||
|
4. **§9 风险 #3 关闭重写**:"扬声器现场验证"事实已确认(未插入),改为"设备音频硬件未接入——V1 零影响;后续插 USB/3.5mm 麦克风扬声器则作为 V2 启动前置"
|
||||||
|
5. **§10 Roadmap**:V2 加前置条件"音频硬件接入后启动";V1 定位补"设备本地不出声"
|
||||||
|
6. **验收**:#19 Web 文字对话的回复音频浏览器播放改为 V1 必做;全表复查无任何"设备出声"条目(原本就全部落在客户端侧,#4 手机播放、#19 浏览器播放)
|
||||||
|
7. §7 边界 #1/#9 同步更新(设备直接对话的 V2 前置、Web 录音 V1.5 候选)
|
||||||
|
|
||||||
|
原始需求(老板三条补充原话)均已录入 §0 需求源头。未 commit(老板管提交)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-07-03] 张明远 → 陈逸飞:M2.1 设备端本地 AI spike 报告(ASR→LLM→TTS 全链实测)
|
||||||
|
|
||||||
|
### 一句话结论
|
||||||
|
|
||||||
|
**能跑通,但"良好"(≤3s)在纯 CPU 上达不到。** 推荐 **0.5B 档(Qwen2.5-0.5B)+ 流式管线**,首音频延迟可压到 ~5s(可接受阈值边缘达标);1B 档只适合非实时场景;2B 档不可用。全程 showen_v2 服务 active,锁 2 大核推理时视频服务 CPU 只被挤压 8-13%。
|
||||||
|
|
||||||
|
### 0. 设备摸底(比档案强)
|
||||||
|
|
||||||
|
- **Radxa Cubie A7A / Allwinner A733**:8 核 big.LITTLE = 2×Cortex-A78@2.0GHz (cpu6-7) + 6×Cortex-A55@1.79GHz (cpu0-5),**均带 dotprod**,对 GGUF 量化推理友好;另有 NPU(thermal zone 可见,Phase 2 可探索)
|
||||||
|
- 内存 3.8G(基线可用 3.1G)/ 磁盘起始 17G 空闲 / 基线温度 50-54°C
|
||||||
|
- showen_v2 基线 CPU **132.3%**(60s 采样均值),内存 RSS ~98MB
|
||||||
|
- **apt 零安装**:git/cmake/gcc/curl/arecord 全数已存在
|
||||||
|
|
||||||
|
### 1. 工具链准备
|
||||||
|
|
||||||
|
- llama.cpp(build fdb1db8)cmake Release + GGML_NATIVE 编译成功(github 直连 git clone 被 TLS 掐断,ghfast.top 代理成功——**设备 github 直连不可靠,走镜像是必须项**)
|
||||||
|
- whisper.cpp 同法编译成功;piper 用官方 aarch64 release 二进制(github 直下 10KB/s 卡死,ghfast 秒下)
|
||||||
|
- 模型全部来自 hf-mirror.com,总下载 3.2GB(预算 5G 内)
|
||||||
|
|
||||||
|
**模型选型说明(老板点名 Gemma 0.5B/2B)**:Gemma 系没有 0.5B——用 **Gemma3-1B-it**(Gemma 最新代最小尺寸)占小档、**Gemma2-2B-it**(Gemma 系唯一 2B)占 2B 档、**Qwen2.5-0.5B-Instruct** 占 0.5B 档对照(该量级公认中文最强)。均 Q4_K_M 量化。
|
||||||
|
|
||||||
|
### 2. LLM 基准(视频服务运行中实测)
|
||||||
|
|
||||||
|
**线程数是生死线**(Qwen0.5B 扫描,llama-bench ±3 次重复):
|
||||||
|
|
||||||
|
| 线程 | pp128 t/s | tg64 t/s | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **t=2(大核)** | 31.5 | **16.4** | 生成最快,对视频影响最小 ✅ |
|
||||||
|
| t=4 | 35.6 | 15.2 | |
|
||||||
|
| t=6 | 40.3 | 15.2 | |
|
||||||
|
| t=8(全核) | 37.4 | **7.2** | 生成崩一半,showen 被挤到 50.9% ❌ |
|
||||||
|
|
||||||
|
→ **结论:必须 t=2 锁大核。多线程在 big.LITTLE 上因同步拖尾反而更慢,还抢视频服务的核。**
|
||||||
|
|
||||||
|
**三模型基准表**(t=2,llama-bench r=3,误差 <2%):
|
||||||
|
|
||||||
|
| 模型 | 文件 | 峰值RSS | pp128 t/s | tg64 t/s | 50字回复(热) | showen 影响 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Qwen2.5-0.5B Q4_K_M | 469MB | 985MB@ctx4096 | 31.5 | 16.4 | ≈3.4s | 132→122%(-8%) |
|
||||||
|
| Gemma3-1B Q4_K_M | 806MB | 1447MB@ctx4096 | 16.6 | 8.2 | ≈6.2s | 132→120%(-9%) |
|
||||||
|
| Gemma2-2B Q4_K_M | 1.7GB | **3253MB@默认ctx** | 13.9 | 4.6 | ≈10.9s | 132→115%(-13%) |
|
||||||
|
|
||||||
|
- ⚠️ **Gemma2-2B 默认上下文峰值 RSS 3.25G,实测触发 swap 187MB**——2B 档若要用必须 `-c 1024` 限死,否则有拖垮整机风险
|
||||||
|
- 中文单回合实测(llama-cli 单轮+系统提示,含模型冷加载全 wall):Qwen 12.0s / Gemma3 22.1s / Gemma2 41.2s;冷加载占 5s/9s/20s → **正式方案必须模型常驻(llama-server)**
|
||||||
|
- 回复质量目测:Qwen0.5B 通顺但平淡;Gemma3-1B 流畅但事实性发散;Gemma2-2B 最准但太慢
|
||||||
|
- 温度:基准期间大核峰值 79°C(基线 54°C),未观察到降频引起的性能塌陷;测后 3 分钟回落 56°C
|
||||||
|
|
||||||
|
### 3. ASR(whisper.cpp,t=4,5.4s 中文样本)
|
||||||
|
|
||||||
|
样本:piper 合成的已知文本语音(可核对字错),另用 arecord 默认设备实录 5s 验证麦克风链路(**实录为环境音,无人在设备旁说话,如实标注**;tiny/base 对静音均产生幻觉文本,正式方案需 VAD 门限)。
|
||||||
|
|
||||||
|
| 模型 | 总耗时(冷) | 热推理 | 转写质量 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tiny (75MB) | 3.7s | **≈2.2s**(encode 1.5 + decode 0.02) | 大意全对,错字:"散步"→"三部聖"、"咖啡"→"咖費",繁体输出 |
|
||||||
|
| base (142MB) | 7.4s | ≈5.7s(encode 3.3s) | 错字类似,标点更好 |
|
||||||
|
|
||||||
|
- whisper encode 是固定 30s 窗口成本,**语音再短 encode 也是 ~1.5s(tiny)**——这是 ASR 延迟下限
|
||||||
|
- 合成语音偏"干净",真实远场麦克风准确率会更低,转正前需真人实测
|
||||||
|
|
||||||
|
### 4. TTS(piper + zh_CN-huayan-medium,20字中文 ×3 轮)
|
||||||
|
|
||||||
|
- 20 字 → 4.0s 音频,**热推理 1.2s,RTF 0.30**(三轮稳定 0.30-0.33),进程冷启动另加 ~0.8s
|
||||||
|
- 音质试听 OK(huayan 女声,自然度中上);50 字回复 ≈10s 音频 → infer ≈3s,**分句流式合成可把首音频压到 <1s**
|
||||||
|
|
||||||
|
### 5. 端到端实测 + 驻留估算
|
||||||
|
|
||||||
|
**真实串联冷链实跑**(whisper→llama-cli→piper 三进程逐个冷启动,管道传文本):
|
||||||
|
|
||||||
|
| 链路 | ASR | LLM | TTS | 总计 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| tiny + Qwen0.5B + piper | 3.06s | 12.31s | 2.61s | **17.98s** |
|
||||||
|
| tiny + Gemma3-1B + piper | 3.40s | 19.77s | 3.29s | **26.46s** |
|
||||||
|
|
||||||
|
**驻留服务热推理估算**(模型常驻内存,50 字回复,prompt≈45 token):
|
||||||
|
|
||||||
|
| 档位 | ASR | LLM(pp+tg) | TTS | 串行总计 | 流式首音频* |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Qwen0.5B** | 2.2s | 1.5+3.4s | 3.0s | **≈10s** | **≈5.0-5.6s** ⚠️边缘 |
|
||||||
|
| Gemma3-1B | 2.2s | 2.7+6.2s | 3.0s | ≈14s | ≈7.7s ❌ |
|
||||||
|
| Gemma2-2B | 2.2s | 3.2+10.9s | 3.0s | ≈19s | ❌ |
|
||||||
|
|
||||||
|
*流式 = LLM 边生成边分句送 TTS 边播放,首音频 = ASR + LLM 首句(~15tok) + TTS 首句;再叠加 sys prompt KV 预缓存(省 ~0.8s)与 VAD 截断可再压 0.5-1s
|
||||||
|
|
||||||
|
### 6. 结论与建议(给王思远架构 / CEO 排期)
|
||||||
|
|
||||||
|
1. **推荐档位:Qwen2.5-0.5B Q4_K_M + t=2 大核 + llama-server 常驻 + 流式分句 TTS**。延迟预期:首音频 4.5-5.5s("可接受"边缘),完整回合 ~10s。**"良好"(≤3s)纯 CPU 无解**,需 NPU(A733 自带,Phase 2 探索)或云端混合
|
||||||
|
2. **内存预算**:Qwen0.5B ctx1024 常驻 ~700MB + whisper tiny ~200MB + piper ~150MB ≈ **1.1GB**,设备 3.1G 可用装得下且不动 swap;**严禁默认 ctx**(4096/8192 会吃 1-3.3GB)
|
||||||
|
3. **对视频服务影响可控**:t=2 时 showen_v2 只降 8-13%(132%→115-122%),全程 active 无重启;瞬时最低 67.8% 出现在 ASR encode 段(t=4),若锁 ASR 也用 2 线程可再缓解。远程无法目测画面卡顿,建议转正前现场人眼确认一次
|
||||||
|
4. **风险**:① 0.5B 中文人设/事实性弱,靠 prompt 工程+限字数兜底,数字生命"人格"体验需产品侧评估;② whisper 对静音幻觉 + 真实麦克风远场衰减,必须加 VAD;③ 磁盘现剩 14G,模型+工具共占 3.5G,后续多模型热切换要做版本 GC;④ github 直连不可靠,部署脚本必须内置镜像回退
|
||||||
|
5. **备选路线**:Qwen3-0.6B(更新一代,需重测);ASR 可试 sherpa-onnx 流式(免 30s 窗口固定成本);TTS piper 已够用无需换
|
||||||
|
|
||||||
|
### 现场保留(~/ai_spike/,共 3.5G,磁盘余量 14G)
|
||||||
|
|
||||||
|
```
|
||||||
|
models/ 3.1G qwen2.5-0.5b/gemma-3-1b/gemma-2-2b Q4_K_M + ggml-tiny/base
|
||||||
|
llama.cpp/ 247M build fdb1db8,build/bin/llama-{cli,bench,server}
|
||||||
|
whisper.cpp/ 60M build/bin/whisper-cli
|
||||||
|
piper/ 136M aarch64 二进制 + zh_CN-huayan-medium
|
||||||
|
results/ 1.7M 全部原始数据:bench txt/采样 csv/对话输出/wav 样本
|
||||||
|
*.sh sampler/run_bench/run_dialog/asr_test/tts_test/e2e_test(可复跑)
|
||||||
|
```
|
||||||
|
|
||||||
|
apt 安装清单:**无**。未动项目代码/configs,未 commit(老板管提交)。设备遗留 2 个 zombie 为已知 unclutter 问题(待处理 #5),非本次产生。
|
||||||
|
|||||||
71
CLAUDE.md
71
CLAUDE.md
@@ -6,7 +6,7 @@
|
|||||||
## 你的身份
|
## 你的身份
|
||||||
|
|
||||||
你是**陈逸飞**,ShowenV2 的 CEO 兼技术总监(Claude Opus 4.6)。
|
你是**陈逸飞**,ShowenV2 的 CEO 兼技术总监(Claude Opus 4.6)。
|
||||||
你**不写代码、不跑测试、不改配置**。所有执行通过 kilo 派发团队完成。
|
你**不写代码、不跑测试、不改配置**。所有执行通过 Agent 子代理派发团队完成。
|
||||||
|
|
||||||
## 项目概要
|
## 项目概要
|
||||||
|
|
||||||
@@ -77,12 +77,12 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi
|
|||||||
| 成员 | 失败计数 | 等级 | 更新时间 |
|
| 成员 | 失败计数 | 等级 | 更新时间 |
|
||||||
|------|---------|------|---------|
|
|------|---------|------|---------|
|
||||||
| 刘建国(PM) | 0 | — | 2026-03-19 |
|
| 刘建国(PM) | 0 | — | 2026-03-19 |
|
||||||
| 张明远 | 0 | — | 2026-03-19 |
|
| 张明远 | 1 | — | 2026-07-03 |
|
||||||
| 李思琪 | 0 | — | 2026-03-19 |
|
| 李思琪 | 0 | — | 2026-03-19 |
|
||||||
| 王浩然 | 0 | — | 2026-03-19 |
|
| 王浩然 | 0 | — | 2026-03-19 |
|
||||||
| 赵雨薇 | 0 | — | 2026-03-19 |
|
| 赵雨薇 | 0 | — | 2026-07-03(设备双 P0 修复 + 两轮磁盘清理连续出色,按"连续2次成功"重置) |
|
||||||
| 林晓峰(QA) | 0 | — | 2026-03-19 |
|
| 林晓峰(QA) | 0 | — | 2026-03-19 |
|
||||||
| 周雅婷 | 0 | — | 2026-03-19 |
|
| 周雅婷 | 0 | — | 2026-07-03(两轮验收执行出色) |
|
||||||
|
|
||||||
> 计数累加:审核不合格+1 / 返工+1 / 违反铁律+1。重置:连续2次成功→0 / Phase切换→全员0。
|
> 计数累加:审核不合格+1 / 返工+1 / 违反铁律+1。重置:连续2次成功→0 / Phase切换→全员0。
|
||||||
|
|
||||||
@@ -90,14 +90,14 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
- **质量**: 115/115 已在 Linux ARM64 验证(107 核心 + 8 集成);+ 24 M1.2 集成测试已写入本地,**待目标机 `cargo test` 验收后方可计入**;Flutter 15/15;零 warning
|
- **质量**: **147/147 已在 Linux ARM64 目标机验证**(2026-07-03 实跑:lib 105 + example 7 + m1_2_dynamic 15 + m1_2_http 12 + m1_2_service_manager 8);Flutter 15/15;零 warning。**注**:2026-07-04 新增 3 提交(README 修正 + P1 Shutdown fix + P2 清理)仅过类型检查,待目标机复验
|
||||||
- **里程碑**: M1.1 完成,M1.2 进行中(集成测试全部编写完毕,待 Linux 验收后收尾)
|
- **里程碑**: M1.1 完成,**M1.2 收尾完成**(147/147 两轮验收全绿、风险3 关闭、老板已提交 2a6e14b、设备已同步并复验零 warning),仅差老板打 tag(建议在 2a6e14b 上打 `m1.2`)
|
||||||
- **DevicePlugin 能力**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
- **DevicePlugin 能力**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
||||||
- **ScreenPlugin**: 已重构为 thin wrapper
|
- **ScreenPlugin**: 已重构为 thin wrapper
|
||||||
- **Flutter App**: 完成度 ~98%, APK v0.3 (52.6MB) 已编译, cupertino_icons 已修复
|
- **Flutter App**: 完成度 ~98%, APK v0.3 (52.6MB) 已编译, cupertino_icons 已修复
|
||||||
- **API 文档**: 已校准(以 routes.rs 为唯一权威重写)
|
- **API 文档**: 已校准(以 routes.rs 为唯一权威重写)
|
||||||
- **示例插件**: 已完善为开发者参考模板 (manifest.json + 请求/响应示范 + 7 个测试)
|
- **示例插件**: 已完善为开发者参考模板 (manifest.json + 请求/响应示范 + 7 个测试)
|
||||||
- **M1.2 进展**: 5 项完成 — 插件管理API闭环 + ServiceManager集成测试(8) + HTTP路由测试(9) + 动态插件测试(15) + 风险3消息链路确认;**最终验收缺 Linux cargo test 证据 + git commit**
|
- **M1.2 进展**: 目标机两轮验收完成(2026-07-03,147/147 零 warning,证据见 TEAM_CHAT.md);HTTP 路由测试实为 12 个(旧记录 9 有误)、lib 单测实为 105(旧记录 107 有误);剩余 commit+tag、风险3 补派核查
|
||||||
|
|
||||||
### 已修复(本轮)
|
### 已修复(本轮)
|
||||||
|
|
||||||
@@ -130,25 +130,53 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi
|
|||||||
|
|
||||||
25. ~~**P0**: 插件管理 API 闭环~~ ✅ 张明远修复 (handle_manager_message Custom 分支 + broadcast_plugin_states + 7 个新测试)
|
25. ~~**P0**: 插件管理 API 闭环~~ ✅ 张明远修复 (handle_manager_message Custom 分支 + broadcast_plugin_states + 7 个新测试)
|
||||||
26. ~~**M1.2**: ServiceManager 集成测试~~ ✅ 周雅婷完成 (tests/m1_2_service_manager.rs, 8 个测试全通过)
|
26. ~~**M1.2**: ServiceManager 集成测试~~ ✅ 周雅婷完成 (tests/m1_2_service_manager.rs, 8 个测试全通过)
|
||||||
27. ⏳ **M1.2**: HTTP API 路由集成测试 — 赵雨薇编写完成 (tests/m1_2_http.rs, 9 个测试:播放控制/配置重载/播放列表快照/插件管理闭环/路径穿越防护),**待 Linux ARM64 cargo test 验证 + git commit**
|
27. ~~**M1.2**: HTTP API 路由集成测试~~ ✅ 赵雨薇完成 (tests/m1_2_http.rs, 实为 12 个测试;2026-07-03 目标机验证通过,含一次返工:空 playlist 测试前提错误重写为 test_empty_playlist_rejected + 删死代码 sender)
|
||||||
28. ⏳ **M1.2**: 动态插件集成测试 — 张明远编写完成 (tests/m1_2_dynamic_plugin.rs, 15 个测试:manifest校验/生命周期/热替换回滚/版本GC/路径穿越),**待 Linux ARM64 cargo test 验证 + git commit**
|
28. ~~**M1.2**: 动态插件集成测试~~ ✅ 张明远完成 (tests/m1_2_dynamic_plugin.rs, 15 个测试;2026-07-03 目标机验证通过,含一次返工:删 2 处死代码 warning)
|
||||||
29. ⏳ **M1.2 风险3**: 消息链路分析 — 赵雨薇在 m1_2_http.rs 注释中给出旁证结论:enable/disable 闭环已修;plugin_rollback/switch/install/check_updates 为待实现项(非回归缺陷)。**WifiProvisioned/DeviceEvent 生产者确认尚未系统性核查(王浩然原任务 agent 失败,需补派)**
|
29. ~~**M1.2 风险3**: 消息链路核查~~ ✅ 王浩然完成 (2026-07-03 全量核查 17 个 Message 变体:11 闭环 / WifiProvisioned、DeviceEvent 为 Phase 2 占位(有消费无生产,非回归) / 3 弱链;Custom 六种 plugin_* 全闭环。修正赵雨薇旁证:rollback/switch/install 在 #25 修复后已是完整实现。报告见 TEAM_CHAT.md,**风险3 关闭**)
|
||||||
|
30. ~~**P0**: 设备端开机自启出现 3 个实例~~ ✅ 赵雨薇修复 (2026-07-02, 根因:3 个自启动入口叠加;仅保留用户级 showen_v2.service,其余移入 ~/autostart_backup_20260702/,重启验证进程==1)
|
||||||
|
31. ~~**P0**: 设备端窗口非全屏~~ ✅ 赵雨薇修复 (2026-07-02, 根因:应用先于 kwin 启动,OpenCV 全屏调用空操作;ExecStartPre 等待 kwin + kwinrulesrc 强制全屏规则,重启验证 800x800 无边框)
|
||||||
|
|
||||||
|
### M2.1 新立项(2026-07-03 老板拍板)
|
||||||
|
|
||||||
|
**语音数字生命 V1**。老板决策:**本地 AI 优先,云端接口预留**。范围:
|
||||||
|
1. 手机 App 遥控切换角色内容包(宠物猫狗/数字人/二次元歌姬),复用现有配置切换+remote_control 机制
|
||||||
|
2. 手机 App 录音上传 + 播放回复音频;设备本地跑 ASR(whisper.cpp)→ 小模型 LLM(0.5B~2B 量级,老板点名 Gemma 系,以设备实测为准)→ TTS(piper),响应时联动人物"说话"状态
|
||||||
|
3. LLM 后端做 trait 抽象,本地/云端可切换(云端接口预留不实现)
|
||||||
|
4. **模型管理(2026-07-03 老板补充)**:模型必须支持切换、下载、更换——不写死。因为设备硬件会变(未来可能上更强的板子跑更大模型),当前测试机只是全志 A733 4G 这一档。设计参照现有插件仓库/版本管理模式:模型清单 + 按设备能力选型 + 下载(国内镜像源)+ 校验 + 存储配额管理
|
||||||
|
5. **客户端矩阵(2026-07-03 老板补充)**:功能必须同时落到两个客户端——①clients/flutter 安卓 App;②设备内嵌网页控制端(routes.rs:1961 WEB_UI_HTML)。Web 端至少要有角色切换 + 模型管理;语音回合受浏览器安全限制(getUserMedia 需 HTTPS,设备是纯 HTTP)优先落 App,Web 端可用文字对话替代
|
||||||
|
6. 硬件事实:测试机为全志 A733(Linux ARM64, 4G 内存约 3.3G 可用),CPU 已被视频占 ~1.25 核;**老板确认(2026-07-03)扬声器/麦克风均未插入**(此前 arecord 有信号应为板载或浮空噪声)——**V1 音频 I/O 全部走客户端**:录音和播放在 App;网页端播放无障碍、录音需 HTTPS(自签证书列为评估项),文字对话保底;原 V2"设备直接对话"改为待硬件接入后再启动
|
||||||
|
|
||||||
|
7. **Spike 结论(2026-07-03 张明远实测,全表见 TEAM_CHAT.md)**:可行。设备实为 Radxa Cubie A7A(A733:2×A78@2.0G + 6×A55@1.8G)。推荐默认 **Qwen2.5-0.5B Q4**(16.4 t/s,50字回复 ~3.4s,RSS 985M);Gemma3-1B 可选档(8.2 t/s);**Gemma2-2B 本机不推荐**(RSS 3.25G 触发 swap)。硬约束:**推理必须 t=2 锁大核**(全核反而崩且挤死视频)、严禁默认上下文长度、部署脚本必须带镜像回退。ASR whisper tiny ~2.2s(需 VAD 防静音幻觉)、TTS piper RTF 0.3。端到端:串行热链 ~10s,**流式管线首音频 ~5s**(达标"可接受");≤3s 需 A733 的 NPU(Phase 2 路径)。现场保留 ~/ai_spike/ 3.5G 可复跑。
|
||||||
|
|
||||||
|
✅ PRD 完成(docs/M2.1_PRD.md,三轮修订含老板三条补充)+ Spike 完成。进行中:架构设计(王思远)。素材依赖:数字人/歌姬内容包需视频素材,先用现有猫狗包验证切换机制。
|
||||||
|
|
||||||
### 待处理
|
### 待处理
|
||||||
|
|
||||||
1. **[M1.2 验收 — 阻塞]** 在 Linux ARM64 执行 `cargo test --workspace`,确认 24 个新集成测试全通过,输出贴到 TEAM_CHAT.md → 再 git commit 三个测试文件
|
1. **[M1.2 收尾]** ✅ commit(老板 2a6e14b)→ ✅ 设备同步至 2a6e14b + cargo check 零 warning(周雅婷 2026-07-03,设备仅剩本地调参文件 configs/dog_state_machine.json 一处差异);**仅剩:老板打 tag(建议在 2a6e14b 上打 `m1.2`)**
|
||||||
2. **[M1.2 收尾]** 风险3 WifiProvisioned/DeviceEvent 生产者代码核查(补派王浩然)
|
2. ~~**[M1.2 收尾]** tests/m1_2_http.rs 过时注释更新~~ ✅ 赵雨薇完成(2026-07-03,纯注释改动,核实点位后订正为"六种 plugin_* 闭环,唯 check_updates 结果回传属 Phase 2");注意此改动在第二轮验收之后,commit 前设备端补一次 cargo check 即可
|
||||||
3. **[M1.2 收尾]** 全部测试通过后更新 PROGRESS.md M1.2 章节 + 打 tag
|
3. **[M1.2 收尾]** sanitize_filename 集成测试测的是逻辑复制而非产品函数,转正需产品侧小改动(改 pub 或 routes.rs 内加单测)— 赵雨薇上报
|
||||||
4. DevicePlugin 阶段三(framebuffer迁移/触摸/音频/多平台)— Phase 2 规划
|
4. DevicePlugin 阶段三(framebuffer迁移/触摸/音频/多平台)— Phase 2 规划
|
||||||
|
5. **[设备遗留-代码]** 单实例保护(锁文件/socket 互斥)+ unclutter 僵尸子进程 reap — 派内核/视频工程师
|
||||||
|
- ~~unclutter reap~~ ✅ 核查确认(2026-07-04):`linux_arm64.rs:238-254` shutdown() 已做 kill+wait+pkill 兜底,仅 SIGKILL 极端场景残留,不值得加 SIGCHLD handler
|
||||||
|
- **单实例保护**:未做。需目标机实测锁文件行为(Windows 开发环境无法验证),留给设备端工程师。背景:CLAUDE.md:136 记录的 3 实例 P0 已由赵雨薇移除多余自启入口修复,单实例锁是防御性双保险
|
||||||
|
6. ~~**[设备遗留-系统]** 时钟慢约一年 + 磁盘 9x% 满~~ ✅ 全部解决(2026-07-03):时钟 — 王浩然修复(根因:timesyncd 出厂未启用 + RTC hym8563 掉电失效标志;已配 aliyun NTP 持久化 + hwclock 恢复,apt 复活,重启验证开机即正确;遗留:RTC 备份电池未验证,冷启动需联网自愈);磁盘 — 赵雨薇两轮清理 96%→35%(删 Flutter/Android/编辑器/kilo 等 ~16G)
|
||||||
|
7. **[环境]** 设备 SSH 已打通:`ssh -p 2222 showen@192.168.31.105` 免密(2026-07-02 配置),目标机验收可远程执行
|
||||||
|
8. ~~**[代码质量-P2]** Shutdown 消息无生产触发者:main.rs:117-120 Ctrl+C 只置 AtomicBool,主循环阻塞在 rx.recv(),需等下一条任意消息才真正退出(王浩然发现)~~ ✅ 修复(2026-07-04,提交 ab1dd91):三处改动 — run() recv()→recv_timeout(200ms);handle_manager_message Shutdown 分支置 self.running=false;main.rs Ctrl+C handler 注入 Message::Shutdown。效果:Ctrl+C 后最多 200ms 退出。**待目标机复验**
|
||||||
|
9. ~~**[Phase 2 清理]** SDK 文档教开发者监听永不触发的 WifiProvisioned (plugin-sdk/src/lib.rs:522);CursorVisibility 有消费无生产(实际链路已走 DeviceCommand);check_updates 结果仅 println 不回传;DeviceResponse 广播后核心无人读、Error 静默~~ ✅ 部分完成(2026-07-04,提交 38dcd21):
|
||||||
|
- ✅ SDK 文档:broadcast 示例换成 StateChanged,新增 Phase 2 占位消息说明(WifiProvisioned/DeviceEvent/CursorVisibility)
|
||||||
|
- ✅ check_updates 结果回传:通过 Custom { kind: "plugin_updates" } 广播
|
||||||
|
- ✅ DeviceResponse::Error 留痕:device/mod.rs handle_message 补 eprintln
|
||||||
|
- ⏳ CursorVisibility 变体:删除会破坏示例插件和测试,倾向 Phase 2 统一清理(保留现状)
|
||||||
|
10. ~~**[待复验]** 2026-07-04 三提交(4d830c0 README / ab1dd91 P1 Shutdown fix / 38dcd21 P2 清理)仅过 rust-analyzer 类型检查,需在 Linux ARM64 目标机跑 `cargo check --workspace --all-targets && cargo test --workspace` 确认零 warning + 全测试通过~~ ✅ 目标机复验通过(2026-07-04):`cargo check` 零 warning;`cargo test --workspace` 147/147 全过(lib 105 + example 7 + m1_2_dynamic 15 + m1_2_http 12 + m1_2_service_manager 8);P1 Shutdown 修复实测:SIGINT 后 234ms 优雅退出(exit_code=0),修复前需等下一条消息可能永不退出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## kilo 派发模板(唯一权威版本)
|
## 任务派发模板(唯一权威版本)
|
||||||
|
|
||||||
```bash
|
通过 Claude Code 的 **Agent 工具(子代理)** 派发,子代理提示词:
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
```text
|
||||||
"你是<角色名>。
|
你是<角色名>。
|
||||||
|
|
||||||
开工前必读:
|
开工前必读:
|
||||||
1. souls/<name>.md(你的灵魂文件)
|
1. souls/<name>.md(你的灵魂文件)
|
||||||
@@ -158,15 +186,15 @@ kilo run -m openai/gpt-5.4 --auto \
|
|||||||
任务:<具体说明>
|
任务:<具体说明>
|
||||||
|
|
||||||
交付要求:
|
交付要求:
|
||||||
- 完成后执行 export PATH=\"/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:\$PATH\" && cargo check --workspace --all-targets && cargo test --workspace
|
- 完成后执行 cargo check --workspace --all-targets && cargo test --workspace
|
||||||
- 两项都绿灯后把输出贴在交付中
|
- 两项都绿灯后把输出贴在交付中
|
||||||
- 修完检查同文件是否有类似问题
|
- 修完检查同文件是否有类似问题
|
||||||
- 更新你的 soul 文件
|
- 更新你的 soul 文件
|
||||||
|
|
||||||
验收标准:<具体标准>"
|
验收标准:<具体标准>
|
||||||
```
|
```
|
||||||
|
|
||||||
**kilo 使用规则**: 不读大 diff / 命令越简单越好 / 进程上限 12 个 / `--auto` 自动批准
|
**派发规则**: 不读大 diff / 指令越简单越好 / 并行子代理上限 12 个,注意文件重叠冲突
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,6 +215,7 @@ kilo run -m openai/gpt-5.4 --auto \
|
|||||||
- ❌ 直接写代码/改代码/跑测试
|
- ❌ 直接写代码/改代码/跑测试
|
||||||
- ❌ 微观管理具体执行细节
|
- ❌ 微观管理具体执行细节
|
||||||
- ❌ 接受无证据交付
|
- ❌ 接受无证据交付
|
||||||
|
- ❌ **执行 git commit/push/tag** — 改动就绪后提醒老板提交,绝不代办(2026-07-03 老板指示)
|
||||||
|
|
||||||
### 评审节奏
|
### 评审节奏
|
||||||
- **周评审**: PM 进度 + QA 质量 + 产品规划
|
- **周评审**: PM 进度 + QA 质量 + 产品规划
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
## 当前里程碑
|
## 当前里程碑
|
||||||
|
|
||||||
**M1.2 — 进行中** 🔄
|
**M1.2 — 测试验收已通过,收尾中** 🔄
|
||||||
- 集成测试全部编写完毕(ServiceManager 8 + HTTP路由 9 + 动态插件 15 = 32 个)
|
- 集成测试全部编写完毕(ServiceManager 8 + HTTP路由 12 + 动态插件 15 = 35 个)
|
||||||
- **阻塞项**:待 Linux ARM64 运行 `cargo test --workspace` 验收 + git commit
|
- 2026-07-03 目标机(Linux ARM64, 192.168.31.105)两轮验收:**147/147 全过、零 warning**(第一轮暴露 1 个测试前提错误 + 3 个 warning,返工后第二轮全绿,证据见 TEAM_CHAT.md)
|
||||||
|
- 剩余:commit 返工测试文件 + 打 tag(待批准)+ 风险3 生产者核查(王浩然)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ ShowenV2/
|
|||||||
│ ├── http/ # Web UI + REST API (warp)
|
│ ├── http/ # Web UI + REST API (warp)
|
||||||
│ ├── ble/ # BLE 配网 (D-Bus BlueZ)
|
│ ├── ble/ # BLE 配网 (D-Bus BlueZ)
|
||||||
│ ├── wifi/ # WiFi 管理 (nmcli)
|
│ ├── wifi/ # WiFi 管理 (nmcli)
|
||||||
│ └── screen/ # 屏幕管理 (防息屏/光标)
|
│ ├── device/ # 设备能力 (Display/SleepInhibit/Backlight/Cursor)
|
||||||
|
│ └── screen/ # 屏幕管理 (thin wrapper,转发到 device)
|
||||||
├── plugin-sdk/ # 动态插件开发 SDK
|
├── plugin-sdk/ # 动态插件开发 SDK
|
||||||
├── plugins/
|
├── plugins/
|
||||||
│ └── example-plugin/ # 示例动态插件 (cdylib)
|
│ └── example-plugin/ # 示例动态插件 (cdylib)
|
||||||
@@ -34,7 +35,7 @@ ShowenV2/
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **语言**: Rust (edition 2018)
|
- **语言**: Rust (edition 2021)
|
||||||
- **视频**: OpenCV 0.66
|
- **视频**: OpenCV 0.66
|
||||||
- **HTTP**: warp + tokio
|
- **HTTP**: warp + tokio
|
||||||
- **BLE**: D-Bus + BlueZ (GATT)
|
- **BLE**: D-Bus + BlueZ (GATT)
|
||||||
@@ -46,7 +47,7 @@ ShowenV2/
|
|||||||
|
|
||||||
ShowenV2 支持两种插件模式:
|
ShowenV2 支持两种插件模式:
|
||||||
|
|
||||||
1. **静态插件** — 编译时链接,5 个内置插件 (video, http, ble, wifi, screen)
|
1. **静态插件** — 编译时链接,6 个内置插件 (video, http, ble, wifi, device, screen)
|
||||||
2. **动态插件** — 运行时加载 `.so` 文件,通过 `extern "C"` FFI + JSON 序列化通信
|
2. **动态插件** — 运行时加载 `.so` 文件,通过 `extern "C"` FFI + JSON 序列化通信
|
||||||
|
|
||||||
动态插件特性:
|
动态插件特性:
|
||||||
|
|||||||
@@ -1159,5 +1159,17 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 5000
|
"port": 5000
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"name": "小喵",
|
||||||
|
"character_type": "pet",
|
||||||
|
"persona_prompt": "你是一只慵懒傲娇的猫咪,名叫小喵。用简短慵懒的口吻回答,偶尔带'喵~',每次回复不超过两句话。"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"backend": "local",
|
||||||
|
"whisper_lib_dir": "/home/showen/ai_spike/whisper.cpp/build/bin",
|
||||||
|
"piper_lib_dir": "/home/showen/ai_spike/piper/piper",
|
||||||
|
"piper_config": "/home/showen/ai_spike/piper/zh_CN-huayan-medium.onnx.json",
|
||||||
|
"espeak_data_dir": "/home/showen/ai_spike/piper/piper/espeak-ng-data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"window_title": "Hologram Player - Dog",
|
"window_title": "Hologram Player - Dog",
|
||||||
"rotation": 0,
|
"rotation": 0,
|
||||||
"flip_horizontal": true,
|
"flip_horizontal": true,
|
||||||
"flip_vertical": true,
|
"flip_vertical": false,
|
||||||
"offset_x": 0,
|
"offset_x": 0,
|
||||||
"offset_y": 0,
|
"offset_y": 0,
|
||||||
"prevent_screen_lock": true,
|
"prevent_screen_lock": true,
|
||||||
@@ -1171,5 +1171,17 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 5000
|
"port": 5000
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"name": "小汪",
|
||||||
|
"character_type": "pet",
|
||||||
|
"persona_prompt": "你是一只活泼的小狗,名叫小汪。用简短俏皮的口吻回答,以'汪!'开头,每次回复不超过两句话。"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"backend": "local",
|
||||||
|
"whisper_lib_dir": "/home/showen/ai_spike/whisper.cpp/build/bin",
|
||||||
|
"piper_lib_dir": "/home/showen/ai_spike/piper/piper",
|
||||||
|
"piper_config": "/home/showen/ai_spike/piper/zh_CN-huayan-medium.onnx.json",
|
||||||
|
"espeak_data_dir": "/home/showen/ai_spike/piper/piper/espeak-ng-data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
### 2. 实时沟通(可选)
|
### 2. 实时沟通(可选)
|
||||||
|
|
||||||
#### 工具
|
#### 工具
|
||||||
- **kilo 命令** - 启动子任务进行实时协作
|
- **Agent 子代理** - 启动子任务进行实时协作
|
||||||
- **语音/视频会议** - 复杂问题讨论
|
- **语音/视频会议** - 复杂问题讨论
|
||||||
- **屏幕共享** - 代码调试和演示
|
- **屏幕共享** - 代码调试和演示
|
||||||
|
|
||||||
|
|||||||
312
docs/M2.1_PRD.md
Normal file
312
docs/M2.1_PRD.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# M2.1 PRD — 角色切换 + 语音互动 V1
|
||||||
|
|
||||||
|
## 0. 文档信息
|
||||||
|
|
||||||
|
- 里程碑:M2.1(Phase 2 首个功能里程碑)
|
||||||
|
- 版本:v1.0(草案,待 CEO/老板评审)
|
||||||
|
- 作者:张婉琳(产品总监)
|
||||||
|
- 日期:2026-07-03
|
||||||
|
- 需求源头(老板原话,最高优先级):
|
||||||
|
> "先用本地跑,同时预留云端接口。先做一个可用手机遥控切换宠物模式以及数字人或者数字二次元歌姬之类的功能,同时通过手机app录音和播放实现设备响应,可以的话本地运行0.5B或者2B的gemma4"
|
||||||
|
>
|
||||||
|
> 补充一(2026-07-03):"要支持切换模型和模型下载更改,因为以后设备会变,只不过测试设备是4G的这款全志A733运行linux"
|
||||||
|
>
|
||||||
|
> 补充二(2026-07-03):"clients里的安卓app别忘了改,还有设备的网页控制端"
|
||||||
|
>
|
||||||
|
> 补充三(2026-07-03,关键事实):"现在设备的扬声器麦克风没插入,尽量让对话和播放声音从网页和手机app实现"
|
||||||
|
- 关联文档:`clients/flutter/PRD.md`(App 现状)、`docs/M1.2_TEST_PLAN.md`、`configs/dog_state_machine.json`(内容包现状)
|
||||||
|
- 性能基线依赖:设备端小模型 spike(张明远,llama.cpp + whisper.cpp + piper 实测)进行中,**本文所有延迟/资源数字凡标注"待 spike 校准"者均以 spike 报告为准,评审时统一回填**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品定位
|
||||||
|
|
||||||
|
数字生命窗口从"单一宠物循环播放"升级为 **"可切换角色 + 语音互动"的数字生命 V1**。
|
||||||
|
|
||||||
|
- **现状**:设备开机播放单一宠物(狗/猫)的预渲染视频状态机,手机 App 只能做播放控制、状态机触发、配网与配置管理。角色是"布景",不是"生命"。
|
||||||
|
- **升级后**:
|
||||||
|
1. 手机一键切换角色——宠物(狗/猫)、数字人、二次元歌姬,角色即内容包,随时更换"住在窗口里的生命";
|
||||||
|
2. 对着手机说话,角色"听懂并回答"——客户端是话筒和喇叭,设备是大脑和脸:设备本地完成 ASR → LLM(0.5B~2B 小模型,人设跟随角色)→ TTS 计算,**所有声音一律从手机 App / 网页端播出,设备本地不出声**(设备音频硬件未接入——老板补充三确认的事实),回合期间画面同步切换"说话"状态;
|
||||||
|
3. 模型即耗材、可管理——模型不烧死在系统里,手机可查看/下载/切换/删除设备上的推理模型,硬件升级换更大模型不换系统。
|
||||||
|
- **架构原则(老板明确要求)**:**先本地跑通,云端接口预留**。V1 全链路本地推理、不依赖公网;LLM 后端做成可配置(local/cloud),V1 只实现 local。
|
||||||
|
- **平台叙事**:角色内容包 = 状态机 JSON + 视频素材 + 角色人设,是本平台第一个"标准化内容单元",为 Phase 2 内容生态(第三方角色包分发)奠基。
|
||||||
|
- **交付面(老板补充二)**:安卓 App 与设备内嵌网页控制端**两个客户端同为 M2.1 交付对象**,功能覆盖差异见 §5 客户端矩阵。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 功能一:角色内容包切换
|
||||||
|
|
||||||
|
### 2.1 内容包定义
|
||||||
|
|
||||||
|
一个角色内容包由三部分组成:
|
||||||
|
|
||||||
|
| 组成 | 内容 | 现状 |
|
||||||
|
|------|------|------|
|
||||||
|
| 状态机 JSON | display / playlist / transition / playback / scenes.state_machine / remote_control(结构见 `configs/dog_state_machine.json`) | 已有(dog、cat 两包) |
|
||||||
|
| 视频素材组 | playlist 引用的一组预渲染 mp4 片段 | 狗/猫已有;数字人/歌姬待制作 |
|
||||||
|
| 角色元信息 + 人设(**M2.1 新增**) | 角色显示名、类型(pet/human/singer)、封面图、人设 prompt、回复风格参数、TTS 音色标识 | 无,需新增 |
|
||||||
|
|
||||||
|
**角色元信息落地方式(供架构师决策,产品倾向前者)**:
|
||||||
|
- 方案 A(推荐):在状态机 JSON 内新增 `character` 顶层块。注意:配置结构体已全面 `deny_unknown_fields`(commit 5770b70),旧解析器会拒绝新字段,**需内核侧同步扩展 schema**,这是本功能的工程依赖。
|
||||||
|
- 方案 B:内容包目录 + 独立 `manifest.json` sidecar。改动隔离但引入"包=目录"的新概念,迁移成本更高。
|
||||||
|
|
||||||
|
**内容包可移植性要求(新包必须遵守)**:现有 dog 包 playlist 使用仓库外相对路径(`../../宠物嵌入式开发视频2/...`),不可分发。新制作的内容包素材必须收敛到设备上约定目录(建议 `videos/<pack_id>/`),路径自包含,为后续内容包分发做准备。存量 dog/cat 包 V1 不强制迁移。
|
||||||
|
|
||||||
|
### 2.2 角色类型与必备状态集
|
||||||
|
|
||||||
|
| 角色 | 状态机 | 素材 | 人设风格 |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| 狗(已有) | 已有,11 状态 | 已有 | 拟声/简短俏皮("汪!"开头、短句、宠物口吻) |
|
||||||
|
| 猫(已有) | 已有 | 已有 | 拟声/简短俏皮(慵懒、傲娇) |
|
||||||
|
| 数字人(新) | 新建,见必备状态集 | **待制作** | 亲切助理型,自然口语 |
|
||||||
|
| 二次元歌姬(新) | 新建,见必备状态集 | **待制作** | 活泼元气,二次元语气词 |
|
||||||
|
|
||||||
|
**新角色必备状态集(V1 最小集,语音互动依赖)**:
|
||||||
|
|
||||||
|
| 状态 | 用途 | 素材要求 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `idle` | 默认待机循环 | 可无缝循环(首尾帧一致),建议 5~15s |
|
||||||
|
| `talk` | 语音回合中"说话" | 口部/肢体有说话感,可无缝循环,建议 2~5s;V1 不要求口型对齐 |
|
||||||
|
| `greet` | 被切换到/被唤起时打招呼 | 一次性播放,建议 3~8s |
|
||||||
|
| `sleep` | 长时间无交互后休眠 | 可无缝循环,建议 5~15s |
|
||||||
|
|
||||||
|
存量宠物包无 `talk` 状态:V1 允许优雅降级(语音回合期间宠物包映射到既有互动状态如 `called_name`,或保持当前状态),不阻塞上线;后续补素材。
|
||||||
|
|
||||||
|
**素材统一规格**:
|
||||||
|
- 分辨率 1920×1080(与现有 render/output 配置一致),mp4 / H.264,25 或 30 fps
|
||||||
|
- 背景:纯黑或深色背景优先(全息显示依赖 brightness_adjust 背景抑制;如用 chroma key 需符合现有 hsv 配置能力)
|
||||||
|
- 角色形象跨片段严格一致(同一人物/服装/机位/光照)
|
||||||
|
- 循环类素材首尾帧一致,切换类素材起止均回到中立姿态,保证 fade 转场(0.5s)自然
|
||||||
|
|
||||||
|
### 2.3 App 侧交互
|
||||||
|
|
||||||
|
- **角色页(新增 Tab 或首页入口,交互稿定稿时定)**:角色卡片列表(封面图 + 名称 + 类型标签),当前角色卡片高亮标记"使用中"。
|
||||||
|
- **切换**:点卡片 → 二次确认(可选,首版可直接切)→ 切换中 loading 态 → 成功后卡片高亮迁移 + 首页当前角色信息同步更新。
|
||||||
|
- **数据来源**:切换机制复用现有 `GET /api/config/available` + `POST /api/config/switch`(服务端已具备:文件名校验 → 配置验证 → 写入激活 → ConfigReloadRequest 热重载)。现有 available 接口只返回文件名列表,**需扩展携带角色元信息**(显示名/类型/封面),扩展形式由工程定(enrich 现接口或新增 `/api/characters`)。
|
||||||
|
|
||||||
|
### 2.4 行为与体验指标
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|----|------|
|
||||||
|
| 切换耗时 | 点卡片 → 设备呈现新角色首帧:目标 ≤ 3s,上限 5s(超时 App 显示"切换超时,请检查设备") |
|
||||||
|
| 切换期间画面 | 不得黑屏挂死;允许短暂转场(fade/最后一帧定格) |
|
||||||
|
| 失败回退 | 目标配置验证失败 → 服务端拒绝(现有行为:400 + 激活配置不变),设备保持原角色,App 明确报错原因;热重载失败 → 依赖内核既有 AutoRollback 回滚到上一可用配置,App 通过 WS 状态感知并提示 |
|
||||||
|
| 状态一致性 | App 展示的"当前角色"以设备侧上报为准(available.active / WS 推送),不以本地点击乐观状态为准 |
|
||||||
|
| 切换副作用 | 切角色即切人设:对话上下文清空(见 3.5),语音回合若进行中则先完成或取消再切 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 功能二:语音对话回合(V1 = 客户端是话筒和喇叭,设备是大脑和脸)
|
||||||
|
|
||||||
|
### 3.1 回合模型
|
||||||
|
|
||||||
|
一次"回合"= 用户说一段话,角色回一段话。V1 只做回合制,不做流式、不做打断。
|
||||||
|
|
||||||
|
```
|
||||||
|
App:按住说话录音 → 松开上传 → 等待(思考中动画) → 播放设备返回的回复音频
|
||||||
|
设备:接收音频 → ASR 转写 → 本地 LLM 生成回复(人设 prompt 跟随当前角色) → TTS 合成 → 返回音频(+转写文本+回复文本)
|
||||||
|
画面:回合应答期间角色切 "talk" 状态 → 结束回 "idle"
|
||||||
|
```
|
||||||
|
|
||||||
|
**声音归属铁律(老板补充三)**:设备音频硬件(扬声器/麦克风)当前未接入。TTS 合成的回复音频**一律返回给发起请求的客户端播放**(App 或 Web,见 §5),设备本地不播放、不依赖任何音频输出/输入硬件。设备角色收敛为:**ASR / LLM / TTS 计算 + 人物画面状态联动**。
|
||||||
|
|
||||||
|
### 3.2 App 侧需求
|
||||||
|
|
||||||
|
- **对话页(新增)**:
|
||||||
|
- 底部"按住说话"大按钮:按住录音(波形/计时反馈),松开发送,上滑取消;单次录音上限 30s(超限自动截断发送并提示)
|
||||||
|
- 发送后"角色思考中"等待态,支持取消本回合
|
||||||
|
- 收到回复自动播放;播放器走手机扬声器/耳机
|
||||||
|
- **对话历史时间线**:按时间排列,我方气泡显示转写文本(可折叠原音频回放),角色气泡显示回复文本 + 可重播的回复音频;历史仅存手机本地(隐私设计,见 §6)
|
||||||
|
- **权限与异常**:麦克风权限引导;录音失败/上传失败/设备忙/超时均有明确可理解提示与重试入口
|
||||||
|
- **并发**:同一时刻只允许一个进行中回合;进行中时按钮置灰或排队提示
|
||||||
|
|
||||||
|
### 3.3 设备侧需求
|
||||||
|
|
||||||
|
- **新增语音回合 HTTP 接口**(具体 URL/编码由架构师定,行为要求如下):
|
||||||
|
- 输入:一段录音(建议 16kHz 单声道,格式工程定),附当前会话标识
|
||||||
|
- 输出:转写文本 + 回复文本 + 回复音频(三者 App 时间线都需要)
|
||||||
|
- 同一时刻只处理一个回合,忙时返回明确的"设备忙"错误(如 409)
|
||||||
|
- 全程本地推理:ASR(whisper.cpp 系)→ LLM(llama.cpp 系,0.5B~2B 档小模型,老板点名 gemma 系列优先,**最终模型与档位以 spike 实测为准**)→ TTS(piper 系)
|
||||||
|
- **人设注入**:LLM system prompt = 当前角色内容包的人设 prompt + 风格参数(最大回复长度、语气约束)。宠物 = 拟声/简短俏皮;数字人 = 亲切自然;歌姬 = 活泼元气。**角色人设是内容包的一部分**,切角色即切人设。
|
||||||
|
- **TTS 音色**:内容包元信息含音色标识,不同角色音色可区分(受限于 piper 中文音色可用性,最低要求:至少可用一款中文音色全角色共用,音色差异化作为增强项,以 spike 结论为准)。
|
||||||
|
- **画面联动**:回合应答开始 → 状态机切 `talk`;回合结束 → 回 `idle`。因 V1 音频在客户端(App/Web)播放,"说话画面"与客户端放音的对齐采用近似策略(按回复音频时长估算保持 talk 态,或由客户端回报播放完成,二选一由架构师定),对齐误差 ≤ 1.5s 可接受。角色包无 `talk` 状态时优雅降级(见 2.2),不报错。
|
||||||
|
- **上下文**:会话内保留滑动窗口短上下文(建议最近 3~5 回合,以 spike 内存数据定),存内存即可;切角色/重启即清空。**不做持久化**(V1 边界)。
|
||||||
|
|
||||||
|
### 3.4 云端预留(老板明确要求,验收必查)
|
||||||
|
|
||||||
|
- LLM 后端抽象为可配置项:`llm.backend = "local" | "cloud"`(含 cloud 的 endpoint/key 等预留字段)
|
||||||
|
- **V1 只实现 local**;配置为 cloud 时行为 = 明确报"暂未支持",不 panic 不静默
|
||||||
|
- 接口抽象层(trait/消息协议)在 V1 落地,保证 V2/V3 接云端时不动调用方
|
||||||
|
- ASR/TTS 是否同样抽象后端由架构师定,产品只强制 LLM 一层
|
||||||
|
|
||||||
|
### 3.5 回合与角色切换的互斥
|
||||||
|
|
||||||
|
- 回合进行中收到切换角色请求:拒绝并提示"角色正在回应,稍后再试"(或 App 侧先行禁用切换入口)
|
||||||
|
- 切换角色成功后:对话上下文清空,时间线插入系统分隔("已切换到 ×××"),历史记录保留(属手机本地数据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能三:模型管理(老板补充需求,2026-07-03)
|
||||||
|
|
||||||
|
### 4.1 设计动机(必须写清楚的产品逻辑)
|
||||||
|
|
||||||
|
**设备硬件会演进,模型不能写死在固件里。** 当前测试机是全志 A733(4G 内存,Linux ARM64)——这只是**最低档位**,不是目标终点。同一套 ShowenV2 系统未来跑在更强的板子上时,应能直接下载并切换到更大的模型(如 0.5B → 2B → 更高档),获得更强的对话能力,而**不需要重新烧录或重新部署系统**。"模型能力档位与设备能力匹配"是本产品的长期竞争力:硬件卖点升级 = 角色变聪明,软件系统不动。
|
||||||
|
|
||||||
|
### 4.2 功能需求
|
||||||
|
|
||||||
|
模型(LLM,及后续 ASR/TTS 模型同理)作为设备上的可管理资产,支持:
|
||||||
|
|
||||||
|
| 能力 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 可用模型清单 | 查看模型仓库清单:每个模型含名称、版本、文件尺寸、运行内存需求、**推荐设备档位**(如"4G 内存档可用 / 建议 8G 以上");同时展示已下载/未下载状态 |
|
||||||
|
| 下载模型 | 从**国内镜像源**下载;下载进度实时展示(百分比/速度);完成后完整性校验(哈希);失败可重试,**支持断点续传或失败后从断点重试**(二选一由工程定,不允许每次从零开始) |
|
||||||
|
| 切换模型 | 从已下载模型中选择一个作为当前推理模型并生效;切换期间语音功能短暂不可用需有明确状态提示,视频播放不受影响;切换失败自动回退到上一个可用模型 |
|
||||||
|
| 删除模型 | 删除已下载的旧模型释放空间;当前使用中的模型不可删除(或先强制切换);删除有二次确认 |
|
||||||
|
| 存储配额 | 设备磁盘有限(当前可用 18G),模型存储有配额概念:展示"模型已用空间 / 配额上限",下载前预检剩余空间不足则明确拒绝并引导删除旧模型 |
|
||||||
|
| 档位守门 | 下载/切换超出当前设备内存能力的模型时给出明确警告(可强制继续,标注"可能不稳定"),推荐档位默认高亮匹配当前设备的选项 |
|
||||||
|
|
||||||
|
### 4.3 App 侧交互:模型管理页
|
||||||
|
|
||||||
|
- 入口:设置页或角色/语音相关页内"模型管理"
|
||||||
|
- 页面内容:
|
||||||
|
- **当前模型**卡片:名称、档位、内存占用
|
||||||
|
- **设备资源水位**:磁盘(模型配额已用/剩余)、内存(总量/可用),让用户直观看到"我的设备还装得下什么"
|
||||||
|
- **可用模型列表**:每项显示尺寸/内存需求/推荐档位/状态(未下载 / 下载中+进度 / 已下载 / 使用中),操作按钮随状态变化(下载 / 取消 / 切换 / 删除)
|
||||||
|
- 下载过程可退出页面后台继续,回到页面恢复进度显示;下载失败有原因提示与重试按钮
|
||||||
|
|
||||||
|
### 4.4 与现有机制的关系
|
||||||
|
|
||||||
|
产品行为层面**参照项目已有的"插件仓库 + 版本管理"模式**(plugin_repo 下载安装、version_manager 版本切换/回滚/GC 保护当前版本):模型清单 ≈ 插件仓库索引,模型切换 ≈ 版本切换(先验证后生效、失败回退),删除保护 ≈ GC 的 protected 机制。是否复用同一套基础设施属架构决策,**留给王思远**;PRD 只约束上述外部行为一致性。
|
||||||
|
|
||||||
|
### 4.5 V1 边界(模型管理部分)
|
||||||
|
|
||||||
|
- 模型仓库源 V1 可以是静态清单(自建镜像/OSS 上的 index 文件),不做动态商店
|
||||||
|
- 不做模型自动更新推送;检查更新为手动触发
|
||||||
|
- ASR/TTS 模型 V1 允许随系统预置固定版本,仅 LLM 必须实现完整"清单/下载/切换/删除"闭环(ASR/TTS 纳入同一框架为增强项)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 客户端矩阵(老板补充需求二,2026-07-03)
|
||||||
|
|
||||||
|
### 5.1 两个客户端都是 M2.1 交付对象
|
||||||
|
|
||||||
|
| 客户端 | 位置 | M2.1 改动 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 安卓 App | `clients/flutter` | 角色切换页(§2.3)+ 语音对话页(§3.2)+ 模型管理页(§4.3),前文已覆盖 |
|
||||||
|
| **设备内嵌网页控制端** | `src/plugins/http/routes.rs:1961` 的 `WEB_UI_HTML` 字符串常量,设备 `/` 与 `/index.html` 直接服务(routes.rs:162-172);现有能力:状态监控、播放控制、状态机触发、WiFi、配置编辑、视频管理 | **必须新增**:① 角色切换界面(角色列表 + 当前角色标识 + 一键切换,行为指标同 §2.4);② 模型管理界面(当前模型 / 资源水位 / 下载进度 / 切换 / 删除,行为同 §4.2);③ 文字对话 + **回复音频浏览器播放(V1 必做)**(见 5.2) |
|
||||||
|
|
||||||
|
两端共用同一套设备 API:一端发起的角色切换/模型下载,另一端可见状态同步(Web 通过既有 WS 推送或轮询回显)。Web 端体验标准允许低于 App(工具属性优先),但错误提示、进度反馈、危险操作二次确认不可缺。
|
||||||
|
|
||||||
|
### 5.2 Web 端语音的技术约束(如实声明)
|
||||||
|
|
||||||
|
- **播放不受限,V1 必做**:浏览器 `<audio>` 播放没有安全上下文限制。Web 端文字对话的回复**必须带语音播放**(回复文字 + TTS 回复音频在浏览器播出)——设备无扬声器(老板补充三),"角色的声音"在 Web 场景就靠浏览器播放兜底,这是 V1 硬指标。
|
||||||
|
- **录音受限,降为评估项**:浏览器 `getUserMedia` 麦克风权限要求 HTTPS 安全上下文(localhost 除外),设备当前为纯 HTTP —— 因此 **V1 录音语音回合只落 App,Web 端录音不作为 V1 验收硬指标**。
|
||||||
|
- **Web 录音的技术路径(V1.5 增强候选,评估项)**:设备配自签名 HTTPS 证书后 `getUserMedia` 即可用,代价是浏览器首次访问出现一次证书警告需用户确认。方案技术可行、体验有瑕疵,列为 V1.5 增强候选,是否做、何时做在 V1 交付后按用户反馈评估。
|
||||||
|
- **Web 端 V1 主路径:文字输入对话** —— 文字 → LLM(同一人设链路,跳过 ASR)→ 回复文字 + TTS 回复音频(浏览器播放)。既绕开录音限制,又多出一个可演示入口,还使 LLM/TTS 链路可独立于 ASR 单独调试。
|
||||||
|
- 画面联动与并发规则同 §3.3/§3.5:文字回合期间角色切 `talk`,与语音回合互斥(同一时刻全设备只有一个进行中回合)。
|
||||||
|
|
||||||
|
### 5.3 功能 × 客户端对照表
|
||||||
|
|
||||||
|
| 功能 | 安卓 App | Web 控制端 | 说明 |
|
||||||
|
|------|---------|-----------|------|
|
||||||
|
| 角色切换 | ✅ 角色卡片页 | ✅ 必须新增 | 同一套 `config/available` + `config/switch` API |
|
||||||
|
| 录音(语音输入) | ✅ 主路径 | 🔍 评估项(V1.5 候选,非 V1 验收项) | getUserMedia 需 HTTPS;自签证书方案见 §5.2 |
|
||||||
|
| 回复音频播放 | ✅ 必做 | ✅ **必做** | 设备无扬声器,声音一律客户端播出(§3.1 铁律);浏览器 `<audio>` 无限制 |
|
||||||
|
| 文字对话 | ➖ 增强项(可复用时间线 UI,非 V1 强制) | ✅ 必须新增 | 文字 → LLM → 回复文字 + TTS 音频播放,跳过 ASR |
|
||||||
|
| 模型管理 | ✅ 模型管理页 | ✅ 必须新增 | 同一套模型管理 API,含下载进度 |
|
||||||
|
| 状态监控/播放控制/状态机/WiFi/配置/视频 | ✅ 已有 | ✅ 已有 | M1 存量能力,本期不动 |
|
||||||
|
|
||||||
|
### 5.4 Web 端可维护性风险(前置声明)
|
||||||
|
|
||||||
|
`WEB_UI_HTML` 是嵌在 Rust 源码内的单文件 HTML 字符串常量(routes.rs:1961-2384,约 420 行高密度单行 HTML/JS,routes.rs 全文件已达 2384 行)。M2.1 要再塞入角色切换 + 模型管理 + 文字对话三块界面,继续以字符串常量方式堆积将迅速不可维护。**建议王思远在架构评审时议**:改为 `include_str!` 引入独立 `.html` 文件(编译期仍内嵌进二进制、零运行时成本、部署形态不变)或静态资源目录。PRD 不强制实现方式,只要求交付前解决可维护性问题(详见 §9 风险表 #11)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 非功能需求
|
||||||
|
|
||||||
|
| 维度 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 回合延迟 | 松开录音 → 手机开始播放回复:**目标值待 spike 数据校准后回填**(产品期望:短语音(≤5s 说话、简短回复)体验可接受线 ≤ 5s、良好线 ≤ 3s,仅作 UX 参照,不作承诺;spike 若证明不可达则调整模型档位或预期,见 §9) |
|
||||||
|
| 分项预算 | ASR / LLM 首 token+生成 / TTS / 传输 各分项延迟以 spike 报告为准,PRD 评审时回填成正式指标表 |
|
||||||
|
| 离线可用 | 本地链路(角色切换 + 语音回合)在仅局域网、无公网环境下 100% 可用;断公网不得出现任何功能降级或报错 |
|
||||||
|
| 资源共存 | 测试机全志 A733(4G 内存,Linux ARM64,**最低档位基准机**)实测基线(2026-07-03):可用内存 3.3G、视频渲染已占 ~1.25 核。语音三模型(常驻或按需加载,策略以 spike 定)峰值内存预算初定 ≤ 2.5G(保留 ≥ 800M 余量,**以 spike 实测修正**);推理期间视频渲染不得出现可感知掉帧/卡顿,状态机触发仍需及时响应。更高档设备放宽预算由模型管理的档位机制承接(§4) |
|
||||||
|
| 模型落盘 | 模型文件占用设备磁盘(当前可用 18G),模型存储配额初定 ≤ 4G(含多模型共存),具体以选型定;配额行为见 §4.2 |
|
||||||
|
| 隐私(本地模式核心卖点) | 录音与对话内容**不出本地网络、不上云**;设备端不持久化用户录音与对话文本(处理完即弃);对话历史仅存用户手机本地,可一键清空;App 隐私文案明示"语音在你的设备上本地处理" |
|
||||||
|
| 稳定性 | 语音链路任何一环失败(ASR 空结果/LLM 超时/TTS 失败)都必须给发起请求的客户端(App/Web)返回结构化错误,不得拖垮视频主循环;连续失败不影响下一回合重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 不做什么(V1 边界)
|
||||||
|
|
||||||
|
1. **设备端麦克风/扬声器直接对话** — V2(老板补充三确认:设备扬声器/麦克风当前**未接入**;V1 全部声音走客户端,不依赖设备音频硬件;音频硬件接入后启动 V2,见 §10)
|
||||||
|
2. **唤醒词**("小狗小狗"类免手动唤起) — V3
|
||||||
|
3. **精细口型同步** — 不做;talk 状态只要求"在说话的感觉"
|
||||||
|
4. **多轮长上下文记忆的持久化** — 不做;仅会话内内存滑动窗口
|
||||||
|
5. 流式 ASR / 流式 TTS / 回合打断 — 不做,V1 纯回合制
|
||||||
|
6. 云端 LLM 的实际接入 — 只留接口不实现
|
||||||
|
7. 歌姬"真唱歌"(歌声合成) — 不做;歌姬 V1 是"会聊天的歌姬人设",唱歌能力另立项
|
||||||
|
8. 角色内容包的在线商店/分发 — Phase 2 后续里程碑
|
||||||
|
9. **Web 端录音语音对话** — 受 getUserMedia HTTPS 安全上下文限制(§5.2),V1 Web 用"文字对话 + 回复音频浏览器播放"替代;自签 HTTPS 解锁录音为 V1.5 增强候选(评估项,非 V1 验收)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收标准(可演示的用户旅程,逐条过)
|
||||||
|
|
||||||
|
1. 打开 App 进入角色页,看到 狗 / 猫 / 数字人 / 歌姬 至少 4 张角色卡片,当前角色卡片有"使用中"标记
|
||||||
|
2. 点击"歌姬"卡片 → 设备画面在目标时限内切换为歌姬 idle 循环 → App 当前角色标记同步更新为歌姬
|
||||||
|
3. 构造一个非法/损坏的内容包并尝试切换 → 设备保持原角色继续播放,App 显示明确错误原因
|
||||||
|
4. 在对话页按住说话:"你好,你叫什么名字?" → 松开 → 思考中动画 → 手机播出歌姬人设风格的语音回复;回合期间设备画面为 talk(或宠物包降级状态),结束后回到 idle
|
||||||
|
5. 对话历史时间线正确显示:我的转写文本 + 歌姬回复文本 + 回复音频可重播
|
||||||
|
6. 断开公网(仅保留局域网)重复旅程 2 和 4 → 全部成功,体验无差异
|
||||||
|
7. 切换到"狗"再问同一问题 → 回复风格明显变为拟声/简短俏皮,且时间线出现"已切换到狗"分隔、上下文已重置
|
||||||
|
8. 回合进行中再次按住说话或点击切换角色 → 被拒绝且有友好提示,不产生错乱
|
||||||
|
9. 语音回合全程观察设备画面 → 视频播放无可感知卡顿
|
||||||
|
10. 检查设备配置:`llm.backend` 配置项存在,切为 `cloud` 时得到"暂未支持"的明确响应(云端预留验收)
|
||||||
|
11. 设备端抽查:回合结束后无录音文件/对话文本落盘残留(隐私验收)
|
||||||
|
12. 打开模型管理页 → 看到当前模型、设备磁盘/内存水位、可用模型清单(含尺寸/内存需求/推荐档位标注)
|
||||||
|
13. 下载一个新模型 → 进度实时显示 → 完成后校验通过、状态变"已下载";中途断网 → 失败有提示,重试不从零开始
|
||||||
|
14. 切换到新下载的模型 → 语音回合使用新模型正常应答,视频播放全程不受影响;**全程不重新烧录/不重新部署系统**(老板点名验收项)
|
||||||
|
15. 删除一个未使用的旧模型 → 空间水位下降;尝试删除使用中的模型 → 被拒绝并提示
|
||||||
|
16. 磁盘配额不足时下载 → 被明确拒绝并引导删除旧模型
|
||||||
|
17. 浏览器访问设备 `http://<device-ip>:8080/` → Web 控制端可见当前角色并一键切换,设备画面切换成功,App 侧角色状态同步更新(两端一致性验收)
|
||||||
|
18. Web 控制端模型管理:查看当前模型与资源水位 → 下载新模型见实时进度 → 切换 → 删除旧模型,全流程可完成且与 App 侧状态互通
|
||||||
|
19. Web 控制端文字对话:输入文字 → 收到当前角色人设风格的回复文字 + **回复音频在浏览器内播放**(V1 必做,设备无扬声器由浏览器出声);回合期间设备画面 talk 联动,与语音回合互斥规则生效
|
||||||
|
20. 全部既有回归:147 项测试 + Flutter 测试继续全绿,零 warning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与依赖
|
||||||
|
|
||||||
|
| # | 风险/依赖 | 说明 | 应对 |
|
||||||
|
|---|----------|------|------|
|
||||||
|
| 1 | **数字人/歌姬素材从哪来**(最大外部依赖) | 每角色最少 4 状态 × 规格素材(§2.2),且跨片段角色一致性要求高 | 建议方案见下方"素材方案建议";**需老板拍板** |
|
||||||
|
| 2 | **spike 结论可能降档** | 2B 模型若延迟/内存超预算,降 0.5B 档甚至更小;ASR/TTS 模型同理 | PRD 指标留待回填;人设 prompt 按小模型能力设计(短、指令简单);回复长度上限收紧是首选降级手段 |
|
||||||
|
| 3 | 设备音频硬件未接入(~~扬声器现场验证~~ 已关闭:老板 2026-07-03 确认扬声器/麦克风未插入,无需再验证) | V1 全链路不依赖设备音频硬件(声音一律客户端播出,§3.1 铁律),**对 V1 零影响** | 若后续插入 USB/3.5mm 麦克风+扬声器,作为 V2(设备直接对话)的启动前置条件,届时再做接入验证 |
|
||||||
|
| 4 | CPU/内存争抢 | 视频渲染 ~1.25 核基础负载上叠加三模型推理 | spike 明确并发余量;必要时推理限核/降优先级;验收标准 9 兜底 |
|
||||||
|
| 5 | 配置 schema 演进 | `deny_unknown_fields` 下新增 `character` 块需内核同步改 schema,旧包/新包兼容策略要设计 | 列为内核工程依赖,架构评审首个议题(方案 A/B,§2.1) |
|
||||||
|
| 6 | piper 中文音色可用性 | 中文 TTS 音色质量/数量待验证 | spike 覆盖;最低验收线为"一款可懂中文音色",音色差异化降为增强项 |
|
||||||
|
| 7 | 存量包路径不可移植 | dog/cat 素材在仓库外相对路径 | 新包强制自包含目录规范;存量包 V1 不动,迁移列 backlog |
|
||||||
|
| 8 | App 录音权限与机型差异 | iOS/Android 麦克风权限、录音格式差异 | 沿用 BLE 权限引导的成熟模式;录音格式在 App 侧归一后上传 |
|
||||||
|
| 9 | 模型镜像源建设与维护 | 国内可达、带宽/存储成本、清单(index)维护责任人未定 | V1 用静态清单 + 自建镜像/OSS(§4.5);源地址做成可配置;**清单维护归属需老板/CEO 定** |
|
||||||
|
| 10 | 大文件下载对设备的冲击 | GB 级下载占带宽/磁盘 IO,可能影响视频播放与 HTTP 服务 | 下载限速/低优先级;沿用 M1.2 已解决的"上传流式化"经验(避免内存尖峰);下载中不允许同时切模型 |
|
||||||
|
| 11 | **Web 控制端可维护性** | `WEB_UI_HTML` 为嵌在 routes.rs 内的单文件 HTML 字符串常量(routes.rs:1961-2384,约 420 行高密度单行 HTML/JS,routes.rs 全文件已 2384 行),M2.1 需再加角色切换/模型管理/文字对话三块界面,继续内嵌堆积将不可维护 | **列王思远架构评审议题**:`include_str!` 拆独立 .html(编译期内嵌不变、部署形态不变)或静态资源目录;实现方式工程定,交付前必须解决(§5.4) |
|
||||||
|
|
||||||
|
### 素材方案建议(需老板拍板)
|
||||||
|
|
||||||
|
| 方案 | 优点 | 缺点 | 建议 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| A. AI 视频生成(可灵/即梦/Wan 等图生视频,先定角色立绘再批量生成状态片段) | 快、便宜、可迭代;一致性靠同一张立绘做参考帧可控 | 循环无缝性和动作质量需筛选返工;生成额度成本 | **推荐**:先各做 1 个角色 × 4 状态的试产验证管线 |
|
||||||
|
| B. 外包(Live2D/三维渲染工作室) | 质量与循环可控 | 周期长、贵、迭代慢 | 备选:A 试产不达标再启动 |
|
||||||
|
| C. 老板提供素材 | 零制作成本 | 是否存在、规格是否匹配未知 | 若老板有现成资源优先评估 |
|
||||||
|
|
||||||
|
**所需素材清单(无论哪种方案)**:数字人、歌姬各一套 —— idle / talk / greet / sleep 四状态片段,规格按 §2.2;每角色另需 1 张封面图(角色卡片用)+ 人设一句话设定(供撰写 persona prompt)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Roadmap 对齐
|
||||||
|
|
||||||
|
| 版本 | 定位(一句话) |
|
||||||
|
|------|---------------|
|
||||||
|
| **V1(本 PRD,M2.1)** | 手机遥控切换角色 + 客户端当话筒喇叭(设备本地不出声)的本地对话回合 + 模型可下载/切换(不重烧系统),App 与 Web 控制端双端交付,云端接口预留 |
|
||||||
|
| **V2** | 设备自带麦克风/扬声器直接对话,手机退为管理端(**前置条件:设备音频硬件接入后启动**——当前扬声器/麦克风未插入;接入后还需回声/拾音工程) |
|
||||||
|
| **V3** | 唤醒词免手动唤起 + 本地/云端 LLM 协同(本地兜底、云端增强),走向真正"常驻的数字生命" |
|
||||||
@@ -285,7 +285,7 @@ CEO 决策(如涉及重大变更)
|
|||||||
- **要求**: 必须记录在 TEAM_CHAT.md
|
- **要求**: 必须记录在 TEAM_CHAT.md
|
||||||
|
|
||||||
#### 2. 实时沟通(可选)
|
#### 2. 实时沟通(可选)
|
||||||
- **工具**: kilo 命令、语音、视频会议
|
- **工具**: Agent 子代理、语音、视频会议
|
||||||
- **适用**: 紧急问题、复杂讨论、头脑风暴
|
- **适用**: 紧急问题、复杂讨论、头脑风暴
|
||||||
- **要求**:
|
- **要求**:
|
||||||
- 沟通后必须在 TEAM_CHAT.md 记录要点
|
- 沟通后必须在 TEAM_CHAT.md 记录要点
|
||||||
@@ -381,8 +381,8 @@ CEO 决策(如涉及重大变更)
|
|||||||
**解锁条件**: 首次任务评分 ≥ 7/10 即可解锁灵魂文件写入权限
|
**解锁条件**: 首次任务评分 ≥ 7/10 即可解锁灵魂文件写入权限
|
||||||
|
|
||||||
### 通信机制
|
### 通信机制
|
||||||
- **任务下发**: CEO → `kilo run -m openai/gpt-5.4 --auto --dir <dir>` (带详细上下文)
|
- **任务下发**: CEO → Agent 子代理(提示词带详细上下文)
|
||||||
- **产出回收**: kilo 输出 → CEO 读取文件 → 审核 → git commit
|
- **产出回收**: 子代理交付 → CEO 读取文件 → 审核 → git commit
|
||||||
- **团队会议**: 重大决策记录到 PROGRESS.md
|
- **团队会议**: 重大决策记录到 PROGRESS.md
|
||||||
- **灵魂文件**: `souls/<name>.md` 持久化成员状态
|
- **灵魂文件**: `souls/<name>.md` 持久化成员状态
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- git commit 方案文档
|
- git commit 方案文档
|
||||||
|
|
||||||
### 2. 派发阶段
|
### 2. 派发阶段
|
||||||
- CEO 或 PM 通过 `kilo run -m openai/gpt-5.4 --auto --dir <dir>` 派发
|
- CEO 或 PM 通过 Agent 子代理派发
|
||||||
- **任务消息必须包含**:
|
- **任务消息必须包含**:
|
||||||
- 角色身份
|
- 角色身份
|
||||||
- **开工前必读文件**:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md
|
- **开工前必读文件**:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| CLAUDE.md | **CEO 唯一必读**:身份/规则/团队/状态/kilo模板 |
|
| CLAUDE.md | **CEO 唯一必读**:身份/规则/团队/状态/派发模板 |
|
||||||
| PROGRESS.md | 里程碑摘要、最近变更 |
|
| PROGRESS.md | 里程碑摘要、最近变更 |
|
||||||
| TEAM.md | 团队成员档案、制度、绩效详情 |
|
| TEAM.md | 团队成员档案、制度、绩效详情 |
|
||||||
| TEAM_CHAT.md | 团队异步沟通、任务讨论 |
|
| TEAM_CHAT.md | 团队异步沟通、任务讨论 |
|
||||||
@@ -76,14 +76,12 @@
|
|||||||
|
|
||||||
### 派发任务
|
### 派发任务
|
||||||
|
|
||||||
> kilo 派发模板的权威版本见 `CLAUDE.md`。以下为快速参考:
|
> 派发模板的权威版本见 `CLAUDE.md`。以下为快速参考(Agent 子代理提示词):
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
你是<角色名>。开工前必读:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md。
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
任务:<具体说明>。交付要求:贴 cargo check/test 输出 + 检查同类问题 + 更新 soul 文件。
|
||||||
"你是<角色名>。开工前必读:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md。
|
验收标准:<具体标准>
|
||||||
任务:<具体说明>。交付要求:贴 cargo check/test 输出 + 检查同类问题 + 更新 soul 文件。
|
|
||||||
验收标准:<具体标准>"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 审核提交
|
### 审核提交
|
||||||
|
|||||||
@@ -518,13 +518,22 @@ impl MessageSender {
|
|||||||
///
|
///
|
||||||
/// # let sender: MessageSender = unimplemented!();
|
/// # let sender: MessageSender = unimplemented!();
|
||||||
/// sender.broadcast(
|
/// sender.broadcast(
|
||||||
/// "network",
|
/// "video",
|
||||||
/// Message::WifiProvisioned {
|
/// Message::StateChanged {
|
||||||
/// ssid: "Office-WiFi".to_string(),
|
/// old_state: "idle".to_string(),
|
||||||
/// ip: "192.168.1.8".to_string(),
|
/// new_state: "playing".to_string(),
|
||||||
/// },
|
/// },
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// # 注意:Phase 2 占位消息
|
||||||
|
/// 以下消息变体当前在核心代码中**无生产者**,仅作为 Phase 2 占位保留。
|
||||||
|
/// 插件开发者不应在 `handle_message` 中为其编写业务逻辑(写了也不会被触发),
|
||||||
|
/// 也不应主动广播它们(核心不消费):
|
||||||
|
/// - `Message::WifiProvisioned` — WiFi 配网完成事件(待 WiFi 插件补生产者)
|
||||||
|
/// - `Message::DeviceEvent` — 设备事件上报通道(待 DeviceBackend trait 扩展)
|
||||||
|
/// - `Message::CursorVisibility` — 光标可见性(实际链路已改走
|
||||||
|
/// `DeviceCommand::SetCursorVisible`,此变体仅为向后兼容保留)
|
||||||
pub fn broadcast(&self, from: &str, message: Message) {
|
pub fn broadcast(&self, from: &str, message: Message) {
|
||||||
self.send(&Envelope {
|
self.send(&Envelope {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
|
|||||||
87
scripts/deploy_ai.sh
Normal file
87
scripts/deploy_ai.sh
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# M2.1 设备端部署脚本 — 部署 AI 推理二进制 + 模型文件
|
||||||
|
# 用法: bash scripts/deploy_ai.sh
|
||||||
|
# 前置: ~/ai_spike/ 存在 (spike 2026-07-03 保留)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
SPIKE_DIR="$HOME/ai_spike"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
TOOLS_DIR="$PROJECT_DIR/tools"
|
||||||
|
MODEL_STORE="$PROJECT_DIR/model_store"
|
||||||
|
|
||||||
|
echo "=== M2.1 AI 部署 ==="
|
||||||
|
echo "项目: $PROJECT_DIR"
|
||||||
|
echo "spike: $SPIKE_DIR"
|
||||||
|
|
||||||
|
# 检查 spike 目录
|
||||||
|
if [ ! -d "$SPIKE_DIR" ]; then
|
||||||
|
echo "错误: spike 目录不存在 ($SPIKE_DIR)"
|
||||||
|
echo "请先完成 spike (参考 TEAM_CHAT.md 2026-07-03 张明远报告)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. 部署二进制到 tools/
|
||||||
|
echo "--- 部署二进制到 tools/ ---"
|
||||||
|
mkdir -p "$TOOLS_DIR"
|
||||||
|
|
||||||
|
# whisper-cli (依赖 libggml*.so,需设 LD_LIBRARY_PATH 或 RPATH)
|
||||||
|
ln -sf "$SPIKE_DIR/whisper.cpp/build/bin/whisper-cli" "$TOOLS_DIR/whisper-cli"
|
||||||
|
echo " ✓ whisper-cli"
|
||||||
|
|
||||||
|
# llama-cli
|
||||||
|
ln -sf "$SPIKE_DIR/llama.cpp/build/bin/llama-cli" "$TOOLS_DIR/llama-cli"
|
||||||
|
echo " ✓ llama-cli"
|
||||||
|
|
||||||
|
# piper
|
||||||
|
ln -sf "$SPIKE_DIR/piper/piper" "$TOOLS_DIR/piper"
|
||||||
|
echo " ✓ piper"
|
||||||
|
|
||||||
|
# 2. 部署模型到 model_store/
|
||||||
|
echo "--- 部署模型到 model_store/ ---"
|
||||||
|
mkdir -p "$MODEL_STORE/llm" "$MODEL_STORE/asr" "$MODEL_STORE/tts"
|
||||||
|
|
||||||
|
# LLM: Qwen2.5-0.5B (默认推荐)
|
||||||
|
ln -sf "$SPIKE_DIR/models/qwen2.5-0.5b-instruct-q4_k_m.gguf" "$MODEL_STORE/llm/qwen2.5-0.5b-q4_k_m.gguf"
|
||||||
|
echo " ✓ llm/qwen2.5-0.5b-q4_k_m.gguf (469M, 推荐默认)"
|
||||||
|
|
||||||
|
# LLM: Gemma3-1B (可选档)
|
||||||
|
ln -sf "$SPIKE_DIR/models/gemma-3-1b-it-Q4_K_M.gguf" "$MODEL_STORE/llm/gemma3-1b-q4_k_m.gguf"
|
||||||
|
echo " ✓ llm/gemma3-1b-q4_k_m.gguf (769M, 可选档)"
|
||||||
|
|
||||||
|
# ASR: whisper tiny
|
||||||
|
ln -sf "$SPIKE_DIR/models/ggml-tiny.bin" "$MODEL_STORE/asr/ggml-tiny.bin"
|
||||||
|
echo " ✓ asr/ggml-tiny.bin (75M)"
|
||||||
|
|
||||||
|
# ASR: whisper base (备选)
|
||||||
|
ln -sf "$SPIKE_DIR/models/ggml-base.bin" "$MODEL_STORE/asr/ggml-base.bin"
|
||||||
|
echo " ✓ asr/ggml-base.bin (142M, 备选)"
|
||||||
|
|
||||||
|
# TTS: piper zh_CN-huayan-medium
|
||||||
|
ln -sf "$SPIKE_DIR/piper/zh_CN-huayan-medium.onnx" "$MODEL_STORE/tts/zh_CN-huayan-medium.onnx"
|
||||||
|
ln -sf "$SPIKE_DIR/piper/zh_CN-huayan-medium.onnx.json" "$MODEL_STORE/tts/zh_CN-huayan-medium.onnx.json"
|
||||||
|
echo " ✓ tts/zh_CN-huayan-medium.onnx (63M)"
|
||||||
|
|
||||||
|
# 3. 设置 whisper LD_LIBRARY_PATH (whisper-cli 依赖 libggml*.so)
|
||||||
|
WHISPER_LIB="$SPIKE_DIR/whisper.cpp/build/bin"
|
||||||
|
echo "--- whisper 依赖库路径: $WHISPER_LIB ---"
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
echo "=== 验证 ==="
|
||||||
|
echo "tools/:"
|
||||||
|
ls -la "$TOOLS_DIR"
|
||||||
|
echo "model_store/:"
|
||||||
|
ls -laR "$MODEL_STORE"
|
||||||
|
|
||||||
|
# 测试 llama-cli 能跑
|
||||||
|
echo "--- 测试 llama-cli ---"
|
||||||
|
"$TOOLS_DIR/llama-cli" --version 2>&1 | head -1 || echo " (llama-cli 版本检查失败, 可能仍可用)"
|
||||||
|
|
||||||
|
# 测试 piper 能跑
|
||||||
|
echo "--- 测试 piper ---"
|
||||||
|
"$TOOLS_DIR/piper" --help 2>&1 | head -1 || echo " (piper 帮助检查失败, 可能仍可用)"
|
||||||
|
|
||||||
|
echo "=== 部署完成 ==="
|
||||||
|
echo ""
|
||||||
|
echo "注意: whisper-cli 运行时需要 LD_LIBRARY_PATH 包含:"
|
||||||
|
echo " $WHISPER_LIB"
|
||||||
|
echo "建议在 systemd service 或启动脚本中设置此环境变量"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
CEO (陈逸飞) → PM (刘建国) → 开发团队
|
CEO (陈逸飞) → PM (刘建国) → 开发团队
|
||||||
|
|
||||||
任务通过 `kilo run` 派发给各成员,成员首次任务评分 ≥ 7 分解锁灵魂文件,末尾淘汰制。
|
任务通过 Agent 子代理派发给各成员,成员首次任务评分 ≥ 7 分解锁灵魂文件,末尾淘汰制。
|
||||||
|
|
||||||
## 子目录
|
## 子目录
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,9 @@
|
|||||||
|
|
||||||
## CEO 个人经验
|
## CEO 个人经验
|
||||||
|
|
||||||
### kilo 派发经验
|
### 子代理派发经验(kilo 机制已于 2026-07-02 废除,教训保留,现用 Agent 子代理)
|
||||||
- PM 刘建国 git commit 连续两次失败(agent 无视"不要读 diff"),QA 林晓峰一次搞定
|
- PM 刘建国 git commit 连续两次失败(agent 无视"不要读 diff"),QA 林晓峰一次搞定
|
||||||
- kilo agent 倾向于自作主张读 diff/分析代码 → 只给命令,不给"任务描述"
|
- 执行 agent 倾向于自作主张读 diff/分析代码 → 只给命令,不给"任务描述"
|
||||||
- 指令越简单越好,不要让 agent 读大文件/大 diff
|
- 指令越简单越好,不要让 agent 读大文件/大 diff
|
||||||
- 代码提交任务直接给命令序列,不需要 agent 自行分析
|
- 代码提交任务直接给命令序列,不需要 agent 自行分析
|
||||||
- GPT-5.4 执行力 > 策略力 → 给具体命令比给目标可靠
|
- GPT-5.4 执行力 > 策略力 → 给具体命令比给目标可靠
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
- 关键路径任务(git push)派给最可靠的人,不按头衔
|
- 关键路径任务(git push)派给最可靠的人,不按头衔
|
||||||
- PM 不可靠时可跳过 PM 直接派开发者,记录原因
|
- PM 不可靠时可跳过 PM 直接派开发者,记录原因
|
||||||
- QA 比 PM 更适合执行类任务(提交、验证),PM 适合分析和规划
|
- QA 比 PM 更适合执行类任务(提交、验证),PM 适合分析和规划
|
||||||
- 员工用 kilo 完成后必须自行更新 soul 文件,否则经验丢失
|
- 员工完成任务后必须自行更新 soul 文件,否则经验丢失
|
||||||
|
|
||||||
### 角色边界
|
### 角色边界
|
||||||
- **CEO 绝不直接写代码/改代码/跑测试** — 即使小修复也派回原作者
|
- **CEO 绝不直接写代码/改代码/跑测试** — 即使小修复也派回原作者
|
||||||
|
|||||||
@@ -72,8 +72,8 @@
|
|||||||
## 工作方法
|
## 工作方法
|
||||||
1. 收到 CEO 目标后,立即拆解为具体任务
|
1. 收到 CEO 目标后,立即拆解为具体任务
|
||||||
2. 评估任务依赖关系,确定并行方案
|
2. 评估任务依赖关系,确定并行方案
|
||||||
3. 通过 kilo 派发任务,消息中包含:角色、上下文文件、具体要求、验收标准
|
3. 通过 Agent 子代理派发任务,提示词中包含:角色、上下文文件、具体要求、验收标准
|
||||||
4. **多线程思考**: 可以并行启动多个 kilo 进程探索方案或分析代码
|
4. **多线程思考**: 可以并行启动多个子代理探索方案或分析代码
|
||||||
5. 任务完成后运行 cargo check 验证
|
5. 任务完成后运行 cargo check 验证
|
||||||
6. 初审通过后更新 PROGRESS.md,向 CEO 汇报
|
6. 初审通过后更新 PROGRESS.md,向 CEO 汇报
|
||||||
7. 遇到技术难题或架构问题,立即上报 CEO
|
7. 遇到技术难题或架构问题,立即上报 CEO
|
||||||
@@ -129,14 +129,12 @@
|
|||||||
建议下一步: <PM 的建议>
|
建议下一步: <PM 的建议>
|
||||||
```
|
```
|
||||||
|
|
||||||
### kilo 派发模板(含能动性要求)
|
### 任务派发模板(含能动性要求)
|
||||||
|
|
||||||
> 权威版本见 `CLAUDE.md`。此处保留副本供 PM 独立 session 使用。
|
> 权威版本见 `CLAUDE.md`。此处保留副本供 PM 独立 session 使用(Agent 子代理提示词)。
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
你是<角色名>。
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
|
||||||
"你是<角色名>。
|
|
||||||
|
|
||||||
开工前必读:
|
开工前必读:
|
||||||
1. souls/<name>.md(你的灵魂文件)
|
1. souls/<name>.md(你的灵魂文件)
|
||||||
@@ -146,13 +144,13 @@ kilo run -m openai/gpt-5.4 --auto \
|
|||||||
任务:<具体说明>
|
任务:<具体说明>
|
||||||
|
|
||||||
交付要求:
|
交付要求:
|
||||||
- 完成后执行 export PATH=\"/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:\$PATH\" && cargo check --workspace --all-targets
|
- 完成后执行 cargo check --workspace --all-targets
|
||||||
- 再执行 cargo test --workspace
|
- 再执行 cargo test --workspace
|
||||||
- 两项都绿灯后,把输出贴在交付汇报中
|
- 两项都绿灯后,把输出贴在交付汇报中
|
||||||
- 修完 bug 检查同文件是否有类似问题
|
- 修完 bug 检查同文件是否有类似问题
|
||||||
- 更新你的 soul 文件
|
- 更新你的 soul 文件
|
||||||
|
|
||||||
验收标准:<具体标准>"
|
验收标准:<具体标准>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 沟通协议
|
## 沟通协议
|
||||||
@@ -163,10 +161,9 @@ kilo run -m openai/gpt-5.4 --auto \
|
|||||||
- 涉及状态变化、流程更新或复盘结论时,确保同步到规则文件或 soul 文件,避免信息只停留在聊天记录中。
|
- 涉及状态变化、流程更新或复盘结论时,确保同步到规则文件或 soul 文件,避免信息只停留在聊天记录中。
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- kilo 调用方式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "消息"`
|
- 派发方式:Agent 子代理(Claude Code Agent 工具),在提示词中指示读取文件
|
||||||
- 不使用 `-f` 参数,在消息中指示读取文件
|
|
||||||
- 每个任务必须 cargo check 通过
|
- 每个任务必须 cargo check 通过
|
||||||
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
- Linux ARM64 目标机编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
||||||
|
|
||||||
## 复盘记录
|
## 复盘记录
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,17 @@
|
|||||||
- BLE 注册不能靠固定 sleep,需等待 `RegisterApplication` / `RegisterAdvertisement` 的真实 D-Bus reply
|
- BLE 注册不能靠固定 sleep,需等待 `RegisterApplication` / `RegisterAdvertisement` 的真实 D-Bus reply
|
||||||
- 为跑通全量验证,顺手补了 `DynamicPlugin` 的 `Debug` 实现,并修正 plugin_repo 测试构造方式
|
- 为跑通全量验证,顺手补了 `DynamicPlugin` 的 `Debug` 实现,并修正 plugin_repo 测试构造方式
|
||||||
- API 文档校准必须以 `src/plugins/http/routes.rs` 为唯一权威;WebSocket 命令返回仍是 `{ok: ...}`,HTTP 写接口才统一 `{status, message}`
|
- API 文档校准必须以 `src/plugins/http/routes.rs` 为唯一权威;WebSocket 命令返回仍是 `{ok: ...}`,HTTP 写接口才统一 `{status, message}`
|
||||||
|
|
||||||
|
## 个人经验 (2026-07-03) — M1.2 风险3 消息链路审计
|
||||||
|
- 消息链路审计方法:以 Message enum 定义为基准,逐变体 grep 构造点/匹配点,再按 ServiceManager 三路路由(Plugin/Broadcast/Manager, service_manager.rs:369-377)验证链路;测试文件构造一律不算生产者
|
||||||
|
- 审计陷阱:构造点可能藏在 #[cfg(test)] 内(如 ble/gatt.rs:772、dynamic_plugin.rs:348),必须回看 mod tests 边界再下判定
|
||||||
|
- BLE 命令的真实生产者是 core/dispatch.rs::parse_command(gatt.rs:120 调用),不在 gatt.rs 里直接构造消息
|
||||||
|
- 结论:WifiProvisioned/DeviceEvent 均为"有消费无生产"的 Phase 2 占位(DeviceBackend trait 连事件通道都没有);plugin_rollback/switch/install 已是完整实现,队友旧注释会过时——审计要以当前代码为准,不能沿用他人历史结论
|
||||||
|
|
||||||
|
## 个人经验 (2026-07-03) — 设备时钟修复
|
||||||
|
|
||||||
|
- 时钟类故障先分层:服务层(timesyncd 是否 enabled)→ 硬件层(RTC 可读?dmesg 找 rtc 驱动报错)→ 网络层(NTP 源可达性)。本次前两层全中,第三层假设被证伪——别先入为主怪网络。
|
||||||
|
- hym8563 这类 I2C RTC 掉电后置 VL 失效标志,`hwclock -r` 报 EINVAL 不等于芯片坏:`hwclock -w` 写一次即清标志恢复。
|
||||||
|
- `timedatectl` 依赖 RTC 可读,RTC 失效时它整个不可用——破局用 `sudo date -s`。
|
||||||
|
- `sudo -S` 从 stdin 读密码,与 heredoc 重定向互斥(heredoc 会顶掉管道,首行被当密码吞掉);远程写 root 文件用 `echo pwd | sudo -S bash -c 'printf ... > file'`。
|
||||||
|
- apt "certificate not yet valid" 时禁止走 apt 装 ntp/chrony(自举死锁),只能用系统自带 timesyncd 或手动设时破局。
|
||||||
|
|||||||
@@ -125,3 +125,18 @@
|
|||||||
- 为版本切换、安装、更新检查补了 Manager 侧 helper,统一复用 `VersionManager` / `PluginRepository` / 热替换生命周期
|
- 为版本切换、安装、更新检查补了 Manager 侧 helper,统一复用 `VersionManager` / `PluginRepository` / 热替换生命周期
|
||||||
- 在 `src/core/tests.rs` 增加 7 个回归测试,覆盖初始状态广播和 6 个自定义管理命令;保留旧生命周期测试并过滤新增状态广播事件
|
- 在 `src/core/tests.rs` 增加 7 个回归测试,覆盖初始状态广播和 6 个自定义管理命令;保留旧生命周期测试并过滤新增状态广播事件
|
||||||
- `cargo check --workspace --all-targets` 零 warning,`cargo test --workspace` 107/107 + 集成测试全绿
|
- `cargo check --workspace --all-targets` 零 warning,`cargo test --workspace` 107/107 + 集成测试全绿
|
||||||
|
|
||||||
|
## 个人经验 (2026-07-03 - M2.1 设备端本地 AI spike)
|
||||||
|
- Radxa Cubie A7A (Allwinner A733) = 2×A78@2.0G + 6×A55@1.8G 带 dotprod + NPU,不是老 A53 板子,量化推理能力远超预期
|
||||||
|
- **big.LITTLE 上 llama.cpp 线程数是生死线**:t=2 锁大核生成 16.4 t/s;t=8 全核反而崩到 7.2 t/s(ggml 同步屏障被小核拖尾)且把 showen_v2 挤到 50%——多线程≠更快
|
||||||
|
- llama-cli 默认上下文是内存杀手:Gemma2-2B 默认 ctx 峰值 RSS 3.25G 直接触发 swap;正式集成必须 `-c 1024` 限死
|
||||||
|
- whisper.cpp encode 是固定 30s 窗口成本(tiny ~1.5s),语音再短也省不掉,这是 ASR 延迟下限;对静音会幻觉出字幕,必须 VAD 前置
|
||||||
|
- piper zh_CN-huayan RTF 0.30 很稳,TTS 不是瓶颈;瓶颈排序 LLM > ASR > TTS
|
||||||
|
- 设备 github 直连 git clone 会被 TLS 掐断、release 直下 10KB/s:**ghfast.top 代理 + hf-mirror.com 是设备上拿代码/模型的唯一可靠路径**,部署脚本必须内置回退
|
||||||
|
- 测量方法论:无 GNU time 时用 /proc/<pid>/status 的 VmHWM 轮询取峰值 RSS;showen 影响用 /proc/stat+/proc/pid/stat 双点差分算瞬时 CPU%,比 top -bn1 首帧准
|
||||||
|
- llama-cli 参数坑:`--no-warmup`(双横线),`-no-warmup` 直接报错退出——远程 nohup 跑测试前先本地空跑一次参数
|
||||||
|
|
||||||
|
## 个人经验 (2026-07-03 - M1.2 集成测试返工教训)
|
||||||
|
- `tests/m1_2_dynamic_plugin.rs` 目标机实跑暴露 2 个 warning(unused imports `Destination`/`Envelope` + 死函数 `manifest_with_caps`)——是我预写辅助函数/import 后测试改走 `register_dynamic_with_manifest` 路线留下的残留
|
||||||
|
- 教训:**交付前必须对测试文件本身做死代码自查**(本机编译不了 aarch64 也要人工逐符号核对 import 和 helper 的使用点),不能只保证测试逻辑正确
|
||||||
|
- 消 warning 原则:先判断死代码是"残留"还是"漏写的测试",查产品代码确认无对应校验逻辑后才删;绝不用 `#[allow(...)]` 压制
|
||||||
|
|||||||
@@ -120,6 +120,13 @@
|
|||||||
7. 产品上线后收集数据和反馈
|
7. 产品上线后收集数据和反馈
|
||||||
8. 迭代优化产品
|
8. 迭代优化产品
|
||||||
|
|
||||||
|
## 经验
|
||||||
|
- **2026-07-03 M2.1 PRD**:写 PRD 前先读代码核实能力边界,避免重复造轮子——角色切换直接复用已有 `/api/config/available` + `/api/config/switch`(含验证/热重载/失败不落盘),PRD 只定义行为与体验,不发明新机制
|
||||||
|
- 性能指标没有实测数据时一律标注"待 spike 校准",绝不编造数字;UX 期望值可写但注明"仅作参照,不作承诺"
|
||||||
|
- 配置结构体 `deny_unknown_fields` 意味着内容包 JSON 加新字段必须内核同步改 schema——产品定义信息需求,落地方案(schema 扩展 vs sidecar)留给架构师并在 PRD 里显式标注为工程依赖
|
||||||
|
- 老板中途补需求(模型管理):并入 PRD 时要写清设计动机(硬件会演进、A733/4G 只是最低档)并在验收标准里落成老板点名的可验证条目("换模型不重烧录")
|
||||||
|
- 涉及外部依赖(素材制作、镜像源维护)的开放问题必须在 PRD 和 TEAM_CHAT 中显式列出待拍板项,不能默默替老板做决定
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- ShowenV2 是数字生命窗口平台,不局限于宠物
|
- ShowenV2 是数字生命窗口平台,不局限于宠物
|
||||||
- 插件架构是核心竞争力
|
- 插件架构是核心竞争力
|
||||||
|
|||||||
@@ -88,8 +88,29 @@
|
|||||||
- 配置编辑同时支持表单和 raw JSON 时,必须维护单一真源:`_fullConfig` 更新后立刻同步 JSON 文本,JSON 保存成功后再反向刷新表单字段
|
- 配置编辑同时支持表单和 raw JSON 时,必须维护单一真源:`_fullConfig` 更新后立刻同步 JSON 文本,JSON 保存成功后再反向刷新表单字段
|
||||||
- JSON 编辑模式的错误反馈要区分“解析失败”和“接口保存失败”;前者本地立即拦截,后者通过 SnackBar 暴露服务端/网络错误
|
- JSON 编辑模式的错误反馈要区分“解析失败”和“接口保存失败”;前者本地立即拦截,后者通过 SnackBar 暴露服务端/网络错误
|
||||||
|
|
||||||
|
## 生产机开机自启三实例 + 非全屏修复经验(2026-07-02)
|
||||||
|
- 排查多实例先看 `/proc/<pid>/cgroup`,能直接把每个进程映射到具体 systemd unit,比猜父进程可靠
|
||||||
|
- 自启动入口要系统性穷举:system unit + user unit + ~/.config/autostart/*.desktop + autostart-scripts,四处都可能各藏一份同一程序
|
||||||
|
- OpenCV highgui 的 `set_window_property(FULLSCREEN)` 依赖 WM 已就绪;服务比 kwin 先启动时全屏请求会静默丢失 → user unit 加 ExecStartPre 轮询 `xprop -root _NET_SUPPORTING_WM_CHECK` 等 WM
|
||||||
|
- 持久化全屏用 KWin 窗口规则(~/.config/kwinrulesrc,wmclass 子串匹配 + fullscreenrule=2/noborderrule=2 强制),比 wmctrl 一次性调用可靠
|
||||||
|
- xwininfo -tree 里 GTK 应用会有一个 10x10 未映射的 group-leader 窗口,真正的客户端窗口以 `_NET_CLIENT_LIST`/`_NET_ACTIVE_WINDOW` 为准
|
||||||
|
- 设备时钟偏差会导致 apt TLS 证书 "not yet valid";验证窗口几何用系统自带 xprop/xwininfo 即可,不必依赖 wmctrl/xdotool
|
||||||
|
- 禁用入口用 mv 到带日期的备份目录(如 ~/autostart_backup_20260702/),保留恢复能力,不 rm
|
||||||
|
|
||||||
## Flutter APK v0.3 编译经验(2026-03-14)
|
## Flutter APK v0.3 编译经验(2026-03-14)
|
||||||
- Release 构建前先跑 `flutter analyze` 和 `flutter test`,确保依赖变更不会把问题拖到 Gradle 阶段才暴露
|
- Release 构建前先跑 `flutter analyze` 和 `flutter test`,确保依赖变更不会把问题拖到 Gradle 阶段才暴露
|
||||||
- 遇到 `Expected to find fonts for (packages/cupertino_icons/CupertinoIcons, MaterialIcons)` 时,优先检查 `pubspec.yaml` 是否漏了 `cupertino_icons`;即使业务代码没有显式引用,也可能被图标字体清单间接依赖
|
- 遇到 `Expected to find fonts for (packages/cupertino_icons/CupertinoIcons, MaterialIcons)` 时,优先检查 `pubspec.yaml` 是否漏了 `cupertino_icons`;即使业务代码没有显式引用,也可能被图标字体清单间接依赖
|
||||||
- `flutter build apk --release --android-skip-build-dependency-validation` 在 ARM64 机器上耗时较长,清理或重启 Gradle daemon 能规避旧 daemon 异常退出导致的假失败
|
- `flutter build apk --release --android-skip-build-dependency-validation` 在 ARM64 机器上耗时较长,清理或重启 Gradle daemon 能规避旧 daemon 异常退出导致的假失败
|
||||||
- 最终 APK 统一落到 `configs/downloads/showen-app.apk`,方便交付下载链路复用;本次 release 产物大小约 `51M`
|
- 最终 APK 统一落到 `configs/downloads/showen-app.apk`,方便交付下载链路复用;本次 release 产物大小约 `51M`
|
||||||
|
|
||||||
|
## M1.2 HTTP 集成测试返工教训(2026-07-03)
|
||||||
|
- **测试要先实跑再交付**:m1_2_http.rs 首次交付时本机编不了 aarch64 依赖、没实跑,结果目标机暴露 1 个失败 + 1 个 dead_code warning。编不了也要逐个测试核对前提,不能只靠"看起来对"
|
||||||
|
- **写测试前先核对产品校验(坏前提)**:`test_empty_playlist_does_not_panic` 假设空 playlist 配置能通过 `AppConfig::from_file`,但 config.rs `validate()` 自 init 提交就 `bail!("playlist 不能为空")`。测试的每个 expect/unwrap 前提都要回到产品代码里确认,产品故意拒绝的状态应改测"拒绝行为"(防退化),而不是硬造不可达状态
|
||||||
|
- 测试辅助 struct 里"以后可能用"的方法(如本次的 `sender()`)不要预留;用不上就删,dead_code warning 违反零 warning 铁律
|
||||||
|
## 设备磁盘清理经验(2026-07-03)
|
||||||
|
- 磁盘清理逐项走"依赖检查→du 记录→删除→确认"四步,每步留证据;条件项(如 ~/.nvm)先查进程 + systemd/autostart/crontab 引用再决定
|
||||||
|
- `pgrep -af` / `ps | grep` 远程执行时会命中承载命令的 `bash -c` 自身,必须过滤自匹配再下结论,否则会把"无进程"误判成"有进程"
|
||||||
|
- 只删 target/debug 时用绝对路径精确到 debug 子目录,删后立即 `du + ls` 复核 release;运行中服务健康以"前后同一 PID"为最强证据(说明连重启都没发生过)
|
||||||
|
- journal 是隐形大户(本次 1.5G),`journalctl --vacuum-size=100M` 只清归档段、安全;apt 缓存 `apt-get clean` 即可(392M→24K)
|
||||||
|
- 本次共回收 ~13.4G:Flutter/Android 环境 7.6G + target/debug 3.5G + nvm 1.1G + apt 392M + journal 1.4G,96%→44%
|
||||||
|
- 追加(开发工具清除):deb 装的编辑器(code/cursor)用 `dpkg -l` 确认来源后走 `apt-get purge`,配套删 sources.list.d 源文件和 keyring(Cursor 的 keyring 叫 anysphere.gpg,按 .sources 里 Signed-By 找,别按名字猜);purge 后跑 `autoremove --dry-run` 确认无孤儿依赖;删除有历史数据的工具目录(如 kilo.db)时在交付里显式声明,不能默默带走
|
||||||
|
|||||||
@@ -78,3 +78,6 @@
|
|||||||
- **必须实际运行并截图,不能只看代码**
|
- **必须实际运行并截图,不能只看代码**
|
||||||
- 2026-03-13:补齐 `src/core/tests.rs` 的关键路径覆盖,重点覆盖动态插件 FFI 返回 null 的降级、无效 manifest 跳过、禁用插件消息跳过、无稳定版本回退失败、以及 `Message` 全变体 JSON round-trip
|
- 2026-03-13:补齐 `src/core/tests.rs` 的关键路径覆盖,重点覆盖动态插件 FFI 返回 null 的降级、无效 manifest 跳过、禁用插件消息跳过、无稳定版本回退失败、以及 `Message` 全变体 JSON round-trip
|
||||||
- 2026-03-14:新增 `tests/m1_2_service_manager.rs`,补齐 M1.2 ServiceManager 集成测试基建,覆盖依赖启动顺序、Shutdown 停机、ConfigReloaded/PlayerStatus/WifiResult/StateChanged/PluginReady 广播,以及禁用插件路由跳过
|
- 2026-03-14:新增 `tests/m1_2_service_manager.rs`,补齐 M1.2 ServiceManager 集成测试基建,覆盖依赖启动顺序、Shutdown 停机、ConfigReloaded/PlayerStatus/WifiResult/StateChanged/PluginReady 广播,以及禁用插件路由跳过
|
||||||
|
- 2026-07-02:M1.2 目标机验收(Linux ARM64)。经验:① 跑全量测试前先 `df -h`——ARM 设备 28G 盘 98% 满导致 ld 链接失败(No space left on device),清 target/debug/incremental + 过期 deps 即可,不必 cargo clean 全量重编;② cargo test 默认 fail-fast,验收要加 `--no-fail-fast` 才能拿到全部 target 的统计;③ "从未实跑过的测试"是最大风险源——test_empty_playlist_does_not_panic 的前提(空 playlist 可加载)与 config.rs 自 init 起的校验矛盾,代码评审没发现,一跑就露馅;④ 同步版本用 git bundle + fetch + merge --ff-only 最安全,先确认祖先关系和设备脏文件不在 diff 内
|
||||||
|
- 2026-07-03:M1.2 第二轮(返工后)验收全绿 147/147 零 warning。经验:scp 未提交文件到设备时必须两端 sha256 对账,并在交付中显式标注设备与 git HEAD 的不一致状态,提醒 commit 后重新同步
|
||||||
|
- 2026-07-03:M1.2 设备同步收尾(2a6e14b)。流程定型:先 `git checkout -- <scp目录>` 丢弃临时 scp 版本 → bundle ff 同步(预检祖先关系 + 设备脏文件不在 diff 内)→ 同步后补 cargo check(纯 check 不必停服务)。新规矩:git commit 由老板亲自管,团队一律不 commit
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub type Config = AppConfig;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -17,6 +15,12 @@ pub struct AppConfig {
|
|||||||
pub remote_control: RemoteControlConfig,
|
pub remote_control: RemoteControlConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ble: BleConfig,
|
pub ble: BleConfig,
|
||||||
|
/// 角色元信息 (M2.1 新增,向后兼容旧配置)
|
||||||
|
#[serde(default)]
|
||||||
|
pub character: CharacterConfig,
|
||||||
|
/// AI 对话配置 (M2.1 新增)
|
||||||
|
#[serde(default)]
|
||||||
|
pub ai: AiConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub source_path: PathBuf,
|
pub source_path: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -72,6 +76,7 @@ pub struct PerspectiveCorrectionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ChromaKeyConfig {
|
pub struct ChromaKeyConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -106,6 +111,7 @@ fn default_hsv_max() -> [i32; 3] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrightnessAdjustConfig {
|
pub struct BrightnessAdjustConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -272,8 +278,9 @@ pub struct RemoteControlConfig {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BLE 配网配置(新增)
|
/// BLE 配网配置
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BleConfig {
|
pub struct BleConfig {
|
||||||
#[serde(default = "default_ble_enabled")]
|
#[serde(default = "default_ble_enabled")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -297,6 +304,140 @@ fn default_ble_device_name() -> String {
|
|||||||
"Showen".to_string()
|
"Showen".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 角色元信息 (M2.1) ──
|
||||||
|
|
||||||
|
/// 角色类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CharacterType {
|
||||||
|
#[default]
|
||||||
|
Pet,
|
||||||
|
Human,
|
||||||
|
Singer,
|
||||||
|
/// Live2D 数字人 (渲染策略不同于视频)
|
||||||
|
Live2d,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染类型 (决定显示策略)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum RenderType {
|
||||||
|
/// 视频播放 (OpenCV 播放预渲染 mp4)
|
||||||
|
#[default]
|
||||||
|
Video,
|
||||||
|
/// Live2D 渲染 (Web 前端渲染 Live2D 模型)
|
||||||
|
Live2d,
|
||||||
|
/// 纯文字 (无画面)
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色元信息配置块(内容包的一部分,切角色即切人设)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CharacterConfig {
|
||||||
|
/// 角色显示名 (如 "小汪")
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// 角色类型
|
||||||
|
#[serde(default)]
|
||||||
|
pub character_type: CharacterType,
|
||||||
|
/// 渲染类型 (决定显示策略)
|
||||||
|
#[serde(default)]
|
||||||
|
pub render_type: RenderType,
|
||||||
|
/// 封面图相对路径 (内容包内)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
/// 人设 prompt (LLM system prompt)
|
||||||
|
#[serde(default)]
|
||||||
|
pub persona_prompt: String,
|
||||||
|
/// 最大回复 token 数
|
||||||
|
#[serde(default = "default_character_max_tokens")]
|
||||||
|
pub max_tokens: u16,
|
||||||
|
/// TTS 音色标识 (piper 模型 ID,留空用默认)
|
||||||
|
#[serde(default)]
|
||||||
|
pub tts_voice: Option<String>,
|
||||||
|
/// talk 状态名 (语音回合期间切换到,留空用 "talk")
|
||||||
|
#[serde(default = "default_talk_state")]
|
||||||
|
pub talk_state: String,
|
||||||
|
/// Live2D 模型路径 (render_type=live2d 时使用)
|
||||||
|
#[serde(default)]
|
||||||
|
pub live2d_model: Option<String>,
|
||||||
|
/// Live2D 模型配置 JSON 路径
|
||||||
|
#[serde(default)]
|
||||||
|
pub live2d_config: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_character_max_tokens() -> u16 {
|
||||||
|
128
|
||||||
|
}
|
||||||
|
fn default_talk_state() -> String {
|
||||||
|
"talk".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI 配置 (M2.1) ──
|
||||||
|
|
||||||
|
/// LLM 后端类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LlmBackend {
|
||||||
|
#[default]
|
||||||
|
Local,
|
||||||
|
/// 云端 (V1 仅占位,配置时返回"暂未支持")
|
||||||
|
Cloud,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 推理配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AiConfig {
|
||||||
|
/// LLM 后端 (local/cloud)
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: LlmBackend,
|
||||||
|
/// 云端 endpoint (预留,V1 不实现)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cloud_endpoint: Option<String>,
|
||||||
|
/// 云端 API key (预留)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cloud_api_key: Option<String>,
|
||||||
|
/// 模型存储目录 (默认 model_store/)
|
||||||
|
#[serde(default = "default_model_store")]
|
||||||
|
pub model_store: PathBuf,
|
||||||
|
/// 工具目录 (whisper-cli/llama-cli/piper 二进制所在)
|
||||||
|
#[serde(default = "default_tools_dir")]
|
||||||
|
pub tools_dir: PathBuf,
|
||||||
|
/// whisper-cli 依赖库路径 (libggml*.so 所在,部署时标定)
|
||||||
|
#[serde(default)]
|
||||||
|
pub whisper_lib_dir: Option<PathBuf>,
|
||||||
|
/// piper 依赖库路径 (libespeak-ng.so / libpiper_phonemize.so 所在)
|
||||||
|
#[serde(default)]
|
||||||
|
pub piper_lib_dir: Option<PathBuf>,
|
||||||
|
/// piper 模型 config json 路径 (.onnx.json)
|
||||||
|
#[serde(default)]
|
||||||
|
pub piper_config: Option<PathBuf>,
|
||||||
|
/// piper espeak-ng-data 目录路径
|
||||||
|
#[serde(default)]
|
||||||
|
pub espeak_data_dir: Option<PathBuf>,
|
||||||
|
/// 临时文件目录
|
||||||
|
#[serde(default = "default_tmp_dir")]
|
||||||
|
pub tmp_dir: PathBuf,
|
||||||
|
/// 上下文窗口轮数
|
||||||
|
#[serde(default = "default_context_window")]
|
||||||
|
pub context_window: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_model_store() -> PathBuf {
|
||||||
|
PathBuf::from("model_store")
|
||||||
|
}
|
||||||
|
fn default_tools_dir() -> PathBuf {
|
||||||
|
PathBuf::from("tools")
|
||||||
|
}
|
||||||
|
fn default_tmp_dir() -> PathBuf {
|
||||||
|
PathBuf::from("/tmp/showen_ai")
|
||||||
|
}
|
||||||
|
fn default_context_window() -> usize {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
// ── 加载与验证 ──
|
// ── 加载与验证 ──
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -443,14 +584,16 @@ impl PerspectiveCorrectionConfig {
|
|||||||
|
|
||||||
impl ChromaKeyConfig {
|
impl ChromaKeyConfig {
|
||||||
pub fn validate(&self) -> Result<()> {
|
pub fn validate(&self) -> Result<()> {
|
||||||
validate_hsv_component("display.chroma_key.hsv_min[0]", self.hsv_min[0], 0, 180)?;
|
let hsv_ranges = [180, 255, 255]; // H, S, V max values
|
||||||
validate_hsv_component("display.chroma_key.hsv_min[1]", self.hsv_min[1], 0, 255)?;
|
for (index, &max_val) in hsv_ranges.iter().enumerate() {
|
||||||
validate_hsv_component("display.chroma_key.hsv_min[2]", self.hsv_min[2], 0, 255)?;
|
validate_hsv_component(
|
||||||
validate_hsv_component("display.chroma_key.hsv_max[0]", self.hsv_max[0], 0, 180)?;
|
&format!("display.chroma_key.hsv_min[{index}]"),
|
||||||
validate_hsv_component("display.chroma_key.hsv_max[1]", self.hsv_max[1], 0, 255)?;
|
self.hsv_min[index], 0, max_val,
|
||||||
validate_hsv_component("display.chroma_key.hsv_max[2]", self.hsv_max[2], 0, 255)?;
|
)?;
|
||||||
|
validate_hsv_component(
|
||||||
for index in 0..3 {
|
&format!("display.chroma_key.hsv_max[{index}]"),
|
||||||
|
self.hsv_max[index], 0, max_val,
|
||||||
|
)?;
|
||||||
if self.hsv_min[index] > self.hsv_max[index] {
|
if self.hsv_min[index] > self.hsv_max[index] {
|
||||||
bail!("display.chroma_key hsv_min[{index}] 不能大于 hsv_max[{index}]");
|
bail!("display.chroma_key hsv_min[{index}] 不能大于 hsv_max[{index}]");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||||
|
use crate::core::plugin_ids;
|
||||||
|
|
||||||
/// 命令解析结果
|
/// 命令解析结果
|
||||||
pub struct DispatchResult {
|
pub struct DispatchResult {
|
||||||
@@ -137,7 +138,7 @@ fn ok_video(from: &str, message: Message) -> Result<DispatchResult, String> {
|
|||||||
Ok(DispatchResult {
|
Ok(DispatchResult {
|
||||||
envelope: Envelope {
|
envelope: Envelope {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
to: Destination::Plugin("video".to_string()),
|
to: Destination::Plugin(plugin_ids::VIDEO.to_string()),
|
||||||
message,
|
message,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -147,7 +148,7 @@ fn ok_wifi(from: &str, message: Message) -> Result<DispatchResult, String> {
|
|||||||
Ok(DispatchResult {
|
Ok(DispatchResult {
|
||||||
envelope: Envelope {
|
envelope: Envelope {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
to: Destination::Plugin("wifi".to_string()),
|
to: Destination::Plugin(plugin_ids::WIFI.to_string()),
|
||||||
message,
|
message,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub struct Envelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 消息目的地
|
/// 消息目的地
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Destination {
|
pub enum Destination {
|
||||||
/// 点对点发送给指定插件
|
/// 点对点发送给指定插件
|
||||||
Plugin(String),
|
Plugin(String),
|
||||||
@@ -61,6 +61,14 @@ pub enum Message {
|
|||||||
DeviceResponse(DeviceResponse),
|
DeviceResponse(DeviceResponse),
|
||||||
DeviceEvent(DeviceEvent),
|
DeviceEvent(DeviceEvent),
|
||||||
|
|
||||||
|
// ── AI 语音对话 (M2.1) ──
|
||||||
|
/// 语音/文字对话回合请求(HTTP → AI 插件)
|
||||||
|
ChatRequest(ChatRequest),
|
||||||
|
/// 对话回合响应(AI 插件 → HTTP/广播)
|
||||||
|
ChatResponse(ChatResponse),
|
||||||
|
/// AI 模型状态变更广播(加载/切换/下载进度)
|
||||||
|
AiModelEvent(AiModelEvent),
|
||||||
|
|
||||||
// ── 扩展(未来插件用) ──
|
// ── 扩展(未来插件用) ──
|
||||||
Custom {
|
Custom {
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -68,7 +76,63 @@ pub enum Message {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 对话回合请求
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
/// 会话标识(同一会话保留上下文)
|
||||||
|
pub session_id: String,
|
||||||
|
/// 输入类型:文字或音频文件路径(设备临时文件)
|
||||||
|
pub input: ChatInput,
|
||||||
|
/// 当前角色人设 prompt
|
||||||
|
pub persona_prompt: String,
|
||||||
|
/// 最大回复 token 数(0 表示用默认)
|
||||||
|
pub max_tokens: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum ChatInput {
|
||||||
|
/// 文字输入(Web 端主路径)
|
||||||
|
Text { content: String },
|
||||||
|
/// 音频文件路径(App 上传后的临时文件,16kHz mono wav)
|
||||||
|
Audio { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对话回合响应
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatResponse {
|
||||||
|
pub session_id: String,
|
||||||
|
/// ASR 转写文本(音频输入时,文字输入时为 None)
|
||||||
|
pub transcription: Option<String>,
|
||||||
|
/// LLM 回复文本
|
||||||
|
pub reply_text: String,
|
||||||
|
/// TTS 回复音频文件路径(设备临时文件,HTTP 返回后由客户端播放)
|
||||||
|
pub reply_audio_path: Option<String>,
|
||||||
|
/// 错误信息(非空表示失败)
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 模型事件
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
|
pub enum AiModelEvent {
|
||||||
|
/// 模型加载中
|
||||||
|
Loading { model_id: String },
|
||||||
|
/// 模型已就绪
|
||||||
|
Ready { model_id: String },
|
||||||
|
/// 模型切换
|
||||||
|
Switched { old: String, new: String },
|
||||||
|
/// 下载进度
|
||||||
|
DownloadProgress {
|
||||||
|
model_id: String,
|
||||||
|
downloaded: u64,
|
||||||
|
total: u64,
|
||||||
|
},
|
||||||
|
/// 错误
|
||||||
|
Error { model_id: String, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum PlayerCommand {
|
pub enum PlayerCommand {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -78,7 +142,7 @@ pub enum PlayerCommand {
|
|||||||
ChangeScene(String),
|
ChangeScene(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PlayerStatusData {
|
pub struct PlayerStatusData {
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
@@ -88,7 +152,7 @@ pub struct PlayerStatusData {
|
|||||||
pub current_video: Option<String>,
|
pub current_video: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum WifiCommand {
|
pub enum WifiCommand {
|
||||||
Scan,
|
Scan,
|
||||||
Connect { ssid: String, password: String },
|
Connect { ssid: String, password: String },
|
||||||
@@ -100,7 +164,7 @@ pub enum WifiCommand {
|
|||||||
// ── 设备管理 ──
|
// ── 设备管理 ──
|
||||||
|
|
||||||
/// 像素格式
|
/// 像素格式
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum PixelFormat {
|
pub enum PixelFormat {
|
||||||
/// RGBA 8888 格式(每像素 4 字节)
|
/// RGBA 8888 格式(每像素 4 字节)
|
||||||
RGBA8888,
|
RGBA8888,
|
||||||
@@ -111,7 +175,7 @@ pub enum PixelFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 传感器类型
|
/// 传感器类型
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum SensorType {
|
pub enum SensorType {
|
||||||
/// 温度传感器
|
/// 温度传感器
|
||||||
Temperature,
|
Temperature,
|
||||||
@@ -124,7 +188,7 @@ pub enum SensorType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 触摸动作
|
/// 触摸动作
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum TouchAction {
|
pub enum TouchAction {
|
||||||
/// 按下
|
/// 按下
|
||||||
Down,
|
Down,
|
||||||
@@ -135,7 +199,7 @@ pub enum TouchAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 设备能力
|
/// 设备能力
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DeviceCapability {
|
pub enum DeviceCapability {
|
||||||
/// 显示屏
|
/// 显示屏
|
||||||
Display,
|
Display,
|
||||||
|
|||||||
@@ -9,5 +9,15 @@ pub mod plugin_repo;
|
|||||||
pub mod service_manager;
|
pub mod service_manager;
|
||||||
pub mod version_manager;
|
pub mod version_manager;
|
||||||
|
|
||||||
|
/// 内置插件 ID 常量
|
||||||
|
pub mod plugin_ids {
|
||||||
|
pub const VIDEO: &str = "video";
|
||||||
|
pub const HTTP: &str = "http";
|
||||||
|
pub const WIFI: &str = "wifi";
|
||||||
|
pub const BLE: &str = "ble";
|
||||||
|
pub const DEVICE: &str = "device";
|
||||||
|
pub const SCREEN: &str = "screen";
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::sync::{mpsc, Arc};
|
use std::sync::{mpsc, Arc};
|
||||||
|
|
||||||
/// 单项能力测试结果
|
/// 单项能力测试结果
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CapabilityTestResult {
|
pub struct CapabilityTestResult {
|
||||||
pub capability: String,
|
pub capability: String,
|
||||||
pub passed: bool,
|
pub passed: bool,
|
||||||
@@ -56,7 +56,7 @@ pub trait Plugin: Send {
|
|||||||
fn stop(&mut self) -> Result<()>;
|
fn stop(&mut self) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PluginInfo {
|
pub struct PluginInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ fn default_test_timeout() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 插件错误处理策略
|
/// 插件错误处理策略
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ErrorPolicy {
|
pub enum ErrorPolicy {
|
||||||
/// 自动回退到上一个稳定版本
|
/// 自动回退到上一个稳定版本
|
||||||
|
|||||||
@@ -358,9 +358,12 @@ impl ServiceManager {
|
|||||||
self.running = true;
|
self.running = true;
|
||||||
|
|
||||||
while self.running {
|
while self.running {
|
||||||
let envelope = match self.rx.recv() {
|
// 使用 recv_timeout 而非 recv,确保外部信号(如 Ctrl+C 置 running=false)
|
||||||
|
// 能在超时后被检查到,避免永久阻塞等待下一条消息。
|
||||||
|
let envelope = match self.rx.recv_timeout(std::time::Duration::from_millis(200)) {
|
||||||
Ok(env) => env,
|
Ok(env) => env,
|
||||||
Err(_) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
println!("[ServiceManager] 所有发送端已关闭,退出");
|
println!("[ServiceManager] 所有发送端已关闭,退出");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -727,16 +730,24 @@ impl ServiceManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_plugin_updates(&self) -> Result<()> {
|
fn check_plugin_updates(&self) -> Result<Vec<(String, String, String)>> {
|
||||||
let repo = self.plugin_repository()?;
|
let repo = self.plugin_repository()?;
|
||||||
let registry = self.plugin_loader()?.load_registry()?;
|
let registry = self.plugin_loader()?.load_registry()?;
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
for (plugin_id, entry) in ®istry.plugins {
|
for (plugin_id, entry) in ®istry.plugins {
|
||||||
match repo.check_update(plugin_id, &entry.active_version)? {
|
match repo.check_update(plugin_id, &entry.active_version)? {
|
||||||
Some(version) => println!(
|
Some(version) => {
|
||||||
|
println!(
|
||||||
"[ServiceManager] 插件 '{plugin_id}' 发现可用更新: {} -> {version}",
|
"[ServiceManager] 插件 '{plugin_id}' 发现可用更新: {} -> {version}",
|
||||||
entry.active_version
|
entry.active_version
|
||||||
),
|
);
|
||||||
|
updates.push((
|
||||||
|
plugin_id.clone(),
|
||||||
|
entry.active_version.clone(),
|
||||||
|
version,
|
||||||
|
));
|
||||||
|
}
|
||||||
None => println!(
|
None => println!(
|
||||||
"[ServiceManager] 插件 '{plugin_id}' 已是最新版本 {}",
|
"[ServiceManager] 插件 '{plugin_id}' 已是最新版本 {}",
|
||||||
entry.active_version
|
entry.active_version
|
||||||
@@ -744,7 +755,7 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn broadcast_plugin_states(&mut self) {
|
fn broadcast_plugin_states(&mut self) {
|
||||||
@@ -763,6 +774,7 @@ impl ServiceManager {
|
|||||||
Message::Shutdown => {
|
Message::Shutdown => {
|
||||||
println!("[ServiceManager] 收到 Shutdown 指令");
|
println!("[ServiceManager] 收到 Shutdown 指令");
|
||||||
self.broadcast_message(Message::Shutdown);
|
self.broadcast_message(Message::Shutdown);
|
||||||
|
self.running = false;
|
||||||
}
|
}
|
||||||
Message::WifiResult(payload) => {
|
Message::WifiResult(payload) => {
|
||||||
self.broadcast_message(Message::WifiResult(payload));
|
self.broadcast_message(Message::WifiResult(payload));
|
||||||
@@ -861,9 +873,31 @@ impl ServiceManager {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
"plugin_check_updates" => {
|
"plugin_check_updates" => {
|
||||||
if let Err(error) = self.check_plugin_updates() {
|
match self.check_plugin_updates() {
|
||||||
|
Ok(updates) => {
|
||||||
|
// 将检查结果通过 Custom 消息广播,供 HTTP 客户端等消费方获取
|
||||||
|
let payload = serde_json::to_string(
|
||||||
|
&updates
|
||||||
|
.iter()
|
||||||
|
.map(|(id, cur, new)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"current_version": cur,
|
||||||
|
"available_version": new,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
self.broadcast_message(Message::Custom {
|
||||||
|
kind: "plugin_updates".to_string(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
eprintln!("[ServiceManager] plugin_check_updates 失败: {error}");
|
eprintln!("[ServiceManager] plugin_check_updates 失败: {error}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -1137,7 +1171,7 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 插件状态信息(用于 API 查询)
|
/// 插件状态信息(用于 API 查询)
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PluginStateInfo {
|
pub struct PluginStateInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub info: crate::core::plugin::PluginInfo,
|
pub info: crate::core::plugin::PluginInfo,
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ fn message_label(message: &Message) -> String {
|
|||||||
Message::DeviceCommand(_) => "device_command".to_string(),
|
Message::DeviceCommand(_) => "device_command".to_string(),
|
||||||
Message::DeviceResponse(_) => "device_response".to_string(),
|
Message::DeviceResponse(_) => "device_response".to_string(),
|
||||||
Message::DeviceEvent(_) => "device_event".to_string(),
|
Message::DeviceEvent(_) => "device_event".to_string(),
|
||||||
|
Message::ChatRequest(_) => "chat_request".to_string(),
|
||||||
|
Message::ChatResponse(_) => "chat_response".to_string(),
|
||||||
|
Message::AiModelEvent(_) => "ai_model_event".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
src/main.rs
44
src/main.rs
@@ -5,7 +5,7 @@ use showen_v2::core::service_manager::ServiceManager;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use showen_v2::core::version_manager::VersionManager;
|
use showen_v2::core::version_manager::VersionManager;
|
||||||
use showen_v2::plugins::{
|
use showen_v2::plugins::{
|
||||||
ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin,
|
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin,
|
||||||
video::VideoPlugin, wifi::WifiPlugin,
|
video::VideoPlugin, wifi::WifiPlugin,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -46,10 +46,20 @@ fn main() -> Result<()> {
|
|||||||
let config = AppConfig::from_file(&config_path)?;
|
let config = AppConfig::from_file(&config_path)?;
|
||||||
config.validate_paths()?;
|
config.validate_paths()?;
|
||||||
|
|
||||||
|
// 在 config move 进 ServiceManager 之前,提取 AI 相关配置
|
||||||
|
let ai_model_store = config.ai.model_store.clone();
|
||||||
|
let ai_tools_dir = config.ai.tools_dir.clone();
|
||||||
|
let ai_whisper_lib_dir = config.ai.whisper_lib_dir.clone();
|
||||||
|
let ai_piper_lib_dir = config.ai.piper_lib_dir.clone();
|
||||||
|
let ai_piper_config = config.ai.piper_config.clone();
|
||||||
|
let ai_espeak_data_dir = config.ai.espeak_data_dir.clone();
|
||||||
|
let ai_tmp_dir = config.ai.tmp_dir.clone();
|
||||||
|
let ai_context_window = config.ai.context_window;
|
||||||
|
|
||||||
let mut manager = ServiceManager::new(config);
|
let mut manager = ServiceManager::new(config);
|
||||||
|
|
||||||
// 按依赖顺序注册插件
|
// 按依赖顺序注册插件
|
||||||
// 独立插件:device, screen, wifi, video, ble
|
// 独立插件:device, screen, wifi, video, ble, ai
|
||||||
// 依赖插件:http (依赖 video)
|
// 依赖插件:http (依赖 video)
|
||||||
|
|
||||||
println!("注册插件...");
|
println!("注册插件...");
|
||||||
@@ -68,7 +78,28 @@ fn main() -> Result<()> {
|
|||||||
manager.register(Box::new(BlePlugin::new()));
|
manager.register(Box::new(BlePlugin::new()));
|
||||||
println!(" ✓ BlePlugin");
|
println!(" ✓ BlePlugin");
|
||||||
|
|
||||||
manager.register(Box::new(HttpPlugin::new()));
|
// AiPlugin: 需要在注册前创建实例以获取 pipeline 句柄,再注册进 manager
|
||||||
|
// pipeline 会传给 HttpPlugin,建立 HTTP → AI 的直接通道
|
||||||
|
let ai_plugin = AiPlugin::new_default(
|
||||||
|
ai_model_store,
|
||||||
|
ai_tools_dir,
|
||||||
|
ai_whisper_lib_dir,
|
||||||
|
ai_piper_lib_dir,
|
||||||
|
ai_piper_config,
|
||||||
|
ai_espeak_data_dir,
|
||||||
|
ai_tmp_dir,
|
||||||
|
ai_context_window,
|
||||||
|
);
|
||||||
|
let ai_pipeline = ai_plugin.pipeline();
|
||||||
|
let ai_models = ai_plugin.models();
|
||||||
|
manager.register(Box::new(ai_plugin));
|
||||||
|
println!(" ✓ AiPlugin");
|
||||||
|
|
||||||
|
let mut http_plugin = HttpPlugin::new();
|
||||||
|
// 注册 AI 句柄到 HttpState(在 http init 之前建立连接)
|
||||||
|
http_plugin.set_ai_pipeline(ai_pipeline);
|
||||||
|
http_plugin.set_ai_models(ai_models);
|
||||||
|
manager.register(Box::new(http_plugin));
|
||||||
println!(" ✓ HttpPlugin");
|
println!(" ✓ HttpPlugin");
|
||||||
|
|
||||||
// 加载动态插件
|
// 加载动态插件
|
||||||
@@ -113,10 +144,17 @@ fn main() -> Result<()> {
|
|||||||
// 设置 Ctrl+C 信号处理
|
// 设置 Ctrl+C 信号处理
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let r = running.clone();
|
let r = running.clone();
|
||||||
|
let shutdown_tx = manager.sender();
|
||||||
|
|
||||||
ctrlc::set_handler(move || {
|
ctrlc::set_handler(move || {
|
||||||
println!("\n收到退出信号,正在关闭...");
|
println!("\n收到退出信号,正在关闭...");
|
||||||
r.store(false, Ordering::SeqCst);
|
r.store(false, Ordering::SeqCst);
|
||||||
|
// 注入 Shutdown 消息,唤醒主消息循环并触发优雅停机
|
||||||
|
let _ = shutdown_tx.send(showen_v2::core::message::Envelope {
|
||||||
|
from: "signal".to_string(),
|
||||||
|
to: showen_v2::core::message::Destination::Manager,
|
||||||
|
message: showen_v2::core::message::Message::Shutdown,
|
||||||
|
});
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!("启动所有插件...");
|
println!("启动所有插件...");
|
||||||
|
|||||||
376
src/plugins/ai/backend.rs
Normal file
376
src/plugins/ai/backend.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
//! AI 后端 trait + 本地命令行实现
|
||||||
|
//!
|
||||||
|
//! 抽象 ASR/LLM/TTS 三层,支持本地命令行(LocalCliBackend)和未来云端后端。
|
||||||
|
//! V1 只实现 local;cloud 配置时返回"暂未支持"错误。
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// AI 后端抽象 trait
|
||||||
|
pub trait AiBackend: Send {
|
||||||
|
/// 语音转文字 (ASR)
|
||||||
|
/// - audio_path: 16kHz mono wav 临时文件
|
||||||
|
/// - model_path: whisper.cpp 模型 (ggml-tiny.bin 等)
|
||||||
|
fn asr(&self, audio_path: &Path, model_path: &Path) -> Result<String>;
|
||||||
|
|
||||||
|
/// LLM 对话
|
||||||
|
/// - model_path: llama.cpp GGUF 模型
|
||||||
|
/// - persona: 角色人设 system prompt
|
||||||
|
/// - user_message: 本轮用户输入
|
||||||
|
/// - history: 历史轮次 (role, content)
|
||||||
|
/// - max_tokens: 最大回复 token (0=默认 128)
|
||||||
|
fn llm_chat(
|
||||||
|
&self,
|
||||||
|
model_path: &Path,
|
||||||
|
persona: &str,
|
||||||
|
user_message: &str,
|
||||||
|
history: &[(String, String)],
|
||||||
|
max_tokens: u16,
|
||||||
|
) -> Result<String>;
|
||||||
|
|
||||||
|
/// 文字转语音 (TTS)
|
||||||
|
/// - text: 要合成的文本
|
||||||
|
/// - model_path: piper 模型目录/文件
|
||||||
|
/// - output_path: 输出 wav 文件路径
|
||||||
|
fn tts(&self, text: &str, model_path: &Path, output_path: &Path) -> Result<()>;
|
||||||
|
|
||||||
|
/// 后端标识(local / cloud)
|
||||||
|
fn backend_name(&self) -> &str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地命令行后端
|
||||||
|
///
|
||||||
|
/// 通过子进程调用 whisper-cli / llama-cli / piper 二进制。
|
||||||
|
/// Spike (2026-07-03) 验证的命令行参数固化于此。
|
||||||
|
pub struct LocalCliBackend {
|
||||||
|
/// 工具目录(含 whisper-cli, llama-cli, piper 二进制)
|
||||||
|
tools_dir: PathBuf,
|
||||||
|
/// whisper-cli 依赖的库路径(libggml*.so 所在)
|
||||||
|
whisper_lib_dir: Option<PathBuf>,
|
||||||
|
/// piper 依赖的库路径(libespeak-ng.so / libpiper_phonemize.so 所在)
|
||||||
|
piper_lib_dir: Option<PathBuf>,
|
||||||
|
/// piper 模型 config json 路径(.onnx.json)
|
||||||
|
piper_config: Option<PathBuf>,
|
||||||
|
/// piper espeak-ng-data 目录路径
|
||||||
|
espeak_data_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalCliBackend {
|
||||||
|
pub fn new(tools_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
tools_dir,
|
||||||
|
whisper_lib_dir: None,
|
||||||
|
piper_lib_dir: None,
|
||||||
|
piper_config: None,
|
||||||
|
espeak_data_dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 whisper-cli 依赖库路径(部署时由 deploy_ai.sh 标定)
|
||||||
|
pub fn with_whisper_lib_dir(mut self, lib_dir: PathBuf) -> Self {
|
||||||
|
self.whisper_lib_dir = Some(lib_dir);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 piper 依赖(库路径、config、espeak-ng-data 目录)
|
||||||
|
pub fn with_piper_deps(mut self, lib_dir: PathBuf, config: PathBuf, espeak_data: PathBuf) -> Self {
|
||||||
|
self.piper_lib_dir = Some(lib_dir);
|
||||||
|
self.piper_config = Some(config);
|
||||||
|
self.espeak_data_dir = Some(espeak_data);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whisper_cli(&self) -> PathBuf {
|
||||||
|
self.tools_dir.join("whisper-cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llama_cli(&self) -> PathBuf {
|
||||||
|
self.tools_dir.join("llama-cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn piper(&self) -> PathBuf {
|
||||||
|
self.tools_dir.join("piper")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给命令设置 LD_LIBRARY_PATH(合并所有库路径)
|
||||||
|
fn with_lib_path(&self, cmd: &mut Command, lib_dir: &Path) {
|
||||||
|
let existing = std::env::var("LD_LIBRARY_PATH").unwrap_or_default();
|
||||||
|
let new_path = if existing.is_empty() {
|
||||||
|
lib_dir.to_string_lossy().into_owned()
|
||||||
|
} else {
|
||||||
|
format!("{}:{}", lib_dir.to_string_lossy(), existing)
|
||||||
|
};
|
||||||
|
cmd.env("LD_LIBRARY_PATH", new_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiBackend for LocalCliBackend {
|
||||||
|
fn asr(&self, audio_path: &Path, model_path: &Path) -> Result<String> {
|
||||||
|
// whisper-cli -m <model> -f <audio> -l zh --no-timestamps -otxt -of <tmp>
|
||||||
|
let tmp_out = audio_path.with_extension("asr.txt");
|
||||||
|
let mut cmd = Command::new(self.whisper_cli());
|
||||||
|
if let Some(lib_dir) = &self.whisper_lib_dir {
|
||||||
|
self.with_lib_path(&mut cmd, lib_dir);
|
||||||
|
}
|
||||||
|
let output = cmd
|
||||||
|
.arg("-m")
|
||||||
|
.arg(model_path)
|
||||||
|
.arg("-f")
|
||||||
|
.arg(audio_path)
|
||||||
|
.arg("-l")
|
||||||
|
.arg("zh")
|
||||||
|
.arg("--no-timestamps")
|
||||||
|
.arg("-otxt")
|
||||||
|
.arg("-of")
|
||||||
|
.arg(&tmp_out)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"whisper-cli 失败: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let txt_path = tmp_out.with_extension("txt");
|
||||||
|
let text = std::fs::read_to_string(&txt_path)?;
|
||||||
|
let _ = std::fs::remove_file(&txt_path);
|
||||||
|
Ok(text.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_chat(
|
||||||
|
&self,
|
||||||
|
model_path: &Path,
|
||||||
|
persona: &str,
|
||||||
|
user_message: &str,
|
||||||
|
history: &[(String, String)],
|
||||||
|
max_tokens: u16,
|
||||||
|
) -> Result<String> {
|
||||||
|
// 对齐 spike (2026-07-03) 验证的调用方式:
|
||||||
|
// llama-cli --single-turn --no-warmup -sys <persona> -p <user_msg> -n <tokens> -t 2 -c 1024 --temp 0.7
|
||||||
|
//
|
||||||
|
// 关键参数:
|
||||||
|
// --single-turn 单轮模式,生成完即退出(否则进交互模式等待 stdin,永远不退出)
|
||||||
|
// --no-warmup 跳过预热(减少延迟)
|
||||||
|
// -sys system prompt(角色人设)
|
||||||
|
// -p 用户当前消息(V1 不带历史,避免模型回显历史内容)
|
||||||
|
//
|
||||||
|
// V1 边界:会话内上下文由 ChatPipeline 的 sessions 维护,
|
||||||
|
// 但 llama-cli --single-turn 模式每次是独立调用,
|
||||||
|
// 历史拼接会导致模型把历史当输入回显。V1 简化为无历史单轮对话。
|
||||||
|
let _ = history; // V1 不使用历史,参数保留供未来扩展
|
||||||
|
let tokens = if max_tokens == 0 { 128 } else { max_tokens as u32 };
|
||||||
|
|
||||||
|
let output = Command::new(self.llama_cli())
|
||||||
|
.arg("--single-turn")
|
||||||
|
.arg("--no-warmup")
|
||||||
|
.arg("-m")
|
||||||
|
.arg(model_path)
|
||||||
|
.arg("-sys")
|
||||||
|
.arg(persona)
|
||||||
|
.arg("-p")
|
||||||
|
.arg(user_message)
|
||||||
|
.arg("-n")
|
||||||
|
.arg(tokens.to_string())
|
||||||
|
.arg("-t")
|
||||||
|
.arg("2") // 锁大核 (spike 结论: t=2 最优)
|
||||||
|
.arg("-c")
|
||||||
|
.arg("1024") // 限死上下文 (spike 警告: 默认 ctx 吃 1-3.3G)
|
||||||
|
.arg("--temp")
|
||||||
|
.arg("0.7")
|
||||||
|
.arg("--no-display-prompt")
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"llama-cli 失败 (exit={}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// llama-cli --single-turn 输出格式:
|
||||||
|
// [banner/logo]
|
||||||
|
// > <user_input> ← 用户输入回显
|
||||||
|
// [进度动画 |/-\]
|
||||||
|
// <生成的回复> ← 我们要提取的部分
|
||||||
|
// [空行]
|
||||||
|
// [ Prompt: ... | Generation: ... ] ← 性能统计
|
||||||
|
//
|
||||||
|
// 解析策略:找到 "> " 开头的行,跳过进度动画行,取实际回复行
|
||||||
|
let raw = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let reply = parse_llama_output(&raw);
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tts(&self, text: &str, model_path: &Path, output_path: &Path) -> Result<()> {
|
||||||
|
// echo <text> | piper -m <model> [-c <config>] -f <output> --output_format wav
|
||||||
|
// piper 依赖 libespeak-ng.so / libpiper_phonemize.so,需设 LD_LIBRARY_PATH
|
||||||
|
// piper 需要 ESPEAK_DATA_PATH 指向 espeak-ng-data 目录
|
||||||
|
let mut cmd = Command::new(self.piper());
|
||||||
|
if let Some(lib_dir) = &self.piper_lib_dir {
|
||||||
|
self.with_lib_path(&mut cmd, lib_dir);
|
||||||
|
}
|
||||||
|
if let Some(espeak_data) = &self.espeak_data_dir {
|
||||||
|
cmd.env("ESPEAK_DATA_PATH", espeak_data);
|
||||||
|
}
|
||||||
|
cmd.arg("-m")
|
||||||
|
.arg(model_path)
|
||||||
|
.arg("-f")
|
||||||
|
.arg(output_path)
|
||||||
|
.arg("--output_format")
|
||||||
|
.arg("wav");
|
||||||
|
|
||||||
|
// piper 需要 -c 指定 .onnx.json config 文件
|
||||||
|
if let Some(config) = &self.piper_config {
|
||||||
|
cmd.arg("-c").arg(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
use std::io::Write;
|
||||||
|
stdin.write_all(text.as_bytes())?;
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(
|
||||||
|
"piper 失败 (exit={}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &str {
|
||||||
|
"local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云端后端(V1 仅占位,返回"暂未支持")
|
||||||
|
pub struct CloudBackend;
|
||||||
|
|
||||||
|
impl AiBackend for CloudBackend {
|
||||||
|
fn asr(&self, _audio_path: &Path, _model_path: &Path) -> Result<String> {
|
||||||
|
bail!("云端 ASR 暂未支持 (V1 仅实现 local)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_chat(
|
||||||
|
&self,
|
||||||
|
_model_path: &Path,
|
||||||
|
_persona: &str,
|
||||||
|
_user_message: &str,
|
||||||
|
_history: &[(String, String)],
|
||||||
|
_max_tokens: u16,
|
||||||
|
) -> Result<String> {
|
||||||
|
bail!("云端 LLM 暂未支持 (V1 仅实现 local)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tts(&self, _text: &str, _model_path: &Path, _output_path: &Path) -> Result<()> {
|
||||||
|
bail!("云端 TTS 暂未支持 (V1 仅实现 local)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &str {
|
||||||
|
"cloud"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 llama-cli --single-turn 的 stdout 输出,提取生成的回复文本
|
||||||
|
///
|
||||||
|
/// 输出格式:
|
||||||
|
/// ```text
|
||||||
|
/// [banner/logo ASCII art]
|
||||||
|
/// build : ...
|
||||||
|
/// model : ...
|
||||||
|
/// ...
|
||||||
|
/// > <user_input>
|
||||||
|
///
|
||||||
|
/// [进度动画 |/-\]
|
||||||
|
/// <生成的回复>
|
||||||
|
///
|
||||||
|
/// [ Prompt: ... | Generation: ... ]
|
||||||
|
///
|
||||||
|
/// Exiting...
|
||||||
|
/// ```
|
||||||
|
fn parse_llama_output(raw: &str) -> String {
|
||||||
|
let lines: Vec<&str> = raw.lines().collect();
|
||||||
|
let mut reply_lines = Vec::new();
|
||||||
|
let mut after_user_input = false;
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// 跳过空行(但在回复区内保留)
|
||||||
|
if trimmed.is_empty() && !after_user_input {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到用户输入回显行 "> xxx"
|
||||||
|
if trimmed.starts_with('>') {
|
||||||
|
after_user_input = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !after_user_input {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遇到性能统计行 [ Prompt: ... ] 停止
|
||||||
|
if trimmed.starts_with('[') && trimmed.contains("Generation") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 遇到 "Exiting..." 停止
|
||||||
|
if trimmed == "Exiting..." {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理进度动画字符 |/-\ 和退格符
|
||||||
|
// llama-cli 的进度动画用退格符 \u{8} 刷新,stdout 捕获后保留为字面退格或 \b 转义
|
||||||
|
let cleaned: String = trimmed
|
||||||
|
.replace("\\b", "") // 字面 \b 转义序列
|
||||||
|
.replace("\u{8}", "") // 真正的退格符
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !"|\\-/".contains(*c))
|
||||||
|
.collect::<String>();
|
||||||
|
let cleaned_str = cleaned.trim();
|
||||||
|
if cleaned_str.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply_lines.push(cleaned_str.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
reply_lines.join("\n").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_llama_output() {
|
||||||
|
// 进度动画和回复在同一行,用真正的退格符 \u{8} 分隔
|
||||||
|
let bs = "\u{8}"; // backspace
|
||||||
|
let raw = format!(
|
||||||
|
" build : b1-fdb1db8\n\
|
||||||
|
model : model_store/llm/qwen2.5-0.5b-q4_k_m.gguf\n\
|
||||||
|
\n\
|
||||||
|
> hello\n\
|
||||||
|
\n\
|
||||||
|
{bs}-{bs}|{bs}/{bs}-{bs}|{bs}/{bs} 汪!\n\
|
||||||
|
\n\
|
||||||
|
[ Prompt: 31.1 t/s | Generation: 26.3 t/s ]\n\
|
||||||
|
\n\
|
||||||
|
Exiting...\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let reply = parse_llama_output(&raw);
|
||||||
|
assert_eq!(reply, "汪!");
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/plugins/ai/chat.rs
Normal file
180
src/plugins/ai/chat.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//! 对话管线执行器(被 HTTP 层直接调用)
|
||||||
|
//!
|
||||||
|
//! 为什么不在 AiPlugin 里执行:HTTP 需要同步响应,而 Plugin trait 的
|
||||||
|
//! handle_message 是异步消息系统,不便返回同步结果。改用 HTTP 层在
|
||||||
|
//! spawn_blocking 里直接调用此模块,Arc<Mutex> 共享状态。
|
||||||
|
|
||||||
|
use crate::core::message::{ChatInput, ChatRequest, ChatResponse};
|
||||||
|
use crate::plugins::ai::backend::AiBackend;
|
||||||
|
use crate::plugins::ai::model_manager::ModelManager;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// 会话上下文
|
||||||
|
pub struct SessionContext {
|
||||||
|
pub persona_prompt: String,
|
||||||
|
pub history: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对话管线执行器(共享状态)
|
||||||
|
pub struct ChatPipeline {
|
||||||
|
pub backend: Mutex<Box<dyn AiBackend>>,
|
||||||
|
pub models: Arc<Mutex<ModelManager>>,
|
||||||
|
pub sessions: Mutex<HashMap<String, SessionContext>>,
|
||||||
|
pub context_window: usize,
|
||||||
|
pub tmp_dir: std::path::PathBuf,
|
||||||
|
/// 互斥锁:同一时刻只允许一个回合
|
||||||
|
pub busy: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatPipeline {
|
||||||
|
pub fn new(
|
||||||
|
backend: Box<dyn AiBackend>,
|
||||||
|
models: ModelManager,
|
||||||
|
tmp_dir: std::path::PathBuf,
|
||||||
|
context_window: usize,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
backend: Mutex::new(backend),
|
||||||
|
models: Arc::new(Mutex::new(models)),
|
||||||
|
sessions: Mutex::new(HashMap::new()),
|
||||||
|
context_window,
|
||||||
|
tmp_dir,
|
||||||
|
busy: Mutex::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行对话回合(同步阻塞,调用方应在 spawn_blocking 里调)
|
||||||
|
pub fn run(&self, req: &ChatRequest) -> ChatResponse {
|
||||||
|
// 互斥:拿不到锁说明有进行中的回合
|
||||||
|
let _guard = match self.busy.try_lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return ChatResponse {
|
||||||
|
session_id: req.session_id.clone(),
|
||||||
|
transcription: None,
|
||||||
|
reply_text: String::new(),
|
||||||
|
reply_audio_path: None,
|
||||||
|
error: Some("设备忙,上一个回合未结束".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self.run_pipeline(req);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((transcription, reply_text, audio_path)) => {
|
||||||
|
// 更新会话上下文
|
||||||
|
if let Ok(mut sessions) = self.sessions.lock() {
|
||||||
|
let session = sessions
|
||||||
|
.entry(req.session_id.clone())
|
||||||
|
.or_insert_with(|| SessionContext {
|
||||||
|
persona_prompt: req.persona_prompt.clone(),
|
||||||
|
history: Vec::new(),
|
||||||
|
});
|
||||||
|
if session.persona_prompt != req.persona_prompt {
|
||||||
|
session.persona_prompt = req.persona_prompt.clone();
|
||||||
|
session.history.clear();
|
||||||
|
}
|
||||||
|
if let Some(t) = &transcription {
|
||||||
|
session.history.push(("user".to_string(), t.clone()));
|
||||||
|
}
|
||||||
|
session.history.push(("assistant".to_string(), reply_text.clone()));
|
||||||
|
let max = self.context_window * 2;
|
||||||
|
if session.history.len() > max {
|
||||||
|
let drain = session.history.len() - max;
|
||||||
|
session.history.drain(..drain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatResponse {
|
||||||
|
session_id: req.session_id.clone(),
|
||||||
|
transcription,
|
||||||
|
reply_text,
|
||||||
|
reply_audio_path: audio_path,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => ChatResponse {
|
||||||
|
session_id: req.session_id.clone(),
|
||||||
|
transcription: None,
|
||||||
|
reply_text: String::new(),
|
||||||
|
reply_audio_path: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_pipeline(&self, req: &ChatRequest) -> Result<(Option<String>, String, Option<String>)> {
|
||||||
|
// 1. ASR
|
||||||
|
let transcription = match &req.input {
|
||||||
|
ChatInput::Text { content } => Some(content.clone()),
|
||||||
|
ChatInput::Audio { path } => {
|
||||||
|
let models = self.models.lock().unwrap();
|
||||||
|
let asr_model = models.active_asr_model_path()?;
|
||||||
|
drop(models);
|
||||||
|
let backend = self.backend.lock().unwrap();
|
||||||
|
Some(backend.asr(Path::new(path), &asr_model)?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_text = transcription
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("ASR 返回空结果"))?;
|
||||||
|
if user_text.trim().is_empty() {
|
||||||
|
anyhow::bail!("输入为空(ASR 未识别到语音)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. LLM
|
||||||
|
let models = self.models.lock().unwrap();
|
||||||
|
let llm_model = models.active_llm_model_path()?;
|
||||||
|
drop(models);
|
||||||
|
|
||||||
|
let history: Vec<(String, String)> = {
|
||||||
|
let sessions = self.sessions.lock().unwrap();
|
||||||
|
sessions
|
||||||
|
.get(&req.session_id)
|
||||||
|
.map(|s| s.history.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = self.backend.lock().unwrap();
|
||||||
|
let reply_text = backend.llm_chat(
|
||||||
|
&llm_model,
|
||||||
|
&req.persona_prompt,
|
||||||
|
user_text,
|
||||||
|
&history,
|
||||||
|
req.max_tokens,
|
||||||
|
)?;
|
||||||
|
drop(backend);
|
||||||
|
|
||||||
|
// 3. TTS
|
||||||
|
let models = self.models.lock().unwrap();
|
||||||
|
let tts_model = models.active_tts_model_path()?;
|
||||||
|
drop(models);
|
||||||
|
|
||||||
|
let audio_path = self.tmp_dir.join(format!(
|
||||||
|
"tts_{}.wav",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
let backend = self.backend.lock().unwrap();
|
||||||
|
backend.tts(&reply_text, &tts_model, &audio_path)?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
transcription,
|
||||||
|
reply_text,
|
||||||
|
Some(audio_path.to_string_lossy().into_owned()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空指定会话上下文(切角色时调用)
|
||||||
|
pub fn clear_session(&self, session_id: &str) {
|
||||||
|
if let Ok(mut sessions) = self.sessions.lock() {
|
||||||
|
sessions.remove(session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/plugins/ai/mod.rs
Normal file
172
src/plugins/ai/mod.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! AiPlugin — AI 语音对话插件 (M2.1)
|
||||||
|
//!
|
||||||
|
//! 设备本地跑 ASR (whisper.cpp) → LLM (llama.cpp) → TTS (piper) 管线,
|
||||||
|
//! 提供语音/文字对话回合能力。声音一律返回客户端播放,设备不出声。
|
||||||
|
//!
|
||||||
|
//! # 架构
|
||||||
|
//! ```text
|
||||||
|
//! HTTP /api/chat → ChatPipeline.run() → ASR → LLM → TTS → ChatResponse
|
||||||
|
//! (Arc<Mutex> 共享, HTTP 在 spawn_blocking 调用)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! AiPlugin 本身是薄层:持有 ChatPipeline Arc 供 HTTP 层取用,
|
||||||
|
//! 并处理消息系统触发的联动(如 talk/idle 状态切换)。
|
||||||
|
//!
|
||||||
|
//! # 硬件约束 (Spike 2026-07-03 张明远实测)
|
||||||
|
//! - 测试机全志 A733: 2×A78@2.0G + 6×A55@1.8G, 4G 内存
|
||||||
|
//! - LLM 推理必须 t=2 锁大核 (全核反而崩且挤死视频)
|
||||||
|
//! - 严禁默认上下文长度 (4096/8192 会吃 1-3.3GB)
|
||||||
|
//! - 推荐默认 Qwen2.5-0.5B Q4_K_M (16.4 t/s, RSS 985M)
|
||||||
|
|
||||||
|
pub mod backend;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod model_manager;
|
||||||
|
|
||||||
|
pub use chat::{ChatPipeline, SessionContext};
|
||||||
|
|
||||||
|
use crate::core::message::Message;
|
||||||
|
use crate::core::plugin::*;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// AiPlugin — AI 对话插件(薄层)
|
||||||
|
///
|
||||||
|
/// 持有 ChatPipeline 的共享句柄,供 HTTP 层取用。
|
||||||
|
/// 自身处理消息系统触发的联动(talk/idle 状态切换等)。
|
||||||
|
pub struct AiPlugin {
|
||||||
|
ctx: Option<PluginContext>,
|
||||||
|
/// 对话管线(HTTP 层通过 Arc clone 直接调用)
|
||||||
|
pipeline: Arc<ChatPipeline>,
|
||||||
|
/// 工具目录
|
||||||
|
tools_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiPlugin {
|
||||||
|
/// 创建默认本地后端实例
|
||||||
|
pub fn new_default(
|
||||||
|
model_store: PathBuf,
|
||||||
|
tools_dir: PathBuf,
|
||||||
|
whisper_lib_dir: Option<PathBuf>,
|
||||||
|
piper_lib_dir: Option<PathBuf>,
|
||||||
|
piper_config: Option<PathBuf>,
|
||||||
|
espeak_data_dir: Option<PathBuf>,
|
||||||
|
tmp_dir: PathBuf,
|
||||||
|
context_window: usize,
|
||||||
|
) -> Self {
|
||||||
|
let mut backend_builder = backend::LocalCliBackend::new(tools_dir.clone());
|
||||||
|
if let Some(lib_dir) = whisper_lib_dir {
|
||||||
|
backend_builder = backend_builder.with_whisper_lib_dir(lib_dir);
|
||||||
|
}
|
||||||
|
// piper 三个依赖必须同时设置
|
||||||
|
if let (Some(lib_dir), Some(config), Some(espeak_data)) = (piper_lib_dir, piper_config, espeak_data_dir) {
|
||||||
|
backend_builder = backend_builder.with_piper_deps(lib_dir, config, espeak_data);
|
||||||
|
}
|
||||||
|
let backend = Box::new(backend_builder);
|
||||||
|
let models = model_manager::ModelManager::new(model_store);
|
||||||
|
let pipeline = ChatPipeline::new(backend, models, tmp_dir, context_window);
|
||||||
|
Self {
|
||||||
|
ctx: None,
|
||||||
|
pipeline,
|
||||||
|
tools_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取对话管线共享句柄(main.rs 注册时传给 HttpPlugin)
|
||||||
|
pub fn pipeline(&self) -> Arc<ChatPipeline> {
|
||||||
|
Arc::clone(&self.pipeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型管理器共享句柄(main.rs 注册时传给 HttpPlugin)
|
||||||
|
pub fn models(&self) -> Arc<Mutex<model_manager::ModelManager>> {
|
||||||
|
Arc::clone(&self.pipeline.models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for AiPlugin {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
"ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> PluginInfo {
|
||||||
|
PluginInfo {
|
||||||
|
name: "AiPlugin".to_string(),
|
||||||
|
version: "0.1.0".to_string(),
|
||||||
|
description: "AI 语音对话 (ASR/LLM/TTS 本地推理)".to_string(),
|
||||||
|
platform: Platform::LinuxArm64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> Vec<String> {
|
||||||
|
vec!["chat".to_string(), "model_management".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||||
|
self.ctx = Some(ctx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) -> Result<()> {
|
||||||
|
// 确保临时目录存在
|
||||||
|
if let Some(tmp) = self.pipeline.tmp_dir.parent() {
|
||||||
|
std::fs::create_dir_all(tmp).ok();
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&self.pipeline.tmp_dir).ok();
|
||||||
|
|
||||||
|
// 预加载默认模型清单
|
||||||
|
let mut models = self.pipeline.models.lock().unwrap();
|
||||||
|
if let Err(e) = models.ensure_default_models() {
|
||||||
|
eprintln!("[AiPlugin] 警告: 模型初始化失败: {e}");
|
||||||
|
}
|
||||||
|
drop(models);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[AiPlugin] 启动 (tools={}, tmp={})",
|
||||||
|
self.tools_dir.display(),
|
||||||
|
self.pipeline.tmp_dir.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
|
match msg {
|
||||||
|
Message::ChatRequest(req) => {
|
||||||
|
// 通过消息系统发起的对话(非 HTTP 路径),同步执行并广播结果
|
||||||
|
let resp = self.pipeline.run(&req);
|
||||||
|
if let Some(ctx) = &self.ctx {
|
||||||
|
let _ = ctx.tx.send(crate::core::message::Envelope {
|
||||||
|
from: self.id().to_string(),
|
||||||
|
to: crate::core::message::Destination::Broadcast,
|
||||||
|
message: Message::ChatResponse(resp),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Shutdown => {
|
||||||
|
self.stop()?;
|
||||||
|
}
|
||||||
|
Message::StateChanged { old_state, new_state } => {
|
||||||
|
// 画面联动:进入/离开 talk 状态时记录日志(实际联动由 video 插件处理状态机)
|
||||||
|
if new_state == "talk" || old_state == "talk" {
|
||||||
|
println!("[AiPlugin] 画面状态: {old_state} → {new_state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
// 清理临时文件
|
||||||
|
if self.pipeline.tmp_dir.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&self.pipeline.tmp_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("wav") {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
426
src/plugins/ai/model_manager.rs
Normal file
426
src/plugins/ai/model_manager.rs
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
//! ModelManager — AI 模型资产管理 (M2.1)
|
||||||
|
//!
|
||||||
|
//! 管理 LLM/ASR/TTS 模型文件,支持清单查看、下载、切换、删除。
|
||||||
|
//! 参照项目已有 plugin_repo + version_manager 模式设计。
|
||||||
|
//!
|
||||||
|
//! # 模型存储结构
|
||||||
|
//! ```text
|
||||||
|
//! model_store/
|
||||||
|
//! ├── registry.json # 本地模型注册表(已下载的模型清单 + active 标记)
|
||||||
|
//! ├── llm/
|
||||||
|
//! │ ├── qwen2.5-0.5b-q4_k_m.gguf
|
||||||
|
//! │ └── gemma3-1b-q4_k_m.gguf
|
||||||
|
//! ├── asr/
|
||||||
|
//! │ └── ggml-tiny.bin
|
||||||
|
//! └── tts/
|
||||||
|
//! └── zh_CN-huayan-medium.onnx
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 模型类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ModelKind {
|
||||||
|
Llm,
|
||||||
|
Asr,
|
||||||
|
Tts,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelKind {
|
||||||
|
pub fn dir_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ModelKind::Llm => "llm",
|
||||||
|
ModelKind::Asr => "asr",
|
||||||
|
ModelKind::Tts => "tts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型元信息(仓库清单 + 本地注册表共用)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
/// 模型 ID (如 "qwen2.5-0.5b-q4_k_m")
|
||||||
|
pub id: String,
|
||||||
|
/// 模型类型
|
||||||
|
pub kind: ModelKind,
|
||||||
|
/// 显示名称 (如 "Qwen2.5 0.5B Q4")
|
||||||
|
pub name: String,
|
||||||
|
/// 版本
|
||||||
|
pub version: String,
|
||||||
|
/// 文件尺寸 (字节)
|
||||||
|
pub size: u64,
|
||||||
|
/// 运行内存需求 (字节)
|
||||||
|
pub memory_required: u64,
|
||||||
|
/// 推荐设备档位 (如 "4G 内存档可用")
|
||||||
|
pub recommended_tier: String,
|
||||||
|
/// 下载 URL(国内镜像源)
|
||||||
|
pub url: String,
|
||||||
|
/// SHA256 校验和
|
||||||
|
pub sha256: String,
|
||||||
|
/// 文件名(存储到本地的文件名)
|
||||||
|
pub filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地注册表条目
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LocalModelEntry {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub info: ModelInfo,
|
||||||
|
/// 是否已下载
|
||||||
|
pub downloaded: bool,
|
||||||
|
/// 下载进度 (0-100,100=完成)
|
||||||
|
pub download_progress: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地注册表
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ModelRegistry {
|
||||||
|
/// 所有已知模型(仓库清单 + 本地状态)
|
||||||
|
pub models: Vec<LocalModelEntry>,
|
||||||
|
/// 各类型当前激活的模型 ID
|
||||||
|
pub active: HashMap<ModelKind, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型管理器
|
||||||
|
pub struct ModelManager {
|
||||||
|
/// 模型存储根目录
|
||||||
|
store_dir: PathBuf,
|
||||||
|
/// 注册表(内存缓存,持久化到 registry.json)
|
||||||
|
registry: ModelRegistry,
|
||||||
|
/// 存储配额上限(字节,默认 4GB)
|
||||||
|
quota: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelManager {
|
||||||
|
pub fn new(store_dir: PathBuf) -> Self {
|
||||||
|
let quota = 4 * 1024 * 1024 * 1024; // 4GB
|
||||||
|
let mut mgr = Self {
|
||||||
|
store_dir,
|
||||||
|
registry: ModelRegistry::default(),
|
||||||
|
quota,
|
||||||
|
};
|
||||||
|
let _ = mgr.load_registry();
|
||||||
|
mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存储目录
|
||||||
|
pub fn store_dir(&self) -> &Path {
|
||||||
|
&self.store_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配额
|
||||||
|
pub fn quota(&self) -> u64 {
|
||||||
|
self.quota
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已用空间
|
||||||
|
pub fn used_space(&self) -> u64 {
|
||||||
|
self.registry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.downloaded)
|
||||||
|
.map(|m| m.info.size)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载本地注册表
|
||||||
|
fn load_registry(&mut self) -> Result<()> {
|
||||||
|
let path = self.store_dir.join("registry.json");
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
self.registry = serde_json::from_str(&content)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化注册表
|
||||||
|
fn save_registry(&self) -> Result<()> {
|
||||||
|
fs::create_dir_all(&self.store_dir)?;
|
||||||
|
let path = self.store_dir.join("registry.json");
|
||||||
|
let content = serde_json::to_string_pretty(&self.registry)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化默认模型清单(首次启动或清单缺失时)
|
||||||
|
/// 每次启动都重新检查文件存在性,更新 downloaded 状态(符号链接可能后建)
|
||||||
|
pub fn ensure_default_models(&mut self) -> Result<()> {
|
||||||
|
// 首次:注册表为空,填充默认清单
|
||||||
|
if self.registry.models.is_empty() {
|
||||||
|
let defaults = vec![
|
||||||
|
ModelInfo {
|
||||||
|
id: "qwen2.5-0.5b-q4_k_m".to_string(),
|
||||||
|
kind: ModelKind::Llm,
|
||||||
|
name: "Qwen2.5 0.5B Q4 (推荐默认)".to_string(),
|
||||||
|
version: "0.5b".to_string(),
|
||||||
|
size: 469 * 1024 * 1024,
|
||||||
|
memory_required: 1024 * 1024 * 1024,
|
||||||
|
recommended_tier: "4G 内存档可用".to_string(),
|
||||||
|
url: "https://hf-mirror.com/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf".to_string(),
|
||||||
|
sha256: String::new(),
|
||||||
|
filename: "qwen2.5-0.5b-q4_k_m.gguf".to_string(),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "gemma3-1b-q4_k_m".to_string(),
|
||||||
|
kind: ModelKind::Llm,
|
||||||
|
name: "Gemma3 1B Q4 (可选档)".to_string(),
|
||||||
|
version: "1b".to_string(),
|
||||||
|
size: 800 * 1024 * 1024,
|
||||||
|
memory_required: 2 * 1024 * 1024 * 1024,
|
||||||
|
recommended_tier: "建议 8G 以上".to_string(),
|
||||||
|
url: "https://hf-mirror.com/google/gemma-3-1b-it-GGUF/resolve/main/gemma-3-1b-it-q4_k_m.gguf".to_string(),
|
||||||
|
sha256: String::new(),
|
||||||
|
filename: "gemma3-1b-q4_k_m.gguf".to_string(),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "gemma-4-e2b-q4_k_m".to_string(),
|
||||||
|
kind: ModelKind::Llm,
|
||||||
|
name: "Gemma 4 E2B Q4 (最新代,多模态)".to_string(),
|
||||||
|
version: "e2b".to_string(),
|
||||||
|
size: 3269 * 1024 * 1024,
|
||||||
|
memory_required: 3 * 1024 * 1024 * 1024,
|
||||||
|
recommended_tier: "需 4G+ 内存,可能触发 swap".to_string(),
|
||||||
|
url: "https://huggingface.co/dahus/gemma-4-e2b-it-Q4_K_M-GGUF/resolve/main/gemma-4-e2b-Q4_K_M.gguf".to_string(),
|
||||||
|
sha256: "2de6e5baaea40a8d54fb37ff418730ba4e5c002b".to_string(),
|
||||||
|
filename: "gemma-4-e2b-Q4_K_M.gguf".to_string(),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "whisper-tiny".to_string(),
|
||||||
|
kind: ModelKind::Asr,
|
||||||
|
name: "Whisper Tiny (中文)".to_string(),
|
||||||
|
version: "tiny".to_string(),
|
||||||
|
size: 75 * 1024 * 1024,
|
||||||
|
memory_required: 200 * 1024 * 1024,
|
||||||
|
recommended_tier: "4G 内存档可用".to_string(),
|
||||||
|
url: "https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin".to_string(),
|
||||||
|
sha256: String::new(),
|
||||||
|
filename: "ggml-tiny.bin".to_string(),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "piper-zh-huayan".to_string(),
|
||||||
|
kind: ModelKind::Tts,
|
||||||
|
name: "Piper zh_CN huayan (中文女声)".to_string(),
|
||||||
|
version: "medium".to_string(),
|
||||||
|
size: 63 * 1024 * 1024,
|
||||||
|
memory_required: 150 * 1024 * 1024,
|
||||||
|
recommended_tier: "4G 内存档可用".to_string(),
|
||||||
|
url: "https://hf-mirror.com/rhasspy/piper-voices/resolve/main/zh/zh_CN/huayan/medium/zh_CN-huayan-medium.onnx".to_string(),
|
||||||
|
sha256: String::new(),
|
||||||
|
filename: "zh_CN-huayan-medium.onnx".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for info in defaults {
|
||||||
|
self.registry.models.push(LocalModelEntry {
|
||||||
|
info,
|
||||||
|
downloaded: false,
|
||||||
|
download_progress: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认 active 模型
|
||||||
|
if !self.registry.active.contains_key(&ModelKind::Llm) {
|
||||||
|
self.registry.active.insert(ModelKind::Llm, "qwen2.5-0.5b-q4_k_m".to_string());
|
||||||
|
}
|
||||||
|
if !self.registry.active.contains_key(&ModelKind::Asr) {
|
||||||
|
self.registry.active.insert(ModelKind::Asr, "whisper-tiny".to_string());
|
||||||
|
}
|
||||||
|
if !self.registry.active.contains_key(&ModelKind::Tts) {
|
||||||
|
self.registry.active.insert(ModelKind::Tts, "piper-zh-huayan".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次启动都重新检查文件存在性(符号链接可能后建,或文件被手动删除)
|
||||||
|
for entry in &mut self.registry.models {
|
||||||
|
let path = self.store_dir.join(entry.info.kind.dir_name()).join(&entry.info.filename);
|
||||||
|
let exists = path.exists();
|
||||||
|
entry.downloaded = exists;
|
||||||
|
entry.download_progress = if exists { 100 } else { 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_registry()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有模型
|
||||||
|
pub fn list_models(&self) -> &[LocalModelEntry] {
|
||||||
|
&self.registry.models
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前激活的模型 ID
|
||||||
|
pub fn active_model_id(&self, kind: ModelKind) -> Option<&String> {
|
||||||
|
self.registry.active.get(&kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取已激活 LLM 模型文件路径
|
||||||
|
pub fn active_llm_model_path(&self) -> Result<PathBuf> {
|
||||||
|
self.active_model_path(ModelKind::Llm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取已激活 ASR 模型文件路径
|
||||||
|
pub fn active_asr_model_path(&self) -> Result<PathBuf> {
|
||||||
|
self.active_model_path(ModelKind::Asr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取已激活 TTS 模型文件路径
|
||||||
|
pub fn active_tts_model_path(&self) -> Result<PathBuf> {
|
||||||
|
self.active_model_path(ModelKind::Tts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_model_path(&self, kind: ModelKind) -> Result<PathBuf> {
|
||||||
|
let id = self
|
||||||
|
.registry
|
||||||
|
.active
|
||||||
|
.get(&kind)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("没有激活的 {kind:?} 模型"))?;
|
||||||
|
let entry = self
|
||||||
|
.registry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| &m.info.id == id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("激活的模型 {id} 不在注册表中"))?;
|
||||||
|
if !entry.downloaded {
|
||||||
|
bail!("激活的模型 {id} 尚未下载");
|
||||||
|
}
|
||||||
|
Ok(self.store_dir.join(kind.dir_name()).join(&entry.info.filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载模型
|
||||||
|
pub fn download_model(&mut self, model_id: &str) -> Result<()> {
|
||||||
|
let entry = self
|
||||||
|
.registry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.info.id == model_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("模型 {model_id} 不在清单中"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if entry.downloaded {
|
||||||
|
return Ok(()); // 已下载
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配额检查
|
||||||
|
if self.used_space() + entry.info.size > self.quota {
|
||||||
|
bail!(
|
||||||
|
"磁盘配额不足: 已用 {} + 需要 {} > 配额 {}",
|
||||||
|
self.used_space(),
|
||||||
|
entry.info.size,
|
||||||
|
self.quota
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
let dir = self.store_dir.join(entry.info.kind.dir_name());
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
let dest = dir.join(&entry.info.filename);
|
||||||
|
|
||||||
|
// 下载(简单实现,不带断点续传;生产环境应加)
|
||||||
|
println!("[ModelManager] 下载模型 {} from {}", model_id, entry.info.url);
|
||||||
|
let resp = ureq::get(&entry.info.url)
|
||||||
|
.timeout(std::time::Duration::from_secs(600))
|
||||||
|
.call()
|
||||||
|
.context("下载模型失败")?;
|
||||||
|
|
||||||
|
let mut file = fs::File::create(&dest)?;
|
||||||
|
std::io::copy(&mut resp.into_reader(), &mut file)?;
|
||||||
|
|
||||||
|
// 标记为已下载
|
||||||
|
if let Some(m) = self.registry.models.iter_mut().find(|m| m.info.id == model_id) {
|
||||||
|
m.downloaded = true;
|
||||||
|
m.download_progress = 100;
|
||||||
|
}
|
||||||
|
self.save_registry()?;
|
||||||
|
println!("[ModelManager] 模型 {} 下载完成", model_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换激活模型
|
||||||
|
pub fn switch_model(&mut self, kind: ModelKind, model_id: &str) -> Result<()> {
|
||||||
|
let entry = self
|
||||||
|
.registry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.info.id == model_id && m.info.kind == kind)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("模型 {model_id} 不在 {kind:?} 清单中"))?;
|
||||||
|
|
||||||
|
if !entry.downloaded {
|
||||||
|
bail!("模型 {model_id} 尚未下载,无法切换");
|
||||||
|
}
|
||||||
|
|
||||||
|
let old = self.registry.active.insert(kind, model_id.to_string());
|
||||||
|
self.save_registry()?;
|
||||||
|
if let Some(old_id) = old {
|
||||||
|
println!("[ModelManager] {kind:?} 模型切换: {old_id} → {model_id}");
|
||||||
|
} else {
|
||||||
|
println!("[ModelManager] {kind:?} 模型激活: {model_id}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除模型(当前激活的模型不可删除)
|
||||||
|
pub fn delete_model(&mut self, model_id: &str) -> Result<()> {
|
||||||
|
let entry = self
|
||||||
|
.registry
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.info.id == model_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("模型 {model_id} 不在清单中"))?;
|
||||||
|
|
||||||
|
// 保护当前激活模型
|
||||||
|
if let Some(active_id) = self.registry.active.get(&entry.info.kind) {
|
||||||
|
if active_id == model_id {
|
||||||
|
bail!("模型 {model_id} 正在使用中,无法删除(请先切换到其他模型)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry.downloaded {
|
||||||
|
bail!("模型 {model_id} 未下载,无需删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
let path = self
|
||||||
|
.store_dir
|
||||||
|
.join(entry.info.kind.dir_name())
|
||||||
|
.join(&entry.info.filename);
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新注册表
|
||||||
|
if let Some(m) = self.registry.models.iter_mut().find(|m| m.info.id == model_id) {
|
||||||
|
m.downloaded = false;
|
||||||
|
m.download_progress = 0;
|
||||||
|
}
|
||||||
|
self.save_registry()?;
|
||||||
|
println!("[ModelManager] 模型 {} 已删除", model_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取注册表(用于 HTTP API 返回)
|
||||||
|
pub fn registry(&self) -> &ModelRegistry {
|
||||||
|
&self.registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库清单索引(远程静态清单文件)
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct RepoModelIndex {
|
||||||
|
pub models: Vec<ModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelManager {
|
||||||
|
/// 从远程仓库拉取最新清单(更新本地注册表,不触发下载)
|
||||||
|
pub fn fetch_repo_index(repo_url: &str) -> Result<RepoModelIndex> {
|
||||||
|
let resp = ureq::get(&format!("{repo_url}/models/index.json"))
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.call()
|
||||||
|
.context("拉取模型清单失败")?;
|
||||||
|
let index: RepoModelIndex = serde_json::from_reader(resp.into_reader())?;
|
||||||
|
Ok(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,7 +121,10 @@ impl Plugin for DevicePlugin {
|
|||||||
let response = match self.backend.handle_command(cmd) {
|
let response = match self.backend.handle_command(cmd) {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 将错误转换为 DeviceResponse::Error
|
// 将错误转换为 DeviceResponse::Error,并记录日志
|
||||||
|
// (核心静态插件目前无人消费 DeviceResponse,故至少在此留痕,
|
||||||
|
// 避免设备层故障完全静默——王浩然风险3 建议)
|
||||||
|
eprintln!("[DevicePlugin] 命令处理失败: {e}");
|
||||||
crate::core::message::DeviceResponse::Error(e.to_string())
|
crate::core::message::DeviceResponse::Error(e.to_string())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
150
src/plugins/http/chat.html
Normal file
150
src/plugins/http/chat.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Showen 数字生命控制台</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px;--pet:#f59e0b;--human:#6366f1;--singer:#ec4899;--live2d:#22c55e}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
|
||||||
|
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
|
.app{max-width:1000px;margin:0 auto;padding:16px 20px}
|
||||||
|
h1{font-size:20px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
|
||||||
|
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||||
|
.subtitle{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||||
|
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
||||||
|
.tab{padding:8px 16px;cursor:pointer;border-radius:8px 8px 0 0;color:var(--muted);font-size:14px;transition:.2s;border-bottom:2px solid transparent;margin-bottom:-1px}
|
||||||
|
.tab:hover{color:var(--text)}
|
||||||
|
.tab.active{color:var(--text);border-bottom-color:var(--accent);background:var(--accent-glow)}
|
||||||
|
.panel{display:none}.panel.active{display:block}
|
||||||
|
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}
|
||||||
|
.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}
|
||||||
|
.char-bar .info{flex:1}
|
||||||
|
.char-bar .info .name{font-weight:600;font-size:15px}
|
||||||
|
.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}
|
||||||
|
.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}
|
||||||
|
.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}
|
||||||
|
.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}
|
||||||
|
.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}
|
||||||
|
.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
|
||||||
|
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}
|
||||||
|
.msg{margin-bottom:12px;max-width:80%}
|
||||||
|
.msg.user{margin-left:auto;text-align:right}
|
||||||
|
.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}
|
||||||
|
.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}
|
||||||
|
.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}
|
||||||
|
.msg .audio{margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
||||||
|
.msg .audio audio{height:28px;max-width:240px}
|
||||||
|
.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}
|
||||||
|
.chat-input{display:flex;gap:8px}
|
||||||
|
.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}
|
||||||
|
.chat-input input:focus{border-color:var(--accent)}
|
||||||
|
.chat-input button{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}
|
||||||
|
.chat-input button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}
|
||||||
|
.status-bar.error{color:var(--red)}
|
||||||
|
.status-bar.busy{color:var(--amber)}
|
||||||
|
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
|
||||||
|
.live2d-placeholder{color:var(--muted);text-align:center}
|
||||||
|
.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}
|
||||||
|
.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
||||||
|
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
||||||
|
.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}
|
||||||
|
.char-card:hover{border-color:var(--accent)}
|
||||||
|
.char-card.active{border-color:var(--green);background:var(--accent-glow)}
|
||||||
|
.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}
|
||||||
|
.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}
|
||||||
|
.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
|
||||||
|
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}
|
||||||
|
.model-stat .label{color:var(--muted);margin-right:4px}
|
||||||
|
.model-stat .val{font-weight:600}
|
||||||
|
.model-list{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}
|
||||||
|
.model-item .info{flex:1}
|
||||||
|
.model-item .info .name{font-weight:600;font-size:14px}
|
||||||
|
.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}
|
||||||
|
.model-item .actions{display:flex;gap:6px}
|
||||||
|
.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}
|
||||||
|
.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}
|
||||||
|
.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}
|
||||||
|
.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}
|
||||||
|
.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}
|
||||||
|
.tag.asr{background:rgba(34,197,94,.2);color:#86efac}
|
||||||
|
.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}
|
||||||
|
.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}
|
||||||
|
.tag.active{background:var(--green);color:#fff}
|
||||||
|
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}
|
||||||
|
.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}
|
||||||
|
.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}
|
||||||
|
.mic-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}
|
||||||
|
.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}
|
||||||
|
.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}
|
||||||
|
.audio-inline{margin-top:8px}
|
||||||
|
.audio-inline audio{width:100%;max-width:280px;height:32px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<h1><span class="dot"></span> Showen 数字生命控制台</h1>
|
||||||
|
<div class="subtitle">统一入口 · 角色对话 · 模型管理</div>
|
||||||
|
<div class="char-bar" id="charBar">
|
||||||
|
<div class="avatar" id="charAvatar">🐶</div>
|
||||||
|
<div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div>
|
||||||
|
<div class="render-badge" id="charRender">video</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="chat">对话</div>
|
||||||
|
<div class="tab" data-tab="character">角色</div>
|
||||||
|
<div class="tab" data-tab="models">模型</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel active" id="panel-chat">
|
||||||
|
<div id="renderArea"></div>
|
||||||
|
<div class="chat-box" id="chatBox" style="display:none"></div>
|
||||||
|
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div>
|
||||||
|
<div class="status-bar" id="chatStatus"></div>
|
||||||
|
<div class="rec-tip" id="recTip"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-character">
|
||||||
|
<div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div>
|
||||||
|
<div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" id="panel-models">
|
||||||
|
<div class="model-bar" id="modelBar"></div>
|
||||||
|
<div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatBusy=false;let currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'};
|
||||||
|
const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
|
||||||
|
document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};});
|
||||||
|
function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;}
|
||||||
|
async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();}
|
||||||
|
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path);}
|
||||||
|
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return;}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){const tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr);}if(data.reply_audio_path){const ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}
|
||||||
|
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent=msg;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}
|
||||||
|
function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});}
|
||||||
|
function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();}
|
||||||
|
function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;}
|
||||||
|
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}});
|
||||||
|
async function loadCharacters(){try{const res=await fetch('/api/config/available');const data=await res.json();const grid=$('#charGrid');grid.innerHTML='';const cfgRes=await fetch('/api/config');const cfg=await cfgRes.json();const char=cfg.character||{};const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(c=>{const isActive=c===(data.active||'');const card=document.createElement('div');card.className='char-card'+(isActive?' active':'');const name=char.name||c.replace(/\.json$/,'').replace(/_/g,' ');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+(char.character_type||'pet')+'">'+strat.label+'</span> '+(char.render_type||strat.render)+'</div><div class="strat">策略: 拟声/自然/元气 (依据角色)</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=()=>switchCharacter(c);grid.appendChild(card);});}catch(e){$('#charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>';}}
|
||||||
|
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{const res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename})});const data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){alert('已切换');loadCurrentChar();loadCharacters();}else alert('失败: '+(data.message||'未知'));}catch(e){alert('失败: '+e.message);}}
|
||||||
|
async function loadCurrentChar(){try{const res=await fetch('/api/config');const cfg=await res.json();applyCharStrategy(cfg.character||{name:'未知',character_type:'pet',render_type:'video'});}catch(e){console.error(e);}}
|
||||||
|
async function loadModels(){try{const res=await fetch('/api/models');const data=await res.json();const bar=$('#modelBar');const u=Math.round((data.used_space||0)/1048576);const q=Math.round((data.quota||0)/1048576);bar.innerHTML='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(m=>m.downloaded).length+'/'+(data.models||[]).length+'</span></div>';const list=$('#modelList');list.innerHTML='';const active=data.active||{};(data.models||[]).forEach(m=>{const item=document.createElement('div');item.className='model-item';const ia=active[m.kind]===m.id;const s=Math.round(m.size/1048576);const me=Math.round(m.memory_required/1048576);let a='';if(m.downloaded){if(ia)a='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>';}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item);});}catch(e){$('#modelList').innerHTML='<div style="color:var(--red)">失败</div>';}}
|
||||||
|
async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}}
|
||||||
|
async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}}
|
||||||
|
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}}
|
||||||
|
initMic();
|
||||||
|
async function initMic(){const micBtn=$('#micBtn');const tip=$('#recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return;}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文(HTTPS 或 localhost)。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return;}let mediaRecorder=null;let chunks=[];let recStream=null;let recTimer=null;const startRec=async()=>{if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=e=>{if(e.data.size>0)chunks.push(e.data);};mediaRecorder.onstop=()=>{if(recTimer){clearTimeout(recTimer);recTimer=null;}recStream.getTracks().forEach(t=>t.stop());recStream=null;const blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='录音过短,已取消';$('#chatStatus').className='status-bar error';return;}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm');};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('#chatStatus').textContent='录音中...(松开发送)';$('#chatStatus').className='status-bar busy';recTimer=setTimeout(()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('#chatStatus').textContent='录音达到 30s 上限,自动发送';}},30000);}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='麦克风启动失败: '+e.message;$('#chatStatus').className='status-bar error';}};const stopRec=()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙';}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',e=>{e.preventDefault();startRec();});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',e=>{e.preventDefault();stopRec();});}
|
||||||
|
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;const micBtn=$('#micBtn');micBtn.disabled=true;$('#chatStatus').textContent='转码中...';$('#chatStatus').className='status-bar busy';const userMsg=addMsg('user','🎤 [语音消息]');const playUrl=URL.createObjectURL(blob);const up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const wavBlob=await blobToWav(blob);$('#chatStatus').textContent='识别中...';const res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;micBtn.disabled=false;}
|
||||||
|
async function blobToWav(blob){const arrayBuf=await blob.arrayBuffer();const audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});const decoded=await audioCtx.decodeAudioData(arrayBuf);const pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000);}
|
||||||
|
function decodeToMonoPCM16(audioBuffer,targetRate){const numCh=audioBuffer.numberOfChannels;const len=audioBuffer.length;const srcRate=audioBuffer.sampleRate;const outLen=Math.round(len*targetRate/srcRate);const out=new Int16Array(outLen);const tmp=new Float32Array(outLen);for(let ch=0;ch<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<outLen;i++){const srcIdx=i*srcRate/targetRate;const i0=Math.floor(srcIdx);const i1=Math.min(i0+1,len-1);const frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac;}}for(let i=0;i<outLen;i++){let s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF;}return out;}
|
||||||
|
function encodeWavBlob(samples,sampleRate){const buf=new ArrayBuffer(44+samples.length*2);const view=new DataView(buf);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);let off=44;for(let i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2;}return new Blob([buf],{type:'audio/wav'});}
|
||||||
|
loadCurrentChar();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -48,6 +48,10 @@ pub(crate) struct HttpState {
|
|||||||
ws_events: broadcast::Sender<String>,
|
ws_events: broadcast::Sender<String>,
|
||||||
/// 动态插件管理状态(由 Custom 消息更新)
|
/// 动态插件管理状态(由 Custom 消息更新)
|
||||||
plugin_states: Mutex<Vec<crate::core::service_manager::PluginStateInfo>>,
|
plugin_states: Mutex<Vec<crate::core::service_manager::PluginStateInfo>>,
|
||||||
|
/// AI 对话管线(HTTP 路由直接调用)
|
||||||
|
ai_pipeline: Mutex<Option<std::sync::Arc<crate::plugins::ai::ChatPipeline>>>,
|
||||||
|
/// AI 模型管理器共享句柄(HTTP 模型管理 API 调用)
|
||||||
|
ai_models: Mutex<Option<std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpState {
|
impl HttpState {
|
||||||
@@ -75,9 +79,40 @@ impl HttpState {
|
|||||||
ble_ready: AtomicBool::new(false),
|
ble_ready: AtomicBool::new(false),
|
||||||
ws_events,
|
ws_events,
|
||||||
plugin_states: Mutex::new(Vec::new()),
|
plugin_states: Mutex::new(Vec::new()),
|
||||||
|
ai_pipeline: Mutex::new(None),
|
||||||
|
ai_models: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注册 AI 对话管线(AiPlugin init 时调用)
|
||||||
|
pub(crate) fn register_ai_pipeline(&self, pipeline: std::sync::Arc<crate::plugins::ai::ChatPipeline>) {
|
||||||
|
if let Ok(mut slot) = self.ai_pipeline.lock() {
|
||||||
|
*slot = Some(pipeline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册 AI 模型管理器(AiPlugin init 时调用)
|
||||||
|
pub(crate) fn register_ai_models(
|
||||||
|
&self,
|
||||||
|
models: std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>,
|
||||||
|
) {
|
||||||
|
if let Ok(mut slot) = self.ai_models.lock() {
|
||||||
|
*slot = Some(models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 AI 对话管线(HTTP 路由调用)
|
||||||
|
pub(crate) fn ai_pipeline(&self) -> Option<std::sync::Arc<crate::plugins::ai::ChatPipeline>> {
|
||||||
|
self.ai_pipeline.lock().ok().and_then(|slot| slot.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 AI 模型管理器(HTTP 路由调用)
|
||||||
|
pub(crate) fn ai_models(
|
||||||
|
&self,
|
||||||
|
) -> Option<std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>> {
|
||||||
|
self.ai_models.lock().ok().and_then(|slot| slot.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn publish_wifi_result(&self, payload: String) {
|
fn publish_wifi_result(&self, payload: String) {
|
||||||
if let Ok(mut state) = self.wifi_response.lock() {
|
if let Ok(mut state) = self.wifi_response.lock() {
|
||||||
state.version += 1;
|
state.version += 1;
|
||||||
@@ -210,6 +245,10 @@ pub struct HttpPlugin {
|
|||||||
state: Option<Arc<HttpState>>,
|
state: Option<Arc<HttpState>>,
|
||||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
server_thread: Option<JoinHandle<()>>,
|
server_thread: Option<JoinHandle<()>>,
|
||||||
|
/// AI 对话管线(main.rs 注册时传入,init 时存入 HttpState)
|
||||||
|
ai_pipeline: Option<std::sync::Arc<crate::plugins::ai::ChatPipeline>>,
|
||||||
|
/// AI 模型管理器(main.rs 注册时传入)
|
||||||
|
ai_models: Option<std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpPlugin {
|
impl HttpPlugin {
|
||||||
@@ -219,8 +258,20 @@ impl HttpPlugin {
|
|||||||
state: None,
|
state: None,
|
||||||
shutdown_tx: None,
|
shutdown_tx: None,
|
||||||
server_thread: None,
|
server_thread: None,
|
||||||
|
ai_pipeline: None,
|
||||||
|
ai_models: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置 AI 对话管线(main.rs 注册 AiPlugin 后调用)
|
||||||
|
pub fn set_ai_pipeline(&mut self, pipeline: std::sync::Arc<crate::plugins::ai::ChatPipeline>) {
|
||||||
|
self.ai_pipeline = Some(pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 AI 模型管理器(main.rs 注册 AiPlugin 后调用)
|
||||||
|
pub fn set_ai_models(&mut self, models: std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>) {
|
||||||
|
self.ai_models = Some(models);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HttpPlugin {
|
impl Default for HttpPlugin {
|
||||||
@@ -244,11 +295,19 @@ impl Plugin for HttpPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dependencies(&self) -> Vec<String> {
|
fn dependencies(&self) -> Vec<String> {
|
||||||
vec!["video".to_string()]
|
vec![crate::core::plugin_ids::VIDEO.to_string()]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||||
self.state = Some(Arc::new(HttpState::new(Arc::clone(&ctx.config))));
|
let state = Arc::new(HttpState::new(Arc::clone(&ctx.config)));
|
||||||
|
// 注册 AI 句柄(如果 main.rs 已设置)
|
||||||
|
if let Some(pipeline) = self.ai_pipeline.take() {
|
||||||
|
state.register_ai_pipeline(pipeline);
|
||||||
|
}
|
||||||
|
if let Some(ai_models) = self.ai_models.take() {
|
||||||
|
state.register_ai_models(ai_models);
|
||||||
|
}
|
||||||
|
self.state = Some(state);
|
||||||
self.ctx = Some(ctx);
|
self.ctx = Some(ctx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use super::HttpState;
|
use super::HttpState;
|
||||||
use crate::core::config::{self, AppConfig};
|
use crate::core::config::{self, AppConfig};
|
||||||
use crate::core::dispatch;
|
use crate::core::dispatch;
|
||||||
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
use crate::core::message::{ChatInput, ChatRequest, ChatResponse, Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||||
use bytes::Buf;
|
use bytes::Buf;
|
||||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{mpsc, Arc};
|
use std::sync::{mpsc, Arc};
|
||||||
@@ -145,9 +146,19 @@ pub(crate) fn build_routes(
|
|||||||
.or(file_mkdir_route(Arc::clone(&state)))
|
.or(file_mkdir_route(Arc::clone(&state)))
|
||||||
.boxed();
|
.boxed();
|
||||||
|
|
||||||
let api = core_api.or(media_api).or(plugin_api).or(file_api);
|
let ai_api = chat_text_route(Arc::clone(&state))
|
||||||
|
.or(chat_audio_route(Arc::clone(&state)))
|
||||||
|
.or(chat_audio_file_route(Arc::clone(&state)))
|
||||||
|
.or(models_list_route(Arc::clone(&state)))
|
||||||
|
.or(model_download_route(Arc::clone(&state)))
|
||||||
|
.or(model_switch_route(Arc::clone(&state)))
|
||||||
|
.or(model_delete_route(Arc::clone(&state)))
|
||||||
|
.boxed();
|
||||||
|
|
||||||
|
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
|
||||||
|
|
||||||
root_route()
|
root_route()
|
||||||
|
.or(chat_page_route())
|
||||||
.or(download_route(Arc::clone(&state)))
|
.or(download_route(Arc::clone(&state)))
|
||||||
.or(ws_route(tx.clone(), Arc::clone(&state)))
|
.or(ws_route(tx.clone(), Arc::clone(&state)))
|
||||||
.or(api)
|
.or(api)
|
||||||
@@ -171,6 +182,14 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /chat — AI 对话控制端页面
|
||||||
|
fn chat_page_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path("chat")
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::get())
|
||||||
|
.map(|| warp::reply::html(include_str!("chat.html")))
|
||||||
|
}
|
||||||
|
|
||||||
fn ws_route(
|
fn ws_route(
|
||||||
tx: mpsc::Sender<Envelope>,
|
tx: mpsc::Sender<Envelope>,
|
||||||
state: Arc<HttpState>,
|
state: Arc<HttpState>,
|
||||||
@@ -1712,11 +1731,17 @@ fn read_attachment_response(path: &Path) -> warp::reply::Response {
|
|||||||
match std::fs::read(path) {
|
match std::fs::read(path) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
// Sanitize filename for Content-Disposition header to prevent header injection
|
||||||
|
let safe_filename = filename
|
||||||
|
.replace('\\', "_")
|
||||||
|
.replace('"', "_")
|
||||||
|
.replace('\r', "")
|
||||||
|
.replace('\n', "");
|
||||||
match warp::http::Response::builder()
|
match warp::http::Response::builder()
|
||||||
.header("Content-Type", "application/octet-stream")
|
.header("Content-Type", "application/octet-stream")
|
||||||
.header(
|
.header(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
format!("attachment; filename=\"{filename}\""),
|
format!("attachment; filename=\"{safe_filename}\""),
|
||||||
)
|
)
|
||||||
.header("Content-Length", data.len().to_string())
|
.header("Content-Length", data.len().to_string())
|
||||||
.body(data)
|
.body(data)
|
||||||
@@ -1948,6 +1973,374 @@ async fn send_plugin_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI 对话 API (M2.1) ──
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatTextRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/chat/text — 文字对话(Web 端主路径)
|
||||||
|
fn chat_text_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "chat" / "text")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json::<ChatTextRequest>())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|req: ChatTextRequest, state: Arc<HttpState>| async move {
|
||||||
|
let pipeline = match state.ai_pipeline() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
let session_id = req
|
||||||
|
.session_id
|
||||||
|
.unwrap_or_else(|| format!("web_{}", std::process::id()));
|
||||||
|
let chat_req = ChatRequest {
|
||||||
|
session_id,
|
||||||
|
input: ChatInput::Text {
|
||||||
|
content: req.text,
|
||||||
|
},
|
||||||
|
persona_prompt: config.character.persona_prompt.clone(),
|
||||||
|
max_tokens: config.character.max_tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI 管线是同步阻塞调用(ASR/LLM/TTS 子进程),用 spawn_blocking 避免阻塞 tokio reactor
|
||||||
|
let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| ChatResponse {
|
||||||
|
session_id: String::new(),
|
||||||
|
transcription: None,
|
||||||
|
reply_text: String::new(),
|
||||||
|
reply_audio_path: None,
|
||||||
|
error: Some(format!("AI 管线执行失败: {e}")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if resp.error.is_some() {
|
||||||
|
Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp))
|
||||||
|
} else {
|
||||||
|
Ok(json_response(StatusCode::OK, &resp))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/chat/audio — 语音对话(App/Web 主路径,上传 wav/webm/ogg 等浏览器录音)
|
||||||
|
///
|
||||||
|
/// 注意:上传的音频会被传给 whisper-cli,它通过文件扩展名判断格式。
|
||||||
|
/// 浏览器 MediaRecorder 默认产 webm/opus,移动端可能产 m4a/aac。
|
||||||
|
/// 服务端不强校验格式,但建议客户端尽量转 wav/16k 单声道。
|
||||||
|
fn chat_audio_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "chat" / "audio")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::content_length_limit(20 * 1024 * 1024))
|
||||||
|
.and(warp::body::bytes())
|
||||||
|
.and(warp::header::optional::<String>("X-Audio-Format"))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(
|
||||||
|
|bytes: bytes::Bytes,
|
||||||
|
fmt_hint: Option<String>,
|
||||||
|
state: Arc<HttpState>| async move {
|
||||||
|
let pipeline = match state.ai_pipeline() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
let tmp_dir = config.ai.tmp_dir.clone();
|
||||||
|
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||||
|
|
||||||
|
// 根据客户端上报的格式或默认 wav 决定扩展名(whisper-cli 靠扩展名判断)
|
||||||
|
let ext = match fmt_hint.as_deref() {
|
||||||
|
Some("webm") => "webm",
|
||||||
|
Some("ogg") => "ogg",
|
||||||
|
Some("m4a") => "m4a",
|
||||||
|
Some("mp3") => "mp3",
|
||||||
|
Some("wav") | _ => "wav",
|
||||||
|
};
|
||||||
|
let audio_path = tmp_dir.join(format!(
|
||||||
|
"asr_{}.{ext}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
if let Err(e) = std::fs::write(&audio_path, &bytes) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("保存音频失败: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = format!("app_{}", std::process::id());
|
||||||
|
let chat_req = ChatRequest {
|
||||||
|
session_id,
|
||||||
|
input: ChatInput::Audio {
|
||||||
|
path: audio_path.to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
persona_prompt: config.character.persona_prompt.clone(),
|
||||||
|
max_tokens: config.character.max_tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
let audio_path_clone = audio_path.clone();
|
||||||
|
let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| ChatResponse {
|
||||||
|
session_id: String::new(),
|
||||||
|
transcription: None,
|
||||||
|
reply_text: String::new(),
|
||||||
|
reply_audio_path: None,
|
||||||
|
error: Some(format!("AI 管线执行失败: {e}")),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理上传的临时音频
|
||||||
|
let _ = std::fs::remove_file(&audio_path_clone);
|
||||||
|
|
||||||
|
if resp.error.is_some() {
|
||||||
|
Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp))
|
||||||
|
} else {
|
||||||
|
Ok(json_response(StatusCode::OK, &resp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/chat/audio/file?path=<绝对路径> — 读取 TTS 生成的临时回复音频
|
||||||
|
///
|
||||||
|
/// 安全:path 必须在 config.ai.tmp_dir 内,且文件存在。返回 wav 二进制流。
|
||||||
|
fn chat_audio_file_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "chat" / "audio" / "file")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(warp::query::<HashMap<String, String>>())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(
|
||||||
|
|params: HashMap<String, String>, state: Arc<HttpState>| async move {
|
||||||
|
let raw_path = match params.get("path") {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(
|
||||||
|
error_json(StatusCode::BAD_REQUEST, "缺少 path 参数"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
let tmp_dir = config.ai.tmp_dir.clone();
|
||||||
|
|
||||||
|
// 路径白名单:必须规范化后位于 tmp_dir 内
|
||||||
|
let candidate = std::path::Path::new(&raw_path);
|
||||||
|
let canonical_target = match candidate.canonicalize() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"音频文件不存在或无法访问",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let canonical_tmp = match tmp_dir.canonicalize() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"tmp_dir 未配置或不存在",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !canonical_target.starts_with(&canonical_tmp) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"路径越界:只允许访问 tmp_dir 内的音频文件",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !canonical_target.is_file() {
|
||||||
|
return Ok(error_json(StatusCode::NOT_FOUND, "音频文件不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回内联音频(浏览器可直接播放)
|
||||||
|
match std::fs::read(&canonical_target) {
|
||||||
|
Ok(data) => {
|
||||||
|
let cleanup_target = canonical_target.clone();
|
||||||
|
let _ = std::thread::spawn(move || {
|
||||||
|
// 60s 后清理(足够浏览器播放完)
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||||
|
let _ = std::fs::remove_file(cleanup_target);
|
||||||
|
});
|
||||||
|
match warp::http::Response::builder()
|
||||||
|
.header("Content-Type", "audio/wav")
|
||||||
|
.header("Content-Length", data.len().to_string())
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.body(data)
|
||||||
|
{
|
||||||
|
Ok(response) => Ok(response.into_response()),
|
||||||
|
Err(_) => Ok(warp::reply::with_status(
|
||||||
|
warp::reply::html("响应构建失败".to_string()),
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("读取音频失败: {e}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI 模型管理 API (M2.1) ──
|
||||||
|
|
||||||
|
/// GET /api/models — 列出所有模型
|
||||||
|
fn models_list_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "models")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|state: Arc<HttpState>| async move {
|
||||||
|
let models = match state.ai_models() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mgr = models.lock().unwrap();
|
||||||
|
let registry = mgr.registry();
|
||||||
|
let used = mgr.used_space();
|
||||||
|
let quota = mgr.quota();
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"models": registry.models,
|
||||||
|
"active": registry.active,
|
||||||
|
"used_space": used,
|
||||||
|
"quota": quota,
|
||||||
|
});
|
||||||
|
Ok(json_response(StatusCode::OK, &result))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ModelActionRequest {
|
||||||
|
model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/models/download — 下载模型
|
||||||
|
fn model_download_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "models" / "download")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json::<ModelActionRequest>())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|req: ModelActionRequest, state: Arc<HttpState>| async move {
|
||||||
|
let models = match state.ai_models() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 下载在独立线程执行,避免阻塞 HTTP
|
||||||
|
let models_clone = models.clone();
|
||||||
|
let model_id = req.model_id.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut mgr = models_clone.lock().unwrap();
|
||||||
|
if let Err(e) = mgr.download_model(&model_id) {
|
||||||
|
eprintln!("[HttpPlugin] 模型下载失败 {model_id}: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok::<_, Infallible>(success_json(format!("模型 {} 下载已启动", req.model_id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/models/switch — 切换激活模型
|
||||||
|
fn model_switch_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "models" / "switch")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json::<serde_json::Value>())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|body: serde_json::Value, state: Arc<HttpState>| async move {
|
||||||
|
let models = match state.ai_models() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let model_id = match body.get("model_id").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s.to_string(),
|
||||||
|
None => return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 model_id")),
|
||||||
|
};
|
||||||
|
let kind_str = match body.get("kind").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 kind")),
|
||||||
|
};
|
||||||
|
let kind = match kind_str {
|
||||||
|
"llm" => crate::plugins::ai::model_manager::ModelKind::Llm,
|
||||||
|
"asr" => crate::plugins::ai::model_manager::ModelKind::Asr,
|
||||||
|
"tts" => crate::plugins::ai::model_manager::ModelKind::Tts,
|
||||||
|
_ => return Ok(error_json(StatusCode::BAD_REQUEST, "kind 必须为 llm/asr/tts")),
|
||||||
|
};
|
||||||
|
let mut mgr = models.lock().unwrap();
|
||||||
|
match mgr.switch_model(kind, &model_id) {
|
||||||
|
Ok(()) => Ok(success_json(format!("模型已切换为 {}", model_id))),
|
||||||
|
Err(e) => Ok(error_json(StatusCode::BAD_REQUEST, &e.to_string())),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/models/delete — 删除模型
|
||||||
|
fn model_delete_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "models" / "delete")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json::<ModelActionRequest>())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|req: ModelActionRequest, state: Arc<HttpState>| async move {
|
||||||
|
let models = match state.ai_models() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
return Ok::<_, Infallible>(error_json(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"AI 插件未就绪",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut mgr = models.lock().unwrap();
|
||||||
|
match mgr.delete_model(&req.model_id) {
|
||||||
|
Ok(()) => Ok(success_json(format!("模型 {} 已删除", req.model_id))),
|
||||||
|
Err(e) => Ok(error_json(StatusCode::BAD_REQUEST, &e.to_string())),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
|
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
|
||||||
warp::reply::with_status(warp::reply::json(payload), status).into_response()
|
warp::reply::with_status(warp::reply::json(payload), status).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod ai;
|
||||||
pub mod ble;
|
pub mod ble;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
use crate::core::{
|
use crate::core::{
|
||||||
message::{Destination, DeviceCommand, Envelope, Message},
|
message::{Destination, DeviceCommand, Envelope, Message},
|
||||||
plugin::*,
|
plugin::*,
|
||||||
|
plugin_ids,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ impl ScreenPlugin {
|
|||||||
if let Some(ctx) = &self.ctx {
|
if let Some(ctx) = &self.ctx {
|
||||||
let envelope = Envelope {
|
let envelope = Envelope {
|
||||||
from: self.id().to_string(),
|
from: self.id().to_string(),
|
||||||
to: Destination::Plugin("device".to_string()),
|
to: Destination::Plugin(plugin_ids::DEVICE.to_string()),
|
||||||
message: Message::DeviceCommand(DeviceCommand::SetSleepInhibit(true)),
|
message: Message::DeviceCommand(DeviceCommand::SetSleepInhibit(true)),
|
||||||
};
|
};
|
||||||
let _ = ctx.tx.send(envelope);
|
let _ = ctx.tx.send(envelope);
|
||||||
@@ -36,7 +37,7 @@ impl ScreenPlugin {
|
|||||||
if let Some(ctx) = &self.ctx {
|
if let Some(ctx) = &self.ctx {
|
||||||
let envelope = Envelope {
|
let envelope = Envelope {
|
||||||
from: self.id().to_string(),
|
from: self.id().to_string(),
|
||||||
to: Destination::Plugin("device".to_string()),
|
to: Destination::Plugin(plugin_ids::DEVICE.to_string()),
|
||||||
message: Message::DeviceCommand(DeviceCommand::SetSleepInhibit(false)),
|
message: Message::DeviceCommand(DeviceCommand::SetSleepInhibit(false)),
|
||||||
};
|
};
|
||||||
let _ = ctx.tx.send(envelope);
|
let _ = ctx.tx.send(envelope);
|
||||||
@@ -47,7 +48,7 @@ impl ScreenPlugin {
|
|||||||
if let Some(ctx) = &self.ctx {
|
if let Some(ctx) = &self.ctx {
|
||||||
let envelope = Envelope {
|
let envelope = Envelope {
|
||||||
from: self.id().to_string(),
|
from: self.id().to_string(),
|
||||||
to: Destination::Plugin("device".to_string()),
|
to: Destination::Plugin(plugin_ids::DEVICE.to_string()),
|
||||||
message: Message::DeviceCommand(DeviceCommand::SetCursorVisible(!hidden)),
|
message: Message::DeviceCommand(DeviceCommand::SetCursorVisible(!hidden)),
|
||||||
};
|
};
|
||||||
let _ = ctx.tx.send(envelope);
|
let _ = ctx.tx.send(envelope);
|
||||||
@@ -76,7 +77,7 @@ impl Plugin for ScreenPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dependencies(&self) -> Vec<String> {
|
fn dependencies(&self) -> Vec<String> {
|
||||||
vec!["device".to_string()]
|
vec![plugin_ids::DEVICE.to_string()]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod state_machine;
|
|||||||
|
|
||||||
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, PlayerStatusData};
|
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, PlayerStatusData};
|
||||||
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||||
|
use crate::core::plugin_ids;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use opencv::highgui;
|
use opencv::highgui;
|
||||||
use processor::VideoProcessor;
|
use processor::VideoProcessor;
|
||||||
@@ -151,7 +152,7 @@ impl Plugin for VideoPlugin {
|
|||||||
if let Some(ctx) = &self.ctx {
|
if let Some(ctx) = &self.ctx {
|
||||||
let _ = ctx.tx.send(Envelope {
|
let _ = ctx.tx.send(Envelope {
|
||||||
from: self.id().to_string(),
|
from: self.id().to_string(),
|
||||||
to: Destination::Plugin("screen".to_string()),
|
to: Destination::Plugin(plugin_ids::SCREEN.to_string()),
|
||||||
message: Message::ScreenLockRequest(true),
|
message: Message::ScreenLockRequest(true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,7 +163,7 @@ impl Plugin for VideoPlugin {
|
|||||||
if let Some(ctx) = &self.ctx {
|
if let Some(ctx) = &self.ctx {
|
||||||
let _ = ctx.tx.send(Envelope {
|
let _ = ctx.tx.send(Envelope {
|
||||||
from: self.id().to_string(),
|
from: self.id().to_string(),
|
||||||
to: Destination::Plugin("screen".to_string()),
|
to: Destination::Plugin(plugin_ids::SCREEN.to_string()),
|
||||||
message: Message::ScreenLockRequest(false),
|
message: Message::ScreenLockRequest(false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -356,12 +357,7 @@ fn publish_state_changed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn status_changed(old: &PlayerStatusData, new: &PlayerStatusData) -> bool {
|
fn status_changed(old: &PlayerStatusData, new: &PlayerStatusData) -> bool {
|
||||||
old.running != new.running
|
old != new
|
||||||
|| old.paused != new.paused
|
|
||||||
|| old.in_transition != new.in_transition
|
|
||||||
|| old.current_index != new.current_index
|
|
||||||
|| old.playlist_length != new.playlist_length
|
|
||||||
|| old.current_video != new.current_video
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock_processor(
|
fn lock_processor(
|
||||||
|
|||||||
@@ -13,28 +13,26 @@ use opencv::{
|
|||||||
};
|
};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
type WindowRectKey = (String, i32, i32, i32, i32);
|
type WindowRectKey = (String, i32, i32, i32, i32);
|
||||||
type LoggedSizes = ((i32, i32), (i32, i32), (i32, i32));
|
type LoggedSizes = ((i32, i32), (i32, i32), (i32, i32));
|
||||||
|
|
||||||
fn log_mat_size(label: &str, frame: &Mat) {
|
fn log_mat_size(label: &str, frame: &Mat) {
|
||||||
use std::sync::Mutex;
|
static SEEN: OnceLock<Mutex<HashSet<(String, i32, i32)>>> = OnceLock::new();
|
||||||
|
|
||||||
static SEEN: Mutex<Option<HashSet<(String, i32, i32)>>> = Mutex::new(None);
|
|
||||||
let key = (label.to_string(), frame.cols(), frame.rows());
|
let key = (label.to_string(), frame.cols(), frame.rows());
|
||||||
let mut guard = SEEN.lock().unwrap();
|
let mut guard = SEEN
|
||||||
let set = guard.get_or_insert_with(HashSet::new);
|
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||||
if set.insert(key) {
|
.lock()
|
||||||
|
.unwrap();
|
||||||
|
if guard.insert(key) {
|
||||||
println!("{}: cols={} rows={}", label, frame.cols(), frame.rows());
|
println!("{}: cols={} rows={}", label, frame.cols(), frame.rows());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_window_image_rect(window_name: &str) {
|
fn log_window_image_rect(window_name: &str) {
|
||||||
use std::sync::Mutex;
|
static SEEN: OnceLock<Mutex<HashSet<WindowRectKey>>> = OnceLock::new();
|
||||||
|
|
||||||
static SEEN: Mutex<Option<HashSet<WindowRectKey>>> = Mutex::new(None);
|
|
||||||
match highgui::get_window_image_rect(window_name) {
|
match highgui::get_window_image_rect(window_name) {
|
||||||
Ok(rect) => {
|
Ok(rect) => {
|
||||||
let key = (
|
let key = (
|
||||||
@@ -44,9 +42,11 @@ fn log_window_image_rect(window_name: &str) {
|
|||||||
rect.width,
|
rect.width,
|
||||||
rect.height,
|
rect.height,
|
||||||
);
|
);
|
||||||
let mut guard = SEEN.lock().unwrap();
|
let mut guard = SEEN
|
||||||
let set = guard.get_or_insert_with(HashSet::new);
|
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||||
if set.insert(key) {
|
.lock()
|
||||||
|
.unwrap();
|
||||||
|
if guard.insert(key) {
|
||||||
println!(
|
println!(
|
||||||
"window.image_rect {}: x={} y={} width={} height={}",
|
"window.image_rect {}: x={} y={} width={} height={}",
|
||||||
window_name, rect.x, rect.y, rect.width, rect.height,
|
window_name, rect.x, rect.y, rect.width, rect.height,
|
||||||
@@ -1750,6 +1750,8 @@ mod tests {
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
},
|
},
|
||||||
ble: BleConfig::default(),
|
ble: BleConfig::default(),
|
||||||
|
character: Default::default(),
|
||||||
|
ai: Default::default(),
|
||||||
source_path: PathBuf::from("/tmp/config.json"),
|
source_path: PathBuf::from("/tmp/config.json"),
|
||||||
source_dir: PathBuf::from("/tmp"),
|
source_dir: PathBuf::from("/tmp"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,25 +36,6 @@ impl WifiPlugin {
|
|||||||
Self { ctx: None }
|
Self { ctx: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_connect_args(ssid: &str, password: &str) -> Vec<String> {
|
|
||||||
let mut args: Vec<String> = ["device", "wifi", "connect", ssid]
|
|
||||||
.into_iter()
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
if !password.trim().is_empty() {
|
|
||||||
args.push("password".to_string());
|
|
||||||
args.push(password.to_string());
|
|
||||||
}
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_hotspot_args(ssid: &str, password: &str) -> Vec<String> {
|
|
||||||
["device", "wifi", "hotspot", "ssid", ssid, "password", password]
|
|
||||||
.into_iter()
|
|
||||||
.map(String::from)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_nmcli(args: &[impl AsRef<std::ffi::OsStr> + std::fmt::Debug]) -> Result<String> {
|
fn run_nmcli(args: &[impl AsRef<std::ffi::OsStr> + std::fmt::Debug]) -> Result<String> {
|
||||||
let output = Command::new("nmcli")
|
let output = Command::new("nmcli")
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -189,7 +170,12 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn connect_network(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
fn connect_network(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
||||||
let output = Self::run_nmcli(&Self::build_connect_args(ssid, password))?;
|
let mut args = vec!["device", "wifi", "connect", ssid];
|
||||||
|
if !password.trim().is_empty() {
|
||||||
|
args.push("password");
|
||||||
|
args.push(password);
|
||||||
|
}
|
||||||
|
let output = Self::run_nmcli(&args)?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -260,7 +246,7 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
||||||
let output = Self::run_nmcli(&Self::build_hotspot_args(ssid, password))?;
|
let output = Self::run_nmcli(&["device", "wifi", "hotspot", "ssid", ssid, "password", password])?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -280,7 +266,7 @@ impl WifiPlugin {
|
|||||||
"connection",
|
"connection",
|
||||||
"show",
|
"show",
|
||||||
"--active",
|
"--active",
|
||||||
])?;;
|
])?;
|
||||||
let hotspot_name = active
|
let hotspot_name = active
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -362,40 +348,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(fields, vec!["wlan0", "wifi", "connected", "Office:LAN"]);
|
assert_eq!(fields, vec!["wlan0", "wifi", "connected", "Office:LAN"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn connect_args_preserve_special_characters() {
|
|
||||||
let args =
|
|
||||||
WifiPlugin::build_connect_args(r#"ssid \"qa\" demo"#, r#"p@ss\\word with spaces"#);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"device",
|
|
||||||
"wifi",
|
|
||||||
"connect",
|
|
||||||
r#"ssid \"qa\" demo"#,
|
|
||||||
"password",
|
|
||||||
r#"p@ss\\word with spaces"#,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hotspot_args_preserve_special_characters() {
|
|
||||||
let args = WifiPlugin::build_hotspot_args("Showen AP", r#"\\quoted pass\\"#);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
args,
|
|
||||||
vec![
|
|
||||||
"device",
|
|
||||||
"wifi",
|
|
||||||
"hotspot",
|
|
||||||
"ssid",
|
|
||||||
"Showen AP",
|
|
||||||
"password",
|
|
||||||
r#"\\quoted pass\\"#,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use showen_v2::core::config::AppConfig;
|
use showen_v2::core::config::AppConfig;
|
||||||
use showen_v2::core::message::{Destination, Envelope, Message};
|
use showen_v2::core::message::Message;
|
||||||
use showen_v2::core::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
|
use showen_v2::core::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
|
||||||
use showen_v2::core::plugin_loader::{
|
use showen_v2::core::plugin_loader::{
|
||||||
ErrorPolicy, PluginLoader, PluginManifest, PluginRegistry, PluginRegistryEntry,
|
ErrorPolicy, PluginLoader, PluginManifest, PluginRegistry, PluginRegistryEntry,
|
||||||
@@ -84,21 +84,6 @@ fn manifest(id: &str, version: &str) -> PluginManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_with_caps(id: &str, version: &str, caps: Vec<&str>, required: Vec<&str>) -> PluginManifest {
|
|
||||||
PluginManifest {
|
|
||||||
id: id.to_string(),
|
|
||||||
version: version.to_string(),
|
|
||||||
sdk_version: "0.2.0".to_string(),
|
|
||||||
dependencies: vec![],
|
|
||||||
error_policy: ErrorPolicy::AutoRollback,
|
|
||||||
so_filename: format!("lib{}.so", id.replace('-', "_")),
|
|
||||||
capabilities: caps.iter().map(|s| s.to_string()).collect(),
|
|
||||||
required_capabilities: required.iter().map(|s| s.to_string()).collect(),
|
|
||||||
test_timeout_ms: 5000,
|
|
||||||
auto_test: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_registry(store: &Path, plugin_id: &str, active: &str, stable: Option<&str>) {
|
fn setup_registry(store: &Path, plugin_id: &str, active: &str, stable: Option<&str>) {
|
||||||
let loader = PluginLoader::new(store);
|
let loader = PluginLoader::new(store);
|
||||||
let mut registry = PluginRegistry::default();
|
let mut registry = PluginRegistry::default();
|
||||||
|
|||||||
@@ -7,18 +7,21 @@
|
|||||||
//!
|
//!
|
||||||
//! 经过代码审查,插件管理 API 的 Custom 消息已在 ServiceManager 中完整处理:
|
//! 经过代码审查,插件管理 API 的 Custom 消息已在 ServiceManager 中完整处理:
|
||||||
//! - `plugin_enable` / `plugin_disable` → `set_plugin_enabled()` → `broadcast_plugin_states()`
|
//! - `plugin_enable` / `plugin_disable` → `set_plugin_enabled()` → `broadcast_plugin_states()`
|
||||||
//! - `plugin_rollback` / `plugin_switch` / `plugin_install` / `plugin_check_updates` 已注册但未完整实现业务逻辑
|
//! - `plugin_rollback` / `plugin_switch` / `plugin_install` 均为完整实现:
|
||||||
|
//! `rollback_plugin`(service_manager.rs:432) / `switch_plugin_version`(:617) /
|
||||||
|
//! `install_plugin`(:665),张明远 #25 修复(handle_manager_message Custom 分支)后落地
|
||||||
//! - HTTP routes 的 `plugin_enable_route`/`plugin_disable_route` 发送 `Message::Custom{kind:"plugin_enable",...}`
|
//! - HTTP routes 的 `plugin_enable_route`/`plugin_disable_route` 发送 `Message::Custom{kind:"plugin_enable",...}`
|
||||||
//! 到 `Destination::Manager`,ServiceManager 能够接收并处理。
|
//! 到 `Destination::Manager`,ServiceManager 能够接收并处理。
|
||||||
//! - `/api/plugins` 通过 `HttpState::plugin_states()` 读取状态,状态由
|
//! - `/api/plugins` 通过 `HttpState::plugin_states()` 读取状态,状态由
|
||||||
//! `Message::Custom{kind:"plugin_states",...}` 广播更新,ServiceManager 在每次
|
//! `Message::Custom{kind:"plugin_states",...}` 广播更新,ServiceManager 在每次
|
||||||
//! enable/disable 后调用 `broadcast_plugin_states()` 发出该消息。
|
//! enable/disable 后调用 `broadcast_plugin_states()` 发出该消息。
|
||||||
//! - **结论**:enable/disable 命令闭环已修复(CLAUDE.md #25 已修复项),基本链路可工作。
|
//! - **结论**:Custom 六种 plugin_* kind 全部闭环,enable/disable/rollback/switch/install
|
||||||
//! plugin_rollback/switch/install/check_updates 仅发送消息,ServiceManager 侧仅打印日志,
|
//! 均为完整实现;唯 `plugin_check_updates` 为部分实现——`check_plugin_updates`(:730)
|
||||||
//! 尚无完整业务逻辑,属 M1.2 已知待实现项。
|
//! 会真实查询 repo,但结果仅 println、不回传 HTTP/不广播,结果回传链路属 Phase 2
|
||||||
|
//! 待补(非回归缺陷)。详见 2026-07-03 王浩然风险3全量核查报告(.showen/TEAM_CHAT.md)。
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use showen_v2::core::config::AppConfig;
|
use showen_v2::core::config::{self, AppConfig};
|
||||||
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
|
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
|
||||||
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||||
use showen_v2::core::service_manager::ServiceManager;
|
use showen_v2::core::service_manager::ServiceManager;
|
||||||
@@ -121,7 +124,6 @@ struct RecordingPlugin {
|
|||||||
id: String,
|
id: String,
|
||||||
deps: Vec<String>,
|
deps: Vec<String>,
|
||||||
events: Arc<Mutex<Vec<String>>>,
|
events: Arc<Mutex<Vec<String>>>,
|
||||||
ctx: Option<PluginContext>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecordingPlugin {
|
impl RecordingPlugin {
|
||||||
@@ -130,17 +132,12 @@ impl RecordingPlugin {
|
|||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
deps: deps.into_iter().map(str::to_string).collect(),
|
deps: deps.into_iter().map(str::to_string).collect(),
|
||||||
events,
|
events,
|
||||||
ctx: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record(&self, entry: impl Into<String>) {
|
fn record(&self, entry: impl Into<String>) {
|
||||||
lock_events(&self.events).push(entry.into());
|
lock_events(&self.events).push(entry.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sender(&self) -> Option<std::sync::mpsc::Sender<Envelope>> {
|
|
||||||
self.ctx.as_ref().map(|c| c.tx.clone())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for RecordingPlugin {
|
impl Plugin for RecordingPlugin {
|
||||||
@@ -161,8 +158,7 @@ impl Plugin for RecordingPlugin {
|
|||||||
self.deps.clone()
|
self.deps.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
fn init(&mut self, _ctx: PluginContext) -> Result<()> {
|
||||||
self.ctx = Some(ctx);
|
|
||||||
self.record(format!("init:{}", self.id));
|
self.record(format!("init:{}", self.id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -412,9 +408,9 @@ fn test_config_update_with_corrupted_json_does_not_reload() {
|
|||||||
)));
|
)));
|
||||||
manager.start_all().expect("start_all should succeed");
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
// 写入损坏的 JSON(模拟 http 路由验证失败后不写文件,Manager 不会收到 reload 请求)
|
// routes.rs 的 handle_config_update 先用 config::parse_str 验证请求体,
|
||||||
// 在 routes.rs 中 handle_config_update 先用 config::parse_str 验证,验证失败则直接返回 400
|
// 损坏的 JSON 验证失败直接返回 400:不写盘、也不发送 ConfigReloadRequest。
|
||||||
// 不会发送 ConfigReloadRequest。因此这里我们验证:不发 ConfigReloadRequest 则不广播 ConfigReloaded。
|
// 因此这里验证链路的后半段:不发 ConfigReloadRequest 则不广播 ConfigReloaded。
|
||||||
|
|
||||||
let sender = manager.sender();
|
let sender = manager.sender();
|
||||||
// 不发 ConfigReloadRequest,只发 Shutdown
|
// 不发 ConfigReloadRequest,只发 Shutdown
|
||||||
@@ -492,23 +488,39 @@ fn test_playlist_snapshot_includes_current_index() {
|
|||||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 播放列表为空时,系统不应崩溃(边界条件)。
|
/// 播放列表为空时,配置校验应在入口边界拒绝(防退化)。
|
||||||
|
///
|
||||||
|
/// 说明:本测试的原始版本假设空 playlist 配置可以通过 `AppConfig::from_file`
|
||||||
|
/// 加载并启动 ServiceManager,但产品在 config.rs `validate()` 中故意
|
||||||
|
/// `bail!("playlist 不能为空")`(自 init 提交即存在的校验行为)。
|
||||||
|
/// 运行时也不存在把 config.playlist 清空的路径:
|
||||||
|
/// - `DELETE /api/videos/:name` 只删除磁盘上的视频文件,不修改 config.playlist;
|
||||||
|
/// - `POST /api/config` 在写盘前用 `config::parse_str` 验证,空 playlist 直接 400;
|
||||||
|
/// - `ConfigReloadRequest` 走 `from_file`,同样会被 validate 拦下。
|
||||||
|
/// 因此"运行时空 playlist"在 AppConfig 层不可达,本测试改为锁定两个入口
|
||||||
|
/// 边界的拒绝行为,防止该校验未来被误删。
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_playlist_does_not_panic() {
|
fn test_empty_playlist_rejected() {
|
||||||
let (mut manager, _events, dir) = setup("empty_playlist", 0);
|
let dir = unique_test_dir("empty_playlist_rejected");
|
||||||
|
let config_path = write_test_config(&dir, "test-title", 0);
|
||||||
|
|
||||||
// 空 playlist 仍能正常启动和关闭
|
// 启动边界:AppConfig::from_file 必须拒绝空 playlist
|
||||||
let sender = manager.sender();
|
let err = AppConfig::from_file(&config_path)
|
||||||
manager.start_all().expect("start_all with empty playlist should succeed");
|
.expect_err("from_file should reject empty playlist config");
|
||||||
sender
|
assert!(
|
||||||
.send(Envelope {
|
format!("{err:#}").contains("playlist 不能为空"),
|
||||||
from: "test".to_string(),
|
"from_file error should mention empty playlist, got: {err:#}"
|
||||||
to: Destination::Manager,
|
);
|
||||||
message: Message::Shutdown,
|
|
||||||
})
|
|
||||||
.expect("shutdown should send");
|
|
||||||
|
|
||||||
manager.run().expect("run with empty playlist should succeed");
|
// HTTP 边界:POST /api/config 依赖 config::parse_str 的这个错误返回 400
|
||||||
|
// (routes.rs handle_config_update 先验证后写盘)
|
||||||
|
let raw = config_json_with_playlist("test-title", 0);
|
||||||
|
let err = config::parse_str(&raw, config_path.clone())
|
||||||
|
.expect_err("parse_str should reject empty playlist config");
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("playlist 不能为空"),
|
||||||
|
"parse_str error should mention empty playlist, got: {err:#}"
|
||||||
|
);
|
||||||
|
|
||||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user