# Phase 2 Completion Review > Date: 2026-08-31 > Basis: **current code**(不是 `phase-2-plan.md` 的原文愿望) > Compared against: `phase-2-plan.md` · `phase-2-voice-test.md` · `roadmap.md` · `Xiaozhi Device Capability Specification v1.0.md` · `phase-1-review.md` > Repositories: `xiaozhi-esp32-2.2.6`(Client)· `xiaozhi-esp32-server`(Server) 本文件只记录事实、差异、债务与对 Spec / Roadmap 的修正。下一阶段施工见 `phase-3-plan.md`,验收见 `phase-3-test.md`。 相关文档索引: | 文档 | 角色 | |------|------| | `Xiaozhi Device Capability Specification v1.0.md` | Device 语义冻结 | | `roadmap.md` | 阶段切分 | | `phase-1.md` / `phase-1-review.md` | 薄 App Runtime 的计划与事实 | | `phase-2-plan.md` | 本阶段原文施工单(采集 + 完成事件) | | `phase-2-voice-test.md` | 烧录后口令回归 | | Client / Server Plan V1.1 | 长期愿景;与 Spec 冲突时以 Spec + 本 Review 为准 | --- ## 1. 当前代码盘点(以仓库为准) ### 1.1 Phase 2 实际改动的文件 #### Client(`xiaozhi-esp32-2.2.6`,仅 Sening) | 文件 | 角色 | |------|------| | `main/boards/sening-xiaozhi-s3/app_task.cc` | `[skill_event]audio.{completed\|stopped}\|app\|task\|duration_ms`;`Board_EnsureAppLayout` | | `main/boards/sening-xiaozhi-s3/app_task.h` | 声明 `Board_EnsureAppLayout` | | `main/boards/sening-xiaozhi-s3/mp3_player.cc` | 播放时长 `duration_ms`(`xTaskGetTickCount`);**未改** `repeat` / `power_off_after` | | `main/boards/sening-xiaozhi-s3/system.c` / `system.h` | `SDCard_Ensure_Business_Dir`(包装已有 `mkdir_recursive`) | | `main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc` | MCP `self.sd.ensure_app` | | `main/boards/sening-xiaozhi-s3/mqtt.c` | `ENSURE_APP_DIRS`;`START_APP_TASK` 补进 ctl 白名单 | 未改:Opus 热路径、`DeviceState` 迁移表、`hello/listen/tts/stt` 字段含义、MultiNet 词表、`repeat` 循环语义。非 Sening 板仍无 Task Gateway。 #### Server(仅 Python `xiaozhi-server`) | 文件 | 角色 | |------|------| | `core/app_runtime/session.py` | **新增。** `TaskSession` 文件归档;`parse_skill_event`;ASR report 推迟策略 | | `core/app_runtime/runtime.py` | begin/end session;吃 skill_event;跟读 SD 逐句 play;听窗 `1.2 × duration_ms`;订阅 App 建目录 | | `core/app_runtime/apps.json` | 跟读 enter_prompt 改为 SD 路径;allowlist 增加 `self_sd_ensure_app` | | `core/connection.py` | `task_session`;断线 `end_session(disconnected)` | | `core/handle/textHandler/listenMessageHandler.py` | `[skill_event]` 优先于 `[app_task]` | | `core/providers/asr/base.py` | PLAY_AUDIO / SHADOWING 不立即 `enqueue_asr_report` | | `core/providers/tools/device_mcp/mcp_handler.py` | tools/list 就绪后调度建目录 | | `tests/test_app_runtime.py` | pytest(口令 / session / skill_event / SD 路径) | #### manager-api / manager-web **无 App Runtime 相关改动。** 无新表、无新 REST。跟读音频仍走现有 `POST /agent/chat-history/report`。 ### 1.2 实际增加的模块 | 模块 | 位置 | 职责 | |------|------|------| | TaskSession | Server `session.py` | 连接级归档:turns + events → `data/task_sessions/{uuid}.json` | | Skill Event 运输 | detect 文本 `[skill_event]` | Spec `AUDIO_COMPLETED` / `AUDIO_STOPPED` | | SD App Layout | 固件 mkdir + Server ensure | `/{app_id}/{task_id}/lessons` | | 跟读步进 | Server `runtime.py` | D16:一句一 MP3 → 听窗 → 下一句 | 没有:TaskInstance 表、Step 引擎、Assessment、Scheduler、App Store、`type=task`。 ### 1.3 实际增加的 API / 协议 | 类型 | 名称 | 说明 | |------|------|------| | REST | **无** | — | | Device MCP | `self.sd.ensure_app` | **计划外。** `app_id` + 可选 `task_id` → mkdir | | MQTT | `ENSURE_APP_DIRS` | **计划外。** payload `{app_id, tasks:[]}` | | WS type | **无新 type** | 仍寄生 `listen/detect` | | detect 前缀 | `[skill_event]audio.completed\|app\|task\|duration_ms` | Spec D1;第 4 段时长可选 | | detect 前缀 | `[skill_event]audio.stopped\|...` | 用户/抢占停止 | `duration_ms` 是原文计划没有、实施中补上的字段;语义仍是 D1 完成事件,不是新命令种类。 ### 1.4 数据库 **无新表。** Session 在本地 JSON。Chat WAV 仍在 `ai_agent_chat_audio`。WS 断线会 flush JSON(`disconnected`),不落 manager-api。 ### 1.5 Client 架构变化(相对 Phase 1 V2) ```text Task Gateway Board_OnSkillPlaybackFinished(stopped, power_off, duration_ms) ├── 仅当当前 task 的 skill 含 self.mp3.play(复读)时:pending 写回 chat ├── 跟读:播完只上报 completed,不停任务 └── SendListenDetect([skill_event]...) 不再发 [app_task]chat Board_EnsureAppLayout(app_id, task_id) └── SDCard_Ensure_Business_Dir("/{app}" 或 "/{app}/{task}/lessons") MCP self.sd.ensure_app MQTT ENSURE_APP_DIRS(ctl 主题) ``` DeviceState 仍只管听/说。固件仍无 InteractionMode 枚举。跟读句子表 **不在** 固件。 ### 1.6 Server 架构变化 ```text listen detect [skill_event] → handle_skill_event PLAY_AUDIO completed → end_session(play_completed) → 回 chat 招呼一次 PLAY_AUDIO stopped → end_session(play_stopped) → 回 chat 不招呼 SHADOWING completed → 开听窗 1.2×duration_ms(最少 800ms) SHADOWING stopped → 结束跟读回 chat start_app_task(shadowing) ensure_app_sd_dirs self_mp3_get_list → lesson_mp3_clips(/{app}/{task}/lessons/) 逐句 self_mp3_play(repeat=1) 有效 ASR → add_turn + report → 下一句 听窗超时 → listen_timeout 事件 → 下一句(不记成功 turn) MCP tools/list 就绪 → 后台为注册表里需 SD 的 App mkdir ``` 权威状态仍在 **当前 WS 连接内存 + JSON 文件**,不是 TaskInstance 表。 ### 1.7 Client ↔ Server 实际通信流程 ```text 复读 口令 / MQTT START_APP_TASK → 设备 play_audio loop=N(D15) → [skill_event]audio.completed|english_learning|repeater|{ms} → Server Session.events + 回 chat(只招呼一次) 跟读 「开始跟读」 → Server ensure dirs + list MP3 → MCP play 01.mp3 → completed + duration_ms → 听窗 → 用户跟读 / 超时 → Server D16 下一句 → 句子耗尽 → JSON flush → chat 订阅新 App(不烧录) apps.json 增加 app_id → 下次 MCP 就绪或 start_app_task → self.sd.ensure_app 创建 /{app_id}/{task}/lessons ``` ### 1.8 Task Runtime 当前实现状态 | 能力 | 状态 | |------|------| | 统一入口 `start_app_task` | 有(Phase 1) | | App / Task 注册表 | 有(Server JSON) | | InteractionMode 分流 | 有 | | D1 `audio.completed/stopped` | **有**(detect 运输 + duration_ms) | | D6 跟读不进 `chat()` | **有** | | D7 句边界 | **有(v1)**:Server VAD + ASR 出句;另有时长听窗上限 | | D8 v1 归档 | **部分**:有效跟读 `enqueue_asr_report`;Session JSON 有文本;**未**把 `audio_id` 写回 turn | | D16 步骤级 loop | **有**:句子列表只在 Server | | D13 资源目录 | **提前落地**:按 App 唯一名建树;仍不是 `resource_id` 抽象 | | Step / TaskInstance | **无** | | Assessment / Metric | **无** | 结论:这是 **可归档的薄 Runtime(文件 Session)**,仍不是 Master Plan / Server Plan V1.1 原文里的「Phase 2 = Java 表 + type=task + 评分」。后者已由 Spec 拆到 Phase 3 / 4。 ### 1.9 测试情况 | 项 | 现状 | |----|------| | pytest `tests/test_app_runtime.py` | **有**(13 passed:口令、echo、session save/load、skill_event、lesson 路径、layout 路径) | | 固件单元测试 | **无** | | `phase-2-voice-test.md` | **有书面口令表**;仓库内无跑测记录 | | Java / REST | 不适用(本阶段无表) | **Phase 2 在工程上补上了 Phase 1 的 P0-1(pytest)。** 语音表仍靠人工烧录勾选。 --- ## 2. Phase 2 Implementation Review(计划 vs 代码) ### 2.1 对照 `phase-2-plan.md` §1.3 验收 | # | 计划验收 | 代码事实 | 结论 | |---|---------|----------|------| | 1 | 复读自然结束 → `audio.completed`,回 CHAT 只招呼一次 | `Board_OnSkillPlaybackFinished` 只发 skill_event,删除 `[app_task]chat`;Server `handle_skill_event` 招呼 | **满足**(需按 `phase-2-voice-test.md` §5 人工确认单次招呼) | | 2 | stop/抢占 → `audio.stopped`,不主动招呼 | stopped 路径 `speak_switch=False` | **满足** | | 3 | 离线复读 / 离线切换模式不 WakeWordInvoke | Phase 1 短路未改 | **满足** | | 4 | 跟读全程不 `chat()` | `handle_shadowing_turn` 不调用 chat | **满足** | | 5 | JSON 含全部有效轮(含最后一轮) | 先 `add_turn` 再回 chat | **满足** | | 6 | 有效跟读 report;echo / PLAY_AUDIO 不上报 | ASR 层推迟;跟读有效轮才 enqueue | **满足** | | 7 | 不新增 WS type / MQTT action / MCP 工具 | **新增了** `self.sd.ensure_app` 与 `ENSURE_APP_DIRS` | **纪律破例**,见 2.2 | | 8–11 | 不对错进固件、不下发 apps.json、不改热路径、无 Java DDL | 遵守 | **满足** | 原文计划里跟读范读是 **Server TTS**。实施改为 **一句一个 SD MP3**(见 `phase-2-voice-test.md`)。这比原文更接近 Spec 的 `play_audio` + D16,也提前动了 Phase 6 的资源形态。 ### 2.2 哪些设计被修改(相对 `phase-2-plan.md`) 1. **跟读媒体:TTS → SD 句文件。** 原因:整段带静默的音频在播放态会丢掉跟读拾音;一句一 MP3 才能「播完 → 再听」。Spec D3 `speak` 仍可用于无音频时的提示,不再当范读主路径。 2. **完成事件带 `duration_ms`。** Server 用它做听窗上限(`1.2 ×`,最少 800ms)。VAD 说完可提前结束。这是 D7 v1 的增强,不是设备 `wait` Skill(D11 仍未做)。 3. **新增 MCP / MQTT 建目录。** 产品要求:每个 App 唯一名;用户订阅后 **不重烧 bin**,由程序创建 `/{app}/{task}/lessons`。运输变了,语义是已有 `mkdir` + Spec §9 资源树,不是新传感器。 4. **`START_APP_TASK` 补进 MQTT ctl 白名单。** 实施中发现它原先不在 `BoardMqtt_ActionIsCtl`,ctl 主题会拒收。属于 Phase 1 漏网,本阶段修好。 5. **跟读播完不停任务。** 只有 skill 表含 `self.mp3.play` 的 Task(复读)才在播放结束时把 pending 写回 chat。符合 D16:步骤循环在 Server。 这些修改应视为 **Phase 2 的真实架构(Current Architecture V3)**。后续不得假装「跟读仍是 TTS Demo」或「SD 根目录随便堆文件」。 ### 2.3 哪些设计没有实现(原文有、代码无) - 跟读 TTS 范读主路径(有意替换,不是漏做) - `wait` 一等 Device Skill(D11):用 Server 听窗代替 - 把 `audio_id` 写进 Session turn - 设备 `user.speech.stopped` 事件(继续用 Server VAD) - Java 表 / `type=task` / `mode.set`(按 Spec 本就不该在 Phase 2) - 倒背 / 口算 / 接龙 ### 2.4 实施中发现的问题 1. 整课 MP3 含句间静默时,播放态会忽略跟读拾音 → 必须一句一文件。 2. 完成事件若不带时长,听窗只能拍脑袋 → 补 `duration_ms`。 3. 新 App 若把目录写死在 C++ 或要求重烧,冻结就是空话 → 暴露已有 mkdir。 4. MQTT action 必须同时进 **Dispatch 与 ctl/task 白名单**,否则 ack 主题不匹配。 5. 在 `tools/list` 处理函数里同步 `call_mcp_tool` 会和 WS 收包死锁 → 建目录必须 `create_task` 后台跑。 6. 无 SD / mkdir 失败不得断开 WS。 ### 2.5 与 Spec v1.0 的对照 | Spec ID | Phase 2 计划钉死? | 代码 | 宣称冻结? | |---------|-------------------|------|------------| | D1 完成/停止事件 | 是 | 有;另有 duration_ms | **可宣称语义已落地**(运输仍是 detect 前缀) | | D2 pause/resume | 否(只用 stop) | 仍是 TTS-Pause-MP3 | 未冻结为 Task 调用 | | D3 speak | 保持 type=tts | 提示/回聊仍 TTS;跟读范读改 play_audio | 设备仍不合成 | | D4 barge_in 按 Mode | 保持 | 未改坏 | 保持 | | D6 listen 不进 Chat | 是 | 有 | **可宣称** | | D7 一句说完 | v1 = Server VAD+ASR | 有;加听窗上限 | **v1 可宣称**;设备 USER_SPEECH_* 仍缺 | | D8 record_clip v1 | 有效跟读 report | 有文本+可选 WAV;turn 无 audio_id | **采集有**;对象关联留给 Phase 3 | | D11 wait | 明确不做 | Server sleep 听窗 | 仍是缺口,不挡 Phase 3 | | D13 fetch / SD | 计划外 | **提前**:ensure dir + 路径约定 | 布局可用;resource_id / DOWNLOAD 权威仍是 Phase 6 | | D14 PAUSE/RESUME Task | 不做 | 无 | — | | D15 播放级 loop | 禁止改 | 未改 | **保持** | | D16 步骤级 loop | Server 步进 | 有 | **可宣称** | | S4/S8 评估/branch | 禁止 | 无 | 正确 | Spec §9 原文示例是 `/sdcard/resources//`。代码采用: ```text /{app_id}/{task_id}/lessons/... 例:/english_learning/shadowing/lessons/unit01/01.mp3 ``` 经 `SDCard_Map_Business_Path` 映射到 `MOUNT_POINT + business_path`。这应写进 Spec / Architecture V3,不要在 Phase 3 再搬一次目录。 Spec §22 四条纪律:新 App 不要求新传感器;对错只在 Server;离线只保证播放;SD 放资源。**仍成立。** 本阶段新增的 MCP/MQTT 是运输,mkdir 不是新能力种类。 ### 2.6 与原文 V1.1 Client/Server Plan 的差异 V1.1 把 Phase 2 写成 `type=task` + Java 表 + 评分。Spec 已把它拆开。本阶段 **没有** 按 V1.1 做表和评分,这是正确的。 V1.1 Client Phase 3 才做「App 资源目录」——实际在 Phase 2 因订阅不烧录提前做了布局。Phase 6 应缩小为「清单/DOWNLOAD/resource_id」,不要重做 mkdir。 --- ## 3. 与 Master Plan 核心抽象对照(更新) | 抽象 | 当前是否存在 | 实现位置 | 完成程度 | Phase 3 是否动 | |------|--------------|----------|----------|----------------| | **App** | 是 | `apps.json` + 唯一 `app_id` 目录 | 连接级容器 + SD 树;无订阅表 | 不升级成商店 | | **Task** | 是(薄) | `apps.json` tasks | 一个 Task ≈ 一种动作 | 仍无 JSON Step 图 | | **Step** | 仅跟读轮次 | `TaskSession.turns[]` | 文件里的 index,不是引擎 | **映射为 `step_instance` 行** | | **Skill** | 是 | MCP + `k_task_skills[]` | 双表仍在 | 不合并;新 App 运行走 Server MCP play | | **InteractionMode** | 是 | `conn.interaction_mode` | 代码名仍 `SHADOWING` = Spec `FOLLOW_READ` | 不全局重命名 | | **TaskInstance** | 否 | JSON `task_session_id` | 有 uuid,无库 | **本阶段目标** | | **Event** | 部分 | `[skill_event]` + session.events | 无第四套总线 | 入库,不加新 WS type | | **Audio** | 聊天附件级 | report → LONGBLOB | 未挂 step | **尽量挂 audio_id;允许空** | | **Assessment** | 否 | — | 0% | **禁止** | | **Schedule** | 半截 | RTC + pending | — | **禁止**(Phase 5) | --- ## 4. Current Architecture V3(基于代码) ### 4.1 Client V3 ```text ESP32-S3 (sening-xiaozhi-s3) ├── Application 热路径 未改语义 ├── MCP │ self.mp3.* / self.power_off / self.sd.ensure_app └── Task Gateway Board_StartAppTask Board_OnSkillPlaybackFinished → [skill_event] + duration_ms Board_EnsureAppLayout → mkdir /{app}/.../lessons k_task_skills[] 仅离线/MQTT 查 Skill;新订阅 App 不靠此表运行 ``` ### 4.2 Server V3 ```text xiaozhi-server ├── App Runtime │ apps.json(权威定义) │ start_app_task / filter_tools / ensure_app_sd_dirs │ SHADOWING:SD 句文件 + 听窗 + D16 │ TaskSession → data/task_sessions/*.json ├── detect:[skill_event] → [app_task] → [voice_entry] └── report:现有 manage_api chat-history(与 Task 表仍脱节) manager-api 仍未参与 Task Runtime ``` ### 4.3 SD 布局(V3 事实) ```text /{app_id}/{task_id}/lessons/{lesson_id}/file.mp3 ``` - `app_id` / `task_id`:`[A-Za-z0-9_-]`,长度 < 32 - 跟读:该 task 下字典序第一个 lesson 文件夹内全部 MP3 - 复读空 `name`:优先 `/{app}/{task}/lessons/`,再 NVS `last_rep`,再全卡第一首 - 订阅后 mkdir,失败只打日志 ### 4.4 通信 V3 ```text 小程序/MQTT START_APP_TASK | SET_ALARM | ENSURE_APP_DIRS ↓ snlp Device Task Gateway ↓ Xiaozhi WS listen detect [skill_event] / [app_task] Server App Runtime ↓ 现有 report HTTP manager-api chat_history + audio BLOB ← 尚未按 instance 查询 ↓ 本地文件 data/task_sessions/{uuid}.json ← Phase 2 唯一可查询归档 ``` --- ## 5. 技术债务(Phase 2 之后) ### P0 — 不解决就无法安全做 Phase 3 无。pytest 与 Session schema 已足够建表。 **进入 Phase 3 的前提是:不要在建表同时做评分 / 新 Device 能力。** ### P1 — 近期应该解决(Phase 3 范围或紧挨着) | ID | 债务 | 放哪 | |----|------|------| | P1-1 | Session 只在设备旁 JSON,断线换机/换 Python 进程无法查询 | **Phase 3 Instance 表** | | P1-2 | 跟读 WAV 在 `chat_audio`,turn 无 `audio_id` | Phase 3 尽量关联;评分为 Phase 4 | | P1-3 | 双技能表 `apps.json` vs `k_task_skills[]` | 保持;新 App 不靠固件表运行;禁止再加业务分支 | | P1-4 | 寄生 `[skill_event]` 与 ASR 文本空间重叠 | 保持前缀;Phase 3 **仍不上** `type=event` | | P1-5 | 建目录 MCP/MQTT 与 Spec 运输表尚未写进 Spec 正文 | 更新 Spec §9;Phase 3 不改这些接口 | ### P2 — 以后再说 | ID | 债务 | |----|------| | P2-1 | D11 `wait` 一等 Skill | | P2-2 | D14 TASK_PAUSE/RESUME | | P2-3 | D8 v1.1 本地 WAV 上传 | | P2-4 | 双 MQTT 合并 | | P2-5 | `SHADOWING` 重命名为 `FOLLOW_READ` | | P2-6 | 非 Sening 板 `play_audio` | | P2-7 | 对象存储、家长 UI、App Store | | P2-8 | 固件查找表仍含 `english_learning` 字符串 | --- ## 6. 结论:Phase 2 是否 Done **功能 Done(带条件):** - D1 完成/停止事件已从「猜 `[app_task]chat`」变成逻辑事件 - 跟读是可归档会话(JSON turns,含最后一轮),不进 LLM - 步骤循环在 Server(D16) - 新 App 目录可在不烧录的前提下创建 - Chat / 翻译 / 离线复读纪律未故意破坏;pytest 覆盖核心纯函数 **工程未 Done:** - 语音回归依赖人工勾选 `phase-2-voice-test.md` - `audio_id` 未挂到 turn - 没有可跨进程查询的 Instance **因此:** 可以宣称 Spec §20 前三条(完成事件、非 Chat 句边界、跟读归档 v1)**在 Runtime 层已钉住**。 **不可以**宣称 Device Capability 已 100% 实现(缺 wait / pause Task / 统一 resource_id)。 **不可以**开启 Assessment、倒背/口算/接龙、`type=task`。 下一阶段只做一件事:把已经稳定的 `TaskSession` **持久化成可查询的 TaskInstance**(见 `phase-3-plan.md`)。