wangzhibo
2026-08-31 17ac2ce0ed52874616c79c910667e41e34604826
Xiaozhi AI Architecture Audit.md
New file
@@ -0,0 +1,603 @@
# Xiaozhi AI Architecture Audit
你现在负责对 Xiaozhi AI é¡¹ç›®è¿›è¡Œä¸€æ¬¡ **Architecture Audit**。
本次任务是:
> **只分析,不修改代码。**
项目包含两个 Repository:
1. Xiaozhi Client â€”— ç¡¬ä»¶ç«¯ / Device Runtime
2. Xiaozhi Server â€”— Server / AI / Backend
同时提供了一份《Xiaozhi AI å¼€æ”¾åº”用平台 Master Plan》。
Master Plan æ˜¯æœªæ¥ç›®æ ‡æž¶æž„,但当前代码很可能与目标架构存在较大差异。
你的任务不是立即实现 Master Plan,而是:
> æ‰¾å‡ºå½“前系统是什么、已经具备什么、缺少什么,以及应该以什么最小代价逐步演进到目标架构。
---
## ä¸€ã€ç¦æ­¢äº‹é¡¹
本阶段禁止:
- ä¿®æ”¹ä»£ç 
- åˆ é™¤ä»£ç 
- é‡æž„代码
- åˆ›å»ºæ–°çš„业务模块
- ä¿®æ”¹æ•°æ®åº“
- ä¿®æ”¹ API
- ä¿®æ”¹åè®®
- ä¿®æ”¹é…ç½®
- ä¿®æ”¹ç¡¬ä»¶ç«¯é€»è¾‘
只允许:
- é˜…读代码
- åˆ†æžä»£ç 
- å»ºç«‹æž¶æž„认知
- è¾“出分析报告
- æå‡ºå»ºè®®
---
# äºŒã€Audit èŒƒå›´
必须同时分析 Client å’Œ Server。
不要只看目录结构。
需要追踪真实代码调用链。
---
# ä¸‰ã€Client Audit
重点分析:
## 1. å½“前 Runtime
回答:
- Client å½“前启动流程是什么?
- ä¸»å¾ªçŽ¯åœ¨å“ªé‡Œï¼Ÿ
- å½“前 Conversation / Chat Runtime åœ¨å“ªé‡Œï¼Ÿ
- ç”¨æˆ·è¯´è¯ â†’ ASR â†’ LLM â†’ TTS â†’ æ’­æ”¾çš„完整调用链是什么?
- Audio Input / Output å¦‚何实现?
- æ˜¯å¦å·²ç»å­˜åœ¨ Event / Message / Command æœºåˆ¶ï¼Ÿ
- Server ä¸Ž Client ä½¿ç”¨ä»€ä¹ˆé€šä¿¡åè®®ï¼Ÿ
---
## 2. ç¡¬ä»¶èƒ½åŠ›
整理当前已经存在的:
- éº¦å…‹é£Ž
- æ‰¬å£°å™¨
- éŸ³é¢‘播放
- éŸ³é¢‘录音
- VAD
- æŒ‰é”®
- LED
- å±å¹•(如果有)
- SD Card
- ç½‘络
- MQTT / WebSocket / HTTP ç­‰é€šä¿¡èƒ½åŠ›
并指出哪些能力已经可以直接作为未来 Skill。
---
## 3. æ‰¾å‡ºå½“前可以抽象成 Skill çš„能力
例如:
```text
play_audio
record_audio
listen
speak
wait
display
led
button
```
但不要强行创造不存在的能力。
必须基于真实代码分析。
---
## 4. å½“前 Chat Architecture
重点回答:
```text
Audio Input
    â†“
VAD
    â†“
ASR
    â†“
Conversation
    â†“
LLM
    â†“
TTS
    â†“
Audio Output
```
实际代码中分别对应什么模块、类、函数和文件?
---
## 5. Task Runtime æ’入点
重点分析:
未来增加:
```text
Task
 â†“
Step
 â†“
Skill
```
应该插入现有 Client çš„什么位置?
重点回答:
> å¦‚何做到“不破坏现有 Chat åŠŸèƒ½â€çš„æƒ…å†µä¸‹å¢žåŠ  Task Runtime。
---
# å››ã€Server Audit
重点分析:
## 1. Server å½“前架构
分析:
- Server å¯åŠ¨å…¥å£
- API
- WebSocket
- MQTT
- Session
- User
- Device
- Conversation
- Audio
- ASR
- LLM
- TTS
之间的关系。
---
## 2. å½“前对话链路
完整追踪:
```text
Device
 â†“
Server
 â†“
ASR
 â†“
Conversation
 â†“
LLM
 â†“
TTS
 â†“
Device
```
指出真实代码实现位置。
---
## 3. æ•°æ®åº“
列出当前与以下对象相关的数据表:
```text
User
Device
Conversation
Message
Audio
Session
```
以及其他与未来 Task ç³»ç»Ÿå¯èƒ½ç›¸å…³çš„表。
说明:
- è¡¨ç»“æž„
- ä¸»é”®
- å¤–é”®
- å…³ç³»
- å½“前数据生命周期
---
# äº”、重点寻找未来 Domain Model çš„落点
对以下概念逐一分析:
```text
App
Task
Step
Skill
InteractionMode
TaskInstance
StepInstance
Event
Audio
Assessment
Metric
Schedule
Plan
Subscription
```
对于每一个概念回答:
1. å½“前代码中是否已经存在类似概念?
2. å¦‚果存在,在哪里?
3. æ˜¯å¦å¯ä»¥å¤ç”¨ï¼Ÿ
4. æ˜¯å¦éœ€è¦æ‰©å±•?
5. æ˜¯å¦éœ€è¦æ–°å»ºï¼Ÿ
6. æœ€é€‚合放在哪个 Repository / Module?
---
# å…­ã€é‡ç‚¹åˆ†æž InteractionMode
Master Plan ä¸­ï¼š
> InteractionMode ä¸æ˜¯ä¸šåŠ¡åœºæ™¯ï¼Œè€Œæ˜¯ Xiaozhi çš„硬件交互方式。
重点确认当前代码是否存在类似:
```text
Chat
Audio Playback
Recording
Listening
Translation
Game
```
或者其他模式。
分析:
> InteractionMode æœ€é€‚合放在 Client、Server,还是两边都有?
并给出推荐。
---
# ä¸ƒã€é‡ç‚¹åˆ†æž Task Runtime
这是本次 Audit æœ€é‡è¦çš„部分。
回答:
当前系统应该如何从:
```text
Conversation Runtime
```
逐步演进为:
```text
Device Runtime
    â”œâ”€â”€ Chat Runtime
    â””── Task Runtime
          â”œâ”€â”€ Step
          â””── Skill
```
不要直接设计新代码。
先分析:
> å½“前代码在哪里最适合插入这个 Runtime。
---
# å…«ã€é‡ç‚¹åˆ†æž Audio / Recording
未来 Server éœ€è¦ä¿å­˜å„¿ç«¥ Task æ‰§è¡Œè¿‡ç¨‹ä¸­çš„语音。
因此分析:
- å½“前录音在哪里产生?
- éŸ³é¢‘是否已经上传 Server?
- éŸ³é¢‘格式是什么?
- æ˜¯å¦å¯ä»¥ä¿å­˜åŽŸå§‹éŸ³é¢‘ï¼Ÿ
- å½“前音频生命周期是什么?
- æ˜¯å¦å­˜åœ¨ Audio ID?
- æ˜¯å¦å­˜åœ¨ Session ID?
- æ˜¯å¦å¯ä»¥å…³è”到 TaskInstance / StepInstance?
指出未来最小改造方案。
---
# ä¹ã€é‡ç‚¹åˆ†æž Event
检查当前是否已经存在:
```text
Event
Message
Command
State
Callback
```
等机制。
如果存在:
> åˆ¤æ–­èƒ½å¦æ¼”进成统一 Event Architecture。
如果不存在:
> æŒ‡å‡ºæœ€åˆé€‚的插入位置。
---
# åã€æœ€ç»ˆè¾“出 Audit Report
最终不要只输出总结。
必须输出以下内容:
## 1. Current Architecture
分别画出:
```text
Client Architecture
Server Architecture
```
最好使用 Mermaid。
---
## 2. Current Runtime Call Graph
画出:
```text
用户说话
 â†“
...
```
对应真实代码模块。
---
## 3. Existing Capability Map
整理:
```text
Capability
Current Implementation
Repository
Module
Reusable?
Required Change
```
---
## 4. Domain Mapping
建立:
```text
Master Plan Domain
        â†“
Current Code
        â†“
Gap
        â†“
Recommended Location
```
---
## 5. Architecture Gap
明确列出:
```text
P0 å¿…须解决
P1 åŽç»­è§£å†³
P2 æœªæ¥å¹³å°åŒ–解决
```
---
## 6. Recommended Evolution Architecture
不是要求立即实现。
而是说明:
```text
Current
   â†“
Phase 1
   â†“
Phase 2
   â†“
Phase 3
   â†“
Target Architecture
```
---
## 7. æœ€å…³é”®çš„修改点
最终明确列出:
### Client
需要改哪些模块。
### Server
需要改哪些模块。
### Database
需要新增/修改哪些实体。
### Protocol
需要增加哪些消息。
### API
需要增加哪些接口。
但此阶段只提出,不实现。
---
# åä¸€ã€æœ€ç»ˆå¿…须回答的核心问题
请特别回答以下问题:
### Q1
当前 Chat Runtime èƒ½å¦ç›´æŽ¥ä½œä¸ºæœªæ¥ Task Runtime çš„一个子 Runtime?
---
### Q2
Task Runtime æœ€é€‚合放在 Client è¿˜æ˜¯ Server?
请结合当前代码分析,而不是只根据理论回答。
---
### Q3
Step çŠ¶æ€åº”è¯¥ç”± Client ä¿å­˜ã€Server ä¿å­˜ï¼Œè¿˜æ˜¯ä¸¤è¾¹éƒ½æœ‰ï¼Ÿ
---
### Q4
Task æ‰§è¡Œè¿‡ç¨‹ä¸­ Server ä¸Ž Client çš„状态同步机制应该如何设计?
---
### Q5
App / Task / Resource æ˜¯å¦å¯ä»¥å®Œå…¨é€šè¿‡ JSON + Audio Resource ä¸‹å‘,而不需要 OTA?
如果不能,请指出哪些能力必须固化到 Firmware。
---
### Q6
当前代码距离:
```text
App
 â†“
Task
 â†“
Step
 â†“
Skill
 â†“
Device Capability
```
最大的架构障碍是什么?
---
### Q7
如何以最小修改实现第一个:
```text
英语复读 Task
```
而不破坏现有 Chat?
---
### Q8
如何以最小修改实现:
```text
英语跟读
 â†“
录音
 â†“
上传
 â†“
ASR
 â†“
Assessment
```
---
# åäºŒã€æœ€ç»ˆç»“论
最后给出:
```text
Architecture Audit Conclusion
```
并明确:
1. å½“前架构可以保留什么
2. å½“前架构必须改什么
3. ç¬¬ä¸€é˜¶æ®µåº”该改什么
4. å“ªäº›äº‹æƒ…现在绝对不要做
5. ä¸‹ä¸€é˜¶æ®µåº”该如何生成 Phase 1 Coding Plan
再次强调:
> æœ¬æ¬¡ä»»åŠ¡çš„ç›®æ ‡ä¸æ˜¯å†™ä»£ç ï¼Œè€Œæ˜¯å»ºç«‹å¯¹å½“å‰ç³»ç»Ÿçš„å®Œæ•´æž¶æž„è®¤çŸ¥ï¼Œå¹¶æ‰¾åˆ°ä»Ž Current Architecture åˆ° Master Plan çš„æœ€å°æ¼”进路径。
完成 Audit åŽåœæ­¢ã€‚
等待下一步指令。
phase-1.md
New file
@@ -0,0 +1,923 @@
# 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)之前不要改播放器循环。
xiaozhi-esp32-2.2.6/main/application.cc
@@ -393,6 +393,7 @@
    Schedule([this]() {
        // Play the success sound to indicate the device is ready
        audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
        Board::GetInstance().OnDeviceReady();
    });
}
@@ -841,6 +842,21 @@
void Application::StopListening() {
    xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING);
}
void Application::SendListenDetect(const std::string& text, bool open_if_idle) {
    Schedule([this, text, open_if_idle]() {
        if (!protocol_ || text.empty()) {
            return;
        }
        if (protocol_->IsAudioChannelOpened()) {
            protocol_->SendWakeWordDetected(text);
            return;
        }
        if (open_if_idle) {
            WakeWordInvoke(text);
        }
    });
}
void Application::HandleToggleChatEvent() {
@@ -1417,7 +1433,9 @@
            SetDeviceState(kDeviceStateIdle);
        }
        audio_service_.EnableWakeWordDetection(true);
        BoardMqtt_ResumePlaylistIfUnfinished();
        if (!Board::GetInstance().OnRestoredFromPowerOff(network_ready)) {
            BoardMqtt_ResumePlaylistIfUnfinished();
        }
    });
}
xiaozhi-esp32-2.2.6/main/application.h
@@ -104,6 +104,7 @@
     * Sends MAIN_EVENT_STOP_LISTENING to be handled in Run()
     */
    void StopListening();
    void SendListenDetect(const std::string& text, bool open_if_idle = true);
    void Reboot();
    void WakeWordInvoke(const std::string& wake_word);
xiaozhi-esp32-2.2.6/main/boards/common/board.h
@@ -85,6 +85,8 @@
    virtual void SetPowerSaveLevel(PowerSaveLevel level) = 0;
    virtual std::string GetBoardJson() = 0;
    virtual std::string GetDeviceStatusJson() = 0;
    virtual void OnDeviceReady() {}
    virtual bool OnRestoredFromPowerOff(bool network_ready) { (void)network_ready; return false; }
};
#define DECLARE_BOARD(BOARD_CLASS_NAME) \
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
New file
@@ -0,0 +1,371 @@
#include "app_task.h"
#include "application.h"
#include "mqtt.h"
#include "system.h"
#include <cJSON.h>
#include <esp_log.h>
#include <nvs.h>
#include <strings.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#define TAG "app_task"
#define APP_TASK_NVS_NS "app_task"
#define APP_TASK_NVS_KEY "pending"
#define APP_TASK_JSON_MAX 768
#define APP_TASK_ID_MAX 32
#define APP_TASK_SOURCE_MAX 24
static char s_current_app[APP_TASK_ID_MAX] = "chat";
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
static int s_current_priority = 0;
typedef struct {
    const char *app_id;
    const char *task_id;
    const char *skills[4];
} app_task_skill_map_t;
/* Configuration table: channels only pass app_id/task_id; skills are looked up here. */
static const app_task_skill_map_t k_task_skills[] = {
    {"chat", "conversation", {NULL, NULL, NULL, NULL}},
    {"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}},
    {"english_translation", "live_translate", {"translate", NULL, NULL, NULL}},
};
int Board_AppTask_SourcePriority(const char *source)
{
    if (!source) {
        return 1;
    }
    if (strcmp(source, "mqtt_immediate") == 0) {
        return 3;
    }
    if (strcmp(source, "schedule") == 0) {
        return 2;
    }
    if (strcmp(source, "voice") == 0) {
        return 1;
    }
    return 0;
}
int Board_AppTask_CurrentPriority(void)
{
    return s_current_priority;
}
static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
{
    if (!app_id) {
        return NULL;
    }
    const char *tid = (task_id && task_id[0]) ? task_id : NULL;
    for (size_t i = 0; i < sizeof(k_task_skills) / sizeof(k_task_skills[0]); ++i) {
        if (strcmp(k_task_skills[i].app_id, app_id) != 0) {
            continue;
        }
        if (!tid || strcmp(k_task_skills[i].task_id, tid) == 0) {
            return &k_task_skills[i];
        }
    }
    return NULL;
}
static bool is_chat_task(const char *app_id, const char *task_id)
{
    return app_id && strcmp(app_id, "chat") == 0 &&
           (!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0);
}
static void copy_id(char *dest, size_t dest_size, const char *src, const char *fallback)
{
    const char *value = (src && src[0]) ? src : fallback;
    strlcpy(dest, value, dest_size);
}
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
                                const char *source, const char *alarm_id)
{
    cJSON *root = cJSON_CreateObject();
    if (!root) {
        return ESP_ERR_NO_MEM;
    }
    cJSON_AddStringToObject(root, "app_id", app_id && app_id[0] ? app_id : "chat");
    cJSON_AddStringToObject(root, "task_id", task_id && task_id[0] ? task_id : "conversation");
    cJSON_AddStringToObject(root, "source", source && source[0] ? source : "default_chat");
    cJSON_AddStringToObject(root, "alarm_id", alarm_id ? alarm_id : "");
    if (params_json && params_json[0]) {
        cJSON *params = cJSON_Parse(params_json);
        if (params) {
            cJSON_AddItemToObject(root, "params", params);
        } else {
            cJSON_AddRawToObject(root, "params", params_json);
        }
    } else {
        cJSON_AddItemToObject(root, "params", cJSON_CreateObject());
    }
    char *printed = cJSON_PrintUnformatted(root);
    cJSON_Delete(root);
    if (!printed) {
        return ESP_ERR_NO_MEM;
    }
    nvs_handle_t handle;
    esp_err_t err = nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle);
    if (err == ESP_OK) {
        err = nvs_set_str(handle, APP_TASK_NVS_KEY, printed);
        if (err == ESP_OK) {
            err = nvs_commit(handle);
        }
        nvs_close(handle);
    }
    free(printed);
    return err;
}
static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
                                char *source, size_t source_size, char *params_json, size_t params_size)
{
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
        return false;
    }
    char buf[APP_TASK_JSON_MAX];
    size_t length = sizeof(buf);
    esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
    nvs_close(handle);
    if (err != ESP_OK) {
        return false;
    }
    cJSON *root = cJSON_Parse(buf);
    if (!root) {
        return false;
    }
    const cJSON *app = cJSON_GetObjectItem(root, "app_id");
    const cJSON *task = cJSON_GetObjectItem(root, "task_id");
    const cJSON *src = cJSON_GetObjectItem(root, "source");
    const cJSON *params = cJSON_GetObjectItem(root, "params");
    copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
    copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
    copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
    if (params_json && params_size > 0) {
        params_json[0] = '\0';
        if (params) {
            char *printed = cJSON_PrintUnformatted(params);
            if (printed) {
                strlcpy(params_json, printed, params_size);
                free(printed);
            }
        }
    }
    cJSON_Delete(root);
    return app_id[0] != '\0';
}
static void preempt_current_skills(void)
{
    SDCard_Stop_Play();
    BoardMqtt_StopPlaylistPlayback();
    Application::GetInstance().Schedule([]() {
        auto state = Application::GetInstance().GetDeviceState();
        if (state == kDeviceStateSpeaking) {
            Application::GetInstance().AbortSpeaking(kAbortReasonNone);
        }
    });
}
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
{
    char detect[160];
    snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s",
             app_id && app_id[0] ? app_id : "chat",
             task_id && task_id[0] ? task_id : "conversation",
             source && source[0] ? source : "default_chat");
    Application::GetInstance().SendListenDetect(detect, open_if_idle);
}
static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
                           const char *params_json, const char *source)
{
    if (!skill || skill[0] == '\0') {
        notify_server(app_id, task_id, source, true);
        return;
    }
    if (strcmp(skill, "self.mp3.play") == 0) {
        char name[SDCARD_PATH_MAX_LEN] = {0};
        float speed = 1.0f;
        int repeat = 1;
        bool power_off_after = false;
        if (params_json && params_json[0]) {
            cJSON *params = cJSON_Parse(params_json);
            if (params) {
                const cJSON *name_item = cJSON_GetObjectItem(params, "name");
                const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
                const cJSON *repeat_item = cJSON_GetObjectItem(params, "repeat");
                const cJSON *power_item = cJSON_GetObjectItem(params, "power_off_after");
                if (cJSON_IsString(name_item) && name_item->valuestring) {
                    strlcpy(name, name_item->valuestring, sizeof(name));
                }
                if (cJSON_IsNumber(speed_item)) {
                    speed = static_cast<float>(speed_item->valuedouble);
                }
                if (cJSON_IsNumber(repeat_item)) {
                    repeat = repeat_item->valueint;
                }
                if (cJSON_IsBool(power_item)) {
                    power_off_after = cJSON_IsTrue(power_item);
                } else if (cJSON_IsNumber(power_item)) {
                    power_off_after = power_item->valueint != 0;
                }
                cJSON_Delete(params);
            }
        }
        if (name[0] == '\0') {
            ESP_LOGW(TAG, "self.mp3.play missing name");
            return;
        }
        if (repeat < 1) {
            repeat = 1;
        }
        esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
            Board_OnSkillPlaybackFinished(true, false);
            return;
        }
        notify_server(app_id, task_id, source, false);
        return;
    }
    if (strcmp(skill, "translate") == 0) {
        notify_server(app_id, task_id, source, true);
        return;
    }
    ESP_LOGW(TAG, "unsupported skill: %s", skill);
}
esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source)
{
    if (!app_id || app_id[0] == '\0') {
        return ESP_ERR_INVALID_ARG;
    }
    const char *src = (source && source[0]) ? source : "mqtt_immediate";
    const char *tid = (task_id && task_id[0]) ? task_id : "conversation";
    const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
    if (!spec) {
        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid);
        return ESP_ERR_NOT_FOUND;
    }
    int new_priority = Board_AppTask_SourcePriority(src);
    if (new_priority < s_current_priority) {
        ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
                 app_id, tid, src, new_priority, s_current_priority);
        return ESP_ERR_INVALID_STATE;
    }
    preempt_current_skills();
    copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
    copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
    copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
    s_current_priority = new_priority;
    Board_SavePendingTask(app_id, tid, params_json, src, NULL);
    ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
    if (!spec->skills[0]) {
        dispatch_skill(NULL, app_id, tid, params_json, src);
        return ESP_OK;
    }
    for (int i = 0; i < 4 && spec->skills[i]; ++i) {
        dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
    }
    return ESP_OK;
}
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested)
{
    (void)power_off_requested;
    ESP_LOGI(TAG, "skill playback %s", stopped ? "stopped" : "finished");
    copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
    copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
    copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
    s_current_priority = 0;
    Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
}
void Board_ResolveBootOrRestore(bool from_alarm_wake)
{
    char app_id[APP_TASK_ID_MAX] = "chat";
    char task_id[APP_TASK_ID_MAX] = "conversation";
    char source[APP_TASK_SOURCE_MAX] = "default_chat";
    char params[APP_TASK_JSON_MAX] = "{}";
    bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                           source, sizeof(source), params, sizeof(params));
    if (!has_pending) {
        Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
        return;
    }
    if (from_alarm_wake && strcmp(source, "schedule") == 0 && !is_chat_task(app_id, task_id)) {
        ESP_LOGI(TAG, "alarm restore runs pending %s/%s", app_id, task_id);
        Board_StartAppTask(app_id, task_id, params, "schedule");
        return;
    }
    Board_StartAppTask("chat", "conversation", "{}", "default_chat");
}
void Board_EnterVoiceEntry(void)
{
    if (s_current_priority >= 2) {
        ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority);
        return;
    }
    Application::GetInstance().SendListenDetect("[voice_entry]", true);
}
bool Board_Handle_Voice_Command(const char *command)
{
    if (!command || command[0] == '\0') {
        return false;
    }
    if (strcmp(command, "切换模式") == 0 || strcmp(command, "qie huan mo shi") == 0) {
        Board_EnterVoiceEntry();
        return true;
    }
    if (strstr(command, "我们来聊天吧") || strstr(command, "聊会天吧")) {
        if (s_current_priority >= 2) {
            ESP_LOGI(TAG, "ignore voice chat switch, priority=%d", s_current_priority);
            return true;
        }
        Board_StartAppTask("chat", "conversation", "{}", "voice");
        return true;
    }
    if (strstr(command, "打开翻译") || strstr(command, "开始翻译")) {
        if (s_current_priority >= 2) {
            return true;
        }
        Board_StartAppTask("english_translation", "live_translate", "{}", "voice");
        return true;
    }
    if (strstr(command, "开始复读") || strstr(command, "打开复读")) {
        if (s_current_priority >= 2) {
            return true;
        }
        Board_EnterVoiceEntry();
        return true;
    }
    if (strstr(command, "数字背诵") || strstr(command, "成语接龙") || strstr(command, "口算")) {
        ESP_LOGI(TAG, "reserved voice slot: %s", command);
        Board_EnterVoiceEntry();
        return true;
    }
    return false;
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
New file
@@ -0,0 +1,24 @@
#ifndef APP_TASK_H
#define APP_TASK_H
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
int Board_AppTask_SourcePriority(const char *source);
esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source);
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json, const char *source, const char *alarm_id);
void Board_ResolveBootOrRestore(bool from_alarm_wake);
void Board_EnterVoiceEntry(void);
bool Board_Handle_Voice_Command(const char *command);
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested);
int Board_AppTask_CurrentPriority(void);
#ifdef __cplusplus
}
#endif
#endif
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
@@ -1,7 +1,9 @@
#include "system.h"
#include "app_task.h"
#include "audio_codec.h"
#include "board.h"
#include "application.h"
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
@@ -27,11 +29,14 @@
typedef struct {
    char path[SDCARD_PATH_MAX_LEN];
    float speed;
    int repeat;
    bool power_off_after;
} mp3_player_task_arg_t;
static TaskHandle_t mp3_player_task_handle;
static volatile bool mp3_stop_requested;
static volatile bool mp3_pause_requested;
static volatile int mp3_repeat_left;
static std::vector<int16_t> convert_channels(const int16_t *input, size_t frame_count, uint8_t src_channels, uint8_t dst_channels)
{
@@ -127,6 +132,8 @@
    char path[SDCARD_PATH_MAX_LEN];
    strlcpy(path, task_arg->path, sizeof(path));
    float speed = task_arg->speed;
    int repeat = task_arg->repeat < 1 ? 1 : task_arg->repeat;
    bool power_off_after = task_arg->power_off_after;
    free(task_arg);
    FILE *fp = fopen(path, "rb");
@@ -134,6 +141,7 @@
        ESP_LOGE(TAG, "open MP3 failed: %s", path);
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        vTaskDelete(nullptr);
        return;
    }
@@ -144,6 +152,7 @@
        fclose(fp);
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        vTaskDelete(nullptr);
        return;
    }
@@ -167,6 +176,7 @@
        fclose(fp);
        mp3_player_task_handle = nullptr;
        mp3_stop_requested = false;
        mp3_repeat_left = 0;
        vTaskDelete(nullptr);
        return;
    }
@@ -184,11 +194,27 @@
        ESP_LOGE(TAG, "no memory for MP3 playback");
        ret = ESP_AUDIO_ERR_MEM_LACK;
    } else {
        ESP_LOGI(TAG, "play MP3: %s, speed: %.2fx", path, speed);
        ESP_LOGI(TAG, "play MP3: %s, speed: %.2fx, repeat: %d, power_off_after: %d", path, speed, repeat, power_off_after ? 1 : 0);
        if (wait_if_paused()) {
            codec->EnableOutput(true);
        }
        for (int pass = 0; pass < repeat && ret == ESP_AUDIO_ERR_OK && !mp3_stop_requested; ++pass) {
            mp3_repeat_left = repeat - pass;
            if (pass > 0) {
                if (fseek(fp, 0, SEEK_SET) != 0) {
                    ESP_LOGE(TAG, "rewind MP3 failed: %s", path);
                    break;
                }
                esp_audio_simple_dec_close(decoder);
                decoder = nullptr;
                ret = esp_audio_simple_dec_open(&dec_cfg, &decoder);
                if (ret != ESP_AUDIO_ERR_OK || !decoder) {
                    ESP_LOGE(TAG, "reopen MP3 decoder failed: %d", ret);
                    break;
                }
            }
        while (ret == ESP_AUDIO_ERR_OK && !mp3_stop_requested) {
            if (!wait_if_paused()) {
@@ -287,6 +313,7 @@
                break;
            }
        }
        }
    }
    if (rate_cvt_handle) {
@@ -297,27 +324,44 @@
    }
    free(in_buf);
    free(out_buf);
    esp_audio_simple_dec_close(decoder);
    if (decoder) {
        esp_audio_simple_dec_close(decoder);
    }
    esp_audio_simple_dec_unregister_default();
    esp_audio_dec_unregister_default();
    fclose(fp);
    ESP_LOGI(TAG, "MP3 playback %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
    bool stopped = mp3_stop_requested;
    bool should_power_off = power_off_after && !stopped;
    mp3_player_task_handle = nullptr;
    mp3_stop_requested = false;
    mp3_pause_requested = false;
    mp3_repeat_left = 0;
    if (should_power_off) {
        Application::GetInstance().RequestPowerOffStandby();
    }
    Board_OnSkillPlaybackFinished(stopped, power_off_after);
    vTaskDelete(nullptr);
}
extern "C" esp_err_t SDCard_Play_MP3_File(const char *path)
{
    return SDCard_Play_MP3_File_With_Speed(path, 1.0f);
    return SDCard_Play_MP3_File_With_Speed_Repeat(path, 1.0f, 1, false);
}
extern "C" esp_err_t SDCard_Play_MP3_File_With_Speed(const char *path, float speed)
{
    return SDCard_Play_MP3_File_With_Speed_Repeat(path, speed, 1, false);
}
extern "C" esp_err_t SDCard_Play_MP3_File_With_Speed_Repeat(const char *path, float speed, int repeat, bool power_off_after)
{
    if (!path || path[0] == '\0' || !is_supported_speed(speed)) {
        return ESP_ERR_INVALID_ARG;
    }
    if (repeat < 1) {
        repeat = 1;
    }
    bool start_paused = mp3_pause_requested;
@@ -335,13 +379,17 @@
    }
    strlcpy(arg->path, path, sizeof(arg->path));
    arg->speed = speed;
    arg->repeat = repeat;
    arg->power_off_after = power_off_after;
    mp3_stop_requested = false;
    mp3_pause_requested = start_paused;
    mp3_repeat_left = repeat;
    BaseType_t ret = xTaskCreate(mp3_player_task, "mp3_player", MP3_PLAYER_STACK_SIZE, arg, MP3_PLAYER_PRIORITY, &mp3_player_task_handle);
    if (ret != pdPASS) {
        free(arg);
        mp3_player_task_handle = nullptr;
        mp3_repeat_left = 0;
        return ESP_FAIL;
    }
@@ -378,3 +426,8 @@
{
    return mp3_player_task_handle != nullptr && mp3_pause_requested;
}
extern "C" int SDCard_Get_Repeat_Left(void)
{
    return mp3_repeat_left;
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -15,6 +15,7 @@
#include "freertos/task.h"
#include "mqtt_client.h"
#include "system.h"
#include "app_task.h"
#define BOARD_MQTT_KEEPALIVE_SEC 120
#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 5000
@@ -1222,8 +1223,20 @@
    }
    esp_err_t err = DS2312_Set_Alarm_Id_Saved(id->valuestring, hour, minute, second, repeat_mask);
    return err == ESP_OK ? BoardMqtt_ResultOk("alarm updated") :
        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    if (err != ESP_OK) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    }
    cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
    cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
    if (cJSON_IsString(app_id) && app_id->valuestring && app_id->valuestring[0]) {
        const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
        cJSON *params = cJSON_GetObjectItem(payload, "params");
        char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
        Board_SavePendingTask(app_id->valuestring, tid, params_json, "schedule", id->valuestring);
        free(params_json);
    }
    return BoardMqtt_ResultOk("alarm updated");
}
static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload)
@@ -1852,6 +1865,35 @@
    return BoardMqtt_ResultOk("manifest accepted");
}
static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(cJSON *payload)
{
    if (!payload) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
    }
    cJSON *app_id = cJSON_GetObjectItem(payload, "app_id");
    cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
    cJSON *source = cJSON_GetObjectItem(payload, "source");
    cJSON *params = cJSON_GetObjectItem(payload, "params");
    if (!cJSON_IsString(app_id) || !app_id->valuestring || app_id->valuestring[0] == '\0') {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing app_id");
    }
    const char *tid = (cJSON_IsString(task_id) && task_id->valuestring) ? task_id->valuestring : "";
    const char *src = (cJSON_IsString(source) && source->valuestring) ? source->valuestring : "mqtt_immediate";
    char *params_json = params ? cJSON_PrintUnformatted(params) : NULL;
    esp_err_t err = Board_StartAppTask(app_id->valuestring, tid, params_json, src);
    free(params_json);
    if (err == ESP_ERR_NOT_FOUND) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
    }
    if (err == ESP_ERR_INVALID_STATE) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "preempted by higher priority task");
    }
    if (err != ESP_OK) {
        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
    }
    return BoardMqtt_ResultOk("app task started");
}
static board_mqtt_action_result_t BoardMqtt_DispatchAction(const char *req_id, const char *action, cJSON *payload)
{
    if (strcmp(action, "SET_VOLUME") == 0) return BoardMqtt_HandleSetVolume(payload);
@@ -1866,6 +1908,7 @@
    if (strcmp(action, "UPDATE_MANIFEST") == 0 || strcmp(action, "SYNC_COURSE_TASK") == 0) return BoardMqtt_HandleUpdateManifest(req_id, payload);
    if (strcmp(action, "DOWNLOAD") == 0) return BoardMqtt_HandleDownload(req_id, payload);
    if (strcmp(action, "OTA_UPDATE") == 0) return BoardMqtt_HandleOtaUpdate(req_id, payload);
    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(payload);
    return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
}
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
@@ -9,6 +9,7 @@
#include "power_save_timer.h"
#include "adc_battery_monitor.h"
#include "wifi_manager.h"
#include "app_task.h"
#include <esp_log.h>
#include <esp_efuse_table.h>
@@ -722,20 +723,27 @@
        static float last_mp3_play_speed = 1.0f;
        static int64_t last_mp3_play_time = 0;
        mcp_server.AddTool("self.mp3.play",
            "Play one MP3 file from the SD card. The name argument must exactly match a files[].name from self.mp3.get_list. Optional speed supports only 0.5, 0.75, 1.0, 1.25, 1.5, or 2.0; non-1.0 speeds change pitch and affect only SD card MP3 playback. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
            "Play one MP3 file from the SD card. The name argument must exactly match a files[].name from self.mp3.get_list. Optional speed supports only 0.5, 0.75, 1.0, 1.25, 1.5, or 2.0; non-1.0 speeds change pitch and affect only SD card MP3 playback. Optional repeat is the number of full-file loops (default 1). Optional power_off_after requests existing power-off standby after a natural finish, not after stop. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
            PropertyList({
                Property("name", kPropertyTypeString),
                Property("speed", kPropertyTypeNumber, 1.0)
                Property("speed", kPropertyTypeNumber, 1.0),
                Property("repeat", kPropertyTypeInteger, 1, 1, 99),
                Property("power_off_after", kPropertyTypeBoolean, false)
            }),
            [](const PropertyList& properties) -> ReturnValue {
                std::string name = properties["name"].value<std::string>();
                float speed = static_cast<float>(properties["speed"].value<double>());
                int repeat = properties["repeat"].value<int>();
                bool power_off_after = properties["power_off_after"].value<bool>();
                if (repeat < 1) {
                    repeat = 1;
                }
                int64_t now = esp_timer_get_time();
                if (name == last_mp3_play_name && std::fabs(speed - last_mp3_play_speed) < 0.001f && now - last_mp3_play_time < 15000000) {
                    return std::string("{\"accepted\":true,\"duplicate_ignored\":true}");
                }
                esp_err_t err = SDCard_Play_MP3_With_Speed(name.data(), speed);
                esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name.data(), speed, repeat, power_off_after);
                if (err != ESP_OK) {
                    return std::string("{\"accepted\":false,\"error\":\"") + esp_err_to_name(err) +
                        "\",\"retry\":\"use exact files[].name from self.mp3.get_list\"}";
@@ -743,7 +751,8 @@
                last_mp3_play_name = name;
                last_mp3_play_speed = speed;
                last_mp3_play_time = now;
                return std::string("{\"accepted\":true}");
                return std::string("{\"accepted\":true,\"repeat\":") + std::to_string(repeat) +
                    ",\"power_off_after\":" + (power_off_after ? "true" : "false") + "}";
            });
        mcp_server.AddTool("self.mp3.get_status",
@@ -751,7 +760,8 @@
            PropertyList(),
            [](const PropertyList& properties) -> ReturnValue {
                return std::string("{\"playing\":") + (SDCard_Is_Playing() ? "true" : "false") +
                    ",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") + "}";
                    ",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") +
                    ",\"repeat_left\":" + std::to_string(SDCard_Get_Repeat_Left()) + "}";
            });
        mcp_server.AddTool("self.mp3.stop",
@@ -855,6 +865,21 @@
        InitializeTools();
    }
    void OnDeviceReady() override {
        xTaskCreate([](void *arg) {
            (void)arg;
            vTaskDelay(pdMS_TO_TICKS(1500));
            Board_ResolveBootOrRestore(false);
            vTaskDelete(nullptr);
        }, "boot_app_task", 4096, nullptr, 3, nullptr);
    }
    bool OnRestoredFromPowerOff(bool network_ready) override {
        (void)network_ready;
        Board_ResolveBootOrRestore(true);
        return true;
    }
    virtual void SetNetworkEventCallback(NetworkEventCallback callback) override {
        WifiBoard::SetNetworkEventCallback([this, callback](NetworkEvent event, const std::string& data) {
            if (event == NetworkEvent::Connected) {
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
@@ -1,5 +1,6 @@
#include "system.h"
#include "config.h"
#include "app_task.h"
#include "display/lvgl_display/jpg/jpeg_to_image.h"
#include <esp_heap_caps.h>
@@ -130,11 +131,15 @@
    if (!command || Board_Is_Power_Off()) {
        return false;
    }
    if (Board_Handle_Voice_Command(command)) {
        return true;
    }
    if (strcmp(command, OFFLINE_COMMAND_WORD) != 0 && strcmp(command, OFFLINE_COMMAND_WORD_PINYIN) != 0) {
        return false;
    }
    Board_Offline_Command_Callback();
    Board_EnterVoiceEntry();
    return true;
}
@@ -547,6 +552,11 @@
esp_err_t SDCard_Play_MP3_With_Speed(char *mp3_name, float speed)
{
    return SDCard_Play_MP3_With_Speed_Repeat(mp3_name, speed, 1, false);
}
esp_err_t SDCard_Play_MP3_With_Speed_Repeat(char *mp3_name, float speed, int repeat, bool power_off_after)
{
    if (!is_supported_mp3_speed(speed)) {
        ESP_LOGW(TAG, "invalid MP3 playback speed: %.2f", speed);
        SDCard_Resume_Play();
@@ -560,7 +570,7 @@
        return ESP_ERR_NOT_FOUND;
    }
    esp_err_t err = SDCard_Play_MP3_File_With_Speed(path, speed);
    esp_err_t err = SDCard_Play_MP3_File_With_Speed_Repeat(path, speed, repeat, power_off_after);
    if (err != ESP_OK) {
        SDCard_Resume_Play();
        ESP_LOGE(TAG, "start MP3 playback failed: %s", esp_err_to_name(err));
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
@@ -202,6 +202,7 @@
 * @return ESP_OK è¡¨ç¤ºæ’­æ”¾ä»»åŠ¡å·²æˆåŠŸå¯åŠ¨ï¼›ESP_ERR_INVALID_ARG è¡¨ç¤ºé€Ÿåº¦æˆ–文件名非法。
 */
esp_err_t SDCard_Play_MP3_With_Speed(char *mp3_name, float speed);
esp_err_t SDCard_Play_MP3_With_Speed_Repeat(char *mp3_name, float speed, int repeat, bool power_off_after);
void SDCard_Show_Matching_JPG_For_MP3_Path(const char *mp3_path);
/**
@@ -272,6 +273,8 @@
 * @return ESP_OK è¡¨ç¤ºæ’­æ”¾ä»»åŠ¡å·²æˆåŠŸå¯åŠ¨ï¼›ESP_ERR_INVALID_ARG è¡¨ç¤ºè·¯å¾„或速度非法。
 */
esp_err_t SDCard_Play_MP3_File_With_Speed(const char *path, float speed);
esp_err_t SDCard_Play_MP3_File_With_Speed_Repeat(const char *path, float speed, int repeat, bool power_off_after);
int SDCard_Get_Repeat_Left(void);
esp_err_t SDCard_Map_Business_Path(const char *business_path, char *sd_path, size_t sd_path_size);
esp_err_t SDCard_Prepare_Business_File(const char *business_path,
                                       char *final_path,
xiaozhi-esp32-server/.gitignore
@@ -161,6 +161,7 @@
.secrets.yaml
.private_config.yaml
.env.development
!main/manager-web/.env.development
# model files
main/xiaozhi-server/models/SenseVoiceSmall/model.pt
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java
@@ -329,7 +329,7 @@
    /**
     * æ— æ•ˆå›ºä»¶URL
     */
    String INVALID_FIRMWARE_URL = "http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL";
    String INVALID_FIRMWARE_URL = "http://192.168.0.107:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL";
    /**
     * å­—典类型
xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java
@@ -233,7 +233,7 @@
        if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
            log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
            wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
            wsUrl = "ws://192.168.0.107:8000/xiaozhi/v1/";
            websocket.setUrl(wsUrl);
        } else {
            String[] wsUrls = wsUrl.split("\\;");
@@ -242,7 +242,7 @@
                websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
            } else {
                log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
                websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
                websocket.setUrl("ws://192.168.0.107:8000/xiaozhi/v1/");
            }
        }
xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202504151206.sql
@@ -20,7 +20,7 @@
-- å¢žåŠ æ˜¯å¦å…è®¸ç”¨æˆ·æ³¨å†Œå‚æ•°
delete from `sys_params` where  id in (103,104);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (103, 'server.allow_user_register', 'false', 'boolean', 1, '是否运行管理员以外的人注册');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://xiaozhi.server.com', 'string', 1, '下发六位验证码时显示的控制面板地址');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://192.168.0.107', 'string', 1, '下发六位验证码时显示的控制面板地址');
-- ä¿®æ­£CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_CosyVoiceSiliconflow';
xiaozhi-esp32-server/main/manager-web/.env.development
New file
@@ -0,0 +1,4 @@
VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_PUBLIC_PATH=/
# æ˜¯å¦å¼€å¯CDN
VUE_APP_USE_CDN=false
xiaozhi-esp32-server/main/manager-web/src/apis/api.js
@@ -20,7 +20,7 @@
 * å¼€å‘时自动读取使用.env.development文件
 * ç¼–译时自动读取使用.env.production文件
 */
const DEV_API_SERVICE = process.env.VUE_APP_API_BASE_URL
const DEV_API_SERVICE = process.env.VUE_APP_API_BASE_URL || '/xiaozhi'
/**
 * æ ¹æ®å¼€å‘环境返回接口url
xiaozhi-esp32-server/main/manager-web/src/components/AddModelDialog.vue
@@ -68,10 +68,10 @@
        </el-form-item>
      </el-form>
      <teleport v-if="chunkedCallInfoFields.length">
      <template v-if="chunkedCallInfoFields.length">
        <div class="section-title">{{ $t('modelConfigDialog.callInfo') }}</div>
        <div class="divider"></div>
      </teleport>
      </template>
      <el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
        <div v-for="(row, rowIndex) in chunkedCallInfoFields" :key="rowIndex" class="form-row">
xiaozhi-esp32-server/main/manager-web/src/components/CacheViewer.vue
@@ -1,7 +1,7 @@
<template>
  <el-dialog
    :title="$t('cache.dialogTitle')"
    :visible.sync="visible"
    :visible.sync="dialogVisible"
    width="70%"
    :before-close="handleClose"
  >
@@ -88,6 +88,7 @@
  },
  data() {
    return {
      dialogVisible: this.visible,
      isLoading: true,
      cacheAvailable: false,
      cacheData: {
@@ -100,9 +101,13 @@
  },
  watch: {
    visible(newVal) {
      this.dialogVisible = newVal;
      if (newVal) {
        this.loadCacheData();
      }
    },
    dialogVisible(val) {
      this.$emit('update:visible', val);
    }
  },
  methods: {
@@ -168,8 +173,11 @@
      window.location.reload();
    },
    
    handleClose() {
      this.$emit('update:visible', false);
    handleClose(done) {
      this.dialogVisible = false;
      if (typeof done === 'function') {
        done();
      }
    }
  }
};
xiaozhi-esp32-server/main/manager-web/src/components/FirmwareDialog.vue
@@ -1,7 +1,7 @@
<template>
  <CustomDialog
    :title="title"
    :visible.sync="visible"
    :visible.sync="dialogVisible"
    width="800px"
    @confirm="submit"
    @close="cancel"
@@ -72,6 +72,7 @@
  data() {
    return {
      dialogVisible: this.visible,
      uploadProgress: 0,
      uploadStatus: '',
      isUploading: false,
@@ -93,6 +94,14 @@
      }
    }
  },
  watch: {
    visible(val) {
      this.dialogVisible = val;
    },
    dialogVisible(val) {
      this.$emit('update:visible', val);
    }
  },
  computed: {
    isTypeDisabled() {
      // å¦‚果有id,说明是编辑模式,禁用类型选择
xiaozhi-esp32-server/main/manager-web/src/components/ModelEditDialog.vue
@@ -57,10 +57,10 @@
      </el-form-item>
    </el-form>
    <teleport v-if="chunkedCallInfoFields.length">
    <template v-if="chunkedCallInfoFields.length">
      <div class="section-title">{{ $t("modelConfigDialog.callInfo") }}</div>
      <div class="section-divider"></div>
    </teleport>
    </template>
    <el-form :model="form.configJson" ref="callInfoForm" label-width="auto" label-position="left">
      <template>
xiaozhi-esp32-server/main/manager-web/src/components/ParamDialog.vue
@@ -1,7 +1,7 @@
<template>
  <CustomDialog
    :title="title"
    :visible.sync="visible"
    :visible.sync="dialogVisible"
    width="600px"
    class="param-dialog-wrapper"
    @confirm="submit"
@@ -84,6 +84,7 @@
  },
  data() {
    return {
      dialogVisible: this.visible,
      dialogKey: Date.now(),
      saving: false,
      valueTypeOptions: [
@@ -157,6 +158,7 @@
  },
  watch: {
    visible(newVal) {
      this.dialogVisible = newVal;
      if (newVal) {
        if (this.form.paramValue) {
          // å¦‚果是 json ç±»åž‹ï¼Œæ ¼å¼åŒ–显示
@@ -178,6 +180,9 @@
        // å½“对话框关闭时,重置saving状态
        this.saving = false;
      }
    },
    dialogVisible(val) {
      this.$emit('update:visible', val);
    }
  }
};
xiaozhi-esp32-server/main/manager-web/src/components/ProviderDialog.vue
@@ -1,7 +1,7 @@
<template>
  <CustomDialog
    :title="title"
    :visible.sync="visible"
    :visible.sync="dialogVisible"
    width="57%"
    class="provider-dialog-wrapper"
    @confirm="submit"
@@ -138,6 +138,7 @@
  },
  data() {
    return {
      dialogVisible: this.visible,
      saving: false,
      isAllFieldsSelected: false,
      tableKey: 0
@@ -326,9 +327,13 @@
  },
  watch: {
    visible(val) {
      this.dialogVisible = val;
      if (!val) {
        this.resetForm();
      }
    },
    dialogVisible(val) {
      this.$emit('update:visible', val);
    }
  }
};
xiaozhi-esp32-server/main/manager-web/src/components/ReplacementWordDialog.vue
@@ -1,7 +1,7 @@
<template>
  <CustomDialog
    :title="title"
    :visible.sync="visible"
    :visible.sync="dialogVisible"
    width="700px"
    class="param-dialog-wrapper"
    @confirm="submit"
@@ -126,6 +126,7 @@
    };
    return {
      dialogVisible: this.visible,
      dialogKey: Date.now(),
      saving: false,
      localForm: {
@@ -243,6 +244,7 @@
  },
  watch: {
    visible(newVal) {
      this.dialogVisible = newVal;
      if (newVal) {
        this.localForm.id = this.form.id;
        this.localForm.fileName = this.form.fileName || '';
@@ -256,6 +258,9 @@
      } else {
        this.saving = false;
      }
    },
    dialogVisible(val) {
      this.$emit('update:visible', val);
    }
  }
};
xiaozhi-esp32-server/main/manager-web/src/components/ViewPasswordDialog.vue
@@ -1,5 +1,5 @@
<template>
  <el-dialog :visible.sync="visible" width="400px" center>
  <el-dialog :visible.sync="dialogVisible" width="400px" center>
    <div
      style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
      <div
@@ -38,9 +38,22 @@
    visible: { type: Boolean, required: true },
    password: { type: String, default: '' }
  },
  data() {
    return {
      dialogVisible: this.visible
    }
  },
  watch: {
    visible(val) {
      this.dialogVisible = val
    },
    dialogVisible(val) {
      this.$emit('update:visible', val)
    }
  },
  methods: {
    closeDialog() {
      this.$emit('update:visible', false)
      this.dialogVisible = false
    },
    copyPassword() {
      navigator.clipboard.writeText(this.password)
xiaozhi-esp32-server/main/manager-web/vue.config.js
@@ -16,8 +16,9 @@
  return path.join(__dirname, dir)
}
// ç¡®ä¿åŠ è½½ .env æ–‡ä»¶
// Vue CLI ä¼šæŒ‰ mode åŠ è½½ .env.*;这里再读一遍,给 vue.config.js è‡ªèº«ç”¨
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, `.env.${process.env.NODE_ENV || 'development'}`) });
// å®šä¹‰CDN资源列表,确保Service Worker也能访问
const cdnResources = {
@@ -87,10 +88,36 @@
      }
    });
    // å¯ç”¨ä¼˜åŒ–设置
    config.optimization.usedExports(true);
    config.optimization.concatenateModules(true);
    config.optimization.minimize(true);
    // webpack 5 å¼€å‘模式默认也会开 usedExports,会把 Vue 2 SFC çš„ style
    // default export treeshake æŽ‰ï¼ˆå¤§é‡ style0 warning)。minimize è¿˜ä¼šç ´å
    // Vue 2 æ¸²æŸ“函数,导致页面空白和 setAttribute æŠ¥é”™ã€‚这些只给生产构建。
    if (process.env.NODE_ENV === 'production') {
      config.optimization.usedExports(true);
      config.optimization.concatenateModules(true);
      config.optimization.minimize(true);
    } else {
      config.optimization.usedExports(false);
      config.optimization.concatenateModules(false);
      config.optimization.minimize(false);
    }
    // Vue 2 + vue-style-loader éœ€è¦ css-loader çš„ CommonJS å¯¼å‡º
    const ruleTypes = ['css', 'postcss', 'scss', 'sass', 'less', 'stylus']
    ruleTypes.forEach(ruleType => {
      const rule = config.module.rule(ruleType)
      if (!rule) return
      const oneOfs = rule.oneOfs && rule.oneOfs.store
      if (!oneOfs) return
      oneOfs.forEach(oneOf => {
        if (oneOf.uses.has('css-loader')) {
          oneOf.use('css-loader').tap(options => {
            options = options || {}
            options.esModule = false
            return options
          })
        }
      })
    })
  },
  configureWebpack: config => {
    if (process.env.NODE_ENV === 'production') {
xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
@@ -67,6 +67,9 @@
enable_wakeup_words_response_cache: true
# å¼€åœºæ˜¯å¦å›žå¤å”¤é†’词
enable_greeting: true
# Phase 1:进入 chat.conversation åŽä¸»åŠ¨å¼€å£
enable_proactive_greeting: true
proactive_chat_greeting: æˆ‘们来聊天吧。
# è¯´å®Œè¯æ˜¯å¦å¼€å¯æç¤ºéŸ³
enable_stop_tts_notify: false
# è¯´å®Œè¯æ˜¯å¦å¼€å¯æç¤ºéŸ³ï¼ŒéŸ³æ•ˆåœ°å€
@@ -270,6 +273,8 @@
      - get_weather
      - get_news_from_newsnow
      - play_music
      - switch_app
      - start_repeater
  function_call:
    # ä¸éœ€è¦åЍtype
    type: function_call
@@ -286,6 +291,8 @@
      # play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
      # å¦‚果用了hass_play_music,就不要开启play_music,两者只留一个
      - play_music
      - switch_app
      - start_repeater
      #- hass_state
      #- hass_play_music
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/__init__.py
New file
@@ -0,0 +1,15 @@
"""Phase 1 App Runtime: chat / english_learning / english_translation."""
from .registry import get_app, get_task, get_registry, default_app_id
from .runtime import start_app_task, switch_app, filter_tools, on_user_wakeup
__all__ = [
    "get_app",
    "get_task",
    "get_registry",
    "default_app_id",
    "start_app_task",
    "switch_app",
    "filter_tools",
    "on_user_wakeup",
]
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
New file
@@ -0,0 +1,63 @@
{
  "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": []
        }
      }
    },
    "english_learning": {
      "name": "英语学习",
      "mode": "PLAY_AUDIO",
      "default_task": "repeater",
      "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",
      "default_task": "live_translate",
      "tool_policy": "allowlist",
      "tools": ["switch_app", "translate"],
      "system_prompt_mode": "translator",
      "tasks": {
        "live_translate": {
          "interaction_mode": "TRANSLATION",
          "skills": [
            {
              "name": "translate",
              "side": "server_mcp"
            }
          ]
        }
      }
    }
  }
}
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
New file
@@ -0,0 +1,77 @@
"""Load the Phase 1 App / Task registry from JSON."""
import json
import os
from typing import Any, Dict, Optional, Tuple
from config.config_loader import get_project_dir
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
_REGISTRY: Optional[Dict[str, Any]] = None
_BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "apps.json")
def _load_registry() -> Dict[str, Any]:
    candidates = [
        os.path.join(get_project_dir(), "data", "apps.json"),
        _BUNDLED_PATH,
    ]
    for path in candidates:
        if not os.path.isfile(path):
            continue
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, dict) and data.get("apps"):
                logger.bind(tag=TAG).info(f"已加载 App æ³¨å†Œè¡¨: {path}")
                return data
        except Exception as e:
            logger.bind(tag=TAG).error(f"加载 App æ³¨å†Œè¡¨å¤±è´¥ {path}: {e}")
    logger.bind(tag=TAG).warning("未找到 apps.json,使用内置最小注册表")
    return {
        "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": []}},
            }
        },
    }
def get_registry() -> Dict[str, Any]:
    global _REGISTRY
    if _REGISTRY is None:
        _REGISTRY = _load_registry()
    return _REGISTRY
def default_app_id() -> str:
    return get_registry().get("default_app_id") or "chat"
def get_app(app_id: str) -> Optional[Dict[str, Any]]:
    if not app_id:
        return None
    return get_registry().get("apps", {}).get(app_id)
def get_task(app_id: str, task_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
    app = get_app(app_id)
    if not app:
        return None, None
    tasks = app.get("tasks") or {}
    if not task_id:
        task_id = app.get("default_task") or ""
    task = tasks.get(task_id)
    if not task:
        return app, None
    return app, task
xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
New file
@@ -0,0 +1,374 @@
"""Unified start_app_task / switch_app / mode helpers."""
from __future__ import annotations
import uuid
from typing import Any, Dict, Optional, TYPE_CHECKING
from config.logger import setup_logging
from plugins_func.register import Action, ActionResponse
from .registry import default_app_id, get_app, get_task
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
TAG = __name__
logger = setup_logging()
SOURCE_PRIORITY = {
    "mqtt_immediate": 3,
    "schedule": 2,
    "voice": 1,
    "default_chat": 0,
}
TRANSLATOR_PROMPT = (
    "你是一台翻译机。只输出译文,不要解释、不要拼音课、不要追问、不要闲聊。"
    "未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。"
)
CHAT_PROACTIVE_HINT = (
    "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
    "用户开口后按正常对话回答,不要反复催促。"
)
VOICE_MENU = "想聊天、听复读,还是打开翻译?也可以说我们来聊天吧。"
CHAT_PHRASES = ("我们来聊天吧", "聊会天吧", "回到聊天", "退出翻译", "结束翻译", "退出复读")
TRANSLATE_PHRASES = ("打开翻译", "开始翻译", "英语翻译", "翻译模式")
REPEATER_PHRASES = ("开始复读", "打开复读", "英语学习", "听复读")
DEVICE_PLAY_TOOL = "self_mp3_play"
DEVICE_STOP_TOOL = "self_mp3_stop"
def source_priority(source: str) -> int:
    return SOURCE_PRIORITY.get(source or "voice", 1)
def _ensure_runtime_fields(conn: "ConnectionHandler") -> None:
    if not hasattr(conn, "active_app_id"):
        conn.active_app_id = default_app_id()
    if not hasattr(conn, "current_task_id"):
        conn.current_task_id = "conversation"
    if not hasattr(conn, "interaction_mode"):
        conn.interaction_mode = "CHAT"
    if not hasattr(conn, "current_source"):
        conn.current_source = "default_chat"
    if not hasattr(conn, "tool_policy"):
        conn.tool_policy = "inherit_agent"
    if not hasattr(conn, "tool_allowlist"):
        conn.tool_allowlist = []
    if not hasattr(conn, "original_system_prompt"):
        conn.original_system_prompt = conn.prompt
def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]:
    """inherit_agent keeps the live agent toolset; allowlist keeps named tools only."""
    _ensure_runtime_fields(conn)
    if getattr(conn, "tool_policy", "inherit_agent") != "allowlist":
        return tools
    allow = set(getattr(conn, "tool_allowlist", None) or [])
    if not allow:
        return {}
    filtered = {}
    for name, definition in tools.items():
        if name in allow:
            filtered[name] = definition
    return filtered
def _refresh_tools(conn: "ConnectionHandler") -> None:
    handler = getattr(conn, "func_handler", None)
    if handler and hasattr(handler, "upload_functions_desc"):
        handler.upload_functions_desc()
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None:
    mode = app.get("system_prompt_mode") or "none"
    if conn.original_system_prompt is None:
        conn.original_system_prompt = conn.prompt
    if mode == "translator":
        conn.change_system_prompt(
            "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            "不要讲笑话、不要查天气、不要闲聊。"
        )
    elif mode == "agent_proactive":
        base = conn.original_system_prompt or conn.prompt or ""
        if CHAT_PROACTIVE_HINT not in (base or ""):
            conn.change_system_prompt((base + "\n" + CHAT_PROACTIVE_HINT).strip())
        else:
            conn.change_system_prompt(base)
    else:
        if conn.original_system_prompt:
            conn.change_system_prompt(conn.original_system_prompt)
async def _execute_named_tool(conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]):
    handler = getattr(conn, "func_handler", None)
    if not handler or not hasattr(handler, "tool_manager"):
        return ActionResponse(action=Action.ERROR, response="工具处理器未就绪")
    try:
        return await handler.tool_manager.execute_tool(tool_name, arguments or {})
    except Exception as e:
        logger.bind(tag=TAG).error(f"执行工具 {tool_name} å¤±è´¥: {e}")
        return ActionResponse(action=Action.ERROR, response=str(e))
async def _stop_current_skills(conn: "ConnectionHandler") -> None:
    mode = getattr(conn, "interaction_mode", "CHAT")
    if mode == "PLAY_AUDIO":
        await _execute_named_tool(conn, DEVICE_STOP_TOOL, {})
    conn.awaiting_voice_app_choice = False
def _speak(conn: "ConnectionHandler", text: str) -> None:
    if not text:
        return
    from core.handle.intentHandler import speak_txt
    if not getattr(conn, "sentence_id", None):
        conn.sentence_id = uuid.uuid4().hex
    try:
        speak_txt(conn, text)
    except Exception as e:
        logger.bind(tag=TAG).warning(f"主动播报失败: {e}")
def _greeting_text(conn: "ConnectionHandler") -> str:
    return (conn.config or {}).get("proactive_chat_greeting") or "我们来聊天吧。"
async def start_app_task(
    conn: "ConnectionHandler",
    app_id: str,
    task_id: str = "",
    params: Optional[Dict[str, Any]] = None,
    source: str = "voice",
    execute_skills: bool = True,
) -> ActionResponse:
    """Single entry for MQTT / schedule / voice / default chat."""
    _ensure_runtime_fields(conn)
    params = params or {}
    app, task = get_task(app_id, task_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}")
    if not task:
        default_task = app.get("default_task") or ""
        app, task = get_task(app_id, default_task)
    if not task:
        return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}")
    resolved_task_id = task_id or app.get("default_task") or "conversation"
    new_p = source_priority(source)
    cur_p = source_priority(getattr(conn, "current_source", "default_chat"))
    same_task = (
        getattr(conn, "active_app_id", "") == app_id
        and getattr(conn, "current_task_id", "") == resolved_task_id
    )
    if new_p < cur_p and not same_task:
        logger.bind(tag=TAG).info(
            f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})"
        )
        return ActionResponse(
            action=Action.NONE,
            response="当前有更高优先级的任务,先完成后再切换",
        )
    if not same_task or new_p >= cur_p:
        await _stop_current_skills(conn)
    conn.active_app_id = app_id
    conn.current_task_id = resolved_task_id
    conn.interaction_mode = task.get("interaction_mode") or app.get("mode") or "CHAT"
    conn.current_source = source
    conn.tool_policy = app.get("tool_policy") or "inherit_agent"
    conn.tool_allowlist = list(app.get("tools") or [])
    _apply_prompt(conn, app)
    _refresh_tools(conn)
    logger.bind(tag=TAG).info(
        f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
    )
    if execute_skills:
        for skill in task.get("skills") or []:
            skill_name = (skill or {}).get("name") or ""
            if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
                play_args = {
                    "name": params.get("name") or "",
                    "repeat": int(params.get("repeat") or 1),
                    "power_off_after": bool(params.get("power_off_after") or False),
                }
                if params.get("speed") is not None:
                    play_args["speed"] = params.get("speed")
                if not play_args["name"]:
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response="没有指定要复读的文件名,请先用 self.mp3.get_list ç¡®è®¤ files[].name",
                    )
                result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
                if result and result.action in (Action.ERROR, Action.NOTFOUND):
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response=result.response or "这台设备没有本地复读播放能力",
                    )
            elif skill_name == "self.power_off":
                await _execute_named_tool(conn, "self_power_off", {})
    if conn.interaction_mode == "CHAT":
        enable = (conn.config or {}).get("enable_proactive_greeting", True)
        woken = bool(getattr(conn, "just_woken_up", False))
        if enable and not woken:
            _speak(conn, _greeting_text(conn))
    app_name = app.get("name") or app_id
    return ActionResponse(
        action=Action.NONE,
        result=f"{app_id}/{resolved_task_id}",
        response=f"已打开{app_name}",
    )
async def switch_app(conn: "ConnectionHandler", app_id: str, source: str = "voice") -> ActionResponse:
    app = get_app(app_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"不支持的 App: {app_id}")
    task_id = app.get("default_task") or ""
    return await start_app_task(conn, app_id, task_id, {}, source)
async def on_user_wakeup(conn: "ConnectionHandler") -> None:
    """User wake word returns to chat; do not treat [app_task] as wakeup."""
    _ensure_runtime_fields(conn)
    if getattr(conn, "interaction_mode", "CHAT") == "CHAT" and getattr(conn, "active_app_id", "chat") == "chat":
        return
    await start_app_task(conn, "chat", "conversation", {}, source="voice")
def match_voice_switch(text: str) -> Optional[tuple]:
    """Map a short phrase to (app_id, task_id) or ('__menu__', '')."""
    if not text:
        return None
    _, filtered = _strip(text)
    if filtered in ("切换模式", "qie huan mo shi"):
        return ("__menu__", "")
    for p in CHAT_PHRASES:
        if p in filtered:
            return ("chat", "conversation")
    for p in TRANSLATE_PHRASES:
        if p in filtered:
            return ("english_translation", "live_translate")
    for p in REPEATER_PHRASES:
        if p in filtered:
            return ("english_learning", "repeater")
    if any(k in filtered for k in ("数字背诵", "成语接龙", "口算", "加减法")):
        return ("__reserved__", "")
    return None
def _strip(text: str) -> tuple:
    from core.utils.util import remove_punctuation_and_length
    return remove_punctuation_and_length(text)
def is_exit_translation(text: str) -> bool:
    _, filtered = _strip(text)
    return any(p in filtered for p in ("退出翻译", "结束翻译", "回到聊天", "我们来聊天吧", "聊会天吧"))
def _plain_user_text(text: str) -> str:
    """ASR å¯èƒ½æŠŠåŽŸæ–‡åŒ…åœ¨ JSON é‡Œï¼Œç¿»è¯‘只要 content。"""
    if not text:
        return ""
    raw = str(text).strip()
    if raw.startswith("{") and raw.endswith("}"):
        try:
            import json
            data = json.loads(raw)
            if isinstance(data, dict) and data.get("content"):
                return str(data["content"]).strip()
        except Exception:
            pass
    return raw
def _looks_like_tool_error(text: str) -> bool:
    if not text:
        return True
    s = str(text)
    if "Error executing tool" in s or "isError=True" in s or "isError=true" in s:
        return True
    if "未配置" in s and "api_key" in s:
        return True
    return False
async def run_translation(conn: "ConnectionHandler", text: str) -> str:
    """Translate with the connection LLM first (智控台已配的模型). MCP is fallback only."""
    text = _plain_user_text(text)
    if not text:
        return ""
    from plugins_func.register import all_function_registry
    item = all_function_registry.get("translate")
    if item:
        try:
            maybe = item.func(conn, text=text)
            if hasattr(maybe, "__await__"):
                maybe = await maybe
            if isinstance(maybe, ActionResponse) and maybe.action not in (
                Action.ERROR,
                Action.NOTFOUND,
            ):
                out = (maybe.response or maybe.result or "").strip()
                if out and not _looks_like_tool_error(out):
                    return out
        except Exception as e:
            logger.bind(tag=TAG).warning(f"翻译插件失败: {e}")
    handler = getattr(conn, "func_handler", None)
    if handler and handler.has_tool("translate"):
        result = await _execute_named_tool(conn, "translate", {"text": text})
        if result and result.action not in (Action.ERROR, Action.NOTFOUND):
            out = _extract_translate_text(result.response or result.result or "")
            if out and not _looks_like_tool_error(out):
                return out
    llm = getattr(conn, "llm", None)
    if llm and hasattr(llm, "response_no_stream"):
        try:
            return (
                llm.response_no_stream(TRANSLATOR_PROMPT, text, temperature=0.1) or ""
            ).strip()
        except Exception as e:
            logger.bind(tag=TAG).error(f"翻译 LLM å¤±è´¥: {e}")
    return ""
def _extract_translate_text(raw: Any) -> str:
    if raw is None:
        return ""
    if isinstance(raw, str):
        text = raw.strip()
        if text.startswith("{") and "text" in text:
            try:
                import json
                data = json.loads(text)
                if isinstance(data, dict):
                    return str(data.get("text") or data.get("content") or text)
            except Exception:
                pass
        return text
    if hasattr(raw, "content"):
        content = raw.content
        if isinstance(content, list) and content:
            first = content[0]
            return getattr(first, "text", None) or str(first)
        return str(content)
    return str(raw).strip()
xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
@@ -200,6 +200,16 @@
        # æ ‡è®°å½“前是否为来电接听模式
        self.incoming_call = None
        # Phase 1 App Runtime(连接内存,不建表)
        self.active_app_id = "chat"
        self.current_task_id = "conversation"
        self.interaction_mode = "CHAT"
        self.current_source = "default_chat"
        self.tool_policy = "inherit_agent"
        self.tool_allowlist = []
        self.original_system_prompt = None
        self.awaiting_voice_app_choice = False
    async def handle_connection(self, ws: websockets.ServerConnection):
        try:
            # èŽ·å–è¿è¡Œä¸­çš„äº‹ä»¶å¾ªçŽ¯ï¼ˆå¿…é¡»åœ¨å¼‚æ­¥ä¸Šä¸‹æ–‡ä¸­ï¼‰
@@ -649,6 +659,8 @@
            self._init_prompt_enhancement()
            """注入工具调用few-shot示例(仅function_call模式)"""
            self._inject_tool_call_fewshot()
            if self.original_system_prompt is None:
                self.original_system_prompt = self.prompt
        except Exception as e:
            self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
@@ -46,22 +46,24 @@
    actual_text = text
    try:
        # å°è¯•解析JSON格式的输入
        # FunASR ç­‰ä¼šæŠŠç»“果包成 {"content","language","emotion"};声纹场景还有 speaker
        if text.strip().startswith("{") and text.strip().endswith("}"):
            data = json.loads(text)
            if "speaker" in data and "content" in data:
                speaker_name = data["speaker"]
                actual_content = data["content"]
                conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}")
                # ä»…在该说话人首次出现时保留 {"speaker":...} JSON,让模型自然称呼一次;
                # åŽç»­è½®é™ä¸ºçº¯æ–‡æœ¬ï¼Œé¿å…æ¯è½®é‡å¤å‡ºçŽ°åå­—è¯±å¯¼æ¨¡åž‹åå¤ç§°å‘¼
                if speaker_name not in conn.introduced_speakers:
                    conn.introduced_speakers.add(speaker_name)
                    actual_text = text
                else:
            if isinstance(data, dict) and "content" in data:
                speaker_name = data.get("speaker")
                actual_content = data.get("content") or ""
                if speaker_name:
                    conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}")
                    # ä»…在该说话人首次出现时保留 {"speaker":...} JSON,让模型自然称呼一次;
                    # åŽç»­è½®é™ä¸ºçº¯æ–‡æœ¬ï¼Œé¿å…æ¯è½®é‡å¤å‡ºçŽ°åå­—è¯±å¯¼æ¨¡åž‹åå¤ç§°å‘¼
                    if speaker_name not in conn.introduced_speakers:
                        conn.introduced_speakers.add(speaker_name)
                        actual_text = text
                    else:
                        actual_text = actual_content
                elif actual_content:
                    actual_text = actual_content
    except (json.JSONDecodeError, KeyError):
    except (json.JSONDecodeError, KeyError, TypeError):
        # å¦‚果解析失败,继续使用原始文本
        pass
@@ -83,6 +85,70 @@
            await max_out_size(conn)
            return
    from core.app_runtime.runtime import (
        is_exit_translation,
        match_voice_switch,
        run_translation,
        start_app_task,
        VOICE_MENU,
    )
    from core.handle.intentHandler import speak_txt
    mode = getattr(conn, "interaction_mode", "CHAT")
    if getattr(conn, "awaiting_voice_app_choice", False):
        mapped = match_voice_switch(actual_text)
        conn.awaiting_voice_app_choice = False
        if mapped and mapped[0] == "__reserved__":
            speak_txt(conn, "这个游戏还没开放,我们先聊天吧。")
            await start_app_task(conn, "chat", "conversation", {}, source="voice")
            return
        if mapped and mapped[0] == "__menu__":
            conn.awaiting_voice_app_choice = True
            speak_txt(conn, VOICE_MENU)
            return
        if mapped and mapped[0] != "__menu__":
            params = {}
            await start_app_task(conn, mapped[0], mapped[1], params, source="voice")
            return
        speak_txt(conn, VOICE_MENU)
        conn.awaiting_voice_app_choice = True
        return
    mapped = match_voice_switch(actual_text)
    if mapped and mapped[0] == "__menu__":
        conn.awaiting_voice_app_choice = True
        speak_txt(conn, VOICE_MENU)
        return
    if mapped and mapped[0] == "__reserved__":
        speak_txt(conn, "这个还没开放,先继续当前内容吧。")
        return
    if mapped and mapped[0] in ("english_translation", "english_learning", "chat"):
        if mapped[0] == "english_learning" and mapped[1] == "repeater":
            speak_txt(conn, "复读需要指定 SD å¡ä¸Šçš„æ–‡ä»¶åï¼Œæˆ–用小程序开始。")
            return
        await start_app_task(conn, mapped[0], mapped[1], {}, source="voice")
        return
    if mode == "PLAY_AUDIO":
        conn.logger.bind(tag=TAG).info("PLAY_AUDIO æ¨¡å¼å¿½ç•¥ä¸Šè¡Œå¯¹è¯")
        return
    if mode == "TRANSLATION":
        if is_exit_translation(actual_text):
            await start_app_task(conn, "chat", "conversation", {}, source="voice")
            return
        await send_stt_message(conn, actual_text)
        conn.client_abort = False
        translated = await run_translation(conn, actual_text)
        if not translated:
            translated = "没有听清要翻译的内容,请再说一次。"
        if not conn.sentence_id:
            import uuid as _uuid
            conn.sentence_id = _uuid.uuid4().hex
        speak_txt(conn, translated)
        return
    # manual æ¨¡å¼ä¸‹ä¸æ‰“断正在播放的内容
    if conn.client_is_speaking and conn.client_listen_mode != "manual":
        await handleAbortMessage(conn)
xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
@@ -62,6 +62,35 @@
                    original_text
                )
                # App Runtime: è®¾å¤‡ä¸ŠæŠ¥çš„ Task å¯åŠ¨ï¼ˆå¤ç”¨ listen detect.text,不新增 WS type)
                if original_text.startswith("[app_task]"):
                    payload = original_text[len("[app_task]"):].strip()
                    parts = [p.strip() for p in payload.split("|") if p.strip()]
                    app_id = parts[0] if parts else "chat"
                    task_id = parts[1] if len(parts) > 1 else ""
                    source = parts[2] if len(parts) > 2 else "mqtt_immediate"
                    conn.logger.bind(tag=TAG).info(
                        f"收到 app_task: {app_id}/{task_id} source={source}"
                    )
                    from core.app_runtime.runtime import start_app_task
                    start_time = time.time()
                    while time.time() - start_time < 3:
                        if conn.tts:
                            break
                        await asyncio.sleep(0.1)
                    await start_app_task(conn, app_id, task_id, {}, source=source, execute_skills=False)
                    return
                if original_text.startswith("[voice_entry]"):
                    from core.app_runtime.runtime import VOICE_MENU
                    from core.handle.intentHandler import speak_txt
                    conn.awaiting_voice_app_choice = True
                    conn.sentence_id = uuid.uuid4().hex
                    speak_txt(conn, VOICE_MENU)
                    return
                # æ£€æŸ¥æ˜¯å¦æ˜¯è®¾å¤‡å‘¼å«æŒ‡ä»¤ [device_call]
                if original_text.startswith("[device_call]"):
                    # æå– tag åŽçš„æ–‡æœ¬
@@ -105,6 +134,9 @@
                    conn.client_is_speaking = False
                elif is_wakeup_words:
                    conn.just_woken_up = True
                    from core.app_runtime.runtime import on_user_wakeup
                    await on_user_wakeup(conn)
                    # ä¸ŠæŠ¥çº¯æ–‡å­—数据(复用ASR上报功能,但不提供音频数据)
                    enqueue_asr_report(conn, "嘿,你好呀", [])
                    await startToChat(conn, "嘿,你好呀")
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py
@@ -2,6 +2,7 @@
import asyncio
import os
import sys
import json
from typing import Dict, Any, List
@@ -33,18 +34,33 @@
    def load_config(self) -> Dict[str, Any]:
        """加载MCP服务配置"""
        if len(self.config_path) == 0:
            return {}
        servers: Dict[str, Any] = {}
        if self.config_path:
            try:
                with open(self.config_path, "r", encoding="utf-8") as f:
                    config = json.load(f)
                servers = config.get("mcpServers", {}) or {}
            except Exception as e:
                logger.bind(tag=TAG).error(
                    f"Error loading MCP config from {self.config_path}: {e}"
                )
                servers = {}
        return self._ensure_builtin_servers(servers)
        try:
            with open(self.config_path, "r", encoding="utf-8") as f:
                config = json.load(f)
            return config.get("mcpServers", {})
        except Exception as e:
            logger.bind(tag=TAG).error(
                f"Error loading MCP config from {self.config_path}: {e}"
            )
            return {}
    def _ensure_builtin_servers(self, servers: Dict[str, Any]) -> Dict[str, Any]:
        """Register the Phase 1 translate MCP even if data/.mcp_server_settings.json is missing."""
        if "english-translate" in servers:
            return servers
        script = os.path.join(
            get_project_dir(), "mcp_servers", "translate", "server.py"
        )
        if os.path.isfile(script):
            servers["english-translate"] = {
                "command": sys.executable or "python",
                "args": [script],
            }
            logger.bind(tag=TAG).info("已注册内置 english-translate MCP")
        return servers
    async def _init_server(self, name: str, srv_config: Dict[str, Any]):
        """初始化单个MCP服务"""
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tools/unified_tool_manager.py
@@ -43,6 +43,13 @@
            except Exception as e:
                self.logger.error(f"获取{tool_type.value}工具时出错: {e}")
        try:
            from core.app_runtime.runtime import filter_tools
            all_tools = filter_tools(self.conn, all_tools)
        except Exception as e:
            self.logger.error(f"按 App è¿‡æ»¤å·¥å…·å¤±è´¥: {e}")
        self._cached_tools = all_tools
        return all_tools
xiaozhi-esp32-server/main/xiaozhi-server/mcp_server_settings.json
@@ -7,6 +7,11 @@
    "当前支持三种传输模式:stdio(标准输入输出), sse(Server-Sent Events), streamable-http(流式HTTP)。"
  ],
  "mcpServers": {
    "english-translate": {
      "command": "python",
      "args": ["mcp_servers/translate/server.py"],
      "des": "Phase 1 è‹±è¯­ç¿»è¯‘ MCP,只暴露 translate"
    },
    "Home Assistant": {
      "command": "mcp-proxy",
      "args": [
xiaozhi-esp32-server/main/xiaozhi-server/mcp_servers/translate/server.py
New file
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Standalone translation MCP. Only exposes translate. Does not call ConnectionHandler.chat."""
from __future__ import annotations
import os
from pathlib import Path
import httpx
import yaml
from mcp.server.fastmcp import FastMCP
ROOT = Path(__file__).resolve().parents[2]
TRANSLATOR_PROMPT = (
    "你是一台翻译机。只输出译文,不要解释、不要拼音课、不要追问、不要闲聊。"
    "未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。"
)
mcp = FastMCP("english-translate")
def _read_yaml(path: Path) -> dict:
    if not path.is_file():
        return {}
    with path.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    return data if isinstance(data, dict) else {}
def _load_llm_config() -> dict:
    default_cfg = _read_yaml(ROOT / "config.yaml")
    custom_cfg = _read_yaml(ROOT / "data" / ".config.yaml")
    def merge(base, overlay):
        out = dict(base)
        for k, v in overlay.items():
            if isinstance(v, dict) and isinstance(out.get(k), dict):
                out[k] = merge(out[k], v)
            else:
                out[k] = v
        return out
    cfg = merge(default_cfg, custom_cfg)
    selected = (cfg.get("selected_module") or {}).get("LLM")
    llm_map = cfg.get("LLM") or {}
    if selected and selected in llm_map:
        item = dict(llm_map[selected])
        item["_name"] = selected
        return item
    if llm_map:
        name, item = next(iter(llm_map.items()))
        data = dict(item)
        data["_name"] = name
        return data
    return {}
def _chat_complete(text: str, source_lang: str, target_lang: str) -> str:
    llm = _load_llm_config()
    api_key = llm.get("api_key") or os.environ.get("OPENAI_API_KEY") or ""
    if api_key and ("你的" in str(api_key) or "key" == str(api_key).lower()):
        api_key = os.environ.get("OPENAI_API_KEY") or api_key
    base_url = llm.get("base_url") or llm.get("url") or "https://api.openai.com/v1"
    model = llm.get("model_name") or llm.get("model") or "gpt-4o-mini"
    if not api_key or "你的" in str(api_key):
        raise RuntimeError("翻译 MCP æœªé…ç½®å¯ç”¨çš„ LLM api_key")
    extra = ""
    if source_lang or target_lang:
        extra = f"\n源语言:{source_lang or 'auto'} ç›®æ ‡è¯­è¨€:{target_lang or 'auto'}"
    user = f"翻译下面内容,只输出译文:{extra}\n{text}"
    url = str(base_url).rstrip("/")
    if not url.endswith("/chat/completions"):
        if url.endswith("/v1"):
            url = url + "/chat/completions"
        else:
            url = url + "/v1/chat/completions"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    payload = {
        "model": model,
        "temperature": 0.1,
        "messages": [
            {"role": "system", "content": TRANSLATOR_PROMPT},
            {"role": "user", "content": user},
        ],
    }
    with httpx.Client(timeout=60.0) as client:
        resp = client.post(url, headers=headers, json=payload)
        resp.raise_for_status()
        data = resp.json()
    choices = data.get("choices") or []
    if not choices:
        raise RuntimeError("翻译接口没有返回内容")
    msg = (choices[0].get("message") or {}).get("content") or ""
    return str(msg).strip()
@mcp.tool()
def translate(text: str, source_lang: str = "", target_lang: str = "") -> str:
    """Translate text. Chinese->English, English->Chinese unless languages are set. Return translation only."""
    if not text or not str(text).strip():
        return ""
    return _chat_complete(str(text).strip(), source_lang or "", target_lang or "")
if __name__ == "__main__":
    os.chdir(ROOT)
    mcp.run(transport="stdio")
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
New file
@@ -0,0 +1,72 @@
from typing import TYPE_CHECKING
from plugins_func.register import register_function, ToolType, ActionResponse, Action
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
start_repeater_function_desc = {
    "type": "function",
    "function": {
        "name": "start_repeater",
        "description": "在设备 SD å¡ä¸Šå¤è¯»æŒ‡å®š MP3。name å¿…须与 self.mp3.get_list è¿”回的 files[].name å®Œå…¨ä¸€è‡´ã€‚repeat æ˜¯æ•´é¦–循环次数。不要用 play_music。",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "SD å¡ MP3 æ–‡ä»¶åï¼Œå¿…须与 get_list çš„ files[].name ä¸€è‡´",
                },
                "repeat": {
                    "type": "integer",
                    "description": "整首循环次数,默认 1",
                },
                "power_off_after": {
                    "type": "boolean",
                    "description": "播完后是否软关机,默认 false",
                },
            },
            "required": ["name"],
        },
    },
}
@register_function("start_repeater", start_repeater_function_desc, ToolType.SYSTEM_CTL)
async def start_repeater(
    conn: "ConnectionHandler",
    name: str,
    repeat: int = 1,
    power_off_after: bool = False,
):
    from core.app_runtime.runtime import start_app_task
    try:
        repeat_n = int(repeat or 1)
    except (TypeError, ValueError):
        repeat_n = 1
    if repeat_n < 1:
        repeat_n = 1
    result = await start_app_task(
        conn,
        "english_learning",
        "repeater",
        {
            "name": name,
            "repeat": repeat_n,
            "power_off_after": bool(power_off_after),
        },
        source="voice",
    )
    if result.action in (Action.ERROR, Action.RESPONSE):
        return ActionResponse(
            action=Action.RESPONSE,
            result=result.result,
            response=result.response or "无法开始复读",
        )
    return ActionResponse(
        action=Action.NONE,
        result=result.result,
        response=result.response,
    )
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
New file
@@ -0,0 +1,41 @@
from typing import TYPE_CHECKING
from plugins_func.register import register_function, ToolType, ActionResponse, Action
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
switch_app_function_desc = {
    "type": "function",
    "function": {
        "name": "switch_app",
        "description": "切换设备当前 App。打开翻译、打开英语学习/复读、回到聊天、退出翻译时调用。app_id åªèƒ½æ˜¯ chat、english_learning、english_translation。",
        "parameters": {
            "type": "object",
            "properties": {
                "app_id": {
                    "type": "string",
                    "description": "目标 App:chat(聊天)、english_learning(复读)、english_translation(翻译)",
                }
            },
            "required": ["app_id"],
        },
    },
}
@register_function("switch_app", switch_app_function_desc, ToolType.SYSTEM_CTL)
async def switch_app(conn: "ConnectionHandler", app_id: str):
    from core.app_runtime.runtime import switch_app as runtime_switch
    result = await runtime_switch(conn, app_id, source="voice")
    if result.action == Action.ERROR:
        return ActionResponse(
            action=Action.RESPONSE,
            result=result.response,
            response=result.response or "打不开这个应用",
        )
    if str(result.result or "").startswith("chat/"):
        return ActionResponse(action=Action.NONE, result=result.result, response=result.response)
    name = result.response or "已切换"
    return ActionResponse(action=Action.RESPONSE, result=result.result, response=name)
xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/translate.py
New file
@@ -0,0 +1,61 @@
from typing import TYPE_CHECKING
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.app_runtime.runtime import TRANSLATOR_PROMPT
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
translate_function_desc = {
    "type": "function",
    "function": {
        "name": "translate",
        "description": "把用户这句话翻译成另一种语言。只返回译文。中文译英文,英文译中文。不要解释。",
        "parameters": {
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "要翻译的原文"},
                "source_lang": {
                    "type": "string",
                    "description": "源语言代码,可空;空则自动判断",
                },
                "target_lang": {
                    "type": "string",
                    "description": "目标语言代码,可空;空则中文↔英文",
                },
            },
            "required": ["text"],
        },
    },
}
def _build_user_prompt(text: str, source_lang: str, target_lang: str) -> str:
    extra = ""
    if source_lang or target_lang:
        extra = f"\n源语言:{source_lang or 'auto'} ç›®æ ‡è¯­è¨€:{target_lang or 'auto'}"
    return f"翻译下面内容,只输出译文:{extra}\n{text}"
@register_function("translate", translate_function_desc, ToolType.SYSTEM_CTL)
def translate(
    conn: "ConnectionHandler",
    text: str,
    source_lang: str = "",
    target_lang: str = "",
):
    llm = getattr(conn, "llm", None)
    if not llm or not text:
        return ActionResponse(action=Action.ERROR, response="翻译服务不可用")
    try:
        out = llm.response_no_stream(
            TRANSLATOR_PROMPT,
            _build_user_prompt(text, source_lang or "", target_lang or ""),
            temperature=0.1,
        )
        out = (out or "").strip()
        if not out:
            return ActionResponse(action=Action.ERROR, response="没有得到译文")
        return ActionResponse(action=Action.RESPONSE, result=out, response=out)
    except Exception as e:
        return ActionResponse(action=Action.ERROR, response=str(e))
СÖÇ AI Server-Task¡¢ÓïÒô·ÖÎöÓëÓû§ÆÀ¹Àƽ̨¼Ü¹¹ Plan.md
New file
@@ -0,0 +1,537 @@
# å°æ™º AI Server Architecture Plan
> æ–‡æ¡£ï¼šå°æ™º AI Server â€”— Task、语音分析与用户评估平台架构 Plan
> Version: V1.1
> Date: 2026-08-30
> æœ¬æ–‡åœ¨ V1.0 æ¦‚念稿上,依据《Xiaozhi AI å¼€æ”¾åº”用平台 Master Plan》与《Architecture Audit》修订。
> **只设计,不编码。**
> ä¸Žã€Šå°æ™º AI ç¡¬ä»¶å¼€æ”¾åº”用平台 â€”— æ€»ä½“架构与开发 Plan》V1.1 æˆå¯¹é˜…读。
---
## 0. æœ¬æ–‡å®šä½
本文只定义 **Server**(仓库 `xiaozhi-esp32-server`)如何从「实时语音对话后端」演进为「Task ç¼–排 + è¯­éŸ³å¯¹è±¡ + è¯„估」平台。
硬件执行细节见 Client Plan。本文只规定 **对设备的契约** ä¸Ž **云端模块 / æ•°æ® / API**。
工作区另有 `smartlearning/`(课程 JSON + MQTT DOWNLOAD/PLAY_MEDIA)。它更接近 App Sync,但**未接入** Xiaozhi Server。本文把它当作可吸收的旁路,而不是第三套长期业务云。
**相对 V1.0 çš„关键修正:**
| V1.0 | V1.1(本次) |
|------|----------------|
| ä¸€ä¸Šæ¥æ‹† Gateway/一堆 Assessment å¾®æœåŠ¡ | **逻辑模块分开,物理仍是 Python xiaozhi-server + Java manager-api** |
| Phase 1 å°±åšè·Ÿè¯»+倒背+口算三套评估 | Phase 1 = è‹±è¯­å¤è¯»ç¼–排且 **不评估**;Phase 2 = è·Ÿè¯» + ASR æ–‡æœ¬æ¯”对 |
| è®¾å¤‡ä¸Ž Server å„有一套完整 Task Runtime | **权威 Task Runtime åªåœ¨ Server**;设备是 Task Executor |
| éŸ³é¢‘规划为对象存储,但未对齐现状 | æ‰¿è®¤çŽ°çŠ¶æ˜¯ `ai_agent_chat_audio` LONGBLOB;演进再迁对象存储 |
| Agent æœªåŒºåˆ† | **Agent â‰  App**;Agent ç»§ç»­åšäººè®¾/模型配置 |
| 8~10 ä¸ªå¹³å°é˜¶æ®µæ··åœ¨ä¸€èµ· | ä¸Ž Client å¯¹é½ä¸º Phase 1/2/3,其后评估平台再拆子阶段 |
---
## 1. å½“前 Server æ˜¯ä»€ä¹ˆï¼ˆAudit äº‹å®žï¼‰
双栈,不是微服务网:
| æ¨¡å— | å…¥å£ | èŒè´£ |
|------|------|------|
| xiaozhi-server | `main/xiaozhi-server/app.py` | WS `/xiaozhi/v1/`、OTA HTTP、实时 ASR/LLM/TTS |
| manager-api | `AdminApplication.java` | ç”¨æˆ·/设备/Agent/插件/聊天落库 |
| manager-web / mobile | â€” | ç®¡ç†ç«¯ |
| digital-human | â€” | æµ‹è¯•端,非主链路 |
一次对话:
```text
Device â†’ ConnectionHandler (session_id = uuid4)
  â†’ VAD â†’ ASR â†’ intent / chat()
  â†’ UnifiedToolHandler (Plugin / MCP / IoT)
  â†’ TTS â†’ type=tts/stt/llm/mcp + Opus
  â†’ å¯é€‰ report â†’ POST /agent/chat-history/report
  â†’ ai_agent_chat_history + ai_agent_chat_audio (WAV)
```
关键代码:
| é˜¶æ®µ | ä½ç½® |
|------|------|
| å…¥ç«™ | `core/connection.py` `_route_message` |
| VAD/ASR | `core/handle/receiveAudioHandle.py` |
| æ„å›¾ | `core/handle/intentHandler.py` |
| LLM | `ConnectionHandler.chat` |
| TTS å‡ºç«™ | `core/handle/sendAudioHandle.py` |
| ä¸ŠæŠ¥ | `core/handle/reportHandle.py` â†’ WAV |
| å·¥å…· | `plugins_func/*` + `unified_tool_handler.py` |
**已有可复用:** Agent、Plugin、MCP、function call、session_id、可选 WAV、`play_music`(但走 TTS é€šé“,**不适合**复读循环)。
**不存在:** App、Task、Step、TaskInstance、InteractionMode、Assessment、Plan、Subscription。
`session_id` æ˜¯ **WS è¿žæŽ¥çº§**,不是 TaskInstance。
---
## 2. ç›®æ ‡ï¼ˆä»æ˜¯ V1.0 æ„¿æ™¯ï¼Œä½†åˆ†å±‚落地)
```text
Xiaozhi Server
  â”œâ”€â”€ Device Gateway        â† çŽ°æœ‰ WS / MQTT ç½‘关,保留
  â”œâ”€â”€ Chat Orchestration    â† çŽ°æœ‰ ConnectionHandler.chat
  â”œâ”€â”€ Task Runtime          â† æ–°å¢žï¼Œæƒå¨ç¼–排
  â”œâ”€â”€ Media / Audio Object  â† ä»Žã€ŒèŠå¤©é™„件」演进为可挂 Step çš„音频对象
  â””── Assessment            â† Phase 2 èµ·ï¼›å…ˆæ–‡æœ¬æ¯”对,后语音特征
```
最终数据流(目标态,不是 Phase 1):
```text
Schedule â†’ TaskInstance â†’ Step â†’ InteractionMode
  â†’ Device Skill â†’ å„¿ç«¥è¯­éŸ³
  â†’ ä¿å­˜ Audio å¯¹è±¡ â†’ ASR â†’ Assessment â†’ Metric â†’ å®¶é•¿æŠ¥å‘Š
```
Phase 1 **截断在**「Mode=PLAY_AUDIO é©±åŠ¨è®¾å¤‡æ’­æ”¾ã€ï¼›ä¸è¦æ±‚è¯„ä¼°ä¸Žå®¶é•¿æŠ¥å‘Šã€‚
---
## 3. èŒè´£è¾¹ç•Œï¼ˆä¸Ž Client Plan å¯¹é½ï¼‰
```text
Server
  åˆ›å»º / æŽ¨è¿› / ç»“束 TaskInstance
  æŒ‡å®š InteractionMode
  è°ƒç”¨è®¾å¤‡ Skill(MCP æˆ– type=task)
  ASR / LLM / TTS / Assessment
  éŸ³é¢‘对象与 step å…³è”
  App JSON ä¸Žèµ„源 URL çš„æƒå¨
Client
  æ‰§è¡Œ Skill、切换 Mode、回报 Event
  ä¸æŒä¹…化 Step åŽ†å²ã€ä¸è¯„åˆ†
```
**禁止**在 Python å¯¹è¯çƒ­è·¯å¾„里写死 `if english_repeat` ä½œä¸ºé•¿æœŸå½¢æ€ï¼›Phase 1 å…è®¸ä¸€ä¸ª **编排插件**,Phase 2 å¿…须升到 Task Runtime + JSON å®šä¹‰ã€‚
---
## 4. é€»è¾‘模块 vs ç‰©ç†éƒ¨ç½²
V1.0 åˆ—出的 Gateway / Task Service / Assessment Service / Emotion â€¦ **全部作为逻辑边界**。
物理部署保持:
```text
xiaozhi-server (Python)     å®žæ—¶ï¼šè¿žæŽ¥ã€ASR、LLM、TTS、Mode åˆ‡æ¢ã€Task æ­¥è¿›ï¼ˆè¿è¡Œæ—¶ï¼‰
manager-api (Java)          é…ç½®ä¸ŽæŒä¹…化:User、Device、Agent、Task å®šä¹‰/实例、音频元数据、评估结果
对象存储(Phase 2+)        åŽŸå§‹éŸ³é¢‘ï¼›ç¬¬ä¸€æœŸå¯ç»§ç»­ BLOB,但 API æŒ‰ audio_id è®¿é—®
```
不要为第一个复读 Task æ‹†æ–°è¿›ç¨‹ã€‚
模块映射:
| é€»è¾‘模块 | Phase | è½ç‚¹ |
|----------|-------|------|
| Device Gateway | 0 å·²æœ‰ | `websocket_server.py` / MQTT gateway |
| Chat Orchestration | 0 å·²æœ‰ | `ConnectionHandler` |
| Interaction Mode å¼€å…³ | 1 | `ConnectionHandler` ä¸Š `interaction_mode`;非 CHAT è·³è¿‡ `chat()` |
| Repeat Orchestrator | 1 | `plugins_func` æˆ–独立薄模块;MCP `self.mp3.play` |
| Task Runtime | 2 | Python è¿è¡Œæ—¶çŠ¶æ€æœº + Java å®žä¾‹è¡¨ |
| Audio Object | 2 | æ‰©å±•上报:挂 `step_instance_id`;逐步迁出 LONGBLOB |
| Assessment v0 | 2 | expected_text vs ASR;Java æˆ– Python å‡å¯ï¼Œç»“果入库 |
| App / Resource | 3 | å¸æ”¶ smartlearning æ¸…单 + ä¸‹è½½ URL;Java ä¸ºæƒå¨ |
| Scheduler / Plan | 3 | manager-api + å®šæ—¶ï¼›ä¸è¦ç”¨è®¾å¤‡ RTC é—¹é’Ÿå†’å…… |
| Voice Feature / Baseline / Emotion | 3+ | å¼‚步队列;禁止进实时 TTS è·¯å¾„ |
| Subscription / Developer API | æ›´åŽ | ä¸åš Phase 1–2 |
---
## 5. çŽ°æœ‰å¯¹è¯é“¾è·¯ï¼šå¿…é¡»ä¿ç•™
无 Task æ—¶è¡Œä¸ºä¸ŽçŽ°ç½‘ä¸€è‡´ï¼š
```text
listen â†’ ASR â†’ intent/LLM/tools â†’ TTS
```
协议 `hello/listen/abort/iot/mcp/ping` ä¸Žä¸‹è¡Œ `stt/tts/llm/mcp` **禁止改语义**。
插入方式:
```text
_route_message / startToChat / chat()
  ä¹‹å‰åˆ¤æ–­ interaction_mode
  CHAT â†’ åŽŸè·¯å¾„
  PLAY_AUDIO / FOLLOW_READ â†’ ä¸è°ƒç”¨ chat()
  FOLLOW_READ çš„语音仍走 ASR.receive_audio,结果写入 Step è€Œéž Dialogue
```
`play_music` ç»§ç»­æœåŠ¡ã€Œç”¨æˆ·è¯´æ’­æ”¾æ­Œæ›²ã€ã€‚**不要**用它实现英语复读(它占用 TTS é˜Ÿåˆ—,和课程 SD æ’­æ”¾å†²çªï¼‰ã€‚
---
## 6. Agent vs App
| | Agent(已有 `ai_agent`) | App(新建,Phase 3) |
|--|--------------------------|----------------------|
| æ˜¯ä»€ä¹ˆ | äººè®¾ + ASR/LLM/TTS/插件配置 | ä¸šåŠ¡å®¹å™¨ï¼ˆè‹±è¯­è€å¸ˆã€å£ç®—â€¦ï¼‰ |
| è°ç”¨ | æ¯å°è®¾å¤‡å½“前绑定一个 | å„¿ç«¥å¯è®¢é˜…多个,同时只 Active ä¸€ä¸ª |
| Phase 1 | **继续用 Agent è·‘ Chat** | ä¸å»º App è¡¨ä¹Ÿå¯ä»¥åšå¤è¯» Demo |
Phase 1 å¤è¯»å¯ä»¥æŒ‚在「当前 Agent + ä¸€ä¸ªæ’件」,不必先有 App å®žä½“。Phase 3 å†æŠŠ Task Definition å½’到 App ä¸‹ã€‚
不要把 `ai_agent` æ”¹åä¸º App,也不要在 Agent ä¸Šå † Workflow JSON ä½œä¸ºé•¿æœŸæ¨¡åž‹ï¼ˆä¸´æ—¶å¯ä»¥ï¼Œæ­£å¼æ¨¡åž‹è§ç¬¬ 10 èŠ‚ï¼‰ã€‚
---
## 7. Task Runtime(Server æƒå¨ï¼‰
### 7.1 èŒè´£
```text
加载 Task Definition
创建 TaskInstance
按 Step æŽ¨è¿›
设置 Connection.interaction_mode
下发 skill.execute / MCP
等待 Device Event æˆ– ASR å®Œæˆ
写 StepInstance
决定下一步 / ç»“束 / å¤±è´¥
```
运行时可活在 `ConnectionHandler` æ—çš„ `TaskRuntime` å¯¹è±¡ï¼ˆæ¯è¿žæŽ¥ä¸€ä¸ªå½“前 Instance)。持久化在 manager-api。
### 7.2 çŠ¶æ€
```text
CREATED â†’ READY â†’ RUNNING
  â†’ WAITING_DEVICE | WAITING_USER | WAITING_AI
  â†’ COMPLETED | FAILED | CANCELLED | TIMEOUT
```
设备只缓存 RUNNING çš„当前 step;重连后 Server é‡æ”¾æˆ–结束。
### 7.3 Definition ä¸Ž Instance åˆ†ç¦»ï¼ˆä¿ç•™ V1.0)
Definition:模板(英语复读 10 éï¼‰ã€‚
Instance:某日某设备的一次执行(进度、状态、结果)。
### 7.4 Phase 1 ç‰¹ä¾‹
没有正式 Runtime ä¹Ÿå¯ä»¥ï¼š
```text
插件 english_repeat(n, resource)
  loop: MCP play â†’ wait play.completed æˆ–è¶…æ—¶
  mode=PLAY_AUDIO æœŸé—´ç¦æ­¢ chat()
```
这是为了验证链路;Phase 2 ç”¨ JSON Step æ›¿æ¢è¯¥å¾ªçŽ¯ã€‚
---
## 8. InteractionMode åœ¨ Server çš„含义
Server æ˜¯ Mode çš„ **指定者**:
```text
Step.mode = PLAY_AUDIO | FOLLOW_READ | CHAT | LISTEN | â€¦
  â†’ conn.interaction_mode = â€¦
  â†’ å¯é€‰ä¸‹è¡Œ mode.set(Phase 2)
```
| Mode | Server è¡Œä¸º |
|------|-------------|
| CHAT | çŽ°æœ‰ `startToChat` / `chat()` |
| PLAY_AUDIO | ä¸ ASR å¯¹è¯ã€ä¸ LLM;驱动 play;等完成事件 |
| FOLLOW_READ | å…ˆ play,再收听;ASR æ–‡æœ¬è¿› Step;**不**进 Dialogue/LLM |
| LISTEN | åª ASR,结果给当前 Step |
| QUIZ/GAME | Phase 3:出题在 Server,设备 CHAT æˆ– TTS+LISTEN |
V1.0 çš„「Interaction Runtime ä½œä¸ºç‹¬ç«‹æœåŠ¡ã€é™çº§ä¸º **ConnectionHandler ä¸Šçš„ mode å­—段 + TaskRuntime ç­–略表**。
---
## 9. éŸ³é¢‘æž¶æž„
### 9.1 çŽ°çŠ¶
- å…¥ï¼šOpus æµï¼›å†…部 ASR PCM 16k
- å‡ºï¼šOpus(hello é‡Œå¸¸è§ 24k / 60ms)
- è½åº“:`chat_history_conf==2` æ—¶ PCM/Opus **转 WAV** è¿› `ai_agent_chat_audio.audio`(注释写 opus,实际 WAV)
- `audio_id` ä»…在上报后存在;history æœ‰ `session_id`,**无** task/step
### 9.2 åŽŸåˆ™ï¼ˆä¿ç•™ V1.0)
> è·Ÿè¯»/评估不要只存 ASR æ–‡æœ¬ã€‚原始音频是可重分析资产。
但 Phase 1 å¤è¯» **可以不存用户音频**(用户可能没说话)。
### 9.3 æ¼”è¿›
**Phase 2 æœ€å°ï¼š**
```text
沿用 reportHandle WAV ä¸ŠæŠ¥
字段增加 task_instance_id, step_instance_id, purpose=follow_read
audio_id å†™å…¥ step_instance
仍可暂存 BLOB
```
**Phase 2+ ç›®æ ‡ï¼ˆV1.0 å¯¹è±¡å­˜å‚¨ï¼‰ï¼š**
```text
DB: audio_id, user/device, task/step, storage_key, duration, format, sample_rate
Blob: å¯¹è±¡å­˜å‚¨ raw wav/opus
不要把长期课程音频继续堆在 MySQL LONGBLOB
```
**Segment:** è·Ÿè¯»ä¸€æ­¥ä¸€æ®µç”¨æˆ·éŸ³é¢‘即可;单词级切分放到评估模型需要时再做。不要 Phase 2 å°±ä¸Šå®Œæ•´ AudioSession æ ‘。
**设备文件录音 / pending ä¸Šä¼ ï¼š** Client Phase 3;Server é¢„ç•™ upload API,不阻塞跟读 v0。
时间戳:连接级足够做 v0;NTP ä¸Žç²¾ç¡® latency æ”¾è¯„ä¼° Phase 3。Sening å·²æœ‰æ—¶é’ŸåŒæ­¥æŒ‡ä»¤ï¼Œå¯å¤ç”¨ï¼Œä¸ç®— Server æ–°æœåŠ¡ã€‚
---
## 10. æ•°æ®æ¨¡åž‹
### 10.1 çŽ°æœ‰è¡¨ï¼ˆä¿ç•™ï¼ŒChat ç»§ç»­ç”¨ï¼‰
| è¡¨ | ä½œç”¨ |
|----|------|
| `sys_user` | è´¦å· |
| `ai_device` | è®¾å¤‡ï¼›`user_id` `agent_id` `mac_address` |
| `ai_agent` | äººè®¾ä¸Žæ¨¡åž‹ï¼›`chat_history_conf` |
| `ai_agent_chat_history` | ä¼šè¯æ–‡æœ¬ï¼›`session_id` `audio_id` `chat_type` |
| `ai_agent_chat_audio` | éŸ³é¢‘ BLOB |
| `ai_agent_plugin_mapping` | Agent â†” æ’ä»¶ |
| `ai_ota` / æ¨¡åž‹ / RAG ç­‰ | ä¸Ž Task æ— å…³ï¼Œä¸åЍ |
Chat åŽ†å² **不要**改语义去存 Task;用新表或可空外键扩展。
### 10.2 Phase 2 æœ€å°æ–°è¡¨
```text
task_definition     id, app_id(可空), name, version, json_body, interaction é»˜è®¤ mode
task_instance       id, device_id, task_definition_id, session_id,
                    status, current_step, started_at, ended_at
step_instance       id, task_instance_id, step_index, mode, status,
                    audio_id, asr_text, result_json, started_at, ended_at
```
第一期 `json_body` å¯å†…嵌 steps,不必先拆 `task_step` å®šä¹‰è¡¨ã€‚
### 10.3 Phase 3+(V1.0 æ¸…单的子集,按需)
```text
app / app_version / app_subscription
plan / schedule
audio_segment          # ä»Ž chat_audio è¿å‡º
assessment / assessment_metric
user_metric_baseline
daily_summary
```
V1.0 çš„ emotion_signal、developer å®½è¡¨ **不要**列入 Phase 2 DDL。
Child å®žä½“:现网是 User+Device。Phase 3 è‹¥éœ€è¦ã€Œä¸€ä¸ªå®¶é•¿å¤šä¸ªå­©å­ã€å†æ‹† `child`;Phase 1–2 ç”¨ `user_id + device_id`。
---
## 11. Assessment
保留 V1.0 åŽŸåˆ™ï¼Œæ”¶ç´§è½åœ°ã€‚
### 11.1 ä¸‰å±‚(概念保留)
```text
L1 ä»»åС坹错 / æ–‡æœ¬åŒ¹é…     â† Phase 2 v0
L2 è¯­éŸ³ç‰¹å¾ï¼ˆè¯­é€Ÿã€åœé¡¿ï¼‰  â† å¼‚步,Phase 3
L3 è¶‹åŠ¿ / åŸºçº¿ / å‚与度    â† è¦æœ‰è¶³å¤Ÿ Instance
```
情绪只做 **信号** ä¸æ˜¯è¯Šæ–­ï¼ˆV1.0 Â§20 ä¿ç•™ï¼‰ï¼Œä¸” **晚于** L1。
### 11.2 è‹±è¯­è·Ÿè¯» v0(Phase 2)
```text
expected_text vs ASR text
→ å‡†ç¡®çއ / æ˜¯å¦è¯´å®Œ
→ å³æ—¶ TTS åé¦ˆï¼ˆèµ°çŽ°æœ‰ TTS)
→ ç»“果写入 step_instance.result_json
```
不在 Phase 2 ä¸Šå‘音模型、音素对齐、word timing(有则存 ASR åŽŸå§‹ JSON,评估器后补)。
### 11.3 å£ç®— / å€’背 / æˆè¯­
归 Phase 3+ Task Definition,用 QUIZ/GAME + ASR è§£æžã€‚**不要**为它们先改 Client。
### 11.4 è¯„估版本化(保留 V1.0)
```text
assessment.model_version
同一 step å¯è¿½åŠ æ–°è¯„ä¼°è¡Œï¼Œç¦æ­¢è¦†ç›–æ—§ç»“æžœ
```
实时路径只做 L1;L2+ èµ°é˜Ÿåˆ—,避免卡住 TTS。
---
## 12. åè®®ä¸Ž API
### 12.1 è®¾å¤‡åè®®
Phase 1:只增加 **MCP è°ƒç”¨**(已有 `type=mcp`)。Server ä¸»åЍ `tools/call` æ’­æ”¾ã€‚
Phase 2:与 Client Plan ç›¸åŒçš„ `task.*` / `mode.set` / å®Œæˆäº‹ä»¶ã€‚Python `TextMessageType` å¢žåŠ æžšä¸¾ï¼ŒHandler ä¸Ž `hello/listen/mcp` å¹¶åˆ—。
二进制 Opus **不改**。MQTT ç½‘å…³ 16 å­—节头逻辑保持。
### 12.2 HTTP API(manager-api)
Phase 1:可无新 API(插件 + ç®¡ç†ç«¯çŽ°æœ‰é…ç½®è¶³å¤Ÿè§¦å‘ Demo)。
Phase 2:
```text
POST /task/instance          å¯åŠ¨ï¼ˆdeviceId, taskId, params)
GET  /task/instance/{id}     æŸ¥è¯¢çŠ¶æ€ä¸Ž steps
POST /agent/chat-history/report  æ‰©å±•可选 task/step å­—段(兼容旧客户端)
```
Phase 3:
```text
/apps /resources /schedule /assessment /reports
```
V1.0 ä¸€æ¬¡é“ºå¼€ `/analytics` `/developer` â€”— åŽç½®ã€‚
上报仍走现有 `manage_api_client.report`,避免再造一条聊天管道。
---
## 13. Plugin / MCP å¤ç”¨
```text
Server Plugin     ç¼–排、业务判断、调 ASR ç»“æžœ
Device MCP        æ’­æ”¾ã€éŸ³é‡ã€å±ã€ä¸‹è½½ï¼ˆè®¾å¤‡ Skill)
Server MCP        å¤–部工具,与 Task æ— å¼ºç»‘定
```
英语复读 Phase 1:Server Plugin è°ƒ Device MCP。
不要把复读写进固件,也不要新建与 `UnifiedToolHandler` å¹³è¡Œçš„第二套工具总线。
---
## 14. smartlearning å¸æ”¶ç­–ç•¥
| å·²æœ‰èƒ½åŠ› | å½’属 |
|----------|------|
| è¯¾ç¨‹ JSON / manifest | Phase 3 App Resource;权威迁入 manager-api |
| MQTT DOWNLOAD / PLAY_MEDIA | Client æ¿çº§å·²å®žçŽ°ï¼›Server æœ€ç»ˆç» Xiaozhi è¿žæŽ¥ä¸‹å‘同等语义 |
| åœºæ™¯è¯†åˆ« HTTP | ä¸è¿›å…¥ Xiaozhi ä¸»é“¾è·¯ï¼Œé™¤éžäº§å“æ˜Žç¡®è¦ |
**禁止**让 Xiaozhi Server ä¸Ž smartlearning é•¿æœŸåŒå†™åŒä¸€è®¾å¤‡çŠ¶æ€ã€‚Phase 1 Demo è‹¥åªèƒ½è·‘ Sening,允许暂时打 snlp,但 Coding Plan å¿…须标明技术债。
---
## 15. åˆ†é˜¶æ®µï¼ˆServer)
与 Client Plan ç¼–号一致。
### Phase 1 â€” å¤è¯»ç¼–排,不破坏 Chat
- `ConnectionHandler.interaction_mode`:`CHAT` | `PLAY_AUDIO`
- PLAY_AUDIO æ—¶ï¼šä¸ `chat()`、不把课程当用户意图
- æ’件:循环 N æ¬¡ MCP/`PLAY_MEDIA`(或等价)
- **不建** Task è¡¨ã€ä¸è¯„估、不改 history è¯­ä¹‰ã€ä¸æ‹†å¾®æœåŠ¡
- Chat å›žå½’:mode å›ž CHAT æˆ–连接仍按现网 listen
验收:管理端或一句触发可让设备复读;同时唤醒聊天与现网一致。
### Phase 2 â€” Task Runtime åˆ‡ç‰‡ + è·Ÿè¯» v0
- `task_definition` / `task_instance` / `step_instance`
- åè®® `task.*` + `mode.set`(若 MCP ä¸å¤Ÿè¡¨è¾¾æ­¥è¿›ï¼‰
- FOLLOW_READ:ASR å†™å…¥ step;扩展 report å…³è” audio_id
- Assessment v0:文本比对 + ç»“æžœ JSON
- ä»ä¸è¦æ±‚对象存储、基线、情绪、家长小程序
验收:一次跟读 Instance å¯æŸ¥è¯¢åˆ°å„ step çš„音频与文本与分数。
### Phase 3 â€” å¹³å°ä¸Žè¯„估深化
- App / Resource Sync(吸收课程清单)
- Schedule / Plan(家长「每天 19:30」)
- éŸ³é¢‘迁对象存储
- å¼‚æ­¥ Voice Feature;可选 Baseline
- QUIZ ç­‰æ–° Task åªåŠ  Definition,不加固件业务
- å†å¾€åŽï¼šEmotion Signal、Subscription、Developer API(V1.0 åŽåŠæ®µï¼‰
---
## 16. ç¦æ­¢äº‹é¡¹ï¼ˆServer)
1. ä¸º Task é‡å†™ ASR/TTS Provider æˆ– WS å¸§æ ¼å¼
2. ç”¨ `play_music` å†’充课程复读
3. æŠŠ Task è¡Œå¡žè¿› `ai_agent_chat_history` å½“正式模型
4. Phase 1 ä¸Šæƒ…绪/心理/参与度大盘
5. Phase 1 æ‹†å¾®æœåŠ¡ / ä¸Šç‹¬ç«‹ MQ è¯„估集群
6. æ”¹ hello/listen/tts å­—段含义
7. è®©è®¾å¤‡æˆä¸º Step æƒå¨
8. æœªè¯» `ConnectionHandler` å°±æ–°å†™ä¸€å¥—对话服务器
9. ä¸Ž smartlearning æ°¸ä¹…双栈调度
---
## 17. AI Coding çº¦æŸ
1. å…ˆè¯» `connection.py`、`receiveAudioHandle.py`、`reportHandle.py`、Agent ä¸ŠæŠ¥ API、现有表结构。
2. é»˜è®¤ Chat å¿…须可关 Task ç‹¬ç«‹è¿è¡Œã€‚
3. å®žæ—¶è·¯å¾„只做 Mode åˆ†æµä¸Ž L1 è¯„估;重分析异步。
4. æ–°è¡¨æ–°åè®®éƒ½è¦å‘后兼容旧设备(忽略未知 type / å¯ç©º task å­—段)。
5. Phase 1 èƒ½ç”¨æ’件解决则不改 Java DDL。
---
## 18. ä¸Ž Client Plan çš„契约
Server ä¿è¯ï¼š
- CHAT è¿žæŽ¥è¡Œä¸ºå‘后兼容
- éž CHAT mode ä¸è¯¯è§¦å‘ LLM
- Instance æƒå¨çŠ¶æ€å¯æŸ¥è¯¢
- Phase 2 éŸ³é¢‘能挂到 step
Server è¦æ±‚ Client:
- æä¾›å¯è°ƒç”¨çš„ play_audio(至少一块目标板)
- FOLLOW_READ æ—¶ç»§ç»­ä¸Šä¼  Opus
- å®Œæˆäº‹ä»¶å¯å…³è” session / instance
- ä¸åœ¨å›ºä»¶å†™è¯„ä¼°
统一验收链(目标态):
```text
Server åˆ›å»º Instance â†’ mode + skill â†’ Device æ‰§è¡Œ â†’ Event/Audio â†’ ASR â†’ Assessment â†’ æŸ¥è¯¢ Instance
```
Phase 1 åªéªŒè¯å‰åŠï¼šåˆ›å»ºæ„å›¾ â†’ play å¾ªçޝ â†’ Chat ä»å¯ç”¨ã€‚
---
## 19. ä¸‹ä¸€é˜¶æ®µ
下一步不是实现评估平台,而是产出:
```text
Phase 1 Coding Plan
  â”œâ”€â”€ Server:mode æ—è·¯ + å¤è¯»æ’ä»¶
  â”œâ”€â”€ Client:复用 MCP/PLAY_MEDIA + éŸ³é¢‘互斥(已有 PauseSdCardMp3)
  â””── å›žå½’:现有 Chat E2E
```
V1.0 ä¸­çš„家长报告分级、跨任务分析、开发者分成等,保留为 Phase 3 ä»¥åŽçš„产品附录,不进入近期架构施工图。
СÖÇ AI ¿ª·ÅÓ¦ÓÃÆ½Ì¨-master Pln.md
New file
@@ -0,0 +1,2187 @@
# Xiaozhi AI å¼€æ”¾åº”用平台
# é¡¶å±‚架构与 AI Coding Master Plan
> Version: V1.0
> Date: 2026-08-30
> Purpose: ä½œä¸º Xiaozhi AI ç¡¬ä»¶ç«¯ã€Server ç«¯ã€å°ç¨‹åºç«¯ä»¥åŠæœªæ¥å¼€å‘者开放平台的最高层架构设计与 AI Coding æ€»ä½“指导文档。
---
# 1. é¡¹ç›®æœ€ç»ˆç›®æ ‡
将现有 Xiaozhi AI ä»Žï¼š
```text
AI å¯¹è¯ç¡¬ä»¶
```
逐步升级为:
```text
AI Application Terminal
```
最终形态类似:
```text
手机
 â””── å¯ä»¥å®‰è£…多个 App
       â””── æ¯ä¸ª App åŒ…含多个 Task
             â””── æ¯ä¸ª Task åŒ…含多个 Step
                   â””── Step è°ƒç”¨åº•层能力
```
但是 Xiaozhi ä¸Žæ‰‹æœºçš„æ ¸å¿ƒåŒºåˆ«æ˜¯ï¼š
```text
手机:
用户主动选择 App â†’ ä¸»åŠ¨æ“ä½œ
Xiaozhi:
家长配置 â†’ ç³»ç»Ÿè°ƒåº¦ â†’ Xiaozhi ä¸»åŠ¨æ‰§è¡Œ â†’ å„¿ç«¥å‚与
```
因此 Xiaozhi æœ€ç»ˆä¸æ˜¯å•纯的聊天机器人,而是:
> **一个可以承载多个 AI App,并根据家长配置主动执行 Task,同时持续记录、评估和分析儿童行为的 AI ç»ˆç«¯å¹³å°ã€‚**
---
# 2. æœ€ç»ˆäº§å“å½¢æ€
最终整个系统由:
```text
Xiaozhi Device
       â”‚
       â”‚
Xiaozhi Server
       â”‚
       â”œâ”€â”€ App Platform
       â”œâ”€â”€ Task Platform
       â”œâ”€â”€ Assessment Platform
       â”œâ”€â”€ User Data Platform
       â”œâ”€â”€ Developer Platform
       â”œâ”€â”€ Subscription Platform
       â””── Parent Platform
```
组成。
用户关系:
```text
平台
 â”‚
 â”œâ”€â”€ App A
 â”‚    â”œâ”€â”€ Task 1
 â”‚    â”œâ”€â”€ Task 2
 â”‚    â””── Task 3
 â”‚
 â”œâ”€â”€ App B
 â”‚    â”œâ”€â”€ Task 1
 â”‚    â””── Task 2
 â”‚
 â””── App C
      â””── ...
```
一个孩子可以订阅多个 App。
但是:
```text
同一时间
只能运行一个 App / Task Runtime
```
因此不要求 Xiaozhi ç¡¬ä»¶åŒæ—¶è¿è¡Œå¤šä¸ªå¤æ‚应用。
---
# 3. æ ¸å¿ƒæž¶æž„原则
整个系统必须坚持以下原则。
## 3.1 App æ˜¯ä¸šåŠ¡èƒ½åŠ›çš„å®¹å™¨
App ä¸åº”该等同于硬件功能。
例如:
```text
英语学习 App
回忆录 App
益智游戏 App
AI ä¸»æŒäºº App
```
都是逻辑应用。
---
## 3.2 Task æ˜¯ç³»ç»ŸçœŸæ­£æ‰§è¡Œçš„基本业务单位
系统最终执行的是:
```text
Task
```
而不是:
```text
Scenario
```
例如:
```text
英语 App
 â”œâ”€â”€ è‹±è¯­è·Ÿè¯» 10 æ¬¡
 â”œâ”€â”€ å•词复习
 â””── è‹±è¯­å¬åŠ›
```
其中:
```text
英语跟读 10 æ¬¡
```
就是一个 Task。
---
# 4. Scenario / Interaction Mode çš„æœ€ç»ˆå®šä¹‰
本项目中不再把 Scenario ç†è§£ä¸ºï¼š
```text
业务场景
```
而定义为:
> **Device Interaction Mode**
即:
> Xiaozhi åœ¨æ‰§è¡Œ Task æ—¶ï¼Œåº”该以什么方式与用户交互。
例如:
```text
CHAT
PLAY_AUDIO
REPEAT
FOLLOW_READ
QUESTION_ANSWER
TRANSLATION
CALCULATION
MEMORY
GAME
SILENT
```
因此:
```text
Task
 â†“
指定 Interaction Mode
 â†“
Device Runtime
 â†“
以对应方式执行
```
例如:
```text
Task:
听英语复读机 10 é
interactionMode:
PLAY_AUDIO
steps:
播放 â†’ ç­‰å¾… â†’ æ’­æ”¾ â†’ ç­‰å¾… ...
```
不需要额外建立:
```text
Scenario
 â†“
Task
```
这样的业务层级。
---
# 5. Task æ˜¯æ ¸å¿ƒæ‰§è¡ŒæŠ½è±¡
统一模型:
```text
App
 â””── Task
      â””── Step
           â””── Action / Skill
```
例如:
```text
English App
 â””── English Repeat 10 Times
      â”œâ”€â”€ Play Audio
      â”œâ”€â”€ Wait
      â”œâ”€â”€ Record
      â”œâ”€â”€ Evaluate
      â””── Repeat
```
---
# 6. Step æ˜¯çœŸæ­£çš„æ‰§è¡Œå•å…ƒ
Task è´Ÿè´£ï¼š
```text
做什么
```
Step è´Ÿè´£ï¼š
```text
具体怎么一步一步做
```
例如:
```text
Task:
英语跟读
Step 1:
播放标准音频
Step 2:
等待用户说话
Step 3:
录音
Step 4:
ASR
Step 5:
比较
Step 6:
评分
Step 7:
反馈
Step 8:
进入下一句
```
---
# 7. Skill / Atomic Capability
Step ä¸ç›´æŽ¥æ“ä½œç¡¬ä»¶ã€‚
Step è°ƒç”¨ï¼š
```text
Skill
```
例如:
```text
play_audio
record_audio
listen
speak
wait
count
asr
evaluate
display
led
button
```
最终:
```text
Task
 â†“
Step
 â†“
Skill
 â†“
Device Capability
```
---
# 8. Device ä¸Ž Server çš„职责边界
## Device
负责:
```text
执行
播放
录音
采集
缓存
通信
基础状态
```
---
## Server
负责:
```text
调度
Task
App
用户
数据
ASR
AI
评估
分析
长期趋势
```
---
# 9. App ä¸åº”该成为硬件固件
未来新增 App:
```text
下载配置
下载资源
```
而不是:
```text
修改 C++ å›ºä»¶
重新 OTA
```
理想情况下:
```text
新增 App
 â†“
App Manifest
Task Definition
Step Definition
Resource
Assessment Definition
 â†“
Server
 â†“
Device Sync
```
即可运行。
---
# 10. App Manifest
每一个 App éƒ½æœ‰ï¼š
```text
app.json
```
定义:
```text
appId
name
version
developerId
description
icon
tasks
resources
permissions
assessment
```
---
# 11. Task Definition
Task å®šä¹‰ï¼š
```text
taskId
name
description
version
steps
parameters
interactionMode
assessment
resources
```
例如:
```json
{
  "taskId": "english_repeat_10",
  "interactionMode": "PLAY_AUDIO",
  "parameters": {
    "repeatCount": 10
  }
}
```
---
# 12. Resource
App å¯ä»¥æºå¸¦ï¼š
```text
MP3
WAV
图片
JSON
文本
配置
模型参数
```
这些属于:
```text
Application Resource
```
而不是固件。
---
# 13. OTA ä¸Ž App æ›´æ–°å¿…须彻底区分
## OTA
用于:
```text
Device Firmware
```
例如:
```text
底层驱动
通信协议
音频驱动
系统 Runtime
```
---
## App Sync
用于:
```text
App
Task
Config
Resource
Audio
```
例如:
```text
新增英语 App
```
只需要:
```text
Sync App
```
无需 OTA。
---
# 14. SD Card çš„定位
16GB SD Card ä¸»è¦ç”¨äºŽï¼š
```text
App Resources
Audio Resources
Cache
Offline Tasks
Pending Upload Audio
Temporary Files
```
例如:
```text
/sdcard/
    apps/
    resources/
    audio/
    cache/
    recordings/
    pending/
```
---
# 15. Server Task Runtime
Server å¿…须建立统一:
```text
Task Runtime
```
负责:
```text
创建 TaskInstance
启动 Task
推进 Step
等待 Device
处理 Event
判断 Step å®Œæˆ
进入下一 Step
结束 Task
```
---
# 16. Task Instance
不要直接修改 Task Definition。
每一次实际执行:
```text
Task
 â†“
TaskInstance
```
例如:
```text
Task:
英语跟读 10 æ¬¡
TaskInstance:
2026-08-30 19:30
```
保存:
```text
startTime
endTime
status
currentStep
result
```
---
# 17. Scheduler
家长通过小程序配置:
```text
每天 19:30
英语学习
```
Server:
```text
Schedule
 â†“
Scheduler
 â†“
TaskInstance
 â†“
Device
```
因此:
> å®¶é•¿é…ç½®çš„æ˜¯â€œä»€ä¹ˆæ—¶å€™åšä»€ä¹ˆ Task”,不是直接控制硬件。
---
# 18. Plan
进一步允许:
```text
Plan
```
例如:
```text
每日学习计划
```
包含:
```text
19:30 è‹±è¯­è·Ÿè¯»
19:45 å£ç®—
19:55 è®°å¿†æ¸¸æˆ
```
最终:
```text
Plan
 â†“
Schedule
 â†“
TaskInstance
```
---
# 19. Device Runtime
Device åªéœ€è¦ç†è§£æ ‡å‡†åè®®ï¼š
```text
APP_SYNC
TASK_START
STEP_START
SKILL_EXECUTE
TASK_PAUSE
TASK_RESUME
TASK_STOP
```
以及向 Server ä¸ŠæŠ¥ï¼š
```text
TASK_STARTED
STEP_STARTED
STEP_COMPLETED
AUDIO_PLAYED
SPEECH_STARTED
SPEECH_STOPPED
TASK_COMPLETED
```
---
# 20. Interaction Runtime
Device Runtime å†…部增加:
```text
Interaction Runtime
```
根据:
```text
interactionMode
```
选择交互方式。
例如:
```text
PLAY_AUDIO
 â†’ Audio Player
FOLLOW_READ
 â†’ Audio Player
 â†’ Recorder
 â†’ VAD
CHAT
 â†’ ASR
 â†’ LLM
 â†’ TTS
TRANSLATION
 â†’ ASR
 â†’ Translation
 â†’ TTS
```
---
# 21. Chat ä¸å†æ˜¯å”¯ä¸€ Runtime
现有 Xiaozhi å¾ˆå¯èƒ½ï¼š
```text
Audio
 â†“
ASR
 â†“
LLM
 â†“
TTS
```
这种架构。
必须升级成:
```text
Task Runtime
      â†“
Interaction Runtime
      â†“
┌────────┬────────┬────────┬────────┐
Chat    Audio    Repeat   Game    ...
```
Chat åªæ˜¯å…¶ä¸­ä¸€ç§èƒ½åŠ›ã€‚
---
# 22. Server Audio Architecture
Server ä¿å­˜ï¼š
```text
Original Audio
```
而不是只保存:
```text
ASR Text
```
结构:
```text
AudioSession
 â””── AudioSegment
       â”œâ”€â”€ Original Audio
       â”œâ”€â”€ ASR
       â”œâ”€â”€ Voice Feature
       â””── Assessment
```
---
# 23. ASR
统一 ASR Service:
```text
Audio
 â†“
ASR
 â†“
Transcript
```
保存:
```text
text
confidence
word timing
word confidence
```
---
# 24. Voice Analysis
统一提取:
```text
speechDuration
pauseCount
pauseDuration
speechRate
responseLatency
volume
pitch
energy
prosody
```
---
# 25. Task Assessment
不同 Task ä½¿ç”¨ä¸åŒè¯„估策略。
例如:
```text
英语跟读
 â†’ pronunciation
 â†’ fluency
 â†’ accuracy
数字倒背
 â†’ sequenceAccuracy
 â†’ memoryAccuracy
 â†’ responseTime
口算
 â†’ accuracy
 â†’ responseTime
 â†’ difficulty
成语接龙
 â†’ correctness
 â†’ responseTime
 â†’ continuity
```
---
# 26. Metric System
所有结果最终统一为:
```text
Metric
```
例如:
```text
accuracy
response_time
pause_count
speech_rate
pronunciation
fluency
completion
participation
engagement
```
新 App å¯ä»¥æ³¨å†Œæ–°çš„ Metric。
---
# 27. Behavioral Analysis
Server å»ºç«‹ï¼š
```text
User Baseline
```
通过:
```text
7 Days
30 Days
90 Days
```
形成动态基线。
然后比较:
```text
Today
vs
Baseline
```
---
# 28. Emotion Analysis
系统可以提供:
```text
Emotion Signal
```
但必须定义为:
> **基于语音、语言和行为数据的情绪表现信号。**
不能直接定义为:
```text
心理疾病诊断
```
输出:
```text
state
confidence
trend
```
---
# 29. Engagement
建立:
```text
Engagement Score
```
综合:
```text
响应
完成
主动性
持续性
互动
```
形成:
```text
0 ~ 100
```
---
# 30. Parent Dashboard
家长最终看到:
```text
今日任务
完成情况
Task Score
能力指标
参与度
趋势
```
而不是复杂技术指标。
---
# 31. Long-term Profile
系统持续形成:
```text
User Profile
```
包括:
```text
Learning
Memory
Speech
Task Performance
Participation
Behavior Trend
Preference
```
所有 Profile éƒ½åº”该来自:
```text
Event
 â†“
Metric
 â†“
Aggregation
```
而不是手工维护。
---
# 32. Developer Platform
最终开放:
```text
Developer
 â†“
App
 â†“
Task
 â†“
Step
 â†“
Skill
 â†“
Assessment
```
开发者不应该直接修改 Device Firmware。
---
# 33. Developer Sandbox
提供:
```text
App SDK
Task SDK
Assessment SDK
Resource SDK
API
Simulator
```
开发者可以在 PC ä¸Šæ¨¡æ‹Ÿï¼š
```text
Xiaozhi Device
```
测试 Task。
---
# 34. App Store
最终平台形成:
```text
App Store
```
用户可以:
```text
浏览 App
查看介绍
试用
订阅
安装
评价
```
例如:
```text
英语学习
数学训练
记忆训练
成语游戏
AI ä¸»æŒäºº
回忆录
故事
```
---
# 35. Subscription
订阅关系:
```text
User
 â†“
Subscription
 â†“
App
```
而不是:
```text
User
 â†“
Firmware
```
---
# 36. Developer Revenue
平台负责:
```text
订单
支付
订阅
退款
结算
分成
```
开发者获得:
```text
Revenue Share
```
---
# 37. Permission Architecture
必须建立:
```text
User
Child
Parent
Developer
App
```
之间的权限体系。
尤其是儿童数据。
开发者:
```text
默认不能读取其他 App æ•°æ®
```
家长:
```text
可以查看孩子数据
```
平台:
```text
负责权限控制
```
---
# 38. Privacy
儿童语音必须重点保护。
建立:
```text
Consent
Encryption
Access Control
Audit Log
Retention Policy
Data Deletion
```
---
# 39. æ•°æ®æ ¸å¿ƒé“¾è·¯
最终统一成:
```text
App
 â†“
Task
 â†“
TaskInstance
 â†“
StepInstance
 â†“
Device Event
 â†“
Audio
 â†“
ASR
 â†“
Voice Feature
 â†“
Assessment
 â†“
Metric
 â†“
Aggregation
 â†“
Profile
 â†“
Report
```
这是整个系统最重要的数据链路。
---
# 40. Event-Driven Architecture
核心状态尽量由 Event é©±åŠ¨ã€‚
例如:
```text
TaskStarted
StepStarted
AudioPlayed
SpeechStarted
SpeechStopped
ASRCompleted
AssessmentCompleted
StepCompleted
TaskCompleted
```
Event æˆä¸ºç³»ç»Ÿçš„重要事实来源。
---
# 41. å¼‚步分析
实时链路:
```text
Device
 â†“
Server
 â†“
Task Runtime
 â†“
实时反馈
```
异步链路:
```text
Audio
 â†“
Queue
 â†“
ASR
 â†“
Voice Analysis
 â†“
Assessment
 â†“
Aggregation
 â†“
Report
```
复杂 AI åˆ†æžä¸èƒ½é˜»å¡ž Task Runtime。
---
# 42. ç¦»çº¿èƒ½åŠ›
Device ç½‘络断开时:
```text
Task
 â†“
Local Cache
 â†“
执行
 â†“
Recording
 â†“
SD Card
 â†“
网络恢复
 â†“
Sync
```
因此:
> Server æ˜¯ä¸­å¿ƒæŽ§åˆ¶å™¨ï¼Œä½† Device å¿…须具备一定程度的 Offline Runtime。
---
# 43. ç‰ˆæœ¬ç®¡ç†
所有这些对象必须版本化:
```text
Firmware
App
Task
Step
Assessment
Model
Resource
```
例如:
```text
App v1.2
Task v3
Assessment v2
Model v5
```
历史数据必须能够追溯到具体版本。
---
# 44. ä¸è¦†ç›–历史结果
例如:
```text
Pronunciation Model v1
Score = 82
```
以后:
```text
Pronunciation Model v2
Score = 87
```
两个结果都保留。
---
# 45. æŽ¨èçš„æ•°æ®åº“关系
核心:
```text
User
 â””── Child
Child
 â”œâ”€â”€ Subscription
 â”œâ”€â”€ Plan
 â”œâ”€â”€ Schedule
 â””── TaskInstance
App
 â””── Task
      â””── TaskStep
TaskInstance
 â””── TaskStepInstance
       â”œâ”€â”€ DeviceEvent
       â”œâ”€â”€ AudioSegment
       â”‚    â”œâ”€â”€ ASR
       â”‚    â””── VoiceFeature
       â””── Assessment
             â””── Metric
```
---
# 46. ç¬¬ä¸€é˜¶æ®µå¼€å‘目标
不要一开始开发完整平台。
首先建立最小闭环:
```text
家长
 â†“
配置 Task
 â†“
Server Scheduler
 â†“
TaskInstance
 â†“
Xiaozhi
 â†“
执行
 â†“
录音
 â†“
Server
 â†“
ASR
 â†“
Assessment
 â†“
家长查看结果
```
必须完整跑通。
---
# 47. MVP Task
第一阶段只实现三个 Task:
## Task A:英语复读
```text
播放
 â†“
等待
 â†“
播放
 â†“
...
```
---
## Task B:英语跟读
```text
播放标准音频
 â†“
录音
 â†“
ASR
 â†“
准确率
```
---
## Task C:口算
```text
播放问题
 â†“
等待回答
 â†“
ASR
 â†“
答案解析
 â†“
正确率
```
---
# 48. ç¬¬äºŒé˜¶æ®µ
增加:
```text
数字倒背
成语接龙
简单记忆游戏
```
并统一:
```text
Task Engine
Step Engine
Assessment Engine
```
---
# 49. ç¬¬ä¸‰é˜¶æ®µ
加入:
```text
Voice Feature
```
实现:
```text
Pause
Speech Rate
Response Latency
Volume
Pitch
```
---
# 50. ç¬¬å››é˜¶æ®µ
加入:
```text
Baseline
Behavior
Engagement
Participation
```
实现:
```text
Today
7 Days
30 Days
```
趋势。
---
# 51. ç¬¬äº”阶段
建立:
```text
Emotion Signal
```
进行实验性分析。
要求:
```text
模型版本化
置信度
趋势
人工验证
```
不允许直接做医疗诊断。
---
# 52. ç¬¬å…­é˜¶æ®µ
开放:
```text
App Manifest
Task DSL
Assessment SDK
Developer API
```
实现第三方 App。
---
# 53. ç¬¬ä¸ƒé˜¶æ®µ
建立:
```text
App Store
Subscription
Payment
Revenue Share
Developer Console
```
---
# 54. ç¬¬å…«é˜¶æ®µ
建立:
```text
Parent AI Assistant
```
家长可以询问:
```text
孩子最近学习怎么样?
哪个 App æœ€å–œæ¬¢ï¼Ÿ
英语最近有没有进步?
最近参与度有没有下降?
过去一个月有什么明显变化?
```
AI ä»Žï¼š
```text
Metrics
Assessment
Trend
Profile
```
中生成解释。
---
# 55. AI Coding æ€»ä½“实施顺序
AI Coding ä¸å…è®¸ä¸€æ¬¡æ€§é‡å†™æ•´ä¸ªé¡¹ç›®ã€‚
必须:
```text
Phase 0
代码审计
Phase 1
建立核心 Domain Model
Phase 2
Task Runtime
Phase 3
Device Runtime
Phase 4
App / Resource Sync
Phase 5
Audio / ASR
Phase 6
Assessment
Phase 7
Parent / Schedule
Phase 8
Behavior / Metrics
Phase 9
Developer Platform
Phase 10
Subscription / Marketplace
```
每个 Phase:
```text
分析
 â†“
设计
 â†“
实现
 â†“
测试
 â†“
回归
 â†“
提交
```
---
# 56. AI Coding ç¬¬ä¸€åŽŸåˆ™
AI ç¼–程 Agent å¿…须:
> **优先理解现有代码,再修改代码。**
禁止:
```text
没有阅读代码
 â†“
直接重构
```
必须:
```text
Repository Audit
 â†“
Architecture Mapping
 â†“
Dependency Analysis
 â†“
Minimal Change
```
---
# 57. ä¸å…è®¸æ— ç†ç”±é‡å†™çŽ°æœ‰åŠŸèƒ½
现有 Xiaozhi çš„:
```text
Audio
ASR
TTS
LLM
MQTT / WebSocket
Device Communication
```
能够复用的必须复用。
新架构应该:
```text
包裹现有能力
```
而不是:
```text
全部推倒重来
```
---
# 58. æ–°æ—§ç³»ç»Ÿè¿ç§»ç­–ç•¥
旧系统:
```text
Conversation
```
保留。
增加:
```text
Task Runtime
```
最终:
```text
Task Runtime
 â”œâ”€â”€ Chat Task
 â”œâ”€â”€ Repeat Task
 â”œâ”€â”€ Follow Read Task
 â”œâ”€â”€ Translation Task
 â””── Game Task
```
因此:
> Chat æœ€ç»ˆåªæ˜¯ Task Runtime çš„一种 Task,而不是整个系统的核心。
---
# 59. API è®¾è®¡åŽŸåˆ™
所有 API å¿…须围绕:
```text
App
Task
TaskInstance
StepInstance
Event
Audio
Assessment
Metric
```
设计。
避免大量:
```text
/english/xxx
/math/xxx
/game/xxx
```
这种 App-specific API。
---
# 60. Task DSL åŽŸåˆ™
未来开发者应该能够通过:
```text
JSON / YAML / SDK
```
定义 Task。
例如:
```text
Task
 â”œâ”€â”€ parameters
 â”œâ”€â”€ steps
 â”‚    â”œâ”€â”€ action
 â”‚    â”œâ”€â”€ condition
 â”‚    â”œâ”€â”€ wait
 â”‚    â”œâ”€â”€ loop
 â”‚    â””── branch
 â””── assessment
```
这样:
> æ–°å¢ž Task å°½é‡ä¸éœ€è¦ä¿®æ”¹æ ¸å¿ƒ Runtime。
---
# 61. Runtime çš„稳定边界
必须建立稳定的:
```text
Task Runtime API
Skill API
Device API
Assessment API
```
未来 App è¶Šå¤šï¼š
```text
Runtime ä¸å˜
App å¢žåŠ 
Task å¢žåŠ 
```
这是开放平台成立的关键。
---
# 62. æœ€ç»ˆæž¶æž„图
```text
                         Xiaozhi Platform
                                â”‚
             â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¼â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
             â”‚                  â”‚                  â”‚
          Parent             Developer           Device
             â”‚                  â”‚                  â”‚
             â†“                  â†“                  â†“
           Plan               App Store       Device Runtime
             â”‚                  â”‚                  â”‚
         Schedule              App              Interaction
             â”‚                  â”‚                  â”‚
             â””──────────────→ Task â†â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜
                               â”‚
                              Step
                               â”‚
                              Skill
                               â”‚
                         Device Capability
                               â”‚
                               â†“
                         Audio / Event
                               â”‚
                               â†“
                        Server Pipeline
                               â”‚
            â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¼â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
            â†“                  â†“                 â†“
           ASR          Voice Analysis      Assessment
            â”‚                  â”‚                 â”‚
            â””──────────────────┼─────────────────┘
                               â†“
                             Metric
                               â†“
                           Aggregation
                               â†“
                         User Profile
                               â†“
                          Parent Report
```
---
# 63. æœ€ç»ˆå¹³å°çš„æ ¸å¿ƒæŠ½è±¡
整个项目最终只需要牢牢抓住以下 8 ä¸ªæ ¸å¿ƒæ¦‚念:
```text
App
Task
Step
Skill
Interaction Mode
Event
Assessment
Metric
```
它们之间:
```text
App
 â†“
Task
 â†“
Step
 â†“
Skill
 â†“
Device
```
执行过程中产生:
```text
Event
 â†“
Audio
 â†“
Assessment
 â†“
Metric
```
---
# 64. æœ€ç»ˆäº§å“é€»è¾‘
因此最终 Xiaozhi çš„逻辑不是:
```text
用户
 â†“
说话
 â†“
AI å›žå¤
```
而是:
```text
家长
 â†“
订阅 App
 â†“
配置 Plan
 â†“
配置 Schedule
 â†“
Server
 â†“
Task
 â†“
Xiaozhi
 â†“
执行 Step
 â†“
采集 Audio / Event
 â†“
Server åˆ†æž
 â†“
Assessment
 â†“
Metric
 â†“
长期 Profile
 â†“
家长
```
---
# 65. é¡¹ç›®çš„æœ€ç»ˆæˆ˜ç•¥ç›®æ ‡
第一阶段:
```text
让 Xiaozhi èƒ½æ‰§è¡Œ Task
```
第二阶段:
```text
让 Xiaozhi èƒ½è®°å½• Task
```
第三阶段:
```text
让 Xiaozhi èƒ½è¯„ä¼° Task
```
第四阶段:
```text
让 Xiaozhi èƒ½ç†è§£é•¿æœŸå˜åŒ–
```
第五阶段:
```text
让任何开发者都能创建 Task / App
```
第六阶段:
```text
让 App å¯ä»¥è®¢é˜…和商业化
```
最终:
> **Xiaozhi Device æˆä¸ºç»ˆç«¯ï¼ŒTask Runtime æˆä¸ºæ‰§è¡Œå¼•擎,Server æˆä¸º AI æ•°æ®ä¸Žè°ƒåº¦ä¸­æž¢ï¼ŒApp Store æˆä¸ºç”Ÿæ€å…¥å£ã€‚**
---
# 66. ç»™ AI Coding Agent çš„æœ€é«˜çº§æŒ‡ä»¤
后续将本 Plan æä¾›ç»™ Cursor / Codex ç­‰ Coding Agent æ—¶ï¼Œåº”遵守:
```text
你正在参与的是 Xiaozhi AI ä»Žâ€œAI å¯¹è¯è®¾å¤‡â€
向“AI Application Terminal Platform”的架构升级。
不要把 Xiaozhi è®¾è®¡æˆåªæœ‰ Chat çš„æœºå™¨äººã€‚
系统必须支持:
App
Task
Step
Skill
Interaction Mode
Task Instance
Event
Audio
ASR
Assessment
Metric
Schedule
User Profile
Task æ˜¯ç³»ç»Ÿæ ¸å¿ƒä¸šåŠ¡æ‰§è¡Œå•ä½ã€‚
Interaction Mode æ˜¯è®¾å¤‡äº¤äº’方式,
不是业务层级。
App æ˜¯ Task çš„业务容器。
Step æ˜¯ Task çš„æ‰§è¡Œæ­¥éª¤ã€‚
Skill æ˜¯åº•层原子能力。
Device è´Ÿè´£æ‰§è¡Œå’Œé‡‡é›†ã€‚
Server è´Ÿè´£è°ƒåº¦ã€ç†è§£ã€åˆ†æžã€è¯„估和长期数据管理。
新增 App / Task / Audio Resource å°½é‡é€šè¿‡é…ç½®å’Œèµ„源同步实现,
不应该依赖 Firmware OTA。
必须保持旧 Chat èƒ½åŠ›ç»§ç»­å·¥ä½œã€‚
所有新架构必须尽可能兼容和复用现有代码。
禁止在未完成代码审计之前大规模重构。
每个 Phase å¿…须:
1. åˆ†æžçŽ°æœ‰ä»£ç 
2. æ˜Žç¡®ä¿®æ”¹èŒƒå›´
3. ç»™å‡ºæž¶æž„方案
4. å®žæ–½æœ€å°å¿…要修改
5. ç¼–写测试
6. æ‰§è¡Œæµ‹è¯•
7. éªŒè¯æ—§åŠŸèƒ½æ²¡æœ‰å›žå½’
8. è¾“出修改总结
不要一次性实现整个系统。
必须按照 Phase é€æ­¥æ¼”进。
优先建立稳定的 Domain Model å’Œ Runtime Boundary。
未来第三方开发者应该能够在不修改 Xiaozhi æ ¸å¿ƒ Runtime çš„æƒ…况下创建新的 App å’Œ Task。
最终目标是:
“核心 Runtime ç¨³å®šï¼ŒApp / Task / Resource / Assessment å¯æ— é™æ‰©å±•。”
```
---
# 67. Master Plan çš„æ‰§è¡Œæ–¹å¼
以后所有 Coding Plan éƒ½åº”该从本 Master Plan æ´¾ç”Ÿã€‚
结构:
```text
Master Plan
     â”‚
     â”œâ”€â”€ Device Plan
     â”‚
     â”œâ”€â”€ Server Plan
     â”‚
     â”œâ”€â”€ Mini Program Plan
     â”‚
     â”œâ”€â”€ Developer Platform Plan
     â”‚
     â”œâ”€â”€ Assessment Plan
     â”‚
     â””── Marketplace Plan
```
然后再进一步:
```text
Architecture Plan
       â†“
Module Plan
       â†“
Implementation Plan
       â†“
Coding Task
       â†“
Code
       â†“
Test
```
---
# 68. æœ€ç»ˆåˆ¤æ–­æ ‡å‡†
以后任何一个新需求,都先问:
```text
这是 App?
是 Task?
是 Step?
是 Skill?
是 Interaction Mode?
是 Event?
是 Assessment?
还是 Metric?
```
如果一个新需求无法归入这些核心抽象:
> ä¸è¦ç«‹å³å†™ä»£ç ã€‚
先重新审视架构。
这套原则将决定 Xiaozhi æœ€ç»ˆèƒ½ä¸èƒ½ä»Žä¸€ä¸ªäº§å“ï¼ŒçœŸæ­£æ¼”化成一个开放平台。
СÖÇ AI Ó²¼þ¿ª·ÅÓ¦ÓÃÆ½Ì¨-×ÜÌå¼Ü¹¹Ó뿪·¢ Plan.md
New file
@@ -0,0 +1,541 @@
# å°æ™º AI Client Architecture Plan
> æ–‡æ¡£ï¼šå°æ™º AI ç¡¬ä»¶å¼€æ”¾åº”用平台 â€”— æ€»ä½“架构与开发 Plan
> Version: V1.1
> Date: 2026-08-30
> æœ¬æ–‡åœ¨ V1.0 æ¦‚念稿上,依据《Xiaozhi AI å¼€æ”¾åº”用平台 Master Plan》与《Architecture Audit》修订。
> **只设计,不编码。**
---
## 0. æœ¬æ–‡å®šä½
本文只定义 **Device / Client**(仓库 `xiaozhi-esp32-2.2.6`)如何从「语音对话固件」演进为「可执行多种 InteractionMode çš„ Device Runtime」。
不在本文展开、由 Server Plan è´Ÿè´£çš„内容:
- App / Task Definition çš„æƒå¨å­˜å‚¨
- TaskInstance / StepInstance æŒä¹…化
- Scheduler / Plan / Subscription
- ASR / LLM / Assessment / å®¶é•¿æŠ¥å‘Š
- ç®¡ç†åŽå°ä¸Žå¼€å‘者平台
**相对 V1.0 çš„关键修正:**
| V1.0 | V1.1(本次) |
|------|----------------|
| è®¾å¤‡ä¸Šæœ‰å®Œæ•´ Task Runtime | æƒå¨ Task Runtime åœ¨ Server;设备只有 **Task Executor** |
| Phase 1 åšå¤è¯» + è·Ÿè¯» + å£ç®— + æˆè¯­æŽ¥é¾™ | Phase 1 åªåš `CHAT` + `PLAY_AUDIO`(英语复读),不破坏现有 Chat |
| æ–°é€ ç»Ÿä¸€ Command Protocol | **扩展现有 JSON `type` + MCP**,不新开总线 |
| Skill åˆ—表含 `math.calculate` / `llm.evaluate` | Skill å¿…须映射到**已有代码**;没有的标为新建 |
| è®¾å¤‡ç»´æŠ¤ App Manager å…¨å¥—生命周期 | Phase 1–2 ä¸åœ¨å›ºä»¶å®žçް App å•†åº—;资源同步可复用板级下载 |
---
## 1. å½“前 Client æ˜¯ä»€ä¹ˆï¼ˆAudit äº‹å®žï¼‰
当前固件**不是** App å¹³å°ï¼Œè€Œæ˜¯ï¼š
```text
Application å•例
  + DeviceState çŠ¶æ€æœº
  + Opus å®žæ—¶åŒå·¥
  + MCP å·¥å…·é¢
```
| é¡¹ | çœŸå®žä½ç½® |
|----|----------|
| å…¥å£ | `main/main.cc` â†’ `Application::Initialize` â†’ `Application::Run` |
| ä¸»å¾ªçޝ | `application.cc`:FreeRTOS EventGroup,永不返回 |
| å¯¹è¯æ€ | `device_state.h`:Idle / Connecting / Listening / Speaking â€¦ |
| éŸ³é¢‘管道 | `audio/audio_service.{h,cc}`:Mic→Opus→Server;Server→Opus→Speaker |
| åè®® | `protocols/protocol.{h,cc}` + `websocket_protocol` / `mqtt_protocol` |
| Skill é¢ | `mcp_server.cc` + å„ board `InitializeTools()` |
| èµ„源 OTA | `ota.{h,cc}`(固件/配置)、`assets.{h,cc}`(语音模型等) |
| æ¿çº§æ—è·¯ | Sening:`self.mp3.*`、`BoardMqtt`(`snlp/v1` DOWNLOAD / PLAY_MEDIA) |
完整对话链:
```text
Mic â†’ AudioService â†’ Opus 16k/60ms â†’ Protocol.SendAudio
     â†’ Server ASR/LLM/TTS
     â†’ type=tts/stt + binary Opus â†’ AudioService decode â†’ Speaker
```
**没有:** InteractionMode æžšä¸¾ã€Task/Step、可归档录音文件、核心 `play_audio`(仅 Sening æ¿çº§ï¼‰ã€ç»Ÿä¸€ Event Bus。
`ListeningMode`(auto/manual/realtime)只是拾音策略,**不是** Master Plan çš„ InteractionMode。
---
## 2. ç›®æ ‡ Device Runtime
目标不是把手机 OS æ¬åˆ° ESP32,而是:
```text
Device Runtime          â† çŽ°æœ‰ Application äº‹ä»¶çŽ¯å‡çº§ï¼Œä¸æ˜¯æŽ¨ç¿»
  â”œâ”€â”€ Chat Runtime      â† çŽ°æœ‰ Listening â†” Speaking,降为 Mode=CHAT
  â””── Task Executor     â† è–„执行器:收指令、跑 Skill、回 Event
        â”œâ”€â”€ InteractionMode åˆ‡æ¢
        â””── Skill â†’ Device Capability
```
同一时间只运行一个 InteractionMode(Chat ä¸Ž Task éŸ³é¢‘互斥)。
设备**不理解**业务:
```text
禁止固件出现:if (app == english) / if (task == idiom)
```
设备只理解:
```text
mode + skill + resource_ref + å®Œæˆæ¡ä»¶ï¼ˆè¶…æ—¶ / æ’­æ”¾ç»“束 / VAD é™éŸ³ï¼‰
```
---
## 3. èŒè´£è¾¹ç•Œ
```text
Server(权威)
  åˆ›å»º TaskInstance
  æŽ¨è¿› Step
  æŒ‡å®š InteractionMode
  ASR / LLM / Assessment
  æŒä¹…化状态与音频对象
Client(执行)
  ä¿æŒ Chat çƒ­è·¯å¾„
  æŒ‰ mode ä½¿ç”¨éº¦/扬声器/屏
  æ‰§è¡Œ Skill(播、听、显示、下载)
  ä¸ŠæŠ¥ Event(开始/结束/失败)
  ç¼“存当前 step(断线 UI),不以本地为权威
```
对应 Master Plan:Device è´Ÿè´£æ‰§è¡Œã€æ’­æ”¾ã€é‡‡é›†ã€ç¼“存、通信;Server è´Ÿè´£è°ƒåº¦ä¸Žè¯„估。
---
## 4. åˆ†å±‚(保留 V1.0,落到现有代码)
```text
业务层     App / Task          â† ä¸ä¸‹å‘到固件业务代码;只下发 JSON + èµ„源
交互层     Interaction Mode    â† æ–°å¢žï¼›æŒ‚在 Application,与 DeviceState æ­£äº¤
能力层     Skill               â† çŽ°æœ‰ MCP tools å‡çº§ä¸ºæ­£å¼ Skill ç›®å½•
设备层     Device Capability   â† AudioCodec / Display / Button / SD / LED
```
**DeviceState ä¸Ž InteractionMode å¿…须分开:**
| | DeviceState(已有) | InteractionMode(新增) |
|--|---------------------|-------------------------|
| å›žç­” | è®¾å¤‡çŽ°åœ¨èƒ½ä¸èƒ½å¬/说/升级 | è¿™ä¸€æ­¥ç”¨ä»€ä¹ˆäº¤äº’方式 |
| ä¾‹å­ | Idle, Listening, Speaking | CHAT, PLAY_AUDIO, FOLLOW_READ |
| è°æ”¹ | éŸ³é¢‘通道占用 | Server æŒ‡å®š / Client æ‰§è¡Œ |
不要把 `PLAY_AUDIO` åšæˆä¸€ä¸ªæ–°çš„ `kDeviceStatePlayingEnglishLesson`。播放时可保持 Idle æˆ–增加**非对话**占用标记,但 **禁止**把 Task å¡žè¿› Listening/Speaking è¿ç§»è¡¨ã€‚
---
## 5. Chat Runtime:保留并降级
现有 Chat **必须继续作为默认能力**。无 App、无 Task æ—¶ï¼š
```text
唤醒 / æŒ‰é”® â†’ Connecting â†’ Listening â†” Speaking â†’ Idle
```
路径、协议 `hello/listen/abort/tts/stt/mcp`、Opus çƒ­è·¯å¾„ **Phase 1–2 ç¦æ­¢æ”¹è¯­ä¹‰**。
降级方式:
```text
InteractionMode = CHAT
  â†’ èµ°çŽ°æœ‰ Application å¯¹è¯çŠ¶æ€æœº
InteractionMode = PLAY_AUDIO
  â†’ ä¸è¿›å…¥ LLM
  â†’ æ‰¬å£°å™¨ç»™ play_audio
  â†’ éº¦å¯å…³æˆ–只用于打断/唤醒退出
任意 Mode ä¸‹å”¤é†’词 / é€€å‡º
  â†’ å›žåˆ° CHAT æˆ– Idle(可配置)
```
Chat ä¸æ˜¯ Task Runtime çš„父容器,而是 Device Runtime ä¸‹ä¸Ž Task Executor **并列**的子 Runtime。
---
## 6. Task Executor(不是完整 Task Runtime)
V1.0 æŠŠ Task Runtime ç”»åœ¨è®¾å¤‡ä¸Šã€‚Audit ç»“论:编排已在 Server `ConnectionHandler`。设备侧只增加:
```text
收到 skill.execute / æ¿çº§ PLAY_MEDIA / MCP tools/call
  â†’ æ‰§è¡Œ
  â†’ ä¸ŠæŠ¥ completed / failed
  â†’ ç­‰å¾…下一步
```
设备 **不** è´Ÿè´£ï¼š
- åˆ›å»º TaskInstance
- åˆ¤æ–­ã€Œè·Ÿè¯»æ˜¯å¦åŠæ ¼åŽæ˜¯å¦é‡è¯•」(可执行 Server ä¸‹å‘的「再播一次」)
- è¯„分、ASR、LLM
设备 **可以** æœ¬åœ°åˆ¤æ–­çš„完成条件仅限硬件可观测:
```text
播放结束
VAD é™éŸ³è¶…æ—¶
用户按键取消
下载完成 / å¤±è´¥
```
状态机(执行器级,缓存用):
```text
IDLE â†’ EXECUTING â†’ WAITING_USER? â†’ COMPLETED | FAILED | CANCELLED
```
权威状态仍在 Server çš„ TaskInstance / StepInstance。
---
## 7. Interaction Mode
> Interaction Mode ä¸æ˜¯ä¸šåŠ¡åœºæ™¯ï¼ˆä¸æ˜¯ã€Œè‹±è¯­è¯¾ã€ï¼‰ï¼Œåªæè¿°ç¡¬ä»¶æ€Žä¹ˆä¸Žäººäº¤äº’ã€‚
### 7.1 åˆ†é˜¶æ®µæ”¯æŒ
| Mode | Phase | çŽ°æœ‰ä»£ç åŸºç¡€ | è®¾å¤‡è¦åšçš„ |
|------|-------|--------------|------------|
| `CHAT` | 0(已有) | Listening/Speaking | ä¿æŒ |
| `PLAY_AUDIO` | 1 | Sening `self.mp3.play` / `PLAY_MEDIA`;`PauseSdCardMp3` | æŠ½æˆå¯è°ƒç”¨ Skill;与 TTS äº’æ–¥ |
| `FOLLOW_READ` | 2 | listen æµ + æ’­æ”¾ | å…ˆæ’­åŽå¬ï¼›å¬å®Œä¸ŠæŠ¥ï¼Œä¸è¿› LLM |
| `LISTEN` | 2 | `SendStartListening` | å¯å•独听,不自动 Chat |
| `QUIZ` / `TRANSLATION` / `GAME` | 3 | æ— ç‹¬ç«‹ Mode | å…ˆç”¨ CHAT+Skill ç»„合,再固化 Mode |
V1.0 è¦æ±‚第一阶段就支持 QUIZ/TRANSLATION/GAME â€”— **取消**。那些是 Server ç¼–排 + CHAT/LISTEN å³å¯å…ˆåšï¼Œä¸å€¼å¾—先改固件状态机。
### 7.2 PLAY_AUDIO(英语复读)
```text
Server:重复 N æ¬¡çš„编排(权威)
Client:play_audio(resource) â†’ æ’­å®Œ event â†’ ç­‰ä¸‹ä¸€æ¬¡ play æˆ– stop
LLM = false,ASR = false
```
TTS æ¥æ—¶æ²¿ç”¨çŽ°æœ‰ `PauseSdCardMp3` / `resume_sdcard_mp3_after_tts_`,**不要**另写一套混音。
### 7.3 FOLLOW_READ(英语跟读)
```text
play æ ‡å‡†éŸ³é¢‘
  â†’ play.completed
listen(复用上行 Opus)
  â†’ speech.stopped / listen.completed
Server ASR + v0 æ–‡æœ¬è¯„ä¼°
  â†’ speak åé¦ˆï¼ˆå¯èµ°çŽ°æœ‰ TTS)
```
设备不评分。
---
## 8. Skill:只基于真实能力
Skill = å¯¹ Device Capability çš„稳定封装。第三方 App ç¦æ­¢ç›´æŽ¥ GPIO/I2S。
### 8.1 å·²æœ‰ï¼Œå¯ç›´æŽ¥å½“作 Skill
| Skill | å½“前实现 | èŒƒå›´ |
|-------|----------|------|
| `device.get_status` | `self.get_device_status` | å…¨å±€ MCP |
| `audio.set_volume` | `self.audio_speaker.set_volume` | å…¨å±€ |
| `screen.set_brightness` / `set_theme` | `self.screen.*` | æœ‰å± |
| `camera.take_photo` | `self.camera.take_photo` | æœ‰æ‘„像头 |
| `audio.play_sound` | `AudioService::PlaySound`(OGG æç¤ºéŸ³ï¼‰ | å…¨å±€ï¼›ä¸èƒ½å½“课程音频 |
| `mp3.play/stop/list` | `self.mp3.*` | **Sening** |
| `media.play` | `BoardMqtt` `PLAY_MEDIA` | **Sening snlp** |
| `resource.download` | `DOWNLOAD` / Assets | Sening / Assets |
| `alarm.*` | `self.alarm.*` | Sening;≠ å®¶é•¿ Schedule |
| `led` / è¿åŠ¨ç±» | å„ board MCP | æ¿ç›¸å…³ |
### 8.2 Phase 1 éœ€è¦ã€Œæå‡ã€çš„
```text
play_audio
  è¾“入:SD è·¯å¾„或已下载 resource_id
  å®žçŽ°ï¼šä¼˜å…ˆå¤ç”¨ self.mp3.play / PLAY_MEDIA
  ç›®æ ‡ï¼šä»Ž board ç§æœ‰å·¥å…·æå‡ä¸º Runtime Skill(可仍 ifdef æ¿åž‹ï¼‰
```
不要求所有板第一天都能 play MP3。无 SD çš„æ¿ Phase 1 å¯ç”¨ Server æŽ¨æµï¼ˆä¸æŽ¨èå  TTS é€šé“做复读循环)。
### 8.3 Phase 2 éœ€è¦æ–°å»ºçš„
| Skill | çŽ°çŠ¶ | è®¾è®¡ |
|-------|------|------|
| `record_audio`(可归档) | **不存在**;只有流式 Opus | ä¼˜å…ˆå¤ç”¨ listen æµï¼Œç”± Server å¥çº§è½ WAV;设备端文件录音为增强(SD `/recordings/pending`) |
| `listen`(不进 Chat) | listen æ¶ˆæ¯ä¼šè¿›å…¥å¯¹è¯ | Mode=FOLLOW_READ/LISTEN æ—¶ Client ä»å‘ Opus,Server **跳过 LLM** |
| `wait` | æ—  | å¯ç”¨ Server è®¡æ—¶ï¼›è®¾å¤‡åªéœ€æ’­å®Œ/超时事件 |
| `display.show` | æœ‰å± MCP / `SetChatMessage` | å¯æ‰©å±•文本/图片资源 |
### 8.4 ä¸è¦åšæˆè®¾å¤‡ Skill çš„
```text
asr.transcribe     â†’ Server
llm.chat/evaluate  â†’ Server
score.evaluate     â†’ Server
translation.translate â†’ Server
math.calculate     â†’ Server
```
V1.0 Skill è¡¨é‡Œçš„这些能力改挂 Server Plan。
---
## 9. éŸ³é¢‘、录音、互斥
### 9.1 çŽ°æœ‰éŸ³é¢‘ç”Ÿå‘½å‘¨æœŸï¼ˆå¿…é¡»ä¿ç•™ï¼‰
```text
对话:PCM 16k â†’ Opus 60ms æµ â†’ ä¸å†™ç›˜ â†’ Server å¯é€‰è½¬ WAV å…¥åº“
提示音:OGG PlaySound
Sening è¯¾ç¨‹ï¼šSD MP3 ç‹¬ç«‹æ’­æ”¾ï¼ŒTTS æ—¶ Pause
```
热路径禁止改:`MAIN_EVENT_SEND_AUDIO`、`AudioInputTask`、`OpusCodecTask`、`FinishTtsResponse`。
### 9.2 äº’斥规则
```text
同一时刻扬声器只服务一个源:TTS | SD/MP3 | PlaySound
CHAT çš„ Listening/Speaking ä¼˜å…ˆäºŽ PLAY_AUDIO(可暂停课程音频)
Task éœ€è¦éº¦æ—¶ï¼šè¦ä¹ˆ Mode ç‹¬å  listen,要么明确与 Chat äº’æ–¥
```
### 9.3 è·Ÿè¯»éŸ³é¢‘最小方案(Phase 2)
**不先做**设备完整 wav æ–‡ä»¶ä¸Šä¼ ã€‚
```text
复用现有上行 Opus
Server æŒ‰å¥ PCM→WAV(已有 chat_history_conf=2)
新增关联:audio_id â†” step_instance_id
```
离线缓存 `/recordings/pending` æ”¾åˆ° Phase 3,除非跟读在弱网成为阻塞。
设备侧 **没有** audio_id;只有 `session_id`(连接级)。audio_id ç”± Server ç”ŸæˆåŽå›žä¼ ï¼ˆå¯é€‰ï¼‰ã€‚
---
## 10. App èµ„源 vs Firmware OTA
原则保留 V1.0 / Master Plan:
```text
OTA     = é©±åŠ¨ã€åè®®ã€Audio Engine、Runtime、Skill å®¿ä¸»
App Sync = manifest、task JSON、MP3、图片、配置
```
App **原则上不 OTA**。但下列能力必须在 Firmware é‡Œï¼ˆå¯ä¸€æ¬¡ OTA è¡¥é½ï¼Œä¹‹åŽæ–° App ä¸å†æ”¹ C++):
```text
Codec / VAD / å”¤é†’
WS æˆ– MQTT+UDP åè®®æ ˆ
MCP / Skill å®¿ä¸»
SD æ–‡ä»¶ç³»ç»Ÿ
HTTP ä¸‹è½½ï¼ˆæˆ–等价)
音频互斥
InteractionMode å¼€å…³
(Phase 2)非 Chat çš„ listen è¯­ä¹‰
```
### 10.1 SD å¸ƒå±€ï¼ˆç›®æ ‡ï¼ŒPhase 2+ è½åœ°ï¼‰
```text
/sdcard/
  apps/<appId>/          # manifest + tasks JSON
  resources/<appId>/     # mp3 / å›¾
  recordings/pending/    # Phase 3
  cache/
```
Sening å½“前下载路径偏 `/data/courses/...`(smartlearning)。演进时 **映射到统一布局**,不要强迫立即改终端已有文件。
### 10.2 åŒ MQTT æ”¶æ•›
当前存在两套:
| é€šé“ | ç”¨é€” |
|------|------|
| Xiaozhi `Protocol` | å¯¹è¯ JSON + Opus |
| Sening `snlp/v1` | å¿ƒè·³ã€DOWNLOAD、PLAY_MEDIA、ack/progress |
**Phase 1:** å…è®¸å¹¶å­˜ï¼›å¤è¯»ä¼˜å…ˆèµ°å·²èƒ½å·¥ä½œçš„ MCP/`PLAY_MEDIA`。
**Phase 2–3:** èµ„源同步与播放控制逐步收到 Xiaozhi JSON `type` æˆ– MCP,snlp é™ä¸ºæ¿çº§å®žçŽ°ç»†èŠ‚ï¼Œé¿å…ä¸šåŠ¡äº‘é•¿æœŸåˆ†å‰ã€‚
**禁止 Phase 1 åˆå¹¶ä¸¤å¥— MQTT。**
---
## 11. åè®®ï¼šæ‰©å±•,不替换
现有设备→云:`hello / listen / abort / iot / mcp / ping / server`
现有云→设备:`hello / stt / tts / llm / mcp / system / alert / custom`
### Phase 1
不强制新 `type`。复用:
```text
type=mcp  tools/call  self.mp3.play | stop
或 snlp PLAY_MEDIA
```
### Phase 2 æ–°å¢žï¼ˆä¸Ž tts/stt/mcp å¹¶åˆ—)
下行:
```text
task.start / task.stop / task.pause / task.resume
step.start
skill.execute
mode.set          # CHAT | PLAY_AUDIO | FOLLOW_READ | LISTEN
```
上行:
```text
task.started / task.completed / task.failed
step.started / step.completed / step.failed
skill.completed / skill.failed
audio.play.completed
speech.started / speech.stopped   # å¯ä»ŽçŽ°æœ‰ VAD äº‹ä»¶å¯¼å‡º
```
载荷必须带:`session_id`;Phase 2 èµ·å¸¦ `task_instance_id` / `step_id`。
**不要**新建第四套 EventBus。Sening `rpt/event` å¯ä½œå®žçŽ°ï¼Œä½†è¯­ä¹‰åº”å¯¹é½ä¸Šè¡¨ã€‚
插入点:`Application` ä¸­ `protocol_->OnIncomingJson`(`application.cc` çº¦ 647–778 è¡Œï¼‰ï¼Œ`unknown type` åˆ†æ”¯æ—å¢žåŠ  `task` / `mode`。
---
## 12. Event ä¸Žä¸»å¾ªçŽ¯æ’å…¥ç‚¹
现有机制:`MAIN_EVENT_*` + åè®®å›žè°ƒ + MCP JSON-RPC +(Sening)rpt/ack。
Task Executor æŒ‚载顺序(侵入性从低到高):
1. **MCP `tools/call`** â€” Phase 1 é¦–选,Chat è·¯å¾„不动
2. **`Application::Schedule`** â€” çº¿ç¨‹å®‰å…¨æŽ¨è¿› UI / éžéŸ³é¢‘副作用
3. **OnIncomingJson æ–° type** â€” Phase 2
4. **禁止**把 Task çŠ¶æ€å†™å…¥ `DeviceStateMachine::IsValidTransition` çš„ Listening/Speaking è¾¹
按键:今日绑定 `ToggleChatState` / Start/Stop Listening。Phase 2 èµ·å…è®¸ã€Œå–消当前 Task / è·³è¿‡ã€è€Œä¸æ‹†æŽ‰ Chat æŒ‰é”®ã€‚
---
## 13. æœ¬åœ°çŠ¶æ€ï¼ˆè–„ï¼‰
设备只持久化必要项(NVS / å†…存):
```text
activeMode
activeTaskInstanceId     # ç¼“存,权威在 Server
activeStepId             # ç¼“å­˜
sdcard play çŠ¶æ€         # å·²æœ‰
```
不在设备保存完整 Step åŽ†å²ã€è¯„åˆ†ã€ASR æ–‡æœ¬ã€‚
App ç”Ÿå‘½å‘¨æœŸ V1.0 çš„ INSTALL/LAUNCH/ACTIVE å…¨å¥— **Phase 3** å†åšã€‚Phase 1 ç”šè‡³ä¸éœ€è¦ `activeAppId`。
---
## 14. åˆ†é˜¶æ®µï¼ˆClient)
与 Server Plan é˜¶æ®µç¼–号对齐。
### Phase 1 â€” åŒæ¨¡å¼è¯æ˜Žï¼ˆå¿…须不破坏 Chat)
目标:英语复读 N éã€‚
Client å·¥ä½œï¼š
- æ–‡æ¡£åŒ–并复用 `self.mp3.play` / `PLAY_MEDIA` + `PauseSdCardMp3`
- çº¦å®šï¼šæ’­æ”¾æœŸé—´å”¤é†’/按键可回到 Chat(现有行为优先)
- **不改** Opus çƒ­è·¯å¾„、DeviceState è¿ç§»ã€hello/listen/tts è¯­ä¹‰
- ä¸å®žçް Task çŠ¶æ€æœºã€App Manager、跟读录音文件
验收:
1. æ—  Task æ—¶ Chat ä¸ŽçŽ°ç½‘ä¸€è‡´
2. èƒ½å¾ªçŽ¯æ’­æ”¾æŒ‡å®š SD/已下载音频 N æ¬¡
3. TTS/唤醒仍能打断或暂停播放
### Phase 2 â€” Task Executor + è·Ÿè¯»é‡‡é›†
- `mode.set` + `type=task` / `skill.execute`
- FOLLOW_READ:播完再 listen,Server è·³è¿‡ LLM
- ä¸ŠæŠ¥ play/listen å®Œæˆäº‹ä»¶ï¼ˆå¸¦ task/step id)
- å½•音最小方案:流式上传 + Server å½’档关联,不强制设备 wav æ–‡ä»¶
验收:一次跟读 Step èƒ½åœ¨ Server çœ‹åˆ° audio_id + asr_text。
### Phase 3 â€” Runtime å¹³å°åŒ–
- æ ¸å¿ƒ `play_audio` / ä¸‹è½½è¿›éž Sening æŠ½è±¡ï¼ˆæˆ–明确仅支持 SD æ¿ï¼‰
- App èµ„源目录与增量 sync
- snlp ä¸Ž Xiaozhi åè®®æ”¶æ•›
- å¯é€‰ï¼šæœ¬åœ° pending å½•音、QUIZ/GAME ä½œä¸º Mode è€Œä¸æ˜¯ç¡¬ç¼–码业务
- æŒ‰é”®å¯ç»‘定 Task å–消
---
## 15. ç¦æ­¢äº‹é¡¹ï¼ˆClient)
1. é‡å†™ AudioService çƒ­è·¯å¾„或「为了 Task æ¢ä¸€å¥—编解码」
2. åœ¨å›ºä»¶å†™è‹±è¯­/口算/成语业务分支
3. ç”¨ OTA å‘课程 App
4. Phase 1 åˆå¹¶ä¸¤å¥— MQTT
5. æŠŠ Task å¡žè¿› Listening/Speaking
6. è®¾å¤‡ä¸Šåš ASR/评分/情绪分析
7. ä¸º Demo å†™ `EnglishRepeatManager` è¿™ç§ä¸€æ¬¡æ€§ç±»ï¼ˆå¯ç”¨è–„ `TaskExecutor` + Skill)
8. å¼•入通用 EventBus / Redux
9. ä¿®æ”¹çŽ°æœ‰ Chat åè®®å­—段含义
---
## 16. AI Coding çº¦æŸï¼ˆä¿ç•™å¹¶æ”¶ç´§ V1.0)
1. å…ˆè¯»çŽ°æœ‰ `Application` / `AudioService` / `Protocol` / `mcp_server`,再改。
2. æ—  App/Task æ—¶ Chat å¿…须可用。
3. ä¸šåŠ¡ä¸ä¸‹æ²‰åˆ° Client。
4. é…ç½®/JSON é©±åЍ InteractionMode ä¸Ž Skill,不写死 App。
5. æœ€å°æ”¹åŠ¨ï¼šèƒ½ MCP è§£å†³çš„ Phase 1 ä¸æ”¹åè®®ã€‚
---
## 17. ä¸Ž Server Plan çš„契约
Client ä¿è¯ï¼š
- CHAT è¡Œä¸ºå‘后兼容
- èƒ½æ‰§è¡Œ `play_audio`(至少 Sening è·¯å¾„)
- Phase 2 èƒ½æŒ‰ mode å¬è€Œä¸è‡ªåЍ当 Chat å›žå¤å¼•擎
- äº‹ä»¶å¯å…³è” `session_id`(及后续 instance id)
Client è¦æ±‚ Server:
- æƒå¨æŽ¨è¿› Step,不要指望设备记住整张 Workflow
- PLAY_AUDIO / FOLLOW_READ ä¸è¦è¯¯å…¥ `ConnectionHandler.chat()`
- éŸ³é¢‘对象与 step å…³è”在云端完成
- App JSON ä¸Žèµ„源 URL ç”±äº‘下发,不要求固件内置课程
下一份可执行文档应是 **Phase 1 Coding Plan(Client åˆ‡ç‰‡ï¼‰**,而不是继续加 Mode。