# Phase 1 Coding Plan > File: `phase-1.md` > Date: 2026-08-30 > Updated: 默认 chat.conversation pending;配网/开机主动发起;三通道才切换 > Based on: Master Plan · Architecture Audit · Client Plan V1.1 · Server Plan V1.1 · Cross-System Review > **本阶段产出设计与编码任务清单。按本文件实施;与 V1.1 Plan 冲突时,以本文为准。** --- ## 0. Cross-System Review 结论 Client Plan 与 Server Plan **方向可协同**(Server 编排、Client 执行、Chat 热路径不动、MCP 优先),但不能按两份文档的「原文 Phase 1」各自开工。必须先对齐下面三条,否则复读和翻译都会做不完或破坏 Chat。 ### 0.1 已经对齐(保持) | 契约 | 含义 | |------|------| | 权威状态在 Server | 设备不持久化 App/Task 历史 | | 固件无业务分支 | 禁止 `if (app == english)` | | DeviceState ≠ InteractionMode | 不把复读/翻译塞进 Listening/Speaking 迁移表 | | 协议语义冻结 | `hello/listen/abort/tts/stt/mcp` 不改字段含义 | | 可复用硬件 | Sening `self.mp3.play` / `self.mp3.stop` / `self.power_off`;`PauseSdCardMp3` | ### 0.2 原文冲突与 Phase 1 决议 | 缺口 | 原文 | 决议 | |------|------|------| | App 层 | Client 说 Phase 1 不做 App;Server 说 App 表 Phase 3 | **做薄 App Runtime(仅 Server JSON + `active_app_id`)**。设备无 App 目录、无 App Store | | 复读 N 次 | 双方都假设 Server 循环等 `play.completed` | **当前 `self.mp3.play` 立即返回 `accepted`**。N 次循环放进**设备 Skill 参数** `repeat`;Server 只下发 `name/repeat/power_off_after` | | 翻译 | Client 把 TRANSLATION Mode 放到 Phase 3 | **App 复用 ASR+TTS 管线,跳过闲聊 LLM,只暴露翻译 MCP** | | 软关机 | 两份 Plan 都未写入复读验收 | 复用现有 `self.power_off` → `RequestPowerOffStandby` | | 双 MQTT | 允许并存 | **色宁 snlp MQTT 是小程序即时启动 / 计划任务的控制面(优先级最高)**。`START_APP_TASK` 只表达「启动哪个 App/Task」,**具体调用哪些 Skill 由该 Task 的配置决定**。禁止用 `PLAY_MEDIA` 冒充「启动 App」。 | ### 0.3 协同总图(Phase 1) ```text 进入通道(优先级从高到低) ① 小程序即时开始 ──色宁 MQTT──► 最高,打断一切 ② 小程序 Schedule ──到点/闹钟唤醒──► 可打断 chat ③ 离线语音切换 ──MultiNet 口令──► 交互选 App/Task │ ▼ 统一 start_app_task(app, task, params, source) │ ▼ App Runtime (Server + 设备执行器) active_app_id 默认 = chat ┌──────────────────┼──────────────────┐ │ │ │ chat english_learning english_translation mode=CHAT mode=PLAY_AUDIO mode=TRANSLATION │ │ │ └──────── Device Skill(MCP / 本地播放器)──────┘ ``` 同一时刻只运行一个 App / 一个 Task。 **默认(没有更高优先级入口时):永远是第一个 App 的 default Task = `chat.conversation`(原有小智对话能力,但更主动开口)。** | 事件 | 进入 | |------|------| | 首次配网成功 | `chat` / default task `conversation`,小智先发起(如「我们来聊天吧」) | | 之后普通开机 / 软硬关机再启动 | **继续** 这份 chat pending task,不要停在纯等待问话的 Idle | | 用户说「我们来聊天吧 / 聊会天吧」或唤醒回聊天 | 切回(或保持)`chat.conversation` | | 小程序即时 / 计划到点 / 离线切 App | 才离开 chat | | RTC 闹钟且 pending **不是** chat(例如复读) | 执行该计划 Task,不要先闲聊 | --- ## 1. Phase 1 目标 做 **全面但薄的 App 框架**,并在框架上跑三个 App: 1. **`chat`(default App + default Task)** — 原有小智对话能力;配网后与每次默认开机都进入并 **主动发起聊天**,不是只等用户提问 2. **`english_learning`** — 听·复读机:本地 SD 卡 MP3 播放 **N 次**,结束后 **可选软关机** 3. **`english_translation`** — 英语翻译 App:Server 插入 **只做翻译的 MCP**;该 App 下不闲聊、不调其他插件 ### 1.1 非目标(明确不做) - 跟读、录音归档、Assessment、TaskInstance 正式表 - Xiaozhi 协议新 `type=task`(小程序走色宁 MQTT,见 §2.7) - 家长小程序 UI / 完整 Java Scheduler 微服务 / App Store 上架 - 成语接龙、数字背诵、口算等尚未存在的 Task 本体(语音菜单只预留意图槽) - 合并 Xiaozhi MQTT 与 snlp/v1 为一条物理连接 - 改 Opus 热路径、DeviceState 合法迁移表 - 非 Sening 板的 SD 复读(翻译 App 不依赖 SD,可全板) - 用 `play_music`(Server TTS 通道)冒充复读 **必须写入本阶段框架(挂钩可测,小程序可后做):** 三种进入通道、优先级抢占、关机唤醒后续跑计划任务、离线口令不再是空回调。 --- ## 2. 框架设计(本阶段重点) ### 2.1 概念 | 概念 | Phase 1 落地 | 不落地 | |------|----------------|--------| | App | Server 注册表 + 连接上 `active_app_id` | 固件 App Manager、SD `/apps` 安装 | | Task | `english_learning` 内一个「复读」动作 | Workflow / StepInstance | | InteractionMode | 连接字段 `CHAT \| PLAY_AUDIO \| TRANSLATION` | 新 DeviceState | | Skill | 现有 MCP tools + 扩展参数 | 设备侧 ASR/评分 | | Default App / Task | `chat` + `conversation` | 无 App 的空 Idle | Chat **就是** 设备上的第一个 App,其 **default Task 名为 `conversation`**:等于今天小智硬件的听/说/LLM 对话,加上「小智更多地主动开口」。 没有小程序即时、没有到期计划、没有离线切 App 时,**任何启动都进入或恢复这一 Task**,不要停在「配好网后干等用户先说话」。 ### 2.1.1 启动解析:默认永远是 chat.conversation 设备本地持久化一份 **current/pending**: ```text pending_task = { app_id, task_id, params, source, updated_at } ``` 出厂 / 首次配网成功后的初值: ```text app_id = chat task_id = conversation source = default_chat ``` 每次开机(含软关机再启动、断电再上电)按下面解析,**从上到下第一条命中**: ```text 1. 通道① 缓存的即时 MQTT 指令尚未执行 → 执行它 2. 通道② 闹钟到期且 pending 不是 chat.conversation → 执行计划 Task 3. 否则 → 恢复 pending;若空或非法 → chat.conversation ``` 因此: - **第一次配网成功** → 进入第一个 App 的 default Task:`chat.conversation` - **之后关机再开** → 默认仍是这份 chat pending(继续聊天 Task),不是空白 Idle - **只有** 小程序即时、小程序计划、离线语音切换 才会改写 pending 并离开 chat - 计划 Task 跑完若配置「回到 default」→ pending 写回 `chat.conversation` `conversation` 也是一个正式 Task,不是「没有 Task」。三通道切换时,chat 与复读、翻译同一套 `start_app_task`。 ### 2.2 App 注册表(Server,配置驱动) 建议文件:`xiaozhi-esp32-server/main/xiaozhi-server/data/apps.json` (或 `config.yaml` 下 `apps:`。优先独立 JSON,避免和模型配置缠在一起。) ```json { "default_app_id": "chat", "apps": { "chat": { "name": "聊天", "mode": "CHAT", "default_task": "conversation", "tool_policy": "inherit_agent", "system_prompt_mode": "agent_proactive", "tasks": { "conversation": { "interaction_mode": "CHAT", "skills": [], "note": "原有 Xiaozhi Chat Runtime;由 Runtime 主动发起,不另挂 MCP" } } }, "english_learning": { "name": "英语学习", "mode": "PLAY_AUDIO", "tool_policy": "allowlist", "tools": ["switch_app", "start_repeater", "self.mp3.play", "self.mp3.stop", "self.mp3.get_list", "self.power_off"], "system_prompt_mode": "none", "tasks": { "repeater": { "interaction_mode": "PLAY_AUDIO", "skills": [ { "name": "self.mp3.play", "args_from": ["name", "repeat", "power_off_after"] } ] } } }, "english_translation": { "name": "英语翻译", "mode": "TRANSLATION", "tool_policy": "allowlist", "tools": ["switch_app", "translate"], "system_prompt_mode": "translator", "tasks": { "live_translate": { "interaction_mode": "TRANSLATION", "skills": [ { "name": "translate", "side": "server_mcp" } ] } } } } } ``` - `inherit_agent`:沿用当前 Agent 已绑定的插件 + 设备 MCP(**现网行为**) - `allowlist`:只把名单里的工具交给 LLM / 编排器 - 固件 **不读取** 此文件 ### 2.3 连接态(Server 内存,不建表) 在 `ConnectionHandler` 增加: ```text active_app_id: str = "chat" interaction_mode: str = "CHAT" # 与当前 App.mode 同步 ``` 切换 App: ```text switch_app(app_id) → 校验注册表 → 停当前动作(复读则 MCP self.mp3.stop) → active_app_id / interaction_mode 更新 → 刷新工具列表(invalidate ToolManager cache) → 按 system_prompt_mode 切换提示词 ``` `switch_app` 在 **chat** 与各 App 白名单中都可用,以便「退出翻译 / 退出复读,回到聊天」。 ### 2.4 Mode 对实时链路的分流 插入点:`startToChat` / `ConnectionHandler.chat` **之前**(`receiveAudioHandle.py`、`connection.py`)。 | mode | ASR | LLM chat() | TTS | 设备播放 | |------|-----|------------|-----|----------| | CHAT | 是 | 是(现网) | 是 | 否(除非插件) | | PLAY_AUDIO | 否(忽略上行当对话) | 否 | 仅启动时一句提示(可选) | SD MP3 | | TRANSLATION | 是 | **否** | 是(翻译结果) | 否 | PLAY_AUDIO 期间若收到**用户唤醒词** / 用户主动 `listen`:复位 `chat`,走现网。不要在复读中把用户说话送进 LLM。 **RTC 闹钟唤醒**走通道②,不走这条复位。 ### 2.5 工具过滤 插入点:`UnifiedToolHandler.get_functions()` / `ToolManager.get_all_tools()`。 ```text if tool_policy == inherit_agent: 现网全集 else: 只保留 allowlist 中存在的工具(含设备 MCP 名 sanitize 后) ``` 翻译 App 下 **禁止** 出现 weather / play_music / 设备拍照等。 这是「只做翻译的 MCP」能成立的前提:不是 MCP 自己谦虚,而是 **App 白名单把别的工具摘掉**。 ### 2.6 Client 框架职责(薄) 设备 **不实现业务 App**(无 `EnglishLearningApp` 类),但必须有 **进入通道网关**: 1. Chat Runtime 协议与热路径保持(hello/listen/tts) 2. 配网成功与默认开机进入 `chat.conversation`,并让小智 **主动开口**(见 §3.1) 3. Skill 可被 MCP 调用 4. `self.mp3.play` 支持 `repeat` + `power_off_after`(仅当 Task 配置了该 Skill) 5. 音频互斥继续用 `PauseSdCardMp3` 6. 软关机继续用 `RequestPowerOffStandby` 7. 色宁 MQTT 能下发 `START_APP_TASK` 并抢占当前任务(§2.7 通道①) 8. 开机解析 pending:无更高优先级则恢复 chat.conversation;闹钟非 chat 计划则执行计划(§2.1.1) 9. 离线语音命令回调进入切换入口;「我们来聊天吧 / 聊会天吧」映射回 `chat.conversation` --- ### 2.7 三种进入 App / Task 的方式(框架核心) 将来进入「某 App 的某 Task」只有三条通道。Phase 1 就把 **优先级、抢占、载荷、关机唤醒** 定死,避免以后每个 App 自己抢设备。 ```text 统一入口: start_app_task(app_id, task_id, params, source) source: mqtt_immediate 优先级 3 最高 schedule 优先级 2 voice 优先级 1 default_chat 优先级 0 ``` 抢占规则: ```text 新请求.priority > 当前.priority → 中断当前 App/Task,立刻切换 新请求.priority = 当前.priority → 同通道后到覆盖(小程序连点两次:后者生效) 新请求.priority < 当前.priority → 拒绝或忽略(例如复读由 MQTT 拉起时,语音切翻译无效) ``` 「中断」统一动作(设备侧,与业务无关): ```text 停止当前正在跑的 Skill(播、听、TTS、显示任务等) → 再按「新 Task 配置」执行其 skills[] ``` 固件仍然 **不写** `if english`;MQTT/语音/闹钟只带 `app_id` + `task_id` + JSON params。 **执行哪些 Skill 不由通道决定,只由该 App/Task 的配置决定。** --- #### 技能解析(所有通道共用,必须明确) ```text START_APP_TASK / start_app_task │ ▼ 查 App 注册表[app_id].tasks[task_id] │ ▼ 读取该 Task 的: interaction_mode skills[] ← 真正要调用的能力 params schema │ ▼ 按 skills[] 依次(或按 Step)调用 设备 MCP Skill(如 self.mp3.* / self.power_off / display) 和/或 Server Skill(如 translate MCP / ASR / TTS) ``` 要点: - **通道只负责「何时、用什么优先级启动哪个 Task」**,不负责「这个 Task 播放 MP3」。 - **`self.mp3.play` 只是 `english_learning.repeater` 这份配置里的 Skill**,不是所有 App/Task 的默认技能。 - 翻译 Task 配的是 Server MCP `translate`;将来跟读会配 `play` + `listen`;口算会配 `speak` + `listen` + Server 判题。同一条 MQTT `START_APP_TASK` 都能启动。 - `PLAY_MEDIA` 是色宁历史上的「播本地文件」指令,**不是 App 协议**。即使某 Task 的 Skill 语义上也是播放,也应走该 Task 配置的 Skill 名(Phase 1 复读用 `self.mp3.play`),不要让小程序直接发 `PLAY_MEDIA` 冒充「打开英语 App」。 Phase 1 已有 Task 与 Skill 对照: | App | Task | 配置中的 skills(示例) | |-----|------|-------------------------| | `chat` | `conversation`(**default**) | 现有 Chat Runtime;小智主动发起,无强制额外 MCP | | `english_learning` | `repeater` | `self.mp3.play`(+ 可选 `self.power_off`) | | `english_translation` | `live_translate` | Server MCP `translate` + 现有 TTS | --- #### 通道① 小程序即时开始(优先级最高) ```text 手机小程序 选 App → 选 Task → 立即开始 → 色宁 MQTT 下发 START_APP_TASK(只带 app_id / task_id / params / source) → 无论当前是 chat、翻译还是别的 Task,全部中断 → start_app_task(...) → 按该 Task 配置的 skills[] 执行(复读才会调到 self.mp3.play;翻译则调 translate) ``` **为什么走色宁 MQTT 而不是 Xiaozhi WS:** 小程序可能在设备 Idle、软关机恢复前、或对话通道未打开时就要能下发;色宁 `snlp/v1/{device_id}/cmd/ctl|task` 已常驻。Xiaozhi `type=mcp` / Server MCP 仍是 **Skill 调用面**。MQTT 的 `START_APP_TASK` 是 **Task 启动面**。二者不要混成一条「播放指令」。 建议新 action(挂在现有 `BoardMqtt_DispatchAction`): ```json { "header": { "device_id": "...", "req_id": "...", "timestamp": 0 }, "action": "START_APP_TASK", "payload": { "app_id": "english_learning", "task_id": "repeater", "params": { "name": "lesson001.mp3", "repeat": 10, "power_off_after": false }, "source": "mqtt_immediate" } } ``` `payload.params` 的字段由 **该 Task 的 schema** 决定,不是全局固定为 MP3 文件名。复读 Task 才需要 `name/repeat/power_off_after`;翻译 Task 可以几乎无 params;跟读以后会有 `resource_id` 等。 设备 ack 走现有 `rpt/ack`。若当前有低优先级任务:先停再按新 Task 的 `skills[]` 启动。 **Phase 1 编码:** 协议 + 抢占 + 能启动本阶段已有 task(`repeater` / `translate` / 回 `chat`)。小程序 UI 可后做,用 MQTT 客户端联调即可。 --- #### 通道② 小程序 Schedule(优先级第二)——关机自动执行的主目标 ```text 小程序设置计划 例如:每天 07:00 英语学习 / 听-复读 或 跟读 → Server 保存 Schedule(权威) → 同步到设备:RTC 闹钟 SET_ALARM + 本地 pending_task → 到点: 若设备开着且当前是 chat(或更低优先级)→ 切换并开始 若设备软关机 / 硬件关机后由 RTC 唤醒 → 联网后立刻执行 pending,不进 chat ``` 这是 **小智硬件重启 / 闹钟唤醒的主要产品目标**:不是为了回到待机聊天,而是到点自动听读、跟读等。 现有代码已经具备半截能力,**尚未接到 App/Task**: | 已有 | 缺口 | |------|------| | MQTT `SET_ALARM` / `UNSET_ALARM` + DS3231 | 闹钟只唤醒,不带 app/task | | 软关机 `RequestPowerOffStandby`,闹钟 `schedule_power_off_restore_from_alarm` | `RestoreFromPowerOffStandby` 只 `ResumePlaylistIfUnfinished` | | 软关机期间关闭唤醒词与离线命令 | 符合「关机后只有闹钟能拉起来」 | **Phase 1 必须补的设备语义:** ```text NVS / SD 保存 pending_task.json { app_id, task_id, params, source: "schedule", alarm_id } 闹钟触发且从 power-off 恢复 → 若 pending 是非 chat 的计划 Task → 执行它,禁止先闲聊 → 若 pending 是 chat.conversation 或没有非 chat 计划 → 进入 chat.conversation 并主动开口 → 计划 Task 成功结束后按配置写回 chat.conversation ``` 开机且已在 chat:到点同等抢占(priority 2 > 0)。 正在执行 MQTT 即时任务(priority 3)时:本次 Schedule 触发跳过或延后,不抢 MQTT。 Server 侧完整 Plan 表、家长日历 UI:**本阶段不建 Java 表**;可用 `apps.json` + 设备闹钟 + pending 文件先跑通「7 点唤醒后自动复读」。跟读 Task 本体仍非本阶段,但 pending 的 `task_id` 字段必须预留。 --- #### 通道③ 离线语音交互切换(优先级第三) 硬件 **已经实现离线语音识别(ESP-SR MultiNet)**,目的就是为将来切 App / Task / InteractionMode 做准备。**当前版本没有接到切换。** 现状(Sening): | 位置 | 行为 | |------|------| | `afe_wake_word.cc` / `custom_wake_word.cc` | MultiNet 检测离线命令 | | 口令 | 仅 `切换模式`(拼音 `qie huan mo shi`) | | `Application` → `Board_Handle_Offline_Command` | 匹配后只 `ESP_LOGI`,**无切换** | | `CustomWakeWord` 的 `index.json` commands | 已有 `command / text / action` 结构,可扩展,尚未用于 App | 目标体验(将来,不限于本阶段三个 App): ```text 小智:现在我们来做个游戏吧, 是想做数字背诵、成语接龙, 还是 10 以内加减法口算,还是 xx? 用户:(离线命令 / 短句) 程序能判定意图 → start_app_task(对应 app, 对应 task, source=voice) 意图不清 → 再问一次 / 留在当前 App ``` 判定原则: - **优先本地 MultiNet 口令**(低延迟、可在未开 Xiaozhi 对话通道时工作) - 口令表按「当前交互步」加载(菜单步只有几个候选,不要一次注册全部业务句) - 本地无法判定时,才允许走云端 ASR+意图(此时已在 chat 管线,priority 仍算 voice) - 语音 **不能** 打断通道① MQTT 即时任务,也 **不能** 打断正在执行的通道② 计划任务 **Phase 1 编码(挂钩,不是做完整游戏平台):** 1. `Board_Handle_Offline_Command` 不再空转:进入 `voice_entry`(例如打开「选 App」菜单态) 2. Phase 1 菜单必须包含回到聊天:「我们来聊天吧」「聊会天吧」→ `chat.conversation`(最常用) 3. 其它候选:`english_learning.repeater` / `english_translation`;「数字背诵 / 成语接龙 / 口算」作意图槽预留 4. 不在固件写死游戏业务;口令 → `app_id+task_id` 映射表(JSON/Assets),与 MQTT 载荷同一套 与唤醒词分工: ```text 配网成功 / 普通开机 → chat.conversation(主动开口) 「我们来聊天吧」等口令 → chat.conversation 唤醒词 → 回到或保持 chat.conversation(可接着主动一句) 离线「切换模式」 → 通道③ 选其它 App/Task RTC 闹钟 + 非 chat 计划 → 通道② 计划 Task,不要先闲聊 ``` 软关机期间两者都关闭(现有逻辑),只有闹钟能恢复。 --- ### 2.8 进入通道 vs Server App Runtime | 通道 | 设备是否必须在线 Xiaozhi WS | 权威 | |------|------------------------------|------| | ① MQTT 即时 | 不必先开对话通道 | 小程序请求;设备立即抢占执行 | | ② Schedule | 到点时再联网即可 | Server 存计划;设备存闹钟 + pending | | ③ 语音 | 纯口令可离线判定;澄清对话需要 WS | Server 注册表;本地只做口令→id | 三通道汇合到同一套 `start_app_task` → **查 Task 配置 → 调 `skills[]`**。MQTT 不要再为「播放」单独走 `PLAY_MEDIA` 业务分支,否则同一复读 Task 会出现两套执行器。其它 Task 也一样:通道不绑定某种 Skill。 --- ## 3. App 规格 ### 3.1 chat · default Task `conversation` 这是设备上的 **第一个 App、默认 Task**,对应原有小智 AI 硬件的对话功能。 **何时进入(只应这些,外加启动解析):** - 首次配网成功 - 之后每次开机 / 软硬关机再启动,且没有更高优先级的计划 Task - 口令或对话:「我们来聊天吧」「聊会天吧」、唤醒回聊 - `switch_app("chat")` / MQTT `app_id=chat, task_id=conversation` - 其它 Task 正常结束且配置回到 default **何时不进 chat:** 小程序即时开始了别的 Task;闹钟唤醒的 pending 是复读/翻译等非 chat 计划。 **协议与状态机:** Listening ↔ Speaking、hello/listen/tts、Agent 插件 **保持**。框架只把这条链路登记为 `chat.conversation`。 **与原程序的差异(本阶段要做):小智更多地发起,而不是完全被动等用户问。** 原行为:配网后 Idle → 等唤醒 → 等用户说话 → 再答。 目标行为:进入 `conversation` 后,小智 **先开口** 邀请聊天,并在冷场时可以再发起一轮,而不是一直沉默。 Phase 1 主动发起的最小实现(不另造 App): ```text 进入 chat.conversation → 打开音频通道(若尚未打开) → TTS 主动句(配置或角色提示驱动) 例:「我们来聊天吧。」「想聊点什么呀?」 → 进入 Listening,等用户 → 之后按现网 ASR → LLM → TTS → 若长时间无用户说话:可再主动一句(次数/间隔可配,避免死循环催促) ``` 实现落点建议: - Server:`system_prompt_mode = agent_proactive`(鼓励先问、短句发起);可选连接建立后下发一句 `tts` 而不等用户 STT - Client:配网成功 / 默认开机解析到 `conversation` 后,不要只停在 Idle 等按键;应走现有「开始听」路径并允许先播 TTS - 口令「我们来聊天吧」与主动句共用同一 Task,不要做成第二个聊天 App **pending:** 关机前若正在 chat,pending 保持 `chat.conversation`。再开机继续该 Task(可再主动打个招呼,不必复述关机前最后一句,但角色记忆仍走现有 Server memory)。 **回归红线:** 用户一旦开始说话,打断、TTS、已有插件与现网一致。主动开口 **不得** 改 Opus 热路径或 listen 语义。 ### 3.2 english_learning · 听-复读机 **用户体验** ```text 进入英语学习 / 开始复读 → 播放 SD 卡上指定 MP3 → 完整播放 N 次(文件已在本地,不下载) → 若 power_off_after=true:软关机(现有 power-off standby) → 否则停在 Idle;唤醒回到 chat ``` **参数(Server 下发,设备执行)** | 参数 | 说明 | |------|------| | `name` | 与 `self.mp3.get_list` 的 `files[].name` 完全一致 | | `repeat` | 正整数 N,整首循环次数(不是服务器连调 N 次 play) | | `power_off_after` | bool,默认 false | **为什么循环在设备上** `self.mp3.play` 今日在文件开始播放后即返回 `{"accepted":true}`,15s 内同名重复调用还会 `duplicate_ignored`。Server 无法可靠「播完再调下一次」。把 `repeat` 放进播放器循环,不依赖新协议事件,也不把「英语」写进固件。 **编排(Server)** 插件 `start_repeater(name, repeat, power_off_after)`: 1. `active_app_id = english_learning`,`mode = PLAY_AUDIO` 2. 可选 TTS:「开始复读 N 次」(短、可关) 3. Device MCP:`self.mp3.play` + 新参数 4. `Action.RECORD` 或 `NONE`,**不再** `chat()` 缺文件:先 `self.mp3.get_list`,没有则 TTS 提示并保持 chat。 **软关机** 设备在第 N 次自然结束(非 stop、非 TTS 打断)后,若 `power_off_after`:调用现有 `Application::RequestPowerOffStandby()`(与 `self.power_off` 同一路径)。 被唤醒词/按键/`self.mp3.stop` 打断则 **不关机**,并应让 Server 在下次 `listen` 时回到 chat。 **目标板:** `sening-xiaozhi-s3`。其他板无 SD MP3 时:`start_repeater` 返回明确错误,不得改 Chat。 ### 3.3 english_translation **用户体验** ```text 进入英语翻译 用户说中文 → 设备播英文 用户说英文 → 设备播中文 不闲聊、不讲笑话、不调天气 「退出翻译」→ chat ``` **Server MCP(本阶段核心新增)** 在 `data/.mcp_server_settings.json` 注册独立 MCP Server,例如名 `english-translate`: ```json { "mcpServers": { "english-translate": { "command": "python", "args": ["mcp_servers/translate/server.py"] } } } ``` MCP 工具(建议只暴露一个): ```text translate(text, source_lang?, target_lang?) ``` 规则: - 未指定语言:中文→en,英文→zh;其它语言默认译成中文 - **只返回译文**,无解释、无拼音课、无 follow-up 提问 - 实现:MCP 进程内用独立「翻译系统提示」调现有 LLM HTTP(读 xiaozhi 配置),**不要**走 `ConnectionHandler.chat`(避免套娃闲聊) - 该 MCP 不注册 play_music / 设备控制 **实时路径** ```text ASR 文本 → interaction_mode == TRANSLATION → 直接 ServerMCPExecutor.execute("translate", {text}) → TTS 播 result → 不进入 chat()、不写长 Dialogue(可选记一条 history) ``` 不要:先让闲聊 LLM「决定是否翻译」。工具白名单 + 旁路 `chat()` 双保险。 --- ## 4. 时序 ### 4.1 复读 ```text 用户(在 chat):「复读 lesson001 三遍然后关机」 → LLM function call start_repeater → Server: app=english_learning, mode=PLAY_AUDIO → MCP self.mp3.play(name=lesson001.mp3, repeat=3, power_off_after=true) → 设备本地播放 3 次 → RequestPowerOffStandby 用户唤醒 → listen/detect → Server 复位 app=chat, mode=CHAT → 现网对话 ``` ### 4.2 翻译 ```text 用户:「打开翻译」 → switch_app(english_translation) → 工具列表仅 switch_app + translate 用户说话 → ASR → translate MCP → TTS 用户:「退出翻译」 → switch_app(chat) ``` ### 4.3 Default:配网与每次开机 ```text 首次配网成功 → pending = chat.conversation → start_app_task(chat, conversation, source=default_chat) → 小智主动:「我们来聊天吧」 → Listening 之后关机再启动(无到期的非 chat 计划) → 读取 pending = chat.conversation → 再次进入该 Task,小智可再主动招呼 → 不是空白 Idle 干等问话 ``` ### 4.3b 用户要聊天 ```text 离线或对话:「我们来聊天吧 / 聊会天吧」 → start_app_task(chat, conversation, source=voice) → 打断当前更低优先级;若正在 MQTT 即时任务则不抢 → 小智主动接话 ``` ### 4.4 小程序即时开始(通道①) ```text MQTT START_APP_TASK app=english_learning task=repeater → 若正在翻译或 chat 或语音菜单:全部中断 → 播 SD 复读 ``` ### 4.5 计划任务 + 关机唤醒(通道②) ```text SET_ALARM 07:00 + pending_task=repeater → 夜间软关机(power_off) → 07:00 DS3231 闹钟 → RestoreFromPowerOffStandby → 执行 pending_task(不要先 chat) ``` ### 4.6 离线语音切换(通道③) ```text 用户说「切换模式」(现有 MultiNet 口令) → Board_Handle_Offline_Command 进入 voice_entry → (Phase 1)可再听「打开翻译 / 开始复读 / 回到聊天」 → start_app_task(source=voice) → 若当前是 MQTT 即时任务:忽略语音切换 ``` --- ## 5. Client 改动清单 仓库:`xiaozhi-esp32-2.2.6` 原则:最小改动;业务名不上固件。 | ID | 任务 | 文件 | 说明 | |----|------|------|------| | C1 | `self.mp3.play` 增加 `repeat`(int,默认 1)、`power_off_after`(bool,默认 false) | `main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc` | 工具描述同步更新;`repeat=1` 兼容旧调用 | | C2 | SD 播放器支持循环 N 次 | `sening` 的 `SDCard_Play_MP3*` 实现处(`system.c` / 播放任务) | 自然播完才计数;`self.mp3.stop`、TTS Pause 打断则取消剩余次数且不关机 | | C3 | N 次完成且 `power_off_after` → `RequestPowerOffStandby` | 同上 + `application.cc` 已有接口 | 禁止新写一套关机 | | C4 | 保持 `PauseSdCardMp3` 与 Chat TTS 互斥 | `application.cc` | 不改热路径;复读中 TTS 仍可暂停 MP3 | | C5 | 确认 `self.mp3.get_status` 能区分 playing / 循环剩余(可选) | `sening_xiaozhi_s3.cc` | Phase 1 可选字段 `repeat_left`;非必须 | | C6 | **禁止** 新增 English App 类、DeviceState、Xiaozhi `type=task` | — | Review 红线 | | C7 | MQTT `START_APP_TASK` 分发 + 抢占 + **按 task 配置调 skills[]** | `mqtt.c` 分发;执行走统一 start_app_task | 不要在 MQTT 里写死 play MP3;ack 走 `rpt/ack` | | C8 | pending 持久化;开机解析 §2.1.1 | NVS/SD + `RestoreFromPowerOffStandby` | 默认恢复 `chat.conversation`;仅非 chat 计划闹钟才不进闲聊 | | C9 | 离线命令回调改为进入通道③,不再只打日志 | `system.c` `Board_Handle_Offline_Command`;口令表可扩 | 先支持切到已有 App/Task;游戏类 task_id 预留 | 非 Sening 板:不改 SD/MQTT/RTC。MCP 无 `self.mp3.play` 时由 Server 报错。离线 MultiNet 在其它板若存在,同样只做挂钩、不做英语业务。 --- ## 6. Server 改动清单 仓库:`xiaozhi-esp32-server` 物理边界:只动 **xiaozhi-server (Python)**。manager-api **不改表**。 ### 6.1 框架 | ID | 任务 | 建议路径 | |----|------|----------| | S1 | App 注册表加载 | `core/app_runtime/registry.py` + `data/apps.json` | | S2 | 连接态默认 `chat` / `conversation` / CHAT | `core/connection.py` | 与设备 pending 对齐 | | S2b | 进入 conversation 后主动 TTS 发起(我们来聊天吧) | 连接欢迎 / 角色 prompt | 不要等用户第一句才说话;可配置开关 | | S3 | `switch_app` 插件 | `plugins_func/functions/switch_app.py` | | S4 | 工具白名单过滤 | `unified_tool_handler.py` 或 `unified_tool_manager.py`;切换后 `refresh_tools()` | | S5 | `startToChat` / `chat` 前按 mode 分流 | `receiveAudioHandle.py`、`connection.py` | | S6 | **用户**唤醒 / `listen.state=detect` 复位 default app | `listenMessageHandler.py` | **不要**在闹钟 pending 执行期间当成用户唤醒清任务 | | S14 | 统一 `start_app_task(app, task, params, source)` + 优先级 | `core/app_runtime/` | MQTT/语音/插件都走这里 | | S15 | 通道③:云端仅作口令不够时的澄清;默认不覆盖 MQTT/Schedule | 同上 | 与离线命令映射表对齐 | chat 下 S4 必须等价于现网(`inherit_agent`),否则 default App 验收失败。 ### 6.2 复读 | ID | 任务 | 建议路径 | |----|------|----------| | S7 | `start_repeater` 插件 | `plugins_func/functions/start_repeater.py` | | S8 | 通过 DeviceMCPExecutor 调 `self.mp3.play` | 复用现有 device MCP,不要走 `play_music` | | S9 | Agent 插件绑定 | 管理端或配置把 `switch_app`、`start_repeater` 加到目标 Agent;chat 需要能切 App | ### 6.3 翻译 MCP | ID | 任务 | 建议路径 | |----|------|----------| | S10 | 独立 MCP Server 进程,只注册 `translate` | `main/xiaozhi-server/mcp_servers/translate/server.py` | | S11 | 写入 `data/.mcp_server_settings.json` | 现有 `ServerMCPManager` 会加载 | | S12 | TRANSLATION 模式:ASR 后直接 `execute_tool(translate)` 再 TTS | 不要先 `chat()` | | S13 | translator 系统提示(仅 switch_app 时给 LLM 用) | 短:说明当前是翻译机、退出需调用 switch_app | `translate` 的 LLM 调用应使用独立短 prompt,温度低,禁止 function call。 ### 6.4 配置示例(S11) ```json "english-translate": { "command": "python", "args": ["mcp_servers/translate/server.py"] } ``` 工作目录为 `xiaozhi-server`。依赖:现有 `mcp` Python 包(Server 已用)。 --- ## 7. 协议与消息 Phase 1 **不新增** Xiaozhi WS `type=task`。小程序/计划走 **色宁 MQTT**。 | 方向 | 消息 | 用途 | |------|------|------| | 下行 WS | 现有 `type=mcp` `tools/call` | play / stop / power_off / 设备列表 | | 下行 WS | 现有 `type=tts` | 翻译结果、复读提示、语音菜单提问 | | 上行 WS | 现有 `listen` / `mcp` result | 用户唤醒复位、工具返回 | | 下行 MQTT | **新** `START_APP_TASK` | 通道①即时开始;可带 `source=schedule` 作补发 | | 下行 MQTT | 现有 `SET_ALARM` | 通道②到点唤醒 | | 上行 MQTT | 现有 `rpt/ack` | 启动/失败回执 | | — | 不新增 play.completed | 由设备内部 repeat 消化 | MCP play 参数向后兼容:旧客户端无 `repeat` 时按 1 次、不关机。 --- ## 8. 数据库 / API | 项 | Phase 1 | |----|---------| | MySQL 新表 | 无 | | `/task/instance` | 无 | | chat_history | 翻译可继续按现网上报文本;不强制新 audio 字段 | | Redis | 不存 App 状态(连接内存即可;断线即回 default) | Xiaozhi WS 断线重连 = 新 `session_id` = 默认 chat。 **例外:** 设备本地 `pending_task`(通道②)在闹钟唤醒后仍然执行,不依赖旧 `session_id`。不恢复「半截被用户打断的复读」。 --- ## 9. 实施顺序(建议 PR 切片) ```text P1-A 框架骨架 S1–S5、S14、switch_app + 进入通道:优先级、C7 抢占、C9 离线口令挂钩、C8 pending 形状 验收:默认仍是完整 Chat;switch_app 往返不破坏对话 验收:MQTT START_APP_TASK 能打断 chat;离线「切换模式」不再是空 log P1-B 翻译 App S10–S13 验收:翻译 App 内只出译文;无天气/音乐工具;退出回 chat P1-C 复读机 C1–C4 + S7–S9 验收:SD 文件播 N 次;可选软关机;打断不关机;Chat TTS 仍能暂停 MP3 P1-D 回归 不切 App 的长对话、打断、已有插件、OTA、配网 ``` 先 A 再 B 再 C:翻译不依赖 SD,可在非 Sening 上先测框架。 --- ## 10. 测试计划 ### 10.1 chat default(`conversation`) - [ ] 首次配网成功后自动进入 chat.conversation,且小智 **先说话** - [ ] 关机再开(无其它计划)仍进入 chat.conversation,不是纯 Idle - [ ] 用户说话后的打断、TTS、原插件与现网一致 - [ ] 「我们来聊天吧 / 聊会天吧」切回 conversation - [ ] 从不调用其它通道时,不会自己跳到复读/翻译 ### 10.2 框架 - [ ] `switch_app("english_translation")` 后工具列表不含 weather - [ ] `switch_app("chat")` 后工具列表恢复 - [ ] 非法 `app_id` 被拒绝并留在当前 App - [ ] MQTT `START_APP_TASK` 正在翻译时能中断并改去复读(通道① > ③/chat) - [ ] 复读由 MQTT 拉起时,离线「切换模式」不能把它切走 - [ ] 软关机 + `SET_ALARM` + pending 复读:闹钟唤醒后直接播放,不先进入闲聊 - [ ] 用户唤醒词仍回到 chat(与闹钟唤醒区分) - [ ] 「切换模式」触发 `voice_entry`(不再只打日志) ### 10.3 翻译 - [ ] 中文句 → 英文 TTS - [ ] 英文句 → 中文 TTS - [ ] 不回答「讲个笑话」类闲聊(应翻译该句或提示说要译的内容,由 translate prompt 定一条并写进测试) - [ ] 「退出翻译」回到 chat,笑话类恢复可用 ### 10.4 复读(Sening + 卡上已有 mp3) - [ ] `get_list` 有文件 - [ ] N=2 完整听完两遍 - [ ] `power_off_after=false` 结束后可唤醒进 chat - [ ] `power_off_after=true` 结束后进现有软关机(唤醒词关闭,与今日 `self.power_off` 一致) - [ ] 播放中 `stop` 或唤醒:剩余遍数取消、**不**关机 - [ ] 复读中若有 Chat TTS,MP3 pause/resume 行为不回归 ### 10.5 负例 - [ ] 无 `self.mp3.play` 的板调用 `start_repeater` → 语音错误,Chat 仍可用 - [ ] 不出现 `play_music` 播课程 --- ## 11. 验收标准(Phase 1 Done) 1. **框架:** 第一个 App 的 default Task 是 `chat.conversation`;配网后与默认开机都进入它;同时只有一个 active。 2. **主动聊天:** 进入 conversation 后小智先发起,不是完全被动等问。用户应答后的对话链路与现网一致。 3. **三通道:** 只有小程序即时、计划、离线切换才会离开 chat;闹钟非 chat pending 才跳过闲聊。 4. **复读 / 翻译:** 见原验收。 5. **边界:** 无 Java 新表、无 Xiaozhi `type=task`、无固件英语业务类、无音频热路径重写。 --- ## 12. 风险与技术债(记入 Phase 2,本阶段接受) | 债 | 说明 | |----|------| | 复读仅 Sening | 其它板需后续把 play_audio 提为核心 Skill | | 无 play.completed 协议 | Server 不知道播到第几遍;状态以设备为准 | | App 状态不落库 | WS 断线即回 chat;**pending_task 必须在设备本地**,否则闹钟唤醒丢失 | | 翻译 MCP 再调一次 LLM | 比「改系统提示硬译」多一跳;换的是工具隔离与「只做翻译」 | | 双控制面 | 小程序/闹钟走 snlp;对话/技能走 Xiaozhi WS+MCP。不在 Phase 1 合并 | | 离线命令词很少 | 现仅「切换模式」;菜单口令与游戏 Task 后续加 Assets/命令表 | | 小程序未做 | 通道①②用 MQTT/闹钟联调;UI 后置 | --- ## 13. 编码约束(给实施 Agent) 1. 先读再改:`application.cc` JSON/MCP、`sening_xiaozhi_s3.cc` mp3/power_off、`connection.py`、`receiveAudioHandle.py`、`unified_tool_handler.py`、`server_mcp/mcp_manager.py`。 2. 每个 PR 先跑 Chat 回归再合业务。 3. 禁止为 Demo 新增 `EnglishRepeatManager` / `TranslationApp` 固件类。 4. 禁止修改 `hello/listen/tts/stt` 语义。 5. `repeat` 默认 1,保证旧 MCP 调用者兼容。 6. 进入通道必须带 `source` 与优先级;禁止三个入口各写一套 stop/play。 7. 普通开机 / 配网成功禁止做成「只 Idle 等用户」;默认 pending 是 `chat.conversation` 并允许先 TTS。 8. 仅当闹钟 pending 为 **非 chat 计划 Task** 时,禁止先走闲聊。 实施从 **P1-A** 开始。未完成 A(含进入通道挂钩与默认 conversation)之前不要改播放器循环。