diff --git a/.chat_req.json b/.chat_req.json deleted file mode 100644 index cf01bb2..0000000 --- a/.chat_req.json +++ /dev/null @@ -1 +0,0 @@ -{"text":"hello","session_id":"e2e"} diff --git a/.gitignore b/.gitignore index 98a12c3..a42a074 100644 --- a/.gitignore +++ b/.gitignore @@ -4,14 +4,38 @@ target/ *.swo *~ extern/ + # 模型文件不进 git,只保留下载链接在 model_manager.rs 清单里 model_download/ model_store/ + # Live2D 模型文件不进 git(体积大,部署时单独下载到设备) configs/live2d/ -# 测试脚本 + +# 测试/临时脚本与请求体 .test_*.sh .chat_req.json .commit_msg.txt +# 截图与调试 dump(勿再提交到仓库根) +*.xwd +*.png +*.jpg +!docs/**/*.png +# Flutter / Android 构建产物 +clients/flutter/build/ +clients/flutter/.dart_tool/ +clients/flutter/.flutter-plugins-dependencies +clients/flutter/android/.gradle/ +clients/flutter/android/local.properties +clients/flutter/**/GeneratedPluginRegistrant.java +*.apk +*.aab +*.ipa + +# 本机 IDE / OS +.DS_Store +.idea/ +.vscode/ +*.iml diff --git a/.haru.moc3 b/.haru.moc3 deleted file mode 100644 index e5fc43a..0000000 Binary files a/.haru.moc3 and /dev/null differ diff --git a/.haru.model3.json b/.haru.model3.json deleted file mode 100644 index 3e20b91..0000000 --- a/.haru.model3.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "Version": 3, - "FileReferences": { - "Moc": "Haru.moc3", - "Textures": [ - "Haru.2048/texture_00.png", - "Haru.2048/texture_01.png" - ], - "Physics": "Haru.physics3.json", - "Pose": "Haru.pose3.json", - "DisplayInfo": "Haru.cdi3.json", - "Expressions": [ - { - "Name": "F01", - "File": "expressions/F01.exp3.json" - }, - { - "Name": "F02", - "File": "expressions/F02.exp3.json" - }, - { - "Name": "F03", - "File": "expressions/F03.exp3.json" - }, - { - "Name": "F04", - "File": "expressions/F04.exp3.json" - }, - { - "Name": "F05", - "File": "expressions/F05.exp3.json" - }, - { - "Name": "F06", - "File": "expressions/F06.exp3.json" - }, - { - "Name": "F07", - "File": "expressions/F07.exp3.json" - }, - { - "Name": "F08", - "File": "expressions/F08.exp3.json" - } - ], - "Motions": { - "Idle": [ - { - "File": "motions/haru_g_idle.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5 - }, - { - "File": "motions/haru_g_m15.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5 - } - ], - "TapBody": [ - { - "File": "motions/haru_g_m26.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5, - "Sound": "sounds/haru_talk_13.wav" - }, - { - "File": "motions/haru_g_m06.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5, - "Sound": "sounds/haru_Info_14.wav" - }, - { - "File": "motions/haru_g_m20.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5, - "Sound": "sounds/haru_normal_6.wav" - }, - { - "File": "motions/haru_g_m09.motion3.json", - "FadeInTime": 0.5, - "FadeOutTime": 0.5, - "Sound": "sounds/haru_Info_04.wav" - } - ] - }, - "UserData": "Haru.userdata3.json" - }, - "Groups": [ - { - "Target": "Parameter", - "Name": "EyeBlink", - "Ids": [ - "ParamEyeLOpen", - "ParamEyeROpen" - ] - }, - { - "Target": "Parameter", - "Name": "LipSync", - "Ids": [ - "ParamMouthOpenY" - ] - } - ], - "HitAreas": [ - { - "Id": "HitArea", - "Name": "Head" - }, - { - "Id": "HitArea2", - "Name": "Body" - } - ] -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9bba1d3..33b8394 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,14 +90,17 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi ## 当前状态 -- **质量**: **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 收尾完成**(147/147 两轮验收全绿、风险3 关闭、老板已提交 2a6e14b、设备已同步并复验零 warning),仅差老板打 tag(建议在 2a6e14b 上打 `m1.2`) -- **DevicePlugin 能力**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64) -- **ScreenPlugin**: 已重构为 thin wrapper -- **Flutter App**: 完成度 ~98%, APK v0.3 (52.6MB) 已编译, cupertino_icons 已修复 -- **API 文档**: 已校准(以 routes.rs 为唯一权威重写) -- **示例插件**: 已完善为开发者参考模板 (manifest.json + 请求/响应示范 + 7 个测试) -- **M1.2 进展**: 目标机两轮验收完成(2026-07-03,147/147 零 warning,证据见 TEAM_CHAT.md);HTTP 路由测试实为 12 个(旧记录 9 有误)、lib 单测实为 105(旧记录 107 有误);剩余 commit+tag、风险3 补派核查 +> **功能成熟度权威清单见 `docs/STATUS.md`(2026-07-09 整理)**。此处只保留 CEO 一页摘要。 + +- **质量 (M1.2)**: **147/147 已在 Linux ARM64 目标机验证**(lib 105 + example 7 + m1_2_dynamic 15 + m1_2_http 12 + m1_2_service_manager 8);零 warning;2026-07-04 三提交亦已复验全绿 +- **里程碑**: M1.1 ✅ · M1.2 ✅(仅差老板打 tag `m1.2`)· **M2.1 🟡 半成品** +- **DevicePlugin**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64) +- **插件**: video / http / ble / wifi / device / screen / **live2d** / **ai** +- **Flutter App**: M1 控制能力可用;**M2.1 角色/对话/模型页已写代码、debug APK 已出(~146MB),真机验收未闭环** → 见 STATUS.md §3.3 +- **Web 控制端**: 已切到 `chat.html`/`chat.js`(角色/对话/模型入口有);非旧 WEB_UI 常量 +- **BLE**: 广播注册已修(不可 LocalName + Includes 双设);设备 ActiveInstances=1 曾验证 +- **AI**: 设备文字对话 API 实测可通;延迟/模型档位/失败体验仍需产品化 +- **本机开发环境 (Mac)**: Flutter 3.44.5 + JDK17 + Android SDK36(`~/.showen_dev_env`) ### 已修复(本轮) @@ -148,26 +151,30 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi 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 完成。进行中:架构设计(王思远)。素材依赖:数字人/歌姬内容包需视频素材,先用现有猫狗包验证切换机制。 +✅ PRD(docs/M2.1_PRD.md)+ Spike + 后端 AI/模型/角色 API + Web 控制台重构 + App M2.1 页面骨架。 +**诚实结论**:能演示链路 ≠ 产品完成。详见 `docs/STATUS.md`。 -### 待处理 +### 待处理(精简 · 2026-07-09) -1. **[M1.2 收尾]** ✅ commit(老板 2a6e14b)→ ✅ 设备同步至 2a6e14b + cargo check 零 warning(周雅婷 2026-07-03,设备仅剩本地调参文件 configs/dog_state_machine.json 一处差异);**仅剩:老板打 tag(建议在 2a6e14b 上打 `m1.2`)** -2. ~~**[M1.2 收尾]** tests/m1_2_http.rs 过时注释更新~~ ✅ 赵雨薇完成(2026-07-03,纯注释改动,核实点位后订正为"六种 plugin_* 闭环,唯 check_updates 结果回传属 Phase 2");注意此改动在第二轮验收之后,commit 前设备端补一次 cargo check 即可 -3. **[M1.2 收尾]** sanitize_filename 集成测试测的是逻辑复制而非产品函数,转正需产品侧小改动(改 pub 或 routes.rs 内加单测)— 赵雨薇上报 -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),修复前需等下一条消息可能永不退出 +**P0 / M2.1 闭环** +1. **工作区提交**:本机有未提交的 BLE 修复 + HTTP 对话修补 + Flutter M2.1 页(老板提交,不代办) +2. **设备代码对齐 + 回归**:同步后 `cargo check/test --workspace` +3. **真机四旅程**:配网 → 角色切换 → 文字/语音对话 → 模型管理(App + Web) +4. **对话失败态产品化**:AI 未就绪/超时/缺模型时客户端明确提示 + +**P1** +5. App 对话历史本地持久化、release APK +6. 模型下载进度与错误重试 UX +7. 数字人/歌姬素材(PRD 外部依赖) + +**P2 / 收尾** +8. **[M1.2]** 老板打 tag `m1.2` +9. sanitize_filename 测试转正;单实例保护;CursorVisibility 清理 +10. DevicePlugin 阶段三(framebuffer/触摸/音频/多平台)— 更后 + +**环境** +- 设备:`ssh -p 2222 showen@192.168.31.105` · Web `http://192.168.31.105:5000/` +- 本机 Flutter:`source ~/.showen_dev_env` --- @@ -228,12 +235,14 @@ export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bi | 需要做什么 | 读什么文件 | |-----------|-----------| +| **功能成熟度 / 不完善清单** | **`docs/STATUS.md`** | | CEO 深层经验和管理方法论 | `souls/chen-yifei.md` | | 查看团队最新动态/沟通 | `.showen/TEAM_CHAT.md` | | 查看/修改公司详细规范 | `.showen/COMPANY_RULES.md` | | 评估团队绩效/制度详情 | `docs/TEAM.md` | | 查看工作流程/审核标准 | `docs/WORKFLOW.md` | -| 查看提交历史 | `PROGRESS.md` | +| 里程碑摘要 | `PROGRESS.md` | +| M2.1 产品需求 | `docs/M2.1_PRD.md` | | 派发任务给 PM | `.showen/inbox/pm.md` | | 查看某成员详情 | `souls/.md` | | 代码审核参考 | `docs/CODE_REVIEW.md` | diff --git a/PROGRESS.md b/PROGRESS.md index d326c3d..fceaf46 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,53 +1,44 @@ # ShowenV2 — 项目进度 -> 当前状态和待办事项的权威来源是 `CLAUDE.md`。本文件保留里程碑摘要和最近变更。 -> 完整提交历史见 `.showen/PROGRESS_ARCHIVE.md`。 +> **功能成熟度看 `docs/STATUS.md`。** +> 派发/铁律/团队看 `CLAUDE.md`。 +> 完整提交历史见 `.showen/PROGRESS_ARCHIVE.md`(若有)。 ## 当前里程碑 -**M1.2 — 测试验收已通过,收尾中** 🔄 -- 集成测试全部编写完毕(ServiceManager 8 + HTTP路由 12 + 动态插件 15 = 35 个) -- 2026-07-03 目标机(Linux ARM64, 192.168.31.105)两轮验收:**147/147 全过、零 warning**(第一轮暴露 1 个测试前提错误 + 3 个 warning,返工后第二轮全绿,证据见 TEAM_CHAT.md) -- 剩余:commit 返工测试文件 + 打 tag(待批准)+ 风险3 生产者核查(王浩然) +| 里程碑 | 状态 | +|--------|------| +| M1.1 | ✅ 完成 | +| M1.2 | ✅ 验收通过(147/147);仅差 tag `m1.2` | +| **M2.1 语音数字生命 V1** | 🟡 **半成品** — 后端/Web/App 有骨架,真机验收与体验未闭环 | ---- +## M2.1 一句话 -**M1.1 — 完成** ✅ -- 30 个提交,Phase 1 骨架 + 功能迁移 + 动态插件 + DevicePlugin 阶段一/二 -- 115/115 测试通过(107 核心 + 8 集成),零 warning(M1.1结束时77/77,M1.2期间增至115) -- DevicePlugin: Display + SleepInhibit + Backlight + Cursor (Linux ARM64) -- ScreenPlugin 重构为 thin wrapper +本地 ASR→LLM→TTS,声音出在 **App/Web**,设备负责算力和画面;角色可切换,模型可管理。 +**现状**:API 与 UI 入口大致齐了,**不能宣称功能完善**(见 STATUS.md 缺口表)。 -## 最近变更 (提交 26-30) +## 最近本机工作(未全部入库) -| # | 提交 | 内容 | 负责人 | -|---|------|------|--------| -| 26 | `48d1eeb` | plugin-sdk 同步 Device 类型 | 李思琪 | -| 27 | `f060519` | DeviceCommand 添加 SetCursorVisible | 张明远 | -| 28 | `5310a92` | LinuxArm64Backend 添加光标控制 | 赵雨薇 | -| 29 | `bf41c45` | ScreenPlugin 重构为 thin wrapper | 赵雨薇 | -| 30 | `be08c63` | 新增 4 个光标控制集成测试 | 李思琪 | +- BLE:修复 BlueZ `LocalName` + `Includes["local-name"]` 冲突;串行注册 +- HTTP:语音对话补 Live2D 说话态与 session;Web 补 `X-Session-Id` +- Flutter:角色 / 对话 / 模型三页 + debug APK(~146MB) +- 仓库整理:根目录截图/临时模型清除;运维脚本迁 `scripts/device/`;新增 `docs/STATUS.md` ## 架构概览 ``` -core/ (插件微内核) - ServiceManager → 生命周期/消息路由/错误策略 - Plugin trait → 统一插件接口 - Message enum → 类型安全消息 (Serialize/Deserialize) - 动态插件层 → FFI Loader / Runtime / Self-Test - -plugins/ (一切皆插件) - video/ screen/ http/ ble/ wifi/ device/ - (未来: render/ avatar/ vr/ ar/ voice/ ai/ singer/) +core/ 插件微内核 · 消息 · 配置 · 动态插件 +plugins/ video · http · ble · wifi · device · screen · live2d · ai +configs/ 角色内容包 JSON +clients/ flutter 安卓控制端 +scripts/device 设备运维脚本 +docs/STATUS.md 功能成熟度板 ``` -## 实机验证结果 +## 实机锚点 -| 验证项 | 结果 | -|--------|------| -| `--validate` | 21 个视频路径全部有效 | -| 插件初始化 | 5/5 正常 start | -| HTTP API | `/api/status`、`/api/playlist` 正常 | -| framebuffer | fb0 480x800 检测成功 | -| Release 编译 | 9.4MB ARM aarch64 | +| 项 | 值 | +|----|-----| +| 设备 | Radxa Cubie A7A · `192.168.31.105:2222` · showen | +| Web | `http://192.168.31.105:5000/` | +| 本机 APK | `clients/flutter/build/app/outputs/flutter-apk/app-debug.apk` | diff --git a/README.md b/README.md index 75e2732..97867b8 100644 --- a/README.md +++ b/README.md @@ -6,33 +6,20 @@ ``` ShowenV2/ +├── CLAUDE.md / docs/STATUS.md # 管理手册 · 功能成熟度板 ├── src/ -│ ├── core/ # 插件微内核 -│ │ ├── message.rs # 类型安全消息协议 (Serialize/Deserialize) -│ │ ├── plugin.rs # Plugin trait 定义 -│ │ ├── service_manager.rs # 生命周期管理 + 消息路由 + 错误策略 -│ │ ├── config.rs # 配置解析/验证 -│ │ ├── dispatch.rs # 文本命令解析 -│ │ ├── plugin_abi.rs # C FFI 类型 (动态插件边界) -│ │ ├── dynamic_plugin.rs # DynamicPlugin (libloading) -│ │ ├── plugin_loader.rs # plugin_store/ 扫描/加载 -│ │ ├── version_manager.rs # 版本切换/回退/GC -│ │ └── plugin_repo.rs # 远程仓库 HTTP 客户端 -│ └── plugins/ # 功能插件 -│ ├── video/ # 视频播放引擎 (OpenCV) -│ ├── http/ # Web UI + REST API (warp) -│ ├── ble/ # BLE 配网 (D-Bus BlueZ) -│ ├── wifi/ # WiFi 管理 (nmcli) -│ ├── device/ # 设备能力 (Display/SleepInhibit/Backlight/Cursor) -│ └── screen/ # 屏幕管理 (thin wrapper,转发到 device) -├── plugin-sdk/ # 动态插件开发 SDK -├── plugins/ -│ └── example-plugin/ # 示例动态插件 (cdylib) -├── configs/ # 配置文件 (状态机 JSON) -├── clients/ # 外部控制客户端应用 -└── souls/ # 团队成员档案 +│ ├── core/ # 插件微内核(消息/配置/动态插件/版本) +│ └── plugins/ # video · http · ble · wifi · device · screen · live2d · ai +├── configs/ # 角色内容包 JSON +├── clients/flutter/ # 安卓控制 App(M1 遥控 + M2.1 对话骨架) +├── scripts/device/ # 设备运维脚本 +├── plugin-sdk/ · plugins/ # 动态插件 SDK 与示例 +├── tests/ # M1.2 集成测试 +└── docs/ # PRD / 测试计划 / STATUS ``` +**功能做到哪了**:请先读 [`docs/STATUS.md`](docs/STATUS.md),勿仅看提交日期。 + ## 技术栈 - **语言**: Rust (edition 2021) @@ -47,7 +34,7 @@ ShowenV2/ ShowenV2 支持两种插件模式: -1. **静态插件** — 编译时链接,6 个内置插件 (video, http, ble, wifi, device, screen) +1. **静态插件** — 编译时链接(video, http, ble, wifi, device, screen, live2d, ai 等) 2. **动态插件** — 运行时加载 `.so` 文件,通过 `extern "C"` FFI + JSON 序列化通信 动态插件特性: diff --git a/clients/flutter/android/app/build.gradle b/clients/flutter/android/app/build.gradle index b3123b4..f2a5a75 100644 --- a/clients/flutter/android/app/build.gradle +++ b/clients/flutter/android/app/build.gradle @@ -7,6 +7,7 @@ plugins { android { namespace 'com.showen.flutter' compileSdk flutter.compileSdkVersion + ndkVersion = "28.2.13676358" defaultConfig { applicationId 'com.showen.flutter' diff --git a/clients/flutter/android/app/src/main/AndroidManifest.xml b/clients/flutter/android/app/src/main/AndroidManifest.xml index d1323a2..0b53000 100644 --- a/clients/flutter/android/app/src/main/AndroidManifest.xml +++ b/clients/flutter/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + main() async { runApp( MultiProvider( providers: [ + Provider.value(value: httpApiService), Provider.value(value: webSocketService), ChangeNotifierProvider( create: (_) => DebugProvider(webSocketService: webSocketService), @@ -70,6 +77,24 @@ Future main() async { debugProvider: context.read(), )..bootstrap(), ), + ChangeNotifierProvider( + create: (context) => CharacterProvider( + httpApiService: httpApiService, + debugProvider: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => ChatProvider( + httpApiService: httpApiService, + debugProvider: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => ModelProvider( + httpApiService: httpApiService, + debugProvider: context.read(), + ), + ), ], child: const ShowenApp(), ), @@ -95,18 +120,27 @@ final GoRouter _router = GoRouter( StatefulShellBranch( routes: [ GoRoute( - path: '/playback', - name: 'playback', - builder: (context, state) => const PlaybackScreen(), + path: '/characters', + name: 'characters', + builder: (context, state) => const CharactersScreen(), ), ], ), StatefulShellBranch( routes: [ GoRoute( - path: '/trigger', - name: 'trigger', - builder: (context, state) => const TriggerScreen(), + path: '/chat', + name: 'chat', + builder: (context, state) => const ChatScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/playback', + name: 'playback', + builder: (context, state) => const PlaybackScreen(), ), ], ), @@ -132,15 +166,23 @@ final GoRouter _router = GoRouter( path: '/settings', name: 'settings', builder: (context, state) => const SettingsScreen(), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/debug', - name: 'debug', - builder: (context, state) => const DebugScreen(), + routes: [ + GoRoute( + path: 'models', + name: 'models', + builder: (context, state) => const ModelsScreen(), + ), + GoRoute( + path: 'trigger', + name: 'trigger', + builder: (context, state) => const TriggerScreen(), + ), + GoRoute( + path: 'debug', + name: 'debug', + builder: (context, state) => const DebugScreen(), + ), + ], ), ], ), diff --git a/clients/flutter/lib/models/ai_model.dart b/clients/flutter/lib/models/ai_model.dart new file mode 100644 index 0000000..8fd3e30 --- /dev/null +++ b/clients/flutter/lib/models/ai_model.dart @@ -0,0 +1,123 @@ +class AiModelInfo { + const AiModelInfo({ + required this.id, + required this.kind, + required this.name, + required this.version, + required this.size, + required this.memoryRequired, + required this.recommendedTier, + required this.downloaded, + required this.downloadProgress, + }); + + final String id; + final String kind; + final String name; + final String version; + final int size; + final int memoryRequired; + final String recommendedTier; + final bool downloaded; + final int downloadProgress; + + String get kindLabel { + switch (kind.toLowerCase()) { + case 'llm': + return 'LLM'; + case 'asr': + return 'ASR'; + case 'tts': + return 'TTS'; + default: + return kind.toUpperCase(); + } + } + + String get sizeLabel => _formatBytes(size); + String get memoryLabel => _formatBytes(memoryRequired); + + factory AiModelInfo.fromJson(Map json) { + return AiModelInfo( + id: json['id']?.toString() ?? '', + kind: json['kind']?.toString() ?? '', + name: json['name']?.toString() ?? json['id']?.toString() ?? '', + version: json['version']?.toString() ?? '', + size: _asInt(json['size']), + memoryRequired: _asInt(json['memory_required']), + recommendedTier: json['recommended_tier']?.toString() ?? '', + downloaded: json['downloaded'] == true, + downloadProgress: _asInt(json['download_progress']), + ); + } + + static int _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } + + static String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } +} + +class ModelsSnapshot { + const ModelsSnapshot({ + required this.models, + required this.active, + required this.usedSpace, + required this.quota, + }); + + final List models; + final Map active; + final int usedSpace; + final int quota; + + String get usedLabel => AiModelInfo._formatBytes(usedSpace); + String get quotaLabel => AiModelInfo._formatBytes(quota); + double get usageRatio => quota <= 0 ? 0 : (usedSpace / quota).clamp(0.0, 1.0); + + bool isActive(AiModelInfo model) { + final current = active[model.kind.toLowerCase()]; + return current != null && current == model.id; + } + + factory ModelsSnapshot.fromJson(Map json) { + final rawModels = json['models']; + final models = []; + if (rawModels is List) { + for (final item in rawModels) { + if (item is Map) { + models.add(AiModelInfo.fromJson(item)); + } else if (item is Map) { + models.add(AiModelInfo.fromJson(Map.from(item))); + } + } + } + + final active = {}; + final rawActive = json['active']; + if (rawActive is Map) { + rawActive.forEach((key, value) { + active[key.toString().toLowerCase()] = value.toString(); + }); + } + + return ModelsSnapshot( + models: models, + active: active, + usedSpace: AiModelInfo._asInt(json['used_space']), + quota: AiModelInfo._asInt(json['quota']), + ); + } +} diff --git a/clients/flutter/lib/models/character_pack.dart b/clients/flutter/lib/models/character_pack.dart new file mode 100644 index 0000000..c74c1c3 --- /dev/null +++ b/clients/flutter/lib/models/character_pack.dart @@ -0,0 +1,92 @@ +class CharacterPack { + const CharacterPack({ + required this.filename, + required this.name, + required this.characterType, + required this.renderType, + this.live2dModel, + }); + + final String filename; + final String name; + final String characterType; + final String renderType; + final String? live2dModel; + + bool get isLive2d => renderType.toLowerCase() == 'live2d'; + + String get typeLabel { + switch (characterType.toLowerCase()) { + case 'human': + return '数字人'; + case 'singer': + return '歌姬'; + case 'live2d': + return 'Live2D'; + case 'pet': + default: + return '宠物'; + } + } + + IconDataLike get iconHint { + switch (characterType.toLowerCase()) { + case 'human': + return IconDataLike.person; + case 'singer': + return IconDataLike.mic; + case 'live2d': + return IconDataLike.sparkle; + default: + return IconDataLike.pets; + } + } + + factory CharacterPack.fromJson(Map json) { + final character = json['character']; + final Map charMap = character is Map + ? Map.from(character) + : const {}; + final filename = json['filename']?.toString() ?? ''; + return CharacterPack( + filename: filename, + name: charMap['name']?.toString().isNotEmpty == true + ? charMap['name'].toString() + : filename, + characterType: charMap['character_type']?.toString() ?? 'pet', + renderType: charMap['render_type']?.toString() ?? 'video', + live2dModel: charMap['live2d_model']?.toString(), + ); + } +} + +/// Avoid importing Flutter material in pure model unit tests. +enum IconDataLike { pets, person, mic, sparkle } + +class AvailableConfigs { + const AvailableConfigs({ + required this.configs, + required this.active, + }); + + final List configs; + final String active; + + factory AvailableConfigs.fromJson(Map json) { + final raw = json['configs']; + final list = []; + if (raw is List) { + for (final item in raw) { + if (item is Map) { + list.add(CharacterPack.fromJson(item)); + } else if (item is Map) { + list.add(CharacterPack.fromJson(Map.from(item))); + } + } + } + return AvailableConfigs( + configs: list, + active: json['active']?.toString() ?? '', + ); + } +} diff --git a/clients/flutter/lib/models/chat_models.dart b/clients/flutter/lib/models/chat_models.dart new file mode 100644 index 0000000..fbe0520 --- /dev/null +++ b/clients/flutter/lib/models/chat_models.dart @@ -0,0 +1,45 @@ +class ChatTurnResponse { + const ChatTurnResponse({ + required this.sessionId, + required this.replyText, + this.transcription, + this.replyAudioPath, + this.error, + }); + + final String sessionId; + final String replyText; + final String? transcription; + final String? replyAudioPath; + final String? error; + + bool get isOk => error == null || error!.isEmpty; + + factory ChatTurnResponse.fromJson(Map json) { + return ChatTurnResponse( + sessionId: json['session_id']?.toString() ?? '', + replyText: json['reply_text']?.toString() ?? '', + transcription: json['transcription']?.toString(), + replyAudioPath: json['reply_audio_path']?.toString(), + error: json['error']?.toString(), + ); + } +} + +enum ChatBubbleRole { user, assistant, system } + +class ChatBubble { + ChatBubble({ + required this.role, + required this.text, + this.audioPath, + this.isPending = false, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + final ChatBubbleRole role; + String text; + String? audioPath; + bool isPending; + final DateTime createdAt; +} diff --git a/clients/flutter/lib/providers/character_provider.dart b/clients/flutter/lib/providers/character_provider.dart new file mode 100644 index 0000000..6e1e8c2 --- /dev/null +++ b/clients/flutter/lib/providers/character_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; + +import '../models/character_pack.dart'; +import '../services/http_api_service.dart'; +import 'debug_provider.dart'; + +class CharacterProvider extends ChangeNotifier { + CharacterProvider({ + required HttpApiService httpApiService, + required DebugProvider debugProvider, + }) : _http = httpApiService, + _debug = debugProvider; + + final HttpApiService _http; + final DebugProvider _debug; + + List _packs = const []; + String _active = ''; + bool _loading = false; + bool _switching = false; + String? _error; + + List get packs => _packs; + String get activeFilename => _active; + bool get isLoading => _loading; + bool get isSwitching => _switching; + String? get error => _error; + + CharacterPack? get activePack { + for (final pack in _packs) { + if (pack.filename == _active) return pack; + } + return null; + } + + Future refresh() async { + _loading = true; + _error = null; + notifyListeners(); + try { + final snapshot = await _http.getAvailableCharacterConfigs(); + _packs = snapshot.configs; + _active = snapshot.active; + _debug.addHttpLog( + 'Loaded characters', + details: 'count=${_packs.length} active=$_active', + ); + } catch (error) { + _error = error.toString(); + _debug.addHttpLog('Load characters failed', details: _error); + } finally { + _loading = false; + notifyListeners(); + } + } + + Future switchTo(String filename) async { + if (_switching || filename == _active) return false; + _switching = true; + _error = null; + notifyListeners(); + try { + final result = await _http.switchConfig(filename); + if (!result.isOk) { + _error = result.message; + _debug.addHttpLog('Switch character failed', details: result.message); + return false; + } + _active = filename; + _debug.addHttpLog('Switched character', details: filename); + // 热重载后刷新列表,确保 active 与服务端一致 + await refresh(); + return true; + } catch (error) { + _error = error.toString(); + _debug.addHttpLog('Switch character failed', details: _error); + return false; + } finally { + _switching = false; + notifyListeners(); + } + } +} diff --git a/clients/flutter/lib/providers/chat_provider.dart b/clients/flutter/lib/providers/chat_provider.dart new file mode 100644 index 0000000..17a9c45 --- /dev/null +++ b/clients/flutter/lib/providers/chat_provider.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../models/chat_models.dart'; +import '../services/http_api_service.dart'; +import 'debug_provider.dart'; + +class ChatProvider extends ChangeNotifier { + ChatProvider({ + required HttpApiService httpApiService, + required DebugProvider debugProvider, + }) : _http = httpApiService, + _debug = debugProvider; + + final HttpApiService _http; + final DebugProvider _debug; + final AudioRecorder _recorder = AudioRecorder(); + final AudioPlayer _player = AudioPlayer(); + + final List _messages = []; + String _sessionId = 'app_${DateTime.now().millisecondsSinceEpoch}'; + bool _busy = false; + bool _recording = false; + String? _error; + String? _status; + + List get messages => List.unmodifiable(_messages); + bool get isBusy => _busy; + bool get isRecording => _recording; + String? get error => _error; + String? get status => _status; + String get sessionId => _sessionId; + + void clearHistory() { + _messages.clear(); + _sessionId = 'app_${DateTime.now().millisecondsSinceEpoch}'; + _error = null; + _status = '已清空对话'; + notifyListeners(); + } + + Future sendText(String text) async { + final content = text.trim(); + if (content.isEmpty || _busy) return; + + _messages.add(ChatBubble(role: ChatBubbleRole.user, text: content)); + final pending = ChatBubble( + role: ChatBubbleRole.assistant, + text: '思考中...', + isPending: true, + ); + _messages.add(pending); + _busy = true; + _error = null; + _status = '角色思考中...'; + notifyListeners(); + + try { + final response = await _http.chatText( + text: content, + sessionId: _sessionId, + ); + await _applyResponse(pending, response); + } catch (error) { + pending + ..isPending = false + ..text = '请求失败: $error'; + _error = error.toString(); + _debug.addHttpLog('chatText failed', details: _error); + } finally { + _busy = false; + if (_status == '角色思考中...') { + _status = null; + } + notifyListeners(); + } + } + + Future startRecording() async { + if (_busy || _recording) return false; + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + _error = '需要麦克风权限才能语音对话'; + _status = _error; + notifyListeners(); + return false; + } + final dir = await getTemporaryDirectory(); + final path = + '${dir.path}/showen_rec_${DateTime.now().millisecondsSinceEpoch}.wav'; + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 16000, + numChannels: 1, + ), + path: path, + ); + _recording = true; + _error = null; + _status = '正在录音... 松开发送'; + _debug.addBleLog('Voice recording started'); + notifyListeners(); + return true; + } catch (error) { + _error = '无法开始录音: $error'; + _status = _error; + _debug.addHttpLog('startRecording failed', details: _error); + notifyListeners(); + return false; + } + } + + Future cancelRecording() async { + if (!_recording) return; + try { + final path = await _recorder.stop(); + if (path != null) { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + } catch (_) {} + _recording = false; + _status = '已取消录音'; + notifyListeners(); + } + + Future stopRecordingAndSend() async { + if (!_recording || _busy) return; + _recording = false; + _status = '正在识别...'; + notifyListeners(); + + String? path; + try { + path = await _recorder.stop(); + } catch (error) { + _error = '停止录音失败: $error'; + _status = _error; + notifyListeners(); + return; + } + + if (path == null) { + _error = '没有录到音频'; + _status = _error; + notifyListeners(); + return; + } + + final file = File(path); + if (!await file.exists()) { + _error = '录音文件不存在'; + _status = _error; + notifyListeners(); + return; + } + + final bytes = await file.readAsBytes(); + try { + await file.delete(); + } catch (_) {} + + if (bytes.length < 1000) { + _error = '录音太短,请按住再说一会儿'; + _status = _error; + notifyListeners(); + return; + } + + _messages.add( + ChatBubble(role: ChatBubbleRole.user, text: '🎤 语音消息'), + ); + final pending = ChatBubble( + role: ChatBubbleRole.assistant, + text: '思考中...', + isPending: true, + ); + _messages.add(pending); + _busy = true; + _status = '角色思考中...'; + notifyListeners(); + + try { + final response = await _http.chatAudio( + bytes: bytes, + format: 'wav', + sessionId: _sessionId, + ); + if (response.transcription != null && + response.transcription!.trim().isNotEmpty) { + // 回填用户气泡为转写文本 + for (var i = _messages.length - 1; i >= 0; i--) { + if (_messages[i].role == ChatBubbleRole.user) { + _messages[i].text = response.transcription!; + break; + } + } + } + await _applyResponse(pending, response); + } catch (error) { + pending + ..isPending = false + ..text = '语音对话失败: $error'; + _error = error.toString(); + _debug.addHttpLog('chatAudio failed', details: _error); + } finally { + _busy = false; + if (_status == '角色思考中...') { + _status = null; + } + notifyListeners(); + } + } + + Future _applyResponse( + ChatBubble pending, + ChatTurnResponse response, + ) async { + pending.isPending = false; + if (!response.isOk) { + pending.text = response.error ?? '对话失败'; + _error = pending.text; + _status = pending.text; + return; + } + if (response.sessionId.isNotEmpty) { + _sessionId = response.sessionId; + } + pending.text = response.replyText.isEmpty ? '(空回复)' : response.replyText; + pending.audioPath = response.replyAudioPath; + _status = '已回复'; + _debug.addHttpLog( + 'chat ok', + details: 'chars=${pending.text.length} audio=${pending.audioPath != null}', + ); + if (pending.audioPath != null && pending.audioPath!.isNotEmpty) { + unawaited(playReply(pending.audioPath!)); + } + } + + Future playReply(String absolutePath) async { + try { + final url = _http.chatAudioFileUrl(absolutePath); + await _player.stop(); + await _player.play(UrlSource(url)); + _status = '正在播放回复...'; + notifyListeners(); + } catch (error) { + _error = '播放失败: $error'; + _debug.addHttpLog('playReply failed', details: _error); + notifyListeners(); + } + } + + @override + void dispose() { + unawaited(_recorder.dispose()); + unawaited(_player.dispose()); + super.dispose(); + } +} diff --git a/clients/flutter/lib/providers/model_provider.dart b/clients/flutter/lib/providers/model_provider.dart new file mode 100644 index 0000000..39ae1a6 --- /dev/null +++ b/clients/flutter/lib/providers/model_provider.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/ai_model.dart'; +import '../models/api_response.dart'; +import '../services/http_api_service.dart'; +import 'debug_provider.dart'; + +class ModelProvider extends ChangeNotifier { + ModelProvider({ + required HttpApiService httpApiService, + required DebugProvider debugProvider, + }) : _http = httpApiService, + _debug = debugProvider; + + final HttpApiService _http; + final DebugProvider _debug; + + ModelsSnapshot? _snapshot; + bool _loading = false; + bool _acting = false; + String? _error; + String? _status; + Timer? _pollTimer; + + ModelsSnapshot? get snapshot => _snapshot; + bool get isLoading => _loading; + bool get isActing => _acting; + String? get error => _error; + String? get status => _status; + + Future refresh() async { + _loading = true; + _error = null; + notifyListeners(); + try { + _snapshot = await _http.getModels(); + _debug.addHttpLog( + 'Loaded models', + details: 'count=${_snapshot?.models.length ?? 0}', + ); + } catch (error) { + _error = error.toString(); + _debug.addHttpLog('Load models failed', details: _error); + } finally { + _loading = false; + notifyListeners(); + } + } + + Future download(String modelId) async { + await _runAction(() => _http.downloadModel(modelId), '下载已启动: $modelId'); + _startPolling(); + } + + Future switchTo({required String modelId, required String kind}) async { + await _runAction( + () => _http.switchModel(modelId: modelId, kind: kind), + '已切换: $modelId', + ); + await refresh(); + } + + Future delete(String modelId) async { + await _runAction(() => _http.deleteModel(modelId), '已删除: $modelId'); + await refresh(); + } + + Future _runAction( + Future Function() action, + String okStatus, + ) async { + if (_acting) return; + _acting = true; + _error = null; + notifyListeners(); + try { + final result = await action(); + _status = result.message.isNotEmpty ? result.message : okStatus; + if (!result.isOk) { + _error = _status; + } + _debug.addHttpLog('Model action ok', details: _status); + } catch (error) { + _error = error.toString(); + _status = _error; + _debug.addHttpLog('Model action failed', details: _error); + } finally { + _acting = false; + notifyListeners(); + } + } + + void _startPolling() { + _pollTimer?.cancel(); + var ticks = 0; + _pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { + ticks += 1; + await refresh(); + final downloading = _snapshot?.models.any( + (m) => m.downloadProgress > 0 && m.downloadProgress < 100 && !m.downloaded, + ) ?? + false; + if (!downloading || ticks > 90) { + timer.cancel(); + } + }); + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } +} diff --git a/clients/flutter/lib/screens/app_shell.dart b/clients/flutter/lib/screens/app_shell.dart index 22fdc8f..0108bc0 100644 --- a/clients/flutter/lib/screens/app_shell.dart +++ b/clients/flutter/lib/screens/app_shell.dart @@ -31,16 +31,21 @@ class AppShell extends StatelessWidget { selectedIcon: Icon(Icons.home), label: '首页', ), + NavigationDestination( + icon: Icon(Icons.face_outlined), + selectedIcon: Icon(Icons.face), + label: '角色', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: '对话', + ), NavigationDestination( icon: Icon(Icons.play_circle_outline), selectedIcon: Icon(Icons.play_circle), label: '播放', ), - NavigationDestination( - icon: Icon(Icons.bolt_outlined), - selectedIcon: Icon(Icons.bolt), - label: '触发', - ), NavigationDestination( icon: Icon(Icons.wifi_outlined), selectedIcon: Icon(Icons.wifi), @@ -51,11 +56,6 @@ class AppShell extends StatelessWidget { selectedIcon: Icon(Icons.settings), label: '设置', ), - NavigationDestination( - icon: Icon(Icons.bug_report_outlined), - selectedIcon: Icon(Icons.bug_report), - label: '调试', - ), ], ), ); diff --git a/clients/flutter/lib/screens/characters_screen.dart b/clients/flutter/lib/screens/characters_screen.dart new file mode 100644 index 0000000..256c627 --- /dev/null +++ b/clients/flutter/lib/screens/characters_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/character_pack.dart'; +import '../providers/character_provider.dart'; +import '../providers/chat_provider.dart'; +import '../theme/app_colors.dart'; + +class CharactersScreen extends StatefulWidget { + const CharactersScreen({super.key}); + + @override + State createState() => _CharactersScreenState(); +} + +class _CharactersScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refresh(); + }); + } + + IconData _iconFor(CharacterPack pack) { + switch (pack.iconHint) { + case IconDataLike.person: + return Icons.person_rounded; + case IconDataLike.mic: + return Icons.mic_rounded; + case IconDataLike.sparkle: + return Icons.auto_awesome_rounded; + case IconDataLike.pets: + return Icons.pets_rounded; + } + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('角色'), + actions: [ + IconButton( + onPressed: provider.isLoading ? null : () => provider.refresh(), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + body: RefreshIndicator( + onRefresh: provider.refresh, + child: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + if (provider.error != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Text( + provider.error!, + style: const TextStyle(color: AppColors.error), + ), + ), + Text( + '选择角色内容包,设备画面与对话人设会同步切换。', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.md), + if (provider.isLoading && provider.packs.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: CircularProgressIndicator()), + ) + else if (provider.packs.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: Text('暂无角色配置包')), + ) + else + ...provider.packs.map((pack) { + final active = pack.filename == provider.activeFilename; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.large), + onTap: provider.isSwitching || active + ? null + : () async { + final ok = await provider.switchTo(pack.filename); + if (!context.mounted) return; + if (ok) { + // 切换角色清空对话上下文(PRD §2.4) + context.read().clearHistory(); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? '已切换到 ${pack.name}' + : (provider.error ?? '切换失败'), + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: active + ? AppColors.primary.withValues(alpha: 0.2) + : AppColors.background, + child: Icon( + _iconFor(pack), + color: active + ? AppColors.primary + : AppColors.textSecondary, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + pack.name, + style: Theme.of(context) + .textTheme + .titleMedium, + ), + ), + if (active) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.success + .withValues(alpha: 0.15), + borderRadius: + BorderRadius.circular(999), + ), + child: const Text( + '使用中', + style: TextStyle( + color: AppColors.success, + fontSize: 12, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + '${pack.typeLabel} · ${pack.renderType} · ${pack.filename}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (provider.isSwitching && !active) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + active + ? Icons.check_circle_rounded + : Icons.chevron_right_rounded, + color: active + ? AppColors.success + : AppColors.textSecondary, + ), + ], + ), + ), + ), + ); + }), + ], + ), + ), + ); + } +} diff --git a/clients/flutter/lib/screens/chat_screen.dart b/clients/flutter/lib/screens/chat_screen.dart new file mode 100644 index 0000000..4f4ddef --- /dev/null +++ b/clients/flutter/lib/screens/chat_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/chat_models.dart'; +import '../providers/character_provider.dart'; +import '../providers/chat_provider.dart'; +import '../theme/app_colors.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refresh(); + }); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent + 80, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + + Future _sendText() async { + final text = _controller.text; + _controller.clear(); + await context.read().sendText(text); + _scrollToEnd(); + } + + @override + Widget build(BuildContext context) { + final chat = context.watch(); + final character = context.watch(); + final activeName = character.activePack?.name ?? '当前角色'; + + return Scaffold( + appBar: AppBar( + title: Text('对话 · $activeName'), + actions: [ + IconButton( + tooltip: '清空会话', + onPressed: chat.isBusy ? null : chat.clearHistory, + icon: const Icon(Icons.delete_outline_rounded), + ), + ], + ), + body: Column( + children: [ + if (chat.status != null) + Container( + width: double.infinity, + color: AppColors.card, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 8, + ), + child: Text( + chat.status!, + style: TextStyle( + color: chat.error != null + ? AppColors.error + : AppColors.textSecondary, + ), + ), + ), + Expanded( + child: chat.messages.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Text( + '文字发送,或按住下方麦克风说话。\n回复会在手机播放(设备本地不出声)。', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: chat.messages.length, + itemBuilder: (context, index) { + final msg = chat.messages[index]; + final isUser = msg.role == ChatBubbleRole.user; + return Align( + alignment: isUser + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + decoration: BoxDecoration( + color: isUser + ? AppColors.primary.withValues(alpha: 0.25) + : AppColors.card, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + msg.text, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (msg.audioPath != null && + msg.audioPath!.isNotEmpty && + !msg.isPending) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => + chat.playReply(msg.audioPath!), + icon: const Icon(Icons.volume_up_rounded), + label: const Text('重播语音'), + ), + ], + if (msg.isPending) + const Padding( + padding: EdgeInsets.only(top: 6), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + enabled: !chat.isBusy && !chat.isRecording, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration( + hintText: '输入文字和角色对话...', + ), + onSubmitted: (_) => _sendText(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: chat.isBusy || chat.isRecording ? null : _sendText, + icon: const Icon(Icons.send_rounded), + ), + const SizedBox(width: 4), + GestureDetector( + onLongPressStart: (_) async { + final ok = + await context.read().startRecording(); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.read().error ?? '无法录音', + ), + ), + ); + } + }, + onLongPressEnd: (_) async { + await context.read().stopRecordingAndSend(); + _scrollToEnd(); + }, + onLongPressCancel: () { + context.read().cancelRecording(); + }, + child: CircleAvatar( + radius: 26, + backgroundColor: chat.isRecording + ? AppColors.error + : AppColors.primary.withValues(alpha: 0.2), + child: Icon( + chat.isRecording ? Icons.mic : Icons.mic_none_rounded, + color: chat.isRecording + ? Colors.white + : AppColors.primary, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/clients/flutter/lib/screens/home_screen.dart b/clients/flutter/lib/screens/home_screen.dart index 15fc581..30cbb62 100644 --- a/clients/flutter/lib/screens/home_screen.dart +++ b/clients/flutter/lib/screens/home_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../providers/character_provider.dart'; import '../providers/device_provider.dart'; import '../providers/player_provider.dart'; import '../providers/wifi_provider.dart'; @@ -16,9 +18,11 @@ class HomeScreen extends StatelessWidget { final deviceProvider = context.watch(); final playerProvider = context.watch(); final wifiProvider = context.watch(); + final characterProvider = context.watch(); final device = deviceProvider.status; final player = playerProvider.status; final wifi = wifiProvider.status; + final characterName = characterProvider.activePack?.name ?? '未同步'; return Scaffold( appBar: AppBar(title: const Text('ShowenV2 控制台')), @@ -28,6 +32,7 @@ class HomeScreen extends StatelessWidget { context.read().refresh(), context.read().bootstrap(), context.read().bootstrap(), + context.read().refresh(), ]); }, child: ListView( @@ -41,6 +46,16 @@ class HomeScreen extends StatelessWidget { accentColor: device.connected ? AppColors.success : AppColors.warning, ), const SizedBox(height: AppSpacing.md), + StatusCard( + title: '当前角色', + value: characterName, + subtitle: characterProvider.activeFilename.isEmpty + ? '点击角色页切换内容包' + : characterProvider.activeFilename, + icon: Icons.face_rounded, + accentColor: AppColors.secondary, + ), + const SizedBox(height: AppSpacing.md), StatusCard( title: '当前播放状态', value: player.currentVideo ?? '暂无播放视频', @@ -59,6 +74,27 @@ class HomeScreen extends StatelessWidget { accentColor: wifi.connected ? AppColors.info : AppColors.border, ), const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: ControlButton( + label: '去对话', + icon: Icons.chat_bubble_rounded, + onPressed: () => context.go('/chat'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ControlButton( + label: '换角色', + icon: Icons.face_rounded, + isFilled: false, + onPressed: () => context.go('/characters'), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: AppSpacing.md), Row( diff --git a/clients/flutter/lib/screens/models_screen.dart b/clients/flutter/lib/screens/models_screen.dart new file mode 100644 index 0000000..1019f10 --- /dev/null +++ b/clients/flutter/lib/screens/models_screen.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/ai_model.dart'; +import '../providers/model_provider.dart'; +import '../theme/app_colors.dart'; + +class ModelsScreen extends StatefulWidget { + const ModelsScreen({super.key}); + + @override + State createState() => _ModelsScreenState(); +} + +class _ModelsScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refresh(); + }); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final snapshot = provider.snapshot; + + return Scaffold( + appBar: AppBar( + title: const Text('模型管理'), + actions: [ + IconButton( + onPressed: provider.isLoading ? null : () => provider.refresh(), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + body: RefreshIndicator( + onRefresh: provider.refresh, + child: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + if (provider.error != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + provider.error!, + style: const TextStyle(color: AppColors.error), + ), + ), + if (provider.status != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + provider.status!, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + if (snapshot != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '存储 ${snapshot.usedLabel} / ${snapshot.quotaLabel}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + LinearProgressIndicator(value: snapshot.usageRatio), + const SizedBox(height: 8), + Text( + '激活: LLM=${snapshot.active['llm'] ?? '-'} · ' + 'ASR=${snapshot.active['asr'] ?? '-'} · ' + 'TTS=${snapshot.active['tts'] ?? '-'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.md), + ], + if (provider.isLoading && snapshot == null) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: CircularProgressIndicator()), + ) + else if (snapshot == null || snapshot.models.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: Text('暂无模型清单(AI 插件可能未就绪)')), + ) + else + ...snapshot.models.map((model) { + final active = snapshot.isActive(model); + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + model.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (active) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: + AppColors.success.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '使用中', + style: TextStyle( + color: AppColors.success, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${model.kindLabel} · ${model.sizeLabel} · 内存 ${model.memoryLabel}', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + if (model.recommendedTier.isNotEmpty) + Text( + model.recommendedTier, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppColors.textSecondary), + ), + if (!model.downloaded && model.downloadProgress > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: LinearProgressIndicator( + value: model.downloadProgress / 100.0, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (!model.downloaded) + FilledButton.tonal( + onPressed: provider.isActing + ? null + : () => provider.download(model.id), + child: Text( + model.downloadProgress > 0 && + model.downloadProgress < 100 + ? '下载中 ${model.downloadProgress}%' + : '下载', + ), + ), + if (model.downloaded && !active) + FilledButton( + onPressed: provider.isActing + ? null + : () => provider.switchTo( + modelId: model.id, + kind: model.kind, + ), + child: const Text('切换使用'), + ), + if (model.downloaded && !active) + OutlinedButton( + onPressed: provider.isActing + ? null + : () => _confirmDelete(context, model), + child: const Text('删除'), + ), + ], + ), + ], + ), + ), + ); + }), + ], + ), + ), + ); + } + + Future _confirmDelete(BuildContext context, AiModelInfo model) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除模型'), + content: Text('确定删除 ${model.name}?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + if (ok == true && context.mounted) { + await context.read().delete(model.id); + } + } +} diff --git a/clients/flutter/lib/screens/settings_screen.dart b/clients/flutter/lib/screens/settings_screen.dart index 223238a..1a41a98 100644 --- a/clients/flutter/lib/screens/settings_screen.dart +++ b/clients/flutter/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../models/video_item.dart'; @@ -80,6 +81,34 @@ class _SettingsScreenState extends State { physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), children: [ + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.memory_rounded), + title: const Text('模型管理'), + subtitle: const Text('下载 / 切换 / 删除 AI 模型'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => context.push('/settings/models'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.bolt_rounded), + title: const Text('状态机触发'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => context.push('/settings/trigger'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.bug_report_rounded), + title: const Text('调试日志'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => context.push('/settings/debug'), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.md), @@ -464,9 +493,17 @@ class _SettingsScreenState extends State { Map.from(results[0] as Map); final available = Map.from(results[1] as Map); + // M2.1: configs 为 {filename, character} 对象列表;兼容旧版纯字符串列表 _availableConfigs = (available['configs'] as List? ?? const []) - .map((item) => item.toString()) + .map((item) { + if (item is String) return item; + if (item is Map) { + return item['filename']?.toString() ?? item.toString(); + } + return item.toString(); + }) + .where((name) => name.isNotEmpty) .toList(growable: false); _activeConfig = available['active']?.toString(); _applyDisplayConfig(Map.from( diff --git a/clients/flutter/lib/services/http_api_service.dart b/clients/flutter/lib/services/http_api_service.dart index fafaeb2..95a8fcd 100644 --- a/clients/flutter/lib/services/http_api_service.dart +++ b/clients/flutter/lib/services/http_api_service.dart @@ -5,8 +5,11 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; +import '../models/ai_model.dart'; import '../models/api_response.dart'; import '../models/ble_status.dart'; +import '../models/character_pack.dart'; +import '../models/chat_models.dart'; import '../models/player_status.dart'; import '../models/video_item.dart'; import '../models/wifi_network.dart'; @@ -300,6 +303,118 @@ class HttpApiService { return ApiResponse.fromJson(_decodeMap(response.body)); } + Future getAvailableCharacterConfigs() async { + final map = await getAvailableConfigs(); + return AvailableConfigs.fromJson(map); + } + + // ── M2.1 AI 对话 / 模型管理 ── + + Future chatText({ + required String text, + String? sessionId, + }) async { + final response = await _client.post( + _uri('/api/chat/text'), + headers: _jsonHeaders, + body: jsonEncode({ + 'text': text, + if (sessionId != null && sessionId.isNotEmpty) 'session_id': sessionId, + }), + ); + // AI 错误也以 JSON ChatResponse 返回(可能是 5xx) + final map = _decodeMap(response.body); + final result = ChatTurnResponse.fromJson(map); + if (response.statusCode >= 200 && response.statusCode < 300) { + return result; + } + if (result.error != null && result.error!.isNotEmpty) { + return result; + } + throw ApiException( + map['message']?.toString() ?? '对话失败 (${response.statusCode})', + statusCode: response.statusCode, + ); + } + + Future chatAudio({ + required List bytes, + String format = 'wav', + String? sessionId, + }) async { + final response = await _client.post( + _uri('/api/chat/audio'), + headers: { + 'Content-Type': 'application/octet-stream', + 'Accept': 'application/json', + 'X-Audio-Format': format, + if (sessionId != null && sessionId.isNotEmpty) 'X-Session-Id': sessionId, + }, + body: bytes, + ); + final map = _decodeMap(response.body); + final result = ChatTurnResponse.fromJson(map); + if (response.statusCode >= 200 && response.statusCode < 300) { + return result; + } + if (result.error != null && result.error!.isNotEmpty) { + return result; + } + throw ApiException( + map['message']?.toString() ?? '语音对话失败 (${response.statusCode})', + statusCode: response.statusCode, + ); + } + + String chatAudioFileUrl(String absolutePath) { + return _uri( + '/api/chat/audio/file', + {'path': absolutePath}, + ).toString(); + } + + Future getModels() async { + final response = await _client.get(_uri('/api/models')); + _ensureSuccess(response); + return ModelsSnapshot.fromJson(_decodeMap(response.body)); + } + + Future downloadModel(String modelId) async { + final response = await _client.post( + _uri('/api/models/download'), + headers: _jsonHeaders, + body: jsonEncode({'model_id': modelId}), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future switchModel({ + required String modelId, + required String kind, + }) async { + final response = await _client.post( + _uri('/api/models/switch'), + headers: _jsonHeaders, + body: jsonEncode({ + 'model_id': modelId, + 'kind': kind, + }), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future deleteModel(String modelId) async { + final response = await _client.post( + _uri('/api/models/delete'), + headers: _jsonHeaders, + body: jsonEncode({'model_id': modelId}), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + Future>> listFiles(String dirKey, [String? path]) async { final response = await _client.get( _uri('/api/files/$dirKey', _pathQuery(path)), diff --git a/clients/flutter/pubspec.lock b/clients/flutter/pubspec.lock index 2e5d83d..5313cb1 100644 --- a/clients/flutter/pubspec.lock +++ b/clients/flutter/pubspec.lock @@ -6,23 +6,79 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.7.0" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" source: hosted - version: "2.13.0" + version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.1" bluez: dependency: transitive description: name: bluez sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.8.3" boolean_selector: @@ -30,7 +86,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" characters: @@ -38,7 +94,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" clock: @@ -46,15 +102,23 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" collection: dependency: transitive description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" crypto: @@ -62,31 +126,31 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.8" + version: "1.0.9" dbus: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.12" + version: "0.7.14" fake_async: dependency: transitive description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" ffi: @@ -94,7 +158,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" file: @@ -102,9 +166,17 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -115,7 +187,7 @@ packages: description: name: flutter_blue_plus sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.36.8" flutter_blue_plus_android: @@ -123,7 +195,7 @@ packages: description: name: flutter_blue_plus_android sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.4" flutter_blue_plus_darwin: @@ -131,7 +203,7 @@ packages: description: name: flutter_blue_plus_darwin sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" flutter_blue_plus_linux: @@ -139,7 +211,7 @@ packages: description: name: flutter_blue_plus_linux sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" flutter_blue_plus_platform_interface: @@ -147,7 +219,7 @@ packages: description: name: flutter_blue_plus_platform_interface sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.0" flutter_blue_plus_web: @@ -155,7 +227,7 @@ packages: description: name: flutter_blue_plus_web sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" flutter_lints: @@ -163,7 +235,7 @@ packages: description: name: flutter_lints sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" flutter_test: @@ -181,15 +253,23 @@ packages: description: name: go_router sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "14.8.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" http_parser: @@ -197,15 +277,31 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -213,7 +309,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.10" leak_tracker_testing: @@ -221,7 +317,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" lints: @@ -229,7 +325,7 @@ packages: description: name: lints sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" logging: @@ -237,7 +333,7 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" matcher: @@ -245,7 +341,7 @@ packages: description: name: matcher sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.12.19" material_color_utilities: @@ -253,55 +349,95 @@ packages: description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: name: nested sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" path: dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" petitparser: @@ -309,7 +445,7 @@ packages: description: name: petitparser sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" platform: @@ -317,7 +453,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.6" plugin_platform_interface: @@ -325,7 +461,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" provider: @@ -333,39 +469,119 @@ packages: description: name: provider sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.28.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" - url: "https://pub.dev" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" - url: "https://pub.dev" + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.21" + version: "2.4.26" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.5.6" shared_preferences_linux: @@ -373,23 +589,23 @@ packages: description: name: shared_preferences_linux sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.4.3" shared_preferences_windows: @@ -397,7 +613,7 @@ packages: description: name: shared_preferences_windows sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" sky_engine: @@ -410,7 +626,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" stack_trace: @@ -418,7 +634,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" stream_channel: @@ -426,7 +642,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" string_scanner: @@ -434,55 +650,71 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.flutter-io.cn" source: hosted - version: "15.0.2" + version: "15.2.0" web: dependency: transitive description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" web_socket: @@ -490,7 +722,7 @@ packages: description: name: web_socket sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.1" web_socket_channel: @@ -498,7 +730,7 @@ packages: description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.3" xdg_directories: @@ -506,7 +738,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" xml: @@ -514,9 +746,17 @@ packages: description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/clients/flutter/pubspec.yaml b/clients/flutter/pubspec.yaml index c6fb5ba..c354a38 100644 --- a/clients/flutter/pubspec.yaml +++ b/clients/flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: showen_v2_flutter description: ShowenV2 cross-platform mobile controller. publish_to: 'none' -version: 0.1.0+1 +version: 0.4.0+4 environment: sdk: '>=3.3.0 <4.0.0' @@ -17,6 +17,9 @@ dependencies: provider: ^6.1.2 web_socket_channel: ^3.0.1 shared_preferences: ^2.3.0 + record: ^6.0.0 + audioplayers: ^6.1.0 + path_provider: ^2.1.5 dev_dependencies: flutter_test: diff --git a/clients/flutter/test/models/m2_models_test.dart b/clients/flutter/test/models/m2_models_test.dart new file mode 100644 index 0000000..d09c051 --- /dev/null +++ b/clients/flutter/test/models/m2_models_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:showen_v2_flutter/models/ai_model.dart'; +import 'package:showen_v2_flutter/models/character_pack.dart'; +import 'package:showen_v2_flutter/models/chat_models.dart'; + +void main() { + group('CharacterPack / AvailableConfigs', () { + test('parses M2.1 config available payload', () { + final snapshot = AvailableConfigs.fromJson({ + 'active': 'dog_state_machine.json', + 'configs': [ + { + 'filename': 'dog_state_machine.json', + 'character': { + 'name': '小狗', + 'character_type': 'pet', + 'render_type': 'video', + }, + }, + { + 'filename': 'live2d_demo.json', + 'character': { + 'name': 'Live2D', + 'character_type': 'live2d', + 'render_type': 'live2d', + 'live2d_model': 'Hiyori', + }, + }, + ], + }); + + expect(snapshot.active, 'dog_state_machine.json'); + expect(snapshot.configs, hasLength(2)); + expect(snapshot.configs.first.name, '小狗'); + expect(snapshot.configs.first.typeLabel, '宠物'); + expect(snapshot.configs.last.isLive2d, isTrue); + }); + }); + + group('ChatTurnResponse', () { + test('parses success and error responses', () { + final ok = ChatTurnResponse.fromJson({ + 'session_id': 'app_1', + 'transcription': '你好', + 'reply_text': '汪!', + 'reply_audio_path': '/tmp/tts.wav', + 'error': null, + }); + expect(ok.isOk, isTrue); + expect(ok.replyText, '汪!'); + expect(ok.replyAudioPath, '/tmp/tts.wav'); + + final err = ChatTurnResponse.fromJson({ + 'session_id': '', + 'reply_text': '', + 'error': 'AI 插件未就绪', + }); + expect(err.isOk, isFalse); + expect(err.error, 'AI 插件未就绪'); + }); + }); + + group('ModelsSnapshot', () { + test('parses model list and active map', () { + final snapshot = ModelsSnapshot.fromJson({ + 'models': [ + { + 'id': 'qwen2.5-0.5b-q4_k_m', + 'kind': 'llm', + 'name': 'Qwen2.5 0.5B', + 'version': '1', + 'size': 400000000, + 'memory_required': 1000000000, + 'recommended_tier': '4G', + 'downloaded': true, + 'download_progress': 100, + }, + ], + 'active': {'llm': 'qwen2.5-0.5b-q4_k_m', 'asr': 'whisper-tiny'}, + 'used_space': 500000000, + 'quota': 4000000000, + }); + + expect(snapshot.models, hasLength(1)); + expect(snapshot.isActive(snapshot.models.first), isTrue); + expect(snapshot.usageRatio, closeTo(0.125, 0.001)); + expect(snapshot.models.first.kindLabel, 'LLM'); + }); + }); +} diff --git a/docs/PLAN_M2.1_NEXT.md b/docs/PLAN_M2.1_NEXT.md new file mode 100644 index 0000000..cc12047 --- /dev/null +++ b/docs/PLAN_M2.1_NEXT.md @@ -0,0 +1,204 @@ +# M2.1 下一阶段实施计划 + +> 创建:2026-07-09 +> 背景:功能不完善;AI 与 Live2D/视频**尚未完整联动**;App/Web 为半成品。 +> 对照:`docs/STATUS.md`(现状)· `docs/M2.1_PRD.md`(产品定义) + +--- + +## 0. 目标(本阶段完成的定义) + +**可演示的闭环(P0 验收)** + +1. 设备开机 → App/Web 可连 +2. 切换角色(至少狗/猫/Live2D 配置)→ 画面与人设同步 +3. 文字对话 → 客户端播 TTS → **画面有“在说话”反馈** +4. App 按住说话 → 同上 +5. 模型列表可见;切换 LLM 后下一回合生效(失败有明确提示) + +**不做本阶段(明确边界)** + +- 设备本机麦克风/扬声器直连(硬件未接) +- 精细口型同步 / 流式打断 +- 数字人/歌姬全套新视频素材制作 +- 云端 LLM 实装 + +--- + +## 1. 现状结论(计划前提) + +| 层 | 现状 | +|----|------| +| AI 管线 | 本地 ASR/LLM/TTS 可跑;HTTP `/api/chat/*` 可用 | +| Live2D | 有字幕 `Subtitle`;`live2d_talking` 标志在 HTTP 内,**未驱动嘴部动画** | +| 视频状态机 | 有 `talk_state` 配置字段;**对话不会 ChangeScene** | +| App | 角色/对话/模型页已写;真机旅程未系统验 | +| Web | chat 控制台有入口;HTTP 录音受限 | +| 仓库 | 2026-07-09 已提交整理与骨架改动(见 git log) | + +--- + +## 2. 工作包拆分 + +### WP-A · AI ↔ 画面联动(核心缺口) + +**A1. Live2D 说话态接通(优先)** + +| 步骤 | 内容 | 验收 | +|------|------|------| +| A1.1 | 定义共享通道:消息 `Message::Live2dTalking { on: bool }` **或** 注入 Arc 状态到 Live2D 插件 | 二选一,文档写清 | +| A1.2 | HTTP chat 成功路径:开始 `talking=true`,结束按**音频时长或字数**回 `false` | 与 TTS 结束大致对齐(误差 ≤1.5s 可接受) | +| A1.3 | Live2D 渲染循环读 talking → `animation.set_talking` | 说话时嘴部开合,结束后回 idle | +| A1.4 | 字幕路径保持:`Subtitle` 继续走 | 字+嘴同时有反馈 | + +**A2. 视频角色 talk 状态(次优先)** + +| 步骤 | 内容 | 验收 | +|------|------|------| +| A2.1 | chat 开始:向 video 发 `ChangeScene(config.character.talk_state)` 或等价 trigger | 有 talk 片段则切过去 | +| A2.2 | chat 结束:回 `idle`(或配置的默认态) | 不卡在 talk | +| A2.3 | 无 talk 状态时优雅降级(日志 + 保持当前态,不 500) | 猫狗旧包不炸 | + +**A3. 时长与互斥** + +| 步骤 | 内容 | +|------|------| +| A3.1 | 优先用回复音频时长驱动 talking 窗口;无音频则字数估算 | +| A3.2 | 对话进行中拒绝二次对话 / 切角色(或排队策略写死一种) | + +**负责建议**:内核/HTTP(张明远 or 王浩然)+ Live2D/视频(李思琪) +**预估**:A1 1~2 天 · A2 1 天 · A3 0.5 天 + +--- + +### WP-B · 设备对齐与回归 + +| 步骤 | 内容 | 验收 | +|------|------|------| +| B1 | 本机已提交代码同步到设备 `192.168.31.105` | git 一致或 rsync 可追溯 | +| B2 | `cargo check --workspace --all-targets` 零 warning | 贴输出 | +| B3 | `cargo test --workspace` 全绿 | 贴输出 | +| B4 | `systemctl --user restart showen_v2` + BLE ActiveInstances=1 | 服务稳定 | +| B5 | `POST /api/chat/text` 冒烟 | 有 reply_text;Live2D 有嘴/字幕 | + +--- + +### WP-C · App 真机验收与修补 + +| 步骤 | 内容 | 验收 | +|------|------|------| +| C1 | 安装 debug APK,配置 `192.168.31.105:5000` | 能连 | +| C2 | 角色页切换 3 套配置 | 画面+人设变 | +| C3 | 文字对话 + 自动播 TTS + 重播 | 有声有字 | +| C4 | 按住说话一轮 | 转写+回复(允许慢) | +| C5 | 模型页列表/切换 | 失败有文案 | +| C6 | 按失败项修 Flutter(超时、503、权限) | 不再静默失败 | + +**依赖**:WP-A 至少完成 A1 后再验“说话联动”。 + +--- + +### WP-D · Web 验收与修补 + +| 步骤 | 内容 | +|------|------| +| D1 | 角色/文字对话/模型三页真机点通 | +| D2 | HTTP 下录音禁用时的固定文案(与 PRD 一致) | +| D3 | 切角色清空会话上下文 | + +--- + +### WP-E · 产品化收尾(P1,A~D 之后) + +| 步骤 | 内容 | +|------|------| +| E1 | App 对话历史本地持久化 + 一键清空 | +| E2 | 模型下载进度轮询/失败重试 UX | +| E3 | release APK 签名与安装说明 | +| E4 | M1.2 tag `m1.2`(老板) | +| E5 | 素材:数字人/歌姬 talk/idle(外部依赖,可并行) | + +--- + +## 3. 推荐执行顺序 + +```text +Day 0 本计划评审(老板/CEO) +Day 1 WP-A1 Live2D talking 接通 + B 设备同步回归 +Day 2 WP-A2 视频 talk 降级策略 + A3 互斥 +Day 3 WP-C App 真机四旅程 + 修洞 +Day 4 WP-D Web 点通 + 文档 STATUS 更新 +Day 5+ WP-E 按优先级穿插 +``` + +--- + +## 4. 技术要点备忘(实现时) + +### 4.1 Live2D talking 推荐实现 + +``` +HTTP chat 开始 + → state.set_live2d_talking(true) + → tx.send(Message::Live2dTalking { on: true }) // 或等价 + → tx.send(Message::Subtitle { text }) + +Live2DPlugin::handle_message + → renderer.set_talking(on) + +HTTP chat 结束(spawn 延迟) + → set_live2d_talking(false) + Live2dTalking { on: false } +``` + +避免 Live2D 去轮询 HTTP 内部 AtomicBool(跨插件耦合差)。 + +### 4.2 视频 talk + +``` +render_type == video 时: + chat 开始 → PlayerCommand::ChangeScene(talk_state) + chat 结束 → ChangeScene("idle") // 或配置 default_idle +无该 scene 时: 忽略并 log,不返回 5xx +``` + +### 4.3 验证命令(设备) + +```bash +ssh -p 2222 showen@192.168.31.105 +systemctl --user status showen_v2 +curl -sS -m 60 -X POST http://127.0.0.1:5000/api/chat/text \ + -H 'Content-Type: application/json' \ + -d '{"text":"你好","session_id":"plan_test"}' +# 同时观察 Live2D 嘴部与字幕 +``` + +--- + +## 5. 风险 + +| 风险 | 应对 | +|------|------| +| 对话延迟 5~15s 体感差 | 先保证正确联动;优化单独立项(锁核/更小模型) | +| 宠物包无 talk 片段 | 强制降级,不阻塞 Live2D 路径 | +| 设备与 git 漂移 | B1 强制对齐后再验 | +| 录音权限/机型差异 | App 明确权限引导;失败可走文字 | + +--- + +## 6. 完成检查清单(勾选) + +- [ ] Live2D:对话时嘴部动画 + 字幕 +- [ ] 视频:有 talk 则切 talk,无则降级 +- [ ] App:角色 / 文字 / 语音 / 模型 四旅程通过 +- [ ] Web:角色 / 文字对话 / 模型通过 +- [ ] 设备 `cargo test --workspace` 全绿 +- [ ] `docs/STATUS.md` 矩阵更新为与实机一致 +- [ ] (可选)release APK + +--- + +## 7. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-09 | 初稿:联动缺口 + 分 WP 计划;对应 git 提交含骨架与整理 | diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..3cf52a5 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,170 @@ +# ShowenV2 项目现状板 + +> **更新:2026-07-09** +> 用途:一页看清「能干什么 / 不能干什么 / 下一步做什么」。 +> 操作派发仍以 `CLAUDE.md` 为 CEO 手册;本文件是**功能成熟度**权威清单。 + +--- + +## 1. 里程碑总览 + +| 里程碑 | 状态 | 说明 | +|--------|------|------| +| **M1.1** | ✅ 完成 | 插件微内核、视频/设备/HTTP/BLE/WiFi 骨架 | +| **M1.2** | ✅ 验收通过 | 目标机 147/147 测试全绿;仅差老板打 tag `m1.2` | +| **M2.1** | 🟡 **进行中(半成品)** | 语音数字生命 V1:后端/网页/App 已有骨架,**体验与验收未闭环** | + +--- + +## 2. 仓库结构(整理后) + +``` +ShowenV2/ +├── CLAUDE.md # CEO 操作手册(派发/铁律/当前状态) +├── README.md # 架构与快速开始 +├── PROGRESS.md # 里程碑摘要 +├── docs/ +│ ├── STATUS.md # ← 本文件(功能成熟度) +│ ├── M2.1_PRD.md # M2.1 产品需求 +│ └── ... +├── src/ +│ ├── core/ # 微内核 +│ └── plugins/ # video / http / ble / wifi / device / screen / live2d / ai +├── configs/ # 角色内容包 JSON(dog/cat/live2d_anime) +├── clients/flutter/ # 安卓控制 App +├── scripts/device/ # 设备运维脚本(从根目录迁入) +├── tests/ # M1.2 集成测试 +└── souls/ # 团队档案 +``` + +**根目录禁止再堆**:截图 `*.xwd`、临时模型 `.haru.*`、一次性 `.*.sh`。 + +--- + +## 3. 能力成熟度矩阵 + +图例:✅ 可用 · 🟡 半成品 · ❌ 缺失 · 🚫 本期不做 + +### 3.1 设备端(Linux ARM64 · 192.168.31.105) + +| 能力 | 状态 | 备注 | +|------|------|------| +| 开机自启 `showen_v2.service` | ✅ | `Restart=always`,用户级 + Linger | +| 视频状态机 / Live2D 渲染 | ✅ | 设备画面主路径 | +| HTTP :5000 Web + API | ✅ | | +| BLE 广播 + GATT 配网 | ✅ | 已修 LocalName/Includes 冲突;广播实例=1 | +| WiFi (nmcli) | ✅ | | +| AI 对话管线 ASR→LLM→TTS | 🟡 | 设备实测文字对话可通;延迟数秒~十余秒 | +| 模型管理 API | 🟡 | 列表/下载/切换/删除有;进度/配额 UX 待验 | +| 角色配置切换 API | ✅ | `/api/config/available` + `switch` 带 character 元信息 | +| 单实例锁 | ❌ | 防御性双保险未做 | +| 设备本机麦克风/扬声器对话 | 🚫 | 硬件未接;V1 声音走客户端 | + +### 3.2 网页控制端(`chat.html` + `chat.js`) + +| 能力 | 状态 | 备注 | +|------|------|------| +| 播放/触发/配置/视频/文件/WiFi | ✅ | M1 存量 | +| 角色切换 UI | 🟡 | 有入口,端到端验收不足 | +| 文字对话 + 浏览器播 TTS | 🟡 | API 通;人设/状态联动待系统验 | +| 按住说话 | 🟡 | HTTP 下 getUserMedia 受限;PRD 允许降级 | +| 模型管理 UI | 🟡 | 有入口,下载进度/错误态未系统验 | +| 与 App 状态互相同步 | 🟡 | 依赖 WS/轮询,未做验收用例 | + +### 3.3 安卓 App(Flutter · v0.4 debug APK) + +| 能力 | 状态 | 备注 | +|------|------|------| +| 设备连接 / 播放 / 网络 / BLE 配网 / 设置 | ✅ | M1 能力 | +| 角色页 | 🟡 | 代码已加,**真机体验未验** | +| 对话页(文字+按住说话+播回复) | 🟡 | 代码已加,**真机体验未验** | +| 模型管理页 | 🟡 | 代码已加,**真机体验未验** | +| debug APK | ✅ | `clients/flutter/build/app/outputs/flutter-apk/app-debug.apk` (~146MB) | +| release 签名包 | ❌ | 未打 | +| 对话历史本地持久化 / 隐私文案 | ❌ | PRD 有要求 | +| 角色卡片封面图 | ❌ | API 未提供 cover | + +### 3.4 内容与素材 + +| 项 | 状态 | +|----|------| +| dog / cat 视频包 | ✅ 可用(路径仍偏仓库外历史形态) | +| live2d_anime 包 | 🟡 有配置,素材依赖设备本地 `configs/live2d/` | +| 数字人/歌姬完整 4 状态视频包 | ❌ 素材未齐(PRD 最大外部依赖) | +| 角色 `talk` 状态全覆盖 | 🟡 宠物包可降级 | + +--- + +## 4. 未提交改动(本机工作区,2026-07-09) + +以下在仓库 **working tree**,尚未 commit(按老板规则不代提交): + +| 区域 | 内容 | +|------|------| +| `src/plugins/ble/` | BLE 广播 LocalName 修复 + 串行注册 | +| `src/plugins/http/` | chat/audio 说话态/session;chat.js session 头 | +| `clients/flutter/` | M2.1 三角色/对话/模型页 + Gradle/依赖升级 | +| 根目录清理 | 移除 xwd/haru 垃圾;脚本迁 `scripts/device/` | + +设备端:上述 BLE/HTTP 二进制可能已部署,但 **git 与设备代码可能不完全一致**,合并前需同步。 + +--- + +## 5. 不完善点(按优先级) + +### P0 — 不做则无法说「M2.1 可用」 + +1. **真机验收闭环**:App 装机 → 角色切换 → 文字/语音对话 → 模型列表/切换 +2. **设备 ↔ 本机代码对齐**:commit + 设备 `git pull`/同步 + `cargo test` +3. **对话失败可理解**:AI 未就绪 / 超时 / 模型缺失时,App/Web 明确提示 + +### P1 — 体验完整度 + +4. App 对话历史本地存储 + 清空 +5. 切换角色时清空会话上下文(App 已调 clear;Web 需确认) +6. 模型下载进度实时刷新与失败重试 +7. Web HTTP 录音限制的产品文案统一 +8. release APK 与安装渠道 + +### P2 — 技术债 / 收尾 + +9. M1.2 tag `m1.2` +10. 单实例保护 +11. `sanitize_filename` 测试转正 +12. CursorVisibility 等 Phase2 消息清理 +13. 数字人/歌姬素材管线 + +--- + +## 6. 建议下一轮工作顺序 + +```text +1) 老板/本机:提交当前工作区改动(或拆成 ble / http / flutter 三提交) +2) 设备同步 + cargo test --workspace +3) App 真机:配网 → 角色 → 对话 → 模型 四条用户旅程 +4) 按失败项修 P0 +5) 再谈素材与 release +``` + +--- + +## 7. 环境速查 + +| 环境 | 要点 | +|------|------| +| 设备 SSH | `ssh -p 2222 showen@192.168.31.105`(密码 showen) | +| 设备 Web | `http://192.168.31.105:5000/` | +| 本机 Flutter | `source ~/.showen_dev_env` · Flutter 3.44.5 · JDK17 · Android SDK36 | +| 本机 APK | `clients/flutter/build/app/outputs/flutter-apk/app-debug.apk` | + +--- + +## 8. 文档导航 + +| 需求 | 文件 | +|------|------| +| 派任务 / 铁律 | `CLAUDE.md` | +| 功能成熟度(本页) | `docs/STATUS.md` | +| M2.1 产品定义 | `docs/M2.1_PRD.md` | +| 里程碑摘要 | `PROGRESS.md` | +| 架构入门 | `README.md` | diff --git a/s3.xwd b/s3.xwd deleted file mode 100644 index 6425050..0000000 Binary files a/s3.xwd and /dev/null differ diff --git a/s4.xwd b/s4.xwd deleted file mode 100644 index 8931714..0000000 Binary files a/s4.xwd and /dev/null differ diff --git a/s6.xwd b/s6.xwd deleted file mode 100644 index b4389b6..0000000 Binary files a/s6.xwd and /dev/null differ diff --git a/scripts/device/README.md b/scripts/device/README.md new file mode 100644 index 0000000..3c8edca --- /dev/null +++ b/scripts/device/README.md @@ -0,0 +1,15 @@ +# 设备运维脚本 + +从仓库根目录迁入的临时/运维 shell,供目标机调试用,**非产品运行时依赖**。 + +| 脚本 | 用途(以内容为准) | +|------|-------------------| +| `ssh_check.sh` | SSH 连通检查 | +| `build.sh` / `run.sh` / `start.sh` | 设备侧编译/启动辅助 | +| 其余 | 历史探测脚本,用前请先读内容 | + +主服务请用: + +```bash +systemctl --user status showen_v2.service +``` diff --git a/.analyze.sh b/scripts/device/analyze.sh similarity index 100% rename from .analyze.sh rename to scripts/device/analyze.sh diff --git a/.build.sh b/scripts/device/build.sh similarity index 100% rename from .build.sh rename to scripts/device/build.sh diff --git a/.dl_core.sh b/scripts/device/dl_core.sh similarity index 100% rename from .dl_core.sh rename to scripts/device/dl_core.sh diff --git a/.extract.sh b/scripts/device/extract.sh similarity index 100% rename from .extract.sh rename to scripts/device/extract.sh diff --git a/.find_cargo.sh b/scripts/device/find_cargo.sh similarity index 100% rename from .find_cargo.sh rename to scripts/device/find_cargo.sh diff --git a/.run.sh b/scripts/device/run.sh similarity index 100% rename from .run.sh rename to scripts/device/run.sh diff --git a/.ssh_check.sh b/scripts/device/ssh_check.sh similarity index 100% rename from .ssh_check.sh rename to scripts/device/ssh_check.sh diff --git a/.start.sh b/scripts/device/start.sh similarity index 100% rename from .start.sh rename to scripts/device/start.sh diff --git a/src/plugins/ble/gatt.rs b/src/plugins/ble/gatt.rs index 5ed5974..9fee740 100644 --- a/src/plugins/ble/gatt.rs +++ b/src/plugins/ble/gatt.rs @@ -250,6 +250,11 @@ pub fn run_ble_service( shared: shared.clone(), }, ); + // BlueZ LEAdvertisement1 约束(5.55+): + // Includes 里若含 "local-name",就禁止再设 LocalName 属性,否则 + // parse_local_name 报 "Local name already included" → + // "Failed to parse advertisement"。 + // 我们需要自定义设备名,因此只用 LocalName,Includes 仅保留 tx-power。 cr.insert( ADV_PATH, &[advertisement_iface], @@ -257,7 +262,7 @@ pub fn run_ble_service( advertisement_type: "peripheral".to_string(), service_uuids: vec![SERVICE_UUID.to_string()], local_name: device_name.clone(), - includes: vec!["tx-power".to_string(), "local-name".to_string()], + includes: vec!["tx-power".to_string()], }, ); @@ -315,20 +320,37 @@ pub fn run_ble_service( // 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留) let _ = unregister_ble_objects(&conn, &adapter_path); + // 给 BlueZ 一点时间释放残留 GATT/Adv 实例 + for _ in 0..5 { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + conn.process(Duration::from_millis(50)).ok(); + } + // 串行注册:先 GATT,再 Advertisement。并行时若 Adv 成功、GATT 失败, + // 残留 ActiveInstances 会导致后续 "Failed to create entry in database"。 let gatt_serial = send_register_gatt_app(&conn, &adapter_path)?; - let ad_serial = send_register_advertisement(&conn, &adapter_path)?; if let Ok(mut replies) = registration_replies.lock() { replies.gatt_serial = Some(gatt_serial); + } + eprintln!("[BLE] RegisterApplication sent, waiting..."); + if let Err(error) = + wait_for_one_registration_reply(&conn, ®istration_replies, "gatt", Duration::from_secs(10)) + { + let _ = unregister_ble_objects(&conn, &adapter_path); + return Err(error); + } + + let ad_serial = send_register_advertisement(&conn, &adapter_path)?; + if let Ok(mut replies) = registration_replies.lock() { replies.advertisement_serial = Some(ad_serial); } - eprintln!("[BLE] registration requests sent, waiting for BlueZ replies..."); - - if let Err(error) = wait_for_registration_replies( + eprintln!("[BLE] RegisterAdvertisement sent, waiting..."); + if let Err(error) = wait_for_one_registration_reply( &conn, ®istration_replies, - gatt_serial, - ad_serial, + "advertisement", Duration::from_secs(10), ) { let _ = unregister_ble_objects(&conn, &adapter_path); @@ -575,36 +597,34 @@ fn replies_for_serial( } } -fn wait_for_registration_replies( +fn wait_for_one_registration_reply( conn: &Connection, replies: &Arc>, - gatt_serial: u32, - advertisement_serial: u32, + which: &str, timeout: Duration, ) -> Result<()> { let deadline = Instant::now() + timeout; + let op = match which { + "gatt" => "RegisterApplication", + "advertisement" => "RegisterAdvertisement", + other => other, + }; loop { if let Ok(mut replies) = replies.lock() { - match reply_status(&mut replies.gatt, "RegisterApplication")? { - Some(()) => {} - None => {} - } - match reply_status(&mut replies.advertisement, "RegisterAdvertisement")? { - Some(()) => {} - None => {} - } - - if replies.gatt.is_some() && replies.advertisement.is_some() { + let slot = match which { + "gatt" => &mut replies.gatt, + "advertisement" => &mut replies.advertisement, + _ => return Err(anyhow!("unknown BLE registration target: {which}")), + }; + if let Some(()) = reply_status(slot, op)? { return Ok(()); } } let now = Instant::now(); if now >= deadline { - return Err(anyhow!( - "timed out waiting for BLE registration reply (gatt_serial={gatt_serial}, advertisement_serial={advertisement_serial})" - )); + return Err(anyhow!("timed out waiting for BLE {op} reply")); } conn.process(Duration::from_millis(100).min(deadline.saturating_duration_since(now))) diff --git a/src/plugins/ble/mod.rs b/src/plugins/ble/mod.rs index 575d2bb..a3385f5 100644 --- a/src/plugins/ble/mod.rs +++ b/src/plugins/ble/mod.rs @@ -1,7 +1,8 @@ //! BlePlugin — BLE 配网服务 //! //! 通过 D-Bus 与 BlueZ 交互,注册 GATT 服务和 LE Advertisement。 -//! 含 LocalName 双连接修复。 +//! 注意:Advertisement 不可同时 Includes["local-name"] + LocalName 属性 +//! (BlueZ 会报 Failed to parse advertisement)。 mod gatt; @@ -95,11 +96,20 @@ impl Plugin for BlePlugin { if stop.load(Ordering::SeqCst) { return Ok(()); } + // BlueZ 残留状态(database / advertisement)需要更长冷却时间 + let err_text = format!("{error:#}"); + let wait_secs = if err_text.contains("database") + || err_text.contains("parse advertisement") + || err_text.contains("Already Exists") + { + 8 + } else { + 3 + }; eprintln!( - "[BlePlugin] worker error: {error:#}, restarting in 3 seconds..." + "[BlePlugin] worker error: {err_text}, restarting in {wait_secs} seconds..." ); - // 等待 3 秒,但如果 stop 被设置则立即退出 - for _ in 0..30 { + for _ in 0..(wait_secs * 10) { if stop.load(Ordering::SeqCst) { return Ok(()); } diff --git a/src/plugins/http/chat.js b/src/plugins/http/chat.js index cf03412..727713a 100644 --- a/src/plugins/http/chat.js +++ b/src/plugins/http/chat.js @@ -65,7 +65,7 @@ async function switchModel(id,kind){try{var res=await fetch('/api/models/switch' async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{var res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'已删除');loadModels()}catch(e){toast('失败',true)}} initMic(); async function initMic(){var micBtn=$('micBtn');var tip=$('recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文(HTTPS 或 localhost)。当前为 HTTP 访问,录音已禁用。
启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 --unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000 → 重启浏览器并访问本页。
或直接用文字对话,回复语音仍可正常播放。';return}var mediaRecorder=null,chunks=[],recStream=null,recTimer=null;var startRec=async function(){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=function(e){if(e.data.size>0)chunks.push(e.data)};mediaRecorder.onstop=function(){if(recTimer){clearTimeout(recTimer);recTimer=null}recStream.getTracks().forEach(function(t){t.stop()});recStream=null;var 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(function(){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'}};var stopRec=function(){if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙'}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',function(e){e.preventDefault();startRec()});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',function(e){e.preventDefault();stopRec()})} -async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;var micBtn=$('micBtn');micBtn.disabled=true;$('chatStatus').textContent='转码中...';$('chatStatus').className='status-bar busy';var userMsg=addMsg('user','🎤 [语音消息]');var playUrl=URL.createObjectURL(blob);var up=document.createElement('div');up.className='audio-inline';up.innerHTML='你的录音';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var wavBlob=await blobToWav(blob);$('chatStatus').textContent='识别中...';var res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;micBtn.disabled=false} +async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;var micBtn=$('micBtn');micBtn.disabled=true;$('chatStatus').textContent='转码中...';$('chatStatus').className='status-bar busy';var userMsg=addMsg('user','🎤 [语音消息]');var playUrl=URL.createObjectURL(blob);var up=document.createElement('div');up.className='audio-inline';up.innerHTML='你的录音';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var wavBlob=await blobToWav(blob);$('chatStatus').textContent='识别中...';var res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav','X-Session-Id':sessionId},body:wavBlob});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;micBtn.disabled=false} /* WAV 转码:浏览器录音(webm/opus) → 16k mono PCM16 wav,whisper-cli 直接可处理 */ async function blobToWav(blob){var arrayBuf=await blob.arrayBuffer();var audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});var decoded=await audioCtx.decodeAudioData(arrayBuf);var pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000)} function decodeToMonoPCM16(audioBuffer,targetRate){var numCh=audioBuffer.numberOfChannels;var len=audioBuffer.length;var srcRate=audioBuffer.sampleRate;var outLen=Math.round(len*targetRate/srcRate);var out=new Int16Array(outLen);var tmp=new Float32Array(outLen);for(var ch=0;ch, ) -> impl Filter + Clone { @@ -2219,10 +2223,12 @@ fn chat_audio_route( .and(warp::body::content_length_limit(20 * 1024 * 1024)) .and(warp::body::bytes()) .and(warp::header::optional::("X-Audio-Format")) + .and(warp::header::optional::("X-Session-Id")) .and(with_state(state)) .and_then( |bytes: bytes::Bytes, fmt_hint: Option, + session_hint: Option, state: Arc| async move { let pipeline = match state.ai_pipeline() { Some(p) => p, @@ -2260,7 +2266,9 @@ fn chat_audio_route( )); } - let session_id = format!("app_{}", std::process::id()); + let session_id = session_hint + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| format!("app_{}", std::process::id())); let chat_req = ChatRequest { session_id, input: ChatInput::Audio { @@ -2270,6 +2278,10 @@ fn chat_audio_route( max_tokens: config.character.max_tokens, }; + // 与文字对话一致:驱动 Live2D 说话态 + let talking_state = Arc::clone(&state); + talking_state.set_live2d_talking(true); + let audio_path_clone = audio_path.clone(); let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req)) .await @@ -2284,6 +2296,21 @@ fn chat_audio_route( // 清理上传的临时音频 let _ = std::fs::remove_file(&audio_path_clone); + let reset_after = if resp.error.is_none() { + std::time::Duration::from_millis( + ((resp.reply_text.len() as u64) * 200).max(2000), + ) + } else { + std::time::Duration::from_millis(0) + }; + let reset_state = Arc::clone(&state); + tokio::spawn(async move { + if !reset_after.is_zero() { + tokio::time::sleep(reset_after).await; + } + reset_state.set_live2d_talking(false); + }); + // 设备端字幕:把回复文字推给 live2d 插件做屏幕 Overlay if resp.error.is_none() && !resp.reply_text.is_empty() { state.send_message( diff --git a/w3.xwd b/w3.xwd deleted file mode 100644 index 5ef0a2b..0000000 Binary files a/w3.xwd and /dev/null differ diff --git a/w5.xwd b/w5.xwd deleted file mode 100644 index 8d89d48..0000000 Binary files a/w5.xwd and /dev/null differ diff --git a/w6.xwd b/w6.xwd deleted file mode 100644 index 0d9a23c..0000000 Binary files a/w6.xwd and /dev/null differ