From 220e4fab27b7f419b3e89e2f2d28ae789aac09d7 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期四, 08 十月 2026 16:29:55 +0800
Subject: [PATCH] 5种场景模式家长端ok

---
 xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue                                                       |   14 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java              |   26 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java               |   15 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h                                                                 |    1 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue                                                       |  223 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h                                                             |    9 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java                |   11 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c                                                                 |  665 ++
 phase-1.md                                                                                                               |    2 
 xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py                                                      |    2 
 xiaozhi-esp32-2.2.6/main/audio/wake_word.h                                                                               |    4 
 xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java |    6 
 xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts                                                              |    7 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java                          |   25 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java                |  288 +
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java          |   11 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py                                                    |  288 +
 xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue                                                        |   18 
 xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc                                                         |   87 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts                                           |   55 
 xiaozhi-esp32-2.2.6/main/audio/audio_service.h                                                                           |    8 
 xiaozhi-esp32-2.2.6/main/application.cc                                                                                  |  359 +
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc                                                          |  203 
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py                                                     |   48 
 xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py                                                              |    3 
 xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py                                                  |    7 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java                  |   17 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java                        |   26 
 xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc                                                                 |    2 
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts                                                               |  124 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py                                                  |    6 
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py                                                        |   75 
 .cursor/rules/xiaozhi-locked-modes.mdc                                                                                   |   21 
 xiaozhi-esp32-server/main/manager-api/pom.xml                                                                            |    5 
 xiaozhi-esp32-server/main/manager-mobile/env/.env.development                                                            |    3 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java              |   31 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc                                                               |  157 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h                                                               |   39 
 xiaozhi-esp32-server/main/manager-mobile/src/manifest.json                                                               |    4 
 xiaozhi-esp32-2.2.6/main/application.h                                                                                   |   11 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java                |  764 +++
 xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts                                                          |    8 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c                                                               |  144 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java                 |   20 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h                                                                |   11 
 xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py                                                             |   11 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java         |   23 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py                                                     |  394 +
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts                                                               |  123 
 xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql                                   |   35 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts                                            |   61 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py                                                    |   32 
 xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql                                   |    5 
 xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts                                                         |    2 
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts                                                                  |  123 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json                                             |  792 +++
 xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc                                                            |   34 
 xiaozhi-esp32-server/main/xiaozhi-server/config.yaml                                                                     |    7 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue                                                 |  488 +
 xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h                                                          |    6 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java                  |   51 
 xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts                                                              |    2 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py                                                     |   54 
 xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py                                       |    2 
 xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py                                        |    6 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc                                                            |  977 +++
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java                              |    6 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java          |  154 
 xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts                                                         |    1 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java                 |  121 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java                     |    2 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java               |  341 +
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java            |    4 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc                                                     |  901 +++
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java                    |   12 
 xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts                                                         |  177 
 xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql                                   |    6 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue                                                  |  907 +++
 xiaozhi-esp32-server/main/manager-mobile/src/pages.json                                                                  |   36 
 xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java            |   37 
 xiaozhi-esp32-2.2.6/main/audio/audio_service.cc                                                                          |   52 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java                 |   11 
 roadmap.md                                                                                                               |  209 
 xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java          |    6 
 xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py                                                     |    7 
 xiaozhi-esp32-server/main/manager-mobile/vite.config.ts                                                                  |    4 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java                        |   11 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java               |  327 +
 xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py                                                           |   64 
 phase-7a-plan.md                                                                                                         |  372 +
 xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h                                                             |    2 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java             |   25 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json                                                      |  142 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h                                                      |   32 
 xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py                                 |   49 
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts                                                               |  123 
 phase-4-eval-and-roadmap.md                                                                                              |  337 +
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py                                                          |  255 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py                                                |  210 
 xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py                                                     |    2 
 xiaozhi-esp32-server/main/manager-mobile/env/.env                                                                        |    2 
 xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py                                               |   10 
 xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py                                                     |   30 
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py                                                      |  205 
 xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py                                               |  146 
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py                                                       |  547 ++
 xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql                                   |    4 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue                                        |  268 +
 xiaozhi-esp32-2.2.6/main/audio/audio_processor.h                                                                         |    2 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue                                                       |   33 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java                    |   31 
 xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml                           |   28 
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts                                                                  |  123 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue                                                      |   77 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java                 |   12 
 xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h                                                        |    5 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java             |  247 +
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java                                  |    7 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java                  |   89 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc                                                   |  166 
 xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts                                                        |  141 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java            |   28 
 xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java           |   26 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java                  |    3 
 xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py                                            |    3 
 xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts                                                                  |  123 
 xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue                                               |  131 
 xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java                    |  100 
 xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py                                                |   64 
 129 files changed, 13,848 insertions(+), 829 deletions(-)

diff --git a/.cursor/rules/xiaozhi-locked-modes.mdc b/.cursor/rules/xiaozhi-locked-modes.mdc
index 7dfb907..481e391 100644
--- a/.cursor/rules/xiaozhi-locked-modes.mdc
+++ b/.cursor/rules/xiaozhi-locked-modes.mdc
@@ -1,11 +1,11 @@
 ---
-description: 宸查獙鏀舵ā寮忎笉鍙洖閫�锛涘彧鏀瑰璇�/鍚娓呭崟涓庢挱鏀鹃�昏緫
+description: 宸查獙鏀舵ā寮忎笉鍙洖閫�锛涘彧鏀规硾鍚�/璺熻娓呭崟涓庢挱鏀鹃�昏緫
 alwaysApply: true
 ---
 
 # 宸查攣瀹氾細涓嶈鏀硅繖浜涘凡姝g‘璺緞
 
-寮�鏈烘杩庤瘝銆佸璇濇ā寮忋�佺炕璇戞ā寮忋�佷腑鑻卞璇濆凡缁忛獙鏀堕�氳繃銆傚悗缁彧璁告敼澶嶈鏈� / 鍚锛堣窡璇伙級銆備笉瑕佷负淇璇�/鍚鍘绘敼 chat銆乼ranslation銆乥ilingual銆乭ello/TTS 娆㈣繋璇嶆椂搴忋��
+寮�鏈烘杩庤瘝銆佸璇濇ā寮忋�佺炕璇戞ā寮忋�佷腑鑻卞璇濆凡缁忛獙鏀堕�氳繃銆傚悗缁彧璁告敼娉涘惉锛堝唴閮� repeater锛�/ 璺熻锛坰hadowing锛夈�備笉瑕佷负淇硾鍚�/璺熻鍘绘敼 chat銆乼ranslation銆乥ilingual銆乭ello/TTS 娆㈣繋璇嶆椂搴忋��
 
 涓嶈鍔細
 
@@ -13,20 +13,21 @@
 - `interaction_mode == CHAT | TRANSLATION | BILINGUAL` 鐨� ASR鈫扡LM/缈昏瘧 璺緞銆�
 - 娆㈣繋璇� TTS锛氬厛鏈� opus 鍐嶅彂 start/瀛楀箷銆�
 
-# 澶嶈鏈� PLAY_AUDIO
+# 娉涘惉 PLAY_AUDIO锛堝唴閮� repeater锛�
 
-JSON task 涓嬪彂鎾斁鍒楄〃锛屾瘡椤� `{url|name, repeat, speed}`锛宻peed 榛樿 1.0銆傛祴璇曟竻鍗曪細
+JSON task 涓嬪彂鎾斁鍒楄〃锛屾瘡椤� `{url|name, repeat, speed}`锛宻peed 榛樿 1.0銆傛祴璇曟竻鍗曪紙涓�绾у紑澶╄緹鍦� `l1.001`锛夛細
 
-- `/english_learning/shadowing/lessons/unit01/01.mp3` 脳 10
-- `/english_learning/shadowing/lessons/unit01/02.mp3` 脳 5
+- `/english_learning/repeater/lessons/l1/001/cn/01.mp3` 脳 1锛�1.0 鍊嶉��
+- `/english_learning/repeater/lessons/l1/001/en/01.mp3` 脳 1锛�0.8 鍊嶉��
+- `/english_learning/repeater/lessons/l1/001/en/01.mp3` 脳 1锛�0.9 鍊嶉��
+- `/english_learning/repeater/lessons/l1/001/en/01.mp3` 脳 3锛�1.0 鍊嶉��
 
-鎾畬鑷姩鍥炲璇濓紝鎾�屽璇荤粨鏉熴�嶃�傛挱鏀句腑蹇界暐闂茶亰锛屽彧鍝嶅簲绂荤嚎鍙d护锛氶��鍑哄璇汇�佹垜浠潵鑱婂ぉ鍚с��
+鎾畬鑷姩鍥炲璇濓紝鎾�屾硾鍚粨鏉熴�嶃�傛挱鏀句腑蹇界暐闂茶亰锛屽彧鍝嶅簲绂荤嚎鍙d护锛氶��鍑烘硾鍚�佹垜浠潵鑱婂ぉ鍚с��
 
-# 鍚 SHADOWING
+# 璺熻 SHADOWING
 
 JSON task 涓�娆℃�т笅鍙戞暣浠藉垪琛紝姣忛」 `{url|name, repeat, speed}`锛宻peed 榛樿 1.0銆備箣鍚庡湪璁惧涓婃帓搴忔墽琛岋紝涓嶈姣忔挱涓�鍙ュ啀 MCP 涓�娆°�傛祴璇曟竻鍗曪細
 
-- `/english_learning/shadowing/lessons/unit01/01.mp3` 脳 5锛堟瘡閬嶅悗褰曢煶锛�
-- `/english_learning/shadowing/lessons/unit01/02.mp3` 脳 10
+- `/english_learning/shadowing/lessons/l1/001/en/01.mp3` 鍒� `58.mp3`锛屽悇 脳 1锛�0.8 鍊嶉�燂紙姣忛亶鍚庡綍闊筹級
 
 璁惧鏈湴寰幆锛氭挱涓�鍙� 鈫� 鍚獥褰曢煶锛堝閽熸挱鏀炬椂闀� 脳 1.2锛夆啋 褰曢煶涓婁紶 鈫� 鍐嶆挱涓嬩竴鍙ワ紝鐩村埌娓呭崟缁撴潫銆侫SR 鍙殏瀛�/璇勪及锛屼笉杩� chat/LLM銆傛竻鍗曠粨鏉熸挱銆岃窡璇荤粨鏉熴�嶅啀鍥炲璇濄��
diff --git a/phase-1.md b/phase-1.md
index 436a537..629825d 100644
--- a/phase-1.md
+++ b/phase-1.md
@@ -392,7 +392,7 @@
          鑻ヨ澶囪蒋鍏虫満 / 纭欢鍏虫満鍚庣敱 RTC 鍞ら啋 鈫� 鑱旂綉鍚庣珛鍒绘墽琛� pending锛屼笉杩� chat
 ```
 
-杩欐槸 **灏忔櫤纭欢閲嶅惎 / 闂归挓鍞ら啋鐨勪富瑕佷骇鍝佺洰鏍�**锛氫笉鏄负浜嗗洖鍒板緟鏈鸿亰澶╋紝鑰屾槸鍒扮偣鑷姩鍚銆佽窡璇荤瓑銆�
+杩欐槸 **灏忔櫤纭欢閲嶅惎 / 闂归挓鍞ら啋鐨勪富瑕佷骇鍝佺洰鏍�**锛氫笉鏄负浜嗗洖鍒板緟鏈鸿亰澶╋紝鑰屾槸鍒扮偣鑷姩娉涘惉銆佽窡璇荤瓑銆�
 
 鐜版湁浠g爜宸茬粡鍏峰鍗婃埅鑳藉姏锛�**灏氭湭鎺ュ埌 App/Task**锛�
 
diff --git a/phase-4-eval-and-roadmap.md b/phase-4-eval-and-roadmap.md
new file mode 100644
index 0000000..e337777
--- /dev/null
+++ b/phase-4-eval-and-roadmap.md
@@ -0,0 +1,337 @@
+# Phase 4 涔嬪悗锛氳瘎浼版姤鍛婁笌鏇存柊鍚庣殑 Roadmap
+
+> Date: 2026-09-18  
+> 瀵圭収锛氬綋鍓嶄粨搴撲唬鐮� 路 `roadmap.md`锛�2026-09-02锛壜� `phase-1-review.md` 路 `phase-2-review.md` 路 `phase-3-review.md` 路 `phase-4-plan.md`  
+> 浜у搧鍐崇瓥锛氬闀跨锛堝皬绋嬪簭锛夋彁鍓嶏紱灏忔櫤纭欢浠i檯 / 澶氱増鏈紙鍘� Phase V锛夋斁鍒版渶鍚庛��  
+> 绮掑害锛氭灦鏋勪笌椤圭洰绠$悊銆備笉鏄� Coding Plan銆�  
+> 涓嬩竴缂栫爜闃舵锛歚phase-7a-plan.md`鈥斺�斿闀夸换鍔′笅鍙戯紙鍦烘櫙+绔嬪嵆/璁″垝锛汻TC 鍙啓鏈�杩戜竴娆★級+ 鎵ц璁板綍锛堝悗锛屾棤璁¢噺 UI锛夈��
+
+鍘熷垯涓嶅彉锛氭瘡瀹屾垚涓�涓� Phase 鈫� Completion Review 鈫� Architecture 鏇存柊 鈫� Roadmap Review 鈫� 涓嬩竴浠� Phase Plan銆�  
+**鏈枃鍙栦唬**鏃ц矾绾垮浘閲屻�屼笅涓�缂栫爜 = Phase 4 / Phase V 鍙笌 4 骞惰銆嶇殑鎺掑簭銆�
+
+---
+
+## 0. 涓�椤电粨璁�
+
+璺熻/澶嶈宸茬粡鑳藉湪**杩欎竴娆�**鑹插畞璁惧涓婂畬鏁磋窇瀹岋細娓呭崟涓�娆′笅鍙戙�佽澶囨湰鍦版挱鈫掑惉鈫掍笅涓�鍙ャ�佸悗璇勪及瀹氶噺銆佹湰鍦� wav + 鍚屽悕 txt銆両nstance 鍙叆搴撱�傜己鐨勪笉鏄啀鍋氫竴涓嫳璇� App锛岃�屾槸**瀹堕暱鑳借涓�澶╃殑璁″垝锛岃澶囪嚜涓诲紑/鍏�/鍒囨ā寮�**锛涘巻鍙茶褰曠浜屽垁锛屽垎鏁板厛涓嶇粰瀹堕暱鐪嬨��
+
+| 鍒ゆ柇 | 璇存槑 |
+|------|------|
+| Phase 1鈥�4 浜у搧楠ㄦ灦 | **鍙敤銆�** 瀵硅瘽 / 缈昏瘧 / 涓嫳 / 澶嶈 / 璺熻宸插湪璁惧涓婇獙鏀堕�氳繃锛堥攣瀹氳矾寰勮 `.cursor/rules`锛� |
+| Phase 4 鐩稿鍘熸柦宸ュ崟 | **鐩爣杈炬垚锛屾墽琛屽櫒鎹簡鍦版柟銆�** 姝ヨ繘鏉冨▉浠庛�孲erver 鏃堕挓 + 姣忓彞 MCP銆嶆敼鎴愩�岃澶囨挱鏀惧垪琛ㄥ惊鐜�嶏紱鍚庤瘎浼颁粛鍦� Server锛屼笉鎸囨尌鍠囧彮 |
+| 婕旂ず闂幆缂哄彛 | 鏃犱簯绔鍒掕〃锛涢椆閽� pending 鍙湁涓�浠斤紱宸插紑鏈哄埌鐐逛笉鍒囨ā寮忥紱璇炬枃鍐欐锛沗manager-mobile` 鏃犺鍒掗〉锛汭nstance GET 浠� server secret |
+| 涓嬩竴鍒� | **Phase 7a 瀹堕暱浠诲姟涓嬪彂**銆傚叏琛ㄥ悓姝ャ�丷TC 鍙啓涓嬩竴鏃跺埢锛涚珛鍗充紭鍏堜簬璁″垝锛汬istory 鍚庡仛锛沵etrics UI 涓嶅仛 |
+| 鍘� Phase V | **鏈�鍚庡仛銆�** 鐜扮綉鍙湁涓�娆剧‖浠讹紱hello 浠i檯瀛楁涓嶅鍔犳紨绀鸿兘鍔� |
+
+涓嶈鍦� 7a 涔嬪墠寮�鍊掕儗/鍙g畻/鎺ラ緳銆佷笉瑕佸仛鎽勫儚澶村吋瀹圭煩闃点�佷笉瑕佹妸璇炬枃涓嬭浇骞冲彴鍖栧綋鎴愰�夎鍓嶆彁锛圫D 鎵嬪伐鎷峰崱 + catalog 涓庡崱涓婅矾寰勪竴鑷村嵆鍙級銆備笉瑕佸厛鍋氬垎鏁伴〉銆�
+
+---
+
+## 1. 鎬昏繘搴︼紙瀵圭収鏃� Roadmap锛�
+
+| 缂栧彿 | 鏃� Roadmap 涓�鍙ヨ瘽 | 浠g爜浜嬪疄锛�2026-09-18锛� | 鐘舵�� |
+|------|-------------------|------------------------|------|
+| Phase 1 | 钖� App Runtime | `apps.json` + `start_app_task`锛汣HAT / PLAY_AUDIO / TRANSLATION / BILINGUAL / SHADOWING锛汳QTT `START_APP_TASK`锛涚绾垮彛浠ゆ寕閽� | **瀹屾垚**锛堟湁鍊猴紝瑙� 搂2.1锛� |
+| Freeze | Capability Spec v1.0 | 鏂囨。鍐荤粨 | **瀹屾垚** |
+| Phase 2 | 浜嬩欢 + 閲囬泦 | `[skill_event]` + `duration_ms`锛汿askSession JSON锛汼D `/{app}/{task}/lessons` | **瀹屾垚**锛堣窡璇诲凡涓嶆槸 TTS 鑼冭锛� |
+| Phase 3 | TaskInstance 鎸佷箙鍖� | 涓夎〃 + PUT/GET锛況eport 鍙洖鍐� `audio_id` | **瀹屾垚**锛圙ET 閴存潈涓嶅瀹堕暱锛涜 搂2.3锛� |
+| Phase 4 | 璺熻鏃堕挓 + 鍚庤瘎浼� | 璁惧鎾斁鍒楄〃 + `listen_ratio` 鍚獥锛沗expected_text` / `metrics`锛涙湰鍦� `data/recordings` + 鍚屽悕 `.txt` | **瀹屾垚**锛堜笌鍘熸枃銆屽浐浠堕浂 diff銆嶄笉绗︼紝瑙� 搂3锛� |
+| Phase V | 浜у搧浠i檯 + 绔簯鐭╅樀 | 鏃� generation/revision/cap_set | **鏈仛 鈫� 璋冨埌鏈�鍚�** |
+| Phase 4b | 鍊掕儗 / 鍙g畻 / 鎺ラ緳 inline | 浠� `apps.json` 鎰忓浘妲介鐣� | **鏈仛 鈫� 婕旂ず闂幆涔嬪悗** |
+| Phase 5 | Server Scheduler | 璁惧 RTC + NVS pending锛�**鏃�**浜戠鏃ュ巻琛� | **鏈仛 鈫� 鏉冨▉琛ㄥ苟鍏� 7a-B锛�5 鍓╁埌鐐硅ˉ鎺�** |
+| Phase 6 | 璧勬簮 DOWNLOAD + 鍦ㄧ嚎 Locale | 鍙湁 mkdir锛涜瘝琛ㄤ粛纭紪鐮佷腑鏂� | **鏈仛** |
+| Phase 6b | 绂荤嚎 Locale OTA | 浠呯幇缃戜腑鏂� MultiNet | **鏈仛** |
+| Phase 7 | 瀹堕暱浠诲姟绠$悊 | `manager-mobile` 鏃犺鍒�/Instance UI | **鏈仛 鈫� 7a 涓嬪彂浼樺厛銆佸巻鍙插悗缃�** |
+| Phase 8 / 9 | 寮�鍙戣�� / 璁㈤槄 | 鈥� | **鍚庣疆** |
+
+```text
+宸茶惤鍦帮紙璁惧鍙敤锛�
+  chat.conversation 路 缈昏瘧 路 涓嫳瀵硅瘽 路 澶嶈娓呭崟 路 璺熻娓呭崟
+  TaskSession JSON 路 Instance 涓夎〃 路 鍚庤瘎浼� metrics 路 鏈湴 wav/txt
+
+鏈惤鍦帮紙鎸′綇銆岀湡瀹炰娇鐢ㄣ�嶏級
+  瀹堕暱锛氭寜鍦烘櫙鍒涘缓浠诲姟锛堟硾鍚�/璺熻/鍙岃鈥︼級锛涚珛鍗虫墽琛屾垨鍛ㄦ湡璁″垝
+  鍥轰欢锛氭湰鍦拌鍒掑叏琛紱RTC 鍙紪绋嬫渶杩戜竴娆★紱宸插紑鏈哄埌鐐规墽琛�
+  浜戠 ai_parent_task + SYNC_SCHEDULE锛堜笉瑕� 10 妲� SET_ALARM 涓�瀵逛竴锛�
+  鍘嗗彶锛歰auth2 璇� Instance / 鑱婂ぉ锛�7a-D锛屾棤 metrics锛�
+```
+
+---
+
+## 2. 宸插畬鎴� Phase 鐨勭己澶憋紙涓嶈鍋囪娌℃湁锛�
+
+鍙垪**浠嶅奖鍝嶄骇鍝佹垨鍚庣画鏂藉伐**鐨勭己鍙c�傚凡鍦ㄥ搴� `*-review.md` 鍐欒繃銆佷笖涓嶅啀鎸℃紨绀虹殑杩囩▼鍊猴紙渚嬪銆孭hase 1 鏃� pytest銆嶏級浠庣暐鈥斺�擯hase 2 涔嬪悗宸叉湁 pytest銆�
+
+### 2.1 Phase 1 浠嶇己
+
+| 缂哄彛 | 鐜扮姸 | 鏄惁鎸� 7a / 婕旂ず |
+|------|------|------------------|
+| 瀹堕暱灏忕▼搴� UI | `manager-mobile` = 鏅鸿兘浣撳垪琛� + 閰嶇綉 + 鑱婂ぉ璁板綍锛屼笉鏄粌涔犳帶鍒跺彴 | **鎸°��** 7a 鏈綋 |
+| Server Schedule 鏉冨▉琛� | 鍙湁璁惧 `SET_ALARM`锛堣蒋浠舵渶澶� 10 鏉★級+ **鍞竴** NVS pending | **鎸°��** 7a锛氫簯绔换鍔¤〃 + 璁惧鍏ㄨ〃锛汥S3231 鍙啓涓嬩竴鏃跺埢銆備笉瑕佹妸 `ALARM_MAX_NUM` 褰撳闀夸笂闄� |
+| 鍐峰満澶氭涓诲姩寮�鍙� | 杩涘叆瀵硅瘽鏈夋杩庤瘝锛涘彲閰嶅惊鐜偓淇冩湭鍋� | 涓嶆尅 |
+| 闈� Sening 鏉垮璇� | Task Gateway 浠� `sening-xiaozhi-s3` | 涓嶆尅锛堝氨杩欎竴娆撅級 |
+| 鍙屾妧鑳借〃 | 鍥轰欢 `k_task_skills[]` + Server `apps.json` | 涓嶆尅锛涚姝㈠啀涓烘柊 App 鎵╁浐浠惰〃 |
+| 绂荤嚎/鍦ㄧ嚎鐘舵�佸垎鍙� | 娌$綉澶嶈缁撴潫鍚� Server 浠嶅彲鑳戒互涓哄湪 CHAT | 婕旂ず淇濇寔鍦ㄧ嚎 |
+
+### 2.2 Phase 2 浠嶇己
+
+| 缂哄彛 | 鐜扮姸 | 鏄惁鎸� 7a / 婕旂ず |
+|------|------|------------------|
+| D8 v1.1 鏈湴鏂囦欢涓婁紶 | 璺熻 wav 鍦� xiaozhi-server 纾佺洏锛沜hat report 鍙﹁蛋 BLOB | 瀹堕暱鎾綍闊充緷璧栫幇鏈� chat_audio **鎴�** 鍙睍绀烘枃鏈�傚畬鏁村惉鍘熷0鍙悗鍋� |
+| D11 `wait` Skill | 鍚獥鍦ㄨ澶囧涓婃椂閽� | 涓嶆尅璺熻锛�4b 闂撮殧鍑洪鍐嶇敤 |
+| D9 闀挎寜閫�鍑� Task | 鍙d护閫�鍑哄凡鏈� | 涓嶆尅 |
+| D10 `indicate` | 鏃� | 涓嶆尅 |
+| 璺熻鍙ュ瓙琛ㄦ浘鍦� Server | **宸叉敼锛�** 鏁翠唤 playlist JSON 涓�娆′笅鍙戯紝璁惧鎺掑簭鎵ц | 宸叉槸鐜扮綉锛涙枃妗i』鎵胯 |
+
+### 2.3 Phase 3 浠嶇己
+
+| 缂哄彛 | 鐜扮姸 | 鏄惁鎸� 7a / 婕旂ず |
+|------|------|------------------|
+| GET 閴存潈 | `ShiroConfig`锛歚/task/instance/**` **鍏ㄩ儴** `server`銆傝鍒掑啓 oauth2锛屽疄鏂藉洜銆屼笉鑳芥寜鏂规硶鎷嗚繃婊ゅ櫒銆嶆敼鎴� server | **鎸� 7a-D銆�** Dispatch 涓嶄緷璧栬 Instance銆侶istory 鍐嶆媶 oauth2 鏂拌矾寰勶紱PUT 浠� server |
+| `prompt_text` 浠嶆槸 SD 璺緞 | 鍘熸枃鍦� `expected_text` | UI 蹇呴』鏄剧ず `expected_text`锛屼笉瑕佹妸璺緞褰撹鏂� |
+| upsert 澶辫触鍚� `_skip_upsert` | 鏈細璇濅笉鍐嶄笂鎶ャ�俙audio_id` 宸叉敼涓� VARCHAR(255) 娑堟帀鎴柇 | 閮ㄧ讲 Liquibase 鍚庢柊浼氳瘽姝e父锛涙棫 skip 浼氳瘽闇�閲嶆柊璺熻 |
+| 鏌ヨ鍓湰闈炴潈濞� | 鏉冨▉ = 杩炴帴鍐呭瓨 + `data/task_sessions/*.json` | 瀹堕暱璇诲簱鍗冲彲婕旂ず锛涗笉瑕佹妸搴撳綋姝ヨ繘寮曟搸 |
+
+### 2.4 Phase 4 浠嶇己 / 涓庤鍒掓紓绉�
+
+瑙佷笅涓�鑺傘�傚瀹堕暱 **History**锛�**asr_text + expected_text + 鍙�� chat_audio 宸插**銆俶etrics 宸插叆搴擄紝7a **涓嶅睍绀�**銆備笉瑕佺瓑闊崇礌銆佸強鏍肩嚎銆佹祦鍒╁害銆�
+
+---
+
+## 3. Phase 4锛氬師璁″垝 vs 浠g爜
+
+### 3.1 鍘熸柦宸ュ崟锛坄phase-4-plan.md`锛夐拤姝荤殑妯″瀷
+
+```text
+璁惧锛堝綋鏃惰姹傞浂 diff锛�
+  鎾竴鍙� 鈫� completed(duration_ms) 鈫� Server 寮�鍚獥 鈫� 鏃堕挓鍒颁簡 MCP 鎾笅涓�鍙�
+
+鍒嗘瀽鍏ㄩ儴鍚庣疆銆佸畾閲忋�佸厑璁稿け璐ワ紱鏃� passed锛涙棤 JUDGE銆�
+```
+
+### 3.2 瀹為檯瀹炵幇锛堝繀椤诲綋鎴� V5 鏋舵瀯锛屼笉瑕佸洖閫�锛�
+
+SRAM / 58 鍙ユ竻鍗曡瘉鏄庯細姣忓彞鍐� MCP 涓�娆′細鎶婅澶囧崱姝绘垨鎴柇鍒楄〃銆傜幇缃戞槸锛�
+
+```text
+Server  涓�娆� JSON playlist锛坲rl|name, repeat, speed锛泂peed 榛樿 1.0锛�
+Device  PSRAM 娓呭崟锛屾湰鍦板惊鐜細
+          鎾竴鍙� 鈫� 鍏宠В鐮佸櫒 鈫� 鍚獥 = 澧欓挓鎾斁鏃堕暱 脳 listen_ratio(1.2)
+          鈫� 褰曢煶涓婅 鈫� 涓嬩竴鍙�
+        娓呭崟缁撴潫 鈫� audio.playlist_completed 鈫� Server 鎾�岃窡璇荤粨鏉熴�嶅洖瀵硅瘽
+Server  ASR 鍙殏瀛�/璇勪及锛屼笉杩� chat/LLM
+        鍚庤瘎浼� text_similarity_v0 鈫� step.metrics
+        data/recordings/{mac}/{mode}/{date}/{time}_{asr|tts}_*.wav
+        鍚� stem .txt锛圲TF-8锛�
+澶嶈    鍚屾牱涓�娆′笅鍙戞竻鍗曪紱鎾畬銆屽璇荤粨鏉熴�嶏紱鎾斁涓拷鐣ラ棽鑱�
+        绂荤嚎鍙d护锛氶��鍑哄璇� / 鎴戜滑鏉ヨ亰澶╁惂
+```
+
+閿佸畾锛堝凡楠屾敹锛屽悗缁姝负淇窡璇诲幓鏀癸級锛氬紑鏈烘杩庤瘝鏃跺簭銆丆HAT / TRANSLATION / BILINGUAL 鐨� ASR鈫扡LM 璺緞銆�
+
+| 鍘熻鍒掓潯鐩� | 浠g爜 | 缁撹 |
+|------------|------|------|
+| 鍥轰欢闆� diff | `app_task.cc` 鎾斁鍒楄〃銆佸惉绐椼�乣listen_ratio`锛涜В鐮佸櫒/鍞ら啋璇嶅湪鍚獥鍓嶅叧鎺� | **鏈夋剰鍋忕銆�** 璁惧鎴愪负姝ラ寰幆鎵ц鍣紝Server 涓嶅啀閫愬彞 MCP |
+| 涓嬩竴鍙ュ彧鐢� Server 鏃堕挓瑙﹀彂 | 璁惧澧欎笂鏃堕挓锛汼erver 鏃佽矾璇勪及 | 璇箟浠嶆槸銆屽垎鏋愪笉鎸″枃鍙�嶏紝瑙﹀彂鍣ㄦ崲绔� |
+| `expected_text` 鏉ヨ嚜 txt 濂戠害 | `resolve_expected_text`锛涚姝㈢敤璺緞鍋氱浉浼煎害 | **绗﹀悎** |
+| `metrics[]` 鏃� passed | `text_similarity_v0` 0鈥�1 | **绗﹀悎** |
+| 璇撮敊/涓嶈浠嶄笅涓�鍙� | 鍚獥鍒扮偣灏辫繘涓嬩竴鍙ワ紱闈欓煶涔熻惤 wav | **绗﹀悎** |
+| 閫�鍑虹珛鍒诲洖 Chat | 鍙d护 `is_exit_to_chat` | **绗﹀悎** |
+| Liquibase 鏂板垪 | `expected_text` / `metrics` / `assessed_at`锛涘悗鍙� `audio_id` VARCHAR(255) | **绗﹀悎**锛�255 鏄� 9-18 涓鸿矾寰勫姞鐨勶級 |
+
+瀹gО Phase 4 **Done** 鐨勪骇鍝佹爣鍑嗭紙鍘� 搂2.3锛夛細鐑矾寰勬祦鐣� + 鍚庤瘎浼板畾閲� 鈥斺�� **杈惧埌**銆�  
+瀹gО銆屽浐浠跺喕缁撻潰鏈Щ鍔ㄣ�嶁�斺�� **涓嶈鍐嶅啓銆�** 鍐荤粨鐨勬槸 Skill 绉嶇被锛屼笉鏄�岃窡璇诲惊鐜繀椤诲湪浜戠銆嶃��
+
+### 3.3 Phase 4 鏈仛锛堝師 Out of Scope锛屼繚鎸侊級
+
+褰撳満绾犻敊鍐嶆挱銆佸強鏍肩嚎銆侀煶绱犮�佺鎴枫��4b 涓変釜 Task銆佺儹璺緞璇诲崱涓� txt銆乵anager-web 鎵撳崱椤点�丩ocale銆�
+
+---
+
+## 4. Current Architecture V5锛堝熀浜庝唬鐮侊級
+
+### 4.1 璁惧锛堜粎 sening-xiaozhi-s3锛�
+
+```text
+Application 鐑矾寰勶紙hello/listen/tts/stt/Opus 璇箟閿佸畾锛�
+Task Gateway
+  START_APP_TASK / pending / 绂荤嚎鍙d护
+  SET_ALARM 杞欢琛ㄦ渶澶� 10 鏃跺埢锛汥S3231 鍙紪绋嬩笅涓�娆� Alarm1
+  **7a-A锛氬闀胯鍒掓敼璧版湰鍦板叏琛紝涓嶈 10 妲戒竴瀵逛竴锛沺ending = 涓嬩竴绗旇鍒�**
+  playlist锛歳epeat + speed + listen_ratio
+  skill_event锛歛udio.completed|stopped|playlist_completed + duration_ms
+  SD mkdir /{app}/{task}/lessons
+```
+
+### 4.2 Server
+
+```text
+xiaozhi-server
+  apps.json 鏉冨▉瀹氫箟
+  start_app_task / mode 鍒嗘祦
+  璺熻/澶嶈锛氫竴娆′笅鍙� playlist锛屼笉閫愬彞 MCP
+  TaskSession 鈫� JSON + PUT Instance
+  assessment.py 鍚庤瘎浼帮紙涓� await 鍐� play锛�
+  audio_archive锛歳ecordings + sidecar txt
+
+manager-api
+  PUT/GET /task/instance   鐩墠浠� server secret
+  ai_task_instance / step / event
+  涓嶆墽琛� Skill銆佷笉鍒ゅ閿欑粨璁�
+```
+
+### 4.3 瀹堕暱绔幇鐘�
+
+```text
+manager-mobile锛坲ni-app锛屽彲鍑哄井淇″皬绋嬪簭锛�
+  鐧诲綍 / 鏅鸿兘浣� / 閰嶇綉 / 鑱婂ぉ璁板綍 / 澹扮汗
+  鏃狅細缁冧範浼氳瘽銆乵etrics銆丼TART_APP_TASK銆丼chedule
+
+manager-web
+  鏅烘帶鍙帮紙浜鸿銆佹ā鍨嬨�丱TA锛�
+  鏃犺窡璇婚〉
+```
+
+鑹插畞 MQTT `START_APP_TASK` **璁惧渚у凡閫�**銆傜己鐨勬槸灏忕▼搴忓彂鍑哄幓锛屼互鍙婄敤鐢ㄦ埛韬唤璇� Instance銆�
+
+### 4.4 鏁版嵁锛堣窡璇讳竴鍙ワ級
+
+```text
+璁惧褰曢煶 Opus
+  鈫� Server ASR锛堜笉杩� LLM锛�
+  鈫� recordings/..._asr_....wav + .txt
+  鈫� turn.asr_text / expected_text / metrics
+  鈫� PUT Instance锛坅udio_id = 鐩稿璺緞锛屸墹255锛�
+  鈫� 鍙�� chat-history report 鈫� 32 浣� blob锛屽洖鍐欏悓涓�鍒楀垯瑕嗙洊璺緞
+```
+
+瀹堕暱 7a-D锛�**鍙睍绀烘枃鏈�**锛堣窡璇� expected/asr锛涘璇濈被璧� chat_history锛夈�傜浉浼煎害涓嶆覆鏌撱�傛挱鏀惧師澹拌嫢瑕佺敤鐜版垚 API锛岃蛋 chat_audio id锛屼笉瑕佸亣璁� manager-api 鑳借 xiaozhi-server 纾佺洏璺緞銆�
+
+---
+
+## 5. 鏇存柊鍚庣殑 Roadmap
+
+### 5.1 鏂颁緷璧栧浘
+
+```text
+Phase 1鈥�4                         鉁� 璁惧渚ц嫳璇涔犲彲婕旂ず
+    鈫�
+Phase 7a  瀹堕暱浠诲姟涓嬪彂            鈫� 涓嬩竴缂栫爜
+    鈹�     A 鏈湴璁″垝鍏ㄨ〃锛汻TC 鍙啓鏈�杩戜竴娆★紱寮�鏈哄埌鐐逛篃鎵ц
+    鈹�     B ai_parent_task + 鍦烘櫙 catalog + SYNC_SCHEDULE / 绔嬪嵆 START
+    鈹�     C 灏忕▼搴忥細鍦烘櫙鍙傛暟 + 绔嬪嵆 / 鍛ㄦ湡
+    鈹�     D 鍘嗗彶锛堟枃鏈�+褰曢煶锛涙棤鍒嗘暟锛�
+    鈹�
+    鈹溾攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�
+    鈫�               鈫�
+Phase 5 鍒扮偣琛ユ帹    Phase 6 璇炬枃 DOWNLOAD + 鍦ㄧ嚎 Locale
+锛�7a 鏃ュ巻琛ㄥ凡寤猴級   锛堟崲璇句笉闈犳嫹鍗℃椂鍐嶅仛锛涗笉鎸¢�夎锛�
+    鈹�
+    鈫�
+Phase 4b  鍊掕儗 / 鍙g畻 / 鎺ラ緳锛坕nline 璇勪及锛�
+    鈹�     鍥轰欢浠嶇姝� if correct锛涚敤宸叉湁 speak/listen
+    鈫�
+Phase 6b  绂荤嚎 Locale锛堜粎 zh/en 鏈夋ā鍨嬫墠鍋氾級
+Phase 7b  瀹堕暱瀹屾暣锛氳瑷�瀵广�丳AUSE銆侀暱鎸夐��鍑恒�佸惉鍘熷0瀹屽杽
+Phase 8   寮�鍙戣�� App 骞冲彴
+Phase 9   璁㈤槄
+Phase V   浜у搧浠i檯 / 绔簯鍏煎     鈫� 鏈�鍚庯紙灏辫繖涓�娆剧‖浠讹級
+```
+
+**蹇呴』涓茶锛�** 7a-A 鍥轰欢锛堝叏琛�+涓嬩竴闂归挓锛夆啋 7a-B 浜戠 鈫� 7a-C 灏忕▼搴忋�傛湭瀹屾垚 A 涓嶈瀹gО鍛ㄦ湡浠诲姟鍙敤銆�7a-D 涓嶆尅 Dispatch Done銆�  
+**7a 涓嶄緷璧栵細** 4b銆�6 涓嬭浇銆�6b銆乂銆係D 宸叉湁 unit01 鍗冲彲閫夎銆傝瑷�瀵瑰彲瀛� params銆�  
+**5 涓嶆尅 Dispatch锛�** 鏉冨▉琛ㄥ湪 7a-B锛汸hase 5 鍙ˉ銆屽凡寮�鏈烘紡鍝嶃�嶇殑浜戠鍐嶆帹銆�
+
+### 5.2 Phase 7a 鈥� 瀹堕暱浠诲姟涓嬪彂锛圧oadmap 绾э級
+
+1. **Objective**  
+   瀹堕暱鍏堥�夊満鏅啀濉弬鏁帮細娉涘惉/璺熻锛堣鏂囥�侀亶鏁般�佺粨鏉熷悗鍏虫満鎴栧洖瀵硅瘽锛夛紱鍙岃锛堜腑鑻�/涓硶/涓棩鈥︼紝鍙�夋椂闀匡級銆傚彲 **绔嬪嵆鎵ц**锛堜复鏃讹紝鏈�楂樹紭鍏堬級鎴� **瀛樹负鍛ㄦ湡璁″垝**銆傝澶囦繚瀛樿鍒掑叏琛紱DS3231 **鍙啓鍏ヤ笅涓�娆℃渶杩戞椂鍒�**銆侶istory 绗簩鍒�鐪嬫枃鏈�/褰曢煶锛�**涓嶅睍绀哄垎鏁�**銆�
+
+2. **Why Now**  
+   10 妲介椆閽熶竴瀵逛竴鎾戜笉浣忋�屾瘡灏忔椂鏁寸偣鑱� 10 鍒嗛挓銆嶃�傝姱鐗囨湰鏉ュ氨鍙紪绋嬩笅涓�娆°�傜幇缃� pending 浼氳瑕嗙洊锛涘凡寮�鏈哄埌鐐逛笉鎵ц锛涜鏂囧啓姝伙紱瀹堕暱鏂囨銆屽璇�/澶嶈鏈恒�嶅拰璺熻鍒嗕笉娓呫��
+
+3. **Dependencies**  
+   `START_APP_TASK` / 杞叧鏈� / Alarm1锛汸hase 3 Instance锛�7a-D锛夛紱鐧诲綍涓庣粦璁惧銆備笉鎶� `SET_ALARM` 褰撲换鍔℃�荤嚎銆�
+
+4. **Scope**  
+   - **7a-A** 鏈湴 `schedule.json` 鍏ㄨ〃锛沗recompute_next_alarm()` 鍐欏敮涓� pending + Alarm1锛涘凡寮�鏈轰篃鎵ц锛涜蒋鍏虫満鍓嶉噸绠椼��  
+   - **7a-B** `ai_parent_task` + 鍦烘櫙/璇炬枃/璇█瀵� catalog锛涜鍒� `SYNC_SCHEDULE`锛涚珛鍗� `START_APP_TASK`銆�  
+   - **7a-C** 灏忕▼搴忥細鍦烘櫙鍔ㄦ�佽〃鍗� + 绔嬪嵆 / 姣忓ぉ / 姣忓皬鏃躲�傚澶栧啓 **娉涘惉**锛屼笉鍐欏璇绘満銆�  
+   - **7a-D**锛堝悗锛塷auth2 璇� Instance + 鑱婂ぉ锛涗笉娓叉煋 metrics銆�
+
+5. **Out of Scope**  
+   鍒嗘暟銆佽鏂� DOWNLOAD銆佸�掕儗/鍙g畻銆丱TA 浠i檯銆佹敼閿佸畾鐑矾寰勩�佺敤 `ALARM_MAX_NUM` 闄愬埗鏉℃暟銆佸垹鏃у彛浠ゃ�屽璇汇�嶃��
+
+6. **Client锛堣澶囷級**  
+   鍏佽琛ヨ鍒掓枃浠躲�佸彧鍐欎笅涓�闂归挓銆乨uration 澧欓挓銆乷n_complete銆傜姝㈡柊 DeviceState銆佷笟鍔� if 鏍戙�佹妸 58 鍙ユ竻鍗曞杩� RTC銆傚彲鏂板涓�鏉� MQTT `SYNC_SCHEDULE`锛堜笉鏄柊 WS type锛夈��
+
+7. **Acceptance锛圖ispatch锛�**  
+   绔嬪嵆璺熻鍙墦鏂鍒掞紱鈮�15 鏉¤鍒掑惈 hourly 鍏ㄩ噺鍦ㄨ澶囥�佽姱鐗囧彧鏈夋渶杩戜竴娆★紱杞叧鏈哄敜閱掕繘姝g‘鍦烘櫙锛涚粨鏉熷悗閲嶇畻涓嬩竴闂归挓锛涜鍒掍笉鎵撴柇涓存椂銆侰hat/缈昏瘧/涓嫳鍙d护鍥炲綊涓嶅潖銆侶istory 涓嶆尅 Dispatch Done銆�
+
+鏂藉伐鍗曪細`phase-7a-plan.md`銆傛湭瀹屾垚 7a-A 涓嶈瀹gО鍛ㄦ湡浠诲姟鍙敤銆�
+
+### 5.3 鍚庣画 Phase锛堢洰鏍囩骇锛屼笉閿佺被鍚嶏級
+
+| 缂栧彿 | 涓�鍙ヨ瘽 | 鐩稿鏃у浘鐨勫彉鍖� |
+|------|--------|----------------|
+| Phase 5 | 璁惧宸插紑鏈烘紡鍝嶆椂浜戠鍐嶆帹 START | 浠诲姟琛ㄥ凡骞跺叆 7a-B锛涙湰闃舵鏄ˉ鏁戞壂鎻� |
+| Phase 6 | 娓呭崟 / DOWNLOAD / `resource_id`锛涘湪绾� phrase pack | mkdir 宸叉湁銆�7a 宸茶兘瀛� language_pair锛沺ack 涓嶆尅閫夎 |
+| Phase 4b | 鍊掕儗/鍙g畻/鎺ラ緳 = `assessment.timing=inline` + 鍙婃椂 speak | **鎸埌瀹堕暱鑳界湅瑙佽窡璇讳箣鍚�** |
+| Phase 6b | 绂荤嚎 zh/en 璇嶈〃 OTA | 涓嶆尅 7a/7b |
+| Phase 7b | PAUSE/RESUME銆侀暱鎸夐��鍑恒�佸綍闊虫挱鏀惧畬鍠� | 璇█瀵归�夋嫨宸插湪 7a 鍙岃鍦烘櫙锛�7b 琛ユ帶鍒跺彴涓庣绾胯瘝 |
+| Phase 8 / 9 | 寮�鍙戣�呮矙绠� / 璁㈤槄 | 涓嶅彉锛屾洿鍚� |
+| Phase V | `product.generation/revision` + hello/OTA 鐭╅樀 | **鏈�鍚庛��** 鎽勫儚澶� SKU 鎴栫浜屾纭欢绔嬮」鏃跺啀鎵撳紑 |
+
+璇勪及鏃舵満琛紙deferred vs inline锛変粛鏈夋晥锛岃鏃� `roadmap.md`锛�4b 涓嶅緱鎶婅窡璇绘敼鍥炴祦鍐呯籂閿欍��
+
+### 5.4 鏄庣‘鍚庣疆
+
+| 浜嬮」 | 鏈�鏃� |
+|------|------|
+| 灏忔櫤 AI 1 / 1-1 / 2 浠i檯瀛楁 | Phase V锛堟渶鍚庯級 |
+| 鎽勫儚澶� / 鏂颁紶鎰熷櫒 Skill | Phase V 涔嬪悗 |
+| D8 v1.1 鏈湴鏂囦欢褰撳闀夸富鎾斁閫氶亾 | 7b 鎴栫嫭绔嬶紱7a 鐢ㄦ枃鏈� + 鍙�� chat_audio |
+| D11 wait 涓�绛� Skill | 4b 闇�瑕併�屾挱瀹屽仠 800ms銆嶄笖涓嶆兂浜戠鐫℃椂 |
+| D14 TASK_PAUSE/RESUME | 7b |
+| 鏂� WS `type=command` | 浠呭綋 detect 鍓嶇紑涓嶅锛涗笉鍔犳柊璇箟 |
+| 寰疯绂荤嚎鍞ら啋 | 鍙兘姘镐箙鍋氫笉鍒帮紙鏃犲畼鏂� MultiNet锛� |
+
+---
+
+## 6. 鍦烘櫙锛�7a Dispatch Done 鏃讹級
+
+```text
+瀹堕暱鍒涘缓锛�
+  绔嬪嵆     璺熻 unit01 脳1锛岀粨鏉熷悗鍥炲璇�     鈫� 椹笂鎵ц锛堝彲鎵撴柇褰撳墠璁″垝锛�
+  璁″垝 A   姣忓ぉ 07:30 璺熻 unit01
+  璁″垝 B   姣忓ぉ 10:00 娉涘惉 unit01锛岀粨鏉熷悗杞叧鏈�
+  璁″垝 C   姣忓皬鏃舵暣鐐� 鍙岃(涓嫳) 10 鍒嗛挓锛岀粨鏉熷悗杞叧鏈�
+  锛堣鍒掑彲浠ュ緢澶氭潯锛屼笉鏄� 10锛�
+
+璁惧锛�
+  鏈湴淇濆瓨鍏ㄩ儴璁″垝
+  杞叧鏈哄墠鍙妸銆屼笅涓�娆℃渶杩戙�嶅啓鍏� Alarm1
+  10:00 娉涘惉鍏虫満鍚庯紝闂归挓鍙樻垚 11:00 鍙岃锛岃�屼笉鏄啀鍗� 10 涓Ы
+
+History锛�7a-D锛屼笉鎸� Dispatch锛夛細
+  璺熻锛氭瘡鍙ュ師鏂� / ASR / 鍙�夊綍闊�
+  娉涘惉锛氬畬鎴愭��
+  鍙岃/缈昏瘧/瀵硅瘽锛氳鏃舵鑱婂ぉ
+  涓嶅睍绀虹浉浼煎害
+```
+
+---
+
+## 7. 鍒绘剰涓嶅仛杩� 7a 鐨勪簨椤�
+
+- 涓哄�掕儗/鍙g畻鏀� C++ 鎴栧姞 MultiNet 涓氬姟鍙d护  
+- 鎶� `branch(if correct)` 鏀捐繘鍥轰欢  
+- 鍦� 7a 鍋氬吋瀹圭煩闃� / hello 浠i檯瀛楁  
+- 鎶� `audio_id` 璺緞褰撴垚 manager-api 鍙洿鎺� fopen 鐨勬枃浠�  
+- 鎶婂強鏍笺�佺瓑绾у啓鍏� `ai_task_step`锛涘闀块〉娓叉煋 metrics  
+- 鏀� CHAT / 缈昏瘧 / 涓嫳 / 寮�鏈烘杩庤瘝鍘汇�岄『渚夸慨銆嶅闀块〉  
+- 閲嶅仛 SD 鐩綍鎴栭�愬彞 MCP 璺熻  
+- 鐢� 10 涓椆閽� id 涓�瀵逛竴缁戝畾瀹堕暱浠诲姟  
+- 鍏堝仛鍘嗗彶椤点�佸悗鍋氥�屽叏琛� + 涓嬩竴闂归挓銆嶏紙椤哄簭蹇呴』 A鈫払鈫扖锛屽啀 D锛�
+
+**涓嬩竴缂栫爜闃舵锛�** 鍙墦寮� `phase-7a-plan.md`銆傚厛琛ユ湰鍦拌鍒掕〃涓庡彧鍐欎笅涓�鏃跺埢鐨� RTC锛屽啀浜戠涓庡皬绋嬪簭銆傚澶栨枃妗堢敤 **娉涘惉 / 璺熻**銆�
diff --git a/phase-7a-plan.md b/phase-7a-plan.md
new file mode 100644
index 0000000..6247ee7
--- /dev/null
+++ b/phase-7a-plan.md
@@ -0,0 +1,372 @@
+# Phase 7a Coding Plan 鈥� 瀹堕暱锛氫换鍔′笅鍙戯紙鍏堬級+ 鎵ц璁板綍锛堝悗锛�
+
+> File: `phase-7a-plan.md`  
+> Date: 2026-09-18锛堜慨璁細鍦烘櫙鍙傛暟 / 娉涘惉鍛藉悕 / 涓存椂 vs 璁″垝 / **闂归挓鍙啓涓嬩竴娆�**锛�  
+> 浜у搧锛氣憼 鍒涘缓骞朵笅鍙戜换鍔� 鈶� 鐪嬪巻鍙诧紱**璁¢噺璇勪及涓嶅仛**銆�  
+> 绾︽潫锛欳apability Spec v1.0锛涢攣瀹� chat / 缈昏瘧 / 涓嫳鐑矾寰勪笌娆㈣繋璇嶆椂搴忋��  
+> 缁� Coding Agent锛氬厛璇绘湰鏂� 搂0鈥撀�5锛屽啀璇� `system.c` `program_alarm1`锛圖S3231 鏈潵灏卞彧缂栫▼涓嬩竴娆★級銆乣app_task.cc` 鍞竴 pending銆乣runtime.py` `start_app_task`銆�
+
+瀹炴柦椤哄簭锛�**鍏� Dispatch锛圓/B/C锛夛紝鍐� History锛圖锛夈�傜姝㈠厛鍋� metrics UI銆�**
+
+---
+
+## 0. 瀹堕暱鎬庝箞鍒涘缓浠诲姟
+
+鍏堥�� **鍦烘櫙**锛屽啀濉鍦烘櫙鐨勫弬鏁帮紝鏈�鍚庨�� **鎬庝箞璺�**锛�
+
+| 鎬庝箞璺� | 鍚箟 | 浼樺厛绾� |
+|--------|------|--------|
+| **涓存椂浠诲姟** | 鍒涘缓鍚� **绔嬪嵆涓嬪彂骞舵墽琛�** | **鏈�楂�**锛坄source=mqtt_immediate`锛� |
+| **璁″垝浠诲姟** | 鍛ㄦ湡鍒扮偣鎵ц锛堟瘡澶╂煇鏃跺埢銆佹瘡灏忔椂鏁寸偣銆佸伐浣滄棩鈥︼級 | 鍏舵锛坄source=schedule`锛� |
+
+鍚屼竴鏉′换鍔″畾涔夛紙鍦烘櫙+鍙傛暟锛夊彲浠ャ�岀幇鍦ㄥ氨缁冦�嶄篃鍙互銆屽瓨鎴愭瘡澶� 7:30銆嶃�備笉瑕佸仛鎴愪袱濂椾簰涓嶇浉骞茬殑瀵硅薄锛氫簯绔竴琛屼换鍔★紝`run_mode=immediate|scheduled`銆�
+
+### 0.1 鍦烘櫙涓庡弬鏁帮紙瀹堕暱 UI锛�
+
+鍐呴儴 `app_id/task_id/Mode` **涓嶈鏀瑰悕**锛堥伩鍏� SD 璺緞涓庡彛浠ゆ柇瑁傦級銆傚澶栨枃妗堝涓嬨��
+
+| 瀹堕暱鍦烘櫙 | 鍐呴儴 | 瀹堕暱瑕佸~鐨勫弬鏁� |
+|----------|------|----------------|
+| **娉涘惉** | `english_learning` / `repeater` / `PLAY_AUDIO` | 璇炬枃銆侀亶鏁般�佺粨鏉熷悗锛�**杞叧鏈�** 鎴� **鍥為粯璁ゅ璇�** |
+| **璺熻** | `english_learning` / `shadowing` / `SHADOWING` | 鍚屼笂锛堣鏂囥�侀亶鏁般�佺粨鏉熷悗鍏虫満鎴栧洖瀵硅瘽锛� |
+| **鍙岃瀵硅瘽** | `english_conversation` / `bilingual_chat` / `BILINGUAL` | 璇█瀵癸細涓嫳 / 涓硶 / 涓棩鈥︼紱鍙�夋椂闀匡紙濡傝亰 10 鍒嗛挓锛夛紱缁撴潫鍚庡叧鏈烘垨淇濇寔/鍥為粯璁ゅ璇� |
+| **缈昏瘧** | 鐜扮綉 translation app | 鍙�夎瑷�瀵癸紱鍙�夋椂闀夸笌缁撴潫鍔ㄤ綔 |
+| **瀵硅瘽** | `chat` / `conversation` | 鍙�夋椂闀匡紙濡傛瘡灏忔椂鏁寸偣鑱� 10 鍒嗛挓锛夛紱缁撴潫鍚庤蒋鍏虫満鎴栦繚鎸� |
+
+璇炬枃锛歝atalog 閫� `lesson_id`锛坲nit01锛夛紝涓嶈鍕� 58 涓枃浠躲�傞亶鏁拌惤鍒� playlist 姣忛」 `repeat`锛堟硾鍚�/璺熻榛樿 1锛屽闀垮彲鏀癸級銆�
+
+鍙岃璇█瀵癸細鍐欏叆 `params.language_pair`锛堝 `zh-en` / `zh-fr` / `zh-ja`锛夈��**涓嶆敼** ASR鈫扡LM 閿佸畾璺敱锛涘彧鎶� pair 浼犺繘宸叉湁 bilingual/缈昏瘧 prompt銆傜幇缃戝彛浠や粛鍙�屼腑鑻卞璇濄�嶇殑锛屽綋浣� zh-en 鍒悕淇濈暀銆�
+
+### 0.2 鍛藉悕锛氫笉瑕佸啀瀵瑰闀胯銆屽璇� / 澶嶈鏈恒��
+
+銆屽璇汇�嶅拰銆岃窡璇汇�嶅湪鍙h閲岄兘鍍忋�岃窡鐫�璇汇�嶏紝瀹堕暱鍒嗕笉娓呫�傚瀹堕暱鐢� **娉涘惉 / 璺熻**锛堝彧鍚� vs 璺熺潃璇伙級锛屼笉瑕佺敤鍚銆佷笉瑕佺敤澶嶈鏈恒��
+
+| 瀵瑰闀� | 鍚箟 | 鍐呴儴锛堝喕缁擄級 |
+|--------|------|----------------|
+| **娉涘惉** | 鍙惉璇炬枃锛屼笉褰曢煶銆佷笉璺熻 | `repeater` / 璺緞浠� `/english_learning/repeater/` |
+| **璺熻** | 鎾竴鍙� 鈫� 鍚獥褰曢煶 鈫� 涓嬩竴鍙� | `shadowing` |
+
+杩涘満 TTS / 灏忕▼搴� / 鍘嗗彶椤� / 绂荤嚎鍙d护涓�寰嬬敤 **娉涘惉**銆備笉鍐嶄繚鐣欍�屽紑濮嬪璇� / 閫�鍑哄璇� / 澶嶈鏈恒�嶃��
+
+涓嶈鏂伴�犵涓夌 Mode銆�
+
+### 0.3 楠屾敹鍦烘櫙锛圖ispatch锛�
+
+```text
+涓存椂锛氬闀跨偣銆岀珛鍗冲紑濮� 路 璺熻 路 unit01銆嶁啋 璁惧椹笂璺熻锛堝彲鎵撴柇褰撳墠璁″垝浠诲姟锛�
+
+璁″垝 A锛氭瘡澶� 07:30 璺熻 unit01 脳1锛岀粨鏉熷悗鍥炲璇�
+璁″垝 B锛氭瘡澶� 10:00 娉涘惉 unit01 脳1锛屾彁绀哄悗鎾紝缁撴潫鍚庤蒋鍏虫満
+璁″垝 C锛氭瘡灏忔椂鏁寸偣 鍙岃(涓嫳) 10 鍒嗛挓锛岀粨鏉熷悗杞叧鏈�
+璁″垝 D锛氭瘡澶� 19:00 鍙岃(涓棩) 锛岀粨鏉熷悗淇濇寔瀵硅瘽鐩村埌涓嬩竴鏉�
+
+杞叧鏈哄悗锛氳澶囦粠鏈湴浠诲姟琛ㄦ寫 **涓嬩竴娆℃渶杩�** 鐨勮鍒掞紝鍙妸杩欎竴娆″啓鍏� DS3231 Alarm1
+  锛堜緥濡傚垰鎾畬 10:00 娉涘惉鍏虫満 鈫� 闂归挓鍐欐垚 11:00 鍙岃锛岃�屼笉鏄啀鍗� 10 涓椆閽熸Ы锛�
+```
+
+璁″垝鏁伴噺 **涓嶆槸 10**銆傛瘡灏忔椂鏁寸偣鏄� **涓�鏉�** 鍛ㄦ湡浠诲姟锛屼笉鏄� 24 鏉¢椆閽熴��
+
+---
+
+## 1. 闂归挓妯″瀷锛堝繀椤绘敼鎺変笂涓�鐗堬級
+
+涓婁竴鐗堛�屾瘡涓鍒掍竴涓� alarm id銆佹渶澶� 10 鏉°�嶆槸閿欑殑銆�
+
+**纭欢浜嬪疄锛�** DS3231 鍙湁 Alarm1锛沗program_alarm1()` **宸茬粡**鍙啓鍏ヤ笅涓�娆¤Е鍙戞椂鍒汇�俙ALARM_MAX_NUM=10` 鏄蒋浠堕噷鐨勯椆閽熸潯鐩〃锛屼笉鏄� RTC 鑳藉姏锛�**涓嶈兘**褰撳闀夸换鍔′笂闄愩��
+
+**姝g‘妯″瀷锛�**
+
+```text
+浜戠鏉冨▉锛氫换鍔¤〃锛堜复鏃朵竴娆� / 璁″垝澶氭潯锛屾暟閲忚繙澶т簬 10锛�
+    鈫� 鍏ㄩ噺鍚屾锛堟浛鎹紝涓嶆槸閫愭潯 SET_ALARM锛�
+璁惧鏈湴锛歴chedule.json锛堝畬鏁磋鍒掑垪琛� + 姣忔潯 params锛�
+    鈫� 姣忔闇�瑕侀椆閽熸椂锛堝悓姝ュ悗銆佷竴娆′换鍔$粨鏉熴�佸嵆灏嗚蒋鍏虫満锛�
+鎸� next = 鎵�鏈夎鍒掗噷澧欓挓涓婃渶杩戠殑涓�娆″彂鐢�
+    鈫�
+鍙� program DS3231 Alarm1 = next.time
+鍚屾椂鎶� next 鐨� {app,task,params} 鍐欐垚銆屼笅涓�娆″敜閱掕浇鑽枫�嶏紙涓�浠� pending 鍗冲彲锛�
+```
+
+| 涓嶈 | 瑕� |
+|------|----|
+| 瀹堕暱 30 鏉¤鍒� 鈫� 30 涓� SET_ALARM | 30 鏉¤繘鏂囦欢锛汻TC 姘歌繙 1 涓�屼笅涓�娆°�� |
+| 姣忓皬鏃舵暣鐐� = 24 涓� id | 涓�鏉� `hourly`锛屾瘡娆″叧鏈哄悗绠椾笅涓�涓� :00 |
+| 鐢ㄦ弧 10 妲藉氨 400 | 杞笂闄愶紙寤鸿浜戠 100銆佽澶� JSON 鍚屾牱锛夛紝涓庨椆閽熻姱鐗囨棤鍏� |
+| 姣忎釜闂归挓 id 缁戜竴浠� playlist | 璁″垝閲屽彧瀛� `lesson_id`锛涚湡姝� playlist 鎵ц鏃跺啀灞曞紑锛圡QTT 杞借嵎涓婇檺 8KB锛� |
+
+涓存椂浠诲姟 **涓嶅啓** 鍛ㄦ湡琛ㄣ��**涓嶅啓** RTC銆傚彧鍙� `START_APP_TASK` `mqtt_immediate`銆傝窇瀹屽悗璁惧鍐� `recompute_next_alarm()`锛屼互鍏嶆妸銆屼笅涓�璁″垝銆嶄涪鎺夈��
+
+---
+
+## 2. 鐜扮綉缂哄彛
+
+| 鑳藉姏 | 鐜扮綉 | 缂哄彛 |
+|------|------|------|
+| RTC | 鍙紪绋嬩笅涓�娆� Alarm1 | **瀵广��** 7a 搴旂户缁彧鐢ㄨ繖涓�涓Ы |
+| 闂归挓鏉$洰琛� | 鏈�澶� 10 鏉℃椂鍒� | **涓嶈**褰撲换鍔″簱銆傛敼鎴愭湰鍦拌鍒掓枃浠� |
+| pending | `SET_ALARM` 瑕嗙洊鍞竴 NVS | 鍞ら啋杞借嵎 = **褰撳墠绠楀嚭鏉ョ殑涓嬩竴娆¤鍒�**锛屼笉鏄� 10 浠� map |
+| 宸插紑鏈哄埌鐐� | 鏈湪 power-off 鍒欎笉 StartAppTask | 宸插紑鏈轰篃瑕佹墽琛屽綋鍓嶈繖绗旇浇鑽� |
+| 璇炬枃/閬嶆暟/缁撴潫 | TEMP 娓呭崟锛涚粨鏉熷姩浣滀笉瀹屾暣 | 鍦烘櫙 params锛歭esson銆乺epeat銆乷n_complete |
+| 璇█瀵� | 鍙岃鍐欐涓嫳鏂囨 | params.language_pair锛涗笉鏀归攣瀹氳矾鐢� |
+| 鏃堕暱缁撴潫 | 鏃� | 瀵硅瘽/鍙岃/缈昏瘧鍙� `duration_minutes` 鍒扮偣缁撴潫 |
+| 浜戠琛� | 鏃� | `ai_parent_task`锛堝悕绉板彲瀹氾紝绂佹鏀规棫 changeset锛� |
+| 鍘嗗彶 | GET 浠� server secret | 7a-D锛涙棤 metrics |
+
+---
+
+## 3. 鐩爣涓庨潪鐩爣
+
+### 3.1 Dispatch 蹇呴』 Done
+
+1. 瀹堕暱閫夊満鏅� 鈫� 濉弬鏁� 鈫� 閫夈�岀珛鍗虫墽琛屻�嶆垨銆屽瓨涓鸿鍒掋�嶃��  
+2. 绔嬪嵆锛氳澶囬┈涓婅繘鍏ヨ鍦烘櫙锛涗紭鍏堢骇楂樹簬璁″垝銆�  
+3. 璁″垝锛氬叏閲忎笅鍙戝埌璁惧锛汻TC **鍙瓨鏈�杩戜竴娆�**锛涜蒋鍏虫満鍚庢湰鍦伴噸绠椾笅涓�涓��  
+4. 娉涘惉/璺熻锛氳鏂囥�侀亶鏁般�佺粨鏉熷悗鍏虫満鎴栧洖榛樿 chat锛涜繘鍦烘湁鎻愮ず銆�  
+5. 鍙岃锛氬彲閫夎瑷�瀵癸紱鍙�夋椂闀裤��  
+6. 璁″垝鏉℃暟涓嶈 10 闄愬埗锛涙瘡灏忔椂鏁寸偣鏄竴鏉″懆鏈熻鍒欍��  
+7. 宸插紑鏈哄埌鐐瑰悓鏍锋墽琛岋紱涓嶅彲鎵撴柇姝e湪璺戠殑涓存椂浠诲姟銆�
+
+### 3.2 Out of Scope
+
+- metrics UI  
+- 璇炬枃浜戜笅杞斤紙SD 宸叉湁 unit01锛�  
+- 鍊掕儗/鍙g畻锛涙柊 Mode锛涙柊 WS type  
+- 鏀� hello / CHAT / 缈昏瘧 / 鍙岃 **鐑矾寰�**锛堝彧鍏佽 params 杩� prompt / playlist锛�  
+- 鍐滃巻锛涙妸 58 涓� mp3 濉炶繘 SYNC 鍖�
+
+### 3.3 Acceptance锛圖ispatch锛�
+
+1. 绔嬪嵆璺熻锛氱偣瀹岃澶囪繘璺熻锛岃鏂囧銆�  
+2. 鈮�15 鏉¤鍒掞紙鍚竴鏉°�屾瘡灏忔椂鏁寸偣鑱� 10 鍒嗛挓鍚庡叧鏈恒�嶏級淇濆瓨鎴愬姛锛岃澶� JSON 鏈夊叏閲忥紱DS3231 **鍙湁**鏈�杩戜竴娆°��  
+3. 杞叧鏈� 鈫� 鍒扮偣鍞ら啋杩涚殑鏄偅鏉℃渶杩戣鍒掞紝涓嶆槸鍒楄〃閲岀涓�鏉°��  
+4. 璇ユ潯缁撴潫鍚庯紙鎴栧啀鍏虫満锛夐椆閽熷彉鎴愬啀涓嬩竴鏉℃椂鍒汇��  
+5. 涓存椂浠诲姟鎵ц涓紝璁″垝鍒扮偣锛�**涓嶆姠鍗�**锛涗复鏃剁粨鏉熷悗鍐嶆墽琛屾垨閲嶇畻锛堣 搂5.3锛夈��  
+6. 璁″垝鎵ц涓潵浜嗕复鏃朵换鍔★細**鎵撴柇璁″垝**锛岃窇涓存椂锛涚粨鏉熷悗閲嶇畻 RTC銆�  
+7. 娉涘惉缁撴潫鍏虫満 vs 璺熻缁撴潫鍥� chat锛屾寜鍚勮嚜 `on_complete`銆�  
+8. Chat / 缈昏瘧 / 涓嫳鍙d护鍥炲綊涓嶅潖銆�
+
+---
+
+## 4. 鏁版嵁妯″瀷
+
+### 4.1 浜戠 `ai_parent_task`
+
+| 鍒� | 璇存槑 |
+|----|------|
+| `id` | 浠诲姟 id锛圲UID锛夛紝**涓嶆槸** 闂归挓 id |
+| `user_id` / `mac_address` / `agent_id` | 缁戝畾鏍¢獙 |
+| `run_mode` | `immediate` 涓�娆℃�э紱`scheduled` 鍛ㄦ湡 |
+| `scene` | `extensive` / `shadowing` / `bilingual` / `translation` / `chat` 锛坋xtensive = 娉涘惉锛� |
+| `app_id` / `task_id` | 鐢� scene 鏄犲皠锛屽嬁璁╁闀垮~ |
+| `params` JSON | 瑙佷笅 |
+| `schedule` JSON | 浠� scheduled锛涜涓� |
+| `enabled` | 璁″垝鍙仠鐢� |
+| `status` | immediate锛歲ueued/running/done/canceled |
+| `updated_at` | |
+
+`params`锛�
+
+```json
+{
+  "lesson_id": "unit01",
+  "repeat": 1,
+  "language_pair": "zh-en",
+  "duration_minutes": 10,
+  "on_complete": "power_off",
+  "announce": true
+}
+```
+
+`on_complete`锛歚power_off` | `chat`锛堝洖榛樿瀵硅瘽锛夈�傛湭濉細娉涘惉/璺熻榛樿 `chat`锛涘甫鏃堕暱鐨勫璇濈被榛樿 `power_off` 鑻ュ闀块�変簡鍏虫満锛屽惁鍒欎繚鎸佽鍦烘櫙鐩村埌琚姠鍗犮��
+
+`schedule`锛�
+
+```json
+{ "type": "daily", "time": "07:30", "weekdays": [1,2,3,4,5,6,7] }
+{ "type": "hourly", "minute": 0 }
+{ "type": "interval", "every_minutes": 60 }
+```
+
+`weekdays`锛�1=鍛ㄤ竴 鈥� 7=鍛ㄦ棩銆俙hourly` = 姣忓皬鏃剁殑绗� `minute` 鍒嗐��
+
+immediate 琛岋細鏃� schedule锛涗笅鍙戝嵆 `START_APP_TASK`锛屼笉杩涜澶囧懆鏈熻〃锛堟垨杩涜〃浣� `enabled` 涓斾竴娆℃�с�佽窇瀹� disable锛夈�傛帹鑽愶細**鍛ㄦ湡琛ㄥ彧鍚� scheduled**锛宨mmediate 涓嶈惤 RTC銆�
+
+杞笂闄� 100 鏉�/璁惧锛�400 瓒呭嚭锛夈��**绂佹**鎸� 10 鎷掔粷銆�
+
+### 4.2 璁惧鏈湴鍛ㄦ湡琛�
+
+鏂囦欢渚嬪 SD 鎴� NVS 鏃佽矾锛歚schedule.json` = 浜戠 scheduled 鐨勭揣鍑戞嫹璐濓紙id銆乻cene銆乤pp銆乼ask銆乸arams銆乻chedule锛夈��  
+**绂佹**鎶婂睍寮�鍚庣殑 58 鍙� playlist 鍐欏叆璇ユ枃浠躲��
+
+`pending`锛堜竴浠斤級锛氫粎銆屼笅涓�娆� Alarm1 瀵瑰簲鐨勯偅鏉′换鍔°�嶇殑 app/task/params銆傚悓姝ユ垨璺戝畬鍚庨噸绠楄鐩栥�傝繖鏄敮涓� pending锛岃涔夊彉浜嗭細涓嶆槸銆屾渶鍚庝竴娆� SET_ALARM銆嶏紝鑰屾槸銆屾渶杩戜竴娆″皢鍙戠敓鐨勮鍒掋�嶃��
+
+`ALARM_MAX_NUM=10` 鐨勬椂閽熸潯鐩〃锛�**涓嶈**缁欏闀胯鍒掔敤銆傛棫 `SET_ALARM` 鍙暀浣滄櫘閫氶椆閽燂紝7a 鍚屾璺緞涓嶈蛋瀹冦��
+
+### 4.3 Catalog
+
+```text
+GET /user/scenes              鍦烘櫙鍒楄〃 + 姣忎釜鍦烘櫙鐨勮〃鍗� schema锛堣鏂�? 閬嶆暟? 璇█瀵�? 鏃堕暱? 缁撴潫鍔ㄤ綔?锛�
+GET /user/lessons?scene=      extensive / shadowing 鐨勮鏂�
+GET /user/language-pairs      zh-en, zh-fr, zh-ja鈥︼紙灞曠ず鍚嶏細涓嫳/涓硶/涓棩锛�
+```
+
+---
+
+## 5. 鎵ц涓庝紭鍏堢骇
+
+```text
+浼樺厛绾э細涓存椂 mqtt_immediate  >  璁″垝 schedule  >  璇煶/榛樿 chat
+```
+
+### 5.1 绔嬪嵆
+
+```text
+灏忕▼搴忋�岀珛鍗冲紑濮嬨��
+  鈫� POST task run_mode=immediate
+  鈫� MQTT START_APP_TASK {app,task,params, source:mqtt_immediate}
+  鈫� 璁惧 Stop 褰撳墠璁″垝 Skill锛堣嫢鏈夛級鍐嶈繘鍏ュ満鏅�
+```
+
+涓嶄慨鏀瑰懆鏈熻〃銆備笉 program 闂归挓銆傜粨鏉熷悗 `recompute_next_alarm()`銆�
+
+### 5.2 璁″垝鍚屾涓� RTC
+
+```text
+瀹堕暱淇濆瓨/鍒犻櫎/鍋滅敤璁″垝
+  鈫� 浜戠鏉冨▉鏇存柊
+  鈫� MQTT SYNC_SCHEDULE { tasks: [ ...绱у噾鍒楄〃 ] }   // 鍏ㄩ噺鏇挎崲
+  鈫� 璁惧鍐� schedule.json
+  鈫� recompute_next_alarm()
+       next = 澧欓挓涓婃渶杩戜竴娆� occurrence
+       pending = next 鐨勮浇鑽�
+       program Alarm1 = next 鐨勬椂鍒�
+```
+
+`SYNC_SCHEDULE` 鏄粰銆屾暣琛ㄦ浛鎹€�嶇敤鐨勪竴鏉� MQTT action锛堢幇缃戞病鏈夈�屼笅鍙� 100 鏉¤鍒掋�嶇殑鍛戒护锛夈�備笉瑕佺敤 10 娆� `SET_ALARM` 鍐掑厖銆備笉鏂板 Xiaozhi WS `type`銆傝嫢 8KB 涓嶅锛氬厛鎺ㄦ枃浠跺啀 `SYNC_SCHEDULE` 鍙甫鐗堟湰鍙封�斺�旀柦宸ユ椂閫変竴绉嶏紝涓嶈涓ょ骞惰銆�
+
+杞叧鏈哄墠蹇呴』 `recompute_next_alarm()`锛岃繖鏍锋病缃戜篃鑳借涓嬩竴绗旇鍒掑敜閱掋��
+
+### 5.3 鍒扮偣
+
+```text
+Alarm1 瑙﹀彂
+  鈫� 璇� pending锛堣繖涓�绗旇鍒掞級
+  鈫� 鑻ユ鍦ㄨ窇涓存椂浠诲姟锛氫笉鎶㈠崰锛涗复鏃剁粨鏉熷悗 recompute锛堣嫢浠嶅埌鏈熷垯琛ヨ窇鎴栬烦杩囨湰 occurrence锛屽缓璁細浠嶅湪瀹介檺绐楀彛鍐呭垯琛ヨ窇锛屽惁鍒欑瓑涓嬩竴鍛ㄦ湡锛�
+  鈫� 鑻ヨ蒋鍏虫満锛歊estore + StartAppTask(schedule)
+  鈫� 鑻ュ凡寮�鏈轰笖闈炰复鏃讹細Stop 褰撳墠 + StartAppTask(schedule)
+  鈫� 鏈瑪寮�濮嬪悗鍗冲彲 recompute銆屽啀涓嬩竴娆°�嶅啓鍏� Alarm1锛堟椂闀� 10 鍒嗛挓鐨勪换鍔′笉瑕佹妸闂归挓璁惧湪杩� 10 鍒嗛挓鍐呯殑涓嬩竴涓暣鐐癸紝闄ら潪浜у搧灏辫閲嶅彔鈥斺�旈粯璁わ細杩涜涓殑杩欐潯鍗犵敤璁惧锛屼笅涓�涓� Alarm 鑷冲皯鍦ㄦ湰绗� on_complete 涔嬪悗锛�
+```
+
+**杩涜涓崰鐢細** 涓�鏉′换鍔℃病缁撴潫锛堟竻鍗曟病瀹� / duration 娌″埌锛夋椂锛屼笉瑕佺敤涓嬩竴鏁寸偣鎶婂畠鍒囨帀锛岄櫎闈炴潵浜嗕复鏃朵换鍔°�傛瘡灏忔椂鏁寸偣銆岃亰 10 鍒嗛挓銆嶏細10:00 寮�濮嬶紝10:10 鍏虫満锛�11:00 鍐嶅紑銆備笉瑕� 10:00 寮�浜嗗張琚� 11:00 鐨勮绠楄鍐欒繘褰撳墠杩� 10 鍒嗛挓閲屻��
+
+### 5.4 缁撴潫
+
+鍙鏈 params锛岀姝� `if (shadowing) power_off`銆�
+
+| on_complete | 琛屼负 |
+|-------------|------|
+| `chat` | 鍥為粯璁ゅ璇濓紙鐜扮綉銆岃窡璇荤粨鏉熴�嶃�屾硾鍚粨鏉熴�嶆彁绀哄彲淇濈暀锛屾枃妗堟硾鍚笉瑕佽澶嶈鏈猴級 |
+| `power_off` | 鑷劧缁撴潫鍚庤蒋鍏虫満锛涘彛浠ら��鍑哄垯 **涓嶅叧** |
+| duration 鍒扮偣 | 绛夊悓鑷劧缁撴潫锛屽啀璧� on_complete |
+
+鏃堕暱瀹氭椂锛氳澶囧閽燂紙鍏虫満/娌$綉涔熻鍑嗭級銆備笉瑕佸彧闈犱簯绔� sleep銆�
+
+### 5.5 璇█瀵�
+
+`start_app_task` 鎶� `language_pair` 鏀捐繘 bilingual/缈昏瘧宸叉湁 prompt 鎷艰銆傛湭瀹炵幇鐨勮绉嶏細浠嶈繘鍏ヨ Mode锛孴TS 璇存槑銆屽厛鐢ㄤ腑鑻便�嶏紝涓嶈宕┿�傚畬鏁� phrase pack 浠嶆槸 Phase 6锛屼笉鎸� 7a 瀛� pair銆�
+
+---
+
+## 6. 鏀瑰姩娓呭崟
+
+### 6.1 manager-api
+
+| ID | 浠诲姟 |
+|----|------|
+| J1 | Liquibase `ai_parent_task` |
+| J2 | oauth2 CRUD锛泂cene 鐧藉悕鍗曪紱璁″垝涓婇檺 100 涓嶆槸 10 |
+| J3 | scenes / lessons / language-pairs catalog |
+| J4 | 璁″垝鍙樻洿 鈫� `SYNC_SCHEDULE` 鍏ㄩ噺 |
+| J5 | 绔嬪嵆 鈫� `START_APP_TASK` mqtt_immediate |
+| J6 | 锛堝悗锛塷auth2 璇� Instance / 鑱婂ぉ锛涗笉娓叉煋 metrics |
+
+### 6.2 xiaozhi-server
+
+| ID | 浠诲姟 |
+|----|------|
+| S1 | lesson_id + repeat 鈫� playlist锛堟浛鎹㈠啓姝� TEMP 涓虹己鐪侊級 |
+| S2 | on_complete / duration / language_pair 浼犲叆鎵ц |
+| S3 | 杩涘満 announce锛涘闀挎枃妗堛�屾硾鍚�� |
+| S4 | pytest锛氬睍寮�璇炬枃锛沬mmediate 涓嶅啓鍛ㄦ湡琛� |
+
+涓嶆敼 ASR鈫扡LM 閿佸畾璺緞銆�
+
+### 6.3 鍥轰欢
+
+| ID | 浠诲姟 |
+|----|------|
+| C1 | 鏈湴 `schedule.json` 鍏ㄩ噺锛�**绂佹**鐢� 10 妲介椆閽熻〃褰撲换鍔″簱 |
+| C2 | `recompute_next_alarm()`锛氭渶杩戜竴娆� 鈫� 鍞竴 pending + Alarm1 |
+| C3 | 瑙﹀彂鏃跺凡寮�鏈轰篃鎵ц锛涜蒋鍏虫満鍞ら啋璧拌繖绗� pending |
+| C4 | 涓存椂涓嶆姠锛熷惁锛氫复鏃跺彲鎵撴柇璁″垝锛涜鍒掍笉鍙墦鏂复鏃� |
+| C5 | `duration_minutes` 澧欓挓鍒扮偣缁撴潫锛沗on_complete` |
+| C6 | 杞叧鏈哄墠閲嶇畻涓嬩竴娆� Alarm1 |
+| C7 | 绂佹鏂� DeviceState / 涓氬姟 if 鏍戯紱璇炬枃涓嶈繘鍥轰欢 |
+
+C1/C2/C6 鏄� P0銆傛病鏈夈�屾湰鍦板叏琛� + 鍙啓涓嬩竴娆°�嶏紝浜戠 100 鏉¤鍒掑湪鐪熸満涓婁粛鏄敊鐨勩��
+
+### 6.4 manager-mobile
+
+| ID | 浠诲姟 |
+|----|------|
+| M1 | 鍒涘缓锛氬満鏅� 鈫� 鍔ㄦ�佽〃鍗� 鈫� 銆岀珛鍗冲紑濮嬨�嶆垨銆屾瘡澶�/姣忓皬鏃垛�︺�� |
+| M2 | 鍦烘櫙鍚嶏細**娉涘惉 / 璺熻 / 鍙岃瀵硅瘽 / 缈昏瘧 / 瀵硅瘽**銆傜姝㈠啓澶嶈鏈� |
+| M3 | 娉涘惉/璺熻锛氳鏂囥�侀亶鏁般�佺粨鏉熷姩浣溿�傚弻璇細璇█瀵广�佸彲閫夋椂闀� |
+| M4 | 璁″垝鍒楄〃锛堝彲寰堝鏉★級锛涚珛鍗充换鍔℃樉绀恒�屾鍦ㄦ墽琛屻�� |
+| M5 | 锛堝悗锛夊巻鍙诧細璺熻鍙ユ枃鏈�+褰曢煶锛涙硾鍚畬鎴愭�侊紱瀵硅瘽绫昏亰澶╄褰� |
+
+---
+
+## 7. 瀹炴柦椤哄簭
+
+```text
+7a-A  鍥轰欢锛歴chedule.json + 鍙紪绋嬩笅涓�娆� Alarm1 + 宸插紑鏈烘墽琛� + 杞叧鏈哄墠閲嶇畻
+7a-B  Java 浠诲姟琛� + SYNC_SCHEDULE / 绔嬪嵆 START
+      Python锛歭esson/repeat/on_complete/language_pair
+7a-C  灏忕▼搴忥細鍦烘櫙琛ㄥ崟 + 绔嬪嵆 + 璁″垝
+7a-D  鍘嗗彶锛堟棤鍒嗘暟锛�
+```
+
+---
+
+## 8. 娴嬭瘯
+
+- 15+ 鏉¤鍒掑惈 hourly锛氳澶囨枃浠跺叏鍦紝鑺墖閲屽彧鏈変竴涓渶杩戞椂鍒�  
+- 娉涘惉鍏虫満鍚庡啀寮�锛岃繘鐨勬槸涓嬩竴绗旀渶杩戣鍒掞紝涓嶆槸鍒楄〃[0]  
+- 绔嬪嵆璺熻鎵撴柇娉涘惉璁″垝锛涜鍒掑埌鐐逛笉鎵撴柇绔嬪嵆  
+- 涓嫳/涓棩 pair 鍐欏叆 params锛涚儹璺緞涓嶅洖褰掑け璐�  
+- 鏈煡璇炬枃锛氭彁绀猴紝涓嶈鍏虫満  
+- 鍙d护銆岄��鍑烘硾鍚�嶉��鍑烘硾鍚�  
+
+閿佸畾鍥炲綊锛氭杩庤瘝銆丆HAT銆佺炕璇戙�佷腑鑻便��
+
+---
+
+## 9. History锛�7a-D锛�
+
+| 鍦烘櫙 | 瀹堕暱鐪嬭 |
+|------|----------|
+| 璺熻 | 姣忓彞 expected / ASR / 鍙�夊綍闊筹紱鏃犲垎鏁� |
+| 娉涘惉 | 瀹屾垚/鏃堕暱锛涢�氬父鏃� turns |
+| 鍙岃/缈昏瘧/瀵硅瘽 | 璇ユ椂娈佃亰澶╂枃鏈� + 鐜版湁闊抽 API |
+
+---
+
+## 10. 缂栫爜绾︽潫
+
+1. 涓嶆敼 hello/listen/tts 璇箟銆�  
+2. 涓嶆妸璇炬枃鍜� 58 鍙ユ竻鍗曞啓鍏� RTC/闂归挓缁撴瀯浣撱��  
+3. 涓嶇敤 `ALARM_MAX_NUM` 闄愬埗瀹堕暱浠诲姟銆�  
+4. 涓存椂 vs 璁″垝鍙�氳繃 `source` 浼樺厛绾э紝涓嶆柊閫� Mode銆�  
+5. 璁惧绂荤嚎锛氫簯绔粛淇濆瓨璁″垝锛涗笂绾垮啀鍏ㄩ噺 SYNC锛涙病缃戣蒋鍏虫満鍙潬鏈湴琛� + Alarm1銆�  
+6. 姣忎釜 PR 鍏� Chat 鍥炲綊銆�
diff --git a/roadmap.md b/roadmap.md
index df9698d..0b6b252 100644
--- a/roadmap.md
+++ b/roadmap.md
@@ -1,14 +1,17 @@
-# Roadmap锛圕urrent Architecture V4 + Capability Spec v1.0锛�
+# Roadmap锛圕urrent Architecture V5 + Capability Spec v1.0锛�
 
-> Date: 2026-09-02  
-> 杈撳叆锛歚phase-1-review.md` 路 `phase-2-review.md` 路 `phase-3-review.md` 路 `Xiaozhi Device Capability Specification v1.0.md` 路 璁惧榛樿璇█ / 璇█瀵硅瘎浼� 路 **浜у搧浠i檯 / 绔簯鐗堟湰鍖归厤**  
+> Date: 2026-09-18  
+> **璇勪及涓庢帓搴忎慨璁細** `phase-4-eval-and-roadmap.md`锛堝鐓� Phase 1鈥�4 浠g爜缂哄彛 + 瀹堕暱绔彁鍓嶏級銆�  
+> 杈撳叆锛堝巻鍙诧級锛歚phase-1-review.md` 路 `phase-2-review.md` 路 `phase-3-review.md` 路 Capability Spec v1.0  
 > 绮掑害锛氭灦鏋勪笌椤圭洰绠$悊銆備笉鏄� Coding Plan銆�  
-> Phase 3 宸插疄鏂斤細`phase-3-review.md`銆備笅涓�缂栫爜闃舵锛歚phase-4-plan.md`锛堥獙鏀� `phase-4-test.md`锛夈��  
-> 璇█锛氳鏈枃 **Phase 6 / 6b / 7**锛�**涓嶈**濉炶繘 Phase 4銆�  
-> 鐗堟湰涓庡吋瀹癸細瑙佹湰鏂� **Phase V**锛涙憚鍍忓ご SKU锛堝皬鏅� AI 2锛変箣鍓嶅繀椤昏惤鍦版彙鎵嬪瓧娈典笌鍏煎鐭╅樀锛�**涓嶈**绛� Phase 8銆�
+> Phase 4 宸插疄鏂斤紙璁惧鎾斁鍒楄〃 + 鍚庤瘎浼帮級銆�**涓嬩竴缂栫爜闃舵锛歅hase 7a 瀹堕暱浠诲姟涓嬪彂**锛堟柦宸ュ崟 `phase-7a-plan.md`锛夈�傚厛 Dispatch锛屽啀 History锛�**涓嶅仛璁¢噺 UI**銆�  
+> 璇█鍖� / 绂荤嚎璇嶈〃浠嶈 **Phase 6 / 6b / 7b**銆�7a 鍙岃鍦烘櫙鍙瓨骞朵紶閫� `language_pair`锛屼笉鎹� MultiNet銆�  
+> **浜у搧浠i檯 / 澶氱‖浠剁増鏈紙鍘� Phase V锛夋敼鍒版渶鍚�**锛氱幇缃戝彧鏈変竴娆捐壊瀹佽澶囷紝涓嶆尅婕旂ず銆�
 
 鍘熷垯锛氭瘡瀹屾垚涓�涓� Phase 鈫� Completion Review 鈫� Architecture 鏇存柊 鈫� Roadmap Review 鈫� 涓嬩竴浠� Phase Plan銆�  
 **绂佹**鎶婃湰鏂囧綋鎴愰攣姝诲埌 Phase 9 鐨勬柦宸ュ浘銆傚悗闈� Phase 鍙繚璇佺洰鏍囥�佽竟鐣屼笌 **瀵瑰簲鐨� Spec 鑳藉姏 ID**銆�
+
+---
 
 ---
 
@@ -46,16 +49,17 @@
 | Phase 1 | 钖� App Runtime | 宸插畬鎴愶細D15 璁惧 loop銆丏4 Mode 绛栫暐闆忓舰銆佺炕璇戣蛋 Server |
 | Freeze | Capability Spec v1.0 | 鏂囨。鍐荤粨锛涗笉缂栫爜 |
 | Phase 2 | 鐢ㄥ璇�+璺熻閽夋浜嬩欢涓庨噰闆� | **宸插畬鎴愶紙瑙� phase-2-review.md锛�**锛欴1銆丏6銆丏7 v1銆丏8 v1 鏂囨湰锛涘彟鎻愬墠 D13 鐩綍 `/{app}/{task}/lessons` |
-| Phase 3 | TaskInstance 鎸佷箙鍖� | **宸插畬鎴愶紙瑙� phase-3-review.md锛�**锛氫笁琛� + PUT/GET锛涘浐浠堕浂 diff |
-| Phase 4 | 璺熻锛氭椂閽熸杩� + **鍚庤瘎浼�** | 鍚獥缁撴潫鍗充笅涓�鍙ワ紱metrics 涓嶆寚鎸ュ枃鍙� |
-| Phase V | 浜у搧浠i檯 + 绔簯鍏煎 | 灏忔櫤 AI 1 / 1-1 / 2锛沨ello 鎶ヤ唬闄呬笌 cap_set锛汼erver 鎸夌煩闃甸檷绾ф垨 OTA |
-| Phase 4b | 鍊掕儗 / 鍙g畻 / 鎺ラ緳 | **娴佸唴璇勪及 + 鍙婃椂 speak 鍙嶉**锛涘弽璇佸浐浠跺喕缁� |
-| Phase 5 | Server Scheduler | D14 TASK_START 鐨勪簯绔潈濞� |
+| Phase 3 | TaskInstance 鎸佷箙鍖� | **宸插畬鎴愶紙瑙� phase-3-review.md锛�**锛氫笁琛� + PUT/GET锛汫ET 鐜颁负 server secret |
+| Phase 4 | 璺熻锛氭竻鍗曞惊鐜� + **鍚庤瘎浼�** | **宸插畬鎴愶紙瑙� phase-4-eval-and-roadmap.md锛�**锛氳澶囨湰鍦版挱鈫掑惉鈫掍笅涓�鍙ワ紱metrics 涓嶆寚鎸ュ枃鍙� |
+| Phase 7a | 瀹堕暱浠诲姟涓嬪彂 + 鎵ц璁板綍 | **涓嬩竴缂栫爜**锛氬満鏅�+鍙傛暟锛涚珛鍗� vs 璁″垝锛涙湰鍦板叏琛ㄣ�丷TC 鍙啓涓嬩竴娆°�傛硾鍚墵璺熻銆侶istory 鍚庡仛銆佹棤 metrics |
+| Phase 5 | Server Scheduler 琛ユ晳鎵弿 | 鏉冨▉琛ㄥ凡骞跺叆 7a-B锛涙湰闃舵鍓┿�岃澶囧凡寮�鏈恒�佷簯绔埌鐐瑰啀鎺ㄤ竴鎶娿�� |
 | Phase 6 | App / 璧勬簮 + **鍦ㄧ嚎 Locale** | D13 fetch锛沺hrase pack锛涚炕璇�/鍙岃鐨� language_pair |
+| Phase 4b | 鍊掕儗 / 鍙g畻 / 鎺ラ緳 | **娴佸唴璇勪及 + 鍙婃椂 speak**锛涙帓鍦ㄥ闀胯兘鐪嬭璺熻涔嬪悗 |
 | Phase 6b | 绂荤嚎 Locale锛堝彲閫� OTA锛� | MultiNet/鍞ら啋璇嶈〃鎸� locale锛�**浠� ESP-SR 宸叉湁妯″瀷鐨勮绉嶏紙鍏� zh/en锛�** |
-| Phase 7 | 瀹堕暱浠诲姟绠$悊 | 鍚細璁惧榛樿璇█銆佽瑷�瀵归�夋嫨锛涘彧娑堣垂宸叉湁 Command |
+| Phase 7b | 瀹堕暱瀹屾暣鑳藉姏 | 璇█瀵广�丳AUSE銆侀暱鎸夐��鍑恒�佸惉鍘熷0瀹屽杽 |
 | Phase 8 | 寮�鍙戣�� App 骞冲彴 | Manifest 鍙兘璋冨凡鍐荤粨 Skill |
 | Phase 9 | Subscription / Marketplace | 璐﹀彿锛屼笉鏀归煶棰� |
+| Phase V | 浜у搧浠i檯 + 绔簯鍏煎 | **鏈�鍚庡仛銆�** 灏忔櫤 AI 1 / 1-1 / 2锛涘氨杩欎竴娆剧‖浠舵椂涓嶅紑宸� |
 
 ---
 
@@ -66,51 +70,56 @@
     鈫�
 Capability Spec v1.0                      鉁� 鏂囨。
     鈫�
-Phase 2 閽夋 D1 浜嬩欢 + D6/D7/D8v1 閲囬泦    鉁� 瑙� phase-2-review.md
+Phase 2 閽夋 D1 浜嬩欢 + D6/D7/D8v1 閲囬泦    鉁�
     鈫�
-Phase 3 TaskInstance 鎸佷箙鍖�               鉁� 瑙� phase-3-review.md
-    鈹溾攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�
-    鈫�                鈫�                鈫�
-Phase 4 璺熻鍚庤瘎浼�    Phase 5 璋冨害     Phase V 浠i檯/鍏煎
-涓嬩竴缂栫爜             鍙悗鍋�            鍙笌 4 骞惰锛涙憚鍍忓ご SKU 鍓嶅繀椤诲畬鎴�
+Phase 3 TaskInstance 鎸佷箙鍖�               鉁�
+    鈫�
+Phase 4 璺熻娓呭崟寰幆 + 鍚庤瘎浼�             鉁�  璁惧鎵ц鍒楄〃锛汼erver 鍙瘎浼�
+    鈫�
+Phase 7a 瀹堕暱浠诲姟涓嬪彂                     鈫� 涓嬩竴缂栫爜
+    鈹�  7a-A 鍥轰欢锛氳鍒掑叏琛� + RTC 鍙啓鏈�杩戜竴娆★紱寮�鏈哄埌鐐逛篃鎵ц
+    鈹�  7a-B 浜戠浠诲姟琛� + 鍦烘櫙 catalog + SYNC_SCHEDULE / 绔嬪嵆 START
+    鈹�  7a-C 灏忕▼搴忥細鍦烘櫙鍙傛暟 + 绔嬪嵆 / 鍛ㄦ湡
+    鈹�  7a-D 鍘嗗彶锛堟枃鏈�+鍙�夊綍闊筹紱鏃� metrics锛�
+    鈹溾攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹�
+    鈫�             鈫�
+Phase 5 鍒扮偣琛ユ帹  Phase 6 璧勬簮 + 鍦ㄧ嚎 Locale
     鈹�
     鈫�
-Phase 4b 鍊掕儗/鍙g畻/鎺ラ緳锛堟祦鍐呭強鏃跺弽棣堬紱鍙笌 5 鍚庢湡閲嶅彔锛�
-    鈹斺攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�鈹攢鈹�鈹�鈹�鈹�鈹�鈹�鈹�
-             鈫�
-        Phase 6 璧勬簮 + 鍦ㄧ嚎 Locale锛堝彲涓� 4b/5 鍚庢湡閲嶅彔锛�
-             鈹�
-             鈹溾攢 Phase 6b 绂荤嚎 Locale锛坺h/en 璇嶈〃 OTA锛屼笉鎸� 7锛�
-             鈫�
-        Phase 7 瀹堕暱 UI锛堣 device.locale / 璇█瀵癸級
-             鈫�
-        Phase 8 寮�鍙戣�呭钩鍙�
-             鈫�
-        Phase 9 璁㈤槄
+Phase 4b 鍊掕儗/鍙g畻/鎺ラ緳锛坕nline锛涚姝㈡敼璺熻鍚庤瘎浼版ā鍨嬶級
+    鈫�
+Phase 6b 绂荤嚎 Locale锛坺h/en锛�
+Phase 7b 瀹堕暱瀹屾暣锛堣瑷�瀵� / PAUSE / 鍚師澹帮級
+    鈫�
+Phase 8 寮�鍙戣�呭钩鍙�
+    鈫�
+Phase 9 璁㈤槄
+    鈫�
+Phase V 浠i檯/鍏煎                         鏈�鍚庯紱绗簩娆剧‖浠舵垨鎽勫儚澶� SKU 鍓嶆墠蹇呴』
 ```
 
-**蹇呴』涓茶锛�** 1 鈫� Freeze 鈫� 2 鈫� 3 鈫� 4銆傝窡璇诲悗璇勪及鎸� Instance銆侾hase 4b **鍙﹀**瑕佹祦鍐呰瘎浼帮細鍚獥缁撴潫鍚庡厛 Server 绠楀畬鍐� speak锛屼笉鑳藉璺熻鐨勩�屽垎鏋愭案涓嶆尅鍠囧彮銆嶃��  
-**Phase V 蹇呴』鍦ㄦ憚鍍忓ご SKU / 鐮村潖鎬� API 涔嬪墠瀹屾垚**锛涗笉鎸� Phase 4 璺熻鏂藉伐銆�
+**蹇呴』涓茶锛�** 1 鈫� Freeze 鈫� 2 鈫� 3 鈫� 4 鈫� **7a-A 鍥轰欢锛堝叏琛�+涓嬩竴娆¢椆閽燂級** 鈫� 7a-B 浜戠 鈫� 7a-C 灏忕▼搴忋��7a-D 鍘嗗彶涓嶆尅瀹gО Dispatch Done銆�  
+**7a 涓嶄緷璧�** 4b / 6 / V銆係D 宸叉湁 unit01 鍗冲彲閫夎銆傚闀夸换鍔℃暟閲� **涓嶆槸** 10锛沗ALARM_MAX_NUM` 涓嶅噯褰撲笂闄愩�傝瑷�瀵瑰彲瀛� params锛宲hrase pack 浠� Phase 6銆�  
+**Phase 4b** 渚濊禆 4 鐨勫惉绐�/Instance锛屼篃渚濊禆瀹堕暱鑳界湅瑙佺粨鏋滐紙鍚﹀垯鏂� App 鍚屾牱銆屽彧鏈夊枃鍙�嶏級銆傜姝负姝ゆ敼 C++銆傝瘎浼版椂鏈鸿涓嬫枃銆岃瘎浼版椂鏈恒�嶈〃銆�  
+**Phase V 涓嶆尅 7a / 4b / 5銆�** 鐜扮綉涓�娆剧‖浠讹紱鎽勫儚澶寸珛椤瑰墠鍐嶅仛鎻℃墜瀛楁銆�
 
-**鍙苟琛岋細** Phase 5銆丳hase V 涓� Phase 4 鏃犳暟鎹緷璧栥�備骇鍝侀『搴忎粛鍏堝仛 4锛堣窡璇绘椂閽燂級銆�  
-**Phase 4b** 渚濊禆 Phase 4 鐨勫惉绐�/Instance 鍘熻锛屼笉渚濊禆 Phase 6銆傜姝负姝ゆ敼 C++銆傝瘎浼版椂鏈鸿涓嬫枃銆岃瘎浼版椂鏈恒�嶈〃銆�
+**Phase 6锛�** 鐩綍鏍戜笌 `ensure_app` 宸插湪 Phase 2 鎻愬墠钀藉湴銆傛湰闃舵鍋氭竻鍗� / DOWNLOAD / `resource_id`锛屽苟鍋� **鍦ㄧ嚎 Locale**銆備笉瑕侀噸鍋� mkdir銆�
 
-**Phase 6锛�** 鐩綍鏍戜笌 `ensure_app` 宸插湪 Phase 2 鎻愬墠钀藉湴銆傛湰闃舵鍋氭竻鍗� / DOWNLOAD / `resource_id`锛屽苟鍋� **鍦ㄧ嚎 Locale**锛坧hrase pack + 璇█瀵癸級銆備笉瑕侀噸鍋� mkdir锛屼篃涓嶈鎶婅矾寰勫啀鎼竴娆°��
-
-**Phase 6b锛�** 浠呭綋闇�瑕併�屾病缃戜篃鐢ㄥ痉璇�/鑻辫鍠婂垏鎹€�嶆椂鎵� OTA 绂荤嚎璇嶈〃銆傚痉璇瓑 **娌℃湁** 瀹樻柟 MultiNet 鐨勮绉嶏紝绂荤嚎鍏ュ彛淇濇寔 zh 鎴� en锛屽湪绾胯彍鍗曚粛鍙敤璇� locale銆�
+**Phase 6b锛�** 浠呭綋闇�瑕併�屾病缃戜篃鐢ㄥ痉璇�/鑻辫鍠婂垏鎹€�嶆椂鎵� OTA 绂荤嚎璇嶈〃銆傚痉璇瓑 **娌℃湁** 瀹樻柟 MultiNet 鐨勮绉嶏紝绂荤嚎鍏ュ彛淇濇寔 zh 鎴� en銆�
 
 **鏄庣‘鍚庣疆銆佷笉鎸″喕缁撳绉帮細**
 
 | Spec 鑳藉姏 | 鏈�鏃� Phase |
 |-----------|------------|
 | D11 `wait` 涓�绛� Skill | 4b 鍓嶅彲浠嶇敤 Server 寤舵椂锛涢渶瑕併�屾挱瀹屽仠 800ms 鍐嶅惉銆嶄笖涓嶆兂浜戠鐫℃椂鍐嶄笅鍥轰欢 |
-| D14 TASK_PAUSE/RESUME | 5 鎴� 7 瀹堕暱鎺у埗闇�瑕佹椂 |
-| D8 v1.1 鏈湴鏂囦欢涓婁紶 | 浼氳绾 / 寰嬪笀璇㈤棶浜у搧鍖栨椂 |
-| D9 闀挎寜閫�鍑� Task | 鍙笌 2 鐨勬寜閽幇鐘跺苟瀛橈紱姝e紡缁戝畾鏀� 7 鍓� |
+| D14 TASK_PAUSE/RESUME | 5 鎴� 7b 瀹堕暱鎺у埗闇�瑕佹椂 |
+| D8 v1.1 鏈湴鏂囦欢涓婁紶 | 浼氳绾 / 寰嬪笀璇㈤棶浜у搧鍖栨椂锛�7a 涓嶄緷璧� |
+| D9 闀挎寜閫�鍑� Task | 鍙笌鍙d护閫�鍑哄苟瀛橈紱姝e紡缁戝畾鏀� 7b |
 | D10 `indicate` 閫昏緫鎬� | 鏈夌┖鍐嶅仛锛涚姝㈠杩� DeviceState 杩佺Щ琛� |
 | 鏂� WS `type=command` | 浠呭綋 detect 鍓嶇紑涓嶅琛ㄨ揪姝ヨ繘锛�**涓嶅姞鏂拌涔�** |
-| 鍦ㄧ嚎 Locale / 璇█瀵� | Phase 6锛坧hrase pack + pair锛夛紱鎺у埗鍙� Phase 7 |
+| 鍦ㄧ嚎 Locale / 璇█瀵� | Phase 6锛坧hrase pack + pair锛夛紱鎺у埗鍙� Phase 7b |
 | 鎽勫儚澶� / 鏂颁紶鎰熷櫒 Skill | **Phase V 涔嬪悗**锛涙柊 SKU = 灏忔櫤 AI 2锛岀姝㈡贩杩� 1 鐨勫浐浠跺寘 |
+| 浜у搧浠i檯 hello 瀛楁 | **Phase V锛屾渶鍚庡仛**锛涚幇缃戜竴娆剧‖浠朵笉鎸� 7a |
 
 ---
 
@@ -200,11 +209,11 @@
 
 ---
 
-### Phase 4 鈥� 璺熻锛氭椂閽熸杩� + 鍚庤瘎浼帮紙瀹氶噺锛�
+### Phase 4 鈥� 璺熻锛氭竻鍗曞惊鐜� + 鍚庤瘎浼帮紙瀹氶噺锛�  鉁�
 
 1. **Objective**  
    **浠呰窡璇昏繖涓�绫�**锛氶瀹� MP3 搴忓垪 + 瀹氭椂褰曢煶銆傚垎鏋愬叏閮ㄥ悗缃�佸畾閲忋�佸厑璁稿け璐ャ�傝澶囦笉瀹h瀵归敊銆�  
-   **涓嶈**鎶婂�掕儗銆佹帴榫欏杩涙湰闃舵鐨勩�屽垎鏋愭案涓嶆尅鍠囧彮銆嶃��
+   **瀹屾垚浜嬪疄锛�** 瑙� `phase-4-eval-and-roadmap.md`銆傛杩涘湪**璁惧鎾斁鍒楄〃**锛堜竴娆� JSON 涓嬪彂锛夛紝涓嶆槸閫愬彞 MCP锛涘悗璇勪及浠嶅湪 Server銆�
 
 2. **Why Now**  
    璺熻/鎵撳崱褰曢煶鐨勪环鍊煎湪褰掓。涓庝簨鍚庣湅鎸囨爣銆傝嫢鎶婁簯绔瘮瀵圭粦鍦ㄥ彞闂达紝鍠囧彮浼氬崱銆�
@@ -212,30 +221,56 @@
 3. **Dependencies**  
    Phase 3 Instance锛汸hase 2 `duration_ms` 鍚獥銆傚師鏂囨潵鑷� txt 濂戠害锛�**涓嶈兘**鐢� `prompt_text` 璺緞銆�
 
-4. **Scope**  
-   ASR 涓� play next 瑙h�︼紱`listen_ratio` 1.2鈥�1.5锛涘悗鍙� `metrics[{method,value}]`銆傛棤 `passed`銆佹棤 retry銆佹棤褰撳満 TTS 鍒嗘暟銆�
+4. **Scope锛堝凡钀藉湴锛�**  
+   璁惧锛歚listen_ratio` 脳 澧欓挓鎾斁鏃堕暱锛涙竻鍗曠粨鏉� `playlist_completed`銆�  
+   Server锛欰SR 涓嶈繘 LLM锛沗metrics[{method,value}]`锛涙湰鍦� wav + 鍚屽悕 txt銆傛棤 `passed`銆佹棤褰撳満 TTS 鍒嗘暟銆�
 
 5. **Out of Scope**  
-   鍊掕儗/鎺ラ緳鐨勫強鏃跺弽棣堬紙4b锛夛紱Spec S8 鍐嶆挱鍚屼竴鍙ワ紱鍙婃牸绾夸骇鍝佸寲锛涢煶绱狅紱绉熸埛锛涘彛浠よ繘鍥轰欢銆�
+   鍊掕儗/鎺ラ緳鐨勫強鏃跺弽棣堬紙4b锛夛紱瀹堕暱 UI锛�**7a**锛夛紱S8 鍐嶆挱鍚屼竴鍙ワ紱闊崇礌锛涚鎴枫��
 
 6. **Client**  
-   闆舵敼鍔ㄣ��
-
-8. **Protocol**  
-   涓嬩竴鍙ュ彧鐢辨椂閽熻Е鍙戙�備笉鍔� `JUDGE`銆�
+   鏈� diff锛堟挱鏀惧垪琛� / 鍚獥锛夈�傜姝㈠啀鏀瑰洖銆屾瘡鍙� MCP銆嶃��
 
 13. **Acceptance**  
-   璇撮敊/涓嶈/API 鎸備簡浠嶆挱涓嬩竴鍙ャ�傝 `phase-4-test.md`銆�
+   璇撮敊/涓嶈/API 鎸備簡浠嶆挱涓嬩竴鍙ャ�傝闊冲洖褰掑凡鎸夐攣瀹氳矾寰勯獙鏀躲��
 
-璇︾粏鏂藉伐锛歚phase-4-plan.md`銆�
+璇︾粏鏂藉伐锛堝巻鍙诧級锛歚phase-4-plan.md`锛堝叾涓�屽浐浠堕浂 diff / Server 鏃堕挓銆嶅凡琚唬鐮佷慨姝o紝浠ヨ瘎浼版姤鍛婁负鍑嗭級銆�
 
 ---
 
-### Phase V 鈥� 浜у搧浠i檯 + 绔簯鐗堟湰鍖归厤
+### Phase 7a 鈥� 瀹堕暱浠诲姟涓嬪彂 + 鎵ц璁板綍  鈫� 涓嬩竴缂栫爜
 
-> 鐜扮姸缁撹锛�2026-09-02 鏍稿浠g爜锛夛細**鐜板湪娌℃湁銆屽皬鏅� AI 1 / 1-1 / 2銆嶈繖濂椾骇鍝佷唬闄咃紝涔熸病鏈夊鎴风涓� Server 鐨勫吋瀹圭煩闃点��**  
-> 宸叉湁鐨勫彧鏄�岃兘涓嶈兘鍗� bin銆嶅拰銆屾彙鎵嬮噷寮�娌″紑 mcp/aec銆嶃�傛憚鍍忓ご SKU 鎴栫牬鍧忔�� API 涔嬪墠蹇呴』琛ラ綈銆�  
-> 涓嶆尅 Phase 4 璺熻銆傛柦宸ュ崟浠ュ悗鍐� `phase-v-plan.md`銆�
+> 浜у搧锛氶�夊満鏅~鍙傛暟锛涘彲绔嬪嵆鎵ц锛堜复鏃讹紝鏈�楂樹紭鍏堬級鎴栧瓨涓哄懆鏈熻鍒掋�傝澶囧瓨鍏ㄨ〃锛孯TC 鍙啓鏈�杩戜竴娆°�傚厛涓嬪彂锛屽悗鐪嬭褰曪紱鏃犺閲� UI銆傚澶� **娉涘惉**锛堟棫绉板璇绘満锛�/ **璺熻**銆�  
+> 璇︾粏瑙� `phase-4-eval-and-roadmap.md` 搂5.2銆傛柦宸ュ崟锛歚phase-7a-plan.md`銆�
+
+1. **Objective**  
+   瀹堕暱鎸夊満鏅垱寤轰换鍔★紙娉涘惉/璺熻锛氳鏂囥�侀亶鏁般�佺粨鏉熷悗鍏虫満鎴栧洖瀵硅瘽锛涘弻璇細涓嫳/涓硶/涓棩鈥︼紱瀵硅瘽鍙甫鏃堕暱锛夈�傜珛鍗充笅鍙戦┈涓婅窇锛涜鍒掍换鍔″埌鐐硅窇銆傝蒋鍏虫満鍚庝粠鏈湴琛ㄦ寫涓嬩竴鏈�杩戞椂鍒诲啓鍏ュ敮涓�纭欢闂归挓銆侶istory锛氳窡璇诲彞鏂囨湰+褰曢煶锛涘璇濈被鑱婂ぉ銆備笉灞曠ず鐩镐技搴︺��
+
+2. **Why Now**  
+   鎶婅鍒� 1:1 濉炶繘 `ALARM_MAX_NUM=10` 鎾戜笉浣忋�屾瘡灏忔椂鏁寸偣鑱� 10 鍒嗛挓銆嶈繖绫诲懆鏈熴�侱S3231 鏈潵灏卞彧缂栫▼涓嬩竴娆°�傜幇缃戝敮涓� pending 浼氳瑕嗙洊锛涘凡寮�鏈哄埌鐐逛笉鎵ц锛涜鏂囧啓姝汇��
+
+3. **Dependencies**  
+   Phase 1 `START_APP_TASK` / 杞叧鏈� / Alarm1锛汸hase 3 Instance锛汸hase 4 鎾斁鍒楄〃銆備笉渚濊禆 4b/6/V銆�
+
+4. **Scope**  
+   7a-A 鏈湴 schedule 鍏ㄨ〃 + `recompute_next_alarm`锛�7a-B `ai_parent_task` + `SYNC_SCHEDULE` + 绔嬪嵆 START锛�7a-C 鍦烘櫙琛ㄥ崟锛�7a-D 鍘嗗彶銆�
+
+5. **Out of Scope**  
+   metrics锛涜鏂� DOWNLOAD锛涘�掕儗鍙g畻锛涙柊 WS type锛涙敼閿佸畾鐑矾寰勶紱鐢� 10 闄愬埗鏉℃暟銆�
+
+6. **Device**  
+   鍏佽 OTA锛氳鍒掓枃浠躲�佸彧鍐� Alarm1銆乨uration 澧欓挓銆乷n_complete銆傜姝㈡柊 DeviceState銆傝鏂囦笉杩涘浐浠躲��
+
+13. **Acceptance锛圖ispatch锛�**  
+   绔嬪嵆璺熻鍙墦鏂鍒掞紱鈮�15 鏉¤鍒掑惈 hourly 鍏ㄩ噺鍦ㄨ澶囥�佽姱鐗囧彧鏈夋渶杩戜竴娆★紱杞叧鏈哄敜閱掕繘姝g‘鍦烘櫙锛涚粨鏉熷悗閲嶇畻涓嬩竴闂归挓銆侶istory 涓嶆尅 Dispatch Done銆�
+
+---
+
+### Phase V 鈥� 浜у搧浠i檯 + 绔簯鐗堟湰鍖归厤  锛堟渶鍚庡仛锛�
+
+> **2026-09-18锛�** 鐜扮綉鍙湁涓�娆剧‖浠躲�傛湰闃舵 **涓嶆尅** 7a / 5 / 4b銆傛憚鍍忓ご SKU 鎴栫浜屾鏉垮瓙绔嬮」鍓嶅啀寮�宸ャ��  
+> 鐜扮姸缁撹锛�2026-09-02 鏍稿浠g爜锛屼粛鎴愮珛锛夛細**娌℃湁銆屽皬鏅� AI 1 / 1-1 / 2銆嶄唬闄咃紝涔熸病鏈夊吋瀹圭煩闃点��**  
+> 鏂藉伐鍗曚互鍚庡啓 `phase-v-plan.md`銆�
 
 1. **Objective**  
    鎶婁笁濂楃嫭绔嬬殑缂栧彿閽夋锛屽苟鍦ㄨ繛鎺ユ椂瀵逛笂锛�  
@@ -382,22 +417,22 @@
 
 鏂藉伐鍗曪細浠ュ悗鍐� `phase-4b-plan.md`锛堟湰鏂囦欢鍙拤鏃舵満宸紓锛夈��
 
-### Phase 5 鈥� Server Scheduler
+### Phase 5 鈥� Server Scheduler锛堣ˉ鏁戞壂鎻忥級
 
 1. **Objective**  
-   瀹堕暱銆屾瘡澶� 07:00 澶嶈銆嶆潈濞佸湪 Server銆傝澶� RTC + pending 鏄� D14 鎵ц鍣紝涓嶆槸鏃ュ巻鐪熺浉銆�
+   鏉冨▉浠诲姟琛ㄥ凡鍦� **7a-B `ai_parent_task`**銆傛湰闃舵琛ワ細璁惧宸插紑鏈轰絾鏈湴闂归挓婕忚Е鍙戞椂锛屼簯绔寜鍒嗛挓鍐嶆帹 `START_APP_TASK`銆俁TC 浠嶅彧璐熻矗銆屼笅涓�娆°�嶅敜閱掋��
 
 2. **Why Now**  
-   璁惧闂归挓鑳� Demo锛屼笉鑳藉璐� Instance銆�
+   7a 瀹屾垚鍚庛�屾瘡澶╁噯鏃躲�嶄粛鍙兘涓よ竟婕忎竴鍒�锛涗簯鎵弿鏄璐︼紝涓嶆槸鏃ュ巻鐪熺浉鐨勭浜屼唤鎷疯礉銆�
 
 3. **Dependencies**  
-   Phase 3锛汸hase 1 `SET_ALARM` / `START_APP_TASK`銆�
+   7a-B plan 琛紱Phase 1 `START_APP_TASK`銆�
 
 4. **Scope**  
-   Schedule 琛紱鍒扮偣 `TASK_START`锛圡QTT锛夛紱璺宠繃 mqtt_immediate銆�
+   鍒版湡鎵弿锛涘悓涓� plan+鍚屼竴鍒嗛挓骞傜瓑涓�娆★紱璺宠繃姝e湪璺戠殑 `mqtt_immediate`銆�
 
 5. **Out of Scope**  
-   瀹屾暣瀹堕暱 UI锛汥evice 涓婄殑澶嶆潅 Plan DSL銆�
+   鍐嶅仛涓�寮犲钩琛屾棩鍘嗭紱Device 涓婄殑澶嶆潅 Plan DSL銆傝嫢 7a J5 宸茶惤鍦帮紝鏈樁娈靛彲鏍囬儴鍒嗗畬鎴愩��
 
 8. **Protocol**  
    澶嶇敤宸叉湁 MQTT 鍛戒护锛屼笉鏂板绉嶇被銆侾AUSE/RESUME 鑻ュ仛锛屽繀椤绘槸 Spec D14锛屼笉鏄浜斿鍗忚銆�
@@ -445,19 +480,19 @@
 
 ---
 
-### Phase 7 鈥� Parent Task Management
+### Phase 7b 鈥� Parent Task Management锛堝畬鏁达級
 
 1. **Objective**  
-   灏忕▼搴忚皟鐢ㄥ凡鏈� Command锛氬嵆鏃� START銆佺湅 Instance/瀹氶噺缁撴灉銆佹敼 Schedule锛�**璁剧疆璁惧榛樿璇█涓庣炕璇�/鍙岃璇█瀵�**銆�
+   灏忕▼搴忓湪 7a 涔嬩笂锛氭敼 Schedule锛�**璁剧疆璁惧榛樿璇█涓庣炕璇�/鍙岃璇█瀵�**锛汸AUSE锛涘惉鍘熷0瀹屽杽銆�
 
 2. **Dependencies**  
-   Phase 5 + Phase 6锛汸hase 4 metrics 鍙�夈��6b 涓嶆槸鍓嶆彁锛氭帶鍒跺彴鏀� locale 鍚庯紝鍦ㄧ嚎绔嬪嵆鐢熸晥銆�
+   Phase 7a + Phase 5 + Phase 6銆�6b 涓嶆槸鍓嶆彁锛氭帶鍒跺彴鏀� locale 鍚庯紝鍦ㄧ嚎绔嬪嵆鐢熸晥銆�
 
 4. **Scope**  
-   UI銆侱9 闀挎寜閫�鍑鸿嫢鏈仛鍙竴骞讹紙浠嶆槸宸插垪鑳藉姏锛夈�傝瑷�瀵瑰彧鑳介�� App Manifest 澹版槑鐨� supported_pairs銆�
+   UI銆侱9 闀挎寜閫�鍑鸿嫢鏈仛鍙竴骞躲�傝瑷�瀵瑰彧鑳介�� App Manifest 澹版槑鐨� supported_pairs銆�
 
 5. **Out of Scope**  
-   寮�鍙戣�呭钩鍙般�佹敮浠樸�佸湪灏忕▼搴忛噷璁粌鍞ら啋璇嶃��
+   寮�鍙戣�呭钩鍙般�佹敮浠樸�佸湪灏忕▼搴忛噷璁粌鍞ら啋璇嶃�佺‖浠朵唬闄呯鐞嗐��
 
 ---
 
@@ -538,15 +573,20 @@
   Server  TaskSession JSON         鈹�鈹�HTTP upsert鈹�鈹�鈻�  manager-api Instance
   鏃犳柊 Device 鑳藉姏锛涜澶囦笉鎸佹湁 instance_id
 
-Phase 4
-  Server  浠呮椂閽熷埌鐐� PLAY_AUDIO 涓嬩竴鍙� 鈹�鈹�鈻� Device
+Phase 4  鉁�
+  Server  涓�娆� JSON playlist           鈹�鈹�MCP鈹�鈹�鈻� Device
+  Device  鏈湴鎾啋鍚獥鈫掍笅涓�鍙�
   Device  鍚祦锛堟梺璺� ASR/report锛�     鈹�鈹�鈻� Server 鍚庤瘎浼� metrics
+  Device  playlist_completed          鈹�鈹�detect鈹�鈹�鈻� Server 鍥� CHAT
   璺熻锛氬垎鏋愬け璐ヤ笉鎸′笅涓�鍙ワ紱鏃� JUDGE
 
-Phase V
-  Device  hello/OTA 甯� generation+revision+cap_set  鈹�鈹�鈻� Server 鐭╅樀
-  Server  浜ら泦涓氬姟 / 寮曞 OTA / 鎷掓湭鐭� SKU          鈹�鈹�鈻� Device
-  鏃у浐浠剁己瀛楁 = 灏忔櫤 AI 1 / cap.v1锛涗笉鍔犳柊 WS type
+Phase 7a
+  灏忕▼搴�  鍦烘櫙浠诲姟锛堢珛鍗�/璁″垝锛�         鈹�鈹�HTTP鈹�鈹�鈻� manager-api
+  绔嬪嵆    START_APP_TASK mqtt_immediate 鈹�鈹�MQTT鈹�鈹�鈻� Device
+  璁″垝    SYNC_SCHEDULE 鍏ㄨ〃           鈹�鈹�MQTT鈹�鈹�鈻� Device schedule.json
+  Device  鎸戞渶杩戜竴娆�                    鈹�鈹�Alarm1鈹�鈹�鈻� 鍞ら啋/鍒扮偣 StartAppTask
+  7a-D    GET instance / chat          鈹�鈹�oauth2鈹�鈹�鈻� 鏂囨湰+鍙�夊綍闊�
+  鏃犳柊 WS type锛涙棤 metrics UI
 
 Phase 4b
   Device  鍚祦                       鈹�鈹�鈻� Server ASR + parser锛坕nline锛岀煭瓒呮椂锛�
@@ -560,6 +600,11 @@
 Phase 6
   Server  FETCH                    鈹�鈹�DOWNLOAD鈹�鈹�鈻� Device SD
   Device  PLAY_AUDIO(resource)     鈹�鈹�event鈹�鈹�鈻� Server
+
+Phase V锛堟渶鍚庯級
+  Device  hello/OTA 甯� generation+revision+cap_set  鈹�鈹�鈻� Server 鐭╅樀
+  Server  浜ら泦涓氬姟 / 寮曞 OTA / 鎷掓湭鐭� SKU          鈹�鈹�鈻� Device
+  鏃у浐浠剁己瀛楁 = 褰撳墠杩欎竴娆� SKU锛涗笉鍔犳柊 WS type
 ```
 
 ---
@@ -569,13 +614,14 @@
 | 闀挎湡鐩爣 | Roadmap |
 |----------|---------|
 | Device 鑳藉姏鍐荤粨 | Freeze 鏂囨。 + Phase 2 琛ヤ簨浠�/閲囬泦 |
-| Task Runtime 鍦� Server | P2 Session 鈫� P3 Instance 鉁� 鈫� P4 璺熻鍚庤瘎浼� 鈫� P4b 娴佸唴璇勪及 |
+| Task Runtime 鍦� Server | P2 Session 鈫� P3 Instance 鉁� 鈫� P4 鍚庤瘎浼� 鉁� 鈫� P4b 娴佸唴璇勪及 |
 | 鏂� App 涓嶆敼 C++ | P4b 涓変釜浠h〃 Task 楠屾敹 |
-| 璺熻璇煶瀵硅薄 | P2 D8 v1 鈫� P3 鎸� step 鈫� P6 鏍囧噯闊� |
-| Scheduler | P5 / P7 |
+| 璺熻璇煶瀵硅薄 | P2 D8 v1 鈫� P3 鎸� step 鈫� P4 鏈湴 wav/txt 鈫� P6 鏍囧噯闊充笅杞� |
+| Scheduler | **P7a 浠诲姟鍏ㄨ〃 + 璁惧鍙啓涓嬩竴闂归挓**锛涗复鏃朵紭鍏堜簬璁″垝锛汸5 鍒扮偣琛ユ帹鍙�� |
 | App 鈮� Firmware | 璐┛锛汸6 璧勬簮銆丳8 娌欑 |
-| 璁惧榛樿璇█ / 璇█瀵� | P6 鍦ㄧ嚎 pack + pair锛汸6b 绂荤嚎 zh/en锛汸7 鎺у埗鍙帮紱P4b 缁ф壙 locale |
-| 浜у搧浠i檯 / 绔簯鍖归厤 | **Phase V**锛涙憚鍍忓ご SKU 涔嬪墠锛涚幇缃戠己瀛楁瑙嗕负 灏忔櫤 AI 1 |
+| 璁惧榛樿璇█ / 璇█瀵� | P6 鍦ㄧ嚎 pack + pair锛汸6b 绂荤嚎 zh/en锛汸7b 鎺у埗鍙帮紱P4b 缁ф壙 locale |
+| 浜у搧浠i檯 / 绔簯鍖归厤 | **Phase V锛屾渶鍚�**锛涚幇缃戜竴娆剧‖浠惰涓哄綋鍓� SKU |
+| 瀹堕暱鍙缁冧範 | **P7a 鍦烘櫙浠诲姟涓嬪彂 + 7a-D 鍘嗗彶锛堟棤鍒嗘暟锛�**锛涜瑷�瀵瑰凡浣滀负鍙岃鍙傛暟锛汸AUSE P7b |
 | 蹇冪悊闄即 / 涓绘寔浜� | 鏃犳柊纭欢锛涘鐢� listen/speak + Server Memory |
 | Subscription | P9 |
 
@@ -593,11 +639,12 @@
 - Phase 2 鎷嗗井鏈嶅姟 / 瀵硅薄瀛樺偍 / 鎯呯华鍒嗘瀽 / 涓村簥璇婃柇  
 - 涓�娆℃�у啓姝� Phase 5鈥�9 鐨勭被鍚嶄笌 REST 璺緞  
 - 鎶婁笟鍔� App 鍒囨崲璇嶅啓鍏ュ浐浠讹紙绯荤粺绾т粎 Chat + 绂荤嚎澶嶈 + 銆屽垏鎹㈡ā寮忋�嶆�诲叆鍙o級  
-- 鍦� Phase 4 寮曞叆绉熸埛 / `tenant_id`锛堟渶鏃╃紳鍦� Phase 6锛屼骇鍝佸寲 Phase 7锛�  
+- 鍦� Phase 4 寮曞叆绉熸埛 / `tenant_id`锛堟渶鏃╃紳鍦� Phase 6锛屼骇鍝佸寲 Phase 7b锛�  
 - 鍦� Phase 4 鍋氬璇█ / 鎶婂痉璇敜閱掑啓杩涘浐浠�  
 - 涓烘瘡绉嶇炕璇戣瑷�瀵规柊澧炰竴涓� `app_id` 鎴栨柊 WS type  
 - 鐢ㄤ竴涓暟瀛楀悓鏃惰〃绀轰骇鍝佷唬闄呫�佸浐浠� semver 鍜屽崗璁増鏈�  
 - 鎶婃憚鍍忓ご鍥轰欢鍖呮寜 semver 鎺ㄧ粰鏃犳憚鍍忓ご鐨勫皬鏅� AI 1  
-- 鍦� Phase 4 鍋氬吋瀹圭煩闃� / 鏀� hello 瀛楁锛堟斁鍒� Phase V锛�
+- 鍦� Phase 4 / 7a 鍋氬吋瀹圭煩闃� / 鏀� hello 瀛楁锛堟斁鍒� **Phase V锛屾渶鍚�**锛�
+- 鍦� 7a 涔嬪墠鍋氬�掕儗/鍙g畻/鎺ラ緳鎴栬鏂� DOWNLOAD 骞冲彴
 
-**涓嬩竴缂栫爜闃舵锛�** 鍙墦寮� `phase-4-plan.md`锛岄獙鏀跺鐓� `phase-4-test.md`銆傚浐浠跺喕缁撱�傝窡璇绘寜 MP3 搴忓垪瀹氭椂褰曢煶锛涘悗璇勪及鍙畾閲忋�佷笉鎸囨尌鍠囧彮銆侺ocale 绛夊埌 Phase 6銆傜浜戠増鏈尮閰嶇瓑鍒� **Phase V**锛堝彲涓� 4 骞惰璁捐锛屾憚鍍忓ご SKU 鍓嶅繀椤昏惤鍦帮級銆�
+**涓嬩竴缂栫爜闃舵锛�** 鍙墦寮� `phase-7a-plan.md`銆傝窡璇�/娉涘惉鐑矾寰勪笌娆㈣繋璇�/缈昏瘧/涓嫳閿佸畾銆傚厛 7a-A銆屽叏琛� + 鍙啓涓嬩竴娆¢椆閽熴�嶏紝涓嶈鍐嶅仛 10 妲戒竴瀵逛竴銆侶istory 涓� metrics 鍚庣疆銆�
diff --git a/xiaozhi-esp32-2.2.6/main/application.cc b/xiaozhi-esp32-2.2.6/main/application.cc
index f4cb306..ec1cdfb 100644
--- a/xiaozhi-esp32-2.2.6/main/application.cc
+++ b/xiaozhi-esp32-2.2.6/main/application.cc
@@ -16,6 +16,7 @@
 #include <cstring>
 #include <memory>
 #include <esp_log.h>
+#include <esp_heap_caps.h>
 #include <cJSON.h>
 #include <driver/gpio.h>
 #include <arpa/inet.h>
@@ -25,7 +26,6 @@
 
 extern "C" void SDCard_Pause_Play(void) __attribute__((weak));
 extern "C" void SDCard_Resume_Play(void) __attribute__((weak));
-extern "C" void SDCard_Stop_Play(void) __attribute__((weak));
 extern "C" bool SDCard_Is_Playing(void) __attribute__((weak));
 extern "C" bool SDCard_Is_Paused(void) __attribute__((weak));
 
@@ -37,6 +37,16 @@
     return SDCard_Is_Paused && SDCard_Is_Paused();
 }
 
+static bool ShouldHoldIdleForLessonWork() {
+    return Board_IsLessonSyncing() || Board_IsRepeaterTask() || Board_IsShadowingTask() ||
+           Board_IsWaitingStartPhrase();
+}
+
+static bool IsLessonDetectText(const std::string& text) {
+    return text.rfind("[lesson_sync]", 0) == 0 ||
+           text.rfind("[app_task]english_learning|", 0) == 0;
+}
+
 static void PauseSdCardMp3() {
     if (SDCard_Pause_Play) {
         SDCard_Pause_Play();
@@ -46,12 +56,6 @@
 static void ResumeSdCardMp3() {
     if (SDCard_Resume_Play) {
         SDCard_Resume_Play();
-    }
-}
-
-static void StopSdCardMp3() {
-    if (SDCard_Stop_Play) {
-        SDCard_Stop_Play();
     }
 }
 
@@ -240,6 +244,7 @@
 }
 
 void Application::Run() {
+    main_loop_task_ = xTaskGetCurrentTaskHandle();
     // Set the priority of the main task to 10
     vTaskPrioritySet(nullptr, 10);
 
@@ -331,6 +336,7 @@
             display->UpdateStatusBar();
             auto state = GetDeviceState();
             if (state == kDeviceStateConnecting || state == kDeviceStateSpeaking ||
+                state == kDeviceStateActivating ||
                 (state == kDeviceStateIdle && suppress_wake_until_listen_)) {
                 audio_service_.KeepCodecReadyForPlayback();
             }
@@ -341,10 +347,16 @@
                     if (greeting_listen_wait_ticks_ > 0) {
                         greeting_listen_wait_ticks_--;
                         if (greeting_listen_wait_ticks_ == 0) {
-                            ESP_LOGW(TAG, "greeting TTS missing, start listening");
-                            SetListeningMode(GetDefaultListeningMode());
+                            if (ShouldHoldIdleForLessonWork()) {
+                                ESP_LOGI(TAG, "skip greeting listen while lesson task");
+                            } else {
+                                ESP_LOGW(TAG, "greeting TTS missing, start listening");
+                                SetListeningMode(GetDefaultListeningMode());
+                            }
                         }
                     }
+                } else if (Board_IsLessonSyncing()) {
+                    boot_wake_restore_ticks_ = 0;
                 } else if (++boot_wake_restore_ticks_ >= 20) {
                     ESP_LOGW(TAG, "boot websocket not opened, restore wake word");
                     suppress_wake_until_listen_ = false;
@@ -358,7 +370,7 @@
                 speaking_started_us_ > 0) {
                 const int64_t wait_ms = (esp_timer_get_time() - speaking_started_us_) / 1000;
                 if (wait_ms >= 1000 && (wait_ms / 1000) <= 8) {
-                    ESP_LOGW(TAG, "TTS audio still missing after %lldms", (long long)wait_ms);
+                    ESP_LOGW(TAG, "TTS audio still missing after %dms", (int)wait_ms);
                 }
             }
             if (protocol_ && IsSdCardMp3Playing() && protocol_->IsAudioChannelConnected()) {
@@ -379,7 +391,33 @@
                     pending_detect_text_.clear();
                 }
             }
+            if (hold_idle_for_prompt_ && !pending_detect_text_.empty() &&
+                GetDeviceState() == kDeviceStateIdle &&
+                protocol_ && protocol_->IsAudioChannelConnected()) {
+                if (pending_detect_retry_ticks_ > 0) {
+                    pending_detect_retry_ticks_--;
+                } else if (pending_detect_retries_left_ > 0) {
+                    pending_detect_retries_left_--;
+                    pending_detect_retry_ticks_ = 2;
+                    ESP_LOGW(TAG, "retry app_task detect: %s left=%d",
+                             pending_detect_text_.c_str(), pending_detect_retries_left_);
+                    SendListenDetectNow(pending_detect_text_);
+                } else if (IsLessonDetectText(pending_detect_text_) || ShouldHoldIdleForLessonWork()) {
+                    ESP_LOGW(TAG, "lesson detect unanswered, keep idle: %s",
+                             pending_detect_text_.c_str());
+                    pending_detect_text_.clear();
+                    pending_detect_retries_left_ = 0;
+                    pending_detect_retry_ticks_ = 0;
+                } else {
+                    ESP_LOGW(TAG, "app_task detect unanswered, start listening");
+                    hold_idle_for_prompt_ = false;
+                    suppress_wake_until_listen_ = false;
+                    pending_detect_text_.clear();
+                    SetListeningMode(GetDefaultListeningMode());
+                }
+            }
 
+            Board_PollPendingLessonPlayback();
             // Print debug info every 10 seconds
             if (clock_ticks_ % 10 == 0) {
                 SystemInfo::PrintHeapStats();
@@ -621,6 +659,10 @@
             if (GetDeviceState() == kDeviceStateIdle) {
                 break;
             }
+            // 婵�娲昏姹傚け璐ユ椂鍐嶆挱涓�閬� 6 浣嶇爜锛岄伩鍏嶅彧鍦ㄥ紑鏈虹灛闂存挱涓�娆¤鍚紡銆�
+            if (ota_->HasActivationCode() && i < 9) {
+                ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage());
+            }
         }
     }
 }
@@ -666,10 +708,13 @@
             }
             if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                 tts_audio_expected_ = true;
+                if (!ShouldHoldIdleForLessonWork()) {
+                    hold_idle_for_prompt_ = false;
+                }
             }
 
             tts_response_started_ = true;
-            ESP_LOGI(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
+            ESP_LOGD(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
                      (unsigned)shared_packet->payload.size(),
                      tts_audio_expected_ ? 1 : 0,
                      tts_speaking_state_ready_ ? 1 : 0,
@@ -695,12 +740,10 @@
             ESP_LOGI(TAG, "Skip channel-closed teardown while opening/reusing websocket");
             return;
         }
-        // 璺熻/澶嶈杩樺湪鎾椂涓嶈鍒� LOW_POWER锛氬悗闈㈣ˉ鍙� playlist_completed 浼� handshake timeout銆�
-        if ((Board_IsShadowingTask() || Board_IsRepeaterTask()) && IsSdCardMp3Playing()) {
-            board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
-        } else {
-            board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
-        }
+        // 閫氶亾鍏虫帀鍚庝細绔嬪埢鍒� idle銆佸姞杞藉敜閱掕瘝銆傝繖鏃跺啀杩� MAX_MODEM锛�
+        // MQTT 閲嶈繛浠诲姟鍜� phy_track_pll 浼氭妸鍐呴儴 SRAM 鎵撶┛锛孍SP_ERROR_CHECK 鐩存帴閲嶅惎銆�
+        // 淇濇寔 PERFORMANCE锛岀瓑 PowerSaveTimer 瓒呮椂鍚庡啀鐪熸鏂綉寰呭懡銆�
+        board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
         Schedule([this]() {
             auto display = Board::GetInstance().GetDisplay();
             opening_audio_channel_ = false;
@@ -716,6 +759,7 @@
             tts_stop_pending_ = false;
             tts_stop_waiting_ = false;
             tts_idle_waiting_ = false;
+            tts_session_end_ = false;
             tts_speaking_state_ready_ = false;
             tts_activity_generation_++;
             audio_service_.ResetDecoder();
@@ -748,11 +792,18 @@
             if (strcmp(state->valuestring, "start") == 0) {
                 Schedule([this]() {
                     aborted_ = false;
+                    if (!ShouldHoldIdleForLessonWork()) {
+                        hold_idle_for_prompt_ = false;
+                    }
+                    pending_detect_text_.clear();
+                    pending_detect_retries_left_ = 0;
+                    pending_detect_retry_ticks_ = 0;
                     tts_audio_expected_ = true;
                     tts_response_started_ = false;
                     tts_audio_received_ = false;
                     tts_stop_pending_ = false;
                     tts_stop_waiting_ = false;
+                    tts_session_end_ = false;
                     tts_playback_empty_ticks_ = 0;
                     tts_stop_wait_ticks_ = 0;
                     tts_activity_generation_++;
@@ -766,9 +817,15 @@
                              Board::GetInstance().GetAudioCodec()->output_enabled() ? 1 : 0);
                 });
             } else if (strcmp(state->valuestring, "stop") == 0) {
-                Schedule([this]() {
-                    ESP_LOGI(TAG, "TTS stop started=%d received=%d",
-                             tts_response_started_, tts_audio_received_);
+                auto session_end_item = cJSON_GetObjectItem(root, "session_end");
+                const bool session_end = cJSON_IsTrue(session_end_item) ||
+                    (cJSON_IsNumber(session_end_item) && session_end_item->valuedouble != 0);
+                Schedule([this, session_end]() {
+                    ESP_LOGI(TAG, "TTS stop started=%d received=%d session_end=%d",
+                             tts_response_started_, tts_audio_received_, session_end ? 1 : 0);
+                    if (session_end) {
+                        tts_session_end_ = true;
+                    }
                     tts_stop_pending_ = true;
                     FinishTtsResponseAfterPlayback();
                 });
@@ -785,13 +842,22 @@
                         return;
                     }
                     Schedule([this, display, message = std::string(text->valuestring)]() {
+                        if (!ShouldHoldIdleForLessonWork()) {
+                            hold_idle_for_prompt_ = false;
+                        }
+                        pending_detect_text_.clear();
+                        pending_detect_retries_left_ = 0;
+                        pending_detect_retry_ticks_ = 0;
                         tts_audio_expected_ = true;
                         tts_response_started_ = true;
                         tts_activity_generation_++;
                         tts_playback_empty_ticks_ = 0;
                         audio_service_.SetAudioPowerInhibit(true);
                         audio_service_.KeepCodecReadyForPlayback();
-                        if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
+                        if (Board_OnStartPhraseSentence(message.c_str())) {
+                            resume_sdcard_mp3_after_tts_ = false;
+                            resume_sdcard_mp3_after_voice_ = false;
+                        } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                             PauseSdCardMp3();
                             resume_sdcard_mp3_after_tts_ = true;
                         }
@@ -890,6 +956,18 @@
     protocol_->Start();
 }
 
+static void WaitUntilActivationPlaybackIdle(AudioService& audio) {
+    audio.KeepCodecReadyForPlayback();
+    for (int i = 0; i < 150; ++i) {
+        if (audio.IsPlaybackQueueEmpty()) {
+            vTaskDelay(pdMS_TO_TICKS(150));
+            return;
+        }
+        audio.KeepCodecReadyForPlayback();
+        vTaskDelay(pdMS_TO_TICKS(100));
+    }
+}
+
 void Application::ShowActivationCode(const std::string& code, const std::string& message) {
     struct digit_sound {
         char digit;
@@ -908,14 +986,24 @@
         digit_sound{'9', Lang::Sounds::OGG_9}
     }};
 
+    ESP_LOGI(TAG, "Speaking activation code: %s", code.c_str());
+    audio_service_.KeepCodecReadyForPlayback();
+    // 鍔熸斁鍒氭墦寮�鏃跺墠鍑犵櫨姣甯告槸闈欓煶锛屽厛绋充綇鍐嶆挱锛岄伩鍏嶅彧鍚埌鎻愮ず闊冲惉涓嶅埌鏁板瓧銆�
+    vTaskDelay(pdMS_TO_TICKS(400));
+
     // This sentence uses 9KB of SRAM, so we need to wait for it to finish
     Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION);
+    WaitUntilActivationPlaybackIdle(audio_service_);
 
     for (const auto& digit : code) {
         auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(),
             [digit](const digit_sound& ds) { return ds.digit == digit; });
         if (it != digit_sounds.end()) {
+            ESP_LOGI(TAG, "Playing activation digit: %c", digit);
+            audio_service_.KeepCodecReadyForPlayback();
             audio_service_.PlaySound(it->sound);
+            WaitUntilActivationPlaybackIdle(audio_service_);
+            vTaskDelay(pdMS_TO_TICKS(250));
         }
     }
 }
@@ -966,6 +1054,87 @@
     return protocol_->SendWakeWordDetected(text);
 }
 
+void Application::HoldIdleForServerPrompt() {
+    hold_idle_for_prompt_ = true;
+    suppress_wake_until_listen_ = true;
+    auto state = GetDeviceState();
+    if (state == kDeviceStateListening) {
+        SetDeviceState(kDeviceStateIdle);
+    }
+}
+
+void Application::ReleaseHoldIdle() {
+    hold_idle_for_prompt_ = false;
+    suppress_wake_until_listen_ = false;
+    boot_wake_restore_ticks_ = 0;
+}
+
+void Application::StartListeningAfterPrompt() {
+    SetListeningMode(GetDefaultListeningMode());
+}
+
+void Application::ArmPendingDetectRetry(const std::string& text, int retries) {
+    if (text.empty() || retries <= 0) {
+        return;
+    }
+    pending_detect_text_ = text;
+    pending_detect_retries_left_ = retries;
+    pending_detect_retry_ticks_ = 2;
+}
+
+bool Application::IsAudioChannelConnected() const {
+    return protocol_ && protocol_->IsAudioChannelConnected();
+}
+
+void Application::AnnounceReturnedToChat() {
+    pending_detect_text_.clear();
+    pending_detect_retries_left_ = 0;
+    pending_detect_retry_ticks_ = 0;
+    suppress_wake_until_listen_ = false;
+    audio_service_.KeepCodecReadyForPlayback();
+    auto display = Board::GetInstance().GetDisplay();
+    display->SetStatus("瀵硅瘽妯″紡");
+    display->SetEmotion("happy");
+    display->SetChatMessage("assistant", "鎴戜滑鏉ヨ亰澶╁惂銆�");
+    audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
+    ESP_LOGI(TAG, "announced local return to chat");
+}
+
+void Application::ReconnectAfterLesson(const std::string& detect) {
+    // 璇炬枃缁撴潫 WS 宸叉柇锛氬厛閲婃斁 AFE锛屽啀鎻℃墜琛ュ彂 skill_event銆�
+    // Server 浼氭挱銆屾硾鍚�/璺熻缁撴潫銆傛垜浠潵鑱婂ぉ鍚с�傘�嶈瀹屽啀 listening銆�
+    pending_detect_text_.clear();
+    pending_detect_retries_left_ = 0;
+    pending_detect_retry_ticks_ = 0;
+    suppress_wake_until_listen_ = true;
+    audio_service_.SetAudioPowerInhibit(false);
+    audio_service_.KeepCodecReadyForPlayback();
+    audio_service_.EnableVoiceProcessing(false);
+    audio_service_.EnableWakeWordDetection(false);
+    audio_service_.ReleaseAfeRuntime();
+
+    auto state = GetDeviceState();
+    if (state == kDeviceStateListening || state == kDeviceStateSpeaking) {
+        SetDeviceState(kDeviceStateIdle);
+    }
+
+    if (detect.empty() || !protocol_) {
+        ESP_LOGW(TAG, "lesson reconnect skipped, stay local chat");
+        AnnounceReturnedToChat();
+        audio_service_.EnableWakeWordDetection(true);
+        return;
+    }
+
+    // 娉涘惉鏃堕暱浼氭妸 WiFi 寰呮満鍏虫帀锛堣窡璇绘湁鍚獥鎵�浠ヤ笉浼氾級銆備笉瑕佺敤 IsNetworkReady()
+    // 鐩存帴鏀惧純锛氬悗闈� OpenAudioChannel 浼� EnsureNetworkReadyForWake锛岃繛涓婂啀鎾粨鏉熻銆�
+    ESP_LOGI(TAG, "lesson reconnect for %s", detect.c_str());
+    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
+    SetDeviceState(kDeviceStateConnecting);
+    Schedule([this, detect]() {
+        ContinueOpenAudioChannelForDetect(detect);
+    });
+}
+
 void Application::SendListenDetect(const std::string& text, bool open_if_idle) {
     Schedule([this, text, open_if_idle]() {
         if (!protocol_ || text.empty()) {
@@ -977,6 +1146,7 @@
             Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
             protocol_->MarkIncomingActivity();
             if (protocol_->SendWakeWordDetected(text)) {
+                Board_NoteLessonStartDetectSent();
                 return;
             }
             ESP_LOGW(TAG, "detect send failed, reopen audio channel");
@@ -1089,6 +1259,18 @@
     // AFE 璧锋潵鍚庡唴閮ㄥ爢鍙墿鍑� KB锛寈TaskCreate(8KB) 浼氱洿鎺ュけ璐ャ��
     audio_service_.EnableVoiceProcessing(false);
     audio_service_.EnableWakeWordDetection(false);
+    const size_t dma_before = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+    ESP_LOGI(TAG, "OpenAudioChannel DMA largest=%u free=%u",
+             (unsigned)dma_before,
+             (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
+    if (dma_before < 8192) {
+        ESP_LOGW(TAG, "low DMA, release AFE before websocket");
+        audio_service_.ClearWakeWordBuffers();
+        audio_service_.ReleaseAfeRuntime();
+        vTaskDelay(pdMS_TO_TICKS(50));
+        ESP_LOGI(TAG, "OpenAudioChannel DMA after AFE release=%u",
+                 (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA));
+    }
     Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
     audio_service_.SetAudioPowerInhibit(true);
 
@@ -1136,25 +1318,45 @@
     listening_mode_ = GetDefaultListeningMode();
     suppress_wake_until_listen_ = true;
     if (!OpenAudioChannelWithRetry(2)) {
-        pending_detect_text_ = detect_text;
-        if (pending_detect_retries_left_ <= 0) {
-            pending_detect_retries_left_ = 3;
+        // 璇炬枃缁撴潫鍚庨�氶亾宸叉鏃朵笉瑕佸弽澶� handshake锛氫細鍗″湪銆岃繛鎺ヤ腑銆嶅苟鍏虫帀鍞ら啋璇嶃��
+        const bool skip_retry = detect_text.rfind("[skill_event]", 0) == 0;
+        if (skip_retry) {
+            pending_detect_text_.clear();
+            pending_detect_retries_left_ = 0;
+            pending_detect_retry_ticks_ = 0;
+        } else {
+            pending_detect_text_ = detect_text;
+            if (pending_detect_retries_left_ <= 0) {
+                pending_detect_retries_left_ = 3;
+            }
+            pending_detect_retry_ticks_ = 3;
         }
-        pending_detect_retry_ticks_ = 3;
         suppress_wake_until_listen_ = false;
-        audio_service_.EnableWakeWordDetection(true);
         SetDeviceState(kDeviceStateIdle);
-        ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
+        if (skip_retry) {
+            ESP_LOGW(TAG, "websocket failed after lesson, keep idle chat without retry");
+            AnnounceReturnedToChat();
+        } else {
+            ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
+        }
+        if (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) {
+            suppress_wake_until_listen_ = true;
+            audio_service_.EnableWakeWordDetection(false);
+        } else {
+            audio_service_.EnableWakeWordDetection(true);
+        }
         return;
     }
 
     pending_detect_text_.clear();
     pending_detect_retries_left_ = 0;
     pending_detect_retry_ticks_ = 0;
-    greeting_listen_wait_ticks_ = 8;
+    greeting_listen_wait_ticks_ = (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) ? 0 : 8;
     if (!detect_text.empty()) {
         protocol_->MarkIncomingActivity();
-        protocol_->SendWakeWordDetected(detect_text);
+        if (protocol_->SendWakeWordDetected(detect_text)) {
+            Board_NoteLessonStartDetectSent();
+        }
     }
     // 淇濇寔寰呭懡涓斾笉寮� AFE锛氭杩庤瘝浠� idle 杩� speaking銆傝瀹屽啀 listening 寮�楹︺��
     SetDeviceState(kDeviceStateIdle);
@@ -1185,9 +1387,20 @@
     if (!IsSdCardMp3Playing() || IsSdCardMp3Paused()) {
         return;
     }
-    // 闊抽鍦ㄦ挱锛氫笉涓婅褰曢煶/闂茶亰锛屽彧鐣欑绾垮彛浠わ紙閫�鍑哄璇�/璺熻銆佹垜浠亰澶╁惂锛夈��
+    // 闊抽鍦ㄦ挱锛氫笉涓婅褰曢煶/闂茶亰锛屽彧鐣欑绾垮彛浠わ紙閫�鍑烘硾鍚�/璺熻銆佹垜浠亰澶╁惂锛夈��
+    // 璇炬枃杩樺湪璇� SD 鏃剁姝㈡媺璧� AFE锛氫細鎶� SDMMC DMA 闇�瑕佺殑杩炵画鍐呴儴 SRAM 鍚冨厜锛宖open abort銆�
     audio_service_.EnableVoiceProcessing(false);
-    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
+    const size_t largest_internal = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
+    if (!SDCard_Mp3OfflineWakeAllowed() || largest_internal < 32768) {
+        audio_service_.EnableWakeWordDetection(false);
+        if (!SDCard_Mp3OfflineWakeAllowed()) {
+            ESP_LOGD(TAG, "offline wake deferred until MP3 cached");
+        } else {
+            ESP_LOGW(TAG, "skip offline wake, internal largest=%u", (unsigned)largest_internal);
+        }
+    } else {
+        audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
+    }
     if (GetDeviceState() != kDeviceStateListening) {
         return;
     }
@@ -1241,7 +1454,9 @@
 void Application::CloseShadowingListenWindow()
 {
     audio_service_.EnableVoiceProcessing(false);
-    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
+    // 涓嬩竴鍙ヨ繕瑕� fopen/瑙g爜锛屻�岃窡璇荤粨鏉熴�峊TS 杩樿 mbedtls AES銆傝繖閲屼笉瑕佺珛鍒绘媺璧�
+    // AFE 鍞ら啋璇嶏紝鍚﹀垯鍐呴儴 SRAM 鍙墿 1KB 杩炵画鍧楋紝SD DMA / SSL 閮戒細澶辫触銆�
+    audio_service_.EnableWakeWordDetection(false);
     if (protocol_ && protocol_->IsAudioChannelOpened()) {
         while (auto packet = audio_service_.PopPacketFromSendQueue()) {
             if (!protocol_->SendAudio(std::move(packet))) {
@@ -1326,7 +1541,21 @@
 
     if (state == kDeviceStateIdle) {
         PauseSdCardMp3ForVoice();
-        audio_service_.EncodeWakeWord();
+        size_t dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+        // 鍐嶈鍚庡敜閱掕瘝 AFE 杩樺崰鐫�鍐呴儴鍫嗭紝DMA 寰�寰�涓嶅缂栫爜 opus銆�
+        // 鏃ч�昏緫浼氳烦杩囩紪鐮佸嵈缁х画闃诲绛� opus锛屼富寰幆鍗℃锛�15s 鍚� codec 鎺夌數锛屽儚杞叧鏈恒��
+        if (dma_largest < 12288) {
+            ESP_LOGW(TAG, "low DMA before wake handshake, release AFE largest=%u",
+                     (unsigned)dma_largest);
+            audio_service_.ReleaseAfeRuntime();
+            dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
+        }
+        if (dma_largest >= 8192) {
+            audio_service_.EncodeWakeWord();
+        } else {
+            ESP_LOGW(TAG, "skip wake opus encode, DMA largest=%u", (unsigned)dma_largest);
+            audio_service_.AbortWakeWordEncode();
+        }
         auto wake_word = audio_service_.GetLastWakeWord();
 
         if (!protocol_->IsAudioChannelOpened()) {
@@ -1429,6 +1658,7 @@
                 tts_stop_pending_ = false;
                 tts_stop_waiting_ = false;
                 tts_idle_waiting_ = false;
+                tts_session_end_ = false;
                 tts_speaking_state_ready_ = false;
                 tts_activity_generation_++;
             }
@@ -1437,16 +1667,18 @@
             display->ClearChatMessages();  // Clear messages first
             display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
             audio_service_.EnableVoiceProcessing(false);
-            if (suppress_wake_until_listen_) {
+            // 鍐嶈鏂嚎浼� speaking鈫抣istening鈫抜dle锛歱rocessor AFE 娌¢噴鏀惧氨鎶婂敜閱掕瘝 AFE 鎷夎捣鏉ワ紝
+            // audio_detection 寤轰笉璧锋潵锛宖eed 鍒峰睆鐩村埌鍐呴儴 SRAM 鎵撶┛銆�
+            if (!suppress_wake_until_listen_) {
+                audio_service_.ReleaseVoiceProcessorRuntime();
+            }
+            if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
+                ApplyMp3CommandOnlyMic();
+            } else if (suppress_wake_until_listen_ || Board_IsLessonSyncing() ||
+                       Board_IsRepeaterTask() || Board_IsShadowingTask()) {
                 audio_service_.EnableWakeWordDetection(false);
                 audio_service_.SetAudioPowerInhibit(true);
                 audio_service_.KeepCodecReadyForPlayback();
-            } else if (Board_IsRepeaterTask()) {
-                audio_service_.EnableVoiceProcessing(false);
-                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
-                audio_service_.SetAudioPowerInhibit(false);
-            } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
-                ApplyMp3CommandOnlyMic();
             } else {
                 audio_service_.EnableWakeWordDetection(true);
                 audio_service_.SetAudioPowerInhibit(false);
@@ -1522,6 +1754,10 @@
             // Do nothing
             break;
     }
+}
+
+bool Application::IsOnMainLoop() const {
+    return main_loop_task_ != nullptr && xTaskGetCurrentTaskHandle() == main_loop_task_;
 }
 
 void Application::Schedule(std::function<void()>&& callback) {
@@ -1710,6 +1946,7 @@
         tts_stop_pending_ = false;
         tts_stop_waiting_ = false;
         tts_idle_waiting_ = false;
+        tts_session_end_ = false;
         tts_speaking_state_ready_ = false;
         tts_activity_generation_++;
 
@@ -1770,20 +2007,28 @@
 }
 
 void Application::FinishTtsResponse() {
+    const bool end_session = tts_session_end_;
+    const bool hold_idle = hold_idle_for_prompt_;
     tts_audio_expected_ = false;
     tts_response_started_ = false;
     tts_audio_received_ = false;
     tts_stop_pending_ = false;
     tts_stop_waiting_ = false;
     tts_idle_waiting_ = false;
+    tts_session_end_ = false;
     tts_speaking_state_ready_ = false;
     tts_playback_empty_ticks_ = 0;
     tts_stop_wait_ticks_ = 0;
     speaking_started_us_ = 0;
     audio_service_.SetAudioPowerInhibit(false);
-    suppress_wake_until_listen_ = false;
-    pending_detect_text_.clear();
-    pending_detect_retries_left_ = 0;
+    if (hold_idle || ShouldHoldIdleForLessonWork()) {
+        suppress_wake_until_listen_ = true;
+    } else {
+        suppress_wake_until_listen_ = false;
+        pending_detect_text_.clear();
+        pending_detect_retries_left_ = 0;
+        pending_detect_retry_ticks_ = 0;
+    }
     ResumeSdCardMp3AfterVoice();
     if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
         resume_sdcard_mp3_after_tts_ = false;
@@ -1792,8 +2037,12 @@
         resume_sdcard_mp3_after_tts_ = false;
     }
     if (GetDeviceState() == kDeviceStateSpeaking) {
-        if (listening_mode_ == kListeningModeManualStop ||
+        if (end_session || hold_idle || ShouldHoldIdleForLessonWork() ||
+            listening_mode_ == kListeningModeManualStop ||
             (IsSdCardMp3Playing() && !IsSdCardMp3Paused())) {
+            ESP_LOGI(TAG, "TTS done, return to idle session_end=%d hold=%d lesson=%d",
+                     end_session ? 1 : 0, hold_idle ? 1 : 0,
+                     ShouldHoldIdleForLessonWork() ? 1 : 0);
             SetDeviceState(kDeviceStateIdle);
         } else {
             listening_mode_ = GetDefaultListeningMode();
@@ -1803,6 +2052,7 @@
     if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
         ApplyMp3CommandOnlyMic();
     }
+    Board_FlushPendingLessonPlayback();
     if (power_off_requested_) {
         power_off_requested_ = false;
         Board_Enter_Power_Off();
@@ -1866,8 +2116,9 @@
         tts_stop_wait_ticks_++;
         const int empty_needed = tts_audio_received_ ? 1 : 2;
         if (playback_empty && tts_playback_empty_ticks_ >= empty_needed) {
-            ESP_LOGI(TAG, "TTS done, return to listen empty=%d wait=%d received=%d",
-                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_);
+            ESP_LOGI(TAG, "TTS done empty=%d wait=%d received=%d session_end=%d",
+                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_,
+                     tts_session_end_ ? 1 : 0);
             FinishTtsResponse();
         } else if (!playback_empty && tts_stop_wait_ticks_ >= 30) {
             ESP_LOGW(TAG, "TTS playback stuck after stop, return to listen");
@@ -1878,13 +2129,13 @@
 
     // 娆㈣繋璇�/闀垮洖绛旓細stop 娌″埌鏃讹紝鍙湪銆屼竴鐩存病鏀跺埌 opus銆嶆垨銆屾挱瀹屽悗闀挎椂闂寸┖闃熷垪銆嶆墠鍥炲惉銆�
     // 涓嶈鐢ㄨ璇濆閽� 15s/闃熷垪绌� 2s锛氫腑鑻卞璇濋暱鍙ヤ細鍦ㄥ彞闂寸┖闅欐垨 15 绉掑琚帎鏂��
-    if (!tts_audio_received_ && elapsed_ms >= 5000) {
-        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%lldms received=0 empty=%d",
-                 (long long)elapsed_ms, tts_playback_empty_ticks_);
+    if (!tts_audio_received_ && elapsed_ms >= 10000) {
+        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%dms received=0 empty=%d",
+                 (int)elapsed_ms, tts_playback_empty_ticks_);
         FinishTtsResponse();
     } else if (playback_empty && tts_audio_received_ && tts_playback_empty_ticks_ >= 8) {
-        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%lldms empty=%d",
-                 (long long)elapsed_ms, tts_playback_empty_ticks_);
+        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%dms empty=%d",
+                 (int)elapsed_ms, tts_playback_empty_ticks_);
         FinishTtsResponse();
     }
 }
@@ -1901,6 +2152,12 @@
         return false;
     }
 
+    // 娉涘惉鏄� idle 鎾� SD MP3锛孉udioService 闃熷垪鏄┖鐨勶紝浼氳褰撴垚鍙紤鐪犮��
+    // 璺熻姣忓彞鏈夊惉绐楁墍浠ヤ笉浼氳繘 WiFi 寰呮満锛涙硾鍚細鎶婄綉鍏虫帀锛岀粨鏉熻灏卞彂涓嶅嚭鍘汇��
+    if (IsSdCardMp3Playing() || Board_IsRepeaterTask() || Board_IsShadowingTask()) {
+        return false;
+    }
+
     if (!audio_service_.IsIdle()) {
         return false;
     }
diff --git a/xiaozhi-esp32-2.2.6/main/application.h b/xiaozhi-esp32-2.2.6/main/application.h
index 4713b56..a137543 100644
--- a/xiaozhi-esp32-2.2.6/main/application.h
+++ b/xiaozhi-esp32-2.2.6/main/application.h
@@ -106,6 +106,13 @@
     void StopListening();
     void SendListenDetect(const std::string& text, bool open_if_idle = true);
     bool SendListenDetectNow(const std::string& text);
+    void HoldIdleForServerPrompt();
+    void ReleaseHoldIdle();
+    void StartListeningAfterPrompt();
+    void ArmPendingDetectRetry(const std::string& text, int retries);
+    bool IsAudioChannelConnected() const;
+    void AnnounceReturnedToChat();
+    void ReconnectAfterLesson(const std::string& detect);
 
     void Reboot();
     void WakeWordInvoke(const std::string& wake_word);
@@ -127,6 +134,7 @@
     void PlaySound(const std::string_view& sound);
     AudioService& GetAudioService() { return audio_service_; }
     void ApplyMp3CommandOnlyMic();
+    bool IsOnMainLoop() const;
     void ResumeListeningAfterMp3Clip();
     void CloseShadowingListenWindow();
     
@@ -164,6 +172,7 @@
     bool sdcard_mp3_stop_requested_ = false;
     bool opening_audio_channel_ = false;
     bool suppress_wake_until_listen_ = false;
+    bool hold_idle_for_prompt_ = false;
     bool network_error_suppressed_ = false;
     std::string pending_detect_text_;
     int pending_detect_retry_ticks_ = 0;
@@ -176,6 +185,7 @@
     bool tts_stop_pending_ = false;
     bool tts_stop_waiting_ = false;
     bool tts_idle_waiting_ = false;
+    bool tts_session_end_ = false;
     bool tts_speaking_state_ready_ = false;
     uint32_t tts_activity_generation_ = 0;
     int tts_playback_empty_ticks_ = 0;
@@ -187,6 +197,7 @@
     int sleep_inhibit_count_ = 0;
     int clock_ticks_ = 0;
     TaskHandle_t activation_task_handle_ = nullptr;
+    TaskHandle_t main_loop_task_ = nullptr;
 
 
     // Event handlers
diff --git a/xiaozhi-esp32-2.2.6/main/audio/audio_processor.h b/xiaozhi-esp32-2.2.6/main/audio/audio_processor.h
index 543c7ae..36c7ff3 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/audio_processor.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/audio_processor.h
@@ -16,6 +16,8 @@
     virtual void Feed(std::vector<int16_t>&& data) = 0;
     virtual void Start() = 0;
     virtual void Stop() = 0;
+    virtual void ReleaseRuntime() {}
+    virtual bool EnsureRuntime() { return true; }
     virtual bool IsRunning() = 0;
     virtual void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) = 0;
     virtual void OnVadStateChange(std::function<void(bool speaking)> callback) = 0;
diff --git a/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc b/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
index 209f7fe..78fe720 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
@@ -1,4 +1,5 @@
 #include "audio_service.h"
+#include "processors/audio_debugger.h"
 #include <esp_log.h>
 #include <cstring>
 #include <chrono>
@@ -240,6 +241,9 @@
         if (audio_input_need_warmup_) {
             audio_input_need_warmup_ = false;
             vTaskDelay(pdMS_TO_TICKS(120));
+            if (xEventGroupGetBits(event_group_) & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
+                audio_processor_->Start();
+            }
             continue;
         }
 
@@ -538,6 +542,12 @@
     }
 }
 
+void AudioService::AbortWakeWordEncode() {
+    if (wake_word_) {
+        wake_word_->AbortWakeWordEncode();
+    }
+}
+
 const std::string& AudioService::GetLastWakeWord() const {
     return wake_word_->GetLastDetectedWakeWord();
 }
@@ -568,6 +578,9 @@
                 return;
             }
             wake_word_initialized_ = true;
+        } else if (!wake_word_->EnsureRuntime()) {
+            ESP_LOGE(TAG, "Failed to restore wake word runtime");
+            return;
         }
         // Reset input resampler to clear cached data from previous mode (e.g. AudioProcessor)
         // This prevents buffer overflow when switching between different feed sizes
@@ -585,13 +598,39 @@
     }
 }
 
+void AudioService::ClearWakeWordBuffers() {
+    if (wake_word_) {
+        wake_word_->ClearWakeBuffers();
+    }
+}
+
+void AudioService::ReleaseAfeRuntime() {
+    EnableVoiceProcessing(false);
+    EnableWakeWordDetection(false);
+    if (wake_word_) {
+        wake_word_->ReleaseRuntime();
+    }
+    ReleaseVoiceProcessorRuntime();
+}
+
+void AudioService::ReleaseVoiceProcessorRuntime() {
+    EnableVoiceProcessing(false);
+    if (audio_processor_initialized_ && audio_processor_) {
+        audio_processor_->ReleaseRuntime();
+    }
+}
+
 void AudioService::EnsureVoiceProcessorReady() {
-    if (audio_processor_initialized_ || audio_processor_ == nullptr) {
+    if (audio_processor_ == nullptr) {
         return;
     }
-    ESP_LOGI(TAG, "Preload voice processor");
-    audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
-    audio_processor_initialized_ = true;
+    if (!audio_processor_initialized_) {
+        ESP_LOGI(TAG, "Preload voice processor");
+        audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
+        audio_processor_initialized_ = true;
+    } else if (!audio_processor_->EnsureRuntime()) {
+        ESP_LOGE(TAG, "Failed to restore voice processor runtime");
+    }
 }
 
 void AudioService::EnableVoiceProcessing(bool enable) {
@@ -600,6 +639,9 @@
         if (!audio_processor_initialized_) {
             audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
             audio_processor_initialized_ = true;
+        } else if (!audio_processor_->EnsureRuntime()) {
+            ESP_LOGE(TAG, "Failed to restore voice processor runtime");
+            return;
         }
 
         /* We should make sure no audio is playing */
@@ -613,8 +655,8 @@
                 esp_ae_rate_cvt_reset(input_resampler_);
             }
         }
-        audio_processor_->Start();
         xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
+        // fetch 绛� input warmup 鍚庡啀 Start锛岄伩鍏嶅垰杩涜亞鍚氨 AFE empty / Error -1
     } else {
         audio_processor_->Stop();
         xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
diff --git a/xiaozhi-esp32-2.2.6/main/audio/audio_service.h b/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
index 3699578..05a216e 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
@@ -3,6 +3,7 @@
 
 #include <memory>
 #include <deque>
+#include <vector>
 #include <condition_variable>
 #include <chrono>
 #include <mutex>
@@ -20,7 +21,6 @@
 
 #include "audio_codec.h"
 #include "audio_processor.h"
-#include "processors/audio_debugger.h"
 #include "wake_word.h"
 #include "protocol.h"
 #include "ogg_demuxer.h"
@@ -103,6 +103,8 @@
     uint32_t playback_count = 0;
 };
 
+class AudioDebugger;
+
 class AudioService {
 public:
     AudioService();
@@ -112,6 +114,7 @@
     void Start();
     void Stop();
     void EncodeWakeWord();
+    void AbortWakeWordEncode();
     std::unique_ptr<AudioStreamPacket> PopWakeWordPacket();
     const std::string& GetLastWakeWord() const;
     bool IsVoiceDetected() const { return voice_detected_; }
@@ -124,6 +127,9 @@
 
     void EnableWakeWordDetection(bool enable);
     void EnableVoiceProcessing(bool enable);
+    void ReleaseAfeRuntime();
+    void ReleaseVoiceProcessorRuntime();
+    void ClearWakeWordBuffers();
     void EnsureVoiceProcessorReady();
     void EnableAudioTesting(bool enable);
     void EnableDeviceAec(bool enable);
diff --git a/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc b/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc
index 15e9cc5..b53816e 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.cc
@@ -12,13 +12,37 @@
 
 void AfeAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) {
     codec_ = codec;
+    models_list_ = models_list;
+    frame_duration_ms_ = frame_duration_ms;
     frame_samples_ = frame_duration_ms * 16000 / 1000;
 
     // Pre-allocate output buffer capacity
     output_buffer_.reserve(frame_samples_);
 
-    int ref_num = codec_->input_reference() ? 1 : 0;
+    if (!CreateAfe()) {
+        ESP_LOGE(TAG, "Failed to create AFE runtime");
+        return;
+    }
 
+    if (!processor_task_started_) {
+        xTaskCreate([](void* arg) {
+            auto this_ = (AfeAudioProcessor*)arg;
+            this_->AudioProcessorTask();
+            vTaskDelete(NULL);
+        }, "audio_communication", 4096, this, 3, NULL);
+        processor_task_started_ = true;
+    }
+}
+
+bool AfeAudioProcessor::CreateAfe() {
+    if (afe_data_ != nullptr) {
+        return true;
+    }
+    if (codec_ == nullptr) {
+        return false;
+    }
+
+    int ref_num = codec_->input_reference() ? 1 : 0;
     std::string input_format;
     for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
         input_format.push_back('M');
@@ -28,15 +52,15 @@
     }
 
     srmodel_list_t *models;
-    if (models_list == nullptr) {
+    if (models_list_ == nullptr) {
         models = esp_srmodel_init("model");
     } else {
-        models = models_list;
+        models = models_list_;
     }
 
     char* ns_model_name = esp_srmodel_filter(models, ESP_NSNET_PREFIX, NULL);
     char* vad_model_name = esp_srmodel_filter(models, ESP_VADN_PREFIX, NULL);
-    
+
     afe_config_t* afe_config = afe_config_init(input_format.c_str(), NULL, AFE_TYPE_VC, AFE_MODE_HIGH_PERF);
     afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF;
     afe_config->vad_mode = VAD_MODE_0;
@@ -66,12 +90,31 @@
 
     afe_iface_ = esp_afe_handle_from_config(afe_config);
     afe_data_ = afe_iface_->create_from_config(afe_config);
-    
-    xTaskCreate([](void* arg) {
-        auto this_ = (AfeAudioProcessor*)arg;
-        this_->AudioProcessorTask();
-        vTaskDelete(NULL);
-    }, "audio_communication", 4096, this, 3, NULL);
+    if (afe_data_ == nullptr) {
+        ESP_LOGE(TAG, "create_from_config failed");
+        return false;
+    }
+    ESP_LOGI(TAG, "AFE runtime created");
+    return true;
+}
+
+void AfeAudioProcessor::ReleaseRuntime() {
+    Stop();
+    if (afe_data_ == nullptr) {
+        return;
+    }
+    vTaskDelay(pdMS_TO_TICKS(120));
+    std::lock_guard<std::mutex> lock(input_buffer_mutex_);
+    if (afe_data_ != nullptr && afe_iface_ != nullptr) {
+        afe_iface_->destroy(afe_data_);
+        afe_data_ = nullptr;
+    }
+    input_buffer_.clear();
+    ESP_LOGI(TAG, "AFE runtime released");
+}
+
+bool AfeAudioProcessor::EnsureRuntime() {
+    return CreateAfe();
 }
 
 AfeAudioProcessor::~AfeAudioProcessor() {
@@ -133,21 +176,30 @@
 }
 
 void AfeAudioProcessor::AudioProcessorTask() {
-    auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
-    auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
-    ESP_LOGI(TAG, "Audio communication task started, feed size: %d fetch size: %d",
-        feed_size, fetch_size);
+    ESP_LOGI(TAG, "Audio communication task started");
 
     while (true) {
         xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY);
 
-        auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
+        esp_afe_sr_data_t* afe_data = nullptr;
+        const esp_afe_sr_iface_t* afe_iface = nullptr;
+        {
+            std::lock_guard<std::mutex> lock(input_buffer_mutex_);
+            afe_data = afe_data_;
+            afe_iface = afe_iface_;
+        }
+        if (afe_data == nullptr || afe_iface == nullptr) {
+            vTaskDelay(pdMS_TO_TICKS(20));
+            continue;
+        }
+
+        auto res = afe_iface->fetch_with_delay(afe_data, pdMS_TO_TICKS(80));
         if ((xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING) == 0) {
             continue;
         }
         if (res == nullptr || res->ret_value == ESP_FAIL) {
             if (res != nullptr) {
-                ESP_LOGI(TAG, "Error code: %d", res->ret_value);
+                ESP_LOGD(TAG, "Error code: %d", res->ret_value);
             }
             continue;
         }
@@ -187,6 +239,9 @@
 }
 
 void AfeAudioProcessor::EnableDeviceAec(bool enable) {
+    if (afe_data_ == nullptr || afe_iface_ == nullptr) {
+        return;
+    }
     if (enable) {
 #if CONFIG_USE_DEVICE_AEC
         afe_iface_->disable_vad(afe_data_);
diff --git a/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h b/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h
index fe2f025..e4ae6ea 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/processors/afe_audio_processor.h
@@ -23,6 +23,8 @@
     void Feed(std::vector<int16_t>&& data) override;
     void Start() override;
     void Stop() override;
+    void ReleaseRuntime() override;
+    bool EnsureRuntime() override;
     bool IsRunning() override;
     void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
     void OnVadStateChange(std::function<void(bool speaking)> callback) override;
@@ -36,12 +38,16 @@
     std::function<void(std::vector<int16_t>&& data)> output_callback_;
     std::function<void(bool speaking)> vad_state_change_callback_;
     AudioCodec* codec_ = nullptr;
+    srmodel_list_t* models_list_ = nullptr;
+    int frame_duration_ms_ = 0;
     int frame_samples_ = 0;
+    bool processor_task_started_ = false;
     bool is_speaking_ = false;
     std::vector<int16_t> input_buffer_;
     std::mutex input_buffer_mutex_;
     std::vector<int16_t> output_buffer_;
 
+    bool CreateAfe();
     void AudioProcessorTask();
 };
 
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
index a2daebc..b43ffe8 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
@@ -18,6 +18,10 @@
     virtual void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) = 0;
     virtual void Start() = 0;
     virtual void Stop() = 0;
+    virtual void ReleaseRuntime() {}
+    virtual bool EnsureRuntime() { return true; }
+    virtual void ClearWakeBuffers() {}
+    virtual void AbortWakeWordEncode() {}
     virtual size_t GetFeedSize() = 0;
     virtual void EncodeWakeWordData() = 0;
     virtual bool GetWakeWordOpus(std::vector<uint8_t>& opus) = 0;
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
index 3436fac..0cebc1b 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
@@ -1,9 +1,11 @@
 #include "afe_wake_word.h"
 #include "audio_service.h"
 #include "boards/sening-xiaozhi-s3/system.h"
+#include <esp_heap_caps.h>
 #include <esp_log.h>
 #include <esp_mn_models.h>
 #include <esp_mn_speech_commands.h>
+#include <chrono>
 #include <sstream>
 
 #define DETECTION_RUNNING_EVENT 1
@@ -43,7 +45,6 @@
 
 bool AfeWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) {
     codec_ = codec;
-    int ref_num = codec_->input_reference() ? 1 : 0;
 
     if (models_list == nullptr) {
         models_ = esp_srmodel_init("model");
@@ -73,6 +74,11 @@
     if (multinet_model_ == nullptr) {
         multinet_model_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, NULL);
     }
+    if (!EnsureDetectionTask()) {
+        ESP_LOGE(TAG, "Failed to start audio detection task");
+        return false;
+    }
+
     if (multinet_model_ != nullptr) {
         multinet_ = esp_mn_handle_from_name(multinet_model_);
         multinet_data_ = multinet_->create(multinet_model_, 3000);
@@ -92,6 +98,57 @@
         ESP_LOGW(TAG, "MultiNet model not found, offline command word disabled");
     }
 
+    if (!CreateAfe()) {
+        ESP_LOGE(TAG, "Failed to create AFE runtime");
+        return false;
+    }
+
+    return true;
+}
+
+bool AfeWakeWord::EnsureDetectionTask() {
+    if (detection_task_started_ && detection_task_handle_ != nullptr) {
+        return true;
+    }
+
+    const size_t stack_size = 8192;
+    if (detection_task_stack_ == nullptr) {
+        detection_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM);
+        if (detection_task_stack_ == nullptr) {
+            ESP_LOGE(TAG, "Failed to alloc audio detection stack");
+            return false;
+        }
+    }
+    if (detection_task_buffer_ == nullptr) {
+        detection_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL);
+        if (detection_task_buffer_ == nullptr) {
+            ESP_LOGE(TAG, "Failed to alloc audio detection TCB");
+            return false;
+        }
+    }
+
+    detection_task_handle_ = xTaskCreateStatic([](void* arg) {
+        auto this_ = (AfeWakeWord*)arg;
+        this_->AudioDetectionTask();
+        vTaskDelete(NULL);
+    }, "audio_detection", stack_size, this, 3, detection_task_stack_, detection_task_buffer_);
+    if (detection_task_handle_ == nullptr) {
+        ESP_LOGE(TAG, "Failed to create audio detection task");
+        return false;
+    }
+    detection_task_started_ = true;
+    return true;
+}
+
+bool AfeWakeWord::CreateAfe() {
+    if (afe_data_ != nullptr) {
+        return true;
+    }
+    if (codec_ == nullptr) {
+        return false;
+    }
+
+    int ref_num = codec_->input_reference() ? 1 : 0;
     std::string input_format;
     for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
         input_format.push_back('M');
@@ -105,17 +162,53 @@
     afe_config->afe_perferred_core = 1;
     afe_config->afe_perferred_priority = 1;
     afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
-    
+
     afe_iface_ = esp_afe_handle_from_config(afe_config);
     afe_data_ = afe_iface_->create_from_config(afe_config);
-
-    xTaskCreate([](void* arg) {
-        auto this_ = (AfeWakeWord*)arg;
-        this_->AudioDetectionTask();
-        vTaskDelete(NULL);
-    }, "audio_detection", 8192, this, 3, nullptr);
-
+    if (afe_data_ == nullptr) {
+        ESP_LOGE(TAG, "create_from_config failed");
+        return false;
+    }
+    ESP_LOGI(TAG, "AFE runtime created");
     return true;
+}
+
+void AfeWakeWord::ReleaseRuntime() {
+    Stop();
+    if (afe_data_ == nullptr) {
+        return;
+    }
+    // fetch 鐢ㄧ煭瓒呮椂锛屽仠妫�娴嬪悗鍐嶉攢姣侊紝閬垮厤鍜� AudioDetectionTask 鎶� afe_data_
+    vTaskDelay(pdMS_TO_TICKS(120));
+    {
+        std::lock_guard<std::mutex> lock(input_buffer_mutex_);
+        if (afe_data_ != nullptr && afe_iface_ != nullptr) {
+            afe_iface_->destroy(afe_data_);
+            afe_data_ = nullptr;
+        }
+        input_buffer_.clear();
+        multinet_input_buffer_.clear();
+    }
+    ClearWakeBuffers();
+    ESP_LOGI(TAG, "AFE runtime released");
+}
+
+bool AfeWakeWord::EnsureRuntime() {
+    return CreateAfe();
+}
+
+void AfeWakeWord::ClearWakeBuffers() {
+    std::lock_guard<std::mutex> lock(wake_word_mutex_);
+    wake_word_pcm_.clear();
+    wake_word_opus_.clear();
+}
+
+void AfeWakeWord::AbortWakeWordEncode() {
+    std::lock_guard<std::mutex> lock(wake_word_mutex_);
+    wake_word_pcm_.clear();
+    wake_word_opus_.clear();
+    wake_word_opus_.emplace_back();
+    wake_word_cv_.notify_all();
 }
 
 void AfeWakeWord::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
@@ -145,7 +238,7 @@
 }
 
 void AfeWakeWord::Feed(const std::vector<int16_t>& data) {
-    if (afe_data_ == nullptr) {
+    if (afe_data_ == nullptr || detection_task_handle_ == nullptr) {
         return;
     }
 
@@ -170,17 +263,29 @@
 }
 
 void AfeWakeWord::AudioDetectionTask() {
-    auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
-    auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
-    ESP_LOGI(TAG, "Audio detection task started, feed size: %d fetch size: %d",
-        feed_size, fetch_size);
+    ESP_LOGI(TAG, "Audio detection task started");
 
     while (true) {
         xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY);
 
-        auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
+        esp_afe_sr_data_t* afe_data = nullptr;
+        const esp_afe_sr_iface_t* afe_iface = nullptr;
+        {
+            std::lock_guard<std::mutex> lock(input_buffer_mutex_);
+            afe_data = afe_data_;
+            afe_iface = afe_iface_;
+        }
+        if (afe_data == nullptr || afe_iface == nullptr) {
+            vTaskDelay(pdMS_TO_TICKS(20));
+            continue;
+        }
+
+        auto res = afe_iface->fetch_with_delay(afe_data, pdMS_TO_TICKS(80));
+        if (!(xEventGroupGetBits(event_group_) & DETECTION_RUNNING_EVENT)) {
+            continue;
+        }
         if (res == nullptr || res->ret_value == ESP_FAIL) {
-            continue;;
+            continue;
         }
 
         // Store the wake word data for voice recognition, like who is speaking
@@ -241,8 +346,11 @@
 }
 
 void AfeWakeWord::StoreWakeWordData(const int16_t* data, size_t samples) {
-    // store audio data to wake_word_pcm_
-    wake_word_pcm_.emplace_back(std::vector<int16_t>(data, data + samples));
+    // 512 sample * 2B = 1KB锛岄粯璁や細杩涘唴閮� SRAM锛屾挱瀹屾湰鍦� MP3 鍚� DMA 鏈�澶у潡浼氳鍒囨垚 ~1KB銆�
+    std::vector<int16_t> chunk;
+    chunk.reserve(2048);
+    chunk.assign(data, data + samples);
+    wake_word_pcm_.push_back(std::move(chunk));
     // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
     while (wake_word_pcm_.size() > 2000 / 30) {
         wake_word_pcm_.pop_front();
@@ -330,13 +438,22 @@
         }
         vTaskDelete(NULL);
     }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
+    if (wake_word_encode_task_ == nullptr) {
+        ESP_LOGE(TAG, "Failed to create encode_wake_word task");
+        std::lock_guard<std::mutex> lock(wake_word_mutex_);
+        wake_word_opus_.push_back(std::vector<uint8_t>());
+        wake_word_cv_.notify_all();
+    }
 }
 
 bool AfeWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
     std::unique_lock<std::mutex> lock(wake_word_mutex_);
-    wake_word_cv_.wait(lock, [this]() {
+    if (!wake_word_cv_.wait_for(lock, std::chrono::milliseconds(2000), [this]() {
         return !wake_word_opus_.empty();
-    });
+    })) {
+        ESP_LOGW(TAG, "wake opus wait timeout");
+        return false;
+    }
     opus.swap(wake_word_opus_.front());
     wake_word_opus_.pop_front();
     return !opus.empty();
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
index 1e641ad..8c7f3d7 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
@@ -31,6 +31,10 @@
     void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
     void Start();
     void Stop();
+    void ReleaseRuntime() override;
+    bool EnsureRuntime() override;
+    void ClearWakeBuffers() override;
+    void AbortWakeWordEncode() override;
     size_t GetFeedSize();
     void EncodeWakeWordData();
     bool GetWakeWordOpus(std::vector<uint8_t>& opus);
@@ -57,11 +61,18 @@
     TaskHandle_t wake_word_encode_task_ = nullptr;
     StaticTask_t* wake_word_encode_task_buffer_ = nullptr;
     StackType_t* wake_word_encode_task_stack_ = nullptr;
+    TaskHandle_t detection_task_handle_ = nullptr;
+    StaticTask_t* detection_task_buffer_ = nullptr;
+    StackType_t* detection_task_stack_ = nullptr;
     std::deque<std::vector<int16_t>> wake_word_pcm_;
     std::deque<std::vector<uint8_t>> wake_word_opus_;
     std::mutex wake_word_mutex_;
     std::condition_variable wake_word_cv_;
 
+    bool detection_task_started_ = false;
+
+    bool CreateAfe();
+    bool EnsureDetectionTask();
     void StoreWakeWordData(const int16_t* data, size_t size);
     void AudioDetectionTask();
 };
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
index 7fb298f..b8f678f 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
@@ -9,6 +9,7 @@
 #include <esp_mn_models.h>
 #include <esp_mn_speech_commands.h>
 #include <cJSON.h>
+#include <chrono>
 
 #define TAG "CustomWakeWord"
 
@@ -250,12 +251,28 @@
 }
 
 void CustomWakeWord::StoreWakeWordData(const std::vector<int16_t>& data) {
-    // store audio data to wake_word_pcm_
-    wake_word_pcm_.push_back(data);
+    std::vector<int16_t> chunk;
+    chunk.reserve(2048);
+    chunk.assign(data.begin(), data.end());
+    wake_word_pcm_.push_back(std::move(chunk));
     // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
     while (wake_word_pcm_.size() > 2000 / 30) {
         wake_word_pcm_.pop_front();
     }
+}
+
+void CustomWakeWord::ClearWakeBuffers() {
+    std::lock_guard<std::mutex> lock(wake_word_mutex_);
+    wake_word_pcm_.clear();
+    wake_word_opus_.clear();
+}
+
+void CustomWakeWord::AbortWakeWordEncode() {
+    std::lock_guard<std::mutex> lock(wake_word_mutex_);
+    wake_word_pcm_.clear();
+    wake_word_opus_.clear();
+    wake_word_opus_.emplace_back();
+    wake_word_cv_.notify_all();
 }
 
 void CustomWakeWord::EncodeWakeWordData() {
@@ -333,13 +350,22 @@
         }
         vTaskDelete(NULL);
     }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
+    if (wake_word_encode_task_ == nullptr) {
+        ESP_LOGE(TAG, "Failed to create encode_wake_word task");
+        std::lock_guard<std::mutex> lock(wake_word_mutex_);
+        wake_word_opus_.push_back(std::vector<uint8_t>());
+        wake_word_cv_.notify_all();
+    }
 }
 
 bool CustomWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
     std::unique_lock<std::mutex> lock(wake_word_mutex_);
-    wake_word_cv_.wait(lock, [this]() {
+    if (!wake_word_cv_.wait_for(lock, std::chrono::milliseconds(2000), [this]() {
         return !wake_word_opus_.empty();
-    });
+    })) {
+        ESP_LOGW(TAG, "wake opus wait timeout");
+        return false;
+    }
     opus.swap(wake_word_opus_.front());
     wake_word_opus_.pop_front();
     return !opus.empty();
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
index c26c6f3..1c8fc91 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
@@ -28,6 +28,8 @@
     void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
     void Start();
     void Stop();
+    void ClearWakeBuffers() override;
+    void AbortWakeWordEncode() override;
     size_t GetFeedSize();
     void EncodeWakeWordData();
     bool GetWakeWordOpus(std::vector<uint8_t>& opus);
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
index df9e80e..bc248d6 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,4 +1,5 @@
 #include "app_task.h"
+#include "parent_schedule.h"
 
 #include "application.h"
 #include "board.h"
@@ -12,21 +13,45 @@
 #include <string.h>
 #include <stdio.h>
 #include <stdlib.h>
+#include <sys/stat.h>
+#include <dirent.h>
+#include <ctype.h>
+#include <string>
+#include <esp_heap_caps.h>
+#include <esp_timer.h>
 
 #define TAG "app_task"
 #define APP_TASK_NVS_NS "app_task"
 #define APP_TASK_NVS_KEY "pending"
 #define APP_TASK_NVS_LAST_REP "last_rep"
+#define APP_TASK_NVS_LAST_RPT "last_rpt"
+#define APP_TASK_NVS_LAST_SHD "last_shd"
 #define APP_TASK_JSON_MAX 768
 #define APP_TASK_ID_MAX 32
 #define APP_TASK_SOURCE_MAX 24
+#define LESSON_CLIP_NAME_MAX 16
+#define LESSON_CLIP_MAX 128
 
 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 char s_current_params[APP_TASK_JSON_MAX] = "{}";
 static int s_current_priority = 0;
 static bool s_finish_task_after_mp3 = true;
 static float s_shadowing_listen_ratio = 1.2f;
+static bool s_wait_start_phrase = false;
+static bool s_start_phrase_tts_begun = false;
+static bool s_start_detect_sent = false;
+static bool s_listen_after_phrase = false;
+static int64_t s_wait_start_us = 0;
+static std::string s_start_detect_text;
+
+void Board_TrySendLessonStartDetect(void);
+
+bool Board_IsWaitingStartPhrase(void)
+{
+    return s_wait_start_phrase;
+}
 
 typedef struct {
     const char *app_id;
@@ -63,6 +88,20 @@
 int Board_AppTask_CurrentPriority(void)
 {
     return s_current_priority;
+}
+
+static bool is_safe_task_id(const char *id)
+{
+    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
+        return false;
+    }
+    for (const char *p = id; *p; ++p) {
+        unsigned char ch = static_cast<unsigned char>(*p);
+        if (!isalnum(ch) && ch != '_' && ch != '-') {
+            return false;
+        }
+    }
+    return true;
 }
 
 static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
@@ -376,6 +415,328 @@
     return name[0] != '\0';
 }
 
+static const char *last_lesson_nvs_key(const char *task_id)
+{
+    if (task_id && strcmp(task_id, "shadowing") == 0) {
+        return APP_TASK_NVS_LAST_SHD;
+    }
+    if (task_id && strcmp(task_id, "repeater") == 0) {
+        return APP_TASK_NVS_LAST_RPT;
+    }
+    return NULL;
+}
+
+static const char *const k_last_lesson_keys[] = {
+    "lesson_id",
+    "lesson",
+    "lessonId",
+    "en_passes",
+    "enPasses",
+    "cn_repeat",
+    "cn_speed",
+    "language",
+    "lang",
+    "speed",
+    "on_complete",
+    "listen_ratio",
+    "sentence_count",
+    "org_id",
+    "orgId",
+    NULL,
+};
+
+static bool json_has_lesson_id(cJSON *root)
+{
+    if (!root) {
+        return false;
+    }
+    const cJSON *id = cJSON_GetObjectItem(root, "lesson_id");
+    if (!id) {
+        id = cJSON_GetObjectItem(root, "lessonId");
+    }
+    if (!id) {
+        id = cJSON_GetObjectItem(root, "lesson");
+    }
+    return cJSON_IsString(id) && id->valuestring && id->valuestring[0];
+}
+
+static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size)
+{
+    if (!out || out_size == 0) {
+        return false;
+    }
+    out[0] = '\0';
+    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+        return false;
+    }
+    cJSON *root = cJSON_Parse(params_json);
+    if (!root) {
+        return false;
+    }
+    cJSON *compact = cJSON_CreateObject();
+    if (!compact) {
+        cJSON_Delete(root);
+        return false;
+    }
+    for (int i = 0; k_last_lesson_keys[i]; ++i) {
+        cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]);
+        if (item) {
+            cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1));
+        }
+    }
+    bool ok = json_has_lesson_id(compact);
+    if (ok) {
+        const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id");
+        if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+            id = cJSON_GetObjectItem(compact, "lessonId");
+            if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+                id = cJSON_GetObjectItem(compact, "lesson");
+            }
+            if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) {
+                cJSON_AddStringToObject(compact, "lesson_id", id->valuestring);
+            }
+        }
+        char *printed = cJSON_PrintUnformatted(compact);
+        if (printed) {
+            strlcpy(out, printed, out_size);
+            ok = out[0] != '\0';
+            cJSON_free(printed);
+        } else {
+            ok = false;
+        }
+    }
+    cJSON_Delete(compact);
+    cJSON_Delete(root);
+    return ok;
+}
+
+static bool params_has_lesson_id(const char *params_json)
+{
+    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+        return false;
+    }
+    cJSON *root = cJSON_Parse(params_json);
+    if (!root) {
+        return false;
+    }
+    bool ok = json_has_lesson_id(root);
+    cJSON_Delete(root);
+    return ok;
+}
+
+static bool sd_business_file_ready(const char *business_path)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_path || business_path[0] == '\0') {
+        return false;
+    }
+    if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return false;
+    }
+    struct stat st;
+    if (stat(sd_path, &st) != 0) {
+        return false;
+    }
+    return S_ISREG(st.st_mode) && st.st_size > 0;
+}
+
+static const char *json_clip_name(cJSON *obj)
+{
+    if (!cJSON_IsObject(obj)) {
+        return NULL;
+    }
+    const char *keys[] = {"name", "path", "url"};
+    for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) {
+        cJSON *item = cJSON_GetObjectItem(obj, keys[i]);
+        if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
+            return item->valuestring;
+        }
+    }
+    return NULL;
+}
+
+static int mp3_name_order(const void *a, const void *b)
+{
+    int left = atoi(static_cast<const char *>(a));
+    int right = atoi(static_cast<const char *>(b));
+    if (left != right) {
+        return left - right;
+    }
+    return strcmp(static_cast<const char *>(a), static_cast<const char *>(b));
+}
+
+static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) {
+        return 0;
+    }
+    if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return 0;
+    }
+    DIR *dir = opendir(sd_path);
+    if (!dir) {
+        return 0;
+    }
+    int count = 0;
+    struct dirent *ent;
+    while ((ent = readdir(dir)) != NULL && count < cap) {
+        const char *name = ent->d_name;
+        size_t len = name ? strlen(name) : 0;
+        if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) {
+            continue;
+        }
+        strlcpy(names[count], name, LESSON_CLIP_NAME_MAX);
+        count++;
+    }
+    closedir(dir);
+    if (count > 1) {
+        qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order);
+    }
+    return count;
+}
+
+static const char *shadowing_lang_folder(const char *language)
+{
+    return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en";
+}
+
+static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language)
+{
+    int level = 0;
+    int story = 0;
+    if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return true;
+    }
+    char path[SDCARD_PATH_MAX_LEN];
+    if (task_id && strcmp(task_id, "shadowing") == 0) {
+        const char *lang = shadowing_lang_folder(language);
+        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s",
+                 level, story, lang);
+        char names[1][LESSON_CLIP_NAME_MAX];
+        return list_lesson_mp3s(path, names, 1) >= 1;
+    }
+    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+    char names[1][LESSON_CLIP_NAME_MAX];
+    if (list_lesson_mp3s(path, names, 1) < 1) {
+        return false;
+    }
+    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+    return list_lesson_mp3s(path, names, 1) >= 1;
+}
+
+static bool lesson_files_ready(const char *task_id, const char *params_json)
+{
+    if (!params_json || params_json[0] == '\0') {
+        return true;
+    }
+    cJSON *params = cJSON_Parse(params_json);
+    if (!params) {
+        return true;
+    }
+
+    cJSON *items = cJSON_GetObjectItem(params, "items");
+    bool checked = false;
+    if (cJSON_IsArray(items)) {
+        cJSON *it = NULL;
+        cJSON_ArrayForEach(it, items) {
+            const char *name = json_clip_name(it);
+            if (!name) {
+                continue;
+            }
+            checked = true;
+            if (!sd_business_file_ready(name)) {
+                cJSON_Delete(params);
+                return false;
+            }
+        }
+        if (checked) {
+            cJSON_Delete(params);
+            return true;
+        }
+    }
+
+    const char *single = json_clip_name(params);
+    if (single) {
+        bool ok = sd_business_file_ready(single);
+        cJSON_Delete(params);
+        return ok;
+    }
+
+    cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id");
+    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+        lesson = cJSON_GetObjectItem(params, "lesson");
+    }
+    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+        lesson = cJSON_GetObjectItem(params, "lessonId");
+    }
+    cJSON *lang = cJSON_GetObjectItem(params, "language");
+    if (!cJSON_IsString(lang) || !lang->valuestring) {
+        lang = cJSON_GetObjectItem(params, "lang");
+    }
+    bool ok = true;
+    if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') {
+        ok = probe_lesson_id_on_sd(task_id, lesson->valuestring,
+                                  cJSON_IsString(lang) ? lang->valuestring : NULL);
+    }
+    cJSON_Delete(params);
+    return ok;
+}
+
+static void show_lesson_sync_alert(void)
+{
+    Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+}
+
+bool Board_LessonFilesReady(const char *task_id, const char *params_json)
+{
+    return lesson_files_ready(task_id, params_json);
+}
+
+bool Board_IsLessonPlaybackActive(void)
+{
+    return s_wait_start_phrase || SDCard_Is_Playing();
+}
+
+void Board_RememberLastLessonTask(const char *task_id, const char *params_json)
+{
+    const char *key = last_lesson_nvs_key(task_id);
+    char compact[APP_TASK_JSON_MAX];
+    if (!key || !compact_last_lesson_json(params_json, compact, sizeof(compact))) {
+        return;
+    }
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) != ESP_OK) {
+        return;
+    }
+    if (nvs_set_str(handle, key, compact) == ESP_OK) {
+        nvs_commit(handle);
+        ESP_LOGI(TAG, "remember last %s lesson", task_id);
+    }
+    nvs_close(handle);
+}
+
+bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size)
+{
+    const char *key = last_lesson_nvs_key(task_id);
+    if (!key || !buf || buf_size == 0) {
+        return false;
+    }
+    buf[0] = '\0';
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
+        return false;
+    }
+    size_t length = buf_size;
+    esp_err_t err = nvs_get_str(handle, key, buf, &length);
+    nvs_close(handle);
+    if (err != ESP_OK || buf[0] == '\0' || !params_has_lesson_id(buf)) {
+        buf[0] = '\0';
+        return false;
+    }
+    return true;
+}
+
 esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
                                 const char *source, const char *alarm_id)
 {
@@ -417,66 +778,391 @@
     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 bool is_schedule_talk_task(const char *task_id)
+{
+    return task_id && (strcmp(task_id, "conversation") == 0 ||
+                       strcmp(task_id, "live_translate") == 0 ||
+                       strcmp(task_id, "bilingual_chat") == 0);
+}
+
+static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
+                          bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
+{
+    std::string params = s_current_params[0] ? s_current_params : "{}";
+    if (sd_ready && params.size() >= 2 && params.back() == '}') {
+        params.pop_back();
+        if (params.size() > 1) {
+            params += ',';
         }
+        params += "\"sd_ready\":true}";
+    }
+    std::string detect = "[app_task]";
+    detect += (app_id && app_id[0]) ? app_id : "chat";
+    detect += "|";
+    detect += (task_id && task_id[0]) ? task_id : "conversation";
+    detect += "|";
+    detect += (source && source[0]) ? source : "default_chat";
+    detect += "|";
+    detect += params;
+    const bool hold_for_phrase = sd_ready || wait_phrase;
+    if (hold_for_phrase) {
+        s_start_detect_text = detect;
+        s_start_detect_sent = false;
+    }
+    Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
+        auto& app = Application::GetInstance();
+        app.HoldIdleForServerPrompt();
+        if (hold_for_phrase) {
+            // 鍏虫満鎷夎捣鏃堕�氶亾鍙兘杩樻病鎵撳紑銆傝繛涓婂悗鍐嶄笂鎶ワ紝绛夈�屽紑濮媥x浠诲姟銆嶈瀹屽啀杩涘叆浠诲姟銆�
+            Board_TrySendLessonStartDetect();
+            return;
+        }
+        if (expect_prompt && app.GetDeviceState() == kDeviceStateSpeaking) {
+            app.AbortSpeaking(kAbortReasonNone);
+        }
+        if (app.SendListenDetectNow(detect)) {
+            if (expect_prompt) {
+                app.ArmPendingDetectRetry(detect, 5);
+            }
+            return;
+        }
+        app.SendListenDetect(detect, open_if_idle);
     });
 }
 
-static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
+static const char *json_lesson_id(cJSON *params)
 {
-    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);
+    if (!params) {
+        return NULL;
+    }
+    const cJSON *id = cJSON_GetObjectItem(params, "lesson_id");
+    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+        id = cJSON_GetObjectItem(params, "lessonId");
+    }
+    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+        id = cJSON_GetObjectItem(params, "lesson");
+    }
+    return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL;
+}
+
+static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap,
+                              const char *business, float speed, int repeat)
+{
+    if (!items || !n || *n >= cap || !business || business[0] == '\0') {
+        return false;
+    }
+    sdcard_mp3_playlist_item_t *item = &items[*n];
+    if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) {
+        ESP_LOGW(TAG, "lesson clip missing on sd: %s", business);
+        return false;
+    }
+    item->speed = SDCard_Clamp_MP3_Speed(speed);
+    item->repeat = repeat < 1 ? 1 : repeat;
+    if (item->repeat > 10) {
+        item->repeat = 10;
+    }
+    item->power_off_after = false;
+    (*n)++;
+    return true;
+}
+
+static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+    int level = 0;
+    int story = 0;
+    const char *lesson = json_lesson_id(params);
+    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return 0;
+    }
+    int cn_repeat = 1;
+    float cn_speed = 1.0f;
+    const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat");
+    if (cJSON_IsNumber(cn_r)) {
+        cn_repeat = cn_r->valueint;
+    }
+    const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed");
+    if (cJSON_IsNumber(cn_s)) {
+        cn_speed = static_cast<float>(cn_s->valuedouble);
+    }
+    char dir[SDCARD_PATH_MAX_LEN];
+    char path[SDCARD_PATH_MAX_LEN];
+    static char cn_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    static char en_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    int n = 0;
+    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+    int cn_n = list_lesson_mp3s(dir, cn_names, LESSON_CLIP_MAX);
+    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+    int en_n = list_lesson_mp3s(dir, en_names, LESSON_CLIP_MAX);
+    if (cn_n < 1 || en_n < 1) {
+        return 0;
+    }
+    for (int i = 0; i < cn_n; ++i) {
+        snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn/%.15s",
+                 level, story, cn_names[i]);
+        if (!add_playlist_item(items, &n, cap, path, cn_speed, cn_repeat)) {
+            return n > 0 ? n : 0;
+        }
+    }
+    const cJSON *passes = cJSON_GetObjectItem(params, "en_passes");
+    int en_added = 0;
+    if (cJSON_IsArray(passes)) {
+        cJSON *row = NULL;
+        cJSON_ArrayForEach(row, passes) {
+            if (en_added >= 8) {
+                break;
+            }
+            float speed = 1.0f;
+            int repeat = 1;
+            const cJSON *sp = cJSON_GetObjectItem(row, "speed");
+            const cJSON *rp = cJSON_GetObjectItem(row, "repeat");
+            if (cJSON_IsNumber(sp)) {
+                speed = static_cast<float>(sp->valuedouble);
+            }
+            if (cJSON_IsNumber(rp)) {
+                repeat = rp->valueint;
+            }
+            for (int i = 0; i < en_n; ++i) {
+                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+                         level, story, en_names[i]);
+                if (!add_playlist_item(items, &n, cap, path, speed, repeat)) {
+                    return n;
+                }
+            }
+            en_added++;
+        }
+    }
+    if (en_added == 0) {
+        const float speeds[] = {0.8f, 0.9f, 1.0f};
+        const int repeats[] = {1, 2, 5};
+        for (int pass = 0; pass < 3; ++pass) {
+            for (int i = 0; i < en_n; ++i) {
+                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+                         level, story, en_names[i]);
+                if (!add_playlist_item(items, &n, cap, path, speeds[pass], repeats[pass])) {
+                    return n;
+                }
+            }
+        }
+    }
+    return n;
+}
+
+static int build_shadowing_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+    int level = 0;
+    int story = 0;
+    const char *lesson = json_lesson_id(params);
+    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return 0;
+    }
+    char lang[3] = "en";
+    const cJSON *language = cJSON_GetObjectItem(params, "language");
+    if (!cJSON_IsString(language) || !language->valuestring) {
+        language = cJSON_GetObjectItem(params, "lang");
+    }
+    if (cJSON_IsString(language) && language->valuestring && strcasecmp(language->valuestring, "cn") == 0) {
+        memcpy(lang, "cn", 3);
+    }
+    float speed = 0.8f;
+    const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
+    if (cJSON_IsNumber(speed_item)) {
+        speed = static_cast<float>(speed_item->valuedouble);
+    }
+    char dir[SDCARD_PATH_MAX_LEN];
+    static char names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    snprintf(dir, sizeof(dir), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", level, story, lang);
+    int found = list_lesson_mp3s(dir, names, LESSON_CLIP_MAX);
+    int n = 0;
+    for (int i = 0; i < found && n < cap; ++i) {
+        char clip[LESSON_CLIP_NAME_MAX];
+        memcpy(clip, names[i], sizeof(clip));
+        clip[sizeof(clip) - 1] = '\0';
+        char path[SDCARD_PATH_MAX_LEN];
+        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s/%.15s",
+                 level, story, lang, clip);
+        if (!add_playlist_item(items, &n, cap, path, speed, 1)) {
+            break;
+        }
+    }
+    return n;
+}
+
+static bool start_lesson_playback_locally(const char *task_id, const char *params_json)
+{
+    cJSON *params = cJSON_Parse((params_json && params_json[0]) ? params_json : "{}");
+    if (!params) {
+        return false;
+    }
+    const bool shadowing = task_id && strcmp(task_id, "shadowing") == 0;
+    const int cap = LESSON_CLIP_MAX;
+    sdcard_mp3_playlist_item_t *items = static_cast<sdcard_mp3_playlist_item_t *>(
+        heap_caps_calloc(static_cast<size_t>(cap), sizeof(*items), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
+    if (!items) {
+        cJSON_Delete(params);
+        return false;
+    }
+    int n = shadowing ? build_shadowing_playlist(params, items, cap)
+                      : build_repeater_playlist(params, items, cap);
+    cJSON_Delete(params);
+    if (n < 1) {
+        heap_caps_free(items);
+        return false;
+    }
+    SDCard_PreparePlaybackAndWait();
+    Board_SetFinishTaskAfterMp3(false);
+    esp_err_t err = SDCard_Play_MP3_File_Playlist(items, n);
+    if (err == ESP_OK) {
+        ESP_LOGI(TAG, "local lesson playlist started clips=%d task=%s", n, task_id ? task_id : "");
+        Board_RememberRepeaterPlayback(items[0].path, items[0].speed, items[0].repeat, false);
+        SDCard_Show_Matching_JPG_For_MP3_Path(items[0].path);
+    } else {
+        ESP_LOGW(TAG, "local lesson playlist failed: %s", esp_err_to_name(err));
+    }
+    heap_caps_free(items);
+    return err == ESP_OK;
+}
+
+static bool is_lesson_start_phrase(const char *text)
+{
+    if (!text) {
+        return false;
+    }
+    return strstr(text, "寮�濮嬫硾鍚�") != NULL || strstr(text, "寮�濮嬭窡璇�") != NULL ||
+           strstr(text, "寮�濮嬬炕璇�") != NULL || strstr(text, "寮�濮嬩腑鑻�") != NULL ||
+           strstr(text, "寮�濮嬪璇�") != NULL;
+}
+
+bool Board_OnStartPhraseSentence(const char *text)
+{
+    if (!s_wait_start_phrase || !is_lesson_start_phrase(text)) {
+        return false;
+    }
+    s_start_phrase_tts_begun = true;
+    if (SDCard_Is_Playing()) {
+        ESP_LOGW(TAG, "stop early lesson mp3 until start phrase finishes");
+        SDCard_Stop_Play();
+    }
+    ESP_LOGI(TAG, "start phrase heard, wait until it finishes");
+    return true;
+}
+
+static void begin_scheduled_talk_after_phrase(void)
+{
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    s_listen_after_phrase = false;
+    auto& app = Application::GetInstance();
+    if (app.GetDeviceState() == kDeviceStateSpeaking) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = true;
+        s_listen_after_phrase = true;
+        return;
+    }
+    ESP_LOGI(TAG, "start phrase done, open listening");
+    app.ReleaseHoldIdle();
+    app.StartListeningAfterPrompt();
+}
+
+void Board_FlushPendingLessonPlayback(void)
+{
+    if (!s_wait_start_phrase || !s_start_phrase_tts_begun) {
+        return;
+    }
+    if (s_listen_after_phrase) {
+        begin_scheduled_talk_after_phrase();
+        return;
+    }
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    if (strcmp(s_current_task, "repeater") != 0 && strcmp(s_current_task, "shadowing") != 0) {
+        return;
+    }
+    if (Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = true;
+        return;
+    }
+    ESP_LOGI(TAG, "start phrase done, play lesson");
+    start_lesson_playback_locally(s_current_task, s_current_params);
+}
+
+void Board_NoteLessonStartDetectSent(void)
+{
+    if (!s_wait_start_phrase || s_start_detect_sent) {
+        return;
+    }
+    s_start_detect_sent = true;
+    s_wait_start_us = esp_timer_get_time();
+    ESP_LOGI(TAG, "start detect sent, wait for phrase");
+}
+
+void Board_TrySendLessonStartDetect(void)
+{
+    if (!s_wait_start_phrase || s_start_detect_sent || s_start_detect_text.empty()) {
+        return;
+    }
+    auto& app = Application::GetInstance();
+    auto state = app.GetDeviceState();
+    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+        return;
+    }
+    if (app.IsAudioChannelConnected()) {
+        if (app.SendListenDetectNow(s_start_detect_text)) {
+            Board_NoteLessonStartDetectSent();
+        }
+        return;
+    }
+    if (state == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) {
+        app.SendListenDetect(s_start_detect_text, true);
+    }
+}
+
+void Board_PollPendingLessonPlayback(void)
+{
+    if (!s_wait_start_phrase) {
+        return;
+    }
+    auto state = Application::GetInstance().GetDeviceState();
+    if (!s_start_phrase_tts_begun) {
+        if (!s_start_detect_sent) {
+            Board_TrySendLessonStartDetect();
+            return;
+        }
+        const int64_t elapsed_ms = (esp_timer_get_time() - s_wait_start_us) / 1000;
+        if (elapsed_ms >= 15000 && state != kDeviceStateSpeaking && state != kDeviceStateConnecting) {
+            if (s_listen_after_phrase) {
+                ESP_LOGW(TAG, "start phrase timeout, open listening");
+                s_start_phrase_tts_begun = true;
+                begin_scheduled_talk_after_phrase();
+            } else {
+                ESP_LOGW(TAG, "start phrase timeout, play lesson locally");
+                s_start_phrase_tts_begun = true;
+                Board_FlushPendingLessonPlayback();
+            }
+        }
+        return;
+    }
+    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+        return;
+    }
+    Board_FlushPendingLessonPlayback();
+}
+
+static void return_to_chat_locally(const char *reason, const char *detect)
+{
+    ESP_LOGW(TAG, "%s, return to chat locally", reason ? reason : "skill event not sent");
+    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);
+    Application::GetInstance().ReconnectAfterLesson(detect ? detect : "");
 }
 
 static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
@@ -516,6 +1202,16 @@
                 cJSON_Delete(params);
             }
         }
+        const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
+            (strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
+             strcmp(source, "schedule") == 0);
+        if (server_first) {
+            if (name[0]) {
+                Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
+            }
+            notify_server(app_id, task_id, source, true);
+            return;
+        }
         if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
             ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
             notify_server(app_id, task_id, source, true);
@@ -526,12 +1222,6 @@
         }
         speed = SDCard_Clamp_MP3_Speed(speed);
         Board_SetFinishTaskAfterMp3(true);
-        // 鏈夌綉鐨勮闊冲叆鍙o細鍏堣 Server 鎾姤杩涘満璇嶏紝闊抽鐢� MCP 鍦� TTS 缁撴潫鍚庡啀鎾��
-        if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
-            Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
-            notify_server(app_id, task_id, source, true);
-            return;
-        }
         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));
@@ -555,19 +1245,40 @@
         return ESP_ERR_INVALID_ARG;
     }
     const char *src = (source && source[0]) ? source : "mqtt_immediate";
-    const char *tid = (task_id && task_id[0]) ? task_id : NULL;
-    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 ? tid : "");
-        return ESP_ERR_NOT_FOUND;
+    char resolved_tid[APP_TASK_ID_MAX];
+    copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
+    const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
+    if (spec) {
+        copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
+    } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
+        ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
+        return ESP_ERR_INVALID_ARG;
+    } else {
+        ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
     }
-    tid = spec->task_id;
+    const char *tid = resolved_tid;
 
     int new_priority = Board_AppTask_SourcePriority(src);
-    if (new_priority < s_current_priority) {
+    if (strcmp(src, "mqtt_immediate") != 0 && 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;
+    }
+
+    if (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
+        strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
+        ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
+        return ESP_OK;
+    }
+
+    if (strcmp(src, "schedule") == 0 &&
+        (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
+        const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
+        if (!lesson_files_ready(tid, raw_params)) {
+            ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
+            show_lesson_sync_alert();
+            return ESP_ERR_NOT_FOUND;
+        }
     }
 
     if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
@@ -586,16 +1297,62 @@
     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");
+
+    char loaded_params[APP_TASK_JSON_MAX];
+    loaded_params[0] = '\0';
+    const char *raw = (params_json && params_json[0]) ? params_json : "{}";
+    const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
+    if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
+        if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
+            raw = loaded_params;
+            ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
+        }
+    }
+    if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
+        Board_RememberLastLessonTask(tid, s_current_params);
+    } else {
+        strlcpy(s_current_params, raw, sizeof(s_current_params));
+        if (lesson_task) {
+            Board_RememberLastLessonTask(tid, s_current_params);
+        }
+    }
+    const char *effective = s_current_params;
     s_current_priority = new_priority;
-    Board_SavePendingTask(app_id, tid, params_json, src, NULL);
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    s_listen_after_phrase = false;
+    Board_SavePendingTask(app_id, tid, effective, 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);
+    if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = false;
+        s_start_detect_sent = false;
+        s_listen_after_phrase = true;
+        s_wait_start_us = esp_timer_get_time();
+        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+        notify_server(app_id, tid, src, true, true, false, true);
+        return ESP_OK;
+    }
+    if (lesson_task && !lesson_files_ready(tid, effective)) {
+        ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
+        show_lesson_sync_alert();
+    } else if (lesson_task && lesson_files_ready(tid, effective)) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = false;
+        s_start_detect_sent = false;
+        s_wait_start_us = esp_timer_get_time();
+        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+        notify_server(app_id, tid, src, false, true, true);
+        return ESP_OK;
+    }
+    Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+    if (!spec || !spec->skills[0]) {
+        dispatch_skill(NULL, app_id, tid, effective, 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);
+        dispatch_skill(spec->skills[i], app_id, tid, effective, src);
     }
     return ESP_OK;
 }
@@ -614,7 +1371,7 @@
         copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
         const bool shadowing = strcmp(task_id, "shadowing") == 0;
         const bool repeater = strcmp(task_id, "repeater") == 0;
-        // 澶嶈娓呭崟鏄惁缁撴潫鐢� Server 鍐冲畾銆傝澶囦笉瑕佸湪姝ゅ啓 NVS 鍒囧洖 chat銆�
+        // WS 鍦ㄧ嚎鏃舵竻鍗曠粨鏉熺敱 Server 鍒囧洖 chat銆俉S 宸叉柇鍒欎笅闈㈡湰鍦板厹搴曪紝閬垮厤涓�鐩村仠鍦ㄦ硾鍚��
         const bool end_task = !shadowing && !repeater &&
             task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
 
@@ -632,15 +1389,26 @@
         const bool open_mic = !stopped && shadowing;
         // 鍚獥浠庤鏂囦竴鍋滃氨寮�濮嬨�備笉瑕佺瓑寮�楹�/AFE 鍒濆鍖栧悗鍐嶄笂鎶� completed銆�
         if (!Application::GetInstance().SendListenDetectNow(detect)) {
+            // WS 宸叉柇锛氶噴鏀� AFE 鍚庨噸杩烇紝鎶� completed 琛ュ彂缁� Server 鎾粨鏉熻銆�
+            if (repeater && !stopped) {
+                return_to_chat_locally("repeater completed not sent", detect);
+                Board_Schedule_OnNaturalEnd();
+                return;
+            }
             Application::GetInstance().SendListenDetect(detect, false);
         }
         if (open_mic) {
             Application::GetInstance().GetAudioService().ResetDecoder();
             Application::GetInstance().ResumeListeningAfterMp3Clip();
         } else if (repeater && !stopped) {
-            // 娓呭崟宸叉挱瀹岋紝椹笂瑕佹挱銆屽璇荤粨鏉熴�嶏細淇濇寔鍔熸斁锛屼笉瑕佸啀 ResetDecoder銆�
+            // 涓庤窡璇绘竻鍗曠粨鏉熺浉鍚岋細鍏� AFE/鍞ら啋锛岀瓑 Server 鎾�屾硾鍚粨鏉熴�嶅啀鍥炲璇濄��
+            Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+            Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
             Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
             Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+            Board_Schedule_OnNaturalEnd();
+        } else if (repeater && stopped) {
+            Board_Schedule_OnUserExit();
         } else if (!repeater) {
             Application::GetInstance().GetAudioService().ResetDecoder();
         }
@@ -682,48 +1450,36 @@
         snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
                  app_id, task_id);
         if (!Application::GetInstance().SendListenDetectNow(detect)) {
-            ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
-            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);
-            auto &app = Application::GetInstance();
-            app.GetAudioService().SetAudioPowerInhibit(false);
-            app.GetAudioService().EnableVoiceProcessing(false);
-            app.GetAudioService().EnableWakeWordDetection(true);
-            if (app.GetDeviceState() == kDeviceStateListening ||
-                app.GetDeviceState() == kDeviceStateConnecting) {
-                app.SetDeviceState(kDeviceStateIdle);
-            }
+            return_to_chat_locally("playlist_completed not sent", detect);
+            Board_Schedule_OnNaturalEnd();
             return;
         }
+        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
         Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
         Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+        Board_Schedule_OnNaturalEnd();
     });
 }
 
 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) {
+        Board_Schedule_Reload();
+        Board_Schedule_Recompute();
     }
-
-    const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
-    const bool offline = !Board::GetInstance().IsNetworkReady();
-    if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
-        ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
-                 app_id, task_id, source, offline ? 1 : 0);
-        Board_StartAppTask(app_id, task_id, params, source);
-        return;
+    const int window = 180;
+    if (Board_Schedule_IsDueNow(window)) {
+        char app_id[APP_TASK_ID_MAX];
+        char task_id[APP_TASK_ID_MAX];
+        char params[APP_TASK_JSON_MAX] = "{}";
+        if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
+                                       params, sizeof(params))) {
+            ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
+                     app_id, task_id, from_alarm_wake ? 1 : 0);
+            Board_StartAppTask(app_id, task_id, params, "schedule");
+            return;
+        }
     }
 
     Board_StartAppTask("chat", "conversation", "{}", "default_chat");
@@ -736,7 +1492,7 @@
         return;
     }
     if (!Board::GetInstance().IsNetworkReady()) {
-        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 澶嶈鏈� to play locally");
+        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 娉涘惉 to play locally");
         return;
     }
     Application::GetInstance().SendListenDetect("[voice_entry]", true);
@@ -754,6 +1510,19 @@
     }
     if (Board_Is_Chat_Return_Command(command)) {
         s_current_priority = 0;
+        auto &app = Application::GetInstance();
+        if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
+            // 璇炬枃缁撴潫宸叉湰鍦板洖 chat銆傞�氶亾宸叉柇鏃跺啀鍘� handshake 浼氬崱銆岃繛鎺ヤ腑銆嶅苟鍏虫帀鍞ら啋銆�
+            app.Schedule([]() {
+                auto &inner = Application::GetInstance();
+                inner.AnnounceReturnedToChat();
+                inner.GetAudioService().EnableWakeWordDetection(true);
+                if (inner.GetDeviceState() == kDeviceStateConnecting) {
+                    inner.SetDeviceState(kDeviceStateIdle);
+                }
+            });
+            return true;
+        }
         Board_StartAppTask("chat", "conversation", "{}", "voice");
         return true;
     }
@@ -766,7 +1535,7 @@
     }
     if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
         strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
-        strstr(command, "寮�濮嬪璇�") || strstr(command, "澶嶈鏈�")) {
+        strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "鎵撳紑娉涘惉")) {
         if (s_current_priority >= 2) {
             return true;
         }
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
index 991d6ed..e134fb2 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.h
@@ -11,6 +11,8 @@
 
 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);
+bool Board_LessonFilesReady(const char *task_id, const char *params_json);
+bool Board_IsLessonPlaybackActive(void);
 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);
@@ -22,12 +24,19 @@
 unsigned Board_ShadowingListenWindowMs(unsigned duration_ms);
 bool Board_IsShadowingTask(void);
 bool Board_IsRepeaterTask(void);
+bool Board_IsWaitingStartPhrase(void);
 void Board_BindTaskFromMediaPath(const char *path);
 void Board_SetActiveTask(const char *app_id, const char *task_id);
 int Board_AppTask_CurrentPriority(void);
 bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after);
 void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after);
+void Board_RememberLastLessonTask(const char *task_id, const char *params_json);
+bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size);
 void Board_SetFinishTaskAfterMp3(bool finish);
+void Board_FlushPendingLessonPlayback(void);
+void Board_PollPendingLessonPlayback(void);
+void Board_NoteLessonStartDetectSent(void);
+bool Board_OnStartPhraseSentence(const char *text);
 esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id);
 
 #ifdef __cplusplus
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
index ca97f2b..007bcaf 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
@@ -30,7 +30,7 @@
 
 typedef struct {
     int count;
-    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
+    sdcard_mp3_playlist_item_t items[];
 } mp3_player_task_arg_t;
 
 static TaskHandle_t mp3_player_task_handle;
@@ -41,6 +41,7 @@
 static volatile int mp3_repeat_left;
 static TickType_t s_mp3_audible_started;
 static SemaphoreHandle_t s_shadowing_listen_closed;
+static volatile bool s_mp3_offline_wake_ok;
 
 static int16_t *s_pcm_a = nullptr;
 static int16_t *s_pcm_b = nullptr;
@@ -143,23 +144,110 @@
     return !mp3_stop_requested;
 }
 
-static void wait_for_sd_dma(void)
+static bool wait_for_internal_sram(size_t need_bytes, int tries)
 {
-    for (int i = 0; i < 20; ++i) {
+    for (int i = 0; i < tries; ++i) {
         size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
-        if (largest >= 4096) {
-            return;
+        if (largest >= need_bytes) {
+            return true;
         }
-        ESP_LOGW(TAG, "wait SD DMA largest=%u try=%d", (unsigned)largest, i);
+        ESP_LOGW(TAG, "wait SD DMA largest=%u need=%u try=%d",
+                 (unsigned)largest, (unsigned)need_bytes, i);
         vTaskDelay(pdMS_TO_TICKS(40));
     }
+    return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA) >= need_bytes;
+}
+
+static bool sd_internal_heap_ok_for_fopen(void)
+{
+    return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA) >= 4096 &&
+           heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) >= 2048;
+}
+
+static FILE *fopen_mp3_if_heap_ok(const char *path)
+{
+    wait_for_internal_sram(4096, 25);
+    if (!sd_internal_heap_ok_for_fopen()) {
+        ESP_LOGE(TAG, "refuse fopen, internal DMA heap too small: %s", path);
+        return nullptr;
+    }
+    return fopen(path, "rb");
+}
+
+static void ensure_shadowing_listen_sem(void);
+
+static void close_mp3_decode_resources(esp_audio_simple_dec_handle_t *decoder,
+                                       esp_ae_rate_cvt_handle_t *rate_cvt,
+                                       FILE **fp, uint8_t **file_buf)
+{
+    if (rate_cvt && *rate_cvt) {
+        esp_ae_rate_cvt_close(*rate_cvt);
+        *rate_cvt = nullptr;
+    }
+    if (decoder && *decoder) {
+        esp_audio_simple_dec_close(*decoder);
+        *decoder = nullptr;
+    }
+    if (fp && *fp) {
+        fclose(*fp);
+        *fp = nullptr;
+    }
+    if (file_buf && *file_buf) {
+        heap_caps_free(*file_buf);
+        *file_buf = nullptr;
+    }
+}
+
+static void apply_stop_afe_for_sd(bool leave_listening)
+{
+    auto &app = Application::GetInstance();
+    if (leave_listening) {
+        app.HoldIdleForServerPrompt();
+        if (app.GetDeviceState() == kDeviceStateSpeaking) {
+            app.AbortSpeaking(kAbortReasonNone);
+        }
+        if (app.GetDeviceState() == kDeviceStateListening) {
+            app.SetDeviceState(kDeviceStateIdle);
+        }
+    }
+    auto &audio = app.GetAudioService();
+    audio.EnableVoiceProcessing(false);
+    audio.EnableWakeWordDetection(false);
+    audio.ReleaseAfeRuntime();
+}
+
+static void stop_afe_for_sd_and_wait(bool leave_listening)
+{
+    s_mp3_offline_wake_ok = false;
+    auto &app = Application::GetInstance();
+    if (app.IsOnMainLoop()) {
+        apply_stop_afe_for_sd(leave_listening);
+        wait_for_internal_sram(4096, 40);
+        return;
+    }
+    ensure_shadowing_listen_sem();
+    xSemaphoreTake(s_shadowing_listen_closed, 0);
+    app.Schedule([leave_listening]() {
+        apply_stop_afe_for_sd(leave_listening);
+        if (s_shadowing_listen_closed != nullptr) {
+            xSemaphoreGive(s_shadowing_listen_closed);
+        }
+    });
+    if (xSemaphoreTake(s_shadowing_listen_closed, pdMS_TO_TICKS(2500)) != pdTRUE) {
+        ESP_LOGW(TAG, "pause wake for SD timeout");
+    }
+    wait_for_internal_sram(4096, 40);
+}
+
+static void pause_wake_for_sd(void)
+{
+    stop_afe_for_sd_and_wait(false);
 }
 
 static uint8_t *load_mp3_to_psram(const char *path, size_t *out_len)
 {
     *out_len = 0;
-    wait_for_sd_dma();
-    FILE *fp = fopen(path, "rb");
+    FILE *fp = fopen_mp3_if_heap_ok(path);
     if (!fp) {
         return nullptr;
     }
@@ -274,16 +362,6 @@
     return true;
 }
 
-static void pause_wake_for_sd(void)
-{
-    Application::GetInstance().Schedule([]() {
-        auto &audio = Application::GetInstance().GetAudioService();
-        audio.EnableVoiceProcessing(false);
-        audio.EnableWakeWordDetection(false);
-    });
-    vTaskDelay(pdMS_TO_TICKS(40));
-}
-
 static void wait_shadowing_listen(unsigned duration_ms)
 {
     unsigned wait_ms = Board_ShadowingListenWindowMs(duration_ms);
@@ -322,6 +400,7 @@
     if (xSemaphoreTake(s_shadowing_listen_closed, pdMS_TO_TICKS(2500)) != pdTRUE) {
         ESP_LOGW(TAG, "shadowing wait mic off timeout, force AFE stop");
         Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
     }
 }
 
@@ -329,26 +408,26 @@
 {
     mp3_player_task_handle = xTaskGetCurrentTaskHandle();
     mp3_player_task_arg_t *task_arg = static_cast<mp3_player_task_arg_t *>(arg);
-    sdcard_mp3_playlist_item_t items[SDCARD_MP3_PLAYLIST_MAX];
-    int count = task_arg->count;
+    int count = task_arg ? task_arg->count : 0;
     if (count < 0) {
         count = 0;
     }
     if (count > SDCARD_MP3_PLAYLIST_MAX) {
         count = SDCARD_MP3_PLAYLIST_MAX;
     }
-    if (count > 0) {
-        memcpy(items, task_arg->items, sizeof(items[0]) * static_cast<size_t>(count));
-    }
-    bool power_off_after = count > 0 ? items[count - 1].power_off_after : false;
-    heap_caps_free(task_arg);
+    const sdcard_mp3_playlist_item_t *items = (task_arg && count > 0) ? task_arg->items : nullptr;
+    bool power_off_after = (items != nullptr) ? items[count - 1].power_off_after : false;
 
     auto codec = Board::GetInstance().GetAudioCodec();
-    if (!codec || count < 1) {
+    if (!codec || count < 1 || items == nullptr) {
         ESP_LOGE(TAG, "audio codec unavailable or empty playlist");
+        if (task_arg) {
+            heap_caps_free(task_arg);
+        }
         mp3_player_task_handle = nullptr;
         mp3_stop_requested = false;
         mp3_repeat_left = 0;
+        s_mp3_offline_wake_ok = false;
         Board_OnSkillPlaybackFinished(true, false, 0);
         vTaskDelete(nullptr);
         return;
@@ -371,30 +450,38 @@
         ESP_LOGE(TAG, "no memory for MP3 playback");
     }
 
+    s_mp3_offline_wake_ok = false;
     for (int item_i = 0; in_buf && out_buf && item_i < count && !mp3_stop_requested; ++item_i) {
     strlcpy(path, items[item_i].path, sizeof(path));
     float speed = SDCard_Clamp_MP3_Speed(items[item_i].speed);
     int repeat = items[item_i].repeat < 1 ? 1 : items[item_i].repeat;
-    if (item_i > 0) {
-        pause_wake_for_sd();
-    }
+    pause_wake_for_sd();
 
     size_t file_len = 0;
     uint8_t *file_buf = load_mp3_to_psram(path, &file_len);
+    if (!file_buf) {
+        pause_wake_for_sd();
+        file_buf = load_mp3_to_psram(path, &file_len);
+    }
     FILE *fp = nullptr;
     if (!file_buf) {
-        wait_for_sd_dma();
-        fp = fopen(path, "rb");
+        fp = fopen_mp3_if_heap_ok(path);
     }
     if (!file_buf && !fp) {
         ESP_LOGE(TAG, "open MP3 failed: %s", path);
         continue;
     }
 
-    if (!mp3_pause_requested) {
-        Application::GetInstance().Schedule([]() {
-            Application::GetInstance().ApplyMp3CommandOnlyMic();
-        });
+    // 鍏堟妸鏂囦欢璇昏繘 PSRAM锛屽啀寮�绂荤嚎鍙d护鍞ら啋銆傚弽杩囨潵浼氭妸 SD DMA 闇�瑕佺殑杩炵画鍐呴儴 SRAM 鍚冩帀銆�
+    if (file_buf) {
+        s_mp3_offline_wake_ok = true;
+        if (!mp3_pause_requested) {
+            Application::GetInstance().Schedule([]() {
+                Application::GetInstance().ApplyMp3CommandOnlyMic();
+            });
+        }
+    } else {
+        s_mp3_offline_wake_ok = false;
     }
 
     esp_audio_simple_dec_handle_t decoder = nullptr;
@@ -442,8 +529,7 @@
                         fp = nullptr;
                     }
                     vTaskDelay(pdMS_TO_TICKS(30));
-                    wait_for_sd_dma();
-                    fp = fopen(path, "rb");
+                    fp = fopen_mp3_if_heap_ok(path);
                     if (!fp) {
                         ESP_LOGE(TAG, "reopen MP3 failed: %s", path);
                         break;
@@ -578,6 +664,11 @@
                 unsigned duration_ms = s_mp3_audible_started
                     ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
                     : 0;
+                // 鍚獥瑕佸紑 AFE銆傚厛鍏虫帀 MP3 瑙g爜鍣紝閬垮厤鍜屽綍闊虫姠鍐呴儴 SRAM銆�
+                close_mp3_decode_resources(&decoder, &rate_cvt_handle, &fp, &file_buf);
+                audio_info_checked = false;
+                mp3_sample_rate = 0;
+                mp3_channels = 0;
                 Board_OnShadowingClipFinished(duration_ms);
                 wait_shadowing_listen(duration_ms);
                 wait_shadowing_mic_off();
@@ -585,22 +676,7 @@
             }
         }
 
-    if (rate_cvt_handle) {
-        esp_ae_rate_cvt_close(rate_cvt_handle);
-        rate_cvt_handle = nullptr;
-    }
-    if (decoder) {
-        esp_audio_simple_dec_close(decoder);
-        decoder = nullptr;
-    }
-    if (fp) {
-        fclose(fp);
-        fp = nullptr;
-    }
-    if (file_buf) {
-        heap_caps_free(file_buf);
-        file_buf = nullptr;
-    }
+    close_mp3_decode_resources(&decoder, &rate_cvt_handle, &fp, &file_buf);
     ESP_LOGI(TAG, "MP3 item %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
     }
 
@@ -622,10 +698,15 @@
     unsigned duration_ms = s_mp3_audible_started
         ? pdTICKS_TO_MS(xTaskGetTickCount() - s_mp3_audible_started)
         : pdTICKS_TO_MS(xTaskGetTickCount() - play_started);
+    if (task_arg) {
+        heap_caps_free(task_arg);
+        task_arg = nullptr;
+    }
     mp3_player_task_handle = nullptr;
     mp3_stop_requested = false;
     mp3_pause_requested = false;
     mp3_repeat_left = 0;
+    s_mp3_offline_wake_ok = false;
     if (should_power_off) {
         Application::GetInstance().RequestPowerOffStandby();
     }
@@ -666,6 +747,7 @@
         return ESP_ERR_INVALID_ARG;
     }
     if (count > SDCARD_MP3_PLAYLIST_MAX) {
+        ESP_LOGW(TAG, "playlist truncated incoming=%d max=%d", count, SDCARD_MP3_PLAYLIST_MAX);
         count = SDCARD_MP3_PLAYLIST_MAX;
     }
 
@@ -679,7 +761,10 @@
     }
 
     mp3_player_task_arg_t *arg = static_cast<mp3_player_task_arg_t *>(
-        heap_caps_calloc(1, sizeof(mp3_player_task_arg_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
+        heap_caps_calloc(1,
+                         sizeof(mp3_player_task_arg_t) +
+                             sizeof(sdcard_mp3_playlist_item_t) * static_cast<size_t>(count),
+                         MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
     if (!arg) {
         return ESP_ERR_NO_MEM;
     }
@@ -748,6 +833,16 @@
     });
 }
 
+extern "C" void SDCard_PreparePlaybackAndWait(void)
+{
+    stop_afe_for_sd_and_wait(true);
+}
+
+extern "C" bool SDCard_Mp3OfflineWakeAllowed(void)
+{
+    return s_mp3_offline_wake_ok;
+}
+
 extern "C" bool SDCard_Is_Playing(void)
 {
     return mp3_player_task_handle != nullptr;
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
index f0a4cb4..bf7f4d2 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -4,27 +4,32 @@
 #include <stdlib.h>
 #include <string.h>
 #include <strings.h>
+#include <sys/stat.h>
 #include <time.h>
 #include <unistd.h>
 #include <cJSON.h>
 #include "esp_heap_caps.h"
 #include "esp_log.h"
 #include "esp_timer.h"
+#include "esp_mac.h"
 #include "esp_wifi.h"
 #include "freertos/FreeRTOS.h"
 #include "freertos/task.h"
 #include "mqtt_client.h"
 #include "system.h"
 #include "app_task.h"
+#include "parent_schedule.h"
 
 #define BOARD_MQTT_KEEPALIVE_SEC 120
-#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 30000
-#define BOARD_MQTT_RECONNECT_DELAY_MS 30000
+#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 60000
+#define BOARD_MQTT_RECONNECT_RETRY_MS 5000
+#define BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP 49152
 #define BOARD_MQTT_NETWORK_TIMEOUT_MS 15000
 #define BOARD_MQTT_FINAL_REPORT_WAIT_MS 10000
 #define BOARD_MQTT_VOICE_YIELD_WAIT_MS 120000
+#define BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US 15000000
+#define BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US 2000000
 #define BOARD_MQTT_TASK_PRIORITY 5
-#define BOARD_MQTT_RECONNECT_TASK_PRIORITY 4
 #define BOARD_MQTT_TOPIC_MAX_LEN 128
 #define BOARD_MQTT_PAYLOAD_MAX_LEN 8192
 #define BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN 256
@@ -47,13 +52,19 @@
 static esp_mqtt_client_handle_t s_mqtt_client = NULL;
 static bool s_mqtt_connected = false;
 static char s_device_id[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_mac_id[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_cmd_ctl_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_cmd_task_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_ctl_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_task_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_log_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_ack_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_progress_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_state_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_state_mac_topic[BOARD_MQTT_TOPIC_MAX_LEN];
 static char s_rpt_event_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static const char BOARD_MQTT_LWT_PAYLOAD[] =
+    "{\"msg_type\":\"state\",\"payload\":{\"online\":false,\"net\":\"offline\"}}";
 typedef enum {
     BOARD_MQTT_TASK_NONE = 0,
     BOARD_MQTT_TASK_DOWNLOAD,
@@ -68,11 +79,28 @@
 static volatile bool s_download_voice_yield_requested = false;
 static volatile bool s_download_cancel_requested = false;
 static portMUX_TYPE s_task_lock = portMUX_INITIALIZER_UNLOCKED;
-static TaskHandle_t s_reconnect_task_handle = NULL;
 static TaskHandle_t s_state_task_handle = NULL;
 static TaskHandle_t s_playlist_task_handle = NULL;
 static volatile bool s_playlist_stop_requested = false;
 static uint32_t s_state_sequence = 0;
+
+typedef struct {
+    bool active;
+    uint32_t gen;
+    char app_id[32];
+    char task_id[32];
+    char source[24];
+    char *params_json;
+} board_mqtt_queued_app_task_t;
+
+static board_mqtt_queued_app_task_t s_queued_app_task = {0};
+static uint32_t s_queued_app_gen = 0;
+
+typedef struct {
+    bool success;
+    int error_code;
+    const char *info;
+} board_mqtt_action_result_t;
 
 static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len);
 static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src);
@@ -82,6 +110,10 @@
 static void BoardMqtt_FinishTask(board_mqtt_task_type_t type);
 static void BoardMqtt_StopPlaylistTask(void);
 static esp_err_t BoardMqtt_StartPlaylistTask(void);
+static board_mqtt_action_result_t BoardMqtt_HandleUpdateManifest(const char *req_id, cJSON *payload);
+static void BoardMqtt_ClearQueuedAppTask(void);
+static void BoardMqtt_StartQueuedAppTaskIfReady(bool preload_ok, uint32_t queued_gen);
+static bool s_bind_manifest_to_queue = false;
 
 typedef struct {
     char topic[BOARD_MQTT_TOPIC_MAX_LEN];
@@ -98,12 +130,6 @@
     BOARD_MQTT_TOPIC_CTL,
     BOARD_MQTT_TOPIC_TASK,
 } board_mqtt_topic_type_t;
-
-typedef struct {
-    bool success;
-    int error_code;
-    const char *info;
-} board_mqtt_action_result_t;
 
 static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload);
 
@@ -126,6 +152,8 @@
     char *manifest_json;
     size_t asset_count;
     bool replace_existing_download;
+    bool start_queued_app;
+    uint32_t queued_gen;
     board_mqtt_manifest_asset_t assets[];
 } board_mqtt_manifest_job_t;
 
@@ -160,7 +188,9 @@
     size_t asset_index;
     size_t asset_count;
     int64_t last_publish_us;
+    int64_t last_display_us;
     int last_progress;
+    bool ignore_voice_yield;
 } board_mqtt_manifest_progress_ctx_t;
 
 static bool BoardMqtt_IsDownloadTask(board_mqtt_task_type_t type)
@@ -175,6 +205,11 @@
     type = s_task_type;
     portEXIT_CRITICAL(&s_task_lock);
     return type;
+}
+
+bool Board_IsLessonSyncing(void)
+{
+    return BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType());
 }
 
 static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type)
@@ -300,9 +335,9 @@
     return (long)now;
 }
 
-static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
+static void BoardMqtt_PublishJsonTo(const char *topic, cJSON *json, int qos, int retain)
 {
-    if (!s_mqtt_client || !s_mqtt_connected || !topic || !json) {
+    if (!s_mqtt_client || !s_mqtt_connected || !topic || !topic[0] || !json) {
         return;
     }
 
@@ -312,7 +347,7 @@
         return;
     }
 
-    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, 0);
+    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, retain);
     if (msg_id >= 0) {
         ESP_LOGI(TAG, "Published MQTT message, topic=%s, msg_id=%d", topic, msg_id);
     } else {
@@ -321,8 +356,17 @@
     cJSON_free(payload);
 }
 
+static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
+{
+    BoardMqtt_PublishJsonTo(topic, json, qos, 0);
+}
+
 static void BoardMqtt_WaitConnectedForFinalReport(void)
 {
+    if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL) < BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP) {
+        ESP_LOGW(TAG, "Skip MQTT final report wait, internal heap low");
+        return;
+    }
     int waited_ms = 0;
     while (s_mqtt_client && !s_mqtt_connected && waited_ms < BOARD_MQTT_FINAL_REPORT_WAIT_MS) {
         ESP_LOGW(TAG, "Waiting MQTT reconnect before final report, waited=%dms", waited_ms);
@@ -359,11 +403,18 @@
     cJSON_AddNumberToObject(payload, "cpu", Board_Get_CPU_Usage());
     cJSON_AddNumberToObject(payload, "mem", mem_percent);
     cJSON_AddNumberToObject(payload, "battery", Battery_Get_Value());
+    cJSON_AddTrueToObject(payload, "online");
     cJSON_AddStringToObject(payload, "net", wifi_connected ? "wifi" : "offline");
     cJSON_AddStringToObject(payload, "mode", task_type != BOARD_MQTT_TASK_NONE ? "task" : (Board_Is_Voice_Session_Active() ? "voice" : "idle"));
+    if (s_mac_id[0]) {
+        cJSON_AddStringToObject(payload, "mac", s_mac_id);
+    }
     cJSON_AddItemToObject(root, "payload", payload);
 
-    BoardMqtt_PublishJson(s_rpt_state_topic, root, BOARD_MQTT_QOS);
+    BoardMqtt_PublishJsonTo(s_rpt_state_topic, root, BOARD_MQTT_QOS, 1);
+    if (s_rpt_state_mac_topic[0] && strcmp(s_rpt_state_mac_topic, s_rpt_state_topic) != 0) {
+        BoardMqtt_PublishJsonTo(s_rpt_state_mac_topic, root, BOARD_MQTT_QOS, 1);
+    }
     cJSON_Delete(root);
 }
 
@@ -485,6 +536,23 @@
     cJSON_Delete(root);
 }
 
+static void BoardMqtt_ShowLessonSyncProgress(int percent, size_t asset_index, size_t asset_count)
+{
+    char msg[96];
+    if (percent < 0) {
+        percent = 0;
+    } else if (percent > 100) {
+        percent = 100;
+    }
+    if (asset_count == 0) {
+        snprintf(msg, sizeof(msg), "姝e湪鍚屾璇炬枃锛岃绋嶅��");
+    } else {
+        snprintf(msg, sizeof(msg), "姝e湪涓嬭浇 %u/%u锛屽凡瀹屾垚 %d%%",
+                 (unsigned)(asset_index + 1), (unsigned)asset_count, percent);
+    }
+    Board_Show_Alert("姝e湪鍚屾", msg, "cloud_arrow_down");
+}
+
 static char *BoardMqtt_CopySegment(const char *data, int data_len)
 {
     if (!data || data_len <= 0) {
@@ -583,6 +651,43 @@
     return written > 0 && (size_t)written < dest_size;
 }
 
+static bool BoardMqtt_SdRegularFileReady(const char *business_path)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_path || business_path[0] == '\0') {
+        return false;
+    }
+    if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return false;
+    }
+    struct stat st;
+    if (stat(sd_path, &st) != 0) {
+        return false;
+    }
+    return S_ISREG(st.st_mode) && st.st_size > 0;
+}
+
+static bool BoardMqtt_JoinDownloadUrl(char *dest, size_t dest_size, const char *base_url, const char *rel)
+{
+    if (!dest || dest_size == 0 || !base_url || !rel || rel[0] == '\0') {
+        return false;
+    }
+    size_t base_len = strlen(base_url);
+    while (base_len > 0 && base_url[base_len - 1] == '/') {
+        --base_len;
+    }
+    if (base_len == 0) {
+        return false;
+    }
+    int written;
+    if (rel[0] == '/') {
+        written = snprintf(dest, dest_size, "%.*s%s", (int)base_len, base_url, rel);
+    } else {
+        written = snprintf(dest, dest_size, "%.*s/%s", (int)base_len, base_url, rel);
+    }
+    return written > 0 && (size_t)written < dest_size;
+}
+
 static bool BoardMqtt_IsSupportedResourcePath(const char *sd_path)
 {
     const char *dot = sd_path ? strrchr(sd_path, '.') : NULL;
@@ -610,6 +715,88 @@
     return BoardMqtt_CopyString(dest, dest_size, md5->valuestring);
 }
 
+static bool BoardMqtt_IsLessonAppTask(const char *app_id, const char *task_id)
+{
+    return app_id && task_id && strcmp(app_id, "english_learning") == 0 &&
+           (strcmp(task_id, "repeater") == 0 || strcmp(task_id, "shadowing") == 0);
+}
+
+static void BoardMqtt_ClearQueuedAppTask(void)
+{
+    free(s_queued_app_task.params_json);
+    memset(&s_queued_app_task, 0, sizeof(s_queued_app_task));
+    s_queued_app_gen++;
+}
+
+static bool BoardMqtt_QueueAppTask(const char *app_id, const char *task_id, const char *source, const char *params_json)
+{
+    BoardMqtt_ClearQueuedAppTask();
+    if (!BoardMqtt_CopyString(s_queued_app_task.app_id, sizeof(s_queued_app_task.app_id), app_id) ||
+        !BoardMqtt_CopyString(s_queued_app_task.task_id, sizeof(s_queued_app_task.task_id), task_id) ||
+        !BoardMqtt_CopyString(s_queued_app_task.source, sizeof(s_queued_app_task.source), source && source[0] ? source : "mqtt_immediate")) {
+        BoardMqtt_ClearQueuedAppTask();
+        return false;
+    }
+    const char *params = (params_json && params_json[0]) ? params_json : "{}";
+    size_t params_len = strlen(params) + 1;
+    s_queued_app_task.params_json = (char *)malloc(params_len);
+    if (!s_queued_app_task.params_json) {
+        BoardMqtt_ClearQueuedAppTask();
+        return false;
+    }
+    memcpy(s_queued_app_task.params_json, params, params_len);
+    s_queued_app_task.active = true;
+    s_queued_app_task.gen = s_queued_app_gen;
+    return true;
+}
+
+static bool BoardMqtt_PackageCached(cJSON *files)
+{
+    if (!cJSON_IsArray(files) || cJSON_GetArraySize(files) <= 0) {
+        return false;
+    }
+    cJSON *file = NULL;
+    cJSON_ArrayForEach(file, files) {
+        if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
+            return false;
+        }
+        char business_path[SDCARD_PATH_MAX_LEN];
+        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) {
+            return false;
+        }
+        if (!BoardMqtt_SdRegularFileReady(business_path)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static void BoardMqtt_StartQueuedAppTaskIfReady(bool preload_ok, uint32_t queued_gen)
+{
+    if (!s_queued_app_task.active || s_queued_app_task.gen != queued_gen) {
+        return;
+    }
+    if (!preload_ok) {
+        Board_ReleaseHoldIdle();
+        ESP_LOGW(TAG, "preload failed, drop queued %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id);
+        Board_Show_Alert("鍚屾澶辫触", "璇炬枃涓嬭浇澶辫触锛岃绋嶅悗閲嶈瘯", "sad");
+        BoardMqtt_ClearQueuedAppTask();
+        return;
+    }
+    ESP_LOGI(TAG, "package READY, start AI task %s/%s", s_queued_app_task.app_id, s_queued_app_task.task_id);
+    char app_id[32];
+    char task_id[32];
+    char source[24];
+    char *params = s_queued_app_task.params_json;
+    s_queued_app_task.params_json = NULL;
+    BoardMqtt_CopyString(app_id, sizeof(app_id), s_queued_app_task.app_id);
+    BoardMqtt_CopyString(task_id, sizeof(task_id), s_queued_app_task.task_id);
+    BoardMqtt_CopyString(source, sizeof(source), s_queued_app_task.source);
+    BoardMqtt_ClearQueuedAppTask();
+    Board_StartAppTask(app_id, task_id, params, source);
+    free(params);
+}
+
 static bool BoardMqtt_ActionIsCtl(const char *action)
 {
     return strcmp(action, "SET_VOLUME") == 0 || strcmp(action, "SET_POWER") == 0 ||
@@ -617,6 +804,7 @@
         strcmp(action, "STOP_MEDIA") == 0 || strcmp(action, "DELETE_FILE") == 0 ||
         strcmp(action, "SET_ALARM") == 0 || strcmp(action, "UNSET_ALARM") == 0 ||
         strcmp(action, "REBOOT") == 0 || strcmp(action, "START_APP_TASK") == 0 ||
+        strcmp(action, "SYNC_SCHEDULE") == 0 ||
         strcmp(action, "ENSURE_APP_DIRS") == 0;
 }
 
@@ -1229,16 +1417,6 @@
     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");
 }
 
@@ -1331,7 +1509,8 @@
     if (!force && progress == progress_ctx->last_progress) {
         return true;
     }
-    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+    if (!force && progress_ctx->last_publish_us > 0
+        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return true;
     }
 
@@ -1352,7 +1531,7 @@
         ESP_LOGI(TAG, "Cancel manifest download by request");
         return false;
     }
-    if (Board_Is_Voice_Session_Active()) {
+    if (!progress_ctx->ignore_voice_yield && Board_Is_Voice_Session_Active()) {
         s_download_voice_yield_requested = true;
         ESP_LOGI(TAG, "Cancel manifest download for voice session");
         return false;
@@ -1366,11 +1545,21 @@
     int aggregate_progress = (int)(((progress_ctx->asset_index * 100) + (size_t)progress) / progress_ctx->asset_count);
 
     int64_t now = esp_timer_get_time();
-    bool force = progress <= 0 || progress >= 100 || aggregate_progress >= 100;
+    bool show = progress_ctx->last_display_us == 0
+        || now - progress_ctx->last_display_us >= BOARD_MQTT_SYNC_DISPLAY_INTERVAL_US
+        || aggregate_progress >= 100
+        || progress <= 0;
+    if (show) {
+        progress_ctx->last_display_us = now;
+        BoardMqtt_ShowLessonSyncProgress(aggregate_progress, progress_ctx->asset_index, progress_ctx->asset_count);
+    }
+
+    bool force = progress >= 100 || progress_ctx->last_progress < 0 || aggregate_progress >= 100;
     if (!force && aggregate_progress == progress_ctx->last_progress) {
         return true;
     }
-    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+    if (!force && progress_ctx->last_publish_us > 0
+        && now - progress_ctx->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return true;
     }
 
@@ -1395,7 +1584,8 @@
     if (!force && progress == job->last_progress) {
         return;
     }
-    if (!force && job->last_publish_us > 0 && now - job->last_publish_us < 2000000) {
+    if (!force && job->last_publish_us > 0
+        && now - job->last_publish_us < BOARD_MQTT_PROGRESS_PUBLISH_INTERVAL_US) {
         return;
     }
 
@@ -1467,6 +1657,24 @@
 
     Board_Acquire_Sleep_Inhibit();
     s_download_voice_yield_requested = false;
+    if (job->start_queued_app) {
+        ESP_LOGI(TAG, "lesson preload keeps downloading during listen/speaking");
+    }
+
+    bool need_download = false;
+    for (size_t i = 0; i < job->asset_count; ++i) {
+        if (!BoardMqtt_SdRegularFileReady(job->assets[i].target_path)) {
+            need_download = true;
+            break;
+        }
+    }
+    if (need_download) {
+        Board_SetLessonDownloadBoost(true);
+        vTaskDelay(pdMS_TO_TICKS(80));
+        if (job->start_queued_app) {
+            Board_AnnounceLessonSyncAndWait();
+        }
+    }
 
     char final_path[SDCARD_PATH_MAX_LEN] = {0};
     char part_path[SDCARD_PATH_MAX_LEN] = {0};
@@ -1475,6 +1683,7 @@
 
     vTaskDelay(pdMS_TO_TICKS(100));
     BoardMqtt_PublishProgress(job->req_id, 0, "saving manifest");
+    BoardMqtt_ShowLessonSyncProgress(0, 0, job->asset_count);
     err = SDCard_Save_Manifest(job->course_id, job->manifest_json);
     if (err != ESP_OK) {
         snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
@@ -1485,6 +1694,11 @@
         memset(final_path, 0, sizeof(final_path));
         memset(part_path, 0, sizeof(part_path));
         memset(error_info, 0, sizeof(error_info));
+
+        if (BoardMqtt_SdRegularFileReady(asset->target_path)) {
+            ESP_LOGI(TAG, "skip manifest asset, already on sd: %s", asset->target_path);
+            continue;
+        }
 
         err = SDCard_Prepare_Business_File(asset->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path));
         if (err != ESP_OK) {
@@ -1497,11 +1711,17 @@
             .asset_index = i,
             .asset_count = job->asset_count,
             .last_publish_us = 0,
+            .last_display_us = 0,
             .last_progress = -1,
+            .ignore_voice_yield = job->start_queued_app,
         };
         err = Board_Download_File_To_Path(asset->url, part_path, asset->md5, BoardMqtt_ManifestProgressCallback,
                                           &progress_ctx, error_info, sizeof(error_info));
         if (err != ESP_OK) {
+            ESP_LOGW(TAG, "manifest asset failed %u/%u path=%s err=%s info=%s",
+                     (unsigned)(i + 1), (unsigned)job->asset_count,
+                     asset->target_path, esp_err_to_name(err),
+                     error_info[0] ? error_info : "");
             SDCard_Remove_File_If_Exists(part_path);
             if (s_download_voice_yield_requested || s_download_cancel_requested) {
                 int canceled_progress = progress_ctx.last_progress >= 0 ? progress_ctx.last_progress : 0;
@@ -1534,13 +1754,26 @@
         if (err == ESP_OK) {
             BoardMqtt_PublishProgress(job->req_id, 100, "manifest assets downloaded");
             BoardMqtt_PublishAck(job->req_id, true, 0, "manifest saved and assets downloaded");
+            if (job->start_queued_app) {
+                Board_Show_Alert("鍚屾瀹屾垚", "寮�濮嬫挱鏀�", "happy");
+                BoardMqtt_StartQueuedAppTaskIfReady(true, job->queued_gen);
+            }
+            Board_Schedule_OnLessonFilesUpdated();
         } else {
             BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED,
                                  error_info[0] ? error_info : esp_err_to_name(err));
+            if (job->start_queued_app) {
+                BoardMqtt_StartQueuedAppTaskIfReady(false, job->queued_gen);
+            } else {
+                Board_Show_Alert("鍚屾澶辫触", error_info[0] ? error_info : "璇炬枃涓嬭浇澶辫触", "sad");
+            }
         }
     }
 
     s_download_voice_yield_requested = false;
+    if (need_download) {
+        Board_SetLessonDownloadBoost(false);
+    }
     Board_Release_Sleep_Inhibit();
     BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST);
     cJSON_free(job->manifest_json);
@@ -1558,6 +1791,8 @@
 
     Board_Acquire_Sleep_Inhibit();
     s_download_voice_yield_requested = false;
+    Board_SetLessonDownloadBoost(true);
+    vTaskDelay(pdMS_TO_TICKS(80));
 
     char final_path[SDCARD_PATH_MAX_LEN] = {0};
     char part_path[SDCARD_PATH_MAX_LEN] = {0};
@@ -1608,6 +1843,7 @@
     }
 
     s_download_voice_yield_requested = false;
+    Board_SetLessonDownloadBoost(false);
     BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
     Board_Release_Sleep_Inhibit();
     free(job);
@@ -1641,6 +1877,10 @@
     }
     if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg download is supported");
+    }
+    if (BoardMqtt_SdRegularFileReady(business_path)) {
+        ESP_LOGI(TAG, "skip download, already on sd: %s", business_path);
+        return BoardMqtt_ResultOk("already on sd");
     }
     BoardMqtt_StopPlaylistTask();
     if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_DOWNLOAD)) {
@@ -1724,11 +1964,25 @@
 {
     cJSON *course_id = cJSON_GetObjectItem(payload, "course_id");
     cJSON *assets = cJSON_GetObjectItem(payload, "assets");
+    cJSON *files = cJSON_GetObjectItem(payload, "files");
+    cJSON *base_url = cJSON_GetObjectItem(payload, "base_url");
+    bool has_assets = cJSON_IsArray(assets) && cJSON_GetArraySize(assets) > 0;
+    bool has_files = cJSON_IsString(base_url) && base_url->valuestring && base_url->valuestring[0] != '\0' &&
+                     cJSON_IsArray(files) && cJSON_GetArraySize(files) > 0;
     if (!cJSON_IsString(course_id) || !BoardMqtt_IsSafeCourseId(course_id->valuestring) ||
-        !cJSON_IsArray(assets) || cJSON_GetArraySize(assets) <= 0) {
+        (!has_assets && !has_files)) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
     }
-    BoardMqtt_StopPlaylistTask();
+    if (has_files && !BoardMqtt_HasPrefix(base_url->valuestring, "http://") &&
+        !BoardMqtt_HasPrefix(base_url->valuestring, "https://")) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest base_url");
+    }
+    if (has_files && !BoardMqtt_PackageCached(files)) {
+        Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+    }
+    if (s_bind_manifest_to_queue) {
+        BoardMqtt_StopPlaylistTask();
+    }
 
     bool replace_existing_download = false;
     board_mqtt_task_type_t current_type = BoardMqtt_GetTaskType();
@@ -1751,13 +2005,14 @@
         return BoardMqtt_ResultFail((code), (message)); \
     } while (0)
 
-    int asset_count_int = cJSON_GetArraySize(assets);
+    int asset_count_int = has_assets ? cJSON_GetArraySize(assets) : cJSON_GetArraySize(files);
     size_t source_asset_count = (size_t)asset_count_int;
-    if (source_asset_count > SIZE_MAX / 2) {
+    if (has_assets && source_asset_count > SIZE_MAX / 2) {
         BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
     }
-    size_t max_asset_count = source_asset_count * 2;
-    if (max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
+    size_t max_asset_count = has_assets ? source_asset_count * 2 : source_asset_count;
+    if (max_asset_count == 0 ||
+        max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
         BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
     }
 
@@ -1774,6 +2029,8 @@
     }
     job->manifest_json = manifest_json;
     job->replace_existing_download = replace_existing_download;
+    job->start_queued_app = s_bind_manifest_to_queue && s_queued_app_task.active;
+    job->queued_gen = s_queued_app_task.gen;
 
 #define BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(code, message) do { \
         cJSON_free(job->manifest_json); \
@@ -1787,73 +2044,103 @@
     }
 
     size_t index = 0;
-    cJSON *asset = NULL;
-    cJSON_ArrayForEach(asset, assets) {
-        cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
-        cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
-        cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
-        if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+    if (has_files && !has_assets) {
+        cJSON *file = NULL;
+        cJSON_ArrayForEach(file, files) {
+            if (!cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest file path");
+            }
+            char business_path[SDCARD_PATH_MAX_LEN];
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), file->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            char sd_path[SDCARD_PATH_MAX_LEN];
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
+            }
+            char joined_url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+            if (!BoardMqtt_JoinDownloadUrl(joined_url, sizeof(joined_url), base_url->valuestring, business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset url too long");
+            }
+            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), joined_url) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+            }
+            ++index;
         }
-        if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
-        }
-        if (!BoardMqtt_ValidateOptionalMd5(md5)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
-        }
+    } else {
+        cJSON *asset = NULL;
+        cJSON_ArrayForEach(asset, assets) {
+            cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
+            cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
+            cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
+            if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+            }
+            if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
+            }
+            if (!BoardMqtt_ValidateOptionalMd5(md5)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
+            }
 
-        char business_path[SDCARD_PATH_MAX_LEN];
-        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
-        }
+            char business_path[SDCARD_PATH_MAX_LEN];
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
 
-        char sd_path[SDCARD_PATH_MAX_LEN];
-        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
-        }
-        if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
-        }
+            char sd_path[SDCARD_PATH_MAX_LEN];
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+            }
+            if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
+            }
 
-        board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
-        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
-            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
-            !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
-        }
-        ++index;
+            board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
+                !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+            }
+            ++index;
 
-        cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
-        cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
-        if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
-            cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
-            continue;
-        }
-        if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
-        }
-        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
-        }
-        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
-        }
-        const char *cover_dot = strrchr(sd_path, '.');
-        if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
-        }
+            cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
+            cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
+            if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
+                cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
+                continue;
+            }
+            if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
+            }
+            if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+            }
+            if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+            }
+            const char *cover_dot = strrchr(sd_path, '.');
+            if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
+            }
 
-        job_asset = &job->assets[index];
-        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
-            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
-            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
+            job_asset = &job->assets[index];
+            if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
+                !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
+                BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
+            }
+            ++index;
         }
-        ++index;
     }
     job->asset_count = index;
 
-    if (SDCard_Is_Playing()) {
-        ESP_LOGI(TAG, "Stop MP3 playback before MQTT manifest download");
+    if (job->start_queued_app && SDCard_Is_Playing()) {
+        ESP_LOGI(TAG, "Stop MP3 playback before PRELOAD for queued AI task");
         SDCard_Stop_Play();
     }
 
@@ -1868,7 +2155,7 @@
     return BoardMqtt_ResultOk("manifest accepted");
 }
 
-static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(cJSON *payload)
+static board_mqtt_action_result_t BoardMqtt_HandleStartAppTask(const char *req_id, cJSON *payload)
 {
     if (!payload) {
         return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
@@ -1877,24 +2164,77 @@
     cJSON *task_id = cJSON_GetObjectItem(payload, "task_id");
     cJSON *source = cJSON_GetObjectItem(payload, "source");
     cJSON *params = cJSON_GetObjectItem(payload, "params");
+    cJSON *package = cJSON_GetObjectItem(payload, "package");
     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);
+    const bool lesson = BoardMqtt_IsLessonAppTask(app_id->valuestring, tid);
+
+    if (!lesson || !cJSON_IsObject(package)) {
+        if (lesson && !Board_LessonFilesReady(tid, params_json)) {
+            ESP_LOGW(TAG, "lesson files missing on SD, start after sync %s/%s",
+                     app_id->valuestring, tid);
+            Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾闊抽锛岃绋嶇瓑", "cloud_arrow_down");
+        }
+        BoardMqtt_ClearQueuedAppTask();
+        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_ARG) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
+        }
+        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");
+    }
+
+    cJSON *files = cJSON_GetObjectItem(package, "files");
+    if (BoardMqtt_PackageCached(files)) {
+        ESP_LOGI(TAG, "package cached, READY %s/%s", app_id->valuestring, tid);
+        BoardMqtt_ClearQueuedAppTask();
+        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_ARG) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid app or task id");
+        }
+        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");
+    }
+
+    if (!BoardMqtt_QueueAppTask(app_id->valuestring, tid, src, params_json)) {
+        free(params_json);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to queue app task");
+    }
     free(params_json);
-    if (err == ESP_ERR_NOT_FOUND) {
-        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "unknown app or task");
+    ESP_LOGI(TAG, "PRELOAD package then run %s/%s", app_id->valuestring, tid);
+    Board_HoldIdleForServerPrompt();
+    Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+    s_bind_manifest_to_queue = true;
+    board_mqtt_action_result_t preload = BoardMqtt_HandleUpdateManifest(req_id, package);
+    s_bind_manifest_to_queue = false;
+    if (!preload.success) {
+        Board_ReleaseHoldIdle();
+        BoardMqtt_ClearQueuedAppTask();
+        Board_Show_Alert("鍚屾澶辫触", preload.info ? preload.info : "璇炬枃涓嬭浇澶辫触", "sad");
     }
-    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");
+    return preload.success ? BoardMqtt_ResultOk("preload queued") : preload;
 }
 
 static board_mqtt_action_result_t BoardMqtt_HandleEnsureAppDirs(cJSON *payload)
@@ -1929,6 +2269,29 @@
     return BoardMqtt_ResultOk("app dirs ensured");
 }
 
+static board_mqtt_action_result_t BoardMqtt_HandleSyncSchedule(cJSON *payload)
+{
+    if (!payload) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing payload");
+    }
+    char *json = cJSON_PrintUnformatted(payload);
+    if (!json) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "schedule json alloc failed");
+    }
+    esp_err_t err = Board_Schedule_SyncJson(json);
+    free(json);
+    if (err == ESP_ERR_INVALID_ARG) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid schedule");
+    }
+    if (err == ESP_ERR_NO_MEM) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "too many scheduled tasks");
+    }
+    if (err != ESP_OK) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+    }
+    return BoardMqtt_ResultOk("schedule synced");
+}
+
 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);
@@ -1943,17 +2306,20 @@
     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);
+    if (strcmp(action, "START_APP_TASK") == 0) return BoardMqtt_HandleStartAppTask(req_id, payload);
+    if (strcmp(action, "SYNC_SCHEDULE") == 0) return BoardMqtt_HandleSyncSchedule(payload);
     if (strcmp(action, "ENSURE_APP_DIRS") == 0) return BoardMqtt_HandleEnsureAppDirs(payload);
     return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
 }
 
 static board_mqtt_topic_type_t BoardMqtt_GetTopicType(const char *topic)
 {
-    if (strcmp(topic, s_cmd_ctl_topic) == 0) {
+    if (strcmp(topic, s_cmd_ctl_topic) == 0 ||
+        (s_cmd_ctl_mac_topic[0] && strcmp(topic, s_cmd_ctl_mac_topic) == 0)) {
         return BOARD_MQTT_TOPIC_CTL;
     }
-    if (strcmp(topic, s_cmd_task_topic) == 0) {
+    if (strcmp(topic, s_cmd_task_topic) == 0 ||
+        (s_cmd_task_mac_topic[0] && strcmp(topic, s_cmd_task_mac_topic) == 0)) {
         return BOARD_MQTT_TOPIC_TASK;
     }
     return BOARD_MQTT_TOPIC_UNKNOWN;
@@ -2074,7 +2440,9 @@
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "invalid command topic");
     } else if (!cJSON_IsString(version) || strcmp(version->valuestring, "1.0") != 0) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.version");
-    } else if (!cJSON_IsString(device_id) || strcmp(device_id->valuestring, s_device_id) != 0) {
+    } else if (!cJSON_IsString(device_id) ||
+               (strcmp(device_id->valuestring, s_device_id) != 0 &&
+                (s_mac_id[0] == '\0' || strcasecmp(device_id->valuestring, s_mac_id) != 0))) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.device_id");
     } else if (!cJSON_IsNumber(timestamp)) {
         result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.timestamp");
@@ -2095,49 +2463,45 @@
     free(payload_text);
 }
 
-static void BoardMqtt_ReconnectTask(void *arg)
+static void BoardMqtt_TryReconnect(void)
 {
-    (void)arg;
-    while (s_mqtt_client && !s_mqtt_connected) {
-        if (Board_Is_Voice_Session_Active()) {
-            ESP_LOGI(TAG, "Delay local MQTT reconnect while voice session is active");
-            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
-            continue;
-        }
-        ESP_LOGI(TAG, "Reconnecting local MQTT client");
-        esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
-        if (err != ESP_OK) {
-            ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
-            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
-            continue;
-        }
-        break;
+    if (!s_mqtt_client || s_mqtt_connected) {
+        return;
     }
-    s_reconnect_task_handle = NULL;
-    vTaskDelete(NULL);
+    size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
+    if (internal_free < BOARD_MQTT_RECONNECT_MIN_INTERNAL_HEAP) {
+        ESP_LOGW(TAG, "Skip MQTT reconnect, internal heap=%u", (unsigned)internal_free);
+        return;
+    }
+    ESP_LOGI(TAG, "Reconnecting local MQTT client");
+    esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
+    }
 }
 
 static void BoardMqtt_ScheduleReconnect(void)
 {
-    if (!s_mqtt_client || s_reconnect_task_handle) {
-        return;
-    }
-    BaseType_t ret = xTaskCreate(BoardMqtt_ReconnectTask, "mqtt_reconnect", 4096, NULL, BOARD_MQTT_RECONNECT_TASK_PRIORITY, &s_reconnect_task_handle);
-    if (ret != pdPASS) {
-        ESP_LOGE(TAG, "Failed to create local MQTT reconnect task");
-        s_reconnect_task_handle = NULL;
-    }
+    // 鏂繛鍥炶皟閲屼笉瑕佸啀寮� 4KB 浠诲姟锛氬啀瑙佸悗鍔犺浇鍞ら啋璇嶆椂鍐呴儴 SRAM 寰堢揣锛�
+    // xTaskCreate 澶辫触鍚� WiFi MODEM sleep 杩樹細鍦� phy_track_pll_init 閲� abort銆�
+    ESP_LOGW(TAG, "MQTT disconnected, retry from state task");
 }
 
 static void BoardMqtt_StateTask(void *arg)
 {
     (void)arg;
     while (s_mqtt_client) {
-        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
-        if (Board_Is_Audio_Busy()) {
+        if (s_mqtt_connected) {
+            if (!BoardMqtt_IsDownloadTask(BoardMqtt_GetTaskType())) {
+                BoardMqtt_PublishState();
+            }
+            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
             continue;
         }
-        BoardMqtt_PublishState();
+        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_RETRY_MS));
+        if (!s_mqtt_connected) {
+            BoardMqtt_TryReconnect();
+        }
     }
     s_state_task_handle = NULL;
     vTaskDelete(NULL);
@@ -2175,9 +2539,14 @@
     case MQTT_EVENT_CONNECTED:
         s_mqtt_connected = true;
         ESP_LOGI(TAG, "MQTT business server connected, host=%s, port=%d, username=%s", BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, BOARD_MQTT_USERNAME);
-        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s", s_cmd_ctl_topic, s_cmd_task_topic);
+        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s, mac_ctl=%s",
+                 s_cmd_ctl_topic, s_cmd_task_topic, s_cmd_ctl_mac_topic);
         BoardMqtt_Subscribe(s_cmd_ctl_topic);
         BoardMqtt_Subscribe(s_cmd_task_topic);
+        if (s_cmd_ctl_mac_topic[0] && strcmp(s_cmd_ctl_mac_topic, s_cmd_ctl_topic) != 0) {
+            BoardMqtt_Subscribe(s_cmd_ctl_mac_topic);
+            BoardMqtt_Subscribe(s_cmd_task_mac_topic);
+        }
         BoardMqtt_PublishLog("MQTT connected");
         BoardMqtt_PublishState();
         break;
@@ -2226,6 +2595,23 @@
         return ESP_ERR_INVALID_SIZE;
     }
 
+    uint8_t mac[6] = {0};
+    if (esp_read_mac(mac, ESP_MAC_WIFI_STA) == ESP_OK) {
+        snprintf(s_mac_id, sizeof(s_mac_id), "%02x:%02x:%02x:%02x:%02x:%02x",
+                 mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+        BoardMqtt_FormatTopic(s_cmd_ctl_mac_topic, sizeof(s_cmd_ctl_mac_topic),
+                              BOARD_MQTT_CMD_CTL_TOPIC_FORMAT, s_mac_id);
+        BoardMqtt_FormatTopic(s_cmd_task_mac_topic, sizeof(s_cmd_task_mac_topic),
+                              BOARD_MQTT_CMD_TASK_TOPIC_FORMAT, s_mac_id);
+        BoardMqtt_FormatTopic(s_rpt_state_mac_topic, sizeof(s_rpt_state_mac_topic),
+                              BOARD_MQTT_RPT_STATE_TOPIC_FORMAT, s_mac_id);
+    } else {
+        s_mac_id[0] = '\0';
+        s_cmd_ctl_mac_topic[0] = '\0';
+        s_cmd_task_mac_topic[0] = '\0';
+        s_rpt_state_mac_topic[0] = '\0';
+    }
+
     esp_err_t ret = BoardMqtt_FormatTopic(s_cmd_ctl_topic, sizeof(s_cmd_ctl_topic), BOARD_MQTT_CMD_CTL_TOPIC_FORMAT, client_id);
     if (ret != ESP_OK) {
         return ret;
@@ -2269,6 +2655,11 @@
         mqtt_config.credentials.authentication.password = BOARD_MQTT_PASSWORD;
     }
     mqtt_config.session.keepalive = BOARD_MQTT_KEEPALIVE_SEC;
+    mqtt_config.session.last_will.topic = s_rpt_state_mac_topic[0] ? s_rpt_state_mac_topic : s_rpt_state_topic;
+    mqtt_config.session.last_will.msg = BOARD_MQTT_LWT_PAYLOAD;
+    mqtt_config.session.last_will.msg_len = sizeof(BOARD_MQTT_LWT_PAYLOAD) - 1;
+    mqtt_config.session.last_will.qos = 1;
+    mqtt_config.session.last_will.retain = true;
     mqtt_config.network.disable_auto_reconnect = true;
     mqtt_config.network.timeout_ms = BOARD_MQTT_NETWORK_TIMEOUT_MS;
 
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
index 640b74c..53cc1a0 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
@@ -11,6 +11,7 @@
 esp_err_t BoardMqtt_Start(const char *client_id);
 void BoardMqtt_Stop(void);
 bool BoardMqtt_IsConnected(void);
+bool Board_IsLessonSyncing(void);
 bool BoardMqtt_CancelDownload(void);
 void BoardMqtt_PublishSttEvent(const char *text);
 void BoardMqtt_ResumePlaylistIfUnfinished(void);
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc
new file mode 100644
index 0000000..3a81b5d
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.cc
@@ -0,0 +1,901 @@
+#include "parent_schedule.h"
+
+#include "app_task.h"
+#include "application.h"
+#include "system.h"
+
+#include <cJSON.h>
+#include <esp_log.h>
+#include <esp_timer.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+
+#define TAG "parent_sched"
+#define SCHEDULE_PATH MOUNT_POINT "/.parent_schedule.json"
+#define SCHEDULE_MAX_TASKS 100
+#define SCHEDULE_ID_MAX 40
+#define SCHEDULE_APP_MAX 32
+#define SCHEDULE_PARAMS_MAX 384
+#define SCHEDULE_TYPE_DAILY 0
+#define SCHEDULE_TYPE_HOURLY 1
+#define SCHEDULE_TYPE_INTERVAL 2
+#define SCHEDULE_END_DEFAULT 0
+#define SCHEDULE_END_CHAT 1
+#define SCHEDULE_END_POWER_OFF 2
+#define SCHEDULE_DUE_WINDOW_SEC 180
+
+typedef struct {
+    char id[SCHEDULE_ID_MAX];
+    char app_id[SCHEDULE_APP_MAX];
+    char task_id[SCHEDULE_APP_MAX];
+    char params_json[SCHEDULE_PARAMS_MAX];
+    uint8_t enabled;
+    uint8_t type;
+    uint8_t hour;
+    uint8_t min;
+    uint8_t sec;
+    uint8_t weekdays;
+    uint8_t hourly_minute;
+    uint16_t every_minutes;
+} sched_task_t;
+
+static sched_task_t s_tasks[SCHEDULE_MAX_TASKS];
+static int s_task_count = 0;
+static bool s_loaded = false;
+
+static char s_next_id[SCHEDULE_ID_MAX];
+static char s_next_app[SCHEDULE_APP_MAX];
+static char s_next_task[SCHEDULE_APP_MAX];
+static char s_next_params[SCHEDULE_PARAMS_MAX];
+static time_t s_next_unix = 0;
+static bool s_next_valid = false;
+
+static bool s_occupying = false;
+static time_t s_busy_until = 0;
+static int s_on_complete = SCHEDULE_END_DEFAULT;
+static bool s_deferred_due = false;
+static bool s_fire_queued = false;
+static bool s_wait_lesson_files = false;
+static bool s_files_poll_queued = false;
+static time_t s_wait_lesson_files_since = 0;
+#define CLAIMED_SLOT_MAX 16
+typedef struct {
+    char id[SCHEDULE_ID_MAX];
+    time_t unix_time;
+} claimed_slot_t;
+static claimed_slot_t s_claimed_slots[CLAIMED_SLOT_MAX];
+static int s_claimed_count = 0;
+static time_t s_last_started_unix = 0;
+static char s_pending_app[SCHEDULE_APP_MAX];
+static char s_pending_task[SCHEDULE_APP_MAX];
+static char s_pending_params[SCHEDULE_PARAMS_MAX];
+static bool s_duration_fired = false;
+static esp_timer_handle_t s_duration_timer = NULL;
+
+static bool slot_already_claimed(const sched_task_t *task, time_t cand);
+
+static uint8_t tm_wday_to_repeat_bit(int tm_wday)
+{
+    switch (tm_wday) {
+    case 1: return ALARM_REPEAT_MON;
+    case 2: return ALARM_REPEAT_TUE;
+    case 3: return ALARM_REPEAT_WED;
+    case 4: return ALARM_REPEAT_THU;
+    case 5: return ALARM_REPEAT_FRI;
+    case 6: return ALARM_REPEAT_SAT;
+    default: return ALARM_REPEAT_SUN;
+    }
+}
+
+static bool parse_hhmmss(const char *value, uint8_t *hour, uint8_t *min, uint8_t *sec)
+{
+    int h = 0, m = 0, s = 0;
+    char tail = 0;
+    if (!value) {
+        return false;
+    }
+    if (sscanf(value, "%2d:%2d:%2d%c", &h, &m, &s, &tail) == 3 ||
+        sscanf(value, "%2d:%2d%c", &h, &m, &tail) == 2) {
+        if (h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59) {
+            return false;
+        }
+        *hour = (uint8_t)h;
+        *min = (uint8_t)m;
+        *sec = (uint8_t)s;
+        return true;
+    }
+    return false;
+}
+
+static uint8_t parse_weekdays(const cJSON *repeat)
+{
+    uint8_t mask = 0;
+    if (!cJSON_IsArray(repeat) || cJSON_GetArraySize(repeat) == 0) {
+        return ALARM_REPEAT_ALL;
+    }
+    const cJSON *day = NULL;
+    cJSON_ArrayForEach(day, repeat) {
+        int value = cJSON_IsNumber(day) ? day->valueint : 0;
+        switch (value) {
+        case 1: mask |= ALARM_REPEAT_MON; break;
+        case 2: mask |= ALARM_REPEAT_TUE; break;
+        case 3: mask |= ALARM_REPEAT_WED; break;
+        case 4: mask |= ALARM_REPEAT_THU; break;
+        case 5: mask |= ALARM_REPEAT_FRI; break;
+        case 6: mask |= ALARM_REPEAT_SAT; break;
+        case 7: mask |= ALARM_REPEAT_SUN; break;
+        default: break;
+        }
+    }
+    return mask ? mask : ALARM_REPEAT_ALL;
+}
+
+static void copy_str(char *dest, size_t dest_size, const char *src)
+{
+    if (!dest || dest_size == 0) {
+        return;
+    }
+    dest[0] = '\0';
+    if (src && src[0]) {
+        strlcpy(dest, src, dest_size);
+    }
+}
+
+static time_t wall_now(struct tm *out)
+{
+    struct tm now = {};
+    if (!Board_Get_Wall_Clock(&now)) {
+        time_t t = 0;
+        time(&t);
+        localtime_r(&t, &now);
+    }
+    if (out) {
+        *out = now;
+    }
+    now.tm_isdst = -1;
+    return mktime(&now);
+}
+
+static int on_complete_from_params(const cJSON *params)
+{
+    if (!params) {
+        return SCHEDULE_END_DEFAULT;
+    }
+    const cJSON *item = cJSON_GetObjectItem(params, "on_complete");
+    if (cJSON_IsString(item) && item->valuestring) {
+        if (strcmp(item->valuestring, "power_off") == 0) {
+            return SCHEDULE_END_POWER_OFF;
+        }
+        if (strcmp(item->valuestring, "chat") == 0) {
+            return SCHEDULE_END_CHAT;
+        }
+    }
+    const cJSON *power = cJSON_GetObjectItem(params, "power_off_after");
+    if (cJSON_IsTrue(power) || (cJSON_IsNumber(power) && power->valueint != 0)) {
+        return SCHEDULE_END_POWER_OFF;
+    }
+    return SCHEDULE_END_DEFAULT;
+}
+
+static int duration_minutes_from_params(const cJSON *params)
+{
+    if (!params) {
+        return 0;
+    }
+    const cJSON *item = cJSON_GetObjectItem(params, "duration_minutes");
+    if (cJSON_IsNumber(item) && item->valuedouble > 0) {
+        int value = (int)item->valuedouble;
+        if (value > 24 * 60) {
+            value = 24 * 60;
+        }
+        return value;
+    }
+    return 0;
+}
+
+static bool parse_one_task(const cJSON *obj, sched_task_t *out)
+{
+    if (!cJSON_IsObject(obj) || !out) {
+        return false;
+    }
+    memset(out, 0, sizeof(*out));
+    const cJSON *id = cJSON_GetObjectItem(obj, "id");
+    const cJSON *app = cJSON_GetObjectItem(obj, "app_id");
+    const cJSON *task = cJSON_GetObjectItem(obj, "task_id");
+    const cJSON *enabled = cJSON_GetObjectItem(obj, "enabled");
+    const cJSON *params = cJSON_GetObjectItem(obj, "params");
+    const cJSON *schedule = cJSON_GetObjectItem(obj, "schedule");
+    if (!cJSON_IsString(id) || !id->valuestring[0] || !cJSON_IsString(app) || !app->valuestring[0]) {
+        return false;
+    }
+    copy_str(out->id, sizeof(out->id), id->valuestring);
+    copy_str(out->app_id, sizeof(out->app_id), app->valuestring);
+    copy_str(out->task_id, sizeof(out->task_id),
+             (cJSON_IsString(task) && task->valuestring) ? task->valuestring : "");
+    out->enabled = enabled ? !cJSON_IsFalse(enabled) : 1;
+    if (params) {
+        char *printed = cJSON_PrintUnformatted(params);
+        if (printed) {
+            copy_str(out->params_json, sizeof(out->params_json), printed);
+            free(printed);
+        }
+    } else {
+        copy_str(out->params_json, sizeof(out->params_json), "{}");
+    }
+
+    const cJSON *type = schedule ? cJSON_GetObjectItem(schedule, "type") : NULL;
+    const char *type_str = (cJSON_IsString(type) && type->valuestring) ? type->valuestring : "daily";
+    if (strcmp(type_str, "hourly") == 0) {
+        out->type = SCHEDULE_TYPE_HOURLY;
+        const cJSON *minute = cJSON_GetObjectItem(schedule, "minute");
+        int value = cJSON_IsNumber(minute) ? minute->valueint : 0;
+        if (value < 0 || value > 59) {
+            value = 0;
+        }
+        out->hourly_minute = (uint8_t)value;
+        return true;
+    }
+    if (strcmp(type_str, "interval") == 0) {
+        out->type = SCHEDULE_TYPE_INTERVAL;
+        const cJSON *every = cJSON_GetObjectItem(schedule, "every_minutes");
+        int value = cJSON_IsNumber(every) ? every->valueint : 60;
+        if (value < 1) {
+            value = 1;
+        }
+        if (value > 24 * 60) {
+            value = 24 * 60;
+        }
+        out->every_minutes = (uint16_t)value;
+        return true;
+    }
+    out->type = SCHEDULE_TYPE_DAILY;
+    const cJSON *time_value = schedule ? cJSON_GetObjectItem(schedule, "time") : NULL;
+    if (!cJSON_IsString(time_value) ||
+        !parse_hhmmss(time_value->valuestring, &out->hour, &out->min, &out->sec)) {
+        return false;
+    }
+    out->weekdays = parse_weekdays(schedule ? cJSON_GetObjectItem(schedule, "weekdays") : NULL);
+    ESP_LOGI(TAG, "task %s %s/%s daily %02u:%02u:%02u weekdays=0x%02x",
+             out->id, out->app_id, out->task_id, out->hour, out->min, out->sec, out->weekdays);
+    return true;
+}
+
+static bool load_from_file(void)
+{
+    s_task_count = 0;
+    FILE *fp = fopen(SCHEDULE_PATH, "rb");
+    if (!fp) {
+        // SD 杩樻病鎸傝浇鏃朵笉瑕佽鎴愨�滃凡缁忓姞杞戒笖娌℃湁璁″垝鈥濓紝鍚﹀垯寮�鏈哄悗涓嶅啀閲嶈銆�
+        s_loaded = false;
+        s_task_count = 0;
+        return false;
+    }
+    if (fseek(fp, 0, SEEK_END) != 0) {
+        fclose(fp);
+        return false;
+    }
+    long size = ftell(fp);
+    rewind(fp);
+    if (size <= 0 || size > 49152) {
+        fclose(fp);
+        return false;
+    }
+    char *buf = (char *)malloc((size_t)size + 1);
+    if (!buf) {
+        fclose(fp);
+        return false;
+    }
+    size_t n = fread(buf, 1, (size_t)size, fp);
+    fclose(fp);
+    buf[n] = '\0';
+    cJSON *root = cJSON_Parse(buf);
+    free(buf);
+    if (!root) {
+        return false;
+    }
+    const cJSON *tasks = cJSON_IsArray(root) ? root : cJSON_GetObjectItem(root, "tasks");
+    if (cJSON_IsArray(tasks)) {
+        const cJSON *item = NULL;
+        cJSON_ArrayForEach(item, tasks) {
+            if (s_task_count >= SCHEDULE_MAX_TASKS) {
+                break;
+            }
+            if (parse_one_task(item, &s_tasks[s_task_count])) {
+                s_task_count++;
+            }
+        }
+    }
+    cJSON_Delete(root);
+    s_loaded = true;
+    ESP_LOGI(TAG, "loaded %d scheduled tasks", s_task_count);
+    return true;
+}
+
+static bool next_occurrence(const sched_task_t *task, time_t not_before, time_t *out)
+{
+    if (!task || !out || !task->enabled) {
+        return false;
+    }
+    struct tm now_tm = {};
+    time_t now = wall_now(&now_tm);
+    if (not_before < now) {
+        not_before = now;
+    }
+
+    if (task->type == SCHEDULE_TYPE_HOURLY) {
+        time_t midnight = now - (now_tm.tm_hour * 3600 + now_tm.tm_min * 60 + now_tm.tm_sec);
+        time_t missed = 0;
+        for (int h = 0; h < 24 * 8; ++h) {
+            time_t cand = midnight + h * 3600 + task->hourly_minute * 60;
+            if (cand > not_before) {
+                if (slot_already_claimed(task, cand)) {
+                    continue;
+                }
+                if (missed != 0 && not_before <= now) {
+                    *out = missed;
+                    return true;
+                }
+                *out = cand;
+                return true;
+            }
+            if (not_before <= now && cand <= now && now - cand <= SCHEDULE_DUE_WINDOW_SEC &&
+                !slot_already_claimed(task, cand)) {
+                missed = cand;
+            }
+        }
+        if (missed != 0) {
+            *out = missed;
+            return true;
+        }
+        return false;
+    }
+    if (task->type == SCHEDULE_TYPE_INTERVAL) {
+        int every = task->every_minutes > 0 ? task->every_minutes : 60;
+        time_t step = (time_t)every * 60;
+        if (step < 60) {
+            step = 60;
+        }
+        if (not_before <= now && step > 0) {
+            time_t slot = (now / step) * step;
+            if (slot > 0 && now - slot <= SCHEDULE_DUE_WINDOW_SEC && !slot_already_claimed(task, slot)) {
+                *out = slot;
+                return true;
+            }
+        }
+        time_t cand = ((not_before / step) + 1) * step;
+        if (cand <= not_before) {
+            cand += step;
+        }
+        *out = cand;
+        return true;
+    }
+
+    uint8_t mask = task->weekdays ? task->weekdays : ALARM_REPEAT_ALL;
+    int now_sec = now_tm.tm_hour * 3600 + now_tm.tm_min * 60 + now_tm.tm_sec;
+    int alarm_sec = task->hour * 3600 + task->min * 60 + task->sec;
+    int min_delta = (int)(not_before - now);
+    if (min_delta < 0) {
+        min_delta = 0;
+    }
+    int best_delta = 8 * 24 * 3600;
+    bool found = false;
+    for (int day_offset = 0; day_offset <= 8; ++day_offset) {
+        int tm_wday = (now_tm.tm_wday + day_offset) % 7;
+        if ((mask & tm_wday_to_repeat_bit(tm_wday)) == 0) {
+            continue;
+        }
+        int delta = day_offset * 24 * 3600 + alarm_sec - now_sec;
+        if (delta <= min_delta) {
+            // 鍒氳繃鐐圭殑鍑犲垎閽熷唴浠嶇畻鏈锛堢‖浠跺紑鏈恒�佽蒋鍏虫満鍞ら啋锛夛紝涓嶈鐩存帴鎺掑埌涓嬩竴澶┿��
+            if (min_delta <= 0 && delta >= -SCHEDULE_DUE_WINDOW_SEC &&
+                !slot_already_claimed(task, now + delta)) {
+                best_delta = delta;
+                found = true;
+            }
+            continue;
+        }
+        if (slot_already_claimed(task, now + (time_t)delta)) {
+            continue;
+        }
+        if (found && best_delta <= 0) {
+            break;
+        }
+        if (delta < best_delta) {
+            best_delta = delta;
+            found = true;
+        }
+    }
+    if (!found) {
+        return false;
+    }
+    *out = now + best_delta;
+    return true;
+}
+
+static void clear_next(void)
+{
+    s_next_valid = false;
+    s_next_unix = 0;
+    s_next_id[0] = '\0';
+    s_next_app[0] = '\0';
+    s_next_task[0] = '\0';
+    copy_str(s_next_params, sizeof(s_next_params), "{}");
+    DS2312_Clear_Schedule_Wake();
+}
+
+static void cancel_duration_timer(void)
+{
+    if (s_duration_timer) {
+        esp_timer_stop(s_duration_timer);
+    }
+}
+
+static void duration_timer_cb(void *arg)
+{
+    (void)arg;
+    Application::GetInstance().Schedule([]() {
+        s_duration_fired = true;
+        Board_Schedule_OnNaturalEnd();
+    });
+}
+
+static void ensure_duration_timer(void)
+{
+    if (s_duration_timer) {
+        return;
+    }
+    const esp_timer_create_args_t args = {
+        .callback = duration_timer_cb,
+        .arg = NULL,
+        .dispatch_method = ESP_TIMER_TASK,
+        .name = "sched_dur",
+        .skip_unhandled_events = true,
+    };
+    if (esp_timer_create(&args, &s_duration_timer) != ESP_OK) {
+        s_duration_timer = NULL;
+    }
+}
+
+static bool is_playlist_task(const char *task_id)
+{
+    return task_id && (strcmp(task_id, "repeater") == 0 || strcmp(task_id, "shadowing") == 0);
+}
+
+static bool claim_slot_recorded(const char *id, time_t cand)
+{
+    if (!id || id[0] == '\0' || cand <= 0) {
+        return false;
+    }
+    for (int i = 0; i < s_claimed_count; ++i) {
+        if (s_claimed_slots[i].unix_time == cand && strcmp(s_claimed_slots[i].id, id) == 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static void remember_claim_slot(const char *id, time_t unix_time)
+{
+    if (!id || id[0] == '\0' || unix_time <= 0 || claim_slot_recorded(id, unix_time)) {
+        if (unix_time > s_last_started_unix) {
+            s_last_started_unix = unix_time;
+        }
+        return;
+    }
+    if (s_claimed_count >= CLAIMED_SLOT_MAX) {
+        memmove(&s_claimed_slots[0], &s_claimed_slots[1],
+                sizeof(claimed_slot_t) * (CLAIMED_SLOT_MAX - 1));
+        s_claimed_count = CLAIMED_SLOT_MAX - 1;
+    }
+    copy_str(s_claimed_slots[s_claimed_count].id, sizeof(s_claimed_slots[s_claimed_count].id), id);
+    s_claimed_slots[s_claimed_count].unix_time = unix_time;
+    s_claimed_count++;
+    if (unix_time > s_last_started_unix) {
+        s_last_started_unix = unix_time;
+    }
+}
+
+static bool slot_already_claimed(const sched_task_t *task, time_t cand)
+{
+    if (!task) {
+        return false;
+    }
+    // 鏃堕棿宸茬粡璧板埌鏇存櫄鐨勮鍒掞紝鏇存棭鐨勯偅娆′笉鍐嶅洖澶村惎鍔ㄣ��
+    if (s_last_started_unix > 0 && cand < s_last_started_unix) {
+        return true;
+    }
+    return claim_slot_recorded(task->id, cand);
+}
+
+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 apply_on_complete(bool user_exit)
+{
+    cancel_duration_timer();
+    s_occupying = false;
+    s_busy_until = 0;
+    int complete = s_on_complete;
+    s_on_complete = SCHEDULE_END_DEFAULT;
+    bool from_duration = s_duration_fired;
+    s_duration_fired = false;
+    Board_Schedule_Recompute();
+    if (!user_exit && s_deferred_due && Board_Schedule_IsDueNow(180)) {
+        s_deferred_due = false;
+        Board_Schedule_OnAlarmFired();
+        return;
+    }
+    s_deferred_due = false;
+    if (!user_exit && complete == SCHEDULE_END_POWER_OFF) {
+        Application::GetInstance().RequestPowerOffStandby();
+        return;
+    }
+    if (!user_exit && from_duration) {
+        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
+    }
+}
+
+esp_err_t Board_Schedule_SyncJson(const char *json)
+{
+    if (!json) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    cJSON *root = cJSON_Parse(json);
+    if (!root) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    const cJSON *tasks = cJSON_IsArray(root) ? root : cJSON_GetObjectItem(root, "tasks");
+    if (tasks && !cJSON_IsArray(tasks)) {
+        cJSON_Delete(root);
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (tasks && cJSON_GetArraySize(tasks) > SCHEDULE_MAX_TASKS) {
+        cJSON_Delete(root);
+        return ESP_ERR_NO_MEM;
+    }
+    cJSON *store = cJSON_CreateObject();
+    if (!store) {
+        cJSON_Delete(root);
+        return ESP_ERR_NO_MEM;
+    }
+    cJSON *copy = tasks ? cJSON_Duplicate(tasks, 1) : cJSON_CreateArray();
+    if (!copy) {
+        cJSON_Delete(store);
+        cJSON_Delete(root);
+        return ESP_ERR_NO_MEM;
+    }
+    cJSON_AddItemToObject(store, "tasks", copy);
+    char *printed = cJSON_PrintUnformatted(store);
+    cJSON_Delete(store);
+    cJSON_Delete(root);
+    if (!printed) {
+        return ESP_ERR_NO_MEM;
+    }
+    FILE *fp = fopen(SCHEDULE_PATH, "wb");
+    if (!fp) {
+        free(printed);
+        return ESP_FAIL;
+    }
+    size_t len = strlen(printed);
+    size_t wrote = fwrite(printed, 1, len, fp);
+    fclose(fp);
+    free(printed);
+    if (wrote != len) {
+        return ESP_FAIL;
+    }
+    s_loaded = false;
+    if (!load_from_file()) {
+        return ESP_FAIL;
+    }
+    for (int i = 0; i < s_task_count; ++i) {
+        if (!s_tasks[i].enabled) {
+            continue;
+        }
+        Board_RememberLastLessonTask(s_tasks[i].task_id, s_tasks[i].params_json);
+    }
+    return Board_Schedule_Recompute();
+}
+
+void Board_Schedule_Reload(void)
+{
+    s_loaded = false;
+    s_task_count = 0;
+}
+
+esp_err_t Board_Schedule_Recompute(void)
+{
+    if (!s_loaded && !load_from_file()) {
+        ESP_LOGW(TAG, "schedule file not ready");
+        return ESP_FAIL;
+    }
+    struct tm now_tm = {};
+    time_t now = wall_now(&now_tm);
+    time_t not_before = now;
+    // 璇炬枃璁″垝鎾畬鍚庡鏋滄病鏀跺埌缁撴潫鍥炶皟锛屼笉瑕佺户缁崰鐫�涓嬩竴娆¢椆閽熴��
+    if (s_occupying && s_busy_until <= now && !Board_IsLessonPlaybackActive()) {
+        s_occupying = false;
+        s_busy_until = 0;
+    }
+    // 鍙湁璁惧畾浜嗘寔缁椂闀挎墠寰�鍚庢帹銆傛鍦ㄦ硾鍚笉鑳芥妸璺熻鎺ㄨ繜 12 灏忔椂銆�
+    if (s_occupying && s_busy_until > now) {
+        not_before = s_busy_until;
+    }
+
+    int best_index = -1;
+    time_t best_unix = 0;
+    bool best_due = false;
+    for (int i = 0; i < s_task_count; ++i) {
+        time_t cand = 0;
+        if (!next_occurrence(&s_tasks[i], not_before, &cand)) {
+            continue;
+        }
+        bool due = cand <= now;
+        bool take = false;
+        if (best_index < 0) {
+            take = true;
+        } else if (due && !best_due) {
+            take = true;
+        } else if (due && best_due && cand > best_unix) {
+            take = true;
+        } else if (!due && !best_due && cand < best_unix) {
+            take = true;
+        }
+        if (take) {
+            best_index = i;
+            best_unix = cand;
+            best_due = due;
+        }
+    }
+    if (best_index < 0) {
+        ESP_LOGI(TAG, "no upcoming scheduled task");
+        clear_next();
+        return ESP_OK;
+    }
+
+    const sched_task_t *best = &s_tasks[best_index];
+    copy_str(s_next_id, sizeof(s_next_id), best->id);
+    copy_str(s_next_app, sizeof(s_next_app), best->app_id);
+    copy_str(s_next_task, sizeof(s_next_task), best->task_id);
+    copy_str(s_next_params, sizeof(s_next_params), best->params_json[0] ? best->params_json : "{}");
+    s_next_unix = best_unix;
+    s_next_valid = true;
+
+    struct tm fire_tm = {};
+    localtime_r(&best_unix, &fire_tm);
+    uint8_t bit = tm_wday_to_repeat_bit(fire_tm.tm_wday);
+    DS2312_Set_Schedule_Wake(fire_tm.tm_hour, fire_tm.tm_min, fire_tm.tm_sec, bit);
+    ESP_LOGI(TAG, "next schedule id=%s %s/%s at %02d:%02d:%02d unix=%ld",
+             s_next_id, s_next_app, s_next_task, fire_tm.tm_hour, fire_tm.tm_min, fire_tm.tm_sec,
+             (long)best_unix);
+    return ESP_OK;
+}
+
+bool Board_Schedule_IsDueNow(int window_sec)
+{
+    if (!s_next_valid) {
+        if (!s_loaded) {
+            load_from_file();
+            Board_Schedule_Recompute();
+        }
+        if (!s_next_valid) {
+            return false;
+        }
+    }
+    time_t now = wall_now(NULL);
+    if (window_sec < 1) {
+        window_sec = SCHEDULE_DUE_WINDOW_SEC;
+    }
+    time_t late = now - s_next_unix;
+    // 璁″垝鏃堕棿鏈埌灏变笉瑕佸惎鍔紝閬垮厤涓嬪彂鍚庢彁鍓嶆墦鏂鍦ㄨ繘琛岀殑瀵硅瘽銆�
+    if (late < 0) {
+        return false;
+    }
+    return late <= window_sec;
+}
+
+bool Board_Schedule_LoadDueTask(char *app_id, size_t app_size, char *task_id, size_t task_size,
+                                char *params_json, size_t params_size)
+{
+    if (!s_next_valid) {
+        return false;
+    }
+    copy_str(app_id, app_size, s_next_app);
+    copy_str(task_id, task_size, s_next_task);
+    copy_str(params_json, params_size, s_next_params[0] ? s_next_params : "{}");
+    return app_id && app_id[0];
+}
+
+bool Board_Schedule_ClaimDue(void)
+{
+    if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
+        return false;
+    }
+    if (claim_slot_recorded(s_next_id, s_next_unix)) {
+        return false;
+    }
+    remember_claim_slot(s_next_id, s_next_unix);
+    return true;
+}
+
+static void remember_claimed_schedule(const char *task_id)
+{
+    if (!s_next_valid || !task_id || strcmp(task_id, s_next_task) != 0) {
+        return;
+    }
+    if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
+        return;
+    }
+    remember_claim_slot(s_next_id, s_next_unix);
+}
+
+static void arm_wait_lesson_files(const char *app_id, const char *task_id, const char *params)
+{
+    if (!s_wait_lesson_files) {
+        ESP_LOGW(TAG, "schedule %s/%s files not ready, wait for sync", app_id, task_id);
+        Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+        s_wait_lesson_files_since = wall_now(NULL);
+    }
+    s_wait_lesson_files = true;
+    copy_str(s_pending_app, sizeof(s_pending_app), app_id);
+    copy_str(s_pending_task, sizeof(s_pending_task), task_id);
+    copy_str(s_pending_params, sizeof(s_pending_params), (params && params[0]) ? params : "{}");
+    Board_Schedule_Recompute();
+}
+
+void Board_Schedule_OnAlarmFired(void)
+{
+    if (s_fire_queued || s_deferred_due) {
+        return;
+    }
+    s_fire_queued = true;
+    Application::GetInstance().Schedule([]() {
+        s_fire_queued = false;
+        if (!s_next_valid && !Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
+            Board_Schedule_Recompute();
+            return;
+        }
+        if (!Board_Schedule_IsDueNow(SCHEDULE_DUE_WINDOW_SEC)) {
+            Board_Schedule_Recompute();
+            return;
+        }
+        if (Board_AppTask_CurrentPriority() >= Board_AppTask_SourcePriority("mqtt_immediate")) {
+            ESP_LOGI(TAG, "defer schedule, immediate task running");
+            s_deferred_due = true;
+            return;
+        }
+        char app_id[SCHEDULE_APP_MAX];
+        char task_id[SCHEDULE_APP_MAX];
+        char params[SCHEDULE_PARAMS_MAX];
+        if (!Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
+                                        params, sizeof(params))) {
+            Board_Schedule_Recompute();
+            return;
+        }
+        if (is_playlist_task(task_id) && !Board_LessonFilesReady(task_id, params)) {
+            arm_wait_lesson_files(app_id, task_id, params);
+            return;
+        }
+        ESP_LOGI(TAG, "alarm start schedule %s/%s", app_id, task_id);
+        s_deferred_due = false;
+        s_wait_lesson_files = false;
+        s_wait_lesson_files_since = 0;
+        Board_StartAppTask(app_id, task_id, params, "schedule");
+    });
+}
+
+void Board_Schedule_OnLessonFilesUpdated(void)
+{
+    if (!s_wait_lesson_files || s_pending_task[0] == '\0' || s_files_poll_queued) {
+        return;
+    }
+    time_t now = wall_now(NULL);
+    if (s_wait_lesson_files_since > 0 && now > s_wait_lesson_files_since + 15 * 60) {
+        ESP_LOGW(TAG, "lesson files still missing after 15 min, skip %s/%s",
+                 s_pending_app, s_pending_task);
+        s_wait_lesson_files = false;
+        s_wait_lesson_files_since = 0;
+        s_pending_task[0] = '\0';
+        Application::GetInstance().Schedule([]() {
+            Board_Schedule_Recompute();
+        });
+        return;
+    }
+    if (!Board_LessonFilesReady(s_pending_task, s_pending_params)) {
+        return;
+    }
+    s_files_poll_queued = true;
+    Application::GetInstance().Schedule([]() {
+        s_files_poll_queued = false;
+        if (!s_wait_lesson_files || s_pending_task[0] == '\0') {
+            return;
+        }
+        char app_id[SCHEDULE_APP_MAX];
+        char task_id[SCHEDULE_APP_MAX];
+        char params[SCHEDULE_PARAMS_MAX];
+        copy_str(app_id, sizeof(app_id), s_pending_app);
+        copy_str(task_id, sizeof(task_id), s_pending_task);
+        copy_str(params, sizeof(params), s_pending_params[0] ? s_pending_params : "{}");
+        if (!Board_LessonFilesReady(task_id, params)) {
+            return;
+        }
+        ESP_LOGI(TAG, "lesson files ready, start schedule %s/%s", app_id, task_id);
+        s_wait_lesson_files = false;
+        s_wait_lesson_files_since = 0;
+        s_pending_task[0] = '\0';
+        Board_StartAppTask(app_id, task_id, params, "schedule");
+    });
+}
+
+void Board_Schedule_OnTaskStarted(const char *app_id, const char *task_id, const char *source,
+                                  const char *params_json)
+{
+    if (source && strcmp(source, "schedule") == 0) {
+        remember_claimed_schedule(task_id);
+    }
+    cancel_duration_timer();
+    cJSON *params = params_json ? cJSON_Parse(params_json) : NULL;
+    s_on_complete = on_complete_from_params(params);
+    int duration = duration_minutes_from_params(params);
+    if (params) {
+        cJSON_Delete(params);
+    }
+
+    bool chat = is_chat_task(app_id, task_id);
+    bool schedule_or_immediate =
+        source && (strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0);
+    bool parent_end_to_chat = chat && schedule_or_immediate && duration <= 0;
+    if (chat && (!source || strcmp(source, "voice") == 0 || strcmp(source, "default_chat") == 0
+            || parent_end_to_chat)) {
+        if (source && (strcmp(source, "voice") == 0 || parent_end_to_chat)) {
+            s_deferred_due = false;
+        }
+        s_occupying = false;
+        s_busy_until = 0;
+        s_on_complete = SCHEDULE_END_DEFAULT;
+        Board_Schedule_Recompute();
+        return;
+    }
+
+    s_occupying = schedule_or_immediate;
+    time_t now = wall_now(NULL);
+    if (schedule_or_immediate && !is_playlist_task(task_id) && duration > 0) {
+        s_busy_until = now + (time_t)duration * 60;
+        ensure_duration_timer();
+        if (s_duration_timer) {
+            esp_timer_stop(s_duration_timer);
+            esp_timer_start_once(s_duration_timer, (uint64_t)duration * 60ULL * 1000000ULL);
+        }
+    } else {
+        s_busy_until = 0;
+    }
+    if (source && strcmp(source, "mqtt_immediate") == 0) {
+        return;
+    }
+    Board_Schedule_Recompute();
+}
+
+void Board_Schedule_OnNaturalEnd(void)
+{
+    apply_on_complete(false);
+}
+
+void Board_Schedule_OnUserExit(void)
+{
+    s_deferred_due = false;
+    apply_on_complete(true);
+}
+
+void Board_Schedule_OnEnteringPowerOff(void)
+{
+    cancel_duration_timer();
+    s_occupying = false;
+    s_busy_until = 0;
+    s_duration_fired = false;
+    Board_Schedule_Recompute();
+}
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h
new file mode 100644
index 0000000..85ed1b4
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/parent_schedule.h
@@ -0,0 +1,32 @@
+#ifndef PARENT_SCHEDULE_H
+#define PARENT_SCHEDULE_H
+
+#include "esp_err.h"
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+esp_err_t Board_Schedule_SyncJson(const char *json);
+void Board_Schedule_Reload(void);
+esp_err_t Board_Schedule_Recompute(void);
+bool Board_Schedule_IsDueNow(int window_sec);
+bool Board_Schedule_LoadDueTask(char *app_id, size_t app_size, char *task_id, size_t task_size,
+                                char *params_json, size_t params_size);
+bool Board_Schedule_ClaimDue(void);
+void Board_Schedule_OnAlarmFired(void);
+void Board_Schedule_OnLessonFilesUpdated(void);
+void Board_Schedule_OnTaskStarted(const char *app_id, const char *task_id, const char *source,
+                                  const char *params_json);
+void Board_Schedule_OnNaturalEnd(void);
+void Board_Schedule_OnUserExit(void);
+void Board_Schedule_OnEnteringPowerOff(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
index 7561ee8..a522ba8 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
@@ -25,6 +25,7 @@
 #include <cmath>
 #include <cstdio>
 #include <cstring>
+#include <memory>
 #include <string>
 
 #include <esp_lcd_panel_io.h>
@@ -41,6 +42,7 @@
 #include <ds3231.h>
 #include "system.h"
 #include "mqtt.h"
+#include "parent_schedule.h"
 
 #define TAG "sening_xiaozhi_s3"
 #define WIFI_RECONNECT_CONNECTED_BIT BIT0
@@ -119,6 +121,68 @@
 {
     DeviceState state = Application::GetInstance().GetDeviceState();
     return state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking;
+}
+
+extern "C" void Board_HoldIdleForServerPrompt(void)
+{
+    Application::GetInstance().Schedule([]() {
+        auto& app = Application::GetInstance();
+        app.HoldIdleForServerPrompt();
+        if (app.GetDeviceState() == kDeviceStateSpeaking) {
+            app.AbortSpeaking(kAbortReasonNone);
+        }
+    });
+}
+
+extern "C" void Board_ReleaseHoldIdle(void)
+{
+    Application::GetInstance().Schedule([]() {
+        Application::GetInstance().ReleaseHoldIdle();
+    });
+}
+
+extern "C" void Board_SetLessonDownloadBoost(bool enable)
+{
+    Board::GetInstance().SetPowerSaveLevel(
+        enable ? PowerSaveLevel::PERFORMANCE : PowerSaveLevel::LOW_POWER);
+}
+
+extern "C" void Board_AnnounceLessonSyncAndWait(void)
+{
+    Board_SetLessonDownloadBoost(true);
+    Application::GetInstance().Schedule([]() {
+        auto& app = Application::GetInstance();
+        app.HoldIdleForServerPrompt();
+        app.Alert("姝e湪鍚屾", "姝e湪鍚屾闊抽锛岃绋嶇瓑", "cloud_arrow_down");
+        if (!app.SendListenDetectNow("[lesson_sync]")) {
+            app.SendListenDetect("[lesson_sync]", true);
+        }
+    });
+
+    int waited_ms = 0;
+    bool saw_speaking = false;
+    while (waited_ms < 8000) {
+        DeviceState state = Application::GetInstance().GetDeviceState();
+        if (state == kDeviceStateSpeaking) {
+            saw_speaking = true;
+        } else if (saw_speaking && state == kDeviceStateIdle) {
+            break;
+        }
+        vTaskDelay(pdMS_TO_TICKS(100));
+        waited_ms += 100;
+    }
+    if (!saw_speaking) {
+        vTaskDelay(pdMS_TO_TICKS(2500));
+    }
+    Application::GetInstance().Schedule([]() {
+        auto& app = Application::GetInstance();
+        app.HoldIdleForServerPrompt();
+        app.GetAudioService().EnableVoiceProcessing(false);
+        app.GetAudioService().EnableWakeWordDetection(false);
+        if (app.GetDeviceState() == kDeviceStateListening) {
+            app.SetDeviceState(kDeviceStateIdle);
+        }
+    });
 }
 
 extern "C" bool Board_Is_Audio_Busy(void)
@@ -246,11 +310,21 @@
         return ESP_FAIL;
     }
 
+    constexpr size_t IO_BUF_SIZE = 16384;
+    char *io_buf = (char *)heap_caps_malloc(IO_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+    if (io_buf && setvbuf(fp, io_buf, _IOFBF, IO_BUF_SIZE) != 0) {
+        heap_caps_free(io_buf);
+        io_buf = nullptr;
+    }
+
     auto network = Board::GetInstance().GetNetwork();
     auto http = network->CreateHttp(0);
-    http->SetTimeout(30000);
+    http->SetTimeout(120000);
     if (!http->Open("GET", url)) {
         fclose(fp);
+        if (io_buf) {
+            heap_caps_free(io_buf);
+        }
         SetDownloadError(error_info, error_info_size, "open http failed");
         return ESP_FAIL;
     }
@@ -259,6 +333,9 @@
     if (status != 200) {
         fclose(fp);
         http->Close();
+        if (io_buf) {
+            heap_caps_free(io_buf);
+        }
         char message[64];
         snprintf(message, sizeof(message), "http status %d", status);
         SetDownloadError(error_info, error_info_size, message);
@@ -266,11 +343,27 @@
     }
 
     size_t content_length = http->GetBodyLength();
-    constexpr size_t BUFFER_SIZE = 1024;
-    char *buffer = (char *)heap_caps_malloc(BUFFER_SIZE, MALLOC_CAP_INTERNAL);
+    ESP_LOGI(TAG, "download start: %s content_length=%u", url, (unsigned)content_length);
+    constexpr size_t BUFFER_SIZE = 16384;
+    size_t buffer_size = BUFFER_SIZE;
+    char *buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+    if (!buffer) {
+        buffer_size = 4096;
+        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+    }
+    if (!buffer) {
+        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_INTERNAL);
+    }
+    if (!buffer) {
+        buffer_size = 1024;
+        buffer = (char *)heap_caps_malloc(buffer_size, MALLOC_CAP_INTERNAL);
+    }
     if (!buffer) {
         fclose(fp);
         http->Close();
+        if (io_buf) {
+            heap_caps_free(io_buf);
+        }
         SetDownloadError(error_info, error_info_size, "no memory for download buffer");
         return ESP_ERR_NO_MEM;
     }
@@ -283,12 +376,14 @@
     }
 
     size_t total_read = 0;
+    size_t recent_read = 0;
     int last_progress = -1;
     int64_t last_report = 0;
+    int64_t last_speed_us = esp_timer_get_time();
     bool ok = true;
     bool canceled = false;
     while (true) {
-        int ret = http->Read(buffer, BUFFER_SIZE);
+        int ret = http->Read(buffer, buffer_size);
         if (ret < 0) {
             ESP_LOGE(TAG, "read HTTP failed: %d", ret);
             SetDownloadError(error_info, error_info_size, "read http failed");
@@ -310,9 +405,16 @@
         }
 
         total_read += ret;
+        recent_read += (size_t)ret;
+        int64_t now = esp_timer_get_time();
+        if (now - last_speed_us >= 1000000) {
+            ESP_LOGI(TAG, "download %u/%u bytes %uB/s",
+                     (unsigned)total_read, (unsigned)content_length, (unsigned)recent_read);
+            last_speed_us = now;
+            recent_read = 0;
+        }
         if (progress_cb) {
             int progress = content_length > 0 ? (int)(total_read * 100 / content_length) : 0;
-            int64_t now = esp_timer_get_time();
             bool report = content_length <= 0 || progress != last_progress || now - last_report >= 1000000 || progress >= 100;
             if (report) {
                 last_progress = progress;
@@ -338,6 +440,9 @@
     fclose(fp);
     http->Close();
     heap_caps_free(buffer);
+    if (io_buf) {
+        heap_caps_free(io_buf);
+    }
 
     if (!ok) {
         if (check_md5) {
@@ -776,8 +881,10 @@
                     task_id.c_str());
                 Board_SetShadowingListenRatio(
                     static_cast<float>(properties["listen_ratio"].value<double>()));
-                sdcard_mp3_playlist_item_t playlist[SDCARD_MP3_PLAYLIST_MAX];
+                std::unique_ptr<sdcard_mp3_playlist_item_t, void(*)(void*)> playlist(
+                    nullptr, heap_caps_free);
                 int playlist_n = 0;
+                int playlist_cap = 0;
                 if (!items_json.empty()) {
                     cJSON *root = cJSON_Parse(items_json.c_str());
                     cJSON *arr = root;
@@ -785,9 +892,25 @@
                         arr = cJSON_GetObjectItem(root, "items");
                     }
                     if (cJSON_IsArray(arr)) {
+                        const int incoming = cJSON_GetArraySize(arr);
+                        playlist_cap = incoming;
+                        if (playlist_cap > SDCARD_MP3_PLAYLIST_MAX) {
+                            ESP_LOGW(TAG, "playlist truncated incoming=%d max=%d",
+                                     incoming, SDCARD_MP3_PLAYLIST_MAX);
+                            playlist_cap = SDCARD_MP3_PLAYLIST_MAX;
+                        }
+                        if (playlist_cap > 0) {
+                            playlist.reset(static_cast<sdcard_mp3_playlist_item_t *>(
+                                heap_caps_calloc(playlist_cap, sizeof(sdcard_mp3_playlist_item_t),
+                                                 MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)));
+                            if (!playlist) {
+                                cJSON_Delete(root);
+                                return std::string("{\"accepted\":false,\"error\":\"no_mem\"}");
+                            }
+                        }
                         cJSON *it = nullptr;
                         cJSON_ArrayForEach(it, arr) {
-                            if (playlist_n >= SDCARD_MP3_PLAYLIST_MAX) {
+                            if (playlist_n >= playlist_cap) {
                                 break;
                             }
                             const cJSON *name_item = cJSON_GetObjectItem(it, "name");
@@ -800,22 +923,22 @@
                                 continue;
                             }
                             if (!SDCard_Resolve_MP3_Path(name_item->valuestring,
-                                                         playlist[playlist_n].path,
-                                                         sizeof(playlist[playlist_n].path))) {
+                                                         playlist.get()[playlist_n].path,
+                                                         sizeof(playlist.get()[playlist_n].path))) {
                                 ESP_LOGW(TAG, "playlist skip missing %s", name_item->valuestring);
                                 continue;
                             }
                             const cJSON *rep = cJSON_GetObjectItem(it, "repeat");
-                            playlist[playlist_n].repeat = cJSON_IsNumber(rep) ? rep->valueint : 1;
-                            if (playlist[playlist_n].repeat < 1) {
-                                playlist[playlist_n].repeat = 1;
+                            playlist.get()[playlist_n].repeat = cJSON_IsNumber(rep) ? rep->valueint : 1;
+                            if (playlist.get()[playlist_n].repeat < 1) {
+                                playlist.get()[playlist_n].repeat = 1;
                             }
                             const cJSON *spd = cJSON_GetObjectItem(it, "speed");
-                            playlist[playlist_n].speed = cJSON_IsNumber(spd)
+                            playlist.get()[playlist_n].speed = cJSON_IsNumber(spd)
                                 ? SDCard_Clamp_MP3_Speed(static_cast<float>(spd->valuedouble))
                                 : 1.0f;
                             const cJSON *po = cJSON_GetObjectItem(it, "power_off_after");
-                            playlist[playlist_n].power_off_after = cJSON_IsTrue(po);
+                            playlist.get()[playlist_n].power_off_after = cJSON_IsTrue(po);
                             playlist_n++;
                         }
                     }
@@ -832,13 +955,13 @@
                         repeat = 1;
                     }
                 } else {
-                    name = playlist[0].path;
-                    speed = playlist[0].speed;
-                    repeat = playlist[0].repeat;
-                    power_off_after = playlist[playlist_n - 1].power_off_after;
+                    name = playlist.get()[0].path;
+                    speed = playlist.get()[0].speed;
+                    repeat = playlist.get()[0].repeat;
+                    power_off_after = playlist.get()[playlist_n - 1].power_off_after;
                 }
                 int64_t now = esp_timer_get_time();
-                // 璺熻/澶嶈浼氳繛缁挱鍚屼竴鏂囦欢锛涗笉瑕佹寜璺緞鍘婚噸銆俧orce 缁欐棫鍥轰欢閲嶈瘯鐢ㄣ��
+                // 璺熻/娉涘惉浼氳繛缁挱鍚屼竴鏂囦欢锛涗笉瑕佹寜璺緞鍘婚噸銆俧orce 缁欐棫鍥轰欢閲嶈瘯鐢ㄣ��
                 bool lesson = (task_id == "shadowing" || task_id == "repeater");
                 if (!force && !lesson && playlist_n < 2 && name == last_mp3_play_name &&
                     std::fabs(speed - last_mp3_play_speed) < 0.001f &&
@@ -853,9 +976,9 @@
                 audio.EnableWakeWordDetection(false);
                 esp_err_t err = ESP_FAIL;
                 if (playlist_n > 0) {
-                    err = SDCard_Play_MP3_File_Playlist(playlist, playlist_n);
+                    err = SDCard_Play_MP3_File_Playlist(playlist.get(), playlist_n);
                     if (err == ESP_OK) {
-                        SDCard_Show_Matching_JPG_For_MP3_Path(playlist[0].path);
+                        SDCard_Show_Matching_JPG_For_MP3_Path(playlist.get()[0].path);
                     }
                 } else {
                     err = SDCard_Play_MP3_With_Speed_Repeat(name.data(), speed, repeat, power_off_after);
@@ -1108,6 +1231,7 @@
 
 extern "C" esp_err_t Board_Enter_Power_Off(void)
 {
+    Board_Schedule_OnEnteringPowerOff();
     auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
     return board.EnterPowerOffStandby();
 }
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
index d1c2a1f..c892541 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
@@ -1,6 +1,7 @@
 #include "system.h"
 #include "config.h"
 #include "app_task.h"
+#include "parent_schedule.h"
 #include "display/lvgl_display/jpg/jpeg_to_image.h"
 
 #include <esp_heap_caps.h>
@@ -71,6 +72,8 @@
 static bool battery_charging_gpio_initialized;
 static alarm_entry_t current_alarm1_entry;
 static bool current_alarm1_valid;
+static alarm_entry_t schedule_wake_entry;
+static bool schedule_wake_valid;
 static TaskHandle_t alarm_monitor_task_handle;
 static TaskHandle_t alarm_power_off_restore_task_handle;
 
@@ -89,6 +92,8 @@
 static bool get_current_time(i2c_dev_t *i2c_dev, struct tm *timeinfo);
 static int select_next_alarm_index(const alarm_store_t *store, const struct tm *now);
 static int alarm_delta_seconds(const alarm_entry_t *entry, const struct tm *now);
+static const alarm_entry_t *select_soonest_alarm(const alarm_store_t *store, const struct tm *now);
+static void program_soonest_alarm(i2c_dev_t *i2c_dev, const struct tm *now);
 static int mqtt_weekday_to_tm_wday(int day);
 static uint8_t tm_wday_to_repeat_bit(int tm_wday);
 static bool alarm_entry_equals(const alarm_entry_t *left, const alarm_entry_t *right);
@@ -159,10 +164,13 @@
     if (!command || command[0] == '\0') {
         return false;
     }
+    if (Board_Is_Chat_Return_Command(command)) {
+        return false;
+    }
     return strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
            strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
-           strstr(command, OFFLINE_REPEATER_WORD) ||
-           strstr(command, OFFLINE_START_REPEATER_WORD);
+           strstr(command, OFFLINE_START_REPEATER_WORD) ||
+           strstr(command, "鎵撳紑娉涘惉");
 }
 
 bool Board_Handle_Offline_Command(const char *command)
@@ -198,20 +206,52 @@
     esp_sntp_init();
 }
 
-void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time)
+static const alarm_entry_t *select_soonest_alarm(const alarm_store_t *store, const struct tm *now)
+{
+    const alarm_entry_t *best = NULL;
+    int best_delta = SECONDS_PER_DAY * 8 + 1;
+    if (store && store->count > 0) {
+        int index = select_next_alarm_index(store, now);
+        if (index >= 0) {
+            best = &store->entries[index];
+            best_delta = alarm_delta_seconds(best, now);
+        }
+    }
+    if (schedule_wake_valid) {
+        int delta = alarm_delta_seconds(&schedule_wake_entry, now);
+        if (delta < best_delta) {
+            best = &schedule_wake_entry;
+        }
+    }
+    return best;
+}
+
+static void program_soonest_alarm(i2c_dev_t *i2c_dev, const struct tm *now)
 {
     alarm_store_t store;
     if (load_alarm_store(&store) != ESP_OK) {
-        disable_alarm1(i2c_dev);
+        memset(&store, 0, sizeof(store));
+    }
+    const alarm_entry_t *best = select_soonest_alarm(&store, now);
+    if (!best) {
+        if (current_alarm1_valid && !Board_Is_Power_Off()) {
+            disable_alarm1(i2c_dev);
+        }
         return;
     }
-
-    if (store.count == 0) {
-        disable_alarm1(i2c_dev);
-        ESP_LOGI(TAG, "alarm list is empty");
+    if (!current_alarm1_valid) {
+        program_alarm1(i2c_dev, best);
         return;
     }
+    int next_delta = alarm_delta_seconds(best, now);
+    int current_delta = alarm_delta_seconds(&current_alarm1_entry, now);
+    if (next_delta < current_delta || !alarm_entry_equals(best, &current_alarm1_entry)) {
+        program_alarm1(i2c_dev, best);
+    }
+}
 
+void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time)
+{
     struct tm now;
     if (time) {
         now = *time;
@@ -219,14 +259,7 @@
         ESP_LOGW(TAG, "current time unavailable, skip alarm update");
         return;
     }
-
-    int index = select_next_alarm_index(&store, &now);
-    if (index < 0) {
-        disable_alarm1(i2c_dev);
-        return;
-    }
-
-    program_alarm1(i2c_dev, &store.entries[index]);
+    program_soonest_alarm(i2c_dev, &now);
 }
 
 void DS2312_Start_Alarm_Monitor(i2c_dev_t *i2c_dev)
@@ -241,6 +274,43 @@
         alarm_monitor_task_handle = NULL;
         ESP_LOGE(TAG, "create alarm monitor task failed");
     }
+}
+
+bool Board_Get_Wall_Clock(struct tm *out)
+{
+    if (!out) {
+        return false;
+    }
+    return get_current_time(ds2321_dev, out);
+}
+
+esp_err_t DS2312_Set_Schedule_Wake(int hour, int min, int sec, uint8_t weekday_bit)
+{
+    if (hour < 0 || hour > 23 || min < 0 || min > 59 || sec < 0 || sec > 59 ||
+        (weekday_bit & ALARM_REPEAT_ALL) == 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    memset(&schedule_wake_entry, 0, sizeof(schedule_wake_entry));
+    strlcpy(schedule_wake_entry.id, "_sched", sizeof(schedule_wake_entry.id));
+    schedule_wake_entry.hour = (uint8_t)hour;
+    schedule_wake_entry.min = (uint8_t)min;
+    schedule_wake_entry.sec = (uint8_t)sec;
+    schedule_wake_entry.flags = ALARM_ENTRY_ENABLED;
+    schedule_wake_entry.repeat_mask = (uint8_t)(weekday_bit & ALARM_REPEAT_ALL);
+    schedule_wake_valid = true;
+    DS2312_Update_Alarm(ds2321_dev, NULL);
+    ESP_LOGI(TAG, "schedule wake %02d:%02d:%02d mask=0x%02x", hour, min, sec, schedule_wake_entry.repeat_mask);
+    return ESP_OK;
+}
+
+void DS2312_Clear_Schedule_Wake(void)
+{
+    if (!schedule_wake_valid) {
+        return;
+    }
+    schedule_wake_valid = false;
+    memset(&schedule_wake_entry, 0, sizeof(schedule_wake_entry));
+    DS2312_Update_Alarm(ds2321_dev, NULL);
 }
 
 void DS2312_Add_Alarm_Time(i2c_dev_t *i2c_dev, int hour, int min, int sec)
@@ -1623,14 +1693,6 @@
     while (true) {
         vTaskDelay(pdMS_TO_TICKS(1000));
 
-        alarm_store_t store;
-        if (load_alarm_store(&store) != ESP_OK || store.count == 0) {
-            if (current_alarm1_valid) {
-                disable_alarm1(i2c_dev);
-            }
-            continue;
-        }
-
         struct tm now;
         if (!get_current_time(i2c_dev, &now)) {
             continue;
@@ -1641,28 +1703,24 @@
             ESP_LOGI(TAG, "DS3231 alarm1 triggered");
             ds3231_clear_alarm_flags(i2c_dev, DS3231_ALARM_1);
             current_alarm1_valid = false;
-            schedule_power_off_restore_from_alarm();
-        }
-
-        int next_index = select_next_alarm_index(&store, &now);
-        if (next_index < 0) {
-            if (current_alarm1_valid && !Board_Is_Power_Off()) {
-                disable_alarm1(i2c_dev);
+            if (Board_Schedule_ClaimDue()) {
+                if (Board_Is_Power_Off()) {
+                    schedule_power_off_restore_from_alarm();
+                } else {
+                    Board_Schedule_OnAlarmFired();
+                }
             }
-            continue;
+        } else if (Board_Schedule_ClaimDue()) {
+            ESP_LOGI(TAG, "schedule wall clock due");
+            if (Board_Is_Power_Off()) {
+                schedule_power_off_restore_from_alarm();
+            } else {
+                Board_Schedule_OnAlarmFired();
+            }
         }
 
-        alarm_entry_t *next_alarm = &store.entries[next_index];
-        if (!current_alarm1_valid) {
-            program_alarm1(i2c_dev, next_alarm);
-            continue;
-        }
-
-        int next_delta = alarm_delta_seconds(next_alarm, &now);
-        int current_delta = alarm_delta_seconds(&current_alarm1_entry, &now);
-        if (next_delta < current_delta || !alarm_entry_equals(next_alarm, &current_alarm1_entry)) {
-            program_alarm1(i2c_dev, next_alarm);
-        }
+        Board_Schedule_OnLessonFilesUpdated();
+        program_soonest_alarm(i2c_dev, &now);
     }
 }
 
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
index 5500bc2..c4989b3 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
@@ -36,7 +36,8 @@
 
 #define MOUNT_POINT "/sdcard"
 #define SDCARD_PATH_MAX_LEN 256
-#define SDCARD_MP3_PLAYLIST_MAX 16
+/* 璺熻/娉涘惉娌℃湁銆岃鏂囧彧鑳� N 鍙ャ�嶇殑涓氬姟涓婇檺銆�256 鍙槸涓�娆� MCP 涓嬪彂鐨勫畨鍏ㄩ《銆� */
+#define SDCARD_MP3_PLAYLIST_MAX 256
 
 typedef struct {
     char path[SDCARD_PATH_MAX_LEN];
@@ -84,19 +85,19 @@
 
 /**
  * @brief 鏉跨骇 MultiNet 绂荤嚎鍛戒护璇嶃�傚彧鐣欐病缃戜篃蹇呴』鑳界敤鐨勫叆鍙o細
- * 銆屽垏鎹㈡ā寮忋�嶃�屾ā寮忓垏鎹€�嶃�屽璇绘満銆嶃�屽紑濮嬪璇汇�嶃�岄��鍑哄璇汇�嶃�岄��鍑鸿窡璇汇�嶃�屾垜浠亰澶╁惂銆嶃�屾垜浠潵鑱婂ぉ鍚с�嶃��
- * 澶嶈/璺熻鎾煶棰戞椂涓嶄笂琛� ASR锛岄��鍑哄彛浠ゅ繀椤昏兘绂荤嚎璇嗗埆銆傚叾瀹� App 鐨勮繘鍏ヨ瘝浠嶅彧鍦� Server apps.json銆�
+ * 銆屽垏鎹㈡ā寮忋�嶃�屾ā寮忓垏鎹€�嶃�屾硾鍚�嶃�屽紑濮嬫硾鍚�嶃�岄��鍑烘硾鍚�嶃�岄��鍑鸿窡璇汇�嶃�屾垜浠亰澶╁惂銆嶃�屾垜浠潵鑱婂ぉ鍚с�嶃��
+ * 娉涘惉/璺熻鎾煶棰戞椂涓嶄笂琛� ASR锛岄��鍑哄彛浠ゅ繀椤昏兘绂荤嚎璇嗗埆銆傚叾瀹� App 鐨勮繘鍏ヨ瘝浠嶅彧鍦� Server apps.json銆�
  */
 #define OFFLINE_COMMAND_WORD "鍒囨崲妯″紡"
 #define OFFLINE_COMMAND_WORD_PINYIN "qie huan mo shi"
 #define OFFLINE_COMMAND_WORD_ALT "妯″紡鍒囨崲"
 #define OFFLINE_COMMAND_WORD_ALT_PINYIN "mo shi qie huan"
-#define OFFLINE_REPEATER_WORD "澶嶈鏈�"
-#define OFFLINE_REPEATER_WORD_PINYIN "fu du ji"
-#define OFFLINE_START_REPEATER_WORD "寮�濮嬪璇�"
-#define OFFLINE_START_REPEATER_WORD_PINYIN "kai shi fu du"
-#define OFFLINE_EXIT_REPEATER_WORD "閫�鍑哄璇�"
-#define OFFLINE_EXIT_REPEATER_WORD_PINYIN "tui chu fu du"
+#define OFFLINE_REPEATER_WORD "娉涘惉"
+#define OFFLINE_REPEATER_WORD_PINYIN "fan ting"
+#define OFFLINE_START_REPEATER_WORD "寮�濮嬫硾鍚�"
+#define OFFLINE_START_REPEATER_WORD_PINYIN "kai shi fan ting"
+#define OFFLINE_EXIT_REPEATER_WORD "閫�鍑烘硾鍚�"
+#define OFFLINE_EXIT_REPEATER_WORD_PINYIN "tui chu fan ting"
 #define OFFLINE_EXIT_SHADOWING_WORD "閫�鍑鸿窡璇�"
 #define OFFLINE_EXIT_SHADOWING_WORD_PINYIN "tui chu gen du"
 #define OFFLINE_CHAT_WORD "鎴戜滑鑱婂ぉ鍚�"
@@ -274,6 +275,19 @@
 bool SDCard_Is_Playing(void);
 
 /**
+ * @brief 璇炬枃寮�鎾墠閲婃斁 AFE銆侀��鍑鸿亞鍚苟绛夊埌鍐呴儴 SRAM 澶� SD DMA銆�
+ *
+ * 蹇呴』鍦ㄥ垱寤� MP3 鎾斁浠诲姟涔嬪墠璋冪敤銆侫FE/MultiNet 鍜� SDMMC DMA 浜夌敤杩炵画鍐呴儴 SRAM
+ * 鏃讹紝fopen/sdmmc_read_blocks 浼氬け璐ワ紝涓ラ噸鏃� newlib 閿佸垵濮嬪寲 abort 閲嶅惎銆�
+ */
+void SDCard_PreparePlaybackAndWait(void);
+
+/**
+ * @brief 璇炬枃 MP3 宸茶繘鍏� PSRAM 鍚庢墠鍏佽绂荤嚎鍙d护 AFE銆�
+ */
+bool SDCard_Mp3OfflineWakeAllowed(void);
+
+/**
  * @brief 鏌ヨ SD 鍗� MP3 鎾斁鏄惁澶勪簬鏆傚仠鐘舵�併��
  *
  * @return true 琛ㄧず鎾斁琚殏鍋滄垨鍚庣画鏂版挱鏀惧簲浠庢殏鍋滅姸鎬佸紑濮嬶紱false 琛ㄧず鏈殏鍋溿��
@@ -431,10 +445,17 @@
 esp_err_t Board_Enter_Power_Off(void);
 esp_err_t Board_Restore_From_Power_Off(int timeout_ms);
 bool Board_Is_Power_Off(void);
+bool Board_Get_Wall_Clock(struct tm *out);
+esp_err_t DS2312_Set_Schedule_Wake(int hour, int min, int sec, uint8_t weekday_bit);
+void DS2312_Clear_Schedule_Wake(void);
 esp_err_t Board_Reboot_Delayed(int delay_seconds);
 void Board_Acquire_Sleep_Inhibit(void);
 void Board_Release_Sleep_Inhibit(void);
 bool Board_Is_Voice_Session_Active(void);
+void Board_HoldIdleForServerPrompt(void);
+void Board_ReleaseHoldIdle(void);
+void Board_AnnounceLessonSyncAndWait(void);
+void Board_SetLessonDownloadBoost(bool enable);
 bool Board_Is_Audio_Busy(void);
 void Board_Show_JPG(const char *jpg_name);
 void Board_Show_Black_Screen(void);
diff --git a/xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h b/xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h
index 27c5d31..a103490 100644
--- a/xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h
+++ b/xiaozhi-esp32-2.2.6/main/display/lvgl_display/jpg/image_to_jpeg.h
@@ -7,11 +7,10 @@
 #include <stdint.h>
 #include <stddef.h>
 
-#if defined(CONFIG_IDF_TARGET_ESP32P4) || defined(CONFIG_IDF_TARGET_ESP32S3)
-// ESP32-P4 浣跨敤 esp_video 缁勪欢鎻愪緵鐨� V4L2 澶存枃浠�
+#if defined(CONFIG_IDF_TARGET_ESP32P4)
+// ESP32-P4 纭欢 JPEG 璧� V4L2锛汼3 鍙敤鍍忕礌鏍煎紡甯搁噺锛岄伩鍏嶄笌 lwip sockets.h 鐨� _IO/_IOR/_IOW 閲嶅畾涔�
 #include <linux/videodev2.h>
 #else
-// ESP32-S3 绛夊叾浠栬姱鐗囷細瀹氫箟甯哥敤鐨� V4L2 鍍忕礌鏍煎紡
 #define V4L2_PIX_FMT_RGB565 0x50424752  // 'RGBP'
 #define V4L2_PIX_FMT_RGB565X 0x52474250 // 'PRGB'
 #define V4L2_PIX_FMT_RGB24 0x33424752   // 'RGB3'
diff --git a/xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc b/xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc
index d232739..ed97b64 100644
--- a/xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc
+++ b/xiaozhi-esp32-2.2.6/main/protocols/websocket_protocol.cc
@@ -97,7 +97,7 @@
     }
 
     // MODEM 浠� MAX_MODEM 鍒囧洖 NONE 涓嶆槸鐬椂瀹屾垚鐨勩�傜珛鍒绘彙鎵嬩細
-    // WebSocket handshake timeout锛屽紑鏈烘杩庤瘝鍜屽璇荤粨鏉熷悗鍥� chat 閮戒細澶辫触銆�
+    // WebSocket handshake timeout锛屽紑鏈烘杩庤瘝鍜屾硾鍚粨鏉熷悗鍥� chat 閮戒細澶辫触銆�
     vTaskDelay(pdMS_TO_TICKS(400));
 
     Settings settings("websocket", false);
diff --git a/xiaozhi-esp32-server/main/manager-api/pom.xml b/xiaozhi-esp32-server/main/manager-api/pom.xml
index 455ee6e..b591f92 100644
--- a/xiaozhi-esp32-server/main/manager-api/pom.xml
+++ b/xiaozhi-esp32-server/main/manager-api/pom.xml
@@ -207,6 +207,11 @@
             <artifactId>bcprov-jdk18on</artifactId>
             <version>1.78</version>
         </dependency>
+        <dependency>
+            <groupId>org.eclipse.paho</groupId>
+            <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
+            <version>1.2.5</version>
+        </dependency>
         <!-- springdoc -->
         <dependency>
             <groupId>org.springdoc</groupId>
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java
index c37ba61..dc8f4c2 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java
@@ -195,4 +195,11 @@
         return "device:address_book:all";
     }
 
+    /**
+     * 瀹堕暱浠诲姟锛氳澶� MQTT 鍦ㄧ嚎鏍囪
+     */
+    public static String getParentDeviceOnlineKey(String macAddress) {
+        return "parent:device:online:" + macAddress;
+    }
+
 }
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
index 693e1ca..96df007 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
@@ -48,6 +48,12 @@
     @Schema(description = "璁惧鏁伴噺", example = "10")
     private Integer deviceCount;
 
+    @Schema(description = "鍦ㄧ嚎璁惧鏁伴噺")
+    private Integer onlineDeviceCount;
+
+    @Schema(description = "缁戝畾璁惧鍙婂湪绾跨姸鎬�")
+    private List<AgentDeviceStatusDTO> devices;
+
     @Schema(description = "鏍囩鍒楄〃")
     private List<AgentTagDTO> tags;
 }
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java
new file mode 100644
index 0000000..d3425c8
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDeviceStatusDTO.java
@@ -0,0 +1,17 @@
+package xiaozhi.modules.agent.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "鏅鸿兘浣撶粦瀹氳澶囩殑鍦ㄧ嚎鐘舵��")
+public class AgentDeviceStatusDTO {
+    @Schema(description = "MAC 鍦板潃")
+    private String macAddress;
+
+    @Schema(description = "璁惧鍒悕")
+    private String alias;
+
+    @Schema(description = "鏄惁鍦ㄧ嚎锛堝闀� MQTT 鐘舵�侊級")
+    private Boolean online;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
index b7c54a7..95cfe84 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
@@ -34,6 +34,7 @@
 import xiaozhi.modules.agent.dao.AgentTagDao;
 import xiaozhi.modules.agent.dto.AgentCreateDTO;
 import xiaozhi.modules.agent.dto.AgentDTO;
+import xiaozhi.modules.agent.dto.AgentDeviceStatusDTO;
 import xiaozhi.modules.agent.dto.AgentMemoryDTO;
 import xiaozhi.modules.agent.dto.AgentTagDTO;
 import xiaozhi.modules.agent.dto.AgentUpdateDTO;
@@ -53,6 +54,7 @@
 import xiaozhi.modules.correctword.service.CorrectWordFileService;
 import xiaozhi.modules.device.entity.DeviceEntity;
 import xiaozhi.modules.device.service.DeviceService;
+import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
 import xiaozhi.modules.model.dto.ModelProviderDTO;
 import xiaozhi.modules.model.dto.VoiceDTO;
 import xiaozhi.modules.model.entity.ModelConfigEntity;
@@ -71,6 +73,7 @@
     private final ModelConfigService modelConfigService;
     private final RedisUtils redisUtils;
     private final DeviceService deviceService;
+    private final ParentDevicePresence devicePresence;
     private final AgentPluginMappingService agentPluginMappingService;
     private final AgentChatHistoryService agentChatHistoryService;
     private final AgentTemplateService agentTemplateService;
@@ -286,8 +289,26 @@
         // 鑾峰彇鏅鸿兘浣撴渶杩戠殑鏈�鍚庤繛鎺ユ椂闀�
         dto.setLastConnectedAt(deviceService.getLatestLastConnectionTime(agent.getId()));
 
-        // 鑾峰彇璁惧鏁伴噺
-        dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
+        List<DeviceEntity> bound = deviceService.getUserDevices(agent.getUserId(), agent.getId());
+        if (bound == null) {
+            bound = List.of();
+        }
+        List<AgentDeviceStatusDTO> devices = new ArrayList<>();
+        int onlineCount = 0;
+        for (DeviceEntity device : bound) {
+            AgentDeviceStatusDTO row = new AgentDeviceStatusDTO();
+            row.setMacAddress(device.getMacAddress());
+            row.setAlias(device.getAlias());
+            boolean online = devicePresence.isOnline(device.getMacAddress());
+            row.setOnline(online);
+            if (online) {
+                onlineCount++;
+            }
+            devices.add(row);
+        }
+        dto.setDevices(devices);
+        dto.setOnlineDeviceCount(onlineCount);
+        dto.setDeviceCount(devices.size());
 
         // 鑾峰彇鏍囩鍒楄〃
         List<AgentTagEntity> tags = agentTagDao.selectByAgentId(agent.getId());
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
index f8a9b70..11c216e 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
@@ -34,6 +34,9 @@
 import xiaozhi.modules.device.service.DeviceAddressBookService;
 import xiaozhi.modules.device.service.DeviceService;
 import xiaozhi.modules.device.vo.UserShowDeviceListVO;
+import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
+import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
+import xiaozhi.modules.parent.service.ParentTaskService;
 import xiaozhi.modules.security.user.SecurityUser;
 import xiaozhi.modules.sys.service.SysParamsService;
 
@@ -45,13 +48,18 @@
     private final DeviceAddressBookService deviceAddressBookService;
     private final RedisUtils redisUtils;
     private final SysParamsService sysParamsService;
+    private final ParentDevicePresence devicePresence;
+    private final ParentTaskService parentTaskService;
 
     public DeviceController(DeviceService deviceService, DeviceAddressBookService deviceAddressBookService,
-            RedisUtils redisUtils, SysParamsService sysParamsService) {
+            RedisUtils redisUtils, SysParamsService sysParamsService, ParentDevicePresence devicePresence,
+            ParentTaskService parentTaskService) {
         this.deviceService = deviceService;
         this.deviceAddressBookService = deviceAddressBookService;
         this.redisUtils = redisUtils;
         this.sysParamsService = sysParamsService;
+        this.devicePresence = devicePresence;
+        this.parentTaskService = parentTaskService;
     }
 
     @PostMapping("/bind/{agentId}/{deviceCode}")
@@ -89,9 +97,25 @@
     public Result<List<UserShowDeviceListVO>> getUserDevices(@PathVariable String agentId) {
         UserDetail user = SecurityUser.getUser();
         List<UserShowDeviceListVO> devices = deviceService.getUserDeviceList(user.getId(), agentId);
+        for (UserShowDeviceListVO device : devices) {
+            device.setOnline(devicePresence.isOnline(device.getMacAddress()));
+        }
         return new Result<List<UserShowDeviceListVO>>().ok(devices);
     }
 
+    @GetMapping("/presence")
+    @Operation(summary = "鏌ヨ璁惧鏄惁鍦ㄧ嚎锛堝闀� MQTT 鐘舵�侊級")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentDeviceStatusDTO> presence(
+            @RequestParam("macAddress") String macAddress,
+            @RequestParam("agentId") String agentId) {
+        DeviceEntity device = parentTaskService.requireUserDevice(SecurityUser.getUserId(), macAddress, agentId);
+        ParentDeviceStatusDTO dto = new ParentDeviceStatusDTO();
+        dto.setMacAddress(device.getMacAddress());
+        dto.setOnline(devicePresence.isOnline(device.getMacAddress()));
+        return new Result<ParentDeviceStatusDTO>().ok(dto);
+    }
+
     @PostMapping("/bind/{agentId}")
     @Operation(summary = "璁惧鍦ㄧ嚎鎺ュ彛")
     @RequiresPermissions("sys:role:normal")
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java
index b00a168..4c969bf 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java
@@ -11,6 +11,9 @@
 @Schema(description = "鐢ㄦ埛鏄剧ず璁惧鍒楄〃VO")
 public class UserShowDeviceListVO {
 
+    @Schema(description = "鏄惁鍦ㄧ嚎锛堝闀� MQTT 鐘舵�侊級")
+    private Boolean online;
+
     @Schema(description = "app鐗堟湰")
     private String appVersion;
 
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java
new file mode 100644
index 0000000..a9bff10
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/IdiomLessonCatalog.java
@@ -0,0 +1,327 @@
+package xiaozhi.modules.parent.catalog;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * 鎴愯璇炬枃娓呭崟銆傚鍒犲彧鏀� classpath:catalog/idiom-lessons.json銆�
+ * 鏁呬簨鎸� 7 绔犳敹褰曚竴娆★紝6 涓� level 鍏辩敤锛沴essonId = 绾у埆.搴忓彿锛堝 l1.001锛夈��
+ * 鍒嗙被 systemId 鍙粰瀹堕暱绔瓫閫夛紝涓嶈繘鏂囦欢璺緞銆�
+ */
+public final class IdiomLessonCatalog {
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<Map<String, Object>>() {
+    };
+    private static final Pattern LESSON = Pattern.compile("^(l[1-6])\\.(\\d{3})$", Pattern.CASE_INSENSITIVE);
+    private static final Map<String, Object> DATA = load();
+    private static final Map<String, Map<String, Object>> BY_ID = indexStories();
+
+    private IdiomLessonCatalog() {
+    }
+
+    public static Map<String, Object> asApiMap() {
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("version", DATA.getOrDefault("version", 2));
+        out.put("defaults", DATA.get("defaults"));
+        out.put("pathPatterns", DATA.get("pathPatterns"));
+        out.put("levels", DATA.get("levels"));
+        out.put("systems", DATA.get("systems"));
+        List<Map<String, Object>> stories = new ArrayList<>();
+        for (Map<String, Object> story : stories()) {
+            if (enabled(story)) {
+                stories.add(publicStory(story));
+            }
+        }
+        out.put("stories", stories);
+        return out;
+    }
+
+    public static Map<String, Object> findStory(String lessonId) {
+        if (lessonId == null || lessonId.isBlank()) {
+            return null;
+        }
+        String key = lessonId.trim().toLowerCase(Locale.ROOT);
+        Map<String, Object> hit = BY_ID.get(key);
+        if (hit != null) {
+            return hit;
+        }
+        String[] parts = parseLesson(key);
+        if (parts != null) {
+            return BY_ID.get(parts[1]);
+        }
+        return null;
+    }
+
+    public static String canonicalLessonId(String lessonId) {
+        Map<String, Object> story = findStory(lessonId);
+        if (story == null) {
+            return lessonId == null ? "" : lessonId.trim();
+        }
+        Map<String, Object> bound = bindStory(story, lessonId);
+        return text(bound.get("levelId")) + "." + text(story.get("id"));
+    }
+
+    public static String storyName(String lessonId) {
+        Map<String, Object> story = findStory(lessonId);
+        return story == null ? "" : text(story.get("name"));
+    }
+
+    public static String storyNameEn(String lessonId) {
+        Map<String, Object> story = findStory(lessonId);
+        return story == null ? "" : text(story.get("nameEn"));
+    }
+
+    public static int sentenceCount(String lessonId) {
+        Map<String, Object> story = findStory(lessonId);
+        if (story == null) {
+            return defaultInt("sentenceCount", 30);
+        }
+        return Math.max(1, Math.min(200, intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30))));
+    }
+
+    @SuppressWarnings("unchecked")
+    public static List<Map<String, Object>> defaultEnPasses() {
+        Map<String, Object> defaults = (Map<String, Object>) DATA.get("defaults");
+        Object raw = defaults == null ? null : defaults.get("enPasses");
+        if (!(raw instanceof List<?> list) || list.isEmpty()) {
+            return List.of(
+                    pass(0.8, 1),
+                    pass(0.9, 2),
+                    pass(1.0, 5));
+        }
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (Object item : list) {
+            Map<String, Object> row = normalizePass(item);
+            if (row != null) {
+                out.add(row);
+            }
+        }
+        return out.isEmpty() ? List.of(pass(0.8, 1), pass(0.9, 2), pass(1.0, 5)) : out;
+    }
+
+    public static List<String> relativePaths(String scene, String lessonId, String language) {
+        Map<String, Object> story = findStory(lessonId);
+        if (story == null) {
+            return List.of();
+        }
+        Map<String, Object> bound = bindStory(story, lessonId);
+        if ("extensive".equals(scene)) {
+            return extensivePaths(bound, lessonId);
+        }
+        if ("shadowing".equals(scene)) {
+            return shadowingPaths(bound, language, sentenceCount(lessonId), lessonId);
+        }
+        return List.of();
+    }
+
+    public static List<Map<String, Object>> lessons(String scene) {
+        if (!"extensive".equals(scene) && !"shadowing".equals(scene)) {
+            return List.of();
+        }
+        String kind = "extensive".equals(scene) ? "娉涘惉" : "璺熻";
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (Map<String, Object> story : stories()) {
+            if (!enabled(story)) {
+                continue;
+            }
+            Map<String, Object> row = publicStory(story);
+            row.put("lessonId", "l1." + text(story.get("id")));
+            row.put("levelId", "l1");
+            row.put("scene", scene);
+            row.put("hint", kind + " 路 " + text(story.get("name")));
+            out.add(row);
+        }
+        return out;
+    }
+
+    private static Map<String, Object> publicStory(Map<String, Object> story) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", text(story.get("id")));
+        row.put("slug", text(story.get("slug")));
+        row.put("systemId", text(story.get("systemId")));
+        row.put("name", text(story.get("name")));
+        row.put("nameEn", text(story.get("nameEn")));
+        row.put("sentenceCount", intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30)));
+        return row;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<String> extensivePaths(Map<String, Object> story, String lessonId) {
+        Map<String, Object> patterns = (Map<String, Object>) DATA.get("pathPatterns");
+        String unified = patterns == null ? "" : text(patterns.get("repeater"));
+        if (!unified.isBlank()) {
+            return List.of(fill(unified, story, "cn", "01"), fill(unified, story, "en", "01"));
+        }
+        String cnPat = patterns == null ? "" : text(patterns.get("repeaterCn"));
+        String enPat = patterns == null ? "" : text(patterns.get("repeaterEn"));
+        return List.of(fill(cnPat, story, "cn", "01"), fill(enPat, story, "en", "01"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<String> shadowingPaths(Map<String, Object> story, String language, int count, String lessonId) {
+        String lang = "cn".equalsIgnoreCase(language) ? "cn" : "en";
+        int width = count > 99 ? 3 : 2;
+        List<String> files = new ArrayList<>(count);
+        Map<String, Object> patterns = (Map<String, Object>) DATA.get("pathPatterns");
+        String pattern = patterns == null ? "" : text(patterns.get("shadowing"));
+        for (int i = 1; i <= count; i++) {
+            files.add(fill(pattern, story, lang, String.format("%0" + width + "d", i)));
+        }
+        return files;
+    }
+
+    private static Map<String, Object> bindStory(Map<String, Object> story, String lessonId) {
+        Map<String, Object> bound = new LinkedHashMap<>(story);
+        String[] parts = parseLesson(lessonId);
+        bound.put("levelId", parts != null ? parts[0] : "l1");
+        return bound;
+    }
+
+    private static String[] parseLesson(String lessonId) {
+        if (lessonId == null || lessonId.isBlank()) {
+            return null;
+        }
+        Matcher compact = LESSON.matcher(lessonId.trim());
+        if (compact.matches()) {
+            return new String[] {
+                    compact.group(1).toLowerCase(Locale.ROOT),
+                    compact.group(2)
+            };
+        }
+        return null;
+    }
+
+    private static String fill(String pattern, Map<String, Object> story, String lang, String nn) {
+        String value = pattern == null ? "" : pattern;
+        return value
+                .replace("{level}", text(story.get("levelId")))
+                .replace("{story}", text(story.get("id")))
+                .replace("{lang}", lang)
+                .replace("{nn}", nn);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> stories() {
+        Object raw = DATA.get("stories");
+        if (!(raw instanceof List<?> list)) {
+            return List.of();
+        }
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (Object item : list) {
+            if (item instanceof Map<?, ?> map) {
+                out.add((Map<String, Object>) map);
+            }
+        }
+        return out;
+    }
+
+    private static Map<String, Map<String, Object>> indexStories() {
+        Map<String, Map<String, Object>> index = new LinkedHashMap<>();
+        for (Map<String, Object> story : stories()) {
+            putIndex(index, text(story.get("id")), story);
+        }
+        return index;
+    }
+
+    private static void putIndex(Map<String, Map<String, Object>> index, String key, Map<String, Object> story) {
+        if (key == null || key.isBlank()) {
+            return;
+        }
+        index.putIfAbsent(key.trim().toLowerCase(Locale.ROOT), story);
+    }
+
+    private static boolean enabled(Map<String, Object> story) {
+        Object raw = story.get("enabled");
+        if (raw instanceof Boolean bool) {
+            return bool;
+        }
+        return true;
+    }
+
+    public static Map<String, Object> normalizePass(Object raw) {
+        if (!(raw instanceof Map<?, ?> map)) {
+            return null;
+        }
+        double speed = doubleVal(map.get("speed"), 1.0);
+        if (speed < 0.5) {
+            speed = 0.5;
+        }
+        if (speed > 2.0) {
+            speed = 2.0;
+        }
+        int repeat = intVal(map.get("repeat"), 1);
+        if (repeat < 1) {
+            repeat = 1;
+        }
+        if (repeat > 10) {
+            repeat = 10;
+        }
+        return pass(speed, repeat);
+    }
+
+    private static Map<String, Object> pass(double speed, int repeat) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("speed", speed);
+        row.put("repeat", repeat);
+        return row;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static int defaultInt(String key, int fallback) {
+        Map<String, Object> defaults = (Map<String, Object>) DATA.get("defaults");
+        return defaults == null ? fallback : intVal(defaults.get(key), fallback);
+    }
+
+    private static int intVal(Object value, int fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        if (value instanceof Number number) {
+            return number.intValue();
+        }
+        try {
+            return Integer.parseInt(String.valueOf(value).trim());
+        } catch (NumberFormatException ex) {
+            return fallback;
+        }
+    }
+
+    private static double doubleVal(Object value, double fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        if (value instanceof Number number) {
+            return number.doubleValue();
+        }
+        try {
+            return Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException ex) {
+            return fallback;
+        }
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private static Map<String, Object> load() {
+        try (InputStream in = IdiomLessonCatalog.class.getResourceAsStream("/catalog/idiom-lessons.json")) {
+            if (in == null) {
+                throw new IllegalStateException("missing classpath:catalog/idiom-lessons.json");
+            }
+            Map<String, Object> data = MAPPER.readValue(in, MAP);
+            return data == null ? Map.of() : data;
+        } catch (Exception ex) {
+            throw new IllegalStateException("failed to load idiom-lessons.json", ex);
+        }
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java
new file mode 100644
index 0000000..b6416b2
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/LessonClipIndex.java
@@ -0,0 +1,89 @@
+package xiaozhi.modules.parent.catalog;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 璇炬枃 MP3 鏁伴噺浠� xiaozhi-server 纾佺洏鐩綍涓哄噯銆傜储寮曟帴鍙eけ璐ユ椂杩斿洖 null锛岃皟鐢ㄦ柟鍐嶇敤娓呭崟鍏滃簳銆�
+ */
+@Slf4j
+public final class LessonClipIndex {
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final Pattern LESSON = Pattern.compile("^(l[1-6])\\.(\\d{3})$", Pattern.CASE_INSENSITIVE);
+    private static final HttpClient CLIENT = HttpClient.newBuilder()
+            .connectTimeout(Duration.ofSeconds(2))
+            .build();
+
+    private LessonClipIndex() {
+    }
+
+    public static List<String> fetch(String baseUrl, String scene, String lessonId, String language) {
+        String base = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
+        Matcher lesson = LESSON.matcher(lessonId == null ? "" : lessonId.trim());
+        if (base.isEmpty() || !lesson.matches()) {
+            return null;
+        }
+        String level = lesson.group(1).toLowerCase();
+        String story = lesson.group(2);
+        try {
+            if ("shadowing".equals(scene)) {
+                String lang = "cn".equalsIgnoreCase(language) ? "cn" : "en";
+                return oneFolder(base, "shadowing", level, story, lang);
+            }
+            if ("extensive".equals(scene)) {
+                List<String> cn = oneFolder(base, "repeater", level, story, "cn");
+                List<String> en = oneFolder(base, "repeater", level, story, "en");
+                if (cn == null || en == null) {
+                    return null;
+                }
+                List<String> both = new ArrayList<>(cn.size() + en.size());
+                both.addAll(cn);
+                both.addAll(en);
+                return both;
+            }
+        } catch (Exception ex) {
+            log.warn("lesson clip index failed: {}", ex.toString());
+            return null;
+        }
+        return null;
+    }
+
+    private static List<String> oneFolder(String base, String kind, String level, String story, String lang)
+            throws Exception {
+        String url = base + "/index/" + kind + "/" + level + "/" + story + "/" + lang;
+        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+                .timeout(Duration.ofSeconds(3))
+                .GET()
+                .build();
+        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+        if (response.statusCode() != 200) {
+            return null;
+        }
+        JsonNode root = MAPPER.readTree(response.body());
+        if (root == null || !root.path("present").asBoolean(false)) {
+            return null;
+        }
+        List<String> files = new ArrayList<>();
+        JsonNode raw = root.get("files");
+        if (raw != null && raw.isArray()) {
+            for (JsonNode item : raw) {
+                if (item.isTextual()) {
+                    files.add(item.asText());
+                }
+            }
+        }
+        return files;
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java
new file mode 100644
index 0000000..49a35fc
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentCatalog.java
@@ -0,0 +1,100 @@
+package xiaozhi.modules.parent.catalog;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class ParentCatalog {
+    private ParentCatalog() {
+    }
+
+    public static final int SCHEDULED_SOFT_LIMIT = 100;
+    public static final String DEFAULT_ORG_ID = "default_org";
+
+    public static String resolveOrgId(String orgId) {
+        if (orgId == null) {
+            return DEFAULT_ORG_ID;
+        }
+        String value = orgId.trim();
+        if (value.isEmpty() || !value.matches("[A-Za-z0-9_-]{1,64}")) {
+            return DEFAULT_ORG_ID;
+        }
+        return value;
+    }
+
+    public static String courseId(String scene, String lessonId) {
+        String lesson = (lessonId == null || lessonId.isBlank()) ? "l1.001" : lessonId.trim();
+        String kind = "shadowing".equals(scene) ? "shd" : "rpt";
+        String id = (lesson + "-" + kind).replaceAll("[^A-Za-z0-9_-]", "-");
+        return id.length() < 32 ? id : id.substring(0, 31);
+    }
+
+    public static List<String> lessonRelativePaths(String scene, String lessonId) {
+        return lessonRelativePaths(scene, lessonId, "en");
+    }
+
+    public static List<String> lessonRelativePaths(String scene, String lessonId, String language) {
+        String lesson = (lessonId == null || lessonId.isBlank()) ? "l1.001" : lessonId.trim();
+        return IdiomLessonCatalog.relativePaths(scene, lesson, language);
+    }
+
+    public static List<String> uniqueLessonPaths(List<String> files) {
+        Set<String> seen = new LinkedHashSet<>();
+        List<String> out = new ArrayList<>();
+        if (files == null) {
+            return out;
+        }
+        for (String raw : files) {
+            if (raw == null) {
+                continue;
+            }
+            String path = raw.replace('\\', '/').trim();
+            if (path.isEmpty()) {
+                continue;
+            }
+            if (!path.startsWith("/")) {
+                path = "/" + path;
+            }
+            if (path.contains("..") || !path.startsWith("/english_learning/") || !path.toLowerCase().endsWith(".mp3")) {
+                continue;
+            }
+            if (seen.add(path)) {
+                out.add(path);
+            }
+        }
+        return out;
+    }
+
+    public static Map<String, String> sceneToAppTask(String scene) {
+        return ParentSceneCatalog.sceneToAppTask(scene);
+    }
+
+    public static List<Map<String, Object>> scenes() {
+        return ParentSceneCatalog.scenes();
+    }
+
+    public static List<Map<String, Object>> lessons(String scene) {
+        return IdiomLessonCatalog.lessons(scene);
+    }
+
+    public static List<Map<String, Object>> languagePairs() {
+        return List.of(
+                Map.of("id", "zh-en", "name", "涓嫳", "ready", true),
+                Map.of("id", "zh-fr", "name", "涓硶", "ready", false),
+                Map.of("id", "zh-ja", "name", "涓棩", "ready", false));
+    }
+
+    public static String sceneName(String scene) {
+        return ParentSceneCatalog.sceneName(scene);
+    }
+
+    public static String sceneIdFromAppTask(String appId, String taskId) {
+        return ParentSceneCatalog.sceneIdFromAppTask(appId, taskId);
+    }
+
+    public static String kindFromAppTask(String appId, String taskId) {
+        return ParentSceneCatalog.kindFromAppTask(appId, taskId);
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java
new file mode 100644
index 0000000..c21d77f
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/catalog/ParentSceneCatalog.java
@@ -0,0 +1,341 @@
+package xiaozhi.modules.parent.catalog;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 瀹堕暱鍦烘櫙鐩綍銆備紭鍏堣 data/catalog/parent-scenes.json锛堟敼鏂囦欢鍗崇敓鏁堬級锛屽惁鍒欑敤 classpath銆�
+ */
+@Slf4j
+public final class ParentSceneCatalog {
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
+    };
+    private static final Object LOCK = new Object();
+    private static final long CLASSPATH_RECHECK_MS = 2000L;
+
+    private static List<Map<String, Object>> cached = List.of();
+    private static String cachedSource = "";
+    private static long cachedMtime = Long.MIN_VALUE;
+    private static long cachedAtMs = 0L;
+
+    private ParentSceneCatalog() {
+    }
+
+    public static List<Map<String, Object>> scenes() {
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (Map<String, Object> row : snapshot()) {
+            if (bool(row.get("enabled"), true)) {
+                out.add(publicScene(row));
+            }
+        }
+        return out;
+    }
+
+    public static Map<String, Object> find(String sceneId) {
+        String id = text(sceneId).toLowerCase(Locale.ROOT);
+        if (id.isEmpty()) {
+            return null;
+        }
+        for (Map<String, Object> row : snapshot()) {
+            if (id.equalsIgnoreCase(text(row.get("id")))) {
+                return row;
+            }
+        }
+        return null;
+    }
+
+    public static Map<String, Object> require(String sceneId) {
+        Map<String, Object> row = find(sceneId);
+        if (row == null || !bool(row.get("enabled"), true)) {
+            throw new IllegalArgumentException("涓嶆敮鎸佺殑鍦烘櫙");
+        }
+        return row;
+    }
+
+    public static Map<String, String> sceneToAppTask(String sceneId) {
+        Map<String, Object> row = find(sceneId);
+        if (row == null || !bool(row.get("enabled"), true)) {
+            return null;
+        }
+        String appId = text(row.get("appId"));
+        String taskId = text(row.get("taskId"));
+        if (appId.isEmpty() || taskId.isEmpty()) {
+            return null;
+        }
+        Map<String, String> mapped = new LinkedHashMap<>();
+        mapped.put("appId", appId);
+        mapped.put("taskId", taskId);
+        mapped.put("name", text(row.get("name")));
+        return mapped;
+    }
+
+    public static String sceneName(String sceneId) {
+        Map<String, Object> row = find(sceneId);
+        if (row == null) {
+            return text(sceneId);
+        }
+        String name = text(row.get("name"));
+        return name.isEmpty() ? text(sceneId) : name;
+    }
+
+    public static String sceneIdFromAppTask(String appId, String taskId) {
+        String app = text(appId);
+        String task = text(taskId);
+        for (Map<String, Object> row : snapshot()) {
+            if (app.equals(text(row.get("appId"))) && (task.isEmpty() || task.equals(text(row.get("taskId"))))) {
+                return text(row.get("id"));
+            }
+        }
+        return "";
+    }
+
+    public static String kindFromAppTask(String appId, String taskId) {
+        Map<String, Object> row = find(sceneIdFromAppTask(appId, taskId));
+        if (row == null) {
+            return "chat";
+        }
+        String kind = text(row.get("kind"));
+        return kind.isEmpty() ? text(row.get("id")) : kind;
+    }
+
+    public static boolean flag(String sceneId, String key) {
+        Map<String, Object> row = find(sceneId);
+        return row != null && bool(row.get(key), false);
+    }
+
+    public static boolean flag(Map<String, Object> scene, String key) {
+        return scene != null && bool(scene.get(key), false);
+    }
+
+    static void resetForTests() {
+        synchronized (LOCK) {
+            cached = List.of();
+            cachedSource = "";
+            cachedMtime = Long.MIN_VALUE;
+            cachedAtMs = 0L;
+        }
+    }
+
+    private static List<Map<String, Object>> snapshot() {
+        synchronized (LOCK) {
+            reloadIfNeeded();
+            return cached;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void reloadIfNeeded() {
+        Path file = firstExisting(catalogCandidates());
+        long now = System.currentTimeMillis();
+        if (file != null) {
+            long mtime = file.toFile().lastModified();
+            if (!cached.isEmpty() && file.toAbsolutePath().toString().equals(cachedSource) && mtime == cachedMtime) {
+                return;
+            }
+            try {
+                Map<String, Object> data = MAPPER.readValue(Files.readAllBytes(file), MAP);
+                cached = normalizeScenes(data);
+                cachedSource = file.toAbsolutePath().toString();
+                cachedMtime = mtime;
+                cachedAtMs = now;
+                log.info("loaded parent scenes {} count={}", cachedSource, cached.size());
+                return;
+            } catch (Exception ex) {
+                log.warn("failed to read {}, keep previous catalog: {}", file, ex.getMessage());
+                if (!cached.isEmpty()) {
+                    return;
+                }
+            }
+        }
+        if (!cached.isEmpty() && "classpath".equals(cachedSource) && now - cachedAtMs < CLASSPATH_RECHECK_MS) {
+            return;
+        }
+        try (InputStream in = ParentSceneCatalog.class.getResourceAsStream("/catalog/parent-scenes.json")) {
+            if (in != null) {
+                Map<String, Object> data = MAPPER.readValue(in, MAP);
+                cached = normalizeScenes(data);
+                cachedSource = "classpath";
+                cachedMtime = Long.MIN_VALUE;
+                cachedAtMs = now;
+                return;
+            }
+        } catch (Exception ex) {
+            log.warn("failed to read classpath parent-scenes.json: {}", ex.getMessage());
+        }
+        if (cached.isEmpty()) {
+            cached = builtinScenes();
+            cachedSource = "builtin";
+            cachedMtime = Long.MIN_VALUE;
+            cachedAtMs = now;
+        }
+    }
+
+    private static Path firstExisting(List<Path> candidates) {
+        for (Path path : candidates) {
+            if (path != null && Files.isRegularFile(path)) {
+                return path;
+            }
+        }
+        return null;
+    }
+
+    private static List<Path> catalogCandidates() {
+        List<Path> out = new ArrayList<>();
+        String override = System.getProperty("xiaozhi.catalog.dir", System.getenv("XIAOZHI_CATALOG_DIR"));
+        if (override != null && !override.isBlank()) {
+            out.add(Paths.get(override.trim(), "parent-scenes.json"));
+        }
+        String userDir = System.getProperty("user.dir", ".");
+        out.add(Paths.get(userDir, "data", "catalog", "parent-scenes.json"));
+        out.add(Paths.get(userDir, "src", "main", "resources", "catalog", "parent-scenes.json"));
+        return out;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> normalizeScenes(Map<String, Object> data) {
+        Object raw = data == null ? null : data.get("scenes");
+        if (!(raw instanceof List<?> list)) {
+            return builtinScenes();
+        }
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (Object item : list) {
+            if (!(item instanceof Map<?, ?> map)) {
+                continue;
+            }
+            Map<String, Object> row = sanitize((Map<String, Object>) map);
+            if (row != null) {
+                out.add(row);
+            }
+        }
+        out.sort(Comparator.comparingInt(a -> intVal(a.get("sort"), 100)));
+        return out.isEmpty() ? builtinScenes() : out;
+    }
+
+    private static Map<String, Object> sanitize(Map<String, Object> raw) {
+        String id = text(raw.get("id")).toLowerCase(Locale.ROOT);
+        String appId = text(raw.get("appId"));
+        String taskId = text(raw.get("taskId"));
+        if (!id.matches("[a-z0-9_]{1,32}") || !safeId(appId) || !safeId(taskId)) {
+            return null;
+        }
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", id);
+        row.put("name", text(raw.get("name")).isEmpty() ? id : text(raw.get("name")));
+        row.put("hint", text(raw.get("hint")));
+        row.put("enabled", bool(raw.get("enabled"), true));
+        row.put("sort", intVal(raw.get("sort"), 100));
+        row.put("appId", appId);
+        row.put("taskId", taskId);
+        String kind = text(raw.get("kind"));
+        row.put("kind", kind.isEmpty() ? id : kind);
+        row.put("audioPackage", bool(raw.get("audioPackage"), false));
+        row.put("needsLesson", bool(raw.get("needsLesson"), false));
+        row.put("needsEnPasses", bool(raw.get("needsEnPasses"), false));
+        row.put("needsShadowingSettings", bool(raw.get("needsShadowingSettings"), false));
+        row.put("needsRepeat", bool(raw.get("needsRepeat"), false));
+        row.put("needsLanguagePair", bool(raw.get("needsLanguagePair"), false));
+        row.put("needsDuration", bool(raw.get("needsDuration"), false));
+        row.put("needsOnComplete", bool(raw.get("needsOnComplete"), true));
+        return row;
+    }
+
+    private static Map<String, Object> publicScene(Map<String, Object> row) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("id", row.get("id"));
+        out.put("name", row.get("name"));
+        out.put("hint", row.get("hint"));
+        out.put("appId", row.get("appId"));
+        out.put("taskId", row.get("taskId"));
+        out.put("kind", row.get("kind"));
+        out.put("audioPackage", row.get("audioPackage"));
+        out.put("needsLesson", row.get("needsLesson"));
+        out.put("needsEnPasses", row.get("needsEnPasses"));
+        out.put("needsShadowingSettings", row.get("needsShadowingSettings"));
+        out.put("needsRepeat", row.get("needsRepeat"));
+        out.put("needsLanguagePair", row.get("needsLanguagePair"));
+        out.put("needsDuration", row.get("needsDuration"));
+        out.put("needsOnComplete", row.get("needsOnComplete"));
+        return out;
+    }
+
+    private static List<Map<String, Object>> builtinScenes() {
+        List<Map<String, Object>> out = new ArrayList<>();
+        out.add(sanitize(scene("extensive", "娉涘惉", "english_learning", "repeater", "extensive", 10, true, true, false, false, false, true, true)));
+        out.add(sanitize(scene("shadowing", "璺熻", "english_learning", "shadowing", "shadowing", 20, true, false, true, false, false, true, true)));
+        out.add(sanitize(scene("chat", "榛樿鑱婂ぉ", "chat", "conversation", "chat", 30, false, false, false, false, true, true, false)));
+        out.add(sanitize(scene("translation", "缈昏瘧", "english_translation", "live_translate", "translation", 40, false, false, false, true, true, true, false)));
+        out.add(sanitize(scene("bilingual", "涓嫳瀵硅瘽", "english_conversation", "bilingual_chat", "bilingual", 50, false, false, false, true, true, true, false)));
+        return out;
+    }
+
+    private static Map<String, Object> scene(String id, String name, String appId, String taskId, String kind, int sort,
+            boolean lesson, boolean enPasses, boolean shadowing, boolean pair, boolean duration, boolean onComplete,
+            boolean audio) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", id);
+        row.put("name", name);
+        row.put("appId", appId);
+        row.put("taskId", taskId);
+        row.put("kind", kind);
+        row.put("sort", sort);
+        row.put("needsLesson", lesson);
+        row.put("needsEnPasses", enPasses);
+        row.put("needsShadowingSettings", shadowing);
+        row.put("needsLanguagePair", pair);
+        row.put("needsDuration", duration);
+        row.put("needsOnComplete", onComplete);
+        row.put("audioPackage", audio);
+        row.put("enabled", true);
+        return row;
+    }
+
+    private static boolean safeId(String value) {
+        return value != null && value.matches("[A-Za-z0-9_-]{1,31}");
+    }
+
+    private static boolean bool(Object value, boolean fallback) {
+        if (value instanceof Boolean bool) {
+            return bool;
+        }
+        if (value == null) {
+            return fallback;
+        }
+        String text = String.valueOf(value).trim();
+        if ("true".equalsIgnoreCase(text) || "1".equals(text)) {
+            return true;
+        }
+        if ("false".equalsIgnoreCase(text) || "0".equals(text)) {
+            return false;
+        }
+        return fallback;
+    }
+
+    private static int intVal(Object value, int fallback) {
+        if (value instanceof Number number) {
+            return number.intValue();
+        }
+        try {
+            return Integer.parseInt(String.valueOf(value).trim());
+        } catch (Exception ex) {
+            return fallback;
+        }
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java
new file mode 100644
index 0000000..dce9453
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/controller/ParentTaskController.java
@@ -0,0 +1,154 @@
+package xiaozhi.modules.parent.controller;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import xiaozhi.common.utils.Result;
+import xiaozhi.modules.parent.catalog.IdiomLessonCatalog;
+import xiaozhi.modules.parent.catalog.ParentCatalog;
+import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
+import xiaozhi.modules.parent.dto.ParentHistoryDetailDTO;
+import xiaozhi.modules.parent.dto.ParentHistoryListDTO;
+import xiaozhi.modules.parent.dto.ParentTaskDTO;
+import xiaozhi.modules.parent.dto.ParentTaskListDTO;
+import xiaozhi.modules.parent.dto.ParentTaskSaveDTO;
+import xiaozhi.modules.parent.service.ParentHistoryService;
+import xiaozhi.modules.parent.service.ParentTaskService;
+import xiaozhi.modules.security.user.SecurityUser;
+
+@Tag(name = "瀹堕暱浠诲姟涓嬪彂")
+@RestController
+@RequestMapping("/user")
+@RequiredArgsConstructor
+public class ParentTaskController {
+    private final ParentTaskService parentTaskService;
+    private final ParentHistoryService parentHistoryService;
+
+    @GetMapping("/scenes")
+    @Operation(summary = "鍦烘櫙鍒楄〃锛坧arent-scenes.json锛岀儹鍔犺浇锛�")
+    @RequiresPermissions("sys:role:normal")
+    public Result<List<Map<String, Object>>> scenes() {
+        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.scenes());
+    }
+
+    @GetMapping("/idiom-catalog")
+    @Operation(summary = "鎴愯璇炬枃娓呭崟锛堢骇鍒�/鍒嗙被/鏁呬簨锛屽鍒犳敼 idiom-lessons.json锛�")
+    @RequiresPermissions("sys:role:normal")
+    public Result<Map<String, Object>> idiomCatalog() {
+        return new Result<Map<String, Object>>().ok(IdiomLessonCatalog.asApiMap());
+    }
+
+    @GetMapping("/lessons")
+    @Operation(summary = "璇炬枃鐩綍")
+    @RequiresPermissions("sys:role:normal")
+    public Result<List<Map<String, Object>>> lessons(@RequestParam("scene") String scene) {
+        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.lessons(scene));
+    }
+
+    @GetMapping("/language-pairs")
+    @Operation(summary = "璇█瀵�")
+    @RequiresPermissions("sys:role:normal")
+    public Result<List<Map<String, Object>>> languagePairs() {
+        return new Result<List<Map<String, Object>>>().ok(ParentCatalog.languagePairs());
+    }
+
+    @GetMapping("/parent-device/status")
+    @Operation(summary = "鏌ヨ璁惧鏄惁鍦ㄧ嚎锛堝闀� MQTT 鐘舵�侊級")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentDeviceStatusDTO> deviceStatus(
+            @RequestParam("macAddress") String macAddress,
+            @RequestParam("agentId") String agentId) {
+        return new Result<ParentDeviceStatusDTO>().ok(
+                parentTaskService.deviceStatus(SecurityUser.getUserId(), macAddress, agentId));
+    }
+
+    @GetMapping("/parent-task")
+    @Operation(summary = "浠诲姟鍒楄〃")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskListDTO> list(
+            @RequestParam("macAddress") String macAddress,
+            @RequestParam("agentId") String agentId,
+            @RequestParam(value = "runMode", required = false) String runMode) {
+        return new Result<ParentTaskListDTO>().ok(
+                parentTaskService.list(SecurityUser.getUserId(), macAddress, agentId, runMode));
+    }
+
+    @GetMapping("/parent-task/{id}")
+    @Operation(summary = "浠诲姟璇︽儏")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskDTO> get(@PathVariable("id") String id) {
+        return new Result<ParentTaskDTO>().ok(parentTaskService.get(SecurityUser.getUserId(), id));
+    }
+
+    @PostMapping("/parent-task")
+    @Operation(summary = "鍒涘缓浠诲姟锛氱珛鍗冲紑濮嬫垨瀛樹负璁″垝")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskDTO> create(@RequestBody ParentTaskSaveDTO body) {
+        return new Result<ParentTaskDTO>().ok(parentTaskService.create(SecurityUser.getUserId(), body));
+    }
+
+    @PutMapping("/parent-task/{id}")
+    @Operation(summary = "鏇存柊璁″垝")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskDTO> update(@PathVariable("id") String id, @RequestBody ParentTaskSaveDTO body) {
+        return new Result<ParentTaskDTO>().ok(parentTaskService.update(SecurityUser.getUserId(), id, body));
+    }
+
+    @DeleteMapping("/parent-task/{id}")
+    @Operation(summary = "鍒犻櫎浠诲姟")
+    @RequiresPermissions("sys:role:normal")
+    public Result<Boolean> delete(@PathVariable("id") String id) {
+        parentTaskService.delete(SecurityUser.getUserId(), id);
+        return new Result<Boolean>().ok(Boolean.TRUE);
+    }
+
+    @PostMapping("/parent-task/{id}/run")
+    @Operation(summary = "鎸夊凡鏈変换鍔″畾涔夌珛鍗虫墽琛岋紙涓嶆敼鍛ㄦ湡琛級")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskDTO> runNow(@PathVariable("id") String id) {
+        return new Result<ParentTaskDTO>().ok(parentTaskService.runNow(SecurityUser.getUserId(), id));
+    }
+
+    @PostMapping("/parent-task/{id}/stop")
+    @Operation(summary = "缁撴潫姝e湪鎵ц鐨勪换鍔★紝璁惧鍥炲埌瀵硅瘽")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentTaskDTO> stop(@PathVariable("id") String id) {
+        return new Result<ParentTaskDTO>().ok(parentTaskService.stop(SecurityUser.getUserId(), id));
+    }
+
+    @GetMapping("/parent-history")
+    @Operation(summary = "瀹堕暱鎵ц璁板綍鍒楄〃锛堟棤鍒嗘暟锛�")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentHistoryListDTO> historyList(
+            @RequestParam("macAddress") String macAddress,
+            @RequestParam("agentId") String agentId,
+            @RequestParam(value = "limit", required = false) Integer limit) {
+        return new Result<ParentHistoryListDTO>().ok(
+                parentHistoryService.list(SecurityUser.getUserId(), macAddress, agentId, limit));
+    }
+
+    @GetMapping("/parent-history/{id}")
+    @Operation(summary = "瀹堕暱鎵ц璁板綍璇︽儏锛堣窡璇诲彞鏂囨湰/褰曢煶锛屾硾鍚畬鎴愭�侊紝瀵硅瘽绫昏亰澶╋級")
+    @RequiresPermissions("sys:role:normal")
+    public Result<ParentHistoryDetailDTO> historyDetail(
+            @PathVariable("id") String id,
+            @RequestParam("macAddress") String macAddress,
+            @RequestParam("agentId") String agentId) {
+        return new Result<ParentHistoryDetailDTO>().ok(
+                parentHistoryService.get(SecurityUser.getUserId(), macAddress, agentId, id));
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java
new file mode 100644
index 0000000..54b6b7b
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dao/ParentTaskDao.java
@@ -0,0 +1,11 @@
+package xiaozhi.modules.parent.dao;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+import xiaozhi.modules.parent.entity.ParentTaskEntity;
+
+@Mapper
+public interface ParentTaskDao extends BaseMapper<ParentTaskEntity> {
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java
new file mode 100644
index 0000000..a7c2a6a
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentDeviceStatusDTO.java
@@ -0,0 +1,11 @@
+package xiaozhi.modules.parent.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "瀹堕暱绔澶囧湪绾跨姸鎬�")
+public class ParentDeviceStatusDTO {
+    private String macAddress;
+    private Boolean online;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java
new file mode 100644
index 0000000..62530b6
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryDetailDTO.java
@@ -0,0 +1,15 @@
+package xiaozhi.modules.parent.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ParentHistoryDetailDTO extends ParentHistoryItemDTO {
+    private List<ParentHistoryTurnDTO> turns = new ArrayList<>();
+    private List<AgentChatHistoryDTO> chats = new ArrayList<>();
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java
new file mode 100644
index 0000000..4092fd7
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryItemDTO.java
@@ -0,0 +1,20 @@
+package xiaozhi.modules.parent.dto;
+
+import lombok.Data;
+
+@Data
+public class ParentHistoryItemDTO {
+    private String id;
+    private String scene;
+    private String sceneName;
+    private String kind;
+    private String appId;
+    private String taskId;
+    private String source;
+    private String status;
+    private String endReason;
+    private Long startedAt;
+    private Long endedAt;
+    private Long durationMs;
+    private int turnCount;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java
new file mode 100644
index 0000000..9f210e1
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryListDTO.java
@@ -0,0 +1,12 @@
+package xiaozhi.modules.parent.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class ParentHistoryListDTO {
+    private long total;
+    private List<ParentHistoryItemDTO> list = new ArrayList<>();
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java
new file mode 100644
index 0000000..fb38e9d
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentHistoryTurnDTO.java
@@ -0,0 +1,11 @@
+package xiaozhi.modules.parent.dto;
+
+import lombok.Data;
+
+@Data
+public class ParentHistoryTurnDTO {
+    private Integer index;
+    private String expectedText;
+    private String asrText;
+    private String audioId;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java
new file mode 100644
index 0000000..da27ee8
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentImmediateCloseDTO.java
@@ -0,0 +1,31 @@
+package xiaozhi.modules.parent.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Schema(description = "xiaozhi-server 鍏抽棴瀹堕暱鍗虫椂浠诲姟")
+public class ParentImmediateCloseDTO {
+    @JsonProperty("device_id")
+    private String deviceId;
+
+    private String reason;
+
+    @JsonProperty("except_app_id")
+    private String exceptAppId;
+
+    @JsonProperty("except_task_id")
+    private String exceptTaskId;
+
+    @JsonProperty("match_app_id")
+    private String matchAppId;
+
+    @JsonProperty("match_task_id")
+    private String matchTaskId;
+
+    private String status;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java
new file mode 100644
index 0000000..d20adbf
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskDTO.java
@@ -0,0 +1,26 @@
+package xiaozhi.modules.parent.dto;
+
+import java.util.Date;
+import java.util.Map;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "瀹堕暱浠诲姟")
+public class ParentTaskDTO {
+    private String id;
+    private String macAddress;
+    private String agentId;
+    private String runMode;
+    private String scene;
+    private String appId;
+    private String taskId;
+    private String sceneName;
+    private Map<String, Object> params;
+    private Map<String, Object> schedule;
+    private Boolean enabled;
+    private String status;
+    private Date createdAt;
+    private Date updatedAt;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java
new file mode 100644
index 0000000..9acd218
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskListDTO.java
@@ -0,0 +1,12 @@
+package xiaozhi.modules.parent.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class ParentTaskListDTO {
+    private long total;
+    private List<ParentTaskDTO> list = new ArrayList<>();
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java
new file mode 100644
index 0000000..73548a9
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/dto/ParentTaskSaveDTO.java
@@ -0,0 +1,31 @@
+package xiaozhi.modules.parent.dto;
+
+import java.util.Map;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "鍒涘缓鎴栨洿鏂板闀夸换鍔�")
+public class ParentTaskSaveDTO {
+    @Schema(description = "璁惧 MAC", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String macAddress;
+
+    @Schema(description = "鏅鸿兘浣� ID", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String agentId;
+
+    @Schema(description = "immediate 绔嬪嵆鎵ц锛泂cheduled 瀛樹负璁″垝", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String runMode;
+
+    @Schema(description = "extensive/shadowing/bilingual/translation/chat", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String scene;
+
+    @Schema(description = "鍦烘櫙鍙傛暟锛歭esson_id/en_passes/speed/language/language_pair/duration_minutes/on_complete")
+    private Map<String, Object> params;
+
+    @Schema(description = "浠� scheduled锛歞aily/hourly/interval")
+    private Map<String, Object> schedule;
+
+    @Schema(description = "璁″垝鏄惁鍚敤锛岄粯璁� true")
+    private Boolean enabled;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java
new file mode 100644
index 0000000..95e2763
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/entity/ParentTaskEntity.java
@@ -0,0 +1,51 @@
+package xiaozhi.modules.parent.entity;
+
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import lombok.Data;
+
+@Data
+@TableName("ai_parent_task")
+public class ParentTaskEntity {
+    @TableId(type = IdType.INPUT)
+    private String id;
+
+    @TableField("user_id")
+    private Long userId;
+
+    @TableField("mac_address")
+    private String macAddress;
+
+    @TableField("agent_id")
+    private String agentId;
+
+    @TableField("run_mode")
+    private String runMode;
+
+    private String scene;
+
+    @TableField("app_id")
+    private String appId;
+
+    @TableField("task_id")
+    private String taskId;
+
+    private String params;
+
+    private String schedule;
+
+    private Integer enabled;
+
+    private String status;
+
+    @TableField("created_at")
+    private Date createdAt;
+
+    @TableField("updated_at")
+    private Date updatedAt;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java
new file mode 100644
index 0000000..a18c828
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineEvent.java
@@ -0,0 +1,4 @@
+package xiaozhi.modules.parent.mqtt;
+
+public record ParentDeviceOfflineEvent(String macAddress) {
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java
new file mode 100644
index 0000000..57a71dd
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDeviceOfflineListener.java
@@ -0,0 +1,23 @@
+package xiaozhi.modules.parent.mqtt;
+
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import xiaozhi.modules.parent.service.ParentTaskService;
+
+@Component
+@RequiredArgsConstructor
+public class ParentDeviceOfflineListener {
+    private final ParentTaskService parentTaskService;
+
+    @Async("taskExecutor")
+    @EventListener
+    public void onOffline(ParentDeviceOfflineEvent event) {
+        if (event == null) {
+            return;
+        }
+        parentTaskService.closeRunningForOffline(event.macAddress());
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java
new file mode 100644
index 0000000..9433dd6
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentDevicePresence.java
@@ -0,0 +1,288 @@
+package xiaozhi.modules.parent.mqtt;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.modules.sys.service.SysParamsService;
+
+/**
+ * 鏍规嵁璁惧 MQTT 鐘舵�佷富棰樺垽鏂槸鍚﹀湪绾裤�傛湭鍦ㄧ嚎鍒欎笉鍏佽淇濆瓨/涓嬪彂瀹堕暱浠诲姟銆�
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ParentDevicePresence {
+    public static final String OFFLINE_MESSAGE = "璁惧涓嶅湪绾匡紝鏃犳硶鍚屾鍒扮‖浠躲�傝鎵撳紑璁惧骞惰繛涓婄綉缁滃悗鍐嶈瘯锛屾湰娆′笉浼氫繚瀛樸��";
+    private static final long ONLINE_TTL_SEC = 180L;
+    private static final long PROBE_WAIT_MS = 2000L;
+    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
+    };
+
+    private final SysParamsService sysParamsService;
+    private final RedisUtils redisUtils;
+    private final ObjectMapper objectMapper;
+    private final ApplicationEventPublisher eventPublisher;
+
+    private volatile boolean stopped;
+    private volatile MqttClient listener;
+
+    @PostConstruct
+    public void start() {
+        Thread thread = new Thread(this::listenLoop, "parent-mqtt-presence");
+        thread.setDaemon(true);
+        thread.start();
+    }
+
+    @PreDestroy
+    public void stop() {
+        stopped = true;
+        closeQuietly(listener);
+    }
+
+    public void requireOnline(String macAddress) {
+        if (!isOnline(macAddress)) {
+            throw new RenException(OFFLINE_MESSAGE);
+        }
+    }
+
+    public boolean isOnline(String macAddress) {
+        if (isOnlineCached(macAddress)) {
+            return true;
+        }
+        String mac = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(mac)) {
+            return false;
+        }
+        return probe(mac);
+    }
+
+    public boolean isOnlineCached(String macAddress) {
+        String mac = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(mac)) {
+            return false;
+        }
+        return redisUtils.get(RedisKeys.getParentDeviceOnlineKey(mac)) != null;
+    }
+
+    private void listenLoop() {
+        while (!stopped) {
+            try {
+                listenOnce();
+            } catch (Exception ex) {
+                log.warn("parent mqtt presence: {}", ex.getMessage());
+            }
+            sleepQuietly(15_000);
+        }
+    }
+
+    private void listenOnce() throws MqttException {
+        Broker broker = broker();
+        if (broker == null) {
+            return;
+        }
+        MqttClient client = new MqttClient(broker.uri(), "parent-api-presence-" + UUID.randomUUID(),
+                new MemoryPersistence());
+        listener = client;
+        client.setCallback(new org.eclipse.paho.client.mqttv3.MqttCallback() {
+            @Override
+            public void connectionLost(Throwable cause) {
+                log.warn("parent mqtt presence disconnected: {}",
+                        cause == null ? "" : cause.getMessage());
+            }
+
+            @Override
+            public void messageArrived(String topic, org.eclipse.paho.client.mqttv3.MqttMessage message) {
+                applyState(topic, new String(message.getPayload(), java.nio.charset.StandardCharsets.UTF_8));
+            }
+
+            @Override
+            public void deliveryComplete(org.eclipse.paho.client.mqttv3.IMqttDeliveryToken token) {
+            }
+        });
+        client.connect(broker.options());
+        client.subscribe("snlp/v1/+/rpt/state", 0);
+        log.info("parent mqtt presence listening on snlp/v1/+/rpt/state");
+        while (!stopped && client.isConnected()) {
+            sleepQuietly(1000);
+        }
+        closeQuietly(client);
+        if (listener == client) {
+            listener = null;
+        }
+    }
+
+    private boolean probe(String mac) {
+        Broker broker = broker();
+        if (broker == null) {
+            return false;
+        }
+        String topic = "snlp/v1/" + mac + "/rpt/state";
+        CountDownLatch latch = new CountDownLatch(1);
+        MqttClient client = null;
+        try {
+            client = new MqttClient(broker.uri(), "parent-api-probe-" + UUID.randomUUID(), new MemoryPersistence());
+            client.connect(broker.options());
+            client.subscribe(topic, 0, (t, message) -> {
+                applyState(t, new String(message.getPayload(), java.nio.charset.StandardCharsets.UTF_8));
+                latch.countDown();
+            });
+            latch.await(PROBE_WAIT_MS, TimeUnit.MILLISECONDS);
+        } catch (Exception ex) {
+            log.warn("parent mqtt presence probe failed: {}", ex.getMessage());
+        } finally {
+            closeQuietly(client);
+        }
+        return redisUtils.get(RedisKeys.getParentDeviceOnlineKey(mac)) != null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void applyState(String topic, String json) {
+        try {
+            Map<String, Object> root = objectMapper.readValue(json, MAP);
+            Object payloadRaw = root.get("payload");
+            Map<String, Object> payload = payloadRaw instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
+            String mac = ParentMqttPublisher.normalizeMac(text(payload.get("mac")));
+            if (StringUtils.isBlank(mac)) {
+                mac = macFromTopic(topic);
+            }
+            if (StringUtils.isBlank(mac)) {
+                return;
+            }
+            boolean online = payloadMeansOnline(payload);
+            String key = RedisKeys.getParentDeviceOnlineKey(mac);
+            if (online) {
+                redisUtils.set(key, "1", ONLINE_TTL_SEC);
+            } else {
+                boolean wasOnline = redisUtils.get(key) != null;
+                redisUtils.delete(key);
+                if (wasOnline) {
+                    eventPublisher.publishEvent(new ParentDeviceOfflineEvent(mac));
+                }
+            }
+        } catch (Exception ex) {
+            log.debug("ignore parent mqtt state: {}", ex.getMessage());
+        }
+    }
+
+    private static String macFromTopic(String topic) {
+        String[] parts = StringUtils.trimToEmpty(topic).split("/");
+        if (parts.length >= 5 && "snlp".equals(parts[0]) && "v1".equals(parts[1])
+                && "rpt".equals(parts[3]) && "state".equals(parts[4]) && parts[2].contains(":")) {
+            return ParentMqttPublisher.normalizeMac(parts[2]);
+        }
+        return "";
+    }
+
+    private Broker broker() {
+        String host = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_host", true));
+        String portText = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_port", true));
+        String username = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_username", true));
+        String password = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_password", true));
+        if (StringUtils.isBlank(host) || "null".equalsIgnoreCase(host)) {
+            return null;
+        }
+        int port = 2100;
+        try {
+            if (StringUtils.isNotBlank(portText) && !"null".equalsIgnoreCase(portText)) {
+                port = Integer.parseInt(portText);
+            }
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+        MqttConnectOptions options = new MqttConnectOptions();
+        options.setAutomaticReconnect(false);
+        options.setCleanSession(true);
+        options.setConnectionTimeout(8);
+        options.setKeepAliveInterval(30);
+        if (StringUtils.isNotBlank(username) && !"null".equalsIgnoreCase(username)) {
+            options.setUserName(username);
+        }
+        if (StringUtils.isNotBlank(password) && !"null".equalsIgnoreCase(password)) {
+            options.setPassword(password.toCharArray());
+        }
+        return new Broker("tcp://" + host + ":" + port, options);
+    }
+
+    static boolean payloadMeansOnline(Map<String, Object> payload) {
+        if (payload == null || payload.isEmpty()) {
+            return false;
+        }
+        Boolean online = asBoolean(payload.get("online"));
+        if (Boolean.FALSE.equals(online)) {
+            return false;
+        }
+        if (Boolean.TRUE.equals(online)) {
+            return true;
+        }
+        String net = text(payload.get("net"));
+        return StringUtils.isNotBlank(net) && !"offline".equalsIgnoreCase(net);
+    }
+
+    private static Boolean asBoolean(Object value) {
+        if (value instanceof Boolean bool) {
+            return bool;
+        }
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if ("true".equalsIgnoreCase(text) || "1".equals(text)) {
+            return Boolean.TRUE;
+        }
+        if ("false".equalsIgnoreCase(text) || "0".equals(text)) {
+            return Boolean.FALSE;
+        }
+        return null;
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private static void closeQuietly(MqttClient client) {
+        if (client == null) {
+            return;
+        }
+        try {
+            if (client.isConnected()) {
+                client.disconnect();
+            }
+        } catch (Exception ignored) {
+        }
+        try {
+            client.close();
+        } catch (Exception ignored) {
+        }
+    }
+
+    private static void sleepQuietly(long ms) {
+        try {
+            Thread.sleep(ms);
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    private record Broker(String uri, MqttConnectOptions options) {
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java
new file mode 100644
index 0000000..cae7c08
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/mqtt/ParentMqttPublisher.java
@@ -0,0 +1,121 @@
+package xiaozhi.modules.parent.mqtt;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.modules.sys.service.SysParamsService;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ParentMqttPublisher {
+    private static final int PAYLOAD_SOFT_MAX = 7800;
+
+    private final SysParamsService sysParamsService;
+    private final ObjectMapper objectMapper;
+
+    public void publishCtl(String macAddress, String action, Object payload) {
+        String host = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_host", true));
+        String portText = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_port", true));
+        String username = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_username", true));
+        String password = StringUtils.trimToEmpty(sysParamsService.getValue("server.parent_mqtt_password", true));
+        if (StringUtils.isBlank(host) || "null".equalsIgnoreCase(host)) {
+            throw new RenException("鏈厤缃� server.parent_mqtt_host锛屾棤娉曚笅鍙戝埌璁惧");
+        }
+        int port = 2100;
+        try {
+            if (StringUtils.isNotBlank(portText) && !"null".equalsIgnoreCase(portText)) {
+                port = Integer.parseInt(portText);
+            }
+        } catch (NumberFormatException ex) {
+            throw new RenException("server.parent_mqtt_port 鏃犳晥");
+        }
+
+        String mac = normalizeMac(macAddress);
+        String topic = "snlp/v1/" + mac + "/cmd/ctl";
+        Map<String, Object> header = new LinkedHashMap<>();
+        header.put("version", "1.0");
+        header.put("device_id", mac);
+        header.put("req_id", UUID.randomUUID().toString());
+        header.put("timestamp", System.currentTimeMillis() / 1000L);
+        header.put("ttl", 60);
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("header", header);
+        body.put("action", action);
+        body.put("payload", payload == null ? Map.of() : payload);
+
+        String json;
+        try {
+            json = objectMapper.writeValueAsString(body);
+        } catch (Exception ex) {
+            throw new RenException("瀹堕暱浠诲姟 JSON 搴忓垪鍖栧け璐�");
+        }
+        if (json.length() > PAYLOAD_SOFT_MAX) {
+            throw new RenException("涓嬪彂鍐呭瓒呰繃璁惧 MQTT 8KB 涓婇檺锛岃鍑忓皯璁″垝鏉℃暟锛堜笉瑕佷笅鍙戝睍寮�鍚庣殑璇炬枃娓呭崟锛�");
+        }
+
+        String broker = "tcp://" + host + ":" + port;
+        String clientId = "parent-api-" + UUID.randomUUID();
+        try (MqttClient client = new MqttClient(broker, clientId, new MemoryPersistence())) {
+            MqttConnectOptions options = new MqttConnectOptions();
+            options.setAutomaticReconnect(false);
+            options.setCleanSession(true);
+            options.setConnectionTimeout(8);
+            if (StringUtils.isNotBlank(username) && !"null".equalsIgnoreCase(username)) {
+                options.setUserName(username);
+            }
+            if (StringUtils.isNotBlank(password) && !"null".equalsIgnoreCase(password)) {
+                options.setPassword(password.toCharArray());
+            }
+            client.connect(options);
+            MqttMessage message = new MqttMessage(json.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            message.setQos(0);
+            message.setRetained(false);
+            client.publish(topic, message);
+            client.disconnect();
+            log.info("parent mqtt {} -> {} bytes={}", action, topic, json.length());
+        } catch (MqttException ex) {
+            log.error("parent mqtt publish failed: {}", ex.getMessage());
+            throw new RenException("涓嬪彂璁惧澶辫触: " + ex.getMessage());
+        }
+    }
+
+    public void publishLessonManifest(String macAddress, String courseId, String baseUrl, List<String> files) {
+        if (files == null || files.isEmpty()) {
+            return;
+        }
+        String course = StringUtils.defaultIfBlank(courseId, "lesson-sync").trim();
+        if (!course.matches("[A-Za-z0-9_-]{1,31}")) {
+            course = "lesson-sync";
+        }
+        String base = StringUtils.trimToEmpty(baseUrl).replaceAll("/+$", "");
+        if (StringUtils.isBlank(base) || "null".equalsIgnoreCase(base) || base.contains("浣�")) {
+            throw new RenException("鏈厤缃鏂囬煶棰戜笅杞藉湴鍧� server.lesson_audio_public_url");
+        }
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("course_id", course);
+        payload.put("base_url", base);
+        payload.put("files", files);
+        publishCtl(macAddress, "UPDATE_MANIFEST", payload);
+    }
+
+    public static String normalizeMac(String macAddress) {
+        return StringUtils.trimToEmpty(macAddress).toLowerCase().replace('-', ':');
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java
new file mode 100644
index 0000000..59f5918
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentHistoryService.java
@@ -0,0 +1,247 @@
+package xiaozhi.modules.parent.service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+
+import lombok.RequiredArgsConstructor;
+import xiaozhi.common.exception.ErrorCode;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
+import xiaozhi.modules.agent.service.AgentChatHistoryService;
+import xiaozhi.modules.device.entity.DeviceEntity;
+import xiaozhi.modules.parent.catalog.ParentCatalog;
+import xiaozhi.modules.parent.dto.ParentHistoryDetailDTO;
+import xiaozhi.modules.parent.dto.ParentHistoryItemDTO;
+import xiaozhi.modules.parent.dto.ParentHistoryListDTO;
+import xiaozhi.modules.parent.dto.ParentHistoryTurnDTO;
+import xiaozhi.modules.parent.mqtt.ParentMqttPublisher;
+import xiaozhi.modules.task.dao.TaskInstanceDao;
+import xiaozhi.modules.task.dao.TaskStepDao;
+import xiaozhi.modules.task.entity.TaskInstanceEntity;
+import xiaozhi.modules.task.entity.TaskStepEntity;
+
+@Service
+@RequiredArgsConstructor
+public class ParentHistoryService {
+    private static final int LIST_MAX = 50;
+    private static final Pattern PLAYABLE_AUDIO = Pattern.compile("^[0-9A-Fa-f-]{32,36}$");
+
+    private final ParentTaskService parentTaskService;
+    private final TaskInstanceDao instanceDao;
+    private final TaskStepDao stepDao;
+    private final AgentChatHistoryService agentChatHistoryService;
+
+    public ParentHistoryListDTO list(Long userId, String macAddress, String agentId, Integer limit) {
+        DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId);
+        parentTaskService.expireStaleForDevice(device.getMacAddress());
+        Set<String> macs = macCandidates(device.getMacAddress(), macAddress);
+        if (macs.isEmpty()) {
+            return new ParentHistoryListDTO();
+        }
+        int size = limit == null ? 20 : Math.min(Math.max(limit, 1), LIST_MAX);
+        String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId);
+
+        Long total = instanceDao.selectCount(ownedInstances(macs, aid));
+        List<TaskInstanceEntity> rows = instanceDao.selectList(ownedInstances(macs, aid)
+                .orderByDesc(TaskInstanceEntity::getStartedAt)
+                .last("LIMIT " + size));
+
+        Map<String, Integer> turnCounts = countTurns(rows);
+        ParentHistoryListDTO result = new ParentHistoryListDTO();
+        result.setTotal(total == null ? 0 : total);
+        for (TaskInstanceEntity row : rows) {
+            result.getList().add(toItem(row, turnCounts.getOrDefault(row.getId(), 0)));
+        }
+        return result;
+    }
+
+    public ParentHistoryDetailDTO get(Long userId, String macAddress, String agentId, String id) {
+        DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId);
+        if (StringUtils.isBlank(id)) {
+            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
+        }
+        TaskInstanceEntity entity = instanceDao.selectById(id);
+        if (entity == null || !owns(device, entity)) {
+            throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
+        }
+
+        List<TaskStepEntity> steps = stepDao.selectList(new LambdaQueryWrapper<TaskStepEntity>()
+                .eq(TaskStepEntity::getInstanceId, id)
+                .orderByAsc(TaskStepEntity::getStepIndex));
+        ParentHistoryDetailDTO detail = new ParentHistoryDetailDTO();
+        copyItem(toItem(entity, 0), detail);
+        String kind = detail.getKind();
+        if ("shadowing".equals(kind)) {
+            for (TaskStepEntity step : steps) {
+                if (step.getEchoRejected() != null && step.getEchoRejected() == 1) {
+                    continue;
+                }
+                ParentHistoryTurnDTO turn = new ParentHistoryTurnDTO();
+                turn.setIndex(step.getStepIndex());
+                turn.setExpectedText(displayText(step.getExpectedText()));
+                turn.setAsrText(displayText(step.getAsrText()));
+                turn.setAudioId(playableAudioId(step.getAudioId()));
+                detail.getTurns().add(turn);
+            }
+            detail.setTurnCount(detail.getTurns().size());
+        }
+        if ("chat".equals(kind) && StringUtils.isNotBlank(entity.getWsSessionId())) {
+            String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId);
+            List<AgentChatHistoryDTO> chats = agentChatHistoryService
+                    .getChatHistoryBySessionId(aid, entity.getWsSessionId());
+            if (chats != null) {
+                for (AgentChatHistoryDTO chat : chats) {
+                    chat.setAudioId(playableAudioId(chat.getAudioId()));
+                }
+                detail.setChats(chats);
+                detail.setTurnCount(chats.size());
+            }
+        }
+        return detail;
+    }
+
+    private LambdaQueryWrapper<TaskInstanceEntity> ownedInstances(Set<String> macs, String agentId) {
+        return new LambdaQueryWrapper<TaskInstanceEntity>()
+                .in(TaskInstanceEntity::getMacAddress, macs)
+                .and(w -> w.eq(TaskInstanceEntity::getAgentId, agentId)
+                        .or()
+                        .isNull(TaskInstanceEntity::getAgentId)
+                        .or()
+                        .eq(TaskInstanceEntity::getAgentId, ""));
+    }
+
+    private boolean owns(DeviceEntity device, TaskInstanceEntity entity) {
+        Set<String> macs = macCandidates(device.getMacAddress(), null);
+        String instanceMac = StringUtils.trimToEmpty(entity.getMacAddress());
+        boolean macOk = false;
+        for (String candidate : macs) {
+            if (candidate.equalsIgnoreCase(instanceMac)
+                    || ParentMqttPublisher.normalizeMac(candidate)
+                            .equals(ParentMqttPublisher.normalizeMac(instanceMac))) {
+                macOk = true;
+                break;
+            }
+        }
+        if (!macOk) {
+            return false;
+        }
+        if (StringUtils.isBlank(entity.getAgentId()) || StringUtils.isBlank(device.getAgentId())) {
+            return true;
+        }
+        return entity.getAgentId().equals(device.getAgentId());
+    }
+
+    private Map<String, Integer> countTurns(List<TaskInstanceEntity> rows) {
+        Map<String, Integer> counts = new HashMap<>();
+        if (rows == null || rows.isEmpty()) {
+            return counts;
+        }
+        List<String> ids = new ArrayList<>();
+        for (TaskInstanceEntity row : rows) {
+            if ("shadowing".equals(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId()))) {
+                ids.add(row.getId());
+            }
+        }
+        if (ids.isEmpty()) {
+            return counts;
+        }
+        List<TaskStepEntity> steps = stepDao.selectList(new LambdaQueryWrapper<TaskStepEntity>()
+                .in(TaskStepEntity::getInstanceId, ids));
+        for (TaskStepEntity step : steps) {
+            if (step.getEchoRejected() != null && step.getEchoRejected() == 1) {
+                continue;
+            }
+            counts.merge(step.getInstanceId(), 1, Integer::sum);
+        }
+        return counts;
+    }
+
+    private ParentHistoryItemDTO toItem(TaskInstanceEntity row, int turnCount) {
+        ParentHistoryItemDTO item = new ParentHistoryItemDTO();
+        item.setId(row.getId());
+        String scene = ParentCatalog.sceneIdFromAppTask(row.getAppId(), row.getTaskId());
+        item.setScene(scene);
+        item.setSceneName(StringUtils.defaultIfBlank(ParentCatalog.sceneName(scene),
+                StringUtils.defaultIfBlank(row.getTaskId(), row.getAppId())));
+        item.setKind(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId()));
+        item.setAppId(row.getAppId());
+        item.setTaskId(row.getTaskId());
+        item.setSource(row.getSource());
+        item.setStatus(row.getStatus());
+        item.setEndReason(row.getEndReason());
+        item.setStartedAt(row.getStartedAt());
+        item.setEndedAt(row.getEndedAt());
+        if (row.getStartedAt() != null && row.getEndedAt() != null && row.getEndedAt() > row.getStartedAt()) {
+            item.setDurationMs(row.getEndedAt() - row.getStartedAt());
+        }
+        item.setTurnCount(turnCount);
+        return item;
+    }
+
+    private void copyItem(ParentHistoryItemDTO source, ParentHistoryDetailDTO target) {
+        target.setId(source.getId());
+        target.setScene(source.getScene());
+        target.setSceneName(source.getSceneName());
+        target.setKind(source.getKind());
+        target.setAppId(source.getAppId());
+        target.setTaskId(source.getTaskId());
+        target.setSource(source.getSource());
+        target.setStatus(source.getStatus());
+        target.setEndReason(source.getEndReason());
+        target.setStartedAt(source.getStartedAt());
+        target.setEndedAt(source.getEndedAt());
+        target.setDurationMs(source.getDurationMs());
+        target.setTurnCount(source.getTurnCount());
+    }
+
+    private String playableAudioId(String audioId) {
+        if (StringUtils.isBlank(audioId)) {
+            return null;
+        }
+        String value = audioId.trim();
+        if (value.contains("/") || value.contains("\\") || value.contains(".")) {
+            return null;
+        }
+        return PLAYABLE_AUDIO.matcher(value).matches() ? value : null;
+    }
+
+    private String displayText(String text) {
+        if (StringUtils.isBlank(text)) {
+            return "";
+        }
+        String value = text.trim();
+        if (value.contains("/") || value.contains("\\") || value.endsWith(".mp3") || value.endsWith(".wav")) {
+            return "";
+        }
+        return value;
+    }
+
+    private Set<String> macCandidates(String stored, String requested) {
+        Set<String> values = new HashSet<>();
+        addMac(values, stored);
+        addMac(values, requested);
+        return values;
+    }
+
+    private void addMac(Set<String> values, String raw) {
+        String text = StringUtils.trimToEmpty(raw);
+        if (text.isEmpty()) {
+            return;
+        }
+        values.add(text);
+        values.add(text.toLowerCase());
+        values.add(text.toLowerCase().replace('-', ':'));
+        values.add(text.toLowerCase().replace(':', '-'));
+        values.add(ParentMqttPublisher.normalizeMac(text));
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java
new file mode 100644
index 0000000..6a336c0
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/parent/service/ParentTaskService.java
@@ -0,0 +1,764 @@
+package xiaozhi.modules.parent.service;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.exception.ErrorCode;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.modules.agent.service.AgentService;
+import xiaozhi.modules.device.entity.DeviceEntity;
+import xiaozhi.modules.device.service.DeviceService;
+import xiaozhi.modules.parent.catalog.IdiomLessonCatalog;
+import xiaozhi.modules.parent.catalog.LessonClipIndex;
+import xiaozhi.modules.parent.catalog.ParentCatalog;
+import xiaozhi.modules.parent.catalog.ParentSceneCatalog;
+import xiaozhi.modules.parent.dao.ParentTaskDao;
+import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO;
+import xiaozhi.modules.parent.dto.ParentImmediateCloseDTO;
+import xiaozhi.modules.parent.dto.ParentTaskDTO;
+import xiaozhi.modules.parent.dto.ParentTaskListDTO;
+import xiaozhi.modules.parent.dto.ParentTaskSaveDTO;
+import xiaozhi.modules.parent.entity.ParentTaskEntity;
+import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
+import xiaozhi.modules.parent.mqtt.ParentMqttPublisher;
+import xiaozhi.modules.sys.service.SysParamsService;
+import xiaozhi.modules.task.dao.TaskInstanceDao;
+import xiaozhi.modules.task.dto.LessonSyncDTO;
+import xiaozhi.modules.task.entity.TaskInstanceEntity;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ParentTaskService {
+    private static final Set<String> RUN_MODES = Set.of("immediate", "scheduled");
+    private static final Set<String> ON_COMPLETE = Set.of("chat", "power_off");
+    private static final Set<String> SCHEDULE_TYPES = Set.of("daily", "hourly", "interval");
+    private static final Pattern TIME = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d)?$");
+    private static final long STALE_RUNNING_MS = TimeUnit.HOURS.toMillis(48);
+    private static final long OFFLINE_GRACE_MS = TimeUnit.MINUTES.toMillis(5);
+    private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
+    };
+
+    private final ParentTaskDao parentTaskDao;
+    private final DeviceService deviceService;
+    private final AgentService agentService;
+    private final ParentMqttPublisher mqttPublisher;
+    private final ParentDevicePresence devicePresence;
+    private final ObjectMapper objectMapper;
+    private final SysParamsService sysParamsService;
+    private final TaskInstanceDao taskInstanceDao;
+
+    public ParentDeviceStatusDTO deviceStatus(Long userId, String macAddress, String agentId) {
+        DeviceEntity device = requireDevice(userId, macAddress, agentId);
+        ParentDeviceStatusDTO dto = new ParentDeviceStatusDTO();
+        dto.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress()));
+        dto.setOnline(devicePresence.isOnline(device.getMacAddress()));
+        return dto;
+    }
+
+    public ParentTaskListDTO list(Long userId, String macAddress, String agentId, String runMode) {
+        DeviceEntity device = requireDevice(userId, macAddress, agentId);
+        String mac = ParentMqttPublisher.normalizeMac(device.getMacAddress());
+        expireStaleImmediate(mac, devicePresence.isOnlineCached(mac));
+        LambdaQueryWrapper<ParentTaskEntity> wrapper = new LambdaQueryWrapper<ParentTaskEntity>()
+                .eq(ParentTaskEntity::getUserId, userId)
+                .eq(ParentTaskEntity::getMacAddress, mac)
+                .eq(ParentTaskEntity::getAgentId, device.getAgentId())
+                .eq(StringUtils.isNotBlank(runMode), ParentTaskEntity::getRunMode, runMode)
+                .and(w -> w.eq(ParentTaskEntity::getRunMode, "scheduled")
+                        .or(q -> q.in(ParentTaskEntity::getStatus, "queued", "running")))
+                .orderByDesc(ParentTaskEntity::getUpdatedAt);
+        List<ParentTaskEntity> rows = parentTaskDao.selectList(wrapper);
+        ParentTaskListDTO result = new ParentTaskListDTO();
+        result.setTotal(rows.size());
+        for (ParentTaskEntity row : rows) {
+            result.getList().add(toDto(row));
+        }
+        return result;
+    }
+
+    public ParentTaskDTO get(Long userId, String id) {
+        return toDto(requireOwned(userId, id));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public ParentTaskDTO create(Long userId, ParentTaskSaveDTO body) {
+        DeviceEntity device = requireDevice(userId, body.getMacAddress(), body.getAgentId());
+        devicePresence.requireOnline(device.getMacAddress());
+        String runMode = normalizeRunMode(body.getRunMode());
+        Map<String, Object> scene = requireSceneRow(body.getScene());
+        Map<String, String> mapped = sceneAppTask(scene);
+        Map<String, Object> params = compactParams(scene, body.getParams());
+        Map<String, Object> schedule = null;
+        if ("scheduled".equals(runMode)) {
+            schedule = requireSchedule(body.getSchedule());
+            int scheduled = countScheduled(device.getMacAddress());
+            if (scheduled >= ParentCatalog.SCHEDULED_SOFT_LIMIT) {
+                throw new RenException("姣忓彴璁惧鏈�澶� " + ParentCatalog.SCHEDULED_SOFT_LIMIT + " 鏉¤鍒�");
+            }
+        }
+
+        Date now = new Date();
+        ParentTaskEntity entity = new ParentTaskEntity();
+        entity.setId(UUID.randomUUID().toString());
+        entity.setUserId(userId);
+        entity.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress()));
+        entity.setAgentId(device.getAgentId());
+        entity.setRunMode(runMode);
+        entity.setScene(text(scene.get("id")));
+        entity.setAppId(mapped.get("appId"));
+        entity.setTaskId(mapped.get("taskId"));
+        entity.setParams(writeJson(params));
+        entity.setSchedule(schedule == null ? null : writeJson(schedule));
+        entity.setEnabled(Boolean.FALSE.equals(body.getEnabled()) ? 0 : 1);
+        entity.setStatus("scheduled".equals(runMode) ? "active" : "queued");
+        entity.setCreatedAt(now);
+        entity.setUpdatedAt(now);
+        parentTaskDao.insert(entity);
+
+        if ("immediate".equals(runMode)) {
+            dispatchImmediate(entity, params);
+        } else {
+            syncSchedule(device.getMacAddress());
+        }
+        return toDto(entity);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public ParentTaskDTO update(Long userId, String id, ParentTaskSaveDTO body) {
+        ParentTaskEntity entity = requireOwned(userId, id);
+        DeviceEntity device = requireDevice(userId, entity.getMacAddress(), entity.getAgentId());
+        devicePresence.requireOnline(device.getMacAddress());
+        if (StringUtils.isNotBlank(body.getScene()) && !body.getScene().equals(entity.getScene())) {
+            Map<String, Object> scene = requireSceneRow(body.getScene());
+            Map<String, String> mapped = sceneAppTask(scene);
+            entity.setScene(text(scene.get("id")));
+            entity.setAppId(mapped.get("appId"));
+            entity.setTaskId(mapped.get("taskId"));
+        }
+        if (body.getParams() != null) {
+            entity.setParams(writeJson(compactParams(requireSceneRow(entity.getScene()), body.getParams())));
+        }
+        if ("scheduled".equals(entity.getRunMode())) {
+            if (body.getSchedule() != null) {
+                entity.setSchedule(writeJson(requireSchedule(body.getSchedule())));
+            }
+            if (body.getEnabled() != null) {
+                entity.setEnabled(body.getEnabled() ? 1 : 0);
+            }
+        }
+        entity.setUpdatedAt(new Date());
+        parentTaskDao.updateById(entity);
+        if ("scheduled".equals(entity.getRunMode())) {
+            syncSchedule(device.getMacAddress());
+        }
+        return toDto(entity);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long userId, String id) {
+        ParentTaskEntity entity = requireOwned(userId, id);
+        devicePresence.requireOnline(entity.getMacAddress());
+        String mac = entity.getMacAddress();
+        boolean scheduled = "scheduled".equals(entity.getRunMode());
+        parentTaskDao.deleteById(id);
+        if (scheduled) {
+            syncSchedule(mac);
+        }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public ParentTaskDTO runNow(Long userId, String id) {
+        ParentTaskEntity entity = requireOwned(userId, id);
+        devicePresence.requireOnline(entity.getMacAddress());
+        Map<String, Object> params = readMap(entity.getParams());
+        dispatchImmediate(entity, params);
+        return toDto(entity);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public ParentTaskDTO stop(Long userId, String id) {
+        ParentTaskEntity entity = requireOwned(userId, id);
+        if (!"immediate".equals(entity.getRunMode())
+                || (!"queued".equals(entity.getStatus()) && !"running".equals(entity.getStatus()))) {
+            throw new RenException("褰撳墠娌℃湁姝e湪鎵ц鐨勪换鍔�");
+        }
+        devicePresence.requireOnline(entity.getMacAddress());
+        dispatchStopToChat(entity.getMacAddress());
+        entity.setStatus("cancelled");
+        entity.setUpdatedAt(new Date());
+        parentTaskDao.updateById(entity);
+        return toDto(entity);
+    }
+
+    public void syncSchedule(String macAddress) {
+        List<ParentTaskEntity> rows = parentTaskDao.selectList(new LambdaQueryWrapper<ParentTaskEntity>()
+                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
+                .eq(ParentTaskEntity::getRunMode, "scheduled")
+                .eq(ParentTaskEntity::getEnabled, 1)
+                .orderByAsc(ParentTaskEntity::getCreatedAt));
+        List<Map<String, Object>> tasks = new ArrayList<>();
+        for (ParentTaskEntity row : rows) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", row.getId());
+            item.put("app_id", row.getAppId());
+            item.put("task_id", row.getTaskId());
+            item.put("enabled", true);
+            item.put("params", readMap(row.getParams()));
+            Map<String, Object> schedule = readMap(row.getSchedule());
+            if (!schedule.isEmpty()) {
+                item.put("schedule", schedule);
+            }
+            tasks.add(item);
+        }
+        mqttPublisher.publishCtl(macAddress, "SYNC_SCHEDULE", Map.of("tasks", tasks));
+        syncScheduledLessonAudio(macAddress, rows);
+    }
+
+    private void dispatchImmediate(ParentTaskEntity entity, Map<String, Object> params) {
+        Map<String, Object> mqttParams = new LinkedHashMap<>(params == null ? Map.of() : params);
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("app_id", entity.getAppId());
+        payload.put("task_id", entity.getTaskId());
+        payload.put("source", "mqtt_immediate");
+        payload.put("params", mqttParams);
+        Map<String, Object> pkg = buildLessonPackage(entity.getScene(), mqttParams);
+        if (pkg != null) {
+            payload.put("package", pkg);
+        }
+        mqttPublisher.publishCtl(entity.getMacAddress(), "START_APP_TASK", payload);
+        supersedeImmediate(entity.getMacAddress(), entity.getId());
+        if ("immediate".equals(entity.getRunMode())) {
+            entity.setStatus("running");
+            entity.setUpdatedAt(new Date());
+            parentTaskDao.updateById(entity);
+        }
+    }
+
+    private void dispatchStopToChat(String macAddress) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("app_id", "chat");
+        payload.put("task_id", "conversation");
+        payload.put("source", "mqtt_immediate");
+        payload.put("params", Map.of("on_complete", "chat"));
+        mqttPublisher.publishCtl(macAddress, "START_APP_TASK", payload);
+    }
+
+    public void syncLessonFromServer(LessonSyncDTO body) {
+        if (body == null || StringUtils.isBlank(body.getMacAddress())) {
+            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
+        }
+        List<String> files = ParentCatalog.uniqueLessonPaths(body.getFiles());
+        if (files.isEmpty()) {
+            return;
+        }
+        String org = ParentCatalog.resolveOrgId(body.getOrgId());
+        String base = StringUtils.trimToEmpty(body.getBaseUrl()).replaceAll("/+$", "");
+        if (StringUtils.isBlank(base) || "null".equalsIgnoreCase(base) || base.contains("浣�")) {
+            base = resolveLessonAudioBase(org);
+        }
+        if (StringUtils.isBlank(base)) {
+            throw new RenException("鏈厤缃� server.lesson_audio_public_url锛屾棤娉曞悓姝ヨ鏂�");
+        }
+        mqttPublisher.publishLessonManifest(body.getMacAddress(), body.getCourseId(), base, files);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> buildLessonPackage(String sceneId, Map<String, Object> params) {
+        Map<String, Object> scene = ParentSceneCatalog.find(sceneId);
+        if (!ParentSceneCatalog.flag(scene, "audioPackage")) {
+            return null;
+        }
+        Map<String, Object> src = params == null ? Map.of() : params;
+        String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id"));
+        String language = text(src.get("language") == null ? src.get("lang") : src.get("language"));
+        String org = ParentCatalog.resolveOrgId(text(src.get("org_id") == null ? src.get("orgId") : src.get("org_id")));
+        String base = resolveLessonAudioBase(org);
+        List<String> files = lessonFilesForPackage(base, sceneId, lessonId, language);
+        if (StringUtils.isBlank(base)) {
+            log.warn("skip lesson package: server.lesson_audio_public_url is empty");
+            return null;
+        }
+        if (files.isEmpty()) {
+            return null;
+        }
+        Map<String, Object> pkg = new LinkedHashMap<>();
+        pkg.put("course_id", ParentCatalog.courseId(sceneId, lessonId));
+        pkg.put("base_url", base);
+        pkg.put("files", files);
+        return pkg;
+    }
+
+    private void syncScheduledLessonAudio(String macAddress, List<ParentTaskEntity> rows) {
+        List<String> files = new ArrayList<>();
+        String lessonId = "l1.001";
+        String scene = "extensive";
+        for (ParentTaskEntity row : rows) {
+            if (!ParentSceneCatalog.flag(row.getScene(), "audioPackage")) {
+                continue;
+            }
+            Map<String, Object> params = readMap(row.getParams());
+            lessonId = text(params.get("lesson_id") == null ? params.get("lessonId") : params.get("lesson_id"));
+            scene = row.getScene();
+            String language = text(params.get("language") == null ? params.get("lang") : params.get("language"));
+            String org = ParentCatalog.DEFAULT_ORG_ID;
+            String base = resolveLessonAudioBase(org);
+            files.addAll(lessonFilesForPackage(base, row.getScene(), lessonId, language));
+        }
+        files = ParentCatalog.uniqueLessonPaths(files);
+        if (files.isEmpty()) {
+            return;
+        }
+        String base = resolveLessonAudioBase(ParentCatalog.DEFAULT_ORG_ID);
+        if (StringUtils.isBlank(base)) {
+            log.warn("skip scheduled lesson sync: server.lesson_audio_public_url is empty");
+            return;
+        }
+        mqttPublisher.publishLessonManifest(macAddress, ParentCatalog.courseId(scene, lessonId), base, files);
+    }
+
+    private List<String> lessonFilesForPackage(String base, String sceneId, String lessonId, String language) {
+        List<String> listed = LessonClipIndex.fetch(base, sceneId, lessonId, language);
+        if (listed != null) {
+            return ParentCatalog.uniqueLessonPaths(listed);
+        }
+        return ParentCatalog.uniqueLessonPaths(ParentCatalog.lessonRelativePaths(sceneId, lessonId, language));
+    }
+
+    private String resolveLessonAudioBase(String orgId) {
+        String root = StringUtils.trimToEmpty(sysParamsService.getValue("server.lesson_audio_public_url", true));
+        if (StringUtils.isBlank(root) || "null".equalsIgnoreCase(root) || root.contains("浣�")) {
+            return "";
+        }
+        return root.replaceAll("/+$", "") + "/" + ParentCatalog.resolveOrgId(orgId);
+    }
+
+    private void supersedeImmediate(String macAddress, String exceptId) {
+        LambdaUpdateWrapper<ParentTaskEntity> wrapper = new LambdaUpdateWrapper<ParentTaskEntity>()
+                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
+                .eq(ParentTaskEntity::getRunMode, "immediate")
+                .in(ParentTaskEntity::getStatus, "queued", "running")
+                .set(ParentTaskEntity::getStatus, "cancelled")
+                .set(ParentTaskEntity::getUpdatedAt, new Date());
+        if (StringUtils.isNotBlank(exceptId)) {
+            wrapper.ne(ParentTaskEntity::getId, exceptId);
+        }
+        parentTaskDao.update(null, wrapper);
+    }
+
+    public int closeFromServer(ParentImmediateCloseDTO body) {
+        if (body == null || StringUtils.isBlank(body.getDeviceId())) {
+            return 0;
+        }
+        String reason = StringUtils.defaultIfBlank(body.getReason(), "server");
+        String status = closeStatus(body.getStatus(), reason);
+        if (StringUtils.isNotBlank(body.getMatchAppId())) {
+            return closeMatchingImmediate(body.getDeviceId(), body.getMatchAppId(), body.getMatchTaskId(), status);
+        }
+        return closeRunningImmediate(body.getDeviceId(), status, body.getExceptAppId(), body.getExceptTaskId());
+    }
+
+    public void closeRunningForOffline(String macAddress) {
+        closeRunningImmediate(macAddress, "cancelled", null, null);
+        expireRunningInstances(macAddress, "disconnected");
+    }
+
+    public void expireStaleForDevice(String macAddress) {
+        String mac = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(mac)) {
+            return;
+        }
+        expireStaleImmediate(mac, devicePresence.isOnlineCached(mac));
+    }
+
+    public int closeRunningImmediate(String macAddress, String status, String exceptAppId, String exceptTaskId) {
+        String mac = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(mac)) {
+            return 0;
+        }
+        LambdaUpdateWrapper<ParentTaskEntity> wrapper = runningImmediateWrapper(mac)
+                .set(ParentTaskEntity::getStatus, closeStatus(status, status))
+                .set(ParentTaskEntity::getUpdatedAt, new Date());
+        if (StringUtils.isNotBlank(exceptAppId) && StringUtils.isNotBlank(exceptTaskId)) {
+            wrapper.and(w -> w.ne(ParentTaskEntity::getAppId, exceptAppId)
+                    .or()
+                    .ne(ParentTaskEntity::getTaskId, exceptTaskId));
+        }
+        return parentTaskDao.update(null, wrapper);
+    }
+
+    public int closeMatchingImmediate(String macAddress, String appId, String taskId, String status) {
+        String mac = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(mac) || StringUtils.isBlank(appId)) {
+            return 0;
+        }
+        LambdaUpdateWrapper<ParentTaskEntity> wrapper = runningImmediateWrapper(mac)
+                .eq(ParentTaskEntity::getAppId, appId)
+                .eq(StringUtils.isNotBlank(taskId), ParentTaskEntity::getTaskId, taskId)
+                .set(ParentTaskEntity::getStatus, closeStatus(status, status))
+                .set(ParentTaskEntity::getUpdatedAt, new Date());
+        return parentTaskDao.update(null, wrapper);
+    }
+
+    private void expireStaleImmediate(String mac, boolean online) {
+        long ageMs = online ? STALE_RUNNING_MS : OFFLINE_GRACE_MS;
+        Date cutoff = new Date(System.currentTimeMillis() - ageMs);
+        parentTaskDao.update(null, runningImmediateWrapper(mac)
+                .lt(ParentTaskEntity::getUpdatedAt, cutoff)
+                .set(ParentTaskEntity::getStatus, "cancelled")
+                .set(ParentTaskEntity::getUpdatedAt, new Date()));
+        long instanceCutoff = System.currentTimeMillis() - STALE_RUNNING_MS;
+        expireRunningInstancesBefore(mac, instanceCutoff, "stale");
+    }
+
+    private void expireRunningInstances(String macAddress, String reason) {
+        expireRunningInstancesBefore(macAddress, System.currentTimeMillis(), reason);
+    }
+
+    private void expireRunningInstancesBefore(String macAddress, long startedBeforeMs, String reason) {
+        String colon = ParentMqttPublisher.normalizeMac(macAddress);
+        if (StringUtils.isBlank(colon)) {
+            return;
+        }
+        String hyphen = colon.replace(':', '-');
+        Date now = new Date();
+        taskInstanceDao.update(null, new LambdaUpdateWrapper<TaskInstanceEntity>()
+                .in(TaskInstanceEntity::getMacAddress, colon, hyphen)
+                .eq(TaskInstanceEntity::getStatus, "running")
+                .lt(TaskInstanceEntity::getStartedAt, startedBeforeMs)
+                .set(TaskInstanceEntity::getStatus, "cancelled")
+                .set(TaskInstanceEntity::getEndReason, StringUtils.left(reason, 32))
+                .set(TaskInstanceEntity::getEndedAt, now.getTime())
+                .set(TaskInstanceEntity::getUpdatedAt, now));
+    }
+
+    private LambdaUpdateWrapper<ParentTaskEntity> runningImmediateWrapper(String mac) {
+        return new LambdaUpdateWrapper<ParentTaskEntity>()
+                .eq(ParentTaskEntity::getMacAddress, mac)
+                .eq(ParentTaskEntity::getRunMode, "immediate")
+                .in(ParentTaskEntity::getStatus, "queued", "running");
+    }
+
+    private static String closeStatus(String status, String reason) {
+        if ("done".equals(status) || "completed".equals(status)) {
+            return "done";
+        }
+        if ("cancelled".equals(status) || "canceled".equals(status)) {
+            return "cancelled";
+        }
+        if ("play_completed".equals(reason) || "finished".equals(reason) || "done".equals(reason)) {
+            return "done";
+        }
+        return "cancelled";
+    }
+
+    private DeviceEntity requireDevice(Long userId, String macAddress, String agentId) {
+        if (userId == null || userId == 0L) {
+            throw new RenException(ErrorCode.UNAUTHORIZED);
+        }
+        if (StringUtils.isBlank(macAddress) || StringUtils.isBlank(agentId)) {
+            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
+        }
+        DeviceEntity device = findDevice(macAddress);
+        if (device == null || device.getUserId() == null || !device.getUserId().equals(userId)) {
+            throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND);
+        }
+        if (!agentService.checkAgentPermission(agentId, userId)) {
+            throw new RenException(ErrorCode.NO_PERMISSION);
+        }
+        if (StringUtils.isNotBlank(device.getAgentId()) && !agentId.equals(device.getAgentId())) {
+            throw new RenException("璁惧涓庢櫤鑳戒綋涓嶅尮閰�");
+        }
+        return device;
+    }
+
+    public DeviceEntity requireUserDevice(Long userId, String macAddress, String agentId) {
+        return requireDevice(userId, macAddress, agentId);
+    }
+
+    private DeviceEntity findDevice(String macAddress) {
+        String raw = StringUtils.trimToEmpty(macAddress);
+        String lower = raw.toLowerCase();
+        String colon = lower.replace('-', ':');
+        String hyphen = lower.replace(':', '-');
+        for (String candidate : new String[] { raw, lower, colon, hyphen }) {
+            if (StringUtils.isBlank(candidate)) {
+                continue;
+            }
+            DeviceEntity device = deviceService.getDeviceByMacAddress(candidate);
+            if (device != null) {
+                return device;
+            }
+        }
+        return null;
+    }
+
+    private ParentTaskEntity requireOwned(Long userId, String id) {
+        if (StringUtils.isBlank(id)) {
+            throw new RenException(ErrorCode.PARAM_VALUE_NULL);
+        }
+        ParentTaskEntity entity = parentTaskDao.selectById(id);
+        if (entity == null || entity.getUserId() == null || !entity.getUserId().equals(userId)) {
+            throw new RenException(ErrorCode.RESOURCE_NOT_FOUND);
+        }
+        return entity;
+    }
+
+    private int countScheduled(String macAddress) {
+        Long count = parentTaskDao.selectCount(new LambdaQueryWrapper<ParentTaskEntity>()
+                .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress))
+                .eq(ParentTaskEntity::getRunMode, "scheduled"));
+        return count == null ? 0 : count.intValue();
+    }
+
+    private String normalizeRunMode(String runMode) {
+        String value = StringUtils.trimToEmpty(runMode);
+        if (!RUN_MODES.contains(value)) {
+            throw new RenException("run_mode 鍙兘鏄� immediate 鎴� scheduled");
+        }
+        return value;
+    }
+
+    private Map<String, Object> requireSceneRow(String scene) {
+        try {
+            return ParentSceneCatalog.require(StringUtils.trimToEmpty(scene));
+        } catch (IllegalArgumentException ex) {
+            throw new RenException("涓嶆敮鎸佺殑鍦烘櫙");
+        }
+    }
+
+    private Map<String, String> sceneAppTask(Map<String, Object> scene) {
+        Map<String, String> mapped = ParentSceneCatalog.sceneToAppTask(text(scene.get("id")));
+        if (mapped == null) {
+            throw new RenException("涓嶆敮鎸佺殑鍦烘櫙");
+        }
+        return mapped;
+    }
+
+    private Map<String, Object> compactParams(Map<String, Object> scene, Map<String, Object> raw) {
+        Map<String, Object> src = raw == null ? Map.of() : raw;
+        Map<String, Object> out = new LinkedHashMap<>();
+        String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id"));
+        if (ParentSceneCatalog.flag(scene, "needsLesson")) {
+            if (StringUtils.isBlank(lessonId)) {
+                lessonId = "l1.001";
+            }
+            if (IdiomLessonCatalog.findStory(lessonId) == null) {
+                throw new RenException("鏈壘鍒拌鎴愯璇炬枃锛岃妫�鏌ユ竻鍗� idiom-lessons.json");
+            }
+            String canonical = IdiomLessonCatalog.canonicalLessonId(lessonId);
+            out.put("lesson_id", canonical);
+            String lessonName = IdiomLessonCatalog.storyName(canonical);
+            if (StringUtils.isNotBlank(lessonName)) {
+                out.put("lesson_name", lessonName);
+            }
+            String lessonNameEn = IdiomLessonCatalog.storyNameEn(canonical);
+            if (StringUtils.isNotBlank(lessonNameEn)) {
+                out.put("lesson_name_en", lessonNameEn);
+            }
+            if (ParentSceneCatalog.flag(scene, "needsEnPasses")) {
+                out.put("cn_repeat", intInRange(src.get("cn_repeat") == null ? src.get("cnRepeat") : src.get("cn_repeat"),
+                        1, 10, 1));
+                out.put("cn_speed", doubleInRange(src.get("cn_speed") == null ? src.get("cnSpeed") : src.get("cn_speed"),
+                        0.5, 2.0, 1.0));
+                out.put("en_passes", normalizeEnPasses(src.get("en_passes") == null ? src.get("enPasses") : src.get("en_passes")));
+            }
+            if (ParentSceneCatalog.flag(scene, "needsShadowingSettings")) {
+                String language = text(src.get("language") == null ? src.get("lang") : src.get("language")).toLowerCase();
+                if (!"cn".equals(language) && !"en".equals(language)) {
+                    language = "en";
+                }
+                out.put("language", language);
+                out.put("speed", doubleInRange(src.get("speed"), 0.5, 2.0, 0.8));
+                out.put("listen_ratio", 1.2);
+            }
+        }
+        if (ParentSceneCatalog.flag(scene, "needsLanguagePair")) {
+            String pair = text(src.get("language_pair") == null ? src.get("languagePair") : src.get("language_pair"));
+            out.put("language_pair", StringUtils.defaultIfBlank(pair, "zh-en"));
+        }
+        Object duration = src.get("duration_minutes") == null ? src.get("durationMinutes") : src.get("duration_minutes");
+        if (duration != null) {
+            int minutes = intInRange(duration, 0, 24 * 60, 0);
+            if (minutes > 0) {
+                out.put("duration_minutes", minutes);
+            }
+        }
+        if (ParentSceneCatalog.flag(scene, "needsOnComplete")) {
+            String complete = text(src.get("on_complete") == null ? src.get("onComplete") : src.get("on_complete"));
+            if (StringUtils.isBlank(complete)) {
+                complete = "chat";
+            }
+            if (!ON_COMPLETE.contains(complete)) {
+                throw new RenException("on_complete 鍙兘鏄� chat 鎴� power_off");
+            }
+            out.put("on_complete", complete);
+        }
+        Object announce = src.get("announce");
+        if (announce instanceof Boolean bool) {
+            out.put("announce", bool);
+        }
+        return out;
+    }
+
+    private Map<String, Object> requireSchedule(Map<String, Object> raw) {
+        if (raw == null || raw.isEmpty()) {
+            throw new RenException("璁″垝浠诲姟闇�瑕� schedule");
+        }
+        String type = text(raw.get("type"));
+        if (!SCHEDULE_TYPES.contains(type)) {
+            throw new RenException("schedule.type 鍙兘鏄� daily銆乭ourly銆乮nterval");
+        }
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("type", type);
+        if ("daily".equals(type)) {
+            String time = text(raw.get("time"));
+            if (!TIME.matcher(time).matches()) {
+                throw new RenException("daily.time 鏍煎紡搴斾负 HH:MM");
+            }
+            out.put("time", time.length() == 5 ? time : time.substring(0, 5));
+            Object weekdays = raw.get("weekdays");
+            if (weekdays instanceof List<?> list && !list.isEmpty()) {
+                List<Integer> days = new ArrayList<>();
+                for (Object item : list) {
+                    int day = intInRange(item, 1, 7, 0);
+                    if (day >= 1 && day <= 7 && !days.contains(day)) {
+                        days.add(day);
+                    }
+                }
+                if (!days.isEmpty()) {
+                    out.put("weekdays", days);
+                }
+            }
+            return out;
+        }
+        if ("hourly".equals(type)) {
+            out.put("minute", intInRange(raw.get("minute"), 0, 59, 0));
+            return out;
+        }
+        out.put("every_minutes", intInRange(raw.get("every_minutes") == null ? raw.get("everyMinutes") : raw.get("every_minutes"),
+                1, 24 * 60, 60));
+        return out;
+    }
+
+    private ParentTaskDTO toDto(ParentTaskEntity entity) {
+        ParentTaskDTO dto = new ParentTaskDTO();
+        dto.setId(entity.getId());
+        dto.setMacAddress(entity.getMacAddress());
+        dto.setAgentId(entity.getAgentId());
+        dto.setRunMode(entity.getRunMode());
+        dto.setScene(entity.getScene());
+        dto.setAppId(entity.getAppId());
+        dto.setTaskId(entity.getTaskId());
+        dto.setSceneName(ParentCatalog.sceneName(entity.getScene()));
+        dto.setParams(readMap(entity.getParams()));
+        dto.setSchedule(readMap(entity.getSchedule()));
+        dto.setEnabled(entity.getEnabled() == null || entity.getEnabled() != 0);
+        dto.setStatus(entity.getStatus());
+        dto.setCreatedAt(entity.getCreatedAt());
+        dto.setUpdatedAt(entity.getUpdatedAt());
+        return dto;
+    }
+
+    private Map<String, Object> readMap(String json) {
+        if (StringUtils.isBlank(json)) {
+            return new LinkedHashMap<>();
+        }
+        try {
+            Map<String, Object> value = objectMapper.readValue(json, MAP);
+            return value == null ? new LinkedHashMap<>() : value;
+        } catch (Exception ex) {
+            return new LinkedHashMap<>();
+        }
+    }
+
+    private String writeJson(Object value) {
+        try {
+            return objectMapper.writeValueAsString(value == null ? Map.of() : value);
+        } catch (Exception ex) {
+            throw new RenException("JSON 鍐欏叆澶辫触");
+        }
+    }
+
+    private String text(Object value) {
+        return value == null ? "" : StringUtils.trimToEmpty(String.valueOf(value));
+    }
+
+    private List<Map<String, Object>> normalizeEnPasses(Object raw) {
+        List<Map<String, Object>> out = new ArrayList<>();
+        if (raw instanceof List<?> list) {
+            for (Object item : list) {
+                Map<String, Object> row = IdiomLessonCatalog.normalizePass(item);
+                if (row != null) {
+                    out.add(row);
+                }
+                if (out.size() >= 8) {
+                    break;
+                }
+            }
+        }
+        if (out.isEmpty()) {
+            return new ArrayList<>(IdiomLessonCatalog.defaultEnPasses());
+        }
+        return out;
+    }
+
+    private double doubleInRange(Object value, double min, double max, double fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        double parsed;
+        try {
+            parsed = Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException ex) {
+            return fallback;
+        }
+        if (Double.isNaN(parsed) || Double.isInfinite(parsed)) {
+            return fallback;
+        }
+        if (parsed < min) {
+            return min;
+        }
+        if (parsed > max) {
+            return max;
+        }
+        return parsed;
+    }
+
+    private int intInRange(Object value, int min, int max, int fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        int parsed;
+        try {
+            parsed = Integer.parseInt(String.valueOf(value).trim());
+        } catch (NumberFormatException ex) {
+            return fallback;
+        }
+        if (parsed < min) {
+            return min;
+        }
+        if (parsed > max) {
+            return max;
+        }
+        return parsed;
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
index 0960f16..233a5fd 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
@@ -95,6 +95,8 @@
         filterMap.put("/agent/chat-history/report", "server");
         filterMap.put("/task/instance", "server");
         filterMap.put("/task/instance/**", "server");
+        filterMap.put("/task/lesson-sync", "server");
+        filterMap.put("/task/lesson-sync/**", "server");
         filterMap.put("/agent/chat-history/download/**", "anon");
         filterMap.put("/agent/chat-summary/**", "server");
         filterMap.put("/agent/chat-title/**", "server");
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java
new file mode 100644
index 0000000..91752d7
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/LessonSyncController.java
@@ -0,0 +1,28 @@
+package xiaozhi.modules.task.controller;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import xiaozhi.common.utils.Result;
+import xiaozhi.modules.parent.service.ParentTaskService;
+import xiaozhi.modules.task.dto.LessonSyncDTO;
+
+@Tag(name = "Lesson Sync")
+@RestController
+@RequestMapping("/task/lesson-sync")
+@RequiredArgsConstructor
+public class LessonSyncController {
+    private final ParentTaskService parentTaskService;
+
+    @Operation(summary = "xiaozhi-server 璇锋眰鍚戣澶囦竴娆℃�у悓姝ヨ鏂� MP3")
+    @PostMapping
+    public Result<Boolean> sync(@RequestBody LessonSyncDTO body) {
+        parentTaskService.syncLessonFromServer(body);
+        return new Result<Boolean>().ok(Boolean.TRUE);
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java
index 888ec7b..9236f69 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/controller/TaskInstanceController.java
@@ -2,6 +2,7 @@
 
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -14,6 +15,8 @@
 import xiaozhi.common.exception.ErrorCode;
 import xiaozhi.common.exception.RenException;
 import xiaozhi.common.utils.Result;
+import xiaozhi.modules.parent.dto.ParentImmediateCloseDTO;
+import xiaozhi.modules.parent.service.ParentTaskService;
 import xiaozhi.modules.task.dto.TaskInstanceListDTO;
 import xiaozhi.modules.task.dto.TaskInstanceUpsertDTO;
 import xiaozhi.modules.task.service.TaskInstanceService;
@@ -24,6 +27,14 @@
 @RequiredArgsConstructor
 public class TaskInstanceController {
     private final TaskInstanceService taskInstanceService;
+    private final ParentTaskService parentTaskService;
+
+    @Operation(summary = "xiaozhi-server 鍏抽棴瀹堕暱鍗虫椂浠诲姟锛堝彛浠ゅ垏鎹�/鏂嚎锛�")
+    @PostMapping("/close-parent-immediate")
+    public Result<Integer> closeParentImmediate(@RequestBody ParentImmediateCloseDTO body) {
+        int n = parentTaskService.closeFromServer(body);
+        return new Result<Integer>().ok(n);
+    }
 
     @Operation(summary = "xiaozhi-server 涓婃姤 TaskSession 鍏ㄦ枃锛堝箓绛夎鐩栵級")
     @PutMapping("/{id}")
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java
new file mode 100644
index 0000000..5f6ca72
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/java/xiaozhi/modules/task/dto/LessonSyncDTO.java
@@ -0,0 +1,25 @@
+package xiaozhi.modules.task.dto;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "xiaozhi-server 璇锋眰鍚戣澶囦竴娆℃�у悓姝ヨ鏂� MP3")
+public class LessonSyncDTO {
+    @Schema(description = "璁惧 MAC")
+    private String macAddress;
+
+    @Schema(description = "鍥轰欢 course_id锛屼粎瀛楁瘝鏁板瓧_-")
+    private String courseId;
+
+    @Schema(description = "鏈烘瀯 id锛岀┖鍒� default_org")
+    private String orgId;
+
+    @Schema(description = "璁惧鍙闂殑 HTTP 鏍癸紝鍚� /lesson-audio/{org}")
+    private String baseUrl;
+
+    @Schema(description = "SD 鐩稿璺緞锛屽 /english_learning/repeater/lessons/l1/001/cn/01.mp3")
+    private List<String> files;
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql
new file mode 100644
index 0000000..07e016b
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181100.sql
@@ -0,0 +1,4 @@
+-- Local recording relative path is ~90 chars; VARCHAR(32) truncated and aborted TaskInstance upsert.
+
+ALTER TABLE `ai_task_step`
+    MODIFY COLUMN `audio_id` VARCHAR(255) DEFAULT NULL COMMENT '鏈湴褰曢煶鐩稿璺緞鎴� ai_agent_chat_audio.id';
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql
new file mode 100644
index 0000000..1bbbf5f
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609181400.sql
@@ -0,0 +1,35 @@
+-- Phase 7a-B parent dispatch. Do not edit older changesets.
+
+CREATE TABLE `ai_parent_task`
+(
+    `id`           VARCHAR(36)  NOT NULL COMMENT '浠诲姟UUID锛屼笉鏄椆閽焛d',
+    `user_id`      BIGINT       NOT NULL COMMENT '瀹堕暱鐢ㄦ埛',
+    `mac_address`  VARCHAR(50)  NOT NULL COMMENT '璁惧MAC',
+    `agent_id`     VARCHAR(32)  NOT NULL COMMENT '鏅鸿兘浣搃d',
+    `run_mode`     VARCHAR(16)  NOT NULL COMMENT 'immediate/scheduled',
+    `scene`        VARCHAR(32)  NOT NULL COMMENT 'extensive/shadowing/bilingual/translation/chat',
+    `app_id`       VARCHAR(32)  NOT NULL COMMENT '鍐呴儴 app',
+    `task_id`      VARCHAR(32)  NOT NULL COMMENT '鍐呴儴 task',
+    `params`       TEXT         NOT NULL COMMENT '绱у噾params锛屼笉鍚睍寮�playlist',
+    `schedule`     TEXT         DEFAULT NULL COMMENT '浠� scheduled',
+    `enabled`      TINYINT(1)   NOT NULL DEFAULT 1 COMMENT '璁″垝鍙仠鐢�',
+    `status`       VARCHAR(16)  NOT NULL DEFAULT 'queued' COMMENT 'immediate: queued/running/done/canceled锛泂cheduled: active',
+    `created_at`   DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
+    `updated_at`   DATETIME(3)  DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (`id`),
+    KEY `idx_parent_task_mac_mode` (`mac_address`, `run_mode`, `enabled`),
+    KEY `idx_parent_task_user` (`user_id`, `updated_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='瀹堕暱浠诲姟锛堟硾鍚�/璺熻/鍙岃/缈昏瘧/瀵硅瘽锛�';
+
+DELETE FROM `sys_params` WHERE `param_code` IN (
+    'server.parent_mqtt_host',
+    'server.parent_mqtt_port',
+    'server.parent_mqtt_username',
+    'server.parent_mqtt_password'
+);
+
+INSERT INTO `sys_params` (`id`, `param_code`, `param_value`, `value_type`, `param_type`, `remark`) VALUES
+(313, 'server.parent_mqtt_host', '124.222.109.197', 'string', 1, '瀹堕暱浠诲姟涓嬪彂 MQTT 涓绘満锛堣澶� cmd/ctl锛�'),
+(314, 'server.parent_mqtt_port', '2100', 'number', 1, '瀹堕暱浠诲姟涓嬪彂 MQTT 绔彛'),
+(315, 'server.parent_mqtt_username', 'AI_XIAOZHI', 'string', 1, '瀹堕暱浠诲姟涓嬪彂 MQTT 鐢ㄦ埛鍚�'),
+(316, 'server.parent_mqtt_password', 'V9qL7!mX2@rT8$kN5^wP1&c', 'string', 1, '瀹堕暱浠诲姟涓嬪彂 MQTT 瀵嗙爜');
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql
new file mode 100644
index 0000000..1ee103f
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191800.sql
@@ -0,0 +1,5 @@
+-- Lesson MP3 public URL for device DOWNLOAD / UPDATE_MANIFEST.
+
+DELETE FROM `sys_params` WHERE `param_code` = 'server.lesson_audio_public_url';
+INSERT INTO `sys_params` (`id`, `param_code`, `param_value`, `value_type`, `param_type`, `remark`) VALUES
+(317, 'server.lesson_audio_public_url', 'https://xiaozhi.illegalfm.com/lesson-audio', 'string', 1, '璁惧涓嬭浇璇炬枃 MP3 鐨勫叕缃戞牴锛圕addy /lesson-audio* 鈫� 鍐呯綉 8003锛夛紱纾佺洏 data/lesson_audio/{org_id}/锛岀┖鏈烘瀯 default_org');
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql
new file mode 100644
index 0000000..af07324
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/202609191930.sql
@@ -0,0 +1,6 @@
+-- Device lesson MP3 download via Caddy public path (xiaozhi-server still listens on 8003).
+
+UPDATE `sys_params`
+SET `param_value` = 'https://xiaozhi.illegalfm.com/lesson-audio',
+    `remark` = '璁惧涓嬭浇璇炬枃 MP3 鐨勫叕缃戞牴锛圕addy /lesson-audio* 鈫� 鍐呯綉 8003锛夛紱纾佺洏 data/lesson_audio/{org_id}/锛岀┖鏈烘瀯 default_org'
+WHERE `param_code` = 'server.lesson_audio_public_url';
diff --git a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
index 02a035d..ebcebfb 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
+++ b/xiaozhi-esp32-server/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -753,3 +753,31 @@
         - sqlFile:
             encoding: utf8
             path: classpath:db/changelog/202609011100.sql
+  - changeSet:
+      id: 202609181100
+      author: xiaozhi
+      changes:
+        - sqlFile:
+            encoding: utf8
+            path: classpath:db/changelog/202609181100.sql
+  - changeSet:
+      id: 202609181400
+      author: xiaozhi
+      changes:
+        - sqlFile:
+            encoding: utf8
+            path: classpath:db/changelog/202609181400.sql
+  - changeSet:
+      id: 202609191800
+      author: xiaozhi
+      changes:
+        - sqlFile:
+            encoding: utf8
+            path: classpath:db/changelog/202609191800.sql
+  - changeSet:
+      id: 202609191930
+      author: xiaozhi
+      changes:
+        - sqlFile:
+            encoding: utf8
+            path: classpath:db/changelog/202609191930.sql
diff --git a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java
index 8a3473e..955be53 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java
@@ -1079,7 +1079,7 @@
         AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class);
         CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class);
         AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
-        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null,
+        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, null,
                 null, null, contextProviderService, null, correctWordFileService, snapshotService);
         ReflectionTestUtils.setField(service, "baseDao", agentDao);
 
@@ -1113,7 +1113,7 @@
         AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class);
         CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class);
         AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
-        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null,
+        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, null,
                 null, null, contextProviderService, null, correctWordFileService, snapshotService);
         ReflectionTestUtils.setField(service, "baseDao", agentDao);
 
@@ -1149,7 +1149,7 @@
         AgentTemplateService templateService = mock(AgentTemplateService.class);
         ModelProviderService providerService = mock(ModelProviderService.class);
         AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
-        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null,
+        AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null, null,
                 pluginMappingService, null, templateService, providerService, null, null, null, snapshotService);
         ReflectionTestUtils.setField(service, "baseDao", agentDao);
 
diff --git a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java
index a3fa0ad..a9f3687 100644
--- a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java
+++ b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/device/controller/DeviceControllerTest.java
@@ -19,6 +19,8 @@
 import xiaozhi.modules.device.entity.DeviceEntity;
 import xiaozhi.modules.device.service.DeviceAddressBookService;
 import xiaozhi.modules.device.service.DeviceService;
+import xiaozhi.modules.parent.mqtt.ParentDevicePresence;
+import xiaozhi.modules.parent.service.ParentTaskService;
 import xiaozhi.modules.security.user.SecurityUser;
 import xiaozhi.modules.sys.service.SysParamsService;
 
@@ -59,7 +61,9 @@
                 deviceService,
                 mock(DeviceAddressBookService.class),
                 mock(RedisUtils.class),
-                mock(SysParamsService.class));
+                mock(SysParamsService.class),
+                mock(ParentDevicePresence.class),
+                mock(ParentTaskService.class));
     }
 
     private DeviceEntity ownedDevice() {
diff --git a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java
new file mode 100644
index 0000000..2fae13d
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/catalog/ParentSceneCatalogTest.java
@@ -0,0 +1,26 @@
+package xiaozhi.modules.parent.catalog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class ParentSceneCatalogTest {
+    @Test
+    void listsChatTranslationAndBilingual() {
+        ParentSceneCatalog.resetForTests();
+        List<Map<String, Object>> scenes = ParentSceneCatalog.scenes();
+        assertTrue(scenes.stream().anyMatch(row -> "chat".equals(row.get("id"))));
+        assertTrue(scenes.stream().anyMatch(row -> "translation".equals(row.get("id"))));
+        assertTrue(scenes.stream().anyMatch(row -> "bilingual".equals(row.get("id"))));
+        assertEquals("榛樿鑱婂ぉ", ParentSceneCatalog.sceneName("chat"));
+        Map<String, String> translation = ParentSceneCatalog.sceneToAppTask("translation");
+        assertEquals("english_translation", translation.get("appId"));
+        assertEquals("live_translate", translation.get("taskId"));
+        Map<String, String> chat = ParentSceneCatalog.sceneToAppTask("chat");
+        assertEquals("conversation", chat.get("taskId"));
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java
new file mode 100644
index 0000000..0507cd3
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-api/src/test/java/xiaozhi/modules/parent/mqtt/ParentDevicePresenceTest.java
@@ -0,0 +1,37 @@
+package xiaozhi.modules.parent.mqtt;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class ParentDevicePresenceTest {
+    @Test
+    void liveStateStaysOnlineWhenWifiApLookupFails() {
+        assertTrue(ParentDevicePresence.payloadMeansOnline(Map.of(
+                "online", true,
+                "net", "offline",
+                "mac", "14:c1:9f:49:43:a4")));
+    }
+
+    @Test
+    void lastWillMarksDeviceOffline() {
+        assertFalse(ParentDevicePresence.payloadMeansOnline(Map.of(
+                "online", false,
+                "net", "offline")));
+    }
+
+    @Test
+    void wifiHeartbeatStaysOnline() {
+        assertTrue(ParentDevicePresence.payloadMeansOnline(Map.of(
+                "online", true,
+                "net", "wifi")));
+    }
+
+    @Test
+    void emptyPayloadIsOffline() {
+        assertFalse(ParentDevicePresence.payloadMeansOnline(Map.of()));
+    }
+}
diff --git a/xiaozhi-esp32-server/main/manager-mobile/env/.env b/xiaozhi-esp32-server/main/manager-mobile/env/.env
index e0f4eea..91a7cc2 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/env/.env
+++ b/xiaozhi-esp32-server/main/manager-mobile/env/.env
@@ -5,7 +5,7 @@
 VITE_WX_APPID = 'wxa2abb91f64032a2b'
 
 # h5閮ㄧ讲缃戠珯鐨刡ase锛岄厤缃埌 manifest.config.ts 閲岀殑 h5.router.base
-VITE_APP_PUBLIC_BASE=/
+VITE_APP_PUBLIC_BASE='/mobile/'
 
 # 鐧诲綍椤甸潰
 VITE_LOGIN_URL = '/pages/login/index'
diff --git a/xiaozhi-esp32-server/main/manager-mobile/env/.env.development b/xiaozhi-esp32-server/main/manager-mobile/env/.env.development
index 04fa273..e663ad0 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/env/.env.development
+++ b/xiaozhi-esp32-server/main/manager-mobile/env/.env.development
@@ -4,3 +4,6 @@
 VITE_DELETE_CONSOLE = false
 # 鏄惁寮�鍚痵ourcemap
 VITE_SHOW_SOURCEMAP = true
+
+# 涓� manager-web(8001) 鍚屼竴濂楁湰鍦� manager-api
+VITE_SERVER_BASEURL = 'http://127.0.0.1:8002/xiaozhi'
diff --git a/xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts b/xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts
index d0429b8..e66963e 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/manifest.config.ts
@@ -30,7 +30,7 @@
   'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
   'h5': {
     router: {
-      // base: VITE_APP_PUBLIC_BASE,
+      base: VITE_APP_PUBLIC_BASE,
     },
   },
   /* 5+App鐗规湁鐩稿叧 */
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts b/xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts
index 440f507..9ba529f 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/api/agent/types.ts
@@ -11,9 +11,17 @@
   summaryMemory: string | null
   lastConnectedAt: string | null
   deviceCount: number
+  onlineDeviceCount?: number
+  devices?: AgentDevice[]
   tags: Record<string, string>[]
 }
 
+export interface AgentDevice {
+  macAddress: string
+  alias?: string
+  online?: boolean
+}
+
 // 鏅鸿兘浣撳垱寤烘暟鎹被鍨�
 export interface AgentCreateData {
   agentName: string
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts b/xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts
index 8b0de4e..cee4fb4 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/api/device/types.ts
@@ -13,6 +13,7 @@
   alias?: string
   agentId: string
   appVersion: string
+  online?: boolean
   sort: number
   updater?: string
   updateDate: string
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts
new file mode 100644
index 0000000..4c31f2c
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/index.ts
@@ -0,0 +1,2 @@
+export * from './parent'
+export * from './types'
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts
new file mode 100644
index 0000000..eeb10eb
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/parent.ts
@@ -0,0 +1,141 @@
+import type {
+  ParentHistoryDetail,
+  ParentHistoryList,
+  ParentLanguagePair,
+  ParentLesson,
+  IdiomCatalog,
+  ParentScene,
+  ParentDeviceStatus,
+  ParentTask,
+  ParentTaskList,
+  ParentTaskSave,
+} from './types'
+import { http } from '@/http/request/alova'
+
+const noCache = {
+  ignoreAuth: false,
+  toast: false,
+}
+
+export function getParentScenes() {
+  return http.Get<ParentScene[]>('/user/scenes', {
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentIdiomCatalog() {
+  return http.Get<IdiomCatalog>('/user/idiom-catalog', {
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentLessons(scene: string) {
+  return http.Get<ParentLesson[]>('/user/lessons', {
+    params: { scene },
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentLanguagePairs() {
+  return http.Get<ParentLanguagePair[]>('/user/language-pairs', {
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentDeviceStatus(macAddress: string, agentId: string) {
+  return http.Get<ParentDeviceStatus>('/device/presence', {
+    params: { macAddress, agentId },
+    meta: noCache,
+    cacheFor: { expire: 0 },
+    timeout: 10000,
+  })
+}
+
+export function getParentTasks(macAddress: string, agentId: string, runMode?: string) {
+  return http.Get<ParentTaskList>('/user/parent-task', {
+    params: {
+      macAddress,
+      agentId,
+      ...(runMode ? { runMode } : {}),
+    },
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentTask(id: string) {
+  return http.Get<ParentTask>(`/user/parent-task/${id}`, {
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+const mqttMeta = {
+  ignoreAuth: false,
+  toast: false,
+  isExposeError: true,
+}
+
+export function createParentTask(data: ParentTaskSave) {
+  return http.Post<ParentTask>('/user/parent-task', data, {
+    timeout: 20000,
+    meta: mqttMeta,
+  })
+}
+
+export function updateParentTask(id: string, data: ParentTaskSave) {
+  return http.Put<ParentTask>(`/user/parent-task/${id}`, data, {
+    timeout: 20000,
+    meta: mqttMeta,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function deleteParentTask(id: string) {
+  return http.Delete<boolean>(`/user/parent-task/${id}`, {
+    timeout: 20000,
+    meta: mqttMeta,
+  })
+}
+
+export function runParentTaskNow(id: string) {
+  return http.Post<ParentTask>(`/user/parent-task/${id}/run`, {}, {
+    timeout: 20000,
+    meta: mqttMeta,
+  })
+}
+
+export function stopParentTask(id: string) {
+  return http.Post<ParentTask>(`/user/parent-task/${id}/stop`, {}, {
+    timeout: 20000,
+    meta: mqttMeta,
+  })
+}
+
+export function getParentHistory(macAddress: string, agentId: string, limit = 20) {
+  return http.Get<ParentHistoryList>('/user/parent-history', {
+    params: { macAddress, agentId, limit },
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function getParentHistoryDetail(id: string, macAddress: string, agentId: string) {
+  return http.Get<ParentHistoryDetail>(`/user/parent-history/${id}`, {
+    params: { macAddress, agentId },
+    meta: noCache,
+    cacheFor: { expire: 0 },
+  })
+}
+
+export function parentTaskError(error: unknown, fallback: string) {
+  if (typeof error === 'string' && error.trim())
+    return error.trim()
+  const message = error instanceof Error ? error.message : ''
+  const cut = message.includes('锛�') ? message.split('锛�').slice(1).join('锛�').trim() : message.trim()
+  return cut || fallback
+}
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts
new file mode 100644
index 0000000..90c57d7
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/api/parent/types.ts
@@ -0,0 +1,177 @@
+export interface ParentScene {
+  id: string
+  name: string
+  hint?: string
+  needsLesson: boolean
+  needsRepeat?: boolean
+  needsEnPasses?: boolean
+  needsShadowingSettings?: boolean
+  needsLanguagePair: boolean
+  needsDuration: boolean
+  needsOnComplete: boolean
+  audioPackage?: boolean
+  kind?: string
+  appId: string
+  taskId: string
+}
+
+export interface ParentLesson {
+  lessonId: string
+  name: string
+  scene: string
+  hint?: string
+  levelId?: string
+  systemId?: string
+  sentenceCount?: number
+}
+
+export interface IdiomNamed {
+  id: string
+  name: string
+  nameEn?: string
+}
+
+export interface IdiomEnPass {
+  speed: number
+  repeat: number
+}
+
+export interface IdiomStory {
+  id: string
+  slug?: string
+  systemId: string
+  name: string
+  nameEn?: string
+  lessonId?: string
+  levelId?: string
+  sentenceCount?: number
+  enabled?: boolean
+}
+
+export interface IdiomCatalog {
+  version?: number
+  defaults?: {
+    cnRepeat?: number
+    cnSpeed?: number
+    enPasses?: IdiomEnPass[]
+    shadowingLanguage?: string
+    shadowingSpeed?: number
+    listenRatio?: number
+    sentenceCount?: number
+  }
+  levels: IdiomNamed[]
+  systems: IdiomNamed[]
+  stories: IdiomStory[]
+}
+
+export interface ParentLanguagePair {
+  id: string
+  name: string
+  ready: boolean
+}
+
+export interface ParentTaskParams {
+  lesson_id?: string
+  lesson_name?: string
+  lesson_name_en?: string
+  repeat?: number
+  cn_repeat?: number
+  cn_speed?: number
+  en_passes?: IdiomEnPass[]
+  language?: 'cn' | 'en' | string
+  speed?: number
+  sentence_count?: number
+  listen_ratio?: number
+  language_pair?: string
+  duration_minutes?: number
+  on_complete?: 'chat' | 'power_off'
+  announce?: boolean
+}
+
+export interface ParentSchedule {
+  type?: 'daily' | 'hourly' | 'interval'
+  time?: string
+  weekdays?: number[]
+  minute?: number
+  every_minutes?: number
+}
+
+export interface ParentTask {
+  id: string
+  macAddress: string
+  agentId: string
+  runMode: 'immediate' | 'scheduled'
+  scene: string
+  appId: string
+  taskId: string
+  sceneName: string
+  params: ParentTaskParams
+  schedule: ParentSchedule
+  enabled: boolean
+  status: string
+  createdAt?: string
+  updatedAt?: string
+}
+
+export interface ParentTaskList {
+  total: number
+  list: ParentTask[]
+}
+
+export interface ParentDeviceStatus {
+  macAddress: string
+  online: boolean
+}
+
+export interface ParentTaskSave {
+  macAddress: string
+  agentId: string
+  runMode: 'immediate' | 'scheduled'
+  scene: string
+  params?: ParentTaskParams
+  schedule?: ParentSchedule
+  enabled?: boolean
+}
+
+export type ParentHistoryKind = 'extensive' | 'shadowing' | 'chat' | string
+
+export interface ParentHistoryItem {
+  id: string
+  scene: string
+  sceneName: string
+  kind: ParentHistoryKind
+  appId: string
+  taskId: string
+  source: string
+  status: string
+  endReason: string
+  startedAt?: number
+  endedAt?: number
+  durationMs?: number
+  turnCount: number
+}
+
+export interface ParentHistoryList {
+  total: number
+  list: ParentHistoryItem[]
+}
+
+export interface ParentHistoryTurn {
+  index: number
+  expectedText: string
+  asrText: string
+  audioId: string | null
+}
+
+export interface ParentHistoryChat {
+  createdAt: string
+  chatType: 1 | 2 | 3
+  content: string
+  audioId: string | null
+  macAddress?: string
+}
+
+export interface ParentHistoryDetail extends ParentHistoryItem {
+  turns: ParentHistoryTurn[]
+  chats: ParentHistoryChat[]
+}
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts
index 3a95557..e01c2e3 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/de.ts
@@ -84,6 +84,8 @@
   'home.daysAgo': 'Tage her',
   'home.languageModel': 'LLM',
   'home.voiceModel': 'TTS',
+  'home.noBoundDevice': 'Kein Ger盲t gebunden',
+  'home.onlineCount': '{online}/{total} online',
 
   // Agentenseite
   'agent.pageTitle': 'Agent',
@@ -91,6 +93,7 @@
   'agent.deviceManagement': 'Ger盲teverwaltung',
   'agent.chatHistory': 'Chat-Verlauf',
   'agent.voiceprintManagement': 'Stimmabdruckverwaltung',
+  'agent.parentTasks': 'Lernaufgaben',
   'agent.editTitle': 'Agent bearbeiten',
   'agent.toolsTitle': 'Bearbeiten',
   'agent.voiceActivityDetection': 'Sprachaktivit盲tserkennung',
@@ -305,6 +308,11 @@
   'device.deviceType': 'Ger盲tetyp',
   'device.loading': 'L盲dt...',
   'device.neverConnected': 'Nie verbunden',
+  'device.checkingOnline': 'Ger盲testatus wird gepr眉ft鈥�',
+  'device.online': 'Ger盲t online',
+  'device.offline': 'Ger盲t offline',
+  'device.onlineHint': 'Sofort- und Planaufgaben k枚nnen gesendet werden.',
+  'device.offlineHint': 'Keine Aufgaben m枚glich. Chatverlauf bleibt sichtbar.',
   'device.justNow': 'Gerade eben',
   'device.minutesAgo': 'Vor {minutes} Minuten',
   'device.hoursAgo': 'Vor {hours} Stunden',
@@ -594,4 +602,119 @@
   'voiceprint.audioNotExist': 'Audio existiert nicht',
   'voiceprint.getAudioFailed': 'Audio konnte nicht abgerufen werden',
   'voiceprint.audioPlayFailed': 'Audio-Wiedergabe fehlgeschlagen',
+
+  'parentTask.pageTitle': 'Lernaufgaben',
+  'parentTask.createTitle': 'Lernaufgabe erstellen',
+  'parentTask.editTitle': 'Lernaufgabe bearbeiten',
+  'parentTask.device': 'Ger盲t',
+  'parentTask.selectDevice': 'Ger盲t w盲hlen',
+  'parentTask.bindDeviceFirst': 'Bitte zuerst ein Ger盲t binden',
+  'parentTask.runningSection': 'L盲uft jetzt',
+  'parentTask.scheduledSection': 'Geplante Aufgaben',
+  'parentTask.running': 'L盲uft',
+  'parentTask.emptySchedule': 'Noch keine Pl盲ne. Tippe +, um extensives H枚ren, Shadowing oder Chat zu erstellen.',
+  'parentTask.startNow': 'Jetzt starten',
+  'parentTask.saveSchedule': 'Als Plan speichern',
+  'parentTask.started': 'An das Ger盲t gesendet',
+  'parentTask.saved': 'Plan mit dem Ger盲t synchronisiert',
+  'parentTask.saveFailed': 'Speichern fehlgeschlagen',
+  'parentTask.deviceOffline': 'Ger盲t offline. Nichts gespeichert. Ger盲t einschalten und mit dem Netz verbinden, dann erneut versuchen.',
+  'parentTask.online': 'Ger盲t online',
+  'parentTask.offline': 'Ger盲t offline',
+  'parentTask.checkingOnline': 'Ger盲testatus wird gepr眉ft鈥�',
+  'parentTask.onlineHint': 'Sofort- und Planaufgaben k枚nnen gesendet werden.',
+  'parentTask.offlineHint': 'Keine Sofort- oder Planaufgaben m枚glich. Ger盲t einschalten und mit dem Netz verbinden.',
+  'parentTask.startFailed': 'Senden fehlgeschlagen. Ger盲t online?',
+  'parentTask.stop': 'Beenden',
+  'parentTask.stopTitle': 'Aktuelle Aufgabe beenden',
+  'parentTask.stopConfirm': 'Das Ger盲t kehrt sofort zum Chat zur眉ck.',
+  'parentTask.stopped': 'Stopp an das Ger盲t gesendet',
+  'parentTask.stopFailed': 'Beenden fehlgeschlagen. Ger盲t online?',
+  'parentTask.deleteTitle': 'Aufgabe l枚schen',
+  'parentTask.deleteConfirm': 'Dieser Plan wird auch vom Ger盲t entfernt.',
+  'parentTask.deleted': 'Gel枚scht',
+  'parentTask.deleteFailed': 'L枚schen fehlgeschlagen',
+  'parentTask.scene': 'Szene',
+  'parentTask.dispatchHint': 'Sofortstart wechselt den Modus sofort. Ein Zeitplan startet ihn zur eingestellten Zeit, ohne Sprachbefehl.',
+  'parentTask.lesson': 'Lektion',
+  'parentTask.pickScene': 'Szene w盲hlen',
+  'parentTask.pickLesson': 'Lektion w盲hlen',
+  'parentTask.repeat': 'Wiederholungen',
+  'parentTask.level': 'Stufe',
+  'parentTask.system': 'Kategorie',
+  'parentTask.story': 'Redewendung',
+  'parentTask.pickStory': 'Eine Geschichte w盲hlen',
+  'parentTask.noStories': 'In dieser Kategorie gibt es noch keine Geschichten.',
+  'parentTask.sentenceCount': 'Nachsprechen: {n} S盲tze',
+  'parentTask.enPassesTitle': 'Englisch h枚ren',
+  'parentTask.cnFixed': 'Zuerst Chinesisch 1脳 bei 1,0, danach Englisch wie unten.',
+  'parentTask.enPassIndex': 'Englisch-Durchgang {n}',
+  'parentTask.addEnPass': 'Englischen Durchgang hinzuf眉gen',
+  'parentTask.removePass': 'Entfernen',
+  'parentTask.shadowingSettings': 'Nachsprechen',
+  'parentTask.shadowingLang': 'Sprache',
+  'parentTask.shadowingLang.cn': 'Chinesisch',
+  'parentTask.shadowingLang.en': 'Englisch',
+  'parentTask.shadowingSpeed': 'Abspielgeschwindigkeit',
+  'parentTask.shadowingHint': 'H枚rfenster = tats盲chliche Spieldauer 脳 1,2. Aktuell {speed}脳.',
+  'parentTask.languagePair': 'Sprachpaar',
+  'parentTask.pickPair': 'Sprachpaar w盲hlen',
+  'parentTask.pairSoon': 'in Arbeit',
+  'parentTask.pairFallbackHint': 'Die vollst盲ndigen Satzpakete folgen noch. Das Ger盲t 眉bt zuerst Chinesisch-Englisch.',
+  'parentTask.duration': 'Dauer (Minuten, 0 = unbegrenzt)',
+  'parentTask.noDuration': 'Keine Zeitbegrenzung',
+  'parentTask.minutesValue': '{n} Min.',
+  'parentTask.onComplete': 'Danach',
+  'parentTask.completeChat': 'Zur眉ck zum Chat',
+  'parentTask.completePowerOff': 'Sanft ausschalten',
+  'parentTask.howToRun': 'Ausf眉hrung',
+  'parentTask.immediate': 'Sofort',
+  'parentTask.schedule': 'Zeitplan',
+  'parentTask.schedule.daily': 'T盲glich',
+  'parentTask.schedule.hourly': 'St眉ndlich',
+  'parentTask.schedule.interval': 'Intervall',
+  'parentTask.time': 'Uhrzeit',
+  'parentTask.pickTime': 'Uhrzeit w盲hlen',
+  'parentTask.timeHint': 'Beliebige Stunde und Minute',
+  'parentTask.repeatDays': 'Wiederholen',
+  'parentTask.everyDay': 'T盲glich',
+  'parentTask.weekdays': 'Wochentage',
+  'parentTask.hourlyMinute': 'Minute jeder Stunde',
+  'parentTask.hourlyOnTheHour': 'Jede volle Stunde',
+  'parentTask.hourlyAt': 'Jede Stunde um :{minute}',
+  'parentTask.everyMinutes': 'Alle {minutes} Minuten',
+  'parentTask.intervalMinutes': 'Intervall (Minuten)',
+  'parentTask.weekday.mon': 'Mo',
+  'parentTask.weekday.tue': 'Di',
+  'parentTask.weekday.wed': 'Mi',
+  'parentTask.weekday.thu': 'Do',
+  'parentTask.weekday.fri': 'Fr',
+  'parentTask.weekday.sat': 'Sa',
+  'parentTask.weekday.sun': 'So',
+
+  'parentHistory.pageTitle': 'Verlauf',
+  'parentHistory.detailTitle': 'Eintrag',
+  'parentHistory.entry': 'Verlauf',
+  'parentHistory.empty': 'Noch keine Eintr盲ge. Sie erscheinen, nachdem das Kind eine Aufgabe beendet.',
+  'parentHistory.loadFailed': 'Laden fehlgeschlagen',
+  'parentHistory.status.running': 'L盲uft',
+  'parentHistory.status.completed': 'Abgeschlossen',
+  'parentHistory.status.cancelled': 'Beendet',
+  'parentHistory.status.preempted': 'Unterbrochen',
+  'parentHistory.status.other': 'Unvollst盲ndig',
+  'parentHistory.source.immediate': 'Sofort gestartet',
+  'parentHistory.source.schedule': 'Zeitplan',
+  'parentHistory.source.voice': 'Sprachbefehl',
+  'parentHistory.durationSec': '{s}s',
+  'parentHistory.durationMin': '{m}m {s}s',
+  'parentHistory.turns': '{n} S盲tze',
+  'parentHistory.sentence': 'Satz {n}',
+  'parentHistory.expected': 'Original',
+  'parentHistory.asr': 'Nachsprechen',
+  'parentHistory.noExpected': 'Kein Originaltext',
+  'parentHistory.noAsr': 'Kein Nachsprechen erkannt',
+  'parentHistory.noAudio': 'Keine abspielbare Aufnahme',
+  'parentHistory.emptyTurns': 'Keine S盲tze f眉r diese Nachsprechsitzung.',
+  'parentHistory.extensiveHint': 'Umfangreiches H枚ren hat keine Satzaufnahmen. Hier siehst du Abschluss und Dauer.',
+  'parentHistory.noChat': 'In diesem Zeitraum wurde kein Chat gespeichert.',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts
index 14fbe86..6d86583 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/en.ts
@@ -84,6 +84,8 @@
   'home.daysAgo': 'days ago',
   'home.languageModel': 'LLM',
   'home.voiceModel': 'TTS',
+  'home.noBoundDevice': 'No device bound',
+  'home.onlineCount': '{online}/{total} online',
 
   // Agent page
   'agent.pageTitle': 'Agent',
@@ -91,6 +93,7 @@
   'agent.deviceManagement': 'Device Management',
   'agent.chatHistory': 'Chat History',
   'agent.voiceprintManagement': 'Voiceprint Management',
+  'agent.parentTasks': 'Learning Tasks',
   'agent.editTitle': 'Edit Agent',
   'agent.toolsTitle': 'Edit Features',
   'agent.voiceActivityDetection': 'Voice Activity Detection',
@@ -305,6 +308,11 @@
   'device.deviceType': 'Device Type',
   'device.loading': 'Loading...',
   'device.neverConnected': 'Never connected',
+  'device.checkingOnline': 'Checking device status鈥�',
+  'device.online': 'Device online',
+  'device.offline': 'Device offline',
+  'device.onlineHint': 'You can send immediate and scheduled tasks.',
+  'device.offlineHint': 'Tasks cannot be sent. Chat history and other views still work.',
   'device.justNow': 'Just now',
   'device.minutesAgo': '{minutes} minutes ago',
   'device.hoursAgo': '{hours} hours ago',
@@ -594,4 +602,119 @@
   'voiceprint.audioNotExist': 'Audio does not exist',
   'voiceprint.getAudioFailed': 'Failed to get audio',
   'voiceprint.audioPlayFailed': 'Audio playback failed',
+
+  'parentTask.pageTitle': 'Learning Tasks',
+  'parentTask.createTitle': 'Create Learning Task',
+  'parentTask.editTitle': 'Edit Learning Task',
+  'parentTask.device': 'Device',
+  'parentTask.selectDevice': 'Select a device',
+  'parentTask.bindDeviceFirst': 'Bind a device first',
+  'parentTask.runningSection': 'Running now',
+  'parentTask.scheduledSection': 'Scheduled tasks',
+  'parentTask.running': 'Running',
+  'parentTask.emptySchedule': 'No schedules yet. Tap + to create extensive listening, shadowing, or chat.',
+  'parentTask.startNow': 'Start now',
+  'parentTask.saveSchedule': 'Save schedule',
+  'parentTask.started': 'Sent to the device',
+  'parentTask.saved': 'Schedule synced to the device',
+  'parentTask.saveFailed': 'Save failed',
+  'parentTask.deviceOffline': 'Device is offline, so nothing was saved. Turn it on and connect it to the network, then try again.',
+  'parentTask.online': 'Device online',
+  'parentTask.offline': 'Device offline',
+  'parentTask.checkingOnline': 'Checking device status鈥�',
+  'parentTask.onlineHint': 'You can send immediate and scheduled tasks.',
+  'parentTask.offlineHint': 'Cannot send immediate or scheduled tasks. Turn the device on and connect it to the network.',
+  'parentTask.startFailed': 'Dispatch failed. Check that the device is online.',
+  'parentTask.stop': 'Stop',
+  'parentTask.stopTitle': 'Stop current task',
+  'parentTask.stopConfirm': 'The device will return to chat immediately.',
+  'parentTask.stopped': 'Sent stop to the device',
+  'parentTask.stopFailed': 'Stop failed. Check that the device is online.',
+  'parentTask.deleteTitle': 'Delete task',
+  'parentTask.deleteConfirm': 'This schedule will also be removed from the device.',
+  'parentTask.deleted': 'Deleted',
+  'parentTask.deleteFailed': 'Delete failed',
+  'parentTask.scene': 'Scene',
+  'parentTask.dispatchHint': 'Start now switches the device immediately. A schedule starts that mode at the set time, no voice command needed.',
+  'parentTask.lesson': 'Lesson',
+  'parentTask.pickScene': 'Choose a scene',
+  'parentTask.pickLesson': 'Choose a lesson',
+  'parentTask.repeat': 'Repeats',
+  'parentTask.level': 'Level',
+  'parentTask.system': 'Category',
+  'parentTask.story': 'Idiom story',
+  'parentTask.pickStory': 'Choose one story',
+  'parentTask.noStories': 'No stories in this category yet. Add them in the lesson catalog.',
+  'parentTask.sentenceCount': 'Shadowing has {n} sentences',
+  'parentTask.enPassesTitle': 'How to listen in English',
+  'parentTask.cnFixed': 'Chinese plays once at 1.0脳, then English uses the passes below.',
+  'parentTask.enPassIndex': 'English pass {n}',
+  'parentTask.addEnPass': 'Add an English pass',
+  'parentTask.removePass': 'Remove',
+  'parentTask.shadowingSettings': 'Shadowing',
+  'parentTask.shadowingLang': 'Language',
+  'parentTask.shadowingLang.cn': 'Chinese',
+  'parentTask.shadowingLang.en': 'English',
+  'parentTask.shadowingSpeed': 'Playback speed',
+  'parentTask.shadowingHint': 'Listen window = actual play time 脳 1.2. Current speed {speed}脳; slower playback makes a longer window.',
+  'parentTask.languagePair': 'Language pair',
+  'parentTask.pickPair': 'Choose a language pair',
+  'parentTask.pairSoon': 'coming soon',
+  'parentTask.pairFallbackHint': 'Full phrase packs are still in progress. The device will practice in Chinese-English first.',
+  'parentTask.duration': 'Duration (minutes, 0 = unlimited)',
+  'parentTask.noDuration': 'No time limit',
+  'parentTask.minutesValue': '{n} min',
+  'parentTask.onComplete': 'When finished',
+  'parentTask.completeChat': 'Return to chat',
+  'parentTask.completePowerOff': 'Soft power off',
+  'parentTask.howToRun': 'How to run',
+  'parentTask.immediate': 'Run now',
+  'parentTask.schedule': 'Schedule',
+  'parentTask.schedule.daily': 'Daily',
+  'parentTask.schedule.hourly': 'Hourly',
+  'parentTask.schedule.interval': 'Interval',
+  'parentTask.time': 'Time',
+  'parentTask.pickTime': 'Pick a time',
+  'parentTask.timeHint': 'Any hour and minute of the day',
+  'parentTask.repeatDays': 'Repeat',
+  'parentTask.everyDay': 'Every day',
+  'parentTask.weekdays': 'Weekdays',
+  'parentTask.hourlyMinute': 'Minute of each hour',
+  'parentTask.hourlyOnTheHour': 'Every hour on the hour',
+  'parentTask.hourlyAt': 'Every hour at :{minute}',
+  'parentTask.everyMinutes': 'Every {minutes} minutes',
+  'parentTask.intervalMinutes': 'Interval (minutes)',
+  'parentTask.weekday.mon': 'Mon',
+  'parentTask.weekday.tue': 'Tue',
+  'parentTask.weekday.wed': 'Wed',
+  'parentTask.weekday.thu': 'Thu',
+  'parentTask.weekday.fri': 'Fri',
+  'parentTask.weekday.sat': 'Sat',
+  'parentTask.weekday.sun': 'Sun',
+
+  'parentHistory.pageTitle': 'History',
+  'parentHistory.detailTitle': 'Record',
+  'parentHistory.entry': 'History',
+  'parentHistory.empty': 'No records yet. They appear after the child finishes a task.',
+  'parentHistory.loadFailed': 'Failed to load',
+  'parentHistory.status.running': 'In progress',
+  'parentHistory.status.completed': 'Completed',
+  'parentHistory.status.cancelled': 'Stopped',
+  'parentHistory.status.preempted': 'Interrupted',
+  'parentHistory.status.other': 'Incomplete',
+  'parentHistory.source.immediate': 'Started now',
+  'parentHistory.source.schedule': 'Scheduled',
+  'parentHistory.source.voice': 'Voice command',
+  'parentHistory.durationSec': '{s}s',
+  'parentHistory.durationMin': '{m}m {s}s',
+  'parentHistory.turns': '{n} lines',
+  'parentHistory.sentence': 'Line {n}',
+  'parentHistory.expected': 'Original',
+  'parentHistory.asr': 'Follow-read',
+  'parentHistory.noExpected': 'No original text',
+  'parentHistory.noAsr': 'No follow-read recognized',
+  'parentHistory.noAudio': 'No playable recording',
+  'parentHistory.emptyTurns': 'No sentence records for this shadowing session.',
+  'parentHistory.extensiveHint': 'Extensive listening has no per-sentence recordings. This page shows completion and duration.',
+  'parentHistory.noChat': 'No chat was saved for this window.',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts
index 928ffc6..222eac7 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/pt_BR.ts
@@ -84,6 +84,8 @@
   'home.daysAgo': 'dias atr谩s',
   'home.languageModel': 'LLM',
   'home.voiceModel': 'TTS',
+  'home.noBoundDevice': 'Nenhum dispositivo vinculado',
+  'home.onlineCount': '{online}/{total} online',
 
   // Agent page
   'agent.pageTitle': 'Agente',
@@ -91,6 +93,7 @@
   'agent.deviceManagement': 'Gerenciamento de Dispositivos',
   'agent.chatHistory': 'Hist贸rico de Conversas',
   'agent.voiceprintManagement': 'Gerenciamento de Impress茫o Vocal',
+  'agent.parentTasks': 'Tarefas de estudo',
   'agent.editTitle': 'Editar Agente',
   'agent.toolsTitle': 'Editar Funcionalidades',
   'agent.voiceActivityDetection': 'Detec莽茫o de Atividade de Voz',
@@ -305,6 +308,11 @@
   'device.deviceType': 'Tipo de Dispositivo',
   'device.loading': 'Carregando...',
   'device.neverConnected': 'Nunca conectado',
+  'device.checkingOnline': 'Verificando o status do dispositivo鈥�',
+  'device.online': 'Dispositivo online',
+  'device.offline': 'Dispositivo offline',
+  'device.onlineHint': '脡 poss铆vel enviar tarefas imediatas e agendadas.',
+  'device.offlineHint': 'N茫o 茅 poss铆vel enviar tarefas. O hist贸rico de chat continua dispon铆vel.',
   'device.justNow': 'Agora mesmo',
   'device.minutesAgo': '{minutes} minutos atr谩s',
   'device.hoursAgo': '{hours} horas atr谩s',
@@ -594,4 +602,119 @@
   'voiceprint.audioNotExist': 'O 谩udio n茫o existe',
   'voiceprint.getAudioFailed': 'Falha ao obter 谩udio',
   'voiceprint.audioPlayFailed': 'Falha na reprodu莽茫o de 谩udio',
+
+  'parentTask.pageTitle': 'Tarefas de estudo',
+  'parentTask.createTitle': 'Criar tarefa',
+  'parentTask.editTitle': 'Editar tarefa',
+  'parentTask.device': 'Dispositivo',
+  'parentTask.selectDevice': 'Selecione o dispositivo',
+  'parentTask.bindDeviceFirst': 'Vincule um dispositivo primeiro',
+  'parentTask.runningSection': 'Em execu莽茫o',
+  'parentTask.scheduledSection': 'Tarefas agendadas',
+  'parentTask.running': 'Em execu莽茫o',
+  'parentTask.emptySchedule': 'Ainda sem agenda. Toque + para criar escuta ampla, shadowing ou conversa.',
+  'parentTask.startNow': 'Come莽ar agora',
+  'parentTask.saveSchedule': 'Salvar agenda',
+  'parentTask.started': 'Enviado ao dispositivo',
+  'parentTask.saved': 'Agenda sincronizada com o dispositivo',
+  'parentTask.saveFailed': 'Falha ao salvar',
+  'parentTask.deviceOffline': 'Dispositivo offline. Nada foi salvo. Ligue o aparelho, conecte 脿 rede e tente de novo.',
+  'parentTask.online': 'Dispositivo online',
+  'parentTask.offline': 'Dispositivo offline',
+  'parentTask.checkingOnline': 'Verificando o status do dispositivo鈥�',
+  'parentTask.onlineHint': '脡 poss铆vel enviar tarefas imediatas e agendadas.',
+  'parentTask.offlineHint': 'N茫o 茅 poss铆vel enviar tarefas. Ligue o aparelho e conecte 脿 rede.',
+  'parentTask.startFailed': 'Falha no envio. Verifique se o dispositivo est谩 online.',
+  'parentTask.stop': 'Encerrar',
+  'parentTask.stopTitle': 'Encerrar tarefa atual',
+  'parentTask.stopConfirm': 'O dispositivo voltar谩 imediatamente para o chat.',
+  'parentTask.stopped': 'Parada enviada ao dispositivo',
+  'parentTask.stopFailed': 'Falha ao encerrar. Verifique se o dispositivo est谩 online.',
+  'parentTask.deleteTitle': 'Excluir tarefa',
+  'parentTask.deleteConfirm': 'Esta agenda tamb茅m ser谩 removida do dispositivo.',
+  'parentTask.deleted': 'Exclu铆do',
+  'parentTask.deleteFailed': 'Falha ao excluir',
+  'parentTask.scene': 'Cena',
+  'parentTask.dispatchHint': 'Iniciar agora troca o modo na hora. O agendamento troca no hor谩rio, sem comando de voz.',
+  'parentTask.lesson': 'Li莽茫o',
+  'parentTask.pickScene': 'Escolha a cena',
+  'parentTask.pickLesson': 'Escolha a li莽茫o',
+  'parentTask.repeat': 'Repeti莽玫es',
+  'parentTask.level': 'N铆vel',
+  'parentTask.system': 'Categoria',
+  'parentTask.story': 'Hist贸ria',
+  'parentTask.pickStory': 'Escolha uma hist贸ria',
+  'parentTask.noStories': 'Ainda n茫o h谩 hist贸rias nesta categoria.',
+  'parentTask.sentenceCount': 'Shadowing tem {n} frases',
+  'parentTask.enPassesTitle': 'Como ouvir em ingl锚s',
+  'parentTask.cnFixed': 'Chin锚s 1 vez em 1.0脳, depois ingl锚s conforme abaixo.',
+  'parentTask.enPassIndex': 'Passagem em ingl锚s {n}',
+  'parentTask.addEnPass': 'Adicionar passagem em ingl锚s',
+  'parentTask.removePass': 'Remover',
+  'parentTask.shadowingSettings': 'Shadowing',
+  'parentTask.shadowingLang': 'Idioma',
+  'parentTask.shadowingLang.cn': 'Chin锚s',
+  'parentTask.shadowingLang.en': 'Ingl锚s',
+  'parentTask.shadowingSpeed': 'Velocidade',
+  'parentTask.shadowingHint': 'Janela de escuta = dura莽茫o real 脳 1.2. Velocidade atual {speed}脳.',
+  'parentTask.languagePair': 'Par de idiomas',
+  'parentTask.pickPair': 'Escolha o par de idiomas',
+  'parentTask.pairSoon': 'em breve',
+  'parentTask.pairFallbackHint': 'Os pacotes de frases ainda est茫o em andamento. O dispositivo praticar谩 chin锚s-ingl锚s primeiro.',
+  'parentTask.duration': 'Dura莽茫o (minutos, 0 = ilimitado)',
+  'parentTask.noDuration': 'Sem limite',
+  'parentTask.minutesValue': '{n} min',
+  'parentTask.onComplete': 'Ao terminar',
+  'parentTask.completeChat': 'Voltar ao chat',
+  'parentTask.completePowerOff': 'Desligar suavemente',
+  'parentTask.howToRun': 'Como executar',
+  'parentTask.immediate': 'Executar agora',
+  'parentTask.schedule': 'Agenda',
+  'parentTask.schedule.daily': 'Di谩rio',
+  'parentTask.schedule.hourly': 'Por hora',
+  'parentTask.schedule.interval': 'Intervalo',
+  'parentTask.time': 'Hor谩rio',
+  'parentTask.pickTime': 'Escolher hor谩rio',
+  'parentTask.timeHint': 'Qualquer hora e minuto do dia',
+  'parentTask.repeatDays': 'Repetir',
+  'parentTask.everyDay': 'Todos os dias',
+  'parentTask.weekdays': 'Dias 煤teis',
+  'parentTask.hourlyMinute': 'Minuto de cada hora',
+  'parentTask.hourlyOnTheHour': 'A cada hora cheia',
+  'parentTask.hourlyAt': 'A cada hora 脿s :{minute}',
+  'parentTask.everyMinutes': 'A cada {minutes} minutos',
+  'parentTask.intervalMinutes': 'Intervalo (minutos)',
+  'parentTask.weekday.mon': 'Seg',
+  'parentTask.weekday.tue': 'Ter',
+  'parentTask.weekday.wed': 'Qua',
+  'parentTask.weekday.thu': 'Qui',
+  'parentTask.weekday.fri': 'Sex',
+  'parentTask.weekday.sat': 'S谩b',
+  'parentTask.weekday.sun': 'Dom',
+
+  'parentHistory.pageTitle': 'Hist贸rico',
+  'parentHistory.detailTitle': 'Detalhe',
+  'parentHistory.entry': 'Hist贸rico',
+  'parentHistory.empty': 'Ainda n茫o h谩 registros. Eles aparecem depois que a crian莽a termina uma tarefa.',
+  'parentHistory.loadFailed': 'Falha ao carregar',
+  'parentHistory.status.running': 'Em andamento',
+  'parentHistory.status.completed': 'Conclu铆do',
+  'parentHistory.status.cancelled': 'Encerrado',
+  'parentHistory.status.preempted': 'Interrompido',
+  'parentHistory.status.other': 'Incompleto',
+  'parentHistory.source.immediate': 'In铆cio imediato',
+  'parentHistory.source.schedule': 'Agenda',
+  'parentHistory.source.voice': 'Comando de voz',
+  'parentHistory.durationSec': '{s}s',
+  'parentHistory.durationMin': '{m}m {s}s',
+  'parentHistory.turns': '{n} frases',
+  'parentHistory.sentence': 'Frase {n}',
+  'parentHistory.expected': 'Original',
+  'parentHistory.asr': 'Repeti莽茫o',
+  'parentHistory.noExpected': 'Sem texto original',
+  'parentHistory.noAsr': 'Nenhuma repeti莽茫o reconhecida',
+  'parentHistory.noAudio': 'Sem grava莽茫o reproduz铆vel',
+  'parentHistory.emptyTurns': 'N茫o h谩 frases nesta sess茫o de shadowing.',
+  'parentHistory.extensiveHint': 'A escuta extensiva n茫o tem grava莽玫es por frase. Aqui voc锚 v锚 conclus茫o e dura莽茫o.',
+  'parentHistory.noChat': 'N茫o houve conversa salva neste per铆odo.',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts
index 5c89966..96d5aee 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/vi.ts
@@ -84,6 +84,8 @@
   'home.daysAgo': 'ng脿y tr瓢峄沜',
   'home.languageModel': 'LLM',
   'home.voiceModel': 'TTS',
+  'home.noBoundDevice': 'Ch瓢a g岷痭 thi岷縯 b峄�',
+  'home.onlineCount': '{online}/{total} online',
 
   // Trang 膽岷 l媒
   'agent.pageTitle': '膼岷 l媒',
@@ -91,6 +93,7 @@
   'agent.deviceManagement': 'Qu岷 l媒 thi岷縯 b峄�',
   'agent.chatHistory': 'L峄媍h s峄� tr貌 chuy峄噉',
   'agent.voiceprintManagement': 'Qu岷 l媒 d岷 gi峄峮g n贸i',
+  'agent.parentTasks': 'B脿i h峄峜',
   'agent.editTitle': 'Ch峄塶h s峄璦 膽岷 l媒',
   'agent.toolsTitle': 'Ch峄塶h s峄璦 t铆nh n膬ng',
   'agent.voiceActivityDetection': 'Ph谩t hi峄噉 ho岷 膽峄檔g gi峄峮g n贸i',
@@ -305,6 +308,11 @@
   'device.deviceType': 'Lo岷 thi岷縯 b峄�',
   'device.loading': '膼ang t岷...',
   'device.neverConnected': 'Ch瓢a k岷縯 n峄慽',
+  'device.checkingOnline': '膼ang ki峄僲 tra tr岷g th谩i thi岷縯 b峄嬧��',
+  'device.online': 'Thi岷縯 b峄� online',
+  'device.offline': 'Thi岷縯 b峄� offline',
+  'device.onlineHint': 'C贸 th峄� g峄璱 nhi峄噈 v峄� t峄ヽ th矛 v脿 theo l峄媍h.',
+  'device.offlineHint': 'Kh么ng g峄璱 膽瓢峄 nhi峄噈 v峄�. V岷玭 xem 膽瓢峄 l峄媍h s峄� tr貌 chuy峄噉.',
   'device.justNow': 'V峄玜 xong',
   'device.minutesAgo': '{minutes} ph煤t tr瓢峄沜',
   'device.hoursAgo': '{hours} gi峄� tr瓢峄沜',
@@ -594,4 +602,119 @@
   'voiceprint.audioNotExist': '脗m thanh kh么ng t峄搉 t岷',
   'voiceprint.getAudioFailed': 'Kh么ng th峄� l岷 芒m thanh',
   'voiceprint.audioPlayFailed': 'Ph谩t 芒m thanh th岷 b岷',
+
+  'parentTask.pageTitle': 'B脿i h峄峜',
+  'parentTask.createTitle': 'T岷 b脿i h峄峜',
+  'parentTask.editTitle': 'S峄璦 b脿i h峄峜',
+  'parentTask.device': 'Thi岷縯 b峄�',
+  'parentTask.selectDevice': 'Ch峄峮 thi岷縯 b峄�',
+  'parentTask.bindDeviceFirst': 'H茫y g岷痭 thi岷縯 b峄� tr瓢峄沜',
+  'parentTask.runningSection': '膼ang ch岷',
+  'parentTask.scheduledSection': 'L峄媍h tr矛nh',
+  'parentTask.running': '膼ang ch岷',
+  'parentTask.emptySchedule': 'Ch瓢a c贸 l峄媍h. B岷 + 膽峄� t岷 nghe r峄檔g, 膽峄峜 theo ho岷穋 tr貌 chuy峄噉.',
+  'parentTask.startNow': 'B岷痶 膽岷 ngay',
+  'parentTask.saveSchedule': 'L瓢u l峄媍h',
+  'parentTask.started': '膼茫 g峄璱 t峄沬 thi岷縯 b峄�',
+  'parentTask.saved': '膼茫 膽峄搉g b峄� l峄媍h t峄沬 thi岷縯 b峄�',
+  'parentTask.saveFailed': 'L瓢u th岷 b岷',
+  'parentTask.deviceOffline': 'Thi岷縯 b峄� offline, ch瓢a l瓢u. B岷璽 m谩y v脿 k岷縯 n峄慽 m岷g r峄搃 th峄� l岷.',
+  'parentTask.online': 'Thi岷縯 b峄� online',
+  'parentTask.offline': 'Thi岷縯 b峄� offline',
+  'parentTask.checkingOnline': '膼ang ki峄僲 tra tr岷g th谩i thi岷縯 b峄嬧��',
+  'parentTask.onlineHint': 'C贸 th峄� g峄璱 nhi峄噈 v峄� t峄ヽ th矛 v脿 theo l峄媍h.',
+  'parentTask.offlineHint': 'Kh么ng g峄璱 膽瓢峄 nhi峄噈 v峄�. B岷璽 m谩y v脿 k岷縯 n峄慽 m岷g.',
+  'parentTask.startFailed': 'G峄璱 th岷 b岷, ki峄僲 tra thi岷縯 b峄� online',
+  'parentTask.stop': 'D峄玭g',
+  'parentTask.stopTitle': 'D峄玭g nhi峄噈 v峄� hi峄噉 t岷',
+  'parentTask.stopConfirm': 'Thi岷縯 b峄� s岷� quay l岷 tr貌 chuy峄噉 ngay.',
+  'parentTask.stopped': '膼茫 g峄璱 l峄噉h d峄玭g t峄沬 thi岷縯 b峄�',
+  'parentTask.stopFailed': 'D峄玭g th岷 b岷, ki峄僲 tra thi岷縯 b峄� online',
+  'parentTask.deleteTitle': 'X贸a nhi峄噈 v峄�',
+  'parentTask.deleteConfirm': 'L峄媍h tr锚n thi岷縯 b峄� c农ng s岷� b峄� x贸a.',
+  'parentTask.deleted': '膼茫 x贸a',
+  'parentTask.deleteFailed': 'X贸a th岷 b岷',
+  'parentTask.scene': 'C岷h',
+  'parentTask.dispatchHint': 'B岷痶 膽岷 ngay s岷� 膽峄昳 ch岷� 膽峄� ngay. L峄媍h s岷� 膽峄昳 膽煤ng gi峄�, kh么ng c岷 l峄噉h tho岷.',
+  'parentTask.lesson': 'B脿i',
+  'parentTask.pickScene': 'Ch峄峮 c岷h',
+  'parentTask.pickLesson': 'Ch峄峮 b脿i',
+  'parentTask.repeat': 'S峄� l岷',
+  'parentTask.level': 'C岷',
+  'parentTask.system': 'Nh贸m',
+  'parentTask.story': 'Th脿nh ng峄�',
+  'parentTask.pickStory': 'Ch峄峮 m峄檛 th脿nh ng峄�',
+  'parentTask.noStories': 'Nh贸m n脿y ch瓢a c贸 b脿i. H茫y th锚m trong danh m峄.',
+  'parentTask.sentenceCount': 'B脿i shadowing c贸 {n} c芒u',
+  'parentTask.enPassesTitle': 'Nghe ti岷縩g Anh',
+  'parentTask.cnFixed': 'Ti岷縩g Trung 1 l岷, 1.0脳, sau 膽贸 ti岷縩g Anh theo b锚n d瓢峄沬.',
+  'parentTask.enPassIndex': 'L瓢峄 ti岷縩g Anh {n}',
+  'parentTask.addEnPass': 'Th锚m l瓢峄 ti岷縩g Anh',
+  'parentTask.removePass': 'X贸a',
+  'parentTask.shadowingSettings': 'Shadowing',
+  'parentTask.shadowingLang': 'Ng么n ng峄�',
+  'parentTask.shadowingLang.cn': 'Ti岷縩g Trung',
+  'parentTask.shadowingLang.en': 'Ti岷縩g Anh',
+  'parentTask.shadowingSpeed': 'T峄慶 膽峄� ph谩t',
+  'parentTask.shadowingHint': 'C峄璦 s峄� ghi = th峄漣 l瓢峄g ph谩t th峄眂 t岷� 脳 1.2. Hi峄噉 {speed}脳.',
+  'parentTask.languagePair': 'C岷穚 ng么n ng峄�',
+  'parentTask.pickPair': 'Ch峄峮 c岷穚 ng么n ng峄�',
+  'parentTask.pairSoon': '膽ang ho脿n thi峄噉',
+  'parentTask.pairFallbackHint': 'G贸i c芒u 膽岷 膽峄� v岷玭 膽ang l脿m. Thi岷縯 b峄� s岷� luy峄噉 Trung-Anh tr瓢峄沜.',
+  'parentTask.duration': 'Th峄漣 l瓢峄g (ph煤t, 0 = kh么ng gi峄沬 h岷)',
+  'parentTask.noDuration': 'Kh么ng gi峄沬 h岷',
+  'parentTask.minutesValue': '{n} ph煤t',
+  'parentTask.onComplete': 'Khi xong',
+  'parentTask.completeChat': 'V峄� tr貌 chuy峄噉',
+  'parentTask.completePowerOff': 'T岷痶 m峄乵',
+  'parentTask.howToRun': 'C谩ch ch岷',
+  'parentTask.immediate': 'Ch岷 ngay',
+  'parentTask.schedule': 'L峄媍h',
+  'parentTask.schedule.daily': 'H脿ng ng脿y',
+  'parentTask.schedule.hourly': 'H脿ng gi峄�',
+  'parentTask.schedule.interval': 'Kho岷g',
+  'parentTask.time': 'Gi峄�',
+  'parentTask.pickTime': 'Ch峄峮 gi峄�',
+  'parentTask.timeHint': 'C贸 th峄� ch峄峮 b岷 k峄� gi峄�:ph煤t trong ng脿y',
+  'parentTask.repeatDays': 'L岷穚',
+  'parentTask.everyDay': 'M峄梚 ng脿y',
+  'parentTask.weekdays': 'Ng脿y l脿m vi峄嘽',
+  'parentTask.hourlyMinute': 'Ph煤t m峄梚 gi峄�',
+  'parentTask.hourlyOnTheHour': 'M峄梚 gi峄� 膽煤ng 膽i峄僲',
+  'parentTask.hourlyAt': 'M峄梚 gi峄� :{minute}',
+  'parentTask.everyMinutes': 'M峄梚 {minutes} ph煤t',
+  'parentTask.intervalMinutes': 'Kho岷g (ph煤t)',
+  'parentTask.weekday.mon': 'T2',
+  'parentTask.weekday.tue': 'T3',
+  'parentTask.weekday.wed': 'T4',
+  'parentTask.weekday.thu': 'T5',
+  'parentTask.weekday.fri': 'T6',
+  'parentTask.weekday.sat': 'T7',
+  'parentTask.weekday.sun': 'CN',
+
+  'parentHistory.pageTitle': 'L峄媍h s峄�',
+  'parentHistory.detailTitle': 'Chi ti岷縯',
+  'parentHistory.entry': 'L峄媍h s峄�',
+  'parentHistory.empty': 'Ch瓢a c贸 b岷 ghi. Ch煤ng s岷� xu岷 hi峄噉 sau khi b茅 ho脿n th脿nh nhi峄噈 v峄�.',
+  'parentHistory.loadFailed': 'T岷 th岷 b岷',
+  'parentHistory.status.running': '膼ang ch岷',
+  'parentHistory.status.completed': 'Ho脿n th脿nh',
+  'parentHistory.status.cancelled': '膼茫 tho谩t',
+  'parentHistory.status.preempted': 'B峄� ng岷痶',
+  'parentHistory.status.other': 'Ch瓢a xong',
+  'parentHistory.source.immediate': 'B岷痶 膽岷 ngay',
+  'parentHistory.source.schedule': 'L峄媍h',
+  'parentHistory.source.voice': 'L峄噉h tho岷',
+  'parentHistory.durationSec': '{s} gi芒y',
+  'parentHistory.durationMin': '{m} ph煤t {s} gi芒y',
+  'parentHistory.turns': '{n} c芒u',
+  'parentHistory.sentence': 'C芒u {n}',
+  'parentHistory.expected': 'Nguy锚n v膬n',
+  'parentHistory.asr': '膼峄峜 theo',
+  'parentHistory.noExpected': 'Kh么ng c贸 nguy锚n v膬n',
+  'parentHistory.noAsr': 'Kh么ng nh岷璶 膽瓢峄 ph岷 膽峄峜 theo',
+  'parentHistory.noAudio': 'Kh么ng c贸 b岷 ghi ph谩t 膽瓢峄',
+  'parentHistory.emptyTurns': 'L岷 膽峄峜 theo n脿y ch瓢a c贸 c芒u.',
+  'parentHistory.extensiveHint': 'Nghe r峄檔g kh么ng c贸 b岷 ghi t峄玭g c芒u. Trang n脿y ch峄� xem ho脿n th脿nh v脿 th峄漣 l瓢峄g.',
+  'parentHistory.noChat': 'Kho岷g th峄漣 gian n脿y kh么ng c贸 h峄檌 tho岷.',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts
index 824bf4c..c2ea2a2 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_CN.ts
@@ -84,6 +84,8 @@
   'home.daysAgo': '澶╁墠',
   'home.languageModel': '璇█妯″瀷',
   'home.voiceModel': '闊宠壊妯″瀷',
+  'home.noBoundDevice': '鏆傛棤缁戝畾璁惧',
+  'home.onlineCount': '{online}/{total} 鍙板湪绾�',
 
   // Agent椤甸潰
   'agent.pageTitle': '鏅鸿兘浣�',
@@ -91,6 +93,7 @@
   'agent.deviceManagement': '璁惧绠$悊',
   'agent.chatHistory': '鑱婂ぉ璁板綍',
   'agent.voiceprintManagement': '澹扮汗绠$悊',
+  'agent.parentTasks': '瀛︿範浠诲姟',
   'agent.editTitle': '缂栬緫鏅鸿兘浣�',
   'agent.toolsTitle': '缂栬緫鍔熻兘',
   'agent.voiceActivityDetection': '璇煶娲诲姩妫�娴�',
@@ -305,6 +308,11 @@
   'device.deviceType': '璁惧绫诲瀷',
   'device.loading': '鍔犺浇涓�...',
   'device.neverConnected': '浠庢湭杩炴帴',
+  'device.checkingOnline': '姝e湪妫�娴嬭澶囩姸鎬佲��',
+  'device.online': '璁惧鍦ㄧ嚎',
+  'device.offline': '璁惧涓嶅湪绾�',
+  'device.onlineHint': '鍙互涓嬪彂鍗虫椂浠诲姟鍜岃鍒掍换鍔°��',
+  'device.offlineHint': '鏃犳硶涓嬪彂浠诲姟锛屼粛鍙煡鐪嬭亰澶╄褰曠瓑銆�',
   'device.justNow': '鍒氬垰',
   'device.minutesAgo': '{minutes}鍒嗛挓鍓�',
   'device.hoursAgo': '{hours}灏忔椂鍓�',
@@ -594,4 +602,120 @@
   'voiceprint.audioNotExist': '璇ラ煶棰戜笉瀛樺湪',
   'voiceprint.getAudioFailed': '鑾峰彇闊抽澶辫触',
   'voiceprint.audioPlayFailed': '闊抽鎾斁澶辫触',
+
+  // 瀹堕暱瀛︿範浠诲姟锛�7a-C锛�
+  'parentTask.pageTitle': '瀛︿範浠诲姟',
+  'parentTask.createTitle': '鍒涘缓瀛︿範浠诲姟',
+  'parentTask.editTitle': '缂栬緫瀛︿範浠诲姟',
+  'parentTask.device': '璁惧',
+  'parentTask.selectDevice': '璇烽�夋嫨璁惧',
+  'parentTask.bindDeviceFirst': '璇峰厛缁戝畾璁惧',
+  'parentTask.runningSection': '姝e湪鎵ц',
+  'parentTask.scheduledSection': '璁″垝浠诲姟',
+  'parentTask.running': '姝e湪鎵ц',
+  'parentTask.emptySchedule': '杩樻病鏈夎鍒掋�傜偣鍙充笅瑙� + 鍒涘缓娉涘惉銆佽窡璇绘垨瀵硅瘽浠诲姟銆�',
+  'parentTask.startNow': '绔嬪嵆寮�濮�',
+  'parentTask.saveSchedule': '瀛樹负璁″垝',
+  'parentTask.started': '宸蹭笅鍙戝埌璁惧',
+  'parentTask.saved': '璁″垝宸插悓姝ュ埌璁惧',
+  'parentTask.saveFailed': '淇濆瓨澶辫触',
+  'parentTask.deviceOffline': '璁惧涓嶅湪绾匡紝鏃犳硶鍚屾鍒扮‖浠躲�傝鎵撳紑璁惧骞惰繛涓婄綉缁滃悗鍐嶈瘯锛屾湰娆′笉浼氫繚瀛樸��',
+  'parentTask.online': '璁惧鍦ㄧ嚎',
+  'parentTask.offline': '璁惧涓嶅湪绾�',
+  'parentTask.checkingOnline': '姝e湪妫�娴嬭澶囩姸鎬佲��',
+  'parentTask.onlineHint': '鍙互涓嬪彂鍗虫椂浠诲姟鍜岃鍒掍换鍔°��',
+  'parentTask.offlineHint': '鏃犳硶涓嬪彂鍗虫椂浠诲姟鎴栬鍒掍换鍔°�傝鎵撳紑璁惧骞惰繛涓婄綉缁溿��',
+  'parentTask.startFailed': '涓嬪彂澶辫触锛岃纭璁惧鍦ㄧ嚎',
+  'parentTask.stop': '缁撴潫',
+  'parentTask.stopTitle': '缁撴潫褰撳墠浠诲姟',
+  'parentTask.stopConfirm': '缁撴潫鍚庤澶囦細椹笂鍥炲埌瀵硅瘽銆�',
+  'parentTask.stopped': '宸查�氱煡璁惧缁撴潫',
+  'parentTask.stopFailed': '缁撴潫澶辫触锛岃纭璁惧鍦ㄧ嚎',
+  'parentTask.deleteTitle': '鍒犻櫎浠诲姟',
+  'parentTask.deleteConfirm': '鍒犻櫎鍚庤澶囦笂鐨勮繖鏉¤鍒掍篃浼氫竴璧峰幓鎺夈��',
+  'parentTask.deleted': '宸插垹闄�',
+  'parentTask.deleteFailed': '鍒犻櫎澶辫触',
+  'parentTask.scene': '鍦烘櫙',
+  'parentTask.dispatchHint': '绔嬪嵆寮�濮嬩細鎵撴柇璁惧褰撳墠妯″紡锛涜鍒掍换鍔″埌鐐硅嚜鍔ㄥ垏鎹紝涓嶉渶瑕佸彛澶村彛浠ゃ��',
+  'parentTask.lesson': '璇炬枃',
+  'parentTask.pickScene': '璇烽�夋嫨鍦烘櫙',
+  'parentTask.pickLesson': '璇烽�夋嫨璇炬枃',
+  'parentTask.repeat': '閬嶆暟',
+  'parentTask.level': '绾у埆',
+  'parentTask.system': '鍒嗙被',
+  'parentTask.story': '鎴愯鏁呬簨',
+  'parentTask.pickStory': '璇烽�夋嫨涓�绡囨垚璇�',
+  'parentTask.noStories': '杩欎釜鍒嗙被杩樻病鏈夋垚璇紝璇峰湪璇剧▼娓呭崟閲屾坊鍔犮��',
+  'parentTask.sentenceCount': '鎾斁璇ヨ鐩綍閲岀殑鍏ㄩ儴闊抽锛屾暟閲忎互鏈嶅姟鍣ㄤ笂鐨� MP3 涓哄噯',
+  'parentTask.enPassesTitle': '鑻辨枃鎬庝箞鍚�',
+  'parentTask.cnFixed': '鍏堜腑鏂� 1 閬嶃��1.0 鍊嶉�燂紝鍐嶆寜涓嬮潰璁剧疆鍚嫳鏂囥��',
+  'parentTask.enPassIndex': '鑻辨枃绗� {n} 娈�',
+  'parentTask.addEnPass': '鍔犱竴娈佃嫳鏂�',
+  'parentTask.removePass': '鍒犻櫎',
+  'parentTask.shadowingSettings': '璺熻璁剧疆',
+  'parentTask.shadowingLang': '璺熻璇█',
+  'parentTask.shadowingLang.cn': '涓枃',
+  'parentTask.shadowingLang.en': '鑻辨枃',
+  'parentTask.shadowingSpeed': '鎾斁鍊嶉��',
+  'parentTask.shadowingHint': '褰曢煶绐楀彛 = 瀹為檯鎾斁鏃堕暱 脳 1.2銆傚綋鍓� {speed} 鍊嶉�燂紝鎾緱瓒婃參绐楀彛瓒婇暱銆�',
+  'parentTask.languagePair': '璇█瀵�',
+  'parentTask.pickPair': '璇烽�夋嫨璇█瀵�',
+  'parentTask.pairSoon': '鍙ュ紡瀹屽杽涓�',
+  'parentTask.pairFallbackHint': '杩欏璇█鐨勫畬鏁村彞寮忚繕鍦ㄥ畬鍠勶紝璁惧浼氬厛鐢ㄤ腑鑻辨潵缁冦��',
+  'parentTask.duration': '鏃堕暱锛堝垎閽燂紝0 琛ㄧず涓嶉檺锛�',
+  'parentTask.noDuration': '涓嶉檺鏃堕暱',
+  'parentTask.minutesValue': '{n}鍒嗛挓',
+  'parentTask.onComplete': '缁撴潫鍚�',
+  'parentTask.completeChat': '鍥炲璇�',
+  'parentTask.completePowerOff': '杞叧鏈�',
+  'parentTask.howToRun': '鎬庝箞璺�',
+  'parentTask.immediate': '绔嬪嵆鎵ц',
+  'parentTask.schedule': '璁″垝鏃堕棿',
+  'parentTask.schedule.daily': '姣忓ぉ',
+  'parentTask.schedule.hourly': '姣忓皬鏃�',
+  'parentTask.schedule.interval': '姣忛殧',
+  'parentTask.time': '鏃跺埢',
+  'parentTask.pickTime': '閫夋嫨鏃堕棿',
+  'parentTask.timeHint': '鍙�夋嫨鍏ㄥぉ浠绘剰鏃�:鍒�',
+  'parentTask.repeatDays': '閲嶅',
+  'parentTask.everyDay': '姣忓ぉ',
+  'parentTask.weekdays': '宸ヤ綔鏃�',
+  'parentTask.hourlyMinute': '姣忓皬鏃剁殑绗嚑鍒�',
+  'parentTask.hourlyOnTheHour': '姣忓皬鏃舵暣鐐�',
+  'parentTask.hourlyAt': '姣忓皬鏃� :{minute}',
+  'parentTask.everyMinutes': '姣忛殧 {minutes} 鍒嗛挓',
+  'parentTask.intervalMinutes': '闂撮殧鍒嗛挓',
+  'parentTask.weekday.mon': '涓�',
+  'parentTask.weekday.tue': '浜�',
+  'parentTask.weekday.wed': '涓�',
+  'parentTask.weekday.thu': '鍥�',
+  'parentTask.weekday.fri': '浜�',
+  'parentTask.weekday.sat': '鍏�',
+  'parentTask.weekday.sun': '鏃�',
+
+  'parentHistory.pageTitle': '鎵ц璁板綍',
+  'parentHistory.detailTitle': '璁板綍璇︽儏',
+  'parentHistory.entry': '鎵ц璁板綍',
+  'parentHistory.empty': '杩樻病鏈夋墽琛岃褰曘�傚瀛愬畬鎴愪换鍔″悗浼氬嚭鐜板湪杩欓噷銆�',
+  'parentHistory.loadFailed': '鍔犺浇澶辫触',
+  'parentHistory.status.running': '杩涜涓�',
+  'parentHistory.status.completed': '宸插畬鎴�',
+  'parentHistory.status.cancelled': '宸查��鍑�',
+  'parentHistory.status.preempted': '琚墦鏂�',
+  'parentHistory.status.other': '鏈畬鎴�',
+  'parentHistory.source.immediate': '绔嬪嵆涓嬪彂',
+  'parentHistory.source.schedule': '璁″垝',
+  'parentHistory.source.voice': '鍙d护',
+  'parentHistory.durationSec': '{s}绉�',
+  'parentHistory.durationMin': '{m}鍒唟s}绉�',
+  'parentHistory.turns': '{n}鍙�',
+  'parentHistory.sentence': '绗瑊n}鍙�',
+  'parentHistory.expected': '鍘熸枃',
+  'parentHistory.asr': '璺熻',
+  'parentHistory.noExpected': '娌℃湁鍘熸枃',
+  'parentHistory.noAsr': '娌℃湁璇嗗埆鍒拌窡璇�',
+  'parentHistory.noAudio': '娌℃湁鍙挱鏀剧殑褰曢煶',
+  'parentHistory.emptyTurns': '杩欐璺熻杩樻病鏈夊彞瀛愯褰曘��',
+  'parentHistory.extensiveHint': '娉涘惉娌℃湁閫愬彞褰曢煶锛岃繖閲屽彧鐪嬫槸鍚︽挱瀹屽拰鐢ㄤ簡澶氫箙銆�',
+  'parentHistory.noChat': '杩欐鏃堕棿娌℃湁鐣欎笅瀵硅瘽銆�',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts
index 7a21692..710da4c 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/i18n/zh_TW.ts
@@ -105,6 +105,8 @@
   'home.daysAgo': '澶╁墠',
   'home.languageModel': '瑾炶█妯″瀷',
   'home.voiceModel': '闊宠壊妯″瀷',
+  'home.noBoundDevice': '鏆劇缍佸畾瑁濈疆',
+  'home.onlineCount': '{online}/{total} 鍙板湪绶�',
 
   // Agent闋侀潰
   'agent.pageTitle': '鏅鸿兘楂�',
@@ -112,6 +114,7 @@
   'agent.deviceManagement': '瑷倷绠$悊',
   'agent.chatHistory': '鑱婂ぉ瑷橀寗',
   'agent.voiceprintManagement': '鑱茬磱绠$悊',
+  'agent.parentTasks': '瀛哥繏浠诲嫏',
   'agent.editTitle': '绶ㄨ集鏅鸿兘楂�',
   'agent.toolsTitle': '绶ㄨ集鍔熻兘',
   'agent.voiceActivityDetection': '瑾為煶娲诲嫊妾㈡脯',
@@ -326,6 +329,11 @@
   'device.deviceType': '瑷倷椤炲瀷',
   'device.loading': '鍔犺級涓�...',
   'device.neverConnected': '寰炴湭閫f帴',
+  'device.checkingOnline': '姝e湪妾㈡脯瑁濈疆鐙�鎱嬧��',
+  'device.online': '瑁濈疆鍦ㄧ窔',
+  'device.offline': '瑁濈疆涓嶅湪绶�',
+  'device.onlineHint': '鍙互涓嬬櫦鍗虫檪浠诲嫏鍜岃▓鐣换鍕欍��',
+  'device.offlineHint': '鐒℃硶涓嬬櫦浠诲嫏锛屼粛鍙煡鐪嬭亰澶╄閷勭瓑銆�',
   'device.justNow': '鍓涘墰',
   'device.minutesAgo': '{minutes}鍒嗛悩鍓�',
   'device.hoursAgo': '{hours}灏忔檪鍓�',
@@ -594,4 +602,119 @@
   'voiceprint.audioNotExist': '瑭查煶闋讳笉瀛樺湪',
   'voiceprint.getAudioFailed': '鐛插彇闊抽牷澶辨晽',
   'voiceprint.audioPlayFailed': '闊抽牷鎾斁澶辨晽',
+
+  'parentTask.pageTitle': '瀛哥繏浠诲嫏',
+  'parentTask.createTitle': '寤虹珛瀛哥繏浠诲嫏',
+  'parentTask.editTitle': '绶ㄨ集瀛哥繏浠诲嫏',
+  'parentTask.device': '瑁濈疆',
+  'parentTask.selectDevice': '璜嬮伕鎿囪缃�',
+  'parentTask.bindDeviceFirst': '璜嬪厛缍佸畾瑁濈疆',
+  'parentTask.runningSection': '姝e湪鍩疯',
+  'parentTask.scheduledSection': '瑷堢暙浠诲嫏',
+  'parentTask.running': '姝e湪鍩疯',
+  'parentTask.emptySchedule': '閭勬矑鏈夎▓鐣�傞粸鍙充笅瑙� + 寤虹珛娉涜伣銆佽窡璁�鎴栧皪瑭变换鍕欍��',
+  'parentTask.startNow': '绔嬪嵆闁嬪',
+  'parentTask.saveSchedule': '瀛樼偤瑷堢暙',
+  'parentTask.started': '宸蹭笅鐧煎埌瑁濈疆',
+  'parentTask.saved': '瑷堢暙宸插悓姝ュ埌瑁濈疆',
+  'parentTask.saveFailed': '鍎插瓨澶辨晽',
+  'parentTask.deviceOffline': '瑁濈疆涓嶅湪绶氾紝鐒℃硶鍚屾鍒扮‖楂斻�傝珛鎵撻枊瑁濈疆涓﹂�d笂缍茶矾寰屽啀瑭︼紝鏈涓嶆渻鍎插瓨銆�',
+  'parentTask.online': '瑁濈疆鍦ㄧ窔',
+  'parentTask.offline': '瑁濈疆涓嶅湪绶�',
+  'parentTask.checkingOnline': '姝e湪妾㈡脯瑁濈疆鐙�鎱嬧��',
+  'parentTask.onlineHint': '鍙互涓嬬櫦鍗虫檪浠诲嫏鍜岃▓鐣换鍕欍��',
+  'parentTask.offlineHint': '鐒℃硶涓嬬櫦鍗虫檪浠诲嫏鎴栬▓鐣换鍕欍�傝珛鎵撻枊瑁濈疆涓﹂�d笂缍茶矾銆�',
+  'parentTask.startFailed': '涓嬬櫦澶辨晽锛岃珛纰鸿獚瑁濈疆鍦ㄧ窔',
+  'parentTask.stop': '绲愭潫',
+  'parentTask.stopTitle': '绲愭潫鐣跺墠浠诲嫏',
+  'parentTask.stopConfirm': '绲愭潫寰岃缃渻椹笂鍥炲埌灏嶈┍銆�',
+  'parentTask.stopped': '宸查�氱煡瑁濈疆绲愭潫',
+  'parentTask.stopFailed': '绲愭潫澶辨晽锛岃珛纰鸿獚瑁濈疆鍦ㄧ窔',
+  'parentTask.deleteTitle': '鍒櫎浠诲嫏',
+  'parentTask.deleteConfirm': '鍒櫎寰岃缃笂鐨勯�欐瑷堢暙涔熸渻涓�璧峰幓鎺夈��',
+  'parentTask.deleted': '宸插埅闄�',
+  'parentTask.deleteFailed': '鍒櫎澶辨晽',
+  'parentTask.scene': '鍫存櫙',
+  'parentTask.dispatchHint': '绔嬪嵆闁嬪鏈冩墦鏂疯缃洰鍓嶆ā寮忥紱瑷堢暙浠诲嫏鍒伴粸鑷嫊鍒囨彌锛屼笉闇�瑕佸彛闋寚浠ゃ��',
+  'parentTask.lesson': '瑾叉枃',
+  'parentTask.pickScene': '璜嬮伕鎿囧牬鏅�',
+  'parentTask.pickLesson': '璜嬮伕鎿囪鏂�',
+  'parentTask.repeat': '閬嶆暩',
+  'parentTask.level': '绱氬垾',
+  'parentTask.system': '鍒嗛',
+  'parentTask.story': '鎴愯獮鏁呬簨',
+  'parentTask.pickStory': '璜嬮伕鎿囦竴绡囨垚瑾�',
+  'parentTask.noStories': '閫欏�嬪垎椤為倓娌掓湁鎴愯獮锛岃珛鍦ㄨ绋嬫竻鍠!娣诲姞銆�',
+  'parentTask.sentenceCount': '璺熻畝鎷嗘垚 {n} 鍙�',
+  'parentTask.enPassesTitle': '鑻辨枃鎬庨杭鑱�',
+  'parentTask.cnFixed': '鍏堜腑鏂� 1 閬嶃��1.0 鍊嶉�燂紝鍐嶆寜涓嬮潰瑷疆鑱借嫳鏂囥��',
+  'parentTask.enPassIndex': '鑻辨枃绗� {n} 娈�',
+  'parentTask.addEnPass': '鍔犱竴娈佃嫳鏂�',
+  'parentTask.removePass': '鍒櫎',
+  'parentTask.shadowingSettings': '璺熻畝瑷疆',
+  'parentTask.shadowingLang': '璺熻畝瑾炶█',
+  'parentTask.shadowingLang.cn': '涓枃',
+  'parentTask.shadowingLang.en': '鑻辨枃',
+  'parentTask.shadowingSpeed': '鎾斁鍊嶉��',
+  'parentTask.shadowingHint': '閷勯煶绐楀彛 = 瀵﹂殯鎾斁鏅傞暦 脳 1.2銆傜洰鍓� {speed} 鍊嶉�燂紝鎾緱瓒婃參绐楀彛瓒婇暦銆�',
+  'parentTask.languagePair': '瑾炶█灏�',
+  'parentTask.pickPair': '璜嬮伕鎿囪獮瑷�灏�',
+  'parentTask.pairSoon': '鍙ュ紡瀹屽杽涓�',
+  'parentTask.pairFallbackHint': '閫欏瑾炶█鐨勫畬鏁村彞寮忛倓鍦ㄥ畬鍠勶紝瑁濈疆鏈冨厛鐢ㄤ腑鑻变締绶淬��',
+  'parentTask.duration': '鏅傞暦锛堝垎閻橈紝0 琛ㄧず涓嶉檺锛�',
+  'parentTask.noDuration': '涓嶉檺鏅傞暦',
+  'parentTask.minutesValue': '{n}鍒嗛悩',
+  'parentTask.onComplete': '绲愭潫寰�',
+  'parentTask.completeChat': '鍥炲皪瑭�',
+  'parentTask.completePowerOff': '杌熼棞姗�',
+  'parentTask.howToRun': '鎬庨杭璺�',
+  'parentTask.immediate': '绔嬪嵆鍩疯',
+  'parentTask.schedule': '瑷堢暙鏅傞枔',
+  'parentTask.schedule.daily': '姣忓ぉ',
+  'parentTask.schedule.hourly': '姣忓皬鏅�',
+  'parentTask.schedule.interval': '姣忛殧',
+  'parentTask.time': '鏅傚埢',
+  'parentTask.pickTime': '閬告搰鏅傞枔',
+  'parentTask.timeHint': '鍙伕鎿囧叏澶╀换鎰忔檪:鍒�',
+  'parentTask.repeatDays': '閲嶈',
+  'parentTask.everyDay': '姣忓ぉ',
+  'parentTask.weekdays': '宸ヤ綔鏃�',
+  'parentTask.hourlyMinute': '姣忓皬鏅傜殑绗咕鍒�',
+  'parentTask.hourlyOnTheHour': '姣忓皬鏅傛暣榛�',
+  'parentTask.hourlyAt': '姣忓皬鏅� :{minute}',
+  'parentTask.everyMinutes': '姣忛殧 {minutes} 鍒嗛悩',
+  'parentTask.intervalMinutes': '闁撻殧鍒嗛悩',
+  'parentTask.weekday.mon': '涓�',
+  'parentTask.weekday.tue': '浜�',
+  'parentTask.weekday.wed': '涓�',
+  'parentTask.weekday.thu': '鍥�',
+  'parentTask.weekday.fri': '浜�',
+  'parentTask.weekday.sat': '鍏�',
+  'parentTask.weekday.sun': '鏃�',
+
+  'parentHistory.pageTitle': '鍩疯绱�閷�',
+  'parentHistory.detailTitle': '绱�閷勮┏鎯�',
+  'parentHistory.entry': '鍩疯绱�閷�',
+  'parentHistory.empty': '閭勬矑鏈夊煼琛岀磤閷勩�傚瀛愬畬鎴愪换鍕欏緦鏈冨嚭鐝惧湪閫欒!銆�',
+  'parentHistory.loadFailed': '杓夊叆澶辨晽',
+  'parentHistory.status.running': '閫茶涓�',
+  'parentHistory.status.completed': '宸插畬鎴�',
+  'parentHistory.status.cancelled': '宸查��鍑�',
+  'parentHistory.status.preempted': '琚墦鏂�',
+  'parentHistory.status.other': '鏈畬鎴�',
+  'parentHistory.source.immediate': '绔嬪嵆涓嬬櫦',
+  'parentHistory.source.schedule': '瑷堢暙',
+  'parentHistory.source.voice': '鍙d护',
+  'parentHistory.durationSec': '{s}绉�',
+  'parentHistory.durationMin': '{m}鍒唟s}绉�',
+  'parentHistory.turns': '{n}鍙�',
+  'parentHistory.sentence': '绗瑊n}鍙�',
+  'parentHistory.expected': '鍘熸枃',
+  'parentHistory.asr': '璺熻畝',
+  'parentHistory.noExpected': '娌掓湁鍘熸枃',
+  'parentHistory.noAsr': '娌掓湁璀樺垾鍒拌窡璁�',
+  'parentHistory.noAudio': '娌掓湁鍙挱鏀剧殑閷勯煶',
+  'parentHistory.emptyTurns': '閫欐璺熻畝閭勬矑鏈夊彞瀛愮磤閷勩��',
+  'parentHistory.extensiveHint': '娉涜伣娌掓湁閫愬彞閷勯煶锛岄�欒!鍙湅鏄惁鎾畬鍜岀敤浜嗗涔呫��',
+  'parentHistory.noChat': '閫欐鏅傞枔娌掓湁鐣欎笅灏嶈┍銆�',
 }
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/manifest.json b/xiaozhi-esp32-server/main/manager-mobile/src/manifest.json
index 67225dc..10aa485 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/manifest.json
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/manifest.json
@@ -119,6 +119,8 @@
   },
   "vueVersion": "3",
   "h5": {
-    "router": {}
+    "router": {
+      "base": "/mobile/"
+    }
   }
 }
\ No newline at end of file
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages.json b/xiaozhi-esp32-server/main/manager-mobile/src/pages.json
index 9799c40..80f9ed4 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages.json
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages.json
@@ -181,6 +181,42 @@
       }
     },
     {
+      "path": "pages/parent-task/edit",
+      "type": "page",
+      "layout": "default",
+      "style": {
+        "navigationBarTitleText": "鍒涘缓瀛︿範浠诲姟",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/parent-task/history-detail",
+      "type": "page",
+      "layout": "default",
+      "style": {
+        "navigationBarTitleText": "璁板綍璇︽儏",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/parent-task/history",
+      "type": "page",
+      "layout": "default",
+      "style": {
+        "navigationBarTitleText": "鎵ц璁板綍",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/parent-task/index",
+      "type": "page",
+      "layout": "default",
+      "style": {
+        "navigationBarTitleText": "瀛︿範浠诲姟",
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/register/index",
       "type": "page",
       "layout": "default",
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue
index 8e4f65b..673aa54 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/edit.vue
@@ -25,6 +25,10 @@
 
 const agentId = computed(() => props.agentId)
 
+function isUsableAgentId(id?: string) {
+  return Boolean(id && id !== 'default')
+}
+
 // 琛ㄥ崟鏁版嵁
 const formData = ref<Partial<AgentDetail>>({
   agentName: '',
@@ -326,7 +330,7 @@
 
 // 鍔犺浇鏅鸿兘浣撹鎯�
 async function loadAgentDetail(targetAgentId = agentId.value) {
-  if (!targetAgentId)
+  if (!isUsableAgentId(targetAgentId))
     return false
 
   const requestId = ++agentDetailRequestSequence
@@ -1100,7 +1104,7 @@
 
 // 鑾峰彇鏅鸿兘浣撴爣绛�
 async function loadAgentTags(targetAgentId = agentId.value) {
-  if (!targetAgentId) {
+  if (!isUsableAgentId(targetAgentId)) {
     return false
   }
   const requestId = ++agentTagRequestSequence
@@ -1233,16 +1237,18 @@
   invalidateTtsMetadataRequest()
   loading.value = false
   snapshotReloadFailed.value = false
-  snapshotReloadBlocked.value = Boolean(currentAgentId)
+  snapshotReloadBlocked.value = isUsableAgentId(currentAgentId)
   originalAgentDetail.value = null
   originalTagNames.value = []
-  if (currentAgentId) {
+  if (isUsableAgentId(currentAgentId)) {
     void reloadAgentAfterSnapshotRestore(currentAgentId)
   }
 }, { flush: 'sync' })
 
 onMounted(async () => {
-  loadAgentTags()
+  if (isUsableAgentId(agentId.value)) {
+    loadAgentTags()
+  }
 
   // 鍏堝姞杞芥ā鍨嬮�夐」鍜岃鑹叉ā鏉�
   await Promise.all([
@@ -1252,7 +1258,7 @@
   ])
 
   // 鐒跺悗鍔犺浇鏅鸿兘浣撹鎯咃紝杩欐牱鍙互姝g‘鏄犲皠鏄剧ず鍚嶇О
-  if (agentId.value && !snapshotReloadBlocked.value) {
+  if (isUsableAgentId(agentId.value) && !snapshotReloadBlocked.value) {
     await loadAgentDetail()
   }
 })
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue
index 104aced..746cb7e 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/agent/index.vue
@@ -9,12 +9,13 @@
 </route>
 
 <script lang="ts" setup>
-import { onLoad } from '@dcloudio/uni-app'
-import { onMounted, ref } from 'vue'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { onMounted, ref, watch } from 'vue'
 import CustomTabs from '@/components/custom-tabs/index.vue'
 import { t } from '@/i18n'
 import ChatHistory from '@/pages/chat-history/index.vue'
 import DeviceManagement from '@/pages/device/index.vue'
+import ParentTaskList from '@/pages/parent-task/index.vue'
 import VoiceprintManagement from '@/pages/voiceprint/index.vue'
 import AgentEdit from './edit.vue'
 
@@ -44,7 +45,7 @@
 // #endif
 
 // 鏅鸿兘浣揑D
-const currentAgentId = ref('default')
+const currentAgentId = ref('')
 
 // 褰撳墠 tab
 const currentTab = ref('agent-config')
@@ -57,6 +58,7 @@
 const deviceRef = ref()
 const chatRef = ref()
 const voiceprintRef = ref()
+const parentTaskRef = ref()
 
 // 鏇存柊鍒锋柊鍣ㄧ姸鎬�
 function updateRefresherEnabled(value: boolean) {
@@ -88,6 +90,12 @@
     value: 'voiceprint-management',
     icon: '/static/tabbar/microphone.png',
     activeIcon: '/static/tabbar/microphone_activate.png',
+  },
+  {
+    label: t('agent.parentTasks'),
+    value: 'parent-tasks',
+    icon: '/static/tabbar/system.png',
+    activeIcon: '/static/tabbar/system_activate.png',
   },
 ]
 
@@ -127,6 +135,11 @@
           await voiceprintRef.value.refresh()
         }
         break
+      case 'parent-tasks':
+        if (parentTaskRef.value?.refresh) {
+          await parentTaskRef.value.refresh()
+        }
+        break
     }
   }
   catch (error) {
@@ -154,6 +167,12 @@
   if (options?.agentId) {
     currentAgentId.value = options.agentId
     console.log('鎺ユ敹鍒版櫤鑳戒綋ID:', options.agentId)
+  }
+})
+
+onShow(() => {
+  if (currentTab.value === 'parent-tasks' && parentTaskRef.value?.refresh) {
+    parentTaskRef.value.refresh()
   }
 })
 
@@ -192,7 +211,7 @@
       <!-- Tab 鍐呭 -->
       <view class="flex-1">
         <AgentEdit
-          v-if="currentTab === 'agent-config'"
+          v-if="currentTab === 'agent-config' && currentAgentId"
           :agent-id="currentAgentId"
         />
         <DeviceManagement
@@ -211,6 +230,12 @@
           :agent-id="currentAgentId"
           @update-refresher-enabled="updateRefresherEnabled"
         />
+        <ParentTaskList
+          v-else-if="currentTab === 'parent-tasks'"
+          ref="parentTaskRef"
+          embedded
+          :agent-id="currentAgentId"
+        />
       </view>
     </scroll-view>
   </view>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue
index ce08cab..79e3737 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/device/index.vue
@@ -126,6 +126,14 @@
 }
 
 // 鑾峰彇璁惧绫诲瀷鍚嶇О
+function presenceState(device: Device): 'online' | 'offline' | 'unknown' {
+  if (device.online === true)
+    return 'online'
+  if (device.online === false)
+    return 'offline'
+  return 'unknown'
+}
+
 function getDeviceTypeName(boardKey: string): string {
   const firmwareType = firmwareTypes.value.find(type => type.key === boardKey)
   return firmwareType?.name || boardKey
@@ -209,6 +217,16 @@
     const errorMessage = error?.message || t('device.bindFailed')
     toast.error(errorMessage)
   }
+}
+
+function goToParentTasks(device: Device) {
+  if (!currentAgentId.value || !device?.macAddress) {
+    toast.warning(t('parentTask.bindDeviceFirst'))
+    return
+  }
+  uni.navigateTo({
+    url: `/pages/parent-task/index?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(device.macAddress)}`,
+  })
 }
 
 // 鎵撳紑缁戝畾璁惧瀵硅瘽妗�
@@ -397,9 +415,55 @@
             <view class="cursor-pointer bg-[#fbfbfb] p-[32rpx] transition-all duration-200 active:bg-[#f8f9fa]">
               <view class="flex items-start justify-between">
                 <view class="flex-1">
-                  <view class="mb-[16rpx] flex items-center justify-between">
-                    <text class="max-w-[60%] break-all text-[32rpx] text-[#232338] font-semibold">
+                  <view class="mb-[16rpx] flex items-start justify-between gap-[16rpx]">
+                    <text class="max-w-[52%] break-all text-[32rpx] text-[#232338] font-semibold">
                       {{ getDeviceTypeName(device.board) }}
+                    </text>
+                    <view
+                      class="shrink-0 rounded-[24rpx] px-[16rpx] py-[6rpx]"
+                      :class="{
+                        'bg-[#e8f8ef]': presenceState(device) === 'online',
+                        'bg-[#fff1f0]': presenceState(device) === 'offline',
+                        'bg-[#f5f7fb]': presenceState(device) === 'unknown',
+                      }"
+                    >
+                      <text
+                        class="text-[24rpx] font-bold"
+                        :class="{
+                          'text-[#146c36]': presenceState(device) === 'online',
+                          'text-[#d4380d]': presenceState(device) === 'offline',
+                          'text-[#65686f]': presenceState(device) === 'unknown',
+                        }"
+                      >
+                        {{ presenceState(device) === 'online' ? t('device.online') : (presenceState(device) === 'offline' ? t('device.offline') : t('device.checkingOnline')) }}
+                      </text>
+                    </view>
+                  </view>
+                  <view
+                    class="mb-[16rpx] flex items-start gap-[12rpx] rounded-[16rpx] p-[16rpx]"
+                    :class="{
+                      'bg-[#e8f8ef]': presenceState(device) === 'online',
+                      'bg-[#fff1f0]': presenceState(device) === 'offline',
+                      'bg-[#f5f7fb]': presenceState(device) === 'unknown',
+                    }"
+                  >
+                    <view
+                      class="mt-[8rpx] h-[16rpx] w-[16rpx] shrink-0 rounded-full"
+                      :class="{
+                        'bg-[#1aa34a]': presenceState(device) === 'online',
+                        'bg-[#d4380d]': presenceState(device) === 'offline',
+                        'bg-[#c0c4cc]': presenceState(device) === 'unknown',
+                      }"
+                    />
+                    <text
+                      class="text-[24rpx] leading-[1.45]"
+                      :class="{
+                        'text-[#2f6f45]': presenceState(device) === 'online',
+                        'text-[#a8071a]': presenceState(device) === 'offline',
+                        'text-[#65686f]': presenceState(device) === 'unknown',
+                      }"
+                    >
+                      {{ presenceState(device) === 'online' ? t('device.onlineHint') : (presenceState(device) === 'offline' ? t('device.offlineHint') : t('device.checkingOnline')) }}
                     </text>
                   </view>
 
@@ -425,6 +489,15 @@
                       @change="toggleAutoUpdate(device)"
                     />
                   </view>
+                  <view
+                    class="mt-[16rpx] flex items-center justify-between border-[1rpx] border-[#d6e4ff] rounded-[12rpx] bg-[#f5f8ff] p-[16rpx_20rpx]"
+                    @click.stop="goToParentTasks(device)"
+                  >
+                    <text class="text-[28rpx] text-[#336cff] font-medium">
+                      {{ t('parentTask.pageTitle') }}
+                    </text>
+                    <wd-icon name="arrow-right" size="16" color="#336cff" />
+                  </view>
                 </view>
               </view>
             </view>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue
index 4d73a15..55c5e70 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/index/index.vue
@@ -11,12 +11,13 @@
 </route>
 
 <script lang="ts" setup>
-import type { Agent } from '@/api/agent/types'
+import type { Agent, AgentDevice } from '@/api/agent/types'
+import { onShow } from '@dcloudio/uni-app'
 import { computed, onMounted, ref } from 'vue'
-// 鍦ㄧ粍浠舵寕杞藉悗璁剧疆瀵艰埅鏍忔爣棰�
 import { useMessage } from 'wot-design-uni/components/wd-message-box'
 import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
 import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
+import { getBindDevices } from '@/api/device'
 import { t } from '@/i18n'
 import { toast } from '@/utils/toast'
 
@@ -68,6 +69,7 @@
     console.log('z-paging鑾峰彇鏅鸿兘浣撳垪琛�')
 
     const response = await getAgentList()
+    await attachDevices(response)
 
     // 鏇存柊鏈湴鍒楄〃
     agentList.value = response
@@ -144,6 +146,73 @@
   finally {
     deleteAgentLoading.value = false
   }
+}
+
+async function attachDevices(agents: Agent[]) {
+  await Promise.all((agents || []).map(async (agent) => {
+    if (Array.isArray(agent.devices))
+      return
+    try {
+      const list = await getBindDevices(agent.id)
+      agent.devices = (list || []).map(device => ({
+        macAddress: device.macAddress,
+        alias: device.alias,
+        online: device.online,
+      }))
+      agent.onlineDeviceCount = (agent.devices || []).filter(item => item.online === true).length
+      agent.deviceCount = agent.devices?.length || 0
+    }
+    catch (error) {
+      console.error('load home device status failed', error)
+      agent.devices = []
+    }
+  }))
+}
+
+function agentPresence(agent: Agent): 'online' | 'offline' | 'none' | 'unknown' {
+  const devices = agent.devices
+  if (!Array.isArray(devices))
+    return 'unknown'
+  if (devices.length === 0)
+    return 'none'
+  if (devices.some(item => item.online === true))
+    return 'online'
+  if (devices.every(item => item.online === false))
+    return 'offline'
+  return 'unknown'
+}
+
+function agentPresenceTitle(agent: Agent) {
+  const state = agentPresence(agent)
+  if (state === 'online') {
+    const devices = agent.devices || []
+    const online = devices.filter(item => item.online === true).length
+    if (devices.length > 1)
+      return t('home.onlineCount', { online, total: devices.length })
+    return t('device.online')
+  }
+  if (state === 'offline')
+    return t('device.offline')
+  if (state === 'none')
+    return t('home.noBoundDevice')
+  return t('device.checkingOnline')
+}
+
+function devicePresence(device: AgentDevice): 'online' | 'offline' | 'unknown' {
+  if (device.online === true)
+    return 'online'
+  if (device.online === false)
+    return 'offline'
+  return 'unknown'
+}
+
+function devicePresenceText(device: AgentDevice) {
+  const state = devicePresence(device)
+  if (state === 'online')
+    return t('device.online')
+  if (state === 'offline')
+    return t('device.offline')
+  return t('device.checkingOnline')
 }
 
 // 杩涘叆缂栬緫椤甸潰
@@ -260,6 +329,28 @@
                   <text class="agent-name">
                     {{ agent.agentName }}
                   </text>
+                  <view class="presence-chip" :class="agentPresence(agent)">
+                    <view class="presence-chip-dot" :class="agentPresence(agent)" />
+                    <text class="presence-chip-text" :class="agentPresence(agent)">
+                      {{ agentPresenceTitle(agent) }}
+                    </text>
+                  </view>
+                </view>
+
+                <view v-if="agent.devices && agent.devices.length" class="device-presence-list">
+                  <view
+                    v-for="device in agent.devices"
+                    :key="device.macAddress"
+                    class="device-presence-row"
+                  >
+                    <view class="device-presence-dot" :class="devicePresence(device)" />
+                    <text class="device-presence-name">
+                      {{ device.macAddress }}
+                    </text>
+                    <text class="device-presence-state" :class="devicePresence(device)">
+                      {{ devicePresenceText(device) }}
+                    </text>
+                  </view>
                 </view>
 
                 <view class="model-info">
@@ -557,15 +648,143 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
+    gap: 12rpx;
     margin-bottom: 12rpx;
 
     .agent-name {
+      flex: 1;
+      min-width: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
       font-size: 32rpx;
       font-weight: 600;
       color: #1a1a1a;
     }
   }
 
+  .presence-chip {
+    display: flex;
+    align-items: center;
+    gap: 8rpx;
+    flex-shrink: 0;
+    padding: 6rpx 14rpx;
+    border-radius: 24rpx;
+
+    &.online {
+      background: #e8f8ef;
+    }
+
+    &.offline {
+      background: #fff1f0;
+    }
+
+    &.none,
+    &.unknown {
+      background: #f5f7fb;
+    }
+  }
+
+  .presence-chip-dot {
+    width: 12rpx;
+    height: 12rpx;
+    flex-shrink: 0;
+    border-radius: 50%;
+
+    &.online {
+      background: #1aa34a;
+    }
+
+    &.offline {
+      background: #d4380d;
+    }
+
+    &.none,
+    &.unknown {
+      background: #c0c4cc;
+    }
+  }
+
+  .presence-chip-text {
+    font-size: 22rpx;
+    font-weight: 600;
+    line-height: 1.4;
+
+    &.online {
+      color: #146c36;
+    }
+
+    &.offline {
+      color: #d4380d;
+    }
+
+    &.none,
+    &.unknown {
+      color: #65686f;
+    }
+  }
+
+  .device-presence-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8rpx;
+    margin-bottom: 16rpx;
+  }
+
+  .device-presence-row {
+    display: flex;
+    align-items: center;
+    gap: 10rpx;
+    padding: 8rpx 4rpx;
+  }
+
+  .device-presence-dot {
+    width: 12rpx;
+    height: 12rpx;
+    flex-shrink: 0;
+    border-radius: 50%;
+
+    &.online {
+      background: #1aa34a;
+    }
+
+    &.offline {
+      background: #d4380d;
+    }
+
+    &.unknown {
+      background: #c0c4cc;
+    }
+  }
+
+  .device-presence-name {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 24rpx;
+    color: #232338;
+  }
+
+  .device-presence-state {
+    flex-shrink: 0;
+    font-size: 22rpx;
+    font-weight: 600;
+
+    &.online {
+      color: #146c36;
+    }
+
+    &.offline {
+      color: #d4380d;
+    }
+
+    &.unknown {
+      color: #65686f;
+    }
+  }
+
   .model-info {
     margin-bottom: 16rpx;
 
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue
index f8fd6c0..2d6e9de 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/login/index.vue
@@ -266,15 +266,13 @@
   showLanguageSheet.value = false
 }
 
-// 缁勪欢鎸傝浇鏃剁‘淇濋厤缃凡鍔犺浇
+// 姣忔杩涚櫥褰曢〉閮芥媺褰撳墠鎺ュ彛鐨勫叕閽ワ紝閬垮厤娌跨敤涓婃澶栫綉缂撳瓨瀵艰嚧瀵嗙爜瀵逛笉涓�
 onMounted(async () => {
-  if (!configStore.config.name) {
-    try {
-      await configStore.fetchPublicConfig()
-    }
-    catch (error) {
-      console.error(t('login.fetchConfigError'), error)
-    }
+  try {
+    await configStore.fetchPublicConfig()
+  }
+  catch (error) {
+    console.error(t('login.fetchConfigError'), error)
   }
 })
 </script>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue
new file mode 100644
index 0000000..2cc8141
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/edit.vue
@@ -0,0 +1,907 @@
+<route lang="jsonc" type="page">
+{
+  "layout": "default",
+  "style": {
+    "navigationBarTitleText": "鍒涘缓瀛︿範浠诲姟",
+    "navigationStyle": "custom"
+  }
+}
+</route>
+
+<script lang="ts" setup>
+import type {
+  IdiomCatalog,
+  IdiomEnPass,
+  IdiomNamed,
+  IdiomStory,
+  ParentLanguagePair,
+  ParentScene,
+  ParentTask,
+  ParentTaskParams,
+  ParentSchedule,
+} from '@/api/parent'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { computed, ref, watch } from 'vue'
+import {
+  createParentTask,
+  getParentDeviceStatus,
+  getParentIdiomCatalog,
+  getParentLanguagePairs,
+  getParentScenes,
+  getParentTask,
+  parentTaskError,
+  runParentTaskNow,
+  updateParentTask,
+} from '@/api/parent'
+import { t } from '@/i18n'
+import { toast } from '@/utils/toast'
+import { normalizeTime, pad2 } from './scheduleText'
+
+defineOptions({
+  name: 'ParentTaskEdit',
+})
+
+const FALLBACK_SCENES: ParentScene[] = [
+  { id: 'extensive', name: '娉涘惉', hint: '鎸夎鏂囧惊鐜挱鏀撅紝涓嶅璇濄��', needsLesson: true, needsEnPasses: true, needsShadowingSettings: false, needsLanguagePair: false, needsDuration: false, needsOnComplete: true, appId: 'english_learning', taskId: 'repeater' },
+  { id: 'shadowing', name: '璺熻', hint: '鎾竴鍙ャ�佽窡璇讳竴鍙ャ��', needsLesson: true, needsEnPasses: false, needsShadowingSettings: true, needsLanguagePair: false, needsDuration: false, needsOnComplete: true, appId: 'english_learning', taskId: 'shadowing' },
+  { id: 'chat', name: '榛樿鑱婂ぉ', hint: '鍥炲埌鏃ュ父瀵硅瘽銆�', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: false, needsDuration: true, needsOnComplete: true, appId: 'chat', taskId: 'conversation' },
+  { id: 'translation', name: '缈昏瘧', hint: '绔嬪嵆鎴栧埌鐐硅繘鍏ヤ腑鑻变簰璇戯紝鏃犻渶鍙eご鍒囨崲銆�', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: true, needsDuration: true, needsOnComplete: true, appId: 'english_translation', taskId: 'live_translate' },
+  { id: 'bilingual', name: '涓嫳瀵硅瘽', hint: '绔嬪嵆鎴栧埌鐐硅繘鍏ヤ腑鑻卞璇濓紝鏃犻渶鍙eご鍒囨崲銆�', needsLesson: false, needsEnPasses: false, needsShadowingSettings: false, needsLanguagePair: true, needsDuration: true, needsOnComplete: true, appId: 'english_conversation', taskId: 'bilingual_chat' },
+]
+
+const FALLBACK_LEVELS: IdiomNamed[] = [
+  { id: 'l1', name: '涓�绾�' },
+  { id: 'l2', name: '浜岀骇' },
+  { id: 'l3', name: '涓夌骇' },
+  { id: 'l4', name: '鍥涚骇' },
+  { id: 'l5', name: '浜旂骇' },
+  { id: 'l6', name: '鍏骇' },
+]
+const FALLBACK_SYSTEMS: IdiomNamed[] = [
+  { id: 'origin', name: '璧锋簮涓庡瓨鍦ㄧ悊瑙�', nameEn: 'Understanding Origins and Existence' },
+  { id: 'growth', name: '瀛︿範涓庢垚闀挎満鍒�', nameEn: 'Learning & Growth Mechanisms' },
+  { id: 'language', name: '璇█涓庢剰涔夊缓鏋�', nameEn: 'Construction of Language & Meaning' },
+  { id: 'cognition', name: '璁ょ煡涓庢�濈淮鍋忓樊', nameEn: 'Cognitive & Thinking Biases' },
+  { id: 'values', name: '浠峰�间笌琛屼负閫夋嫨', nameEn: 'Values & Behavioral Choices' },
+  { id: 'society', name: '绀句細浜掑姩涓庣粨鏋勫叧绯�', nameEn: 'Social Interaction & Structural Relationships' },
+  { id: 'destiny', name: '鍛借繍涓庤嚜鎴戣秴瓒�', nameEn: 'Destiny & Self-Transcendence' },
+]
+const DEFAULT_EN_PASSES: IdiomEnPass[] = [
+  { speed: 0.8, repeat: 1 },
+  { speed: 0.9, repeat: 2 },
+  { speed: 1.0, repeat: 5 },
+]
+const SPEED_OPTIONS = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2]
+
+const WEEKDAYS = [
+  { value: 1, key: 'mon' },
+  { value: 2, key: 'tue' },
+  { value: 3, key: 'wed' },
+  { value: 4, key: 'thu' },
+  { value: 5, key: 'fri' },
+  { value: 6, key: 'sat' },
+  { value: 7, key: 'sun' },
+]
+const SCHEDULE_TYPES: { id: 'daily' | 'hourly' | 'interval', key: string }[] = [
+  { id: 'daily', key: 'parentTask.schedule.daily' },
+  { id: 'hourly', key: 'parentTask.schedule.hourly' },
+  { id: 'interval', key: 'parentTask.schedule.interval' },
+]
+
+
+const agentId = ref('')
+const macAddress = ref('')
+const taskId = ref('')
+const loading = ref(false)
+const submitting = ref('')
+const deviceOnline = ref<boolean | null>(null)
+const statusLoading = ref(false)
+let statusSeq = 0
+const scenes = ref<ParentScene[]>(FALLBACK_SCENES)
+const languagePairs = ref<ParentLanguagePair[]>([])
+const levels = ref<IdiomNamed[]>(FALLBACK_LEVELS)
+const systems = ref<IdiomNamed[]>(FALLBACK_SYSTEMS)
+const stories = ref<IdiomStory[]>([{
+  id: '001',
+  slug: 'pangu',
+  systemId: 'origin',
+  name: '寮�澶╄緹鍦�',
+  nameEn: 'Pangu splits the chaos to create the world',
+  sentenceCount: 58,
+}])
+
+const sceneId = ref('shadowing')
+const runMode = ref<'immediate' | 'scheduled'>('immediate')
+const levelId = ref('l1')
+const systemId = ref('origin')
+const lessonId = ref('l1.001')
+const enPasses = ref<IdiomEnPass[]>(DEFAULT_EN_PASSES.map(item => ({ ...item })))
+const shadowingLanguage = ref<'cn' | 'en'>('en')
+const shadowingSpeed = ref(0.8)
+const languagePair = ref('zh-en')
+const durationMinutes = ref(0)
+const onComplete = ref<'chat' | 'power_off'>('chat')
+const scheduleType = ref<'daily' | 'hourly' | 'interval'>('daily')
+const dailyHour = ref(7)
+const dailyMinute = ref(30)
+const hourlyMinute = ref(0)
+const intervalMinutes = ref(60)
+const weekdays = ref<number[]>([1, 2, 3, 4, 5, 6, 7])
+
+const isEdit = computed(() => !!taskId.value)
+const currentScene = computed(() => scenes.value.find(item => item.id === sceneId.value) || FALLBACK_SCENES[1])
+const pageTitle = computed(() => isEdit.value ? t('parentTask.editTitle') : t('parentTask.createTitle'))
+const canDispatch = computed(() => deviceOnline.value === true)
+const statusTitle = computed(() => {
+  if (statusLoading.value || deviceOnline.value === null)
+    return t('parentTask.checkingOnline')
+  return deviceOnline.value ? t('parentTask.online') : t('parentTask.offline')
+})
+const statusHint = computed(() => {
+  if (statusLoading.value || deviceOnline.value === null)
+    return ''
+  return deviceOnline.value ? t('parentTask.onlineHint') : t('parentTask.offlineHint')
+})
+const pairColumns = computed(() => languagePairs.value.map(item => ({
+  value: item.id,
+  label: item.ready ? item.name : `${item.name}锛�${t('parentTask.pairSoon')}锛塦,
+})))
+const selectedPair = computed(() => languagePairs.value.find(item => item.id === languagePair.value))
+const filteredStories = computed(() => stories.value.filter(item => item.systemId === systemId.value))
+const selectedStory = computed(() => {
+  const parts = lessonId.value.split('.')
+  const sid = parts.length >= 2 ? parts[parts.length - 1] : lessonId.value
+  return filteredStories.value.find(item => item.id === sid)
+    || stories.value.find(item => item.id === sid)
+})
+
+function composeLessonId(story?: IdiomStory, level = levelId.value) {
+  if (!story)
+    return ''
+  return `${level}.${story.id}`
+}
+
+function goBack() {
+  uni.navigateBack()
+}
+
+async function loadDeviceStatus() {
+  if (!agentId.value || !macAddress.value) {
+    deviceOnline.value = null
+    statusLoading.value = false
+    return
+  }
+  const seq = ++statusSeq
+  statusLoading.value = true
+  try {
+    const result = await getParentDeviceStatus(macAddress.value, agentId.value)
+    if (seq !== statusSeq)
+      return
+    deviceOnline.value = !!result?.online
+  }
+  catch (error) {
+    console.error('load device status failed', error)
+    if (seq !== statusSeq)
+      return
+  }
+  finally {
+    if (seq === statusSeq)
+      statusLoading.value = false
+  }
+}
+
+function normalizeScene(raw: ParentScene): ParentScene {
+  const fallback = FALLBACK_SCENES.find(item => item.id === raw.id)
+  return {
+    ...fallback,
+    ...raw,
+    name: raw.name || fallback?.name || raw.id,
+    needsLesson: !!raw.needsLesson,
+    needsEnPasses: raw.needsEnPasses ?? fallback?.needsEnPasses ?? raw.id === 'extensive',
+    needsShadowingSettings: raw.needsShadowingSettings ?? fallback?.needsShadowingSettings ?? raw.id === 'shadowing',
+    needsLanguagePair: !!raw.needsLanguagePair,
+    needsDuration: !!raw.needsDuration,
+    needsOnComplete: raw.needsOnComplete !== false,
+    appId: raw.appId || fallback?.appId || '',
+    taskId: raw.taskId || fallback?.taskId || '',
+  }
+}
+
+function selectScene(id: string) {
+  sceneId.value = id
+}
+
+function selectLevel(id: string) {
+  levelId.value = id
+  const story = selectedStory.value || filteredStories.value[0]
+  if (story)
+    lessonId.value = composeLessonId(story, id)
+}
+
+function selectSystem(id: string) {
+  systemId.value = id
+  ensureStorySelection()
+}
+
+function selectStory(story: IdiomStory) {
+  lessonId.value = composeLessonId(story)
+}
+
+function ensureStorySelection() {
+  const current = selectedStory.value
+  if (current && current.systemId === systemId.value) {
+    lessonId.value = composeLessonId(current)
+    return
+  }
+  const firstInSystem = filteredStories.value[0]
+  if (firstInSystem) {
+    lessonId.value = composeLessonId(firstInSystem)
+    return
+  }
+  const first = stories.value[0]
+  if (first) {
+    systemId.value = first.systemId
+    lessonId.value = composeLessonId(first)
+  }
+}
+
+function parseLessonId(raw?: string) {
+  const value = String(raw || '').trim()
+  if (!value) {
+    return { levelId: 'l1', systemId: 'origin', lessonId: 'l1.001' }
+  }
+  const parts = value.split('.')
+  if (parts.length === 2 && /^l[1-6]$/i.test(parts[0]) && /^\d{3}$/.test(parts[1])) {
+    const matched = stories.value.find(item => item.id === parts[1])
+    return {
+      levelId: parts[0],
+      systemId: matched?.systemId || systemId.value,
+      lessonId: `${parts[0]}.${parts[1]}`,
+    }
+  }
+  return { levelId: levelId.value, systemId: systemId.value, lessonId: value }
+}
+
+function applyCatalog(catalog: IdiomCatalog) {
+  if (catalog.levels?.length)
+    levels.value = catalog.levels
+  if (catalog.systems?.length)
+    systems.value = catalog.systems
+  if (catalog.stories?.length)
+    stories.value = catalog.stories
+  if (catalog.defaults?.enPasses?.length && !taskId.value)
+    enPasses.value = catalog.defaults.enPasses.map(item => ({ speed: Number(item.speed), repeat: Number(item.repeat) }))
+  if (catalog.defaults?.shadowingSpeed && !taskId.value)
+    shadowingSpeed.value = Number(catalog.defaults.shadowingSpeed)
+}
+
+function normalizePasses(raw: unknown): IdiomEnPass[] {
+  if (!Array.isArray(raw) || raw.length === 0)
+    return DEFAULT_EN_PASSES.map(item => ({ ...item }))
+  return raw.slice(0, 8).map((item: any) => ({
+    speed: Math.min(2, Math.max(0.5, Number(item?.speed) || 1)),
+    repeat: Math.min(10, Math.max(1, Number(item?.repeat) || 1)),
+  }))
+}
+
+function addEnPass() {
+  if (enPasses.value.length >= 8)
+    return
+  enPasses.value = [...enPasses.value, { speed: 1.0, repeat: 1 }]
+}
+
+function removeEnPass(index: number) {
+  if (enPasses.value.length <= 1)
+    return
+  enPasses.value = enPasses.value.filter((_, i) => i !== index)
+}
+
+function setPassSpeed(index: number, speed: number) {
+  enPasses.value = enPasses.value.map((item, i) => i === index ? { ...item, speed } : item)
+}
+
+function toggleWeekday(day: number) {
+  if (weekdays.value.includes(day)) {
+    if (weekdays.value.length === 1)
+      return
+    weekdays.value = weekdays.value.filter(item => item !== day)
+  }
+  else {
+    weekdays.value = [...weekdays.value, day].sort()
+  }
+}
+
+function savedDailyTime(): string {
+  const hour = Math.min(23, Math.max(0, Number(dailyHour.value) || 0))
+  const minute = Math.min(59, Math.max(0, Number(dailyMinute.value) || 0))
+  return `${pad2(hour)}:${pad2(minute)}`
+}
+
+function applyDailyTime(raw?: string) {
+  const time = normalizeTime(raw)
+  const [hour, minute] = time.split(':')
+  dailyHour.value = Number(hour)
+  dailyMinute.value = Number(minute)
+}
+
+async function loadCatalog() {
+  try {
+    const [sceneList, pairList, catalog] = await Promise.all([
+      getParentScenes(),
+      getParentLanguagePairs(),
+      getParentIdiomCatalog(),
+    ])
+    if (sceneList?.length)
+      scenes.value = sceneList.map(normalizeScene)
+    if (pairList?.length)
+      languagePairs.value = pairList
+    if (catalog?.stories?.length)
+      applyCatalog(catalog)
+  }
+  catch (error) {
+    console.error('load parent catalog failed', error)
+    languagePairs.value = [
+      { id: 'zh-en', name: '涓嫳', ready: true },
+      { id: 'zh-fr', name: '涓硶', ready: false },
+      { id: 'zh-ja', name: '涓棩', ready: false },
+    ]
+  }
+}
+
+async function refreshScenes() {
+  try {
+    const sceneList = await getParentScenes()
+    if (sceneList?.length)
+      scenes.value = sceneList.map(normalizeScene)
+  }
+  catch (error) {
+    console.error('refresh parent scenes failed', error)
+  }
+}
+
+function applyTask(task: ParentTask) {
+  sceneId.value = task.scene || 'shadowing'
+  runMode.value = task.runMode === 'scheduled' ? 'scheduled' : 'immediate'
+  const parsed = parseLessonId(task.params?.lesson_id)
+  levelId.value = parsed.levelId
+  systemId.value = parsed.systemId
+  lessonId.value = parsed.lessonId
+  enPasses.value = normalizePasses(task.params?.en_passes)
+  shadowingLanguage.value = task.params?.language === 'cn' ? 'cn' : 'en'
+  shadowingSpeed.value = Number(task.params?.speed || 0.8)
+  languagePair.value = task.params?.language_pair || 'zh-en'
+  durationMinutes.value = Number(task.params?.duration_minutes || 0)
+  onComplete.value = task.params?.on_complete === 'power_off' ? 'power_off' : 'chat'
+  const schedule = task.schedule || {}
+  scheduleType.value = schedule.type || 'daily'
+  applyDailyTime(schedule.time)
+  hourlyMinute.value = Number(schedule.minute || 0)
+  intervalMinutes.value = Number(schedule.every_minutes || 60)
+  weekdays.value = (schedule.weekdays && schedule.weekdays.length > 0)
+    ? [...schedule.weekdays]
+    : [1, 2, 3, 4, 5, 6, 7]
+}
+
+function buildParams(): ParentTaskParams {
+  const params: ParentTaskParams = {}
+  if (currentScene.value.needsLesson)
+    params.lesson_id = lessonId.value
+  if (sceneId.value === 'extensive')
+    params.en_passes = enPasses.value.map(item => ({ speed: item.speed, repeat: item.repeat }))
+  if (sceneId.value === 'shadowing') {
+    params.language = shadowingLanguage.value
+    params.speed = shadowingSpeed.value
+  }
+  if (currentScene.value.needsLanguagePair)
+    params.language_pair = languagePair.value || 'zh-en'
+  if (currentScene.value.needsDuration && Number(durationMinutes.value) > 0)
+    params.duration_minutes = Math.min(24 * 60, Math.max(1, Number(durationMinutes.value)))
+  if (currentScene.value.needsOnComplete)
+    params.on_complete = onComplete.value
+  return params
+}
+
+function buildSchedule(): ParentSchedule | undefined {
+  if (runMode.value !== 'scheduled')
+    return undefined
+  if (scheduleType.value === 'hourly') {
+    return { type: 'hourly', minute: Math.min(59, Math.max(0, Number(hourlyMinute.value) || 0)) }
+  }
+  if (scheduleType.value === 'interval') {
+    return { type: 'interval', every_minutes: Math.min(24 * 60, Math.max(1, Number(intervalMinutes.value) || 60)) }
+  }
+  const schedule: ParentSchedule = {
+    type: 'daily',
+    time: savedDailyTime(),
+  }
+  if (weekdays.value.length > 0 && weekdays.value.length < 7)
+    schedule.weekdays = [...weekdays.value]
+  return schedule
+}
+
+function validate(): boolean {
+  if (!macAddress.value || !agentId.value) {
+    toast.warning(t('parentTask.bindDeviceFirst'))
+    return false
+  }
+  if (!sceneId.value) {
+    toast.warning(t('parentTask.pickScene'))
+    return false
+  }
+  if (currentScene.value.needsLesson && !lessonId.value) {
+    toast.warning(t('parentTask.pickStory'))
+    return false
+  }
+  return true
+}
+
+async function submit(mode: 'immediate' | 'scheduled') {
+  if (submitting.value || !validate())
+    return
+  if (!canDispatch.value) {
+    toast.warning(t('parentTask.deviceOffline'))
+    return
+  }
+  runMode.value = mode
+  submitting.value = mode
+  const payload = {
+    macAddress: macAddress.value,
+    agentId: agentId.value,
+    runMode: mode,
+    scene: sceneId.value,
+    params: buildParams(),
+    schedule: mode === 'scheduled' ? buildSchedule() : undefined,
+    enabled: true,
+  }
+  try {
+    if (isEdit.value) {
+      await updateParentTask(taskId.value, payload)
+      toast.success(payload.schedule?.time ? `${t('parentTask.saved')} ${payload.schedule.time}` : t('parentTask.saved'))
+    }
+    else {
+      await createParentTask(payload)
+      toast.success(mode === 'immediate'
+        ? t('parentTask.started')
+        : (payload.schedule?.time ? `${t('parentTask.saved')} ${payload.schedule.time}` : t('parentTask.saved')))
+    }
+    setTimeout(() => uni.navigateBack(), 400)
+  }
+  catch (error: any) {
+    toast.error(parentTaskError(error, t('parentTask.saveFailed')))
+  }
+  finally {
+    submitting.value = ''
+  }
+}
+
+async function startExisting() {
+  if (!taskId.value || submitting.value)
+    return
+  if (!canDispatch.value) {
+    toast.warning(t('parentTask.deviceOffline'))
+    return
+  }
+  submitting.value = 'run'
+  try {
+    await runParentTaskNow(taskId.value)
+    toast.success(t('parentTask.started'))
+    setTimeout(() => uni.navigateBack(), 400)
+  }
+  catch (error: any) {
+    toast.error(parentTaskError(error, t('parentTask.startFailed')))
+  }
+  finally {
+    submitting.value = ''
+  }
+}
+
+watch(sceneId, () => {
+  if (currentScene.value.needsOnComplete && !onComplete.value)
+    onComplete.value = 'chat'
+})
+
+onLoad(async (options) => {
+  agentId.value = String(options?.agentId || '')
+  macAddress.value = decodeURIComponent(String(options?.macAddress || ''))
+  taskId.value = String(options?.taskId || '')
+  loading.value = true
+  loadDeviceStatus()
+  try {
+    await loadCatalog()
+    if (taskId.value) {
+      const task = await getParentTask(taskId.value)
+      applyTask(task)
+    }
+    ensureStorySelection()
+  }
+  finally {
+    loading.value = false
+  }
+})
+
+onShow(() => {
+  if (agentId.value && macAddress.value)
+    loadDeviceStatus()
+  refreshScenes()
+})
+</script>
+
+<template>
+  <view class="min-h-screen bg-[#f5f7fb]">
+    <wd-navbar :title="pageTitle" safe-area-inset-top>
+      <template #left>
+        <wd-icon name="arrow-left" size="18" @click="goBack" />
+      </template>
+    </wd-navbar>
+
+    <view v-if="loading" class="flex flex-col items-center py-[80rpx]">
+      <wd-loading color="#336cff" />
+    </view>
+
+    <view v-else class="box-border px-[24rpx] py-[20rpx] pb-[200rpx]">
+      <view
+        class="mb-[20rpx] flex items-start gap-[16rpx] rounded-[20rpx] p-[24rpx]"
+        :class="deviceOnline ? 'bg-[#e8f8ef]' : (statusLoading || deviceOnline === null ? 'bg-white' : 'bg-[#fff1f0]')"
+      >
+        <view
+          class="mt-[10rpx] h-[20rpx] w-[20rpx] shrink-0 rounded-full"
+          :class="deviceOnline ? 'bg-[#1aa34a]' : (statusLoading || deviceOnline === null ? 'bg-[#c0c4cc]' : 'bg-[#d4380d]')"
+        />
+        <view class="min-w-0 flex-1">
+          <text
+            class="block text-[34rpx] font-bold leading-[1.3]"
+            :class="deviceOnline ? 'text-[#146c36]' : (statusLoading || deviceOnline === null ? 'text-[#65686f]' : 'text-[#d4380d]')"
+          >
+            {{ statusTitle }}
+          </text>
+          <text
+            v-if="statusHint"
+            class="mt-[6rpx] block text-[24rpx] leading-[1.45]"
+            :class="deviceOnline ? 'text-[#2f6f45]' : 'text-[#a8071a]'"
+          >
+            {{ statusHint }}
+          </text>
+        </view>
+      </view>
+      <view class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.scene') }}
+        </text>
+        <view class="flex flex-wrap gap-[16rpx]">
+          <view
+            v-for="scene in scenes"
+            :key="scene.id"
+            class="rounded-[32rpx] px-[28rpx] py-[12rpx] text-[26rpx]"
+            :class="sceneId === scene.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="selectScene(scene.id)"
+          >
+            {{ scene.name }}
+          </view>
+        </view>
+        <text class="mt-[16rpx] block text-[22rpx] text-[#8a8d96]">
+          {{ t('parentTask.dispatchHint') }}
+        </text>
+        <text v-if="currentScene.hint" class="mt-[8rpx] block text-[22rpx] text-[#8a8d96]">
+          {{ currentScene.hint }}
+        </text>
+      </view>
+
+      <view v-if="currentScene.needsLesson" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.story') }}
+        </text>
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.level') }}
+        </text>
+        <view class="mb-[20rpx] flex flex-wrap gap-[12rpx]">
+          <view
+            v-for="level in levels"
+            :key="level.id"
+            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
+            :class="levelId === level.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="selectLevel(level.id)"
+          >
+            {{ level.name }}
+          </view>
+        </view>
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.system') }}
+        </text>
+        <view class="mb-[20rpx] flex flex-col gap-[12rpx]">
+          <view
+            v-for="item in systems"
+            :key="item.id"
+            class="rounded-[16rpx] px-[20rpx] py-[14rpx]"
+            :class="systemId === item.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="selectSystem(item.id)"
+          >
+            <text class="block text-[26rpx] font-medium">
+              {{ item.name }}
+            </text>
+            <text
+              class="mt-[4rpx] block text-[20rpx] leading-[1.4]"
+              :class="systemId === item.id ? 'text-white/80' : 'text-[#8a8d96]'"
+            >
+              {{ item.nameEn }}
+            </text>
+          </view>
+        </view>
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.pickStory') }}
+        </text>
+        <view v-if="filteredStories.length" class="flex flex-col gap-[12rpx]">
+          <view
+            v-for="story in filteredStories"
+            :key="story.id"
+            class="rounded-[16rpx] px-[20rpx] py-[16rpx]"
+            :class="selectedStory && selectedStory.id === story.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="selectStory(story)"
+          >
+            <text class="block text-[28rpx] font-medium">
+              {{ story.name }}
+            </text>
+            <text
+              class="mt-[6rpx] block text-[22rpx] leading-[1.4]"
+              :class="selectedStory && selectedStory.id === story.id ? 'text-white/85' : 'text-[#65686f]'"
+            >
+              {{ story.nameEn }}
+            </text>
+          </view>
+        </view>
+        <text v-else class="block text-[24rpx] text-[#fa8c16]">
+          {{ t('parentTask.noStories') }}
+        </text>
+        <text v-if="selectedStory" class="mt-[16rpx] block text-[22rpx] text-[#8a8d96]">
+          {{ t('parentTask.sentenceCount', { n: selectedStory.sentenceCount || 0 }) }}
+        </text>
+      </view>
+
+      <view v-if="currentScene.needsEnPasses" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[8rpx] block text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.enPassesTitle') }}
+        </text>
+        <text class="mb-[16rpx] block text-[22rpx] text-[#8a8d96]">
+          {{ t('parentTask.cnFixed') }}
+        </text>
+        <view
+          v-for="(pass, index) in enPasses"
+          :key="index"
+          class="mb-[16rpx] rounded-[16rpx] bg-[#f5f7fb] p-[16rpx]"
+        >
+          <view class="mb-[12rpx] flex items-center justify-between">
+            <text class="text-[24rpx] text-[#232338]">
+              {{ t('parentTask.enPassIndex', { n: index + 1 }) }}
+            </text>
+            <text
+              v-if="enPasses.length > 1"
+              class="text-[24rpx] text-[#fa5151]"
+              @click="removeEnPass(index)"
+            >
+              {{ t('parentTask.removePass') }}
+            </text>
+          </view>
+          <view class="mb-[12rpx] flex flex-wrap gap-[12rpx]">
+            <view
+              v-for="speed in SPEED_OPTIONS"
+              :key="speed"
+              class="rounded-[24rpx] px-[16rpx] py-[6rpx] text-[22rpx]"
+              :class="pass.speed === speed ? 'bg-[#336cff] text-white' : 'bg-white text-[#232338]'"
+              @click="setPassSpeed(index, speed)"
+            >
+              {{ speed.toFixed(1) }}脳
+            </view>
+          </view>
+          <view class="flex items-center gap-[12rpx]">
+            <text class="text-[24rpx] text-[#65686f]">
+              {{ t('parentTask.repeat') }}
+            </text>
+            <wd-input-number v-model="enPasses[index].repeat" :min="1" :max="10" />
+          </view>
+        </view>
+        <view
+          v-if="enPasses.length < 8"
+          class="rounded-[16rpx] border border-dashed border-[#336cff] py-[12rpx] text-center text-[24rpx] text-[#336cff]"
+          @click="addEnPass"
+        >
+          {{ t('parentTask.addEnPass') }}
+        </view>
+      </view>
+
+      <view v-if="currentScene.needsShadowingSettings" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.shadowingSettings') }}
+        </text>
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.shadowingLang') }}
+        </text>
+        <view class="mb-[20rpx] flex gap-[16rpx]">
+          <view
+            class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
+            :class="shadowingLanguage === 'cn' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="shadowingLanguage = 'cn'"
+          >
+            {{ t('parentTask.shadowingLang.cn') }}
+          </view>
+          <view
+            class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
+            :class="shadowingLanguage === 'en' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="shadowingLanguage = 'en'"
+          >
+            {{ t('parentTask.shadowingLang.en') }}
+          </view>
+        </view>
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.shadowingSpeed') }}
+        </text>
+        <view class="mb-[16rpx] flex flex-wrap gap-[12rpx]">
+          <view
+            v-for="speed in SPEED_OPTIONS"
+            :key="speed"
+            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
+            :class="shadowingSpeed === speed ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="shadowingSpeed = speed"
+          >
+            {{ speed.toFixed(1) }}脳
+          </view>
+        </view>
+        <text class="block text-[22rpx] text-[#8a8d96]">
+          {{ t('parentTask.shadowingHint', { speed: shadowingSpeed.toFixed(1) }) }}
+        </text>
+      </view>
+
+      <view v-if="currentScene.needsLanguagePair" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+          {{ t('parentTask.languagePair') }}
+        </text>
+        <wd-picker
+          v-model="languagePair"
+          :columns="pairColumns"
+          :placeholder="t('parentTask.pickPair')"
+          :cancel-button-text="t('common.cancel')"
+          :confirm-button-text="t('common.confirm')"
+        />
+        <text v-if="selectedPair && !selectedPair.ready" class="mt-[12rpx] block text-[24rpx] text-[#fa8c16]">
+          {{ t('parentTask.pairFallbackHint') }}
+        </text>
+      </view>
+
+      <view v-if="currentScene.needsDuration || currentScene.needsOnComplete" class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <view v-if="currentScene.needsDuration" class="mb-[24rpx]">
+          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.duration') }}
+          </text>
+          <view class="mb-[16rpx] flex flex-wrap gap-[12rpx]">
+            <view
+              v-for="item in [0, 10, 30]"
+              :key="item"
+              class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
+              :class="Number(durationMinutes) === item ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+              @click="durationMinutes = item"
+            >
+              {{ item === 0 ? t('parentTask.noDuration') : t('parentTask.minutesValue', { n: item }) }}
+            </view>
+          </view>
+          <wd-input-number v-model="durationMinutes" :min="0" :max="240" />
+        </view>
+        <view v-if="currentScene.needsOnComplete">
+          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.onComplete') }}
+          </text>
+          <view class="flex gap-[16rpx]">
+            <view
+              class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
+              :class="onComplete === 'chat' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+              @click="onComplete = 'chat'"
+            >
+              {{ t('parentTask.completeChat') }}
+            </view>
+            <view
+              class="flex-1 rounded-[16rpx] py-[16rpx] text-center text-[26rpx]"
+              :class="onComplete === 'power_off' ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+              @click="onComplete = 'power_off'"
+            >
+              {{ t('parentTask.completePowerOff') }}
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="mb-[20rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.schedule') }}
+        </text>
+        <view class="mb-[20rpx] flex flex-wrap gap-[12rpx]">
+          <view
+            v-for="item in SCHEDULE_TYPES"
+            :key="item.id"
+            class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
+            :class="scheduleType === item.id ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+            @click="scheduleType = item.id"
+          >
+            {{ t(item.key) }}
+          </view>
+        </view>
+
+        <view v-if="scheduleType === 'daily'">
+          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.time') }}
+          </text>
+          <view class="flex items-center gap-[16rpx]">
+            <wd-input-number v-model="dailyHour" :min="0" :max="23" />
+            <text class="text-[32rpx] text-[#232338]">:</text>
+            <wd-input-number v-model="dailyMinute" :min="0" :max="59" />
+          </view>
+          <text class="mb-[12rpx] mt-[12rpx] block text-[22rpx] text-[#8a8d96]">
+            {{ t('parentTask.timeHint') }} {{ savedDailyTime() }}
+          </text>
+          <text class="mb-[12rpx] mt-[20rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.repeatDays') }}
+          </text>
+          <view class="flex flex-wrap gap-[12rpx]">
+            <view
+              v-for="day in WEEKDAYS"
+              :key="day.value"
+              class="rounded-[24rpx] px-[20rpx] py-[8rpx] text-[24rpx]"
+              :class="weekdays.includes(day.value) ? 'bg-[#336cff] text-white' : 'bg-[#f5f7fb] text-[#232338]'"
+              @click="toggleWeekday(day.value)"
+            >
+              {{ t(`parentTask.weekday.${day.key}`) }}
+            </view>
+          </view>
+        </view>
+
+        <view v-else-if="scheduleType === 'hourly'">
+          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.hourlyMinute') }}
+          </text>
+          <wd-input-number v-model="hourlyMinute" :min="0" :max="59" />
+        </view>
+
+        <view v-else>
+          <text class="mb-[12rpx] block text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.intervalMinutes') }}
+          </text>
+          <wd-input-number v-model="intervalMinutes" :min="1" :max="1440" />
+        </view>
+      </view>
+    </view>
+
+    <view class="action-bar">
+      <wd-button
+        class="flex-1"
+        type="primary"
+        plain
+        :disabled="!canDispatch"
+        :loading="submitting === 'immediate' || submitting === 'run'"
+        @click="isEdit ? startExisting() : submit('immediate')"
+      >
+        {{ t('parentTask.startNow') }}
+      </wd-button>
+      <wd-button
+        class="flex-1"
+        type="primary"
+        :disabled="!canDispatch"
+        :loading="submitting === 'scheduled'"
+        @click="submit('scheduled')"
+      >
+        {{ t('parentTask.saveSchedule') }}
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<style scoped>
+.action-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 16rpx;
+  padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
+  background: #ffffff;
+  border-top: 1rpx solid #eeeeee;
+  z-index: 20;
+}
+
+</style>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue
new file mode 100644
index 0000000..680a93e
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history-detail.vue
@@ -0,0 +1,268 @@
+<route lang="jsonc" type="page">
+{
+  "layout": "default",
+  "style": {
+    "navigationBarTitleText": "璁板綍璇︽儏",
+    "navigationStyle": "custom"
+  }
+}
+</route>
+
+<script lang="ts" setup>
+import type { ParentHistoryChat, ParentHistoryDetail } from '@/api/parent'
+import { onLoad, onUnload } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { getAudioId } from '@/api/chat-history/chat-history'
+import { getParentHistoryDetail } from '@/api/parent'
+import { t } from '@/i18n'
+import { debounce, getEnvBaseUrl } from '@/utils'
+import { toast } from '@/utils/toast'
+import { durationMsOf, formatDuration, formatStartedAt, sourceLabel, statusLabel } from './historyText'
+
+defineOptions({
+  name: 'ParentHistoryDetail',
+})
+
+const instanceId = ref('')
+const agentId = ref('')
+const macAddress = ref('')
+const loading = ref(false)
+const detail = ref<ParentHistoryDetail | null>(null)
+const audioContext = ref<UniApp.InnerAudioContext | null>(null)
+const playingAudioId = ref<string | null>(null)
+
+const headerMeta = computed(() => {
+  if (!detail.value)
+    return ''
+  return [
+    formatStartedAt(detail.value.startedAt),
+    formatDuration(durationMsOf(detail.value), t),
+    sourceLabel(detail.value.source, t),
+  ].filter(Boolean).join(' 路 ')
+})
+
+function goBack() {
+  uni.navigateBack()
+}
+
+function parseUserMessage(content: string) {
+  try {
+    return JSON.parse(content)
+  }
+  catch {
+    return null
+  }
+}
+
+function chatText(message: ParentHistoryChat): string {
+  if (message.chatType === 1) {
+    const parsed = parseUserMessage(message.content)
+    return parsed?.content || message.content
+  }
+  return message.content
+}
+
+async function loadDetail() {
+  if (!instanceId.value || !agentId.value || !macAddress.value)
+    return
+  loading.value = true
+  try {
+    detail.value = await getParentHistoryDetail(instanceId.value, macAddress.value, agentId.value)
+  }
+  catch (error) {
+    console.error('load parent history detail failed', error)
+    toast.error(t('parentHistory.loadFailed'))
+    detail.value = null
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function destroyAudio() {
+  const ctx = audioContext.value
+  audioContext.value = null
+  playingAudioId.value = null
+  if (!ctx)
+    return
+  ctx.onEnded(() => {})
+  ctx.onError(() => {})
+  try {
+    ctx.stop()
+  }
+  catch {
+    // already stopped
+  }
+  ctx.destroy()
+}
+
+const playAudio = debounce(async (audioId: string) => {
+  if (!audioId) {
+    toast.error(t('parentHistory.noAudio'))
+    return
+  }
+  try {
+    if (playingAudioId.value === audioId) {
+      destroyAudio()
+      return
+    }
+    destroyAudio()
+    const downloadId = await getAudioId(audioId)
+    const audioUrl = `${getEnvBaseUrl()}/agent/play/${downloadId}`
+    audioContext.value = uni.createInnerAudioContext()
+    audioContext.value.src = audioUrl
+    playingAudioId.value = audioId
+    audioContext.value.onEnded(() => {
+      destroyAudio()
+    })
+    audioContext.value.onError(() => {
+      toast.error(t('chatHistory.audioPlayFailed'))
+      destroyAudio()
+    })
+    audioContext.value.play()
+  }
+  catch (error) {
+    console.error('play history audio failed', error)
+    toast.error(t('chatHistory.playAudioFailed'))
+    destroyAudio()
+  }
+}, 400)
+
+onLoad((options) => {
+  if (options?.id)
+    instanceId.value = String(options.id)
+  if (options?.agentId)
+    agentId.value = String(options.agentId)
+  if (options?.macAddress)
+    macAddress.value = decodeURIComponent(String(options.macAddress))
+  loadDetail()
+})
+
+onUnload(() => {
+  destroyAudio()
+})
+</script>
+
+<template>
+  <view class="history-detail-page">
+    <wd-navbar :title="t('parentHistory.detailTitle')" safe-area-inset-top>
+      <template #left>
+        <wd-icon name="arrow-left" size="18" @click="goBack" />
+      </template>
+    </wd-navbar>
+
+    <view v-if="loading && !detail" class="flex flex-col items-center py-[80rpx]">
+      <wd-loading color="#336cff" />
+      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
+        {{ t('common.loading') }}
+      </text>
+    </view>
+
+    <view v-else-if="detail" class="px-[20rpx] py-[16rpx] pb-[40rpx]">
+      <view class="mb-[16rpx] rounded-[20rpx] bg-white p-[28rpx]">
+        <view class="mb-[8rpx] flex items-center justify-between">
+          <text class="text-[32rpx] text-[#232338] font-semibold">
+            {{ detail.sceneName || detail.scene }}
+          </text>
+          <text
+            class="rounded-[8rpx] px-[12rpx] py-[4rpx] text-[22rpx] text-white"
+            :class="detail.status === 'completed' ? 'bg-[#2ba471]' : detail.status === 'running' ? 'bg-[#336cff]' : 'bg-[#9d9ea3]'"
+          >
+            {{ statusLabel(detail, t) }}
+          </text>
+        </view>
+        <text class="text-[26rpx] text-[#65686f]">
+          {{ headerMeta }}
+        </text>
+      </view>
+
+      <view v-if="detail.kind === 'extensive'" class="rounded-[20rpx] bg-white p-[28rpx]">
+        <text class="block text-[28rpx] leading-[1.6] text-[#65686f]">
+          {{ t('parentHistory.extensiveHint') }}
+        </text>
+      </view>
+
+      <view v-else-if="detail.kind === 'shadowing'">
+        <view v-if="detail.turns.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
+          {{ t('parentHistory.emptyTurns') }}
+        </view>
+        <view
+          v-for="turn in detail.turns"
+          :key="turn.index"
+          class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
+        >
+          <view class="mb-[12rpx] flex items-center justify-between">
+            <text class="text-[24rpx] text-[#9d9ea3]">
+              {{ t('parentHistory.sentence', { n: (turn.index ?? 0) + 1 }) }}
+            </text>
+            <view
+              v-if="turn.audioId"
+              class="text-[#336cff]"
+              @click="playAudio(turn.audioId)"
+            >
+              <wd-icon
+                :name="playingAudioId === turn.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
+                size="22"
+              />
+            </view>
+          </view>
+          <text class="mb-[8rpx] block text-[24rpx] text-[#9d9ea3]">
+            {{ t('parentHistory.expected') }}
+          </text>
+          <text class="mb-[16rpx] block text-[28rpx] text-[#232338]">
+            {{ turn.expectedText || t('parentHistory.noExpected') }}
+          </text>
+          <text class="mb-[8rpx] block text-[24rpx] text-[#9d9ea3]">
+            {{ t('parentHistory.asr') }}
+          </text>
+          <text class="block text-[28rpx] text-[#232338]">
+            {{ turn.asrText || t('parentHistory.noAsr') }}
+          </text>
+        </view>
+      </view>
+
+      <view v-else>
+        <view v-if="!detail.chats || detail.chats.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
+          {{ t('parentHistory.noChat') }}
+        </view>
+        <view v-else class="flex flex-col gap-[20rpx]">
+          <view
+            v-for="(message, index) in detail.chats"
+            :key="index"
+            class="w-full flex"
+            :class="message.chatType === 1 ? 'justify-end' : 'justify-start'"
+          >
+            <view
+              class="max-w-[80%] rounded-[20rpx] p-[24rpx]"
+              :class="message.chatType === 1 ? 'bg-[#336cff] text-white' : 'border-[1rpx] border-[#eeeeee] bg-white text-[#232338]'"
+            >
+              <view class="flex items-center gap-[12rpx]">
+                <view
+                  v-if="message.audioId"
+                  class="flex-shrink-0"
+                  :class="message.chatType === 1 ? 'text-white' : 'text-[#336cff]'"
+                  @click="playAudio(message.audioId)"
+                >
+                  <wd-icon
+                    :name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
+                    size="20"
+                  />
+                </view>
+                <text class="text-[28rpx] leading-[1.4]">
+                  {{ chatText(message) }}
+                </text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<style scoped>
+.history-detail-page {
+  min-height: 100%;
+  background: #f5f7fb;
+}
+</style>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue
new file mode 100644
index 0000000..03d6d00
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/history.vue
@@ -0,0 +1,131 @@
+<route lang="jsonc" type="page">
+{
+  "layout": "default",
+  "style": {
+    "navigationBarTitleText": "鎵ц璁板綍",
+    "navigationStyle": "custom"
+  }
+}
+</route>
+
+<script lang="ts" setup>
+import type { ParentHistoryItem } from '@/api/parent'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { getParentHistory } from '@/api/parent'
+import { t } from '@/i18n'
+import { durationMsOf, formatDuration, formatStartedAt, sourceLabel, statusLabel } from './historyText'
+
+defineOptions({
+  name: 'ParentHistoryList',
+})
+
+const agentId = ref('')
+const macAddress = ref('')
+const items = ref<ParentHistoryItem[]>([])
+const loading = ref(false)
+
+function goBack() {
+  uni.navigateBack()
+}
+
+async function loadHistory() {
+  if (!agentId.value || !macAddress.value) {
+    items.value = []
+    return
+  }
+  loading.value = true
+  try {
+    const result = await getParentHistory(macAddress.value, agentId.value, 50)
+    items.value = result?.list || []
+  }
+  catch (error) {
+    console.error('load parent history failed', error)
+    items.value = []
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function goDetail(item: ParentHistoryItem) {
+  uni.navigateTo({
+    url: `/pages/parent-task/history-detail?id=${encodeURIComponent(item.id)}&agentId=${encodeURIComponent(agentId.value)}&macAddress=${encodeURIComponent(macAddress.value)}`,
+  })
+}
+
+function itemMeta(item: ParentHistoryItem) {
+  const bits = [
+    formatStartedAt(item.startedAt),
+    formatDuration(durationMsOf(item), t),
+    sourceLabel(item.source, t),
+    item.kind === 'shadowing' && item.turnCount > 0 ? t('parentHistory.turns', { n: item.turnCount }) : '',
+  ].filter(Boolean)
+  return bits.join(' 路 ')
+}
+
+onLoad((options) => {
+  if (options?.agentId)
+    agentId.value = String(options.agentId)
+  if (options?.macAddress)
+    macAddress.value = decodeURIComponent(String(options.macAddress))
+})
+
+onShow(() => {
+  loadHistory()
+})
+</script>
+
+<template>
+  <view class="history-page">
+    <wd-navbar :title="t('parentHistory.pageTitle')" safe-area-inset-top>
+      <template #left>
+        <wd-icon name="arrow-left" size="18" @click="goBack" />
+      </template>
+    </wd-navbar>
+
+    <view v-if="loading && items.length === 0" class="flex flex-col items-center py-[80rpx]">
+      <wd-loading color="#336cff" />
+      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
+        {{ t('common.loading') }}
+      </text>
+    </view>
+
+    <view v-else-if="items.length === 0" class="px-[40rpx] py-[80rpx] text-center">
+      <text class="text-[30rpx] text-[#666]">
+        {{ t('parentHistory.empty') }}
+      </text>
+    </view>
+
+    <view v-else class="px-[20rpx] py-[16rpx]">
+      <view
+        v-for="item in items"
+        :key="item.id"
+        class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
+        @click="goDetail(item)"
+      >
+        <view class="mb-[8rpx] flex items-center justify-between">
+          <text class="text-[32rpx] text-[#232338] font-semibold">
+            {{ item.sceneName || item.scene }}
+          </text>
+          <text
+            class="rounded-[8rpx] px-[12rpx] py-[4rpx] text-[22rpx] text-white"
+            :class="item.status === 'completed' ? 'bg-[#2ba471]' : item.status === 'running' ? 'bg-[#336cff]' : 'bg-[#9d9ea3]'"
+          >
+            {{ statusLabel(item, t) }}
+          </text>
+        </view>
+        <text class="text-[26rpx] text-[#65686f]">
+          {{ itemMeta(item) }}
+        </text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<style scoped>
+.history-page {
+  min-height: 100%;
+  background: #f5f7fb;
+}
+</style>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts
new file mode 100644
index 0000000..024a551
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/historyText.ts
@@ -0,0 +1,61 @@
+import type { ParentHistoryItem } from '@/api/parent'
+
+function pad2(value: number): string {
+  return String(value).padStart(2, '0')
+}
+
+export function formatStartedAt(ms?: number | null): string {
+  const value = Number(ms || 0)
+  if (!value)
+    return ''
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime()))
+    return ''
+  return `${date.getMonth() + 1}/${date.getDate()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
+}
+
+export function durationMsOf(item: Pick<ParentHistoryItem, 'durationMs' | 'startedAt' | 'endedAt' | 'status'>): number {
+  if (item.durationMs && item.durationMs > 0)
+    return item.durationMs
+  if (item.startedAt && item.endedAt && item.endedAt > item.startedAt)
+    return item.endedAt - item.startedAt
+  if (item.status === 'running' && item.startedAt)
+    return Math.max(0, Date.now() - item.startedAt)
+  return 0
+}
+
+export function formatDuration(ms: number, t: (key: string, params?: Record<string, string | number>) => string): string {
+  if (!ms || ms < 0)
+    return ''
+  const totalSec = Math.round(ms / 1000)
+  if (totalSec < 60)
+    return t('parentHistory.durationSec', { s: totalSec })
+  const minutes = Math.floor(totalSec / 60)
+  const seconds = totalSec % 60
+  return t('parentHistory.durationMin', { m: minutes, s: seconds })
+}
+
+export function statusLabel(
+  item: Pick<ParentHistoryItem, 'status' | 'endReason'>,
+  t: (key: string) => string,
+): string {
+  if (item.status === 'running')
+    return t('parentHistory.status.running')
+  if (item.status === 'completed')
+    return t('parentHistory.status.completed')
+  if (item.endReason === 'preempted')
+    return t('parentHistory.status.preempted')
+  if (item.status === 'cancelled')
+    return t('parentHistory.status.cancelled')
+  return t('parentHistory.status.other')
+}
+
+export function sourceLabel(source: string | undefined, t: (key: string) => string): string {
+  if (source === 'mqtt_immediate')
+    return t('parentHistory.source.immediate')
+  if (source === 'schedule')
+    return t('parentHistory.source.schedule')
+  if (source === 'voice')
+    return t('parentHistory.source.voice')
+  return ''
+}
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue
new file mode 100644
index 0000000..5e8079e
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/index.vue
@@ -0,0 +1,488 @@
+<route lang="jsonc" type="page">
+{
+  "layout": "default",
+  "style": {
+    "navigationBarTitleText": "瀛︿範浠诲姟",
+    "navigationStyle": "custom"
+  }
+}
+</route>
+
+<script lang="ts" setup>
+import type { Device } from '@/api/device'
+import type { ParentTask } from '@/api/parent'
+import { onLoad, onShow } from '@dcloudio/uni-app'
+import { computed, onMounted, ref, watch } from 'vue'
+import { useMessage } from 'wot-design-uni/components/wd-message-box'
+import { getBindDevices } from '@/api/device'
+import { deleteParentTask, getParentDeviceStatus, getParentTasks, parentTaskError, runParentTaskNow, stopParentTask, updateParentTask } from '@/api/parent'
+import { t } from '@/i18n'
+import { toast } from '@/utils/toast'
+import { formatLesson, formatOnComplete, formatSchedule } from './scheduleText'
+
+defineOptions({
+  name: 'ParentTaskList',
+})
+
+interface Props {
+  agentId?: string
+  macAddress?: string
+  embedded?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  agentId: '',
+  macAddress: '',
+  embedded: false,
+})
+
+const message = useMessage()
+const pageAgentId = ref('')
+const pageMac = ref('')
+const devices = ref<Device[]>([])
+const selectedMac = ref('')
+const tasks = ref<ParentTask[]>([])
+const loading = ref(false)
+const busyId = ref('')
+const deviceOnline = ref<boolean | null>(null)
+const statusLoading = ref(false)
+let statusSeq = 0
+
+const currentAgentId = computed(() => props.agentId || pageAgentId.value)
+const canDispatch = computed(() => deviceOnline.value === true)
+
+const runningTasks = computed(() =>
+  tasks.value.filter(item => item.runMode === 'immediate' && (item.status === 'queued' || item.status === 'running')),
+)
+const scheduledTasks = computed(() => tasks.value.filter(item => item.runMode === 'scheduled'))
+
+const deviceColumns = computed(() =>
+  devices.value.map(device => ({
+    value: device.macAddress,
+    label: device.alias || device.macAddress,
+  })),
+)
+
+const statusTitle = computed(() => {
+  if (!selectedMac.value)
+    return ''
+  if (statusLoading.value || deviceOnline.value === null)
+    return t('parentTask.checkingOnline')
+  return deviceOnline.value ? t('parentTask.online') : t('parentTask.offline')
+})
+
+const statusHint = computed(() => {
+  if (!selectedMac.value || statusLoading.value || deviceOnline.value === null)
+    return ''
+  return deviceOnline.value ? t('parentTask.onlineHint') : t('parentTask.offlineHint')
+})
+
+function goBack() {
+  uni.navigateBack()
+}
+
+function deviceLabel(mac: string) {
+  const device = devices.value.find(item => item.macAddress === mac)
+  return device?.alias || mac
+}
+
+async function loadDevices() {
+  if (!currentAgentId.value)
+    return
+  devices.value = (await getBindDevices(currentAgentId.value)) || []
+  const preferred = props.macAddress || pageMac.value
+  if (preferred && devices.value.some(item => item.macAddress === preferred)) {
+    selectedMac.value = preferred
+  }
+  else if (!selectedMac.value && devices.value.length > 0) {
+    selectedMac.value = devices.value[0].macAddress
+  }
+  else if (selectedMac.value && !devices.value.some(item => item.macAddress === selectedMac.value)) {
+    selectedMac.value = devices.value[0]?.macAddress || ''
+  }
+}
+
+async function loadTasks() {
+  if (!currentAgentId.value || !selectedMac.value) {
+    tasks.value = []
+    return
+  }
+  loading.value = true
+  try {
+    const result = await getParentTasks(selectedMac.value, currentAgentId.value)
+    tasks.value = result?.list || []
+  }
+  catch (error) {
+    console.error('load parent tasks failed', error)
+    tasks.value = []
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function selectedDeviceOnline(): boolean | null {
+  const device = devices.value.find(item => item.macAddress === selectedMac.value)
+  if (!device || typeof device.online !== 'boolean')
+    return null
+  return device.online
+}
+
+async function loadDeviceStatus() {
+  if (!currentAgentId.value || !selectedMac.value) {
+    deviceOnline.value = null
+    statusLoading.value = false
+    return
+  }
+  const fromList = selectedDeviceOnline()
+  if (fromList !== null)
+    deviceOnline.value = fromList
+  const seq = ++statusSeq
+  statusLoading.value = fromList === null
+  try {
+    const result = await getParentDeviceStatus(selectedMac.value, currentAgentId.value)
+    if (seq !== statusSeq)
+      return
+    deviceOnline.value = !!result?.online
+  }
+  catch (error) {
+    console.error('load device status failed', error)
+    if (seq !== statusSeq)
+      return
+    const fallback = selectedDeviceOnline()
+    if (fallback !== null)
+      deviceOnline.value = fallback
+  }
+  finally {
+    if (seq === statusSeq)
+      statusLoading.value = false
+  }
+}
+
+async function refresh() {
+  await loadDevices()
+  await Promise.all([loadTasks(), loadDeviceStatus()])
+}
+
+function goCreate() {
+  if (!currentAgentId.value || !selectedMac.value) {
+    toast.warning(t('parentTask.bindDeviceFirst'))
+    return
+  }
+  if (!canDispatch.value) {
+    toast.warning(t('parentTask.deviceOffline'))
+    return
+  }
+  uni.navigateTo({
+    url: `/pages/parent-task/edit?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(selectedMac.value)}`,
+  })
+}
+
+function goHistory() {
+  if (!currentAgentId.value || !selectedMac.value) {
+    toast.warning(t('parentTask.bindDeviceFirst'))
+    return
+  }
+  uni.navigateTo({
+    url: `/pages/parent-task/history?agentId=${encodeURIComponent(currentAgentId.value)}&macAddress=${encodeURIComponent(selectedMac.value)}`,
+  })
+}
+
+function goEdit(task: ParentTask) {
+  uni.navigateTo({
+    url: `/pages/parent-task/edit?agentId=${encodeURIComponent(task.agentId)}&macAddress=${encodeURIComponent(task.macAddress)}&taskId=${encodeURIComponent(task.id)}`,
+  })
+}
+
+async function toggleEnabled(task: ParentTask) {
+  if (task.runMode !== 'scheduled' || busyId.value)
+    return
+  if (!canDispatch.value) {
+    toast.warning(t('parentTask.deviceOffline'))
+    return
+  }
+  busyId.value = task.id
+  try {
+    await updateParentTask(task.id, {
+      macAddress: task.macAddress,
+      agentId: task.agentId,
+      runMode: task.runMode,
+      scene: task.scene,
+      params: task.params,
+      schedule: task.schedule,
+      enabled: !task.enabled,
+    })
+    await loadTasks()
+  }
+  catch (error: any) {
+    toast.error(parentTaskError(error, t('parentTask.saveFailed')))
+  }
+  finally {
+    busyId.value = ''
+  }
+}
+
+async function runNow(task: ParentTask) {
+  if (busyId.value)
+    return
+  if (!canDispatch.value) {
+    toast.warning(t('parentTask.deviceOffline'))
+    return
+  }
+  busyId.value = task.id
+  try {
+    await runParentTaskNow(task.id)
+    toast.success(t('parentTask.started'))
+    await loadTasks()
+  }
+  catch (error: any) {
+    toast.error(parentTaskError(error, t('parentTask.startFailed')))
+  }
+  finally {
+    busyId.value = ''
+  }
+}
+
+function confirmStop(task: ParentTask) {
+  message.confirm({
+    title: t('parentTask.stopTitle'),
+    msg: t('parentTask.stopConfirm'),
+    confirmButtonText: t('parentTask.stop'),
+    cancelButtonText: t('common.cancel'),
+  }).then(async () => {
+    if (busyId.value)
+      return
+    busyId.value = task.id
+    try {
+      await stopParentTask(task.id)
+      toast.success(t('parentTask.stopped'))
+      await loadTasks()
+    }
+    catch (error: any) {
+      toast.error(parentTaskError(error, t('parentTask.stopFailed')))
+    }
+    finally {
+      busyId.value = ''
+    }
+  }).catch(() => {})
+}
+
+function confirmDelete(task: ParentTask) {
+  message.confirm({
+    title: t('parentTask.deleteTitle'),
+    msg: t('parentTask.deleteConfirm'),
+    confirmButtonText: t('common.delete'),
+    cancelButtonText: t('common.cancel'),
+  }).then(async () => {
+    busyId.value = task.id
+    try {
+      await deleteParentTask(task.id)
+      toast.success(t('parentTask.deleted'))
+      await loadTasks()
+    }
+    catch (error: any) {
+      toast.error(parentTaskError(error, t('parentTask.deleteFailed')))
+    }
+    finally {
+      busyId.value = ''
+    }
+  }).catch(() => {})
+}
+
+function taskSubtitle(task: ParentTask) {
+  const bits = [
+    formatLesson(task.params),
+    task.runMode === 'scheduled' ? formatSchedule(task.schedule, t) : t('parentTask.immediate'),
+    formatOnComplete(task.params, t),
+  ].filter(Boolean)
+  return bits.join(' 路 ')
+}
+
+onLoad((options) => {
+  if (options?.agentId)
+    pageAgentId.value = String(options.agentId)
+  if (options?.macAddress)
+    pageMac.value = decodeURIComponent(String(options.macAddress))
+})
+
+onShow(() => {
+  refresh()
+})
+
+onMounted(() => {
+  refresh()
+})
+
+watch(() => selectedMac.value, () => {
+  loadTasks()
+  loadDeviceStatus()
+})
+
+defineExpose({
+  refresh,
+})
+</script>
+
+<template>
+  <view class="parent-task-page" :class="{ embedded }">
+    <wd-navbar v-if="!embedded" :title="t('parentTask.pageTitle')" safe-area-inset-top>
+      <template #left>
+        <wd-icon name="arrow-left" size="18" @click="goBack" />
+      </template>
+      <template #right>
+        <text class="px-[8rpx] text-[28rpx] text-[#336cff]" @click="goHistory">
+          {{ t('parentHistory.entry') }}
+        </text>
+      </template>
+    </wd-navbar>
+
+    <view class="px-[20rpx] py-[16rpx]">
+      <view v-if="devices.length > 0 && selectedMac" class="mb-[16rpx] rounded-[16rpx] bg-white p-[24rpx]">
+        <view class="mb-[12rpx] flex items-center justify-between">
+          <text class="text-[26rpx] text-[#65686f]">
+            {{ t('parentTask.device') }}
+          </text>
+          <text v-if="embedded" class="text-[26rpx] text-[#336cff]" @click="goHistory">
+            {{ t('parentHistory.entry') }}
+          </text>
+        </view>
+        <wd-picker
+          v-if="devices.length > 1"
+          v-model="selectedMac"
+          :columns="deviceColumns"
+          :placeholder="t('parentTask.selectDevice')"
+          :cancel-button-text="t('common.cancel')"
+          :confirm-button-text="t('common.confirm')"
+        />
+        <text v-else class="mb-[8rpx] block text-[32rpx] text-[#232338] font-semibold">
+          {{ deviceLabel(selectedMac) }}
+        </text>
+        <view
+          class="mt-[16rpx] flex items-start gap-[16rpx] rounded-[16rpx] p-[20rpx]"
+          :class="deviceOnline ? 'bg-[#e8f8ef]' : (statusLoading || deviceOnline === null ? 'bg-[#f5f7fb]' : 'bg-[#fff1f0]')"
+        >
+          <view
+            class="mt-[10rpx] h-[20rpx] w-[20rpx] shrink-0 rounded-full"
+            :class="deviceOnline ? 'bg-[#1aa34a]' : (statusLoading || deviceOnline === null ? 'bg-[#c0c4cc]' : 'bg-[#d4380d]')"
+          />
+          <view class="min-w-0 flex-1">
+            <text
+              class="block text-[34rpx] font-bold leading-[1.3]"
+              :class="deviceOnline ? 'text-[#146c36]' : (statusLoading || deviceOnline === null ? 'text-[#65686f]' : 'text-[#d4380d]')"
+            >
+              {{ statusTitle }}
+            </text>
+            <text
+              v-if="statusHint"
+              class="mt-[6rpx] block text-[24rpx] leading-[1.45]"
+              :class="deviceOnline ? 'text-[#2f6f45]' : 'text-[#a8071a]'"
+            >
+              {{ statusHint }}
+            </text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loading && tasks.length === 0" class="flex flex-col items-center py-[80rpx]">
+      <wd-loading color="#336cff" />
+      <text class="mt-[16rpx] text-[28rpx] text-[#666]">
+        {{ t('common.loading') }}
+      </text>
+    </view>
+
+    <view v-else-if="devices.length === 0" class="px-[40rpx] py-[80rpx] text-center">
+      <text class="text-[30rpx] text-[#666]">
+        {{ t('parentTask.bindDeviceFirst') }}
+      </text>
+    </view>
+
+    <view v-else class="px-[20rpx] pb-[160rpx]">
+      <view v-if="runningTasks.length > 0" class="mb-[24rpx]">
+        <text class="mb-[12rpx] block px-[8rpx] text-[28rpx] text-[#232338] font-semibold">
+          {{ t('parentTask.runningSection') }}
+        </text>
+        <view
+          v-for="task in runningTasks"
+          :key="task.id"
+          class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#d6e4ff] bg-white p-[28rpx]"
+        >
+          <view class="mb-[8rpx] flex items-center justify-between">
+            <text class="text-[32rpx] text-[#232338] font-semibold">
+              {{ task.sceneName || task.scene }}
+            </text>
+            <text class="rounded-[8rpx] bg-[#336cff] px-[12rpx] py-[4rpx] text-[22rpx] text-white">
+              {{ t('parentTask.running') }}
+            </text>
+          </view>
+          <text class="mb-[16rpx] block text-[26rpx] text-[#65686f]">
+            {{ taskSubtitle(task) }}
+          </text>
+          <wd-button size="small" type="error" plain :loading="busyId === task.id" @click="confirmStop(task)">
+            {{ t('parentTask.stop') }}
+          </wd-button>
+        </view>
+      </view>
+
+      <text class="mb-[12rpx] block px-[8rpx] text-[28rpx] text-[#232338] font-semibold">
+        {{ t('parentTask.scheduledSection') }}
+      </text>
+      <view v-if="scheduledTasks.length === 0" class="rounded-[20rpx] bg-white p-[48rpx] text-center text-[26rpx] text-[#999]">
+        {{ t('parentTask.emptySchedule') }}
+      </view>
+      <view
+        v-for="task in scheduledTasks"
+        :key="task.id"
+        class="mb-[16rpx] rounded-[20rpx] border-[1rpx] border-[#eeeeee] bg-white p-[28rpx]"
+        @click="goEdit(task)"
+      >
+        <view class="mb-[8rpx] flex items-center justify-between">
+          <text class="text-[32rpx] text-[#232338] font-semibold">
+            {{ task.sceneName || task.scene }}
+          </text>
+          <wd-switch
+            :model-value="task.enabled"
+            size="22"
+            :disabled="busyId === task.id || !canDispatch"
+            @click.stop
+            @change="toggleEnabled(task)"
+          />
+        </view>
+        <text class="mb-[16rpx] block text-[26rpx] text-[#65686f]">
+          {{ taskSubtitle(task) }}
+        </text>
+        <view class="flex gap-[16rpx]" @click.stop>
+          <wd-button size="small" type="primary" plain :disabled="!canDispatch" :loading="busyId === task.id" @click="runNow(task)">
+            {{ t('parentTask.startNow') }}
+          </wd-button>
+          <wd-button size="small" type="error" plain :loading="busyId === task.id" @click="confirmDelete(task)">
+            {{ t('common.delete') }}
+          </wd-button>
+        </view>
+      </view>
+    </view>
+
+    <wd-fab
+      v-if="devices.length > 0"
+      type="primary"
+      size="small"
+      icon="add"
+      :disabled="!canDispatch"
+      :draggable="true"
+      :expandable="false"
+      @click="goCreate"
+    />
+    <wd-message-box />
+  </view>
+</template>
+
+<style scoped>
+.parent-task-page {
+  min-height: 100%;
+  background: #f5f7fb;
+}
+
+.parent-task-page.embedded {
+  min-height: auto;
+}
+</style>
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts
new file mode 100644
index 0000000..bf6fea5
--- /dev/null
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/pages/parent-task/scheduleText.ts
@@ -0,0 +1,55 @@
+import type { ParentSchedule, ParentTaskParams } from '@/api/parent/types'
+
+const WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
+
+export function pad2(value: number): string {
+  return String(value).padStart(2, '0')
+}
+
+export function normalizeTime(raw?: string): string {
+  const text = String(raw || '').trim()
+  const match = text.match(/^(\d{1,2}):(\d{1,2})/)
+  if (!match)
+    return '07:30'
+  const hour = Math.min(23, Math.max(0, Number(match[1])))
+  const minute = Math.min(59, Math.max(0, Number(match[2])))
+  return `${pad2(hour)}:${pad2(minute)}`
+}
+
+export function weekdayLabel(days: number[] | undefined, t: (key: string) => string): string {
+  const unique = [...new Set((days || []).filter(day => day >= 1 && day <= 7))].sort()
+  if (unique.length === 0 || unique.length === 7)
+    return t('parentTask.everyDay')
+  if (unique.join(',') === '1,2,3,4,5')
+    return t('parentTask.weekdays')
+  return unique.map(day => t(`parentTask.weekday.${WEEKDAY_KEYS[day - 1]}`)).join('銆�')
+}
+
+export function formatSchedule(schedule: ParentSchedule | undefined, t: (key: string) => string): string {
+  if (!schedule?.type)
+    return ''
+  if (schedule.type === 'hourly') {
+    const minute = Number(schedule.minute || 0)
+    return minute === 0
+      ? t('parentTask.hourlyOnTheHour')
+      : t('parentTask.hourlyAt', { minute: pad2(minute) })
+  }
+  if (schedule.type === 'interval') {
+    return t('parentTask.everyMinutes', { minutes: schedule.every_minutes || 60 })
+  }
+  return `${weekdayLabel(schedule.weekdays, t)} ${normalizeTime(schedule.time)}`
+}
+
+export function formatOnComplete(params: ParentTaskParams | undefined, t: (key: string) => string): string {
+  if (params?.on_complete === 'power_off')
+    return t('parentTask.completePowerOff')
+  return t('parentTask.completeChat')
+}
+
+export function formatLesson(params: ParentTaskParams | undefined): string {
+  if (params?.lesson_name && params?.lesson_name_en)
+    return `${params.lesson_name} / ${params.lesson_name_en}`
+  if (params?.lesson_name)
+    return params.lesson_name
+  return params?.lesson_id || ''
+}
diff --git a/xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts b/xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts
index dbc3f8d..2dbf94d 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/src/utils/index.ts
@@ -1,7 +1,7 @@
 import smCrypto from 'sm-crypto'
 import { pages, subPackages } from '@/pages.json'
 
-import { isMpWeixin } from './platform'
+import { isH5, isMpWeixin } from './platform'
 
 /**
  * 杩愯鏃舵湇鍔$鍦板潃瑕嗙洊瀛樺偍閿�
@@ -136,6 +136,11 @@
   if (override)
     return override
 
+  // H5 寮�鍙戣蛋 Vite 浠g悊锛屼笌 manager-web 涓�鏍锋墦鍒版湰鏈� 8002锛岃�屼笉鏄� env 閲岀殑澶栫綉鍦板潃
+  if (isH5 && import.meta.env.DEV && import.meta.env.VITE_APP_PROXY === 'true') {
+    return import.meta.env.VITE_APP_PROXY_PREFIX || '/xiaozhi'
+  }
+
   // 璇锋眰鍩哄噯鍦板潃锛堥粯璁ゆ潵婧愪簬 env锛�
   let baseUrl = import.meta.env.VITE_SERVER_BASEURL
 
diff --git a/xiaozhi-esp32-server/main/manager-mobile/vite.config.ts b/xiaozhi-esp32-server/main/manager-mobile/vite.config.ts
index 5175427..5e69743 100644
--- a/xiaozhi-esp32-server/main/manager-mobile/vite.config.ts
+++ b/xiaozhi-esp32-server/main/manager-mobile/vite.config.ts
@@ -159,9 +159,9 @@
       proxy: JSON.parse(VITE_APP_PROXY)
         ? {
             [VITE_APP_PROXY_PREFIX]: {
-              target: VITE_SERVER_BASEURL,
+              // 淇濈暀 /xiaozhi context-path锛屼笌 manager-web 浠g悊琛屼负涓�鑷�
+              target: VITE_SERVER_BASEURL.replace(/\/xiaozhi\/?$/, '') || 'http://127.0.0.1:8002',
               changeOrigin: true,
-              rewrite: path => path.replace(new RegExp(`^${VITE_APP_PROXY_PREFIX}`), ''),
             },
           }
         : undefined,
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/config.yaml b/xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
index 6487bee..1092f08 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/config.yaml
@@ -25,6 +25,10 @@
   # 鎵�浠ュ鏋滀綘浣跨敤docker閮ㄧ讲鏃讹紝灏唙ision_explain璁剧疆鎴愬眬鍩熺綉鍦板潃
   # 濡傛灉浣犱娇鐢ㄥ叕缃戦儴缃叉椂锛屽皢vision_explain璁剧疆鎴愬叕缃戝湴鍧�
   vision_explain: http://浣犵殑ip鎴栬�呭煙鍚�:绔彛鍙�/mcp/vision/explain
+  # 璁惧 DOWNLOAD 璇炬枃 MP3 鐨勫叕缃戞牴銆傚唴缃戜粛鐢� xiaozhi-server http_port(8003) 鎻愪緵
+  # /lesson-audio/{org_id}/english_learning/... 锛汣addy 鐢ㄧ嫭绔� path 鍙嶄唬鍒� 8003锛屼笉鏂板紑绔彛
+  # 纾佺洏锛歞ata/lesson_audio/{org_id}/... 涓� SD 鐩稿璺緞涓�鑷达紱绌烘満鏋勭敤 default_org
+  lesson_audio_public_url: https://xiaozhi.illegalfm.com/lesson-audio
   # OTA杩斿洖淇℃伅鏃跺尯鍋忕Щ閲�
   timezone_offset: +8
   # 璁よ瘉閰嶇疆
@@ -78,6 +82,9 @@
 proactive_chat_greeting: 鎴戜滑鏉ヨ亰澶╁惂銆�
 # 璺熻妯″紡榛樿鍙ュ瓙锛屽彲鎸夎绋嬫浛鎹�
 app_runtime:
+  # 绌哄垯浠庡钩鍙板叕鍏卞尯 default_org 鍙栬鏂囷紱鍚庣画澶氭満鏋勫~鍚勮嚜 org id
+  lesson_org_id: ""
+  lesson_sync_timeout_sec: 90
   shadowing_sentences:
     - "Hello, how are you today?"
     - "I like to read English books."
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py b/xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py
index 002c5cb..2efa1df 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/config/manage_api_client.py
@@ -284,6 +284,36 @@
         return None
 
 
+async def close_parent_immediate_tasks(payload: Dict) -> Optional[Dict]:
+    """鍏抽棴瀹堕暱鍗虫椂浠诲姟銆傚け璐ヨ繑鍥� None锛屼笉鎶涚粰璇煶鐑矾寰勩�傛湭閰嶇疆绠$悊绔涓烘垚鍔熴��"""
+    if not payload or not ManageApiClient._instance:
+        return {"skipped": True}
+    try:
+        return await ManageApiClient._instance._async_request(
+            "POST",
+            "/task/instance/close-parent-immediate",
+            json=payload,
+        )
+    except Exception as e:
+        print(f"鍏抽棴瀹堕暱鍗虫椂浠诲姟澶辫触: {e}")
+        return None
+
+
+async def sync_lesson_audio(payload: Dict) -> Optional[Dict]:
+    """璇� manager-api 鍚戣澶囧彂 UPDATE_MANIFEST銆傚け璐ヨ繑鍥� None锛屼笉鎶涚粰鐑矾寰勩��"""
+    if not payload or not ManageApiClient._instance:
+        return {"skipped": True}
+    try:
+        return await ManageApiClient._instance._async_request(
+            "POST",
+            "/task/lesson-sync",
+            json=payload,
+        )
+    except Exception as e:
+        print(f"璇炬枃鍚屾涓嬪彂澶辫触: {e}")
+        return None
+
+
 def init_service(config):
     ManageApiClient(config)
 
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py
new file mode 100644
index 0000000..b375f19
--- /dev/null
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/api/lesson_audio_handler.py
@@ -0,0 +1,64 @@
+"""Serve org-scoped lesson MP3s for device DOWNLOAD / UPDATE_MANIFEST."""
+
+from __future__ import annotations
+
+import re
+
+from aiohttp import web
+
+from core.api.base_handler import BaseHandler
+from core.app_runtime.lesson_audio import (
+    DEFAULT_ORG_ID,
+    ensure_org_dirs,
+    lesson_audio_public_root,
+    list_lesson_clips,
+    org_audio_dir,
+    resolve_disk_file,
+)
+
+_LEVEL_RE = re.compile(r"^l[1-6]$", re.I)
+_STORY_RE = re.compile(r"^\d{3}$")
+
+TAG = __name__
+
+
+class LessonAudioHandler(BaseHandler):
+    def __init__(self, config: dict):
+        super().__init__(config)
+        ensure_org_dirs(config)
+
+    async def handle_index(self, request: web.Request) -> web.StreamResponse:
+        org_id = request.match_info.get("org_id") or DEFAULT_ORG_ID
+        kind = str(request.match_info.get("kind") or "").strip().lower()
+        level = str(request.match_info.get("level") or "").strip().lower()
+        story = str(request.match_info.get("story") or "").strip()
+        lang = str(request.match_info.get("lang") or "").strip().lower()
+        if (
+            kind not in ("repeater", "shadowing")
+            or lang not in ("cn", "en")
+            or not _LEVEL_RE.fullmatch(level)
+            or not _STORY_RE.fullmatch(story)
+        ):
+            return web.json_response({"present": False, "files": []}, status=400)
+        files = list_lesson_clips(self.config, org_id, kind, f"{level}.{story}", lang)
+        return web.json_response({"present": files is not None, "files": files or []})
+
+    async def handle_get(self, request: web.Request) -> web.StreamResponse:
+        org_id = request.match_info.get("org_id") or DEFAULT_ORG_ID
+        rel = request.match_info.get("path") or ""
+        disk = resolve_disk_file(self.config, org_id, rel)
+        if not disk:
+            return web.Response(status=404, text="lesson audio not found")
+        return web.FileResponse(path=disk, headers={"Content-Type": "audio/mpeg"})
+
+    def public_root(self) -> str:
+        return lesson_audio_public_root(self.config)
+
+    def log_ready(self) -> None:
+        ensure_org_dirs(self.config)
+        self.logger.bind(tag=TAG).info(
+            f"璇炬枃闊抽 HTTP: {self.public_root()}/{{org_id}}/english_learning/..."
+        )
+        self.logger.bind(tag=TAG).info(
+            f"璇炬枃闊抽鐩綍: {org_audio_dir(self.config, DEFAULT_ORG_ID)} 锛堢┖ org 鐢� {DEFAULT_ORG_ID}锛�"
+        )
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
index f69ad89..425f303 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/apps.json
@@ -1,7 +1,17 @@
 {
   "default_app_id": "chat",
-  "voice_menu_phrases": ["鍒囨崲妯″紡", "妯″紡鍒囨崲", "qie huan mo shi", "mo shi qie huan"],
-  "reserved_voice_enter": ["鏁板瓧鑳岃", "鎴愯鎺ラ緳", "鍙g畻", "鍔犲噺娉�"],
+  "voice_menu_phrases": [
+    "鍒囨崲妯″紡",
+    "妯″紡鍒囨崲",
+    "qie huan mo shi",
+    "mo shi qie huan"
+  ],
+  "reserved_voice_enter": [
+    "鏁板瓧鑳岃",
+    "鎴愯鎺ラ緳",
+    "鍙g畻",
+    "鍔犲噺娉�"
+  ],
   "apps": {
     "chat": {
       "name": "鑱婂ぉ",
@@ -53,42 +63,72 @@
       "system_prompt_mode": "none",
       "tasks": {
         "repeater": {
-          "display_name": "澶嶈鏈烘ā寮�",
-          "menu_name": "鍚璇绘満",
-          "enter_prompt": "鐜板湪宸茬粡鍒囨崲鑷冲璇绘満妯″紡锛屽彧寰幆鎾斁闊抽銆�",
-          "voice_enter": ["寮�濮嬪璇�", "鎵撳紑澶嶈", "鑻辫瀛︿範", "鍚璇�", "澶嶈鏈�"],
-          "voice_exit": ["閫�鍑哄璇�", "缁撴潫澶嶈", "鍋滄澶嶈", "鍏虫帀澶嶈", "閫�鍑哄璇绘満"],
+          "display_name": "娉涘惉妯″紡",
+          "menu_name": "娉涘惉",
+          "enter_prompt": "鐜板湪宸茬粡鍒囨崲鑷虫硾鍚ā寮忥紝鍙惊鐜挱鏀鹃煶棰戙��",
+          "voice_enter": [
+            "寮�濮嬫硾鍚�",
+            "鎵撳紑娉涘惉",
+            "鑻辫瀛︿範",
+            "娉涘惉妯″紡",
+            "娉涘惉"
+          ],
+          "voice_exit": [
+            "閫�鍑烘硾鍚�",
+            "缁撴潫娉涘惉",
+            "鍋滄娉涘惉",
+            "鍏虫帀娉涘惉"
+          ],
           "interaction_mode": "PLAY_AUDIO",
           "skills": [
             {
               "name": "self.mp3.play",
-              "args_from": ["name", "repeat", "power_off_after", "speed"]
+              "args_from": [
+                "name",
+                "repeat",
+                "power_off_after",
+                "speed"
+              ]
             }
           ],
           "playback": {
             "speed": 1.0,
-            "daily_speeds": [0.8, 0.9, 1.0, 1.05, 1.1, 1.15, 1.2],
-            "repeat_speeds": [0.8, 0.9, 1.0, 1.1, 1.2]
-          },
-          "default_playlist": [
-            {
-              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
-              "repeat": 10,
-              "speed": 1.0
-            },
-            {
-              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
-              "repeat": 5,
-              "speed": 1.0
-            }
-          ]
+            "daily_speeds": [
+              0.8,
+              0.9,
+              1.0,
+              1.05,
+              1.1,
+              1.15,
+              1.2
+            ],
+            "repeat_speeds": [
+              0.8,
+              0.9,
+              1.0,
+              1.1,
+              1.2
+            ]
+          }
         },
         "shadowing": {
           "display_name": "璺熻妯″紡",
           "menu_name": "璺熻",
           "enter_prompt": "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忥紝璇疯窡鐫�闊抽璇汇��",
-          "voice_enter": ["寮�濮嬭窡璇�", "鎵撳紑璺熻", "璺熻妯″紡", "鎴戣璺熻", "璺熻", "寮�濮嬪惉璇�", "鎵撳紑鍚", "鍚妯″紡", "鍚"],
-          "voice_exit": ["閫�鍑鸿窡璇�", "缁撴潫璺熻", "鍋滄璺熻", "鍏虫帀璺熻", "涓嶆兂璺熻", "閫�鍑哄惉璇�", "缁撴潫鍚"],
+          "voice_enter": [
+            "寮�濮嬭窡璇�",
+            "鎵撳紑璺熻",
+            "璺熻妯″紡",
+            "鎴戣璺熻",
+            "璺熻"
+          ],
+          "voice_exit": [
+            "閫�鍑鸿窡璇�",
+            "缁撴潫璺熻",
+            "鍋滄璺熻",
+            "鍏虫帀璺熻",
+            "涓嶆兂璺熻"
+          ],
           "interaction_mode": "SHADOWING",
           "listen_ratio": 1.2,
           "playback": {
@@ -96,23 +136,13 @@
           },
           "assessment": {
             "enabled": true,
-            "methods": ["text_similarity_v0"],
+            "methods": [
+              "text_similarity_v0"
+            ],
             "defer": true,
             "endpoint": ""
           },
           "skills": [],
-          "default_playlist": [
-            {
-              "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
-              "repeat": 5,
-              "speed": 1.0
-            },
-            {
-              "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
-              "repeat": 10,
-              "speed": 1.0
-            }
-          ],
           "sentences": [
             "Hello, how are you today?",
             "I like to read English books.",
@@ -129,15 +159,30 @@
       "mode": "TRANSLATION",
       "default_task": "live_translate",
       "tool_policy": "allowlist",
-      "tools": ["switch_app", "translate"],
+      "tools": [
+        "switch_app",
+        "translate"
+      ],
       "system_prompt_mode": "translator",
       "tasks": {
         "live_translate": {
           "display_name": "缈昏瘧妯″紡",
           "menu_name": "缈昏瘧",
           "enter_prompt": "鐜板湪宸茬粡鍒囨崲鑷崇炕璇戞ā寮忥紝鑷姩杩涜涓嫳浜掕瘧銆�",
-          "voice_enter": ["鎵撳紑缈昏瘧", "寮�濮嬬炕璇�", "鑻辫缈昏瘧", "缈昏瘧妯″紡", "缈昏瘧"],
-          "voice_exit": ["閫�鍑虹炕璇�", "缁撴潫缈昏瘧", "鍋滄缈昏瘧", "鍏虫帀缈昏瘧", "涓嶆兂缈昏瘧"],
+          "voice_enter": [
+            "鎵撳紑缈昏瘧",
+            "寮�濮嬬炕璇�",
+            "鑻辫缈昏瘧",
+            "缈昏瘧妯″紡",
+            "缈昏瘧"
+          ],
+          "voice_exit": [
+            "閫�鍑虹炕璇�",
+            "缁撴潫缈昏瘧",
+            "鍋滄缈昏瘧",
+            "鍏虫帀缈昏瘧",
+            "涓嶆兂缈昏瘧"
+          ],
           "interaction_mode": "TRANSLATION",
           "skills": [
             {
@@ -154,15 +199,24 @@
       "mode": "BILINGUAL",
       "default_task": "bilingual_chat",
       "tool_policy": "inherit_agent",
-      "tools": ["switch_app"],
+      "tools": [
+        "switch_app"
+      ],
       "system_prompt_mode": "bilingual",
       "tasks": {
         "bilingual_chat": {
           "display_name": "涓嫳瀵硅瘽妯″紡",
           "menu_name": "涓嫳瀵硅瘽",
           "enter_prompt": "鐜板湪宸茬粡鍒囨崲鑷充腑鑻卞璇濇ā寮忋�備綘璇翠腑鏂囷紝鎴戠敤鑻辨枃鍥炵瓟锛涗綘璇磋嫳鏂囷紝鎴戠敤涓枃鍥炵瓟銆�",
-          "voice_enter": ["鎵撳紑涓嫳瀵硅瘽", "涓嫳瀵硅瘽妯″紡", "涓嫳瀵硅瘽"],
-          "voice_exit": ["閫�鍑轰腑鑻卞璇�", "缁撴潫涓嫳瀵硅瘽"],
+          "voice_enter": [
+            "鎵撳紑涓嫳瀵硅瘽",
+            "涓嫳瀵硅瘽妯″紡",
+            "涓嫳瀵硅瘽"
+          ],
+          "voice_exit": [
+            "閫�鍑轰腑鑻卞璇�",
+            "缁撴潫涓嫳瀵硅瘽"
+          ],
           "interaction_mode": "BILINGUAL",
           "skills": []
         }
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py
index 6eea494..8a6a7fb 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/assessment.py
@@ -127,6 +127,7 @@
     step_index: int,
     wav_bytes: bytes,
     conn: Any = None,
+    text: Optional[str] = None,
 ) -> Optional[str]:
     """Persist the follow-read utterance. Returns relative audio_id or None."""
     if not wav_bytes:
@@ -135,13 +136,14 @@
     if conn is not None:
         from core.audio_archive import archive_wav
 
-        archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra)
+        archived = archive_wav(conn, wav_bytes, kind="asr", extra=extra, text=text)
         if archived:
             return archived
     if not session_id:
         return None
     try:
         from config.config_loader import get_project_dir
+        from core.audio_archive import write_sidecar_text
 
         directory = os.path.join(get_project_dir(), "data", "task_sessions", "audio")
         os.makedirs(directory, exist_ok=True)
@@ -149,6 +151,8 @@
         path = os.path.join(directory, filename)
         with open(path, "wb") as handle:
             handle.write(wav_bytes)
+        if text is not None:
+            write_sidecar_text(path, text)
         return filename
     except Exception:
         return None
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json
new file mode 100644
index 0000000..26c210a
--- /dev/null
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom-lessons.json
@@ -0,0 +1,792 @@
+{
+  "_comment": "澧炲垹鎴愯鍙敼 stories銆俰d 涓轰笁浣嶅簭鍙�(001璧凤紝鍒犻櫎鍚庡嬁澶嶇敤)銆俿ystemId 浠呬緵瀹堕暱绔垎绫伙紝涓嶈繘鏂囦欢璺緞銆俵essonId=绾у埆.搴忓彿锛屼緥濡� l1.001銆傜鐩�/HTTP: data/lesson_audio/{org}/english_learning/... 锛汼D: /english_learning/...",
+  "version": 3,
+  "defaults": {
+    "cnRepeat": 1,
+    "cnSpeed": 1.0,
+    "enPasses": [
+      {
+        "speed": 0.8,
+        "repeat": 1
+      },
+      {
+        "speed": 0.9,
+        "repeat": 2
+      },
+      {
+        "speed": 1.0,
+        "repeat": 5
+      }
+    ],
+    "shadowingLanguage": "en",
+    "shadowingSpeed": 0.8,
+    "listenRatio": 1.2,
+    "sentenceCount": 30
+  },
+  "pathPatterns": {
+    "repeater": "/english_learning/repeater/lessons/{level}/{story}/{lang}/{nn}.mp3",
+    "repeaterCn": "/english_learning/repeater/lessons/{level}/{story}/cn/01.mp3",
+    "repeaterEn": "/english_learning/repeater/lessons/{level}/{story}/en/01.mp3",
+    "shadowing": "/english_learning/shadowing/lessons/{level}/{story}/{lang}/{nn}.mp3"
+  },
+  "levels": [
+    {
+      "id": "l1",
+      "name": "涓�绾�",
+      "nameEn": "Level 1"
+    },
+    {
+      "id": "l2",
+      "name": "浜岀骇",
+      "nameEn": "Level 2"
+    },
+    {
+      "id": "l3",
+      "name": "涓夌骇",
+      "nameEn": "Level 3"
+    },
+    {
+      "id": "l4",
+      "name": "鍥涚骇",
+      "nameEn": "Level 4"
+    },
+    {
+      "id": "l5",
+      "name": "浜旂骇",
+      "nameEn": "Level 5"
+    },
+    {
+      "id": "l6",
+      "name": "鍏骇",
+      "nameEn": "Level 6"
+    }
+  ],
+  "systems": [
+    {
+      "id": "origin",
+      "name": "璧锋簮涓庡瓨鍦ㄧ悊瑙�",
+      "nameEn": "Understanding Origins and Existence"
+    },
+    {
+      "id": "growth",
+      "name": "瀛︿範涓庢垚闀挎満鍒�",
+      "nameEn": "Learning & Growth Mechanisms"
+    },
+    {
+      "id": "language",
+      "name": "璇█涓庢剰涔夊缓鏋�",
+      "nameEn": "Construction of Language & Meaning"
+    },
+    {
+      "id": "cognition",
+      "name": "璁ょ煡涓庢�濈淮鍋忓樊",
+      "nameEn": "Cognitive & Thinking Biases"
+    },
+    {
+      "id": "values",
+      "name": "浠峰�间笌琛屼负閫夋嫨",
+      "nameEn": "Values & Behavioral Choices"
+    },
+    {
+      "id": "society",
+      "name": "绀句細浜掑姩涓庣粨鏋勫叧绯�",
+      "nameEn": "Social Interaction & Structural Relationships"
+    },
+    {
+      "id": "destiny",
+      "name": "鍛借繍涓庤嚜鎴戣秴瓒�",
+      "nameEn": "Destiny & Self-Transcendence"
+    }
+  ],
+  "stories": [
+    {
+      "id": "001",
+      "slug": "pangu",
+      "systemId": "origin",
+      "name": "寮�澶╄緹鍦�",
+      "nameEn": "Pangu splits the chaos to create the world",
+      "enabled": true,
+      "sentenceCount": 58
+    },
+    {
+      "id": "002",
+      "slug": "nvwa",
+      "systemId": "origin",
+      "name": "濂冲ú閫犱汉",
+      "nameEn": "Nuwa makes humans out of clay",
+      "enabled": true
+    },
+    {
+      "id": "003",
+      "slug": "houyi",
+      "systemId": "origin",
+      "name": "鍚庣究灏勬棩",
+      "nameEn": "Houyi shoots down nine extra suns",
+      "enabled": true
+    },
+    {
+      "id": "004",
+      "slug": "fuxi",
+      "systemId": "origin",
+      "name": "浼忕静缁撶綉鐢诲崷",
+      "nameEn": "Fuxi invents nets and the Eight Trigrams",
+      "enabled": true
+    },
+    {
+      "id": "005",
+      "slug": "shennong",
+      "systemId": "origin",
+      "name": "绁炲啘灏濈櫨鑽�",
+      "nameEn": "Shennong tastes herbs to find medicine",
+      "enabled": true
+    },
+    {
+      "id": "006",
+      "slug": "huangdi",
+      "systemId": "origin",
+      "name": "榛勫笣鍚堣绔嬪浗",
+      "nameEn": "Huangdi unites tribes to form a nation",
+      "enabled": true
+    },
+    {
+      "id": "007",
+      "slug": "dayu",
+      "systemId": "origin",
+      "name": "澶х娌绘按",
+      "nameEn": "Great Yu tames the giant floods",
+      "enabled": true
+    },
+    {
+      "id": "008",
+      "slug": "zaobi",
+      "systemId": "growth",
+      "name": "鍑垮鍊熷厜",
+      "nameEn": "Digging a hole in the wall to steal light for reading",
+      "enabled": true
+    },
+    {
+      "id": "009",
+      "slug": "nangying",
+      "systemId": "growth",
+      "name": "鍥婅悿鏄犻洩",
+      "nameEn": "Catching fireflies or using snow-light to study",
+      "enabled": true
+    },
+    {
+      "id": "010",
+      "slug": "mengmu",
+      "systemId": "growth",
+      "name": "瀛熸瘝涓夎縼",
+      "nameEn": "A mother moves three times to find a better school",
+      "enabled": true
+    },
+    {
+      "id": "011",
+      "slug": "duanzhi",
+      "systemId": "growth",
+      "name": "鏂粐鍔濆",
+      "nameEn": "Cutting the weaving cloth to show the harm of quitting",
+      "enabled": true
+    },
+    {
+      "id": "012",
+      "slug": "xuanliang",
+      "systemId": "growth",
+      "name": "鎮鍒鸿偂",
+      "nameEn": "Tying hair to the ceiling and poking the leg to stay awake",
+      "enabled": true
+    },
+    {
+      "id": "013",
+      "slug": "chengmen",
+      "systemId": "growth",
+      "name": "绋嬮棬绔嬮洩",
+      "nameEn": "Standing in the snow to show respect for the teacher",
+      "enabled": true
+    },
+    {
+      "id": "014",
+      "slug": "shuneng",
+      "systemId": "growth",
+      "name": "鐔熻兘鐢熷阀",
+      "nameEn": "Practice makes perfect",
+      "enabled": true
+    },
+    {
+      "id": "015",
+      "slug": "shuidi",
+      "systemId": "growth",
+      "name": "姘存淮鐭崇┛",
+      "nameEn": "Constant dripping wears away the stone",
+      "enabled": true
+    },
+    {
+      "id": "016",
+      "slug": "wenji",
+      "systemId": "growth",
+      "name": "闂婚浮璧疯垶",
+      "nameEn": "Waking up at the rooster's crow to practice",
+      "enabled": true
+    },
+    {
+      "id": "017",
+      "slug": "tiechu",
+      "systemId": "growth",
+      "name": "閾佹澋纾ㄩ拡",
+      "nameEn": "Grinding an iron bar into a needle",
+      "enabled": true
+    },
+    {
+      "id": "018",
+      "slug": "benniao",
+      "systemId": "growth",
+      "name": "绗ㄩ笩鍏堥",
+      "nameEn": "Clumsy birds fly early",
+      "enabled": true
+    },
+    {
+      "id": "019",
+      "slug": "xiongyou",
+      "systemId": "growth",
+      "name": "鑳告湁鎴愮",
+      "nameEn": "Having a full picture of the bamboo in mind before painting",
+      "enabled": true
+    },
+    {
+      "id": "020",
+      "slug": "hulun",
+      "systemId": "growth",
+      "name": "鍥浀鍚炴灒",
+      "nameEn": "Swallowing a date whole without chewing",
+      "enabled": true
+    },
+    {
+      "id": "021",
+      "slug": "handan",
+      "systemId": "growth",
+      "name": "閭兏瀛︽",
+      "nameEn": "Forgetting how to walk while trying to copy others",
+      "enabled": true
+    },
+    {
+      "id": "022",
+      "slug": "cangjie",
+      "systemId": "language",
+      "name": "浠撻閫犲瓧",
+      "nameEn": "Cangjie invents written characters",
+      "enabled": true
+    },
+    {
+      "id": "023",
+      "slug": "zhilu",
+      "systemId": "language",
+      "name": "鎸囬箍涓洪┈",
+      "nameEn": "Pointing at a deer and calling it a horse",
+      "enabled": true
+    },
+    {
+      "id": "024",
+      "slug": "zixiang",
+      "systemId": "language",
+      "name": "鑷浉鐭涚浘",
+      "nameEn": "Using your unbreakable spear against your unbreakable shield",
+      "enabled": true
+    },
+    {
+      "id": "025",
+      "slug": "wangmei",
+      "systemId": "language",
+      "name": "鏈涙姝㈡复",
+      "nameEn": "Thinking of sour plums to stop thirst",
+      "enabled": true
+    },
+    {
+      "id": "026",
+      "slug": "huabing",
+      "systemId": "language",
+      "name": "鐢婚ゼ鍏呴ゥ",
+      "nameEn": "Drawing a pancake to satisfy hunger",
+      "enabled": true
+    },
+    {
+      "id": "027",
+      "slug": "duiniu",
+      "systemId": "language",
+      "name": "瀵圭墰寮圭惔",
+      "nameEn": "Playing a harp to a cow",
+      "enabled": true
+    },
+    {
+      "id": "028",
+      "slug": "gouwei",
+      "systemId": "language",
+      "name": "鐙楀熬缁矀",
+      "nameEn": "Using a dog's tail to extend a mink coat",
+      "enabled": true
+    },
+    {
+      "id": "029",
+      "slug": "yiye",
+      "systemId": "cognition",
+      "name": "涓�鍙堕殰鐩�",
+      "nameEn": "A single leaf blocking the view of the forest",
+      "enabled": true
+    },
+    {
+      "id": "030",
+      "slug": "mangren",
+      "systemId": "cognition",
+      "name": "鐩蹭汉鎽歌薄",
+      "nameEn": "Blind men touching an elephant to guess what it is",
+      "enabled": true
+    },
+    {
+      "id": "031",
+      "slug": "jingdi",
+      "systemId": "cognition",
+      "name": "浜曞簳涔嬭洐",
+      "nameEn": "A frog at the bottom of a well",
+      "enabled": true
+    },
+    {
+      "id": "032",
+      "slug": "kezhou",
+      "systemId": "cognition",
+      "name": "鍒昏垷姹傚墤",
+      "nameEn": "Carving a mark on a moving boat to find a lost sword",
+      "enabled": true
+    },
+    {
+      "id": "033",
+      "slug": "nanyuan",
+      "systemId": "cognition",
+      "name": "鍗楄緯鍖楄緳",
+      "nameEn": "Driving south to reach the north",
+      "enabled": true
+    },
+    {
+      "id": "034",
+      "slug": "shouzhu",
+      "systemId": "cognition",
+      "name": "瀹堟牚寰呭厰",
+      "nameEn": "Waiting by a tree for a rabbit to crash into it",
+      "enabled": true
+    },
+    {
+      "id": "035",
+      "slug": "yaner",
+      "systemId": "cognition",
+      "name": "鎺╄�崇洍閾�",
+      "nameEn": "Covering your own ears while stealing a bell",
+      "enabled": true
+    },
+    {
+      "id": "036",
+      "slug": "yamiao",
+      "systemId": "cognition",
+      "name": "鎻犺嫍鍔╅暱",
+      "nameEn": "Pulling up sprouts to help them grow faster",
+      "enabled": true
+    },
+    {
+      "id": "037",
+      "slug": "qiren",
+      "systemId": "cognition",
+      "name": "鏉炰汉蹇уぉ",
+      "nameEn": "Worrying that the sky might fall down",
+      "enabled": true
+    },
+    {
+      "id": "038",
+      "slug": "beigong",
+      "systemId": "cognition",
+      "name": "鏉紦铔囧奖",
+      "nameEn": "Mistaking a bow's reflection in a cup for a snake",
+      "enabled": true
+    },
+    {
+      "id": "039",
+      "slug": "yegong",
+      "systemId": "cognition",
+      "name": "鍙跺叕濂介緳",
+      "nameEn": "Lord Ye says he loves dragons but is terrified by a real one",
+      "enabled": true
+    },
+    {
+      "id": "040",
+      "slug": "antu",
+      "systemId": "cognition",
+      "name": "鎸夊浘绱㈤",
+      "nameEn": "Looking for a great horse based only on a drawing",
+      "enabled": true
+    },
+    {
+      "id": "041",
+      "slug": "zhishang",
+      "systemId": "cognition",
+      "name": "绾镐笂璋堝叺",
+      "nameEn": "Fighting a war only on paper",
+      "enabled": true
+    },
+    {
+      "id": "042",
+      "slug": "yuanmu",
+      "systemId": "cognition",
+      "name": "缂樻湪姹傞奔",
+      "nameEn": "Climbing a tree to catch a fish",
+      "enabled": true
+    },
+    {
+      "id": "043",
+      "slug": "xuezu",
+      "systemId": "cognition",
+      "name": "鍓婅冻閫傚饱",
+      "nameEn": "Cutting your feet to fit the shoes",
+      "enabled": true
+    },
+    {
+      "id": "044",
+      "slug": "ziqie",
+      "systemId": "cognition",
+      "name": "鑷娆轰汉",
+      "nameEn": "Deceiving yourself and others",
+      "enabled": true
+    },
+    {
+      "id": "045",
+      "slug": "dangju",
+      "systemId": "cognition",
+      "name": "褰撳眬鑰呰糠",
+      "nameEn": "The players are confused, but the observers see clearly",
+      "enabled": true
+    },
+    {
+      "id": "046",
+      "slug": "yelang",
+      "systemId": "cognition",
+      "name": "澶滈儙鑷ぇ",
+      "nameEn": "A small kingdom thinking it's the biggest",
+      "enabled": true
+    },
+    {
+      "id": "047",
+      "slug": "maidu",
+      "systemId": "cognition",
+      "name": "涔版杩樼彔",
+      "nameEn": "Buying the fancy box but returning the pearl inside",
+      "enabled": true
+    },
+    {
+      "id": "048",
+      "slug": "dongshi",
+      "systemId": "cognition",
+      "name": "涓滄柦鏁堥ⅵ",
+      "nameEn": "An ugly girl mimicking a beauty's frown and looking worse",
+      "enabled": true
+    },
+    {
+      "id": "049",
+      "slug": "shesheng",
+      "systemId": "values",
+      "name": "鑸嶇敓鍙栦箟",
+      "nameEn": "Sacrificing life for justice",
+      "enabled": true
+    },
+    {
+      "id": "050",
+      "slug": "wanbi",
+      "systemId": "values",
+      "name": "瀹岀挧褰掕档",
+      "nameEn": "Returning the jade intact to its owner",
+      "enabled": true
+    },
+    {
+      "id": "051",
+      "slug": "jugong",
+      "systemId": "values",
+      "name": "闉犺含灏界榿",
+      "nameEn": "Giving one's all until the very end",
+      "enabled": true
+    },
+    {
+      "id": "052",
+      "slug": "kongrong",
+      "systemId": "values",
+      "name": "瀛旇瀺璁╂ⅷ",
+      "nameEn": "Kong Rong giving the bigger pear to his brother",
+      "enabled": true
+    },
+    {
+      "id": "053",
+      "slug": "pofu",
+      "systemId": "values",
+      "name": "鐮撮嚋娌夎垷",
+      "nameEn": "Breaking the pots and sinking the boats",
+      "enabled": true
+    },
+    {
+      "id": "054",
+      "slug": "wangyang",
+      "systemId": "values",
+      "name": "浜$緤琛ョ墷",
+      "nameEn": "Fixing the fence after the sheep is stolen",
+      "enabled": true
+    },
+    {
+      "id": "055",
+      "slug": "shaji",
+      "systemId": "values",
+      "name": "鏉�楦″彇鍗�",
+      "nameEn": "Killing the hen to get the eggs",
+      "enabled": true
+    },
+    {
+      "id": "056",
+      "slug": "sheben",
+      "systemId": "values",
+      "name": "鑸嶆湰閫愭湯",
+      "nameEn": "Chasing the branches and ignoring the root",
+      "enabled": true
+    },
+    {
+      "id": "057",
+      "slug": "delong",
+      "systemId": "values",
+      "name": "寰楅檱鏈涜渶",
+      "nameEn": "Getting one place and wanting the next one immediately",
+      "enabled": true
+    },
+    {
+      "id": "058",
+      "slug": "yiluan",
+      "systemId": "values",
+      "name": "浠ュ嵉鍑荤煶",
+      "nameEn": "Hitting a rock with an egg",
+      "enabled": true
+    },
+    {
+      "id": "059",
+      "slug": "nongqiao",
+      "systemId": "values",
+      "name": "寮勫阀鎴愭嫏",
+      "nameEn": "Trying to be smart but making a mess",
+      "enabled": true
+    },
+    {
+      "id": "060",
+      "slug": "huashe",
+      "systemId": "values",
+      "name": "鐢昏泧娣昏冻",
+      "nameEn": "Adding legs to a drawing of a snake",
+      "enabled": true
+    },
+    {
+      "id": "061",
+      "slug": "lebusi",
+      "systemId": "values",
+      "name": "涔愪笉鎬濊渶",
+      "nameEn": "Being so happy that you forget your home",
+      "enabled": true
+    },
+    {
+      "id": "062",
+      "slug": "zhidian",
+      "systemId": "values",
+      "name": "鎸囩偣姹熷北",
+      "nameEn": "Pointing at the land and discussing politics",
+      "enabled": true
+    },
+    {
+      "id": "063",
+      "slug": "dairuo",
+      "systemId": "values",
+      "name": "鍛嗚嫢鏈ㄩ浮",
+      "nameEn": "Standing still like a wooden rooster",
+      "enabled": true
+    },
+    {
+      "id": "064",
+      "slug": "sanren",
+      "systemId": "society",
+      "name": "涓変汉鎴愯檸",
+      "nameEn": "Three people lying about a tiger makes everyone believe it",
+      "enabled": true
+    },
+    {
+      "id": "065",
+      "slug": "chunwang",
+      "systemId": "society",
+      "name": "鍞囦骸榻垮瘨",
+      "nameEn": "When the lips are gone, the teeth get cold",
+      "enabled": true
+    },
+    {
+      "id": "066",
+      "slug": "simian",
+      "systemId": "society",
+      "name": "鍥涢潰妤氭瓕",
+      "nameEn": "Hearing enemy songs from all sides",
+      "enabled": true
+    },
+    {
+      "id": "067",
+      "slug": "weiwei",
+      "systemId": "society",
+      "name": "鍥撮瓘鏁戣档",
+      "nameEn": "Attacking the enemy's home to save your own ally",
+      "enabled": true
+    },
+    {
+      "id": "068",
+      "slug": "hujia",
+      "systemId": "society",
+      "name": "鐙愬亣铏庡▉",
+      "nameEn": "The fox using the tiger's power to scare others",
+      "enabled": true
+    },
+    {
+      "id": "069",
+      "slug": "lanyu",
+      "systemId": "society",
+      "name": "婊ョ鍏呮暟",
+      "nameEn": "Pretending to play the flute in an orchestra",
+      "enabled": true
+    },
+    {
+      "id": "070",
+      "slug": "yubang",
+      "systemId": "society",
+      "name": "楣殞鐩镐簤",
+      "nameEn": "The bird and the clam fight, and the fisherman catches both",
+      "enabled": true
+    },
+    {
+      "id": "071",
+      "slug": "caochuan",
+      "systemId": "society",
+      "name": "鑽夎埞鍊熺",
+      "nameEn": "Using straw boats to borrow arrows from the enemy",
+      "enabled": true
+    },
+    {
+      "id": "072",
+      "slug": "jiedao",
+      "systemId": "society",
+      "name": "鍊熷垁鏉�浜�",
+      "nameEn": "Killing someone with a borrowed knife",
+      "enabled": true
+    },
+    {
+      "id": "073",
+      "slug": "taoyuan",
+      "systemId": "society",
+      "name": "妗冨洯缁撲箟",
+      "nameEn": "Swearing brotherhood in a peach garden",
+      "enabled": true
+    },
+    {
+      "id": "074",
+      "slug": "sangu",
+      "systemId": "society",
+      "name": "涓夐【鑼呭簮",
+      "nameEn": "Visiting a cottage three times to hire a genius",
+      "enabled": true
+    },
+    {
+      "id": "075",
+      "slug": "ertao",
+      "systemId": "society",
+      "name": "浜屾鏉�涓夊+",
+      "nameEn": "Using two peaches to kill three warriors",
+      "enabled": true
+    },
+    {
+      "id": "076",
+      "slug": "zuoshan",
+      "systemId": "society",
+      "name": "鍧愬北瑙傝檸鏂�",
+      "nameEn": "Sitting on a mountain to watch two tigers fight",
+      "enabled": true
+    },
+    {
+      "id": "077",
+      "slug": "gean",
+      "systemId": "society",
+      "name": "闅斿哺瑙傜伀",
+      "nameEn": "Watching a fire from the opposite bank",
+      "enabled": true
+    },
+    {
+      "id": "078",
+      "slug": "yugong",
+      "systemId": "destiny",
+      "name": "鎰氬叕绉诲北",
+      "nameEn": "The foolish old man moving the mountains",
+      "enabled": true
+    },
+    {
+      "id": "079",
+      "slug": "jingwei",
+      "systemId": "destiny",
+      "name": "绮惧崼濉捣",
+      "nameEn": "The Jingwei bird trying to fill the sea with pebbles",
+      "enabled": true
+    },
+    {
+      "id": "080",
+      "slug": "kuafu",
+      "systemId": "destiny",
+      "name": "澶哥埗閫愭棩",
+      "nameEn": "Kuafu chasing the sun",
+      "enabled": true
+    },
+    {
+      "id": "081",
+      "slug": "change",
+      "systemId": "destiny",
+      "name": "瀚﹀ē濂旀湀",
+      "nameEn": "Chang'e flying to the moon",
+      "enabled": true
+    },
+    {
+      "id": "082",
+      "slug": "saiweng",
+      "systemId": "destiny",
+      "name": "濉炵縼澶遍┈",
+      "nameEn": "An old man losing his horse",
+      "enabled": true
+    },
+    {
+      "id": "083",
+      "slug": "woxin",
+      "systemId": "destiny",
+      "name": "鍗ц柂灏濊儐",
+      "nameEn": "Sleeping on brushwood and tasting gall",
+      "enabled": true
+    },
+    {
+      "id": "084",
+      "slug": "yiming",
+      "systemId": "destiny",
+      "name": "涓�楦f儕浜�",
+      "nameEn": "One chirp that shocks everyone",
+      "enabled": true
+    },
+    {
+      "id": "085",
+      "slug": "maosui",
+      "systemId": "destiny",
+      "name": "姣涢亗鑷崘",
+      "nameEn": "Maosui recommending himself",
+      "enabled": true
+    },
+    {
+      "id": "086",
+      "slug": "nanke",
+      "systemId": "destiny",
+      "name": "鍗楁煰涓�姊�",
+      "nameEn": "A dream under a tree",
+      "enabled": true
+    }
+  ]
+}
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py
new file mode 100644
index 0000000..549bb68
--- /dev/null
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/idiom_catalog.py
@@ -0,0 +1,146 @@
+"""Idiom story catalog. Edit idiom-lessons.json to add or remove lessons."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from functools import lru_cache
+from typing import Any, Dict, List, Optional, Tuple
+
+_CATALOG_FILE = os.path.join(os.path.dirname(__file__), "idiom-lessons.json")
+_COMPACT_RE = re.compile(r"^(l[1-6])\.(\d{3})$", re.I)
+
+
+@lru_cache(maxsize=1)
+def load_catalog() -> Dict[str, Any]:
+    with open(_CATALOG_FILE, "r", encoding="utf-8") as handle:
+        data = json.load(handle)
+    return data if isinstance(data, dict) else {}
+
+
+def _stories() -> List[Dict[str, Any]]:
+    rows = load_catalog().get("stories") or []
+    return [row for row in rows if isinstance(row, dict)]
+
+
+@lru_cache(maxsize=1)
+def _index() -> Dict[str, Dict[str, Any]]:
+    index: Dict[str, Dict[str, Any]] = {}
+    for story in _stories():
+        sid = str(story.get("id") or "").strip().lower()
+        if sid and sid not in index:
+            index[sid] = story
+    return index
+
+
+def parse_lesson_id(lesson_id: Any) -> Tuple[str, str]:
+    key = str(lesson_id or "").strip().lower()
+    match = _COMPACT_RE.match(key)
+    if match:
+        return match.group(1).lower(), match.group(2)
+    return "", ""
+
+
+def find_story(lesson_id: Any) -> Optional[Dict[str, Any]]:
+    key = str(lesson_id or "").strip().lower()
+    if not key:
+        return None
+    level, sid = parse_lesson_id(key)
+    if sid:
+        return _index().get(sid)
+    return _index().get(key)
+
+
+def bind_story(story: Dict[str, Any], lesson_id: Any = "") -> Dict[str, Any]:
+    row = dict(story)
+    level, _sid = parse_lesson_id(lesson_id)
+    row["levelId"] = level or "l1"
+    return row
+
+
+def canonical_lesson_id(lesson_id: Any) -> str:
+    story = find_story(lesson_id)
+    if story is None:
+        return str(lesson_id or "").strip()
+    bound = bind_story(story, lesson_id)
+    return f"{bound.get('levelId')}.{story.get('id')}"
+
+
+def default_en_passes() -> List[Dict[str, Any]]:
+    raw = ((load_catalog().get("defaults") or {}).get("enPasses")) or []
+    out: List[Dict[str, Any]] = []
+    for item in raw:
+        row = normalize_pass(item)
+        if row:
+            out.append(row)
+    return out or [
+        {"speed": 0.8, "repeat": 1},
+        {"speed": 0.9, "repeat": 2},
+        {"speed": 1.0, "repeat": 5},
+    ]
+
+
+def normalize_pass(raw: Any) -> Optional[Dict[str, Any]]:
+    if not isinstance(raw, dict):
+        return None
+    try:
+        speed = float(raw.get("speed", 1.0))
+    except (TypeError, ValueError):
+        speed = 1.0
+    try:
+        repeat = int(raw.get("repeat", 1))
+    except (TypeError, ValueError):
+        repeat = 1
+    speed = min(2.0, max(0.5, speed))
+    repeat = min(10, max(1, repeat))
+    return {"speed": speed, "repeat": repeat}
+
+
+def sentence_count(story: Dict[str, Any]) -> int:
+    try:
+        count = int(story.get("sentenceCount") or ((load_catalog().get("defaults") or {}).get("sentenceCount")) or 30)
+    except (TypeError, ValueError):
+        count = 30
+    return min(200, max(1, count))
+
+
+def extensive_paths(story: Dict[str, Any], lesson_id: Any = "") -> Tuple[str, str]:
+    bound = bind_story(story, lesson_id)
+    patterns = load_catalog().get("pathPatterns") or {}
+    pattern = str(patterns.get("repeater") or "")
+    if pattern:
+        return (
+            _fill(pattern, bound, "cn", "01"),
+            _fill(pattern, bound, "en", "01"),
+        )
+    return (
+        _fill(str(patterns.get("repeaterCn") or ""), bound, "cn", "01"),
+        _fill(str(patterns.get("repeaterEn") or ""), bound, "en", "01"),
+    )
+
+
+def shadowing_paths(
+    story: Dict[str, Any],
+    language: str = "en",
+    count: Optional[int] = None,
+    lesson_id: Any = "",
+) -> List[str]:
+    bound = bind_story(story, lesson_id)
+    lang = "cn" if str(language or "").strip().lower() == "cn" else "en"
+    total = sentence_count(bound) if count is None else min(200, max(1, int(count)))
+    width = 3 if total > 99 else 2
+    pattern = str((load_catalog().get("pathPatterns") or {}).get("shadowing") or "")
+    return [
+        _fill(pattern, bound, lang, f"{index:0{width}d}")
+        for index in range(1, total + 1)
+    ]
+
+
+def _fill(pattern: str, story: Dict[str, Any], lang: str, nn: str) -> str:
+    return (
+        pattern.replace("{level}", str(story.get("levelId") or "l1"))
+        .replace("{story}", str(story.get("id") or ""))
+        .replace("{lang}", lang)
+        .replace("{nn}", nn)
+    )
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py
new file mode 100644
index 0000000..dc1682f
--- /dev/null
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/lesson_audio.py
@@ -0,0 +1,210 @@
+"""Platform lesson MP3s live under xiaozhi-server, keyed by org. Device paths have no org."""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Any, Dict, Iterable, List, Optional
+
+DEFAULT_ORG_ID = "default_org"
+LESSON_AUDIO_DIRNAME = "lesson_audio"
+ALLOWED_PREFIX = "english_learning/"
+_ORG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
+_REL_RE = re.compile(
+    r"^english_learning/(repeater|shadowing)/lessons/[A-Za-z0-9][A-Za-z0-9_./-]*\.mp3$"
+)
+
+
+def resolve_org_id(org_id: Optional[str]) -> str:
+    value = str(org_id or "").strip()
+    if not value:
+        return DEFAULT_ORG_ID
+    if not _ORG_RE.fullmatch(value):
+        return DEFAULT_ORG_ID
+    return value
+
+
+def normalize_sd_name(name: str) -> str:
+    text = str(name or "").replace("\\", "/").strip()
+    if not text:
+        return ""
+    if not text.startswith("/"):
+        text = "/" + text
+    return text
+
+
+def unique_lesson_names(items: Iterable[Any]) -> List[str]:
+    seen = set()
+    names: List[str] = []
+    for raw in items or []:
+        if isinstance(raw, dict):
+            name = normalize_sd_name(str(raw.get("name") or raw.get("path") or raw.get("url") or ""))
+        else:
+            name = normalize_sd_name(str(raw or ""))
+        if not name or name in seen:
+            continue
+        seen.add(name)
+        names.append(name)
+    return names
+
+
+def missing_lesson_names(needed: Iterable[str], present: Iterable[str]) -> List[str]:
+    have = {normalize_sd_name(name) for name in present or []}
+    have.discard("")
+    return [name for name in unique_lesson_names(needed) if name not in have]
+
+
+def lesson_audio_root(config: Optional[Dict[str, Any]] = None) -> str:
+    from config.config_loader import get_project_dir
+
+    data_dir = "data"
+    if isinstance(config, dict):
+        log_cfg = config.get("log") if isinstance(config.get("log"), dict) else {}
+        data_dir = str(log_cfg.get("data_dir") or data_dir)
+    root = data_dir if os.path.isabs(data_dir) else os.path.join(get_project_dir(), data_dir)
+    return os.path.join(root, LESSON_AUDIO_DIRNAME)
+
+
+def org_audio_dir(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
+    return os.path.join(lesson_audio_root(config), resolve_org_id(org_id))
+
+
+def sanitize_relative_path(rel: str) -> Optional[str]:
+    text = str(rel or "").replace("\\", "/").lstrip("/")
+    if not text or ".." in text.split("/"):
+        return None
+    if not _REL_RE.fullmatch(text):
+        return None
+    return text
+
+
+def resolve_disk_file(
+    config: Optional[Dict[str, Any]], org_id: Optional[str], rel: str
+) -> Optional[str]:
+    relative = sanitize_relative_path(rel)
+    if not relative:
+        return None
+    org = resolve_org_id(org_id)
+    primary = os.path.normpath(os.path.join(org_audio_dir(config, org), relative))
+    root = os.path.normpath(lesson_audio_root(config))
+    if not primary.startswith(root + os.sep) and primary != root:
+        return None
+    if os.path.isfile(primary):
+        return primary
+    if org != DEFAULT_ORG_ID:
+        fallback = os.path.normpath(os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), relative))
+        if fallback.startswith(root + os.sep) and os.path.isfile(fallback):
+            return fallback
+    return None
+
+
+def lesson_audio_public_root(config: Optional[Dict[str, Any]]) -> str:
+    server = (config or {}).get("server") if isinstance(config, dict) else {}
+    if not isinstance(server, dict):
+        server = {}
+    raw = str(server.get("lesson_audio_public_url") or "").strip()
+    port = int(server.get("http_port", 8003) or 8003)
+    if not raw or "浣�" in raw:
+        raw = f"http://{_local_ip()}:{port}/lesson-audio"
+    return raw.rstrip("/")
+
+
+def _local_ip() -> str:
+    import socket
+
+    try:
+        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        sock.connect(("8.8.8.8", 80))
+        ip = sock.getsockname()[0]
+        sock.close()
+        return ip
+    except Exception:
+        return "127.0.0.1"
+
+
+def lesson_audio_base_url(config: Optional[Dict[str, Any]], org_id: Optional[str]) -> str:
+    return f"{lesson_audio_public_root(config)}/{resolve_org_id(org_id)}"
+
+
+_CLIP_RE = re.compile(r"^(\d+)\.mp3$", re.I)
+
+
+def list_lesson_clips(
+    config: Optional[Dict[str, Any]],
+    org_id: Optional[str],
+    kind: str,
+    lesson_id: str,
+    lang: str,
+) -> Optional[List[str]]:
+    """SD paths for one lesson folder.
+
+    None means the folder is not on disk yet (caller may use the catalog).
+    A list, including empty, means the folder exists and those MP3s are the lesson.
+    """
+    level, story = _split_lesson_id(lesson_id)
+    kind_name = str(kind or "").strip().lower()
+    language = str(lang or "").strip().lower()
+    if (
+        not level
+        or not story
+        or kind_name not in ("repeater", "shadowing")
+        or language not in ("cn", "en")
+    ):
+        return None
+    rel_dir = f"english_learning/{kind_name}/lessons/{level}/{story}/{language}"
+    folder = _existing_lesson_dir(config, org_id, rel_dir)
+    if folder is None:
+        return None
+    numbered = []
+    try:
+        for entry in os.listdir(folder):
+            match = _CLIP_RE.match(entry)
+            if match:
+                numbered.append((int(match.group(1)), entry))
+    except OSError:
+        return None
+    numbered.sort()
+    return [f"/{rel_dir}/{name}" for _, name in numbered]
+
+
+def _split_lesson_id(lesson_id: str) -> tuple:
+    from core.app_runtime.idiom_catalog import parse_lesson_id
+
+    return parse_lesson_id(lesson_id)
+
+
+def _existing_lesson_dir(
+    config: Optional[Dict[str, Any]], org_id: Optional[str], rel_dir: str
+) -> Optional[str]:
+    org = resolve_org_id(org_id)
+    primary = os.path.join(org_audio_dir(config, org), *rel_dir.split("/"))
+    if os.path.isdir(primary):
+        return primary
+    if org != DEFAULT_ORG_ID:
+        fallback = os.path.join(org_audio_dir(config, DEFAULT_ORG_ID), *rel_dir.split("/"))
+        if os.path.isdir(fallback):
+            return fallback
+    return None
+
+
+def course_id_for(task_id: str, lesson_id: str) -> str:
+    lesson = re.sub(r"[^A-Za-z0-9_-]+", "-", str(lesson_id or "l1.001").strip()) or "l1-001"
+    kind = "rpt" if str(task_id or "").strip() == "repeater" else "shd"
+    course = f"{lesson}-{kind}"
+    return course[:31]
+
+
+def conn_org_id(conn: Any, params: Optional[Dict[str, Any]] = None) -> str:
+    params = params or {}
+    raw = params.get("org_id") if params.get("org_id") is not None else params.get("orgId")
+    if raw:
+        return resolve_org_id(str(raw))
+    cfg = getattr(conn, "config", None) if conn is not None else None
+    runtime = cfg.get("app_runtime") if isinstance(cfg, dict) else None
+    if isinstance(runtime, dict) and runtime.get("lesson_org_id") is not None:
+        return resolve_org_id(str(runtime.get("lesson_org_id")))
+    return DEFAULT_ORG_ID
+
+
+def ensure_org_dirs(config: Optional[Dict[str, Any]] = None) -> None:
+    os.makedirs(org_audio_dir(config, DEFAULT_ORG_ID), exist_ok=True)
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py
index e40dca9..88b0d51 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/playback.py
@@ -10,6 +10,15 @@
 
 from config.config_loader import get_project_dir
 from config.logger import setup_logging
+from core.app_runtime.idiom_catalog import (
+    default_en_passes,
+    extensive_paths,
+    find_story,
+    normalize_pass,
+    sentence_count,
+    shadowing_paths,
+)
+from core.app_runtime.lesson_audio import list_lesson_clips
 
 TAG = __name__
 logger = setup_logging()
@@ -18,32 +27,187 @@
 PLAY_SPEED_MAX = 2.0
 PLAY_SPEED_DEFAULT = 1.0
 
-# 灏忕▼搴�/瀹氭椂浠诲姟涓嬪彂娓呭崟涔嬪墠鐨勪复鏃惰鏂囥�傝矾寰勭浉瀵� SD 鍗℃牴锛�/english_learning/...锛夈��
+# 璇煶鍙d护鏈甫 lesson 鏃讹紝涓嶅啀浣跨敤娴嬭瘯娓呭崟锛涘繀椤诲厛鏈夊闀夸笅鍙戠殑璇炬枃銆�
 TEMP_REPEATER_PLAYLIST = (
     {
-        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "repeat": 10,
-        "speed": PLAY_SPEED_DEFAULT,
+        "name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+        "repeat": 1,
+        "speed": 1.0,
     },
     {
-        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
+        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
+        "repeat": 1,
+        "speed": 0.8,
+    },
+    {
+        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
+        "repeat": 2,
+        "speed": 0.9,
+    },
+    {
+        "name": "/english_learning/repeater/lessons/l1/001/en/01.mp3",
         "repeat": 5,
-        "speed": PLAY_SPEED_DEFAULT,
+        "speed": 1.0,
     },
 )
 
-TEMP_SHADOWING_PLAYLIST = (
+TEMP_SHADOWING_PLAYLIST = tuple(
     {
-        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "repeat": 5,
-        "speed": PLAY_SPEED_DEFAULT,
-    },
-    {
-        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
-        "repeat": 10,
-        "speed": PLAY_SPEED_DEFAULT,
-    },
+        "name": f"/english_learning/shadowing/lessons/l1/001/en/{index:02d}.mp3",
+        "repeat": 1,
+        "speed": 0.8,
+    }
+    for index in range(1, 59)
 )
+
+
+def expand_lesson_playlist(
+    task_id: str,
+    lesson_id: str,
+    repeat: Any = None,
+    speed: Any = None,
+    language: Any = None,
+    en_passes: Any = None,
+    cn_repeat: Any = None,
+    cn_speed: Any = None,
+    sentence_count_override: Any = None,
+) -> List[Dict[str, Any]]:
+    """lesson_id + optional repeat/speed 鈫� compact playlist. Unknown lesson returns []."""
+    lesson = str(lesson_id or "").strip()
+    tid = str(task_id or "").strip()
+    if not lesson:
+        return []
+    story = find_story(lesson)
+    if tid == "repeater":
+        items = _expand_repeater(lesson, story, en_passes, cn_repeat, cn_speed)
+    elif tid == "shadowing":
+        items = _expand_shadowing(lesson, story, speed, language, sentence_count_override)
+    else:
+        return []
+    return _apply_repeat_speed(items, repeat, speed if tid == "repeater" else None)
+
+
+def _lesson_lang_paths(kind: str, lesson: str, lang: str, catalog_paths: List[str]) -> List[str]:
+    """Prefer MP3s that actually exist under the lesson folder. Catalog is only a fallback."""
+    listed = list_lesson_clips(None, None, kind, lesson, lang)
+    if listed is not None:
+        return listed
+    return [path for path in catalog_paths if path]
+
+
+def _expand_repeater(
+    lesson: str,
+    story: Optional[Dict[str, Any]],
+    en_passes: Any,
+    cn_repeat: Any,
+    cn_speed: Any,
+) -> List[Dict[str, Any]]:
+    if story is None:
+        return []
+    catalog_cn, catalog_en = extensive_paths(story, lesson)
+    cn_paths = _lesson_lang_paths("repeater", lesson, "cn", [catalog_cn])
+    en_paths = _lesson_lang_paths("repeater", lesson, "en", [catalog_en])
+    if not cn_paths or not en_paths:
+        return []
+    try:
+        cn_times = max(1, int(cn_repeat)) if cn_repeat is not None else 1
+    except (TypeError, ValueError):
+        cn_times = 1
+    cn_rate = clamp_play_speed(cn_speed) if cn_speed is not None else 1.0
+    items: List[Dict[str, Any]] = [
+        {"name": path, "repeat": min(10, cn_times), "speed": cn_rate}
+        for path in cn_paths
+    ]
+    passes = []
+    if isinstance(en_passes, list):
+        for raw in en_passes:
+            row = normalize_pass(raw)
+            if row:
+                passes.append(row)
+    if not passes:
+        passes = default_en_passes()
+    for row in passes[:8]:
+        for path in en_paths:
+            items.append(
+                {"name": path, "repeat": row["repeat"], "speed": clamp_play_speed(row["speed"])}
+            )
+    return items
+
+
+def _expand_shadowing(
+    lesson: str,
+    story: Optional[Dict[str, Any]],
+    speed: Any,
+    language: Any,
+    sentence_count_override: Any,
+) -> List[Dict[str, Any]]:
+    if story is None:
+        return []
+    lang = str(language or "en").strip().lower()
+    if lang not in ("cn", "en"):
+        lang = "en"
+    listed = list_lesson_clips(None, None, "shadowing", lesson, lang)
+    if listed is not None:
+        paths = listed
+    else:
+        count = None
+        if sentence_count_override is not None:
+            try:
+                count = int(sentence_count_override)
+            except (TypeError, ValueError):
+                count = None
+        if count is None:
+            count = sentence_count(story)
+        paths = shadowing_paths(story, lang, count, lesson)
+    if not paths:
+        return []
+    rate = clamp_play_speed(speed) if speed is not None else 0.8
+    return [
+        {"name": name, "repeat": 1, "speed": rate}
+        for name in paths
+    ]
+
+
+def _apply_repeat_speed(items: List[Dict[str, Any]], repeat: Any, speed: Any) -> List[Dict[str, Any]]:
+    if repeat is not None:
+        try:
+            count = max(1, int(repeat))
+        except (TypeError, ValueError):
+            count = 1
+        for item in items:
+            item["repeat"] = count
+    if speed is not None:
+        value = clamp_play_speed(speed)
+        for item in items:
+            item["speed"] = value
+    return items
+
+
+def playlist_from_params(
+    task: Optional[Dict[str, Any]],
+    params: Optional[Dict[str, Any]],
+    task_id: str,
+    default_factory=None,
+) -> List[Dict[str, Any]]:
+    """Explicit items win; lesson_id expands catalog. No last lesson 鈫� empty (not the test playlist)."""
+    params = params or {}
+    explicit = parse_playlist(params)
+    if explicit:
+        return explicit
+    lesson_id = str(params.get("lesson_id") or params.get("lesson") or "").strip()
+    if lesson_id:
+        return expand_lesson_playlist(
+            task_id,
+            lesson_id,
+            params.get("repeat"),
+            params.get("speed"),
+            language=params.get("language") or params.get("lang"),
+            en_passes=params.get("en_passes"),
+            cn_repeat=params.get("cn_repeat"),
+            cn_speed=params.get("cn_speed"),
+            sentence_count_override=params.get("sentence_count"),
+        )
+    return []
 
 
 def clamp_play_speed(raw: Any) -> float:
@@ -91,6 +255,82 @@
             json.dump(data, handle, ensure_ascii=False, indent=2)
     except Exception as exc:
         logger.bind(tag=TAG).warning(f"淇濆瓨鎾斁杩涘害澶辫触: {exc}")
+
+
+_LAST_LESSON_KEYS = (
+    "lesson_id",
+    "lesson",
+    "en_passes",
+    "cn_repeat",
+    "cn_speed",
+    "language",
+    "lang",
+    "speed",
+    "on_complete",
+    "listen_ratio",
+    "sentence_count",
+    "org_id",
+    "orgId",
+)
+
+
+def _last_lesson_path(device_id: str) -> str:
+    safe = re.sub(r"[^A-Za-z0-9_-]+", "_", device_id or "unknown") or "unknown"
+    return os.path.join(get_project_dir(), "data", "last_lesson", f"{safe}.json")
+
+
+def _compact_last_lesson(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+    src = params or {}
+    out: Dict[str, Any] = {}
+    for key in _LAST_LESSON_KEYS:
+        if key in src and src[key] not in (None, ""):
+            out[key] = src[key]
+    lesson = str(out.get("lesson_id") or out.get("lesson") or "").strip()
+    if lesson:
+        out["lesson_id"] = lesson
+    return out
+
+
+def remember_last_lesson_params(device_id: str, task_id: str, params: Optional[Dict[str, Any]]) -> None:
+    kind = str(task_id or "").strip()
+    if kind not in ("repeater", "shadowing"):
+        return
+    compact = _compact_last_lesson(params)
+    if not str(compact.get("lesson_id") or "").strip():
+        return
+    path = _last_lesson_path(device_id)
+    data: Dict[str, Any] = {}
+    if os.path.isfile(path):
+        try:
+            with open(path, "r", encoding="utf-8") as handle:
+                loaded = json.load(handle)
+            if isinstance(loaded, dict):
+                data = loaded
+        except Exception:
+            data = {}
+    data[kind] = compact
+    try:
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, "w", encoding="utf-8") as handle:
+            json.dump(data, handle, ensure_ascii=False, indent=2)
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"淇濆瓨涓婃璇炬枃澶辫触: {exc}")
+
+
+def load_last_lesson_params(device_id: str, task_id: str) -> Dict[str, Any]:
+    kind = str(task_id or "").strip()
+    path = _last_lesson_path(device_id)
+    if kind not in ("repeater", "shadowing") or not os.path.isfile(path):
+        return {}
+    try:
+        with open(path, "r", encoding="utf-8") as handle:
+            data = json.load(handle)
+    except Exception:
+        return {}
+    row = data.get(kind) if isinstance(data, dict) else None
+    if not isinstance(row, dict):
+        return {}
+    return _compact_last_lesson(row)
 
 
 def _lesson_key(app_id: str, task_id: str) -> str:
@@ -255,6 +495,9 @@
     return item
 
 
+PLAYLIST_ITEM_MAX = 256
+
+
 def parse_playlist(params: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
     """Repeater/shadowing playlist. Each item is {name, repeat, optional speed}."""
     params = params or {}
@@ -265,6 +508,11 @@
             item = playlist_item_from_value(raw)
             if item:
                 items.append(item)
+        if len(items) > PLAYLIST_ITEM_MAX:
+            logger.bind(tag=TAG).warning(
+                f"playlist truncated incoming={len(items)} max={PLAYLIST_ITEM_MAX}"
+            )
+            items = items[:PLAYLIST_ITEM_MAX]
         return items
     item = playlist_item_from_value(
         {
@@ -293,20 +541,20 @@
 
 
 def default_repeater_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
-    """No downlink yet: task.default_playlist, else the unit01 01脳10 / 02脳5 stub."""
+    """Only used when a caller still has an explicit default_playlist on the task."""
     raw = (task or {}).get("default_playlist")
     if isinstance(raw, list) and raw:
         items = parse_playlist({"items": raw})
         if items:
             return items
-    return [dict(item) for item in TEMP_REPEATER_PLAYLIST]
+    return []
 
 
 def default_shadowing_playlist(task: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
-    """No downlink yet: task.default_playlist, else the unit01 01脳5 / 02脳10 stub."""
+    """Only used when a caller still has an explicit default_playlist on the task."""
     raw = (task or {}).get("default_playlist")
     if isinstance(raw, list) and raw:
         items = parse_playlist({"items": raw})
         if items:
             return items
-    return [dict(item) for item in TEMP_SHADOWING_PLAYLIST]
+    return []
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
index 22dbcb3..2ea3544 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/registry.py
@@ -18,6 +18,8 @@
 
 _REGISTRY: Optional[Dict[str, Any]] = None
 _VOICE_INDEX: Optional[list] = None
+_REGISTRY_PATH: Optional[str] = None
+_REGISTRY_MTIME: Optional[float] = None
 
 _BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "apps.json")
 _MENU_PRIO = 0
@@ -27,22 +29,37 @@
 _RESERVED_PRIO = 4
 
 
-def _load_registry() -> Dict[str, Any]:
-    candidates = [
+def _registry_candidates() -> list:
+    return [
         os.path.join(get_project_dir(), "data", "apps.json"),
         _BUNDLED_PATH,
     ]
-    for path in candidates:
+
+
+def _file_mtime(path: str) -> Optional[float]:
+    try:
+        return os.path.getmtime(path)
+    except OSError:
+        return None
+
+
+def _load_registry() -> Dict[str, Any]:
+    global _REGISTRY_PATH, _REGISTRY_MTIME
+    for path in _registry_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"):
+                _REGISTRY_PATH = path
+                _REGISTRY_MTIME = _file_mtime(path)
                 logger.bind(tag=TAG).info(f"宸插姞杞� App 娉ㄥ唽琛�: {path}")
                 return data
         except Exception as e:
             logger.bind(tag=TAG).error(f"鍔犺浇 App 娉ㄥ唽琛ㄥけ璐� {path}: {e}")
+    _REGISTRY_PATH = None
+    _REGISTRY_MTIME = None
     logger.bind(tag=TAG).warning("鏈壘鍒� apps.json锛屼娇鐢ㄥ唴缃渶灏忔敞鍐岃〃")
     return {
         "default_app_id": "chat",
@@ -60,9 +77,16 @@
 
 
 def get_registry() -> Dict[str, Any]:
-    global _REGISTRY
+    global _REGISTRY, _VOICE_INDEX
     if _REGISTRY is None:
         _REGISTRY = _load_registry()
+        return _REGISTRY
+    if _REGISTRY_PATH:
+        top = next((item for item in _registry_candidates() if os.path.isfile(item)), None)
+        mtime = _file_mtime(_REGISTRY_PATH)
+        if top != _REGISTRY_PATH or (mtime is not None and mtime != _REGISTRY_MTIME):
+            _REGISTRY = _load_registry()
+            _VOICE_INDEX = None
     return _REGISTRY
 
 
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
index 71dc2af..d4604fa 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/runtime.py
@@ -30,8 +30,10 @@
     default_shadowing_playlist,
     expand_playlist_plays,
     lesson_day_index,
+    load_last_lesson_params,
     mark_lesson_played_today,
-    parse_playlist,
+    playlist_from_params,
+    remember_last_lesson_params,
     resolve_play_speed,
     speeds_for_repeats,
 )
@@ -43,7 +45,7 @@
     match_registry_voice,
     voice_menu_text,
 )
-from .session import begin_session, current_session, end_session, parse_skill_event
+from .session import begin_session, current_session, end_session, parse_skill_event, schedule_close_parent_except
 
 if TYPE_CHECKING:
     from core.connection import ConnectionHandler
@@ -70,6 +72,26 @@
     "鐢ㄦ埛璇撮��鍑轰腑鑻卞璇濄�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
 )
 
+BILINGUAL_PAIR_PROMPTS = {
+    "zh-en": BILINGUAL_PROMPT,
+    "zh-fr": (
+        "浣犳鍦ㄨ繘琛屼腑娉曞鐓у璇濈粌涔狅紝涓嶆槸缈昏瘧鏈猴紝涓嶈鎶婄敤鎴风殑璇濆師鏍疯瘧鍥炲幓浜ゅ樊銆�"
+        "鐢ㄦ埛璇翠腑鏂囨椂浣犵敤娉曡瀹屾暣鍥炵瓟锛涚敤鎴疯娉曡鏃朵綘鐢ㄤ腑鏂囧畬鏁村洖绛斻��"
+        "涓嶈涓ょ璇█鍚屾椂杈撳嚭銆傜敤鎴疯閫�鍑哄璇濄�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
+    ),
+    "zh-ja": (
+        "浣犳鍦ㄨ繘琛屼腑鏃ュ鐓у璇濈粌涔狅紝涓嶆槸缈昏瘧鏈猴紝涓嶈鎶婄敤鎴风殑璇濆師鏍疯瘧鍥炲幓浜ゅ樊銆�"
+        "鐢ㄦ埛璇翠腑鏂囨椂浣犵敤鏃ヨ瀹屾暣鍥炵瓟锛涚敤鎴疯鏃ヨ鏃朵綘鐢ㄤ腑鏂囧畬鏁村洖绛斻��"
+        "涓嶈涓ょ璇█鍚屾椂杈撳嚭銆傜敤鎴疯閫�鍑哄璇濄�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
+    ),
+}
+
+TRANSLATOR_PAIR_PROMPTS = {
+    "zh-en": TRANSLATOR_PROMPT,
+    "zh-fr": "浣犳槸涓�鍙扮炕璇戞満銆傚彧杈撳嚭璇戞枃銆備腑鏂囪瘧鎴愭硶璇紝娉曡璇戞垚涓枃锛涘叾瀹冭瑷�榛樿璇戞垚涓枃銆�",
+    "zh-ja": "浣犳槸涓�鍙扮炕璇戞満銆傚彧杈撳嚭璇戞枃銆備腑鏂囪瘧鎴愭棩璇紝鏃ヨ璇戞垚涓枃锛涘叾瀹冭瑷�榛樿璇戞垚涓枃銆�",
+}
+
 CHAT_PROACTIVE_HINT = (
     "浣犳槸鐢ㄦ埛鐨勮闊充紮浼淬�傝繘鍏ュ璇濆悗鍙互鍏堜富鍔ㄧ煭鍙ラ個璇疯亰澶╋紝渚嬪銆屾垜浠潵鑱婂ぉ鍚с�嶃��"
     "鐢ㄦ埛寮�鍙e悗鎸夋甯稿璇濆洖绛旓紝涓嶈鍙嶅鍌績銆�"
@@ -92,8 +114,13 @@
 SD_LAYOUT_MODES = ("PLAY_AUDIO", "SHADOWING")
 SHADOWING_LISTEN_RATIO = 1.2
 SHADOWING_LISTEN_MIN_MS = 800
-REPEATER_END_PHRASE = "澶嶈缁撴潫"
+REPEATER_END_PHRASE = "娉涘惉缁撴潫"
 SHADOWING_END_PHRASE = "璺熻缁撴潫"
+REPEATER_START_PHRASE = "寮�濮嬫硾鍚换鍔�"
+SHADOWING_START_PHRASE = "寮�濮嬭窡璇讳换鍔�"
+CHAT_START_PHRASE = "寮�濮嬪璇濅换鍔�"
+TRANSLATION_START_PHRASE = "寮�濮嬬炕璇戜换鍔�"
+BILINGUAL_START_PHRASE = "寮�濮嬩腑鑻卞璇濅换鍔�"
 
 
 def source_priority(source: str) -> int:
@@ -282,17 +309,30 @@
         handler.upload_functions_desc()
 
 
-def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None:
+def _normalize_language_pair(raw: Any) -> str:
+    pair = str(raw or "zh-en").strip().lower().replace("_", "-")
+    if pair in ("cn-en", "zh-cn-en", "zhcn-en"):
+        return "zh-en"
+    return pair or "zh-en"
+
+
+def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> None:
     mode = app.get("system_prompt_mode") or "none"
     if conn.original_system_prompt is None:
         conn.original_system_prompt = conn.prompt
+    pair = _normalize_language_pair((params or {}).get("language_pair"))
+    conn.language_pair = pair
+    conn.language_pair_fallback = pair not in BILINGUAL_PAIR_PROMPTS and pair not in TRANSLATOR_PAIR_PROMPTS
+    prompt_pair = "zh-en" if conn.language_pair_fallback else pair
     if mode == "translator":
+        pair_prompt = TRANSLATOR_PAIR_PROMPTS.get(prompt_pair, TRANSLATOR_PROMPT)
         conn.change_system_prompt(
-            "浣犵幇鍦ㄦ槸鑻辫缈昏瘧鏈恒�傜敤鎴疯璇濆悗搴旇皟鐢� translate锛涚敤鎴疯閫�鍑虹炕璇戙�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
+            pair_prompt
+            + "鐢ㄦ埛璇磋瘽鍚庡簲璋冪敤 translate锛涚敤鎴疯閫�鍑虹炕璇戙�佸洖鍒拌亰澶╂椂璋冪敤 switch_app锛宎pp_id=chat銆�"
             "涓嶈璁茬瑧璇濄�佷笉瑕佹煡澶╂皵銆佷笉瑕侀棽鑱娿��"
         )
     elif mode == "bilingual":
-        conn.change_system_prompt(BILINGUAL_PROMPT)
+        conn.change_system_prompt(BILINGUAL_PAIR_PROMPTS.get(prompt_pair, BILINGUAL_PROMPT))
     elif mode == "agent_proactive":
         base = conn.original_system_prompt or conn.prompt or ""
         if CHAT_PROACTIVE_HINT not in (base or ""):
@@ -378,6 +418,28 @@
         speak_txt(conn, text)
     except Exception as e:
         logger.bind(tag=TAG).warning(f"涓诲姩鎾姤澶辫触: {e}")
+
+
+async def _play_bind_prompt(conn: "ConnectionHandler") -> None:
+    from core.handle.receiveAudioHandle import check_bind_device
+
+    await check_bind_device(conn)
+
+
+async def prompt_bind_if_needed(conn: "ConnectionHandler") -> bool:
+    """鏈粦瀹氳澶囨椂鎾� 6 浣嶇粦瀹氱爜锛屽苟闃绘娆㈣繋璇�/杩涘満璇嶃�傚凡缁戝畾鍒欒繑鍥� False銆�"""
+    event = getattr(conn, "bind_completed_event", None)
+    if event is not None and not event.is_set():
+        try:
+            await asyncio.wait_for(event.wait(), timeout=8)
+        except asyncio.TimeoutError:
+            pass
+    if not getattr(conn, "need_bind", False):
+        return False
+    if not await wait_for_tts_channels(conn, timeout=8):
+        logger.bind(tag=TAG).warning("TTS閫氶亾鏈氨缁紝浠嶅皾璇曟挱鏀剧粦瀹氱爜")
+    await _play_bind_prompt(conn)
+    return True
 
 
 def _greeting_text(conn: "ConnectionHandler") -> str:
@@ -575,7 +637,15 @@
             step_index,
             wav_bytes,
             conn=conn,
+            text=asr,
         )
+    elif audio_id and asr:
+        try:
+            from core.audio_archive import write_sidecar_for_relative
+
+            write_sidecar_for_relative(conn, audio_id, asr)
+        except Exception:
+            pass
     try:
         from core.handle.reportHandle import enqueue_asr_report
 
@@ -829,6 +899,112 @@
     return _parse_mp3_list_names(result)
 
 
+async def _list_sd_mp3_names_safe(conn: "ConnectionHandler"):
+    try:
+        names = await _list_sd_mp3_names(conn)
+        return names if isinstance(names, list) else []
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"鍒椾妇 SD 璇炬枃澶辫触: {exc}")
+        return None
+
+
+def _play_lesson_names(play_params: Optional[Dict[str, Any]]) -> list:
+    from .lesson_audio import unique_lesson_names
+
+    if not isinstance(play_params, dict):
+        return []
+    items = play_params.get("items")
+    if isinstance(items, list) and items:
+        return unique_lesson_names(items)
+    return unique_lesson_names([play_params])
+
+
+def _lesson_sync_timeout_sec(conn: "ConnectionHandler") -> float:
+    runtime = {}
+    cfg = getattr(conn, "config", None)
+    if isinstance(cfg, dict) and isinstance(cfg.get("app_runtime"), dict):
+        runtime = cfg.get("app_runtime") or {}
+    raw = runtime.get("lesson_sync_timeout_sec", 90)
+    try:
+        return max(0.0, float(raw))
+    except (TypeError, ValueError):
+        return 90.0
+
+
+async def _request_lesson_sync(
+    conn: "ConnectionHandler",
+    missing: list,
+    task_id: str,
+    params: Optional[Dict[str, Any]],
+) -> None:
+    from config.manage_api_client import sync_lesson_audio
+    from .lesson_audio import conn_org_id, course_id_for, lesson_audio_base_url, unique_lesson_names
+
+    files = unique_lesson_names(missing)
+    if not files:
+        return
+    params = params or {}
+    lesson_id = str(params.get("lesson_id") or params.get("lesson") or "l1.001").strip() or "l1.001"
+    org_id = conn_org_id(conn, params)
+    payload = {
+        "macAddress": getattr(conn, "device_id", None) or "",
+        "courseId": course_id_for(task_id, lesson_id),
+        "orgId": org_id,
+        "baseUrl": lesson_audio_base_url(getattr(conn, "config", None), org_id),
+        "files": files,
+    }
+    result = await sync_lesson_audio(payload)
+    if result is None:
+        logger.bind(tag=TAG).warning(f"璇炬枃 MQTT 鍚屾澶辫触 files={len(files)}")
+    else:
+        logger.bind(tag=TAG).info(f"璇炬枃 MQTT 鍚屾宸蹭笅鍙� org={org_id} files={len(files)}")
+
+
+async def _wait_lesson_files(conn: "ConnectionHandler", needed: list, timeout_sec: float) -> bool:
+    from .lesson_audio import missing_lesson_names
+
+    if not needed:
+        return False
+    deadline = asyncio.get_running_loop().time() + max(0.0, timeout_sec)
+    while True:
+        present = await _list_sd_mp3_names_safe(conn)
+        if present is not None and not missing_lesson_names(needed, present):
+            return True
+        remaining = deadline - asyncio.get_running_loop().time()
+        if remaining <= 0:
+            return False
+        await asyncio.sleep(min(2.0, remaining))
+
+
+async def _ensure_lesson_mp3s(
+    conn: "ConnectionHandler",
+    names: list,
+    task_id: str,
+    params: Optional[Dict[str, Any]],
+) -> bool:
+    """READY if SD already has the package. Otherwise PRELOAD via MQTT then wait. Device paths have no org."""
+    from .lesson_audio import missing_lesson_names, unique_lesson_names
+
+    needed = unique_lesson_names(names)
+    if not needed:
+        return False
+    present = await _list_sd_mp3_names_safe(conn)
+    if present is None:
+        logger.bind(tag=TAG).warning("鏃犳硶鍒椾妇 SD 璇炬枃锛岃烦杩囧悓姝ヤ互鍏嶈鎶�")
+        return True
+    missing = missing_lesson_names(needed, present)
+    if not missing:
+        return True
+    params = params or {}
+    conn.lesson_syncing = True
+    _speak(conn, "姝e湪鍚屾闊抽锛岃绋嶇瓑銆�")
+    await _request_lesson_sync(conn, missing, task_id, params)
+    timeout = _lesson_sync_timeout_sec(conn)
+    ready = await _wait_lesson_files(conn, needed, timeout)
+    conn.lesson_syncing = False
+    return ready
+
+
 def _is_full_lesson_mp3(name: str) -> bool:
     base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
     return base == "full.mp3" or base.endswith("_full.mp3")
@@ -839,9 +1015,7 @@
     params: Optional[Dict[str, Any]],
 ) -> List[Dict[str, Any]]:
     """Keep {name, repeat, speed}. Device expands repeats locally with a listen window each pass."""
-    items = parse_playlist(params)
-    if not items:
-        items = default_shadowing_playlist(_task_for(conn))
+    items = playlist_from_params(_task_for(conn), params, "shadowing", default_shadowing_playlist)
     compact: List[Dict[str, Any]] = []
     for raw in items:
         if not isinstance(raw, dict):
@@ -919,13 +1093,17 @@
     items = _compact_shadowing_playlist(conn, params)
     clips = expand_playlist_plays(items)
     if not clips:
-        names = await _list_sd_mp3_names(conn)
-        lesson = str(params.get("lesson") or "").strip()
+        lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
         single = str(params.get("name") or params.get("path") or params.get("url") or "")
         if not lesson and single and not single.lower().endswith(".mp3"):
             lesson = single
         if lesson.lower().endswith(".mp3"):
             lesson = ""
+        if not lesson:
+            conn.shadowing_clips = []
+            conn.shadowing_prompt = ""
+            return False
+        names = await _list_sd_mp3_names(conn)
         clips = lesson_mp3_clips(
             names,
             getattr(conn, "active_app_id", "english_learning"),
@@ -948,6 +1126,9 @@
     if not await _prepare_shadowing_sd(conn, params):
         return False
     play = _begin_shadowing_playlist(conn, params)
+    names = _play_lesson_names(play)
+    if not await _ensure_lesson_mp3s(conn, names, getattr(conn, "current_task_id", "shadowing") or "shadowing", params):
+        return False
     conn.shadowing_clip_gen = int(getattr(conn, "shadowing_clip_gen", 0) or 0) + 1
     notice = await _execute_named_tool_play(conn, play)
     if notice and notice.action in (Action.ERROR, Action.NOTFOUND):
@@ -972,20 +1153,6 @@
         if play.get("speed") is None and first.get("speed") is not None:
             play["speed"] = first.get("speed")
         conn.repeater_play_name = play["name"]
-        return play
-    try:
-        names = await _list_sd_mp3_names(conn)
-    except Exception as exc:
-        logger.bind(tag=TAG).warning(f"鍒椾妇澶嶈璇炬枃澶辫触: {exc}")
-        return play
-    picked = repeater_lesson_mp3(
-        names,
-        getattr(conn, "active_app_id", "") or "english_learning",
-        getattr(conn, "current_task_id", "") or "repeater",
-    )
-    if picked:
-        play["name"] = picked
-        conn.repeater_play_name = picked
     return play
 
 
@@ -1010,6 +1177,32 @@
             await _execute_named_tool_play(conn, play_params)
     except Exception as exc:
         logger.bind(tag=TAG).warning(f"寤跺悗鎾斁璇炬枃澶辫触: {exc}")
+
+
+def _lesson_start_phrase(task_id: str) -> str:
+    if task_id == "repeater":
+        return REPEATER_START_PHRASE
+    if task_id == "shadowing":
+        return SHADOWING_START_PHRASE
+    return ""
+
+
+def _schedule_talk_start_phrase(task_id: str) -> str:
+    if task_id == "conversation":
+        return CHAT_START_PHRASE
+    if task_id == "live_translate":
+        return TRANSLATION_START_PHRASE
+    if task_id == "bilingual_chat":
+        return BILINGUAL_START_PHRASE
+    return ""
+
+
+def _speak_lesson_start(conn: "ConnectionHandler", task_id: str) -> None:
+    phrase = _lesson_start_phrase(task_id)
+    if not phrase:
+        return
+    conn.sentence_id = uuid.uuid4().hex
+    _speak(conn, phrase)
 
 
 def _lesson_end_speech(conn: "ConnectionHandler", phrase: str) -> str:
@@ -1065,7 +1258,7 @@
             getattr(conn, "repeater_play_name", "") or "",
         )
     except Exception as exc:
-        logger.bind(tag=TAG).warning(f"璁板綍澶嶈澶╂暟澶辫触: {exc}")
+        logger.bind(tag=TAG).warning(f"璁板綍娉涘惉澶╂暟澶辫触: {exc}")
 
 
 def _apply_playback_speed(
@@ -1102,7 +1295,7 @@
         conn.repeater_speed_queue = []
         play["speed"] = speeds[0]
         play["repeat"] = repeat_n
-    # 澶嶈娓呭崟缁撴潫鐢� Server 鏀� audio.completed 鍚庡垏鍥炲璇濄�傝澶囦笉瑕� finish_task 鍐� NVS銆�
+    # 娉涘惉娓呭崟缁撴潫鐢� Server 鏀� audio.completed 鍚庡垏鍥炲璇濄�傝澶囦笉瑕� finish_task 鍐� NVS銆�
     play["finish_task"] = False
     return play
 
@@ -1113,9 +1306,7 @@
     params: Optional[Dict[str, Any]],
 ) -> Dict[str, Any]:
     """One shot: send the whole playlist. Repeat is just the same clip again, no mode switch."""
-    items = parse_playlist(params)
-    if not items:
-        items = default_repeater_playlist(task)
+    items = playlist_from_params(task, params, "repeater", default_repeater_playlist)
     normalized = []
     for raw in items:
         if not isinstance(raw, dict):
@@ -1170,7 +1361,7 @@
             )
             await _execute_named_tool_play(conn, play_params)
         except Exception as exc:
-            logger.bind(tag=TAG).warning(f"澶嶈涓嬩竴閬嶅け璐�: {exc}")
+            logger.bind(tag=TAG).warning(f"娉涘惉涓嬩竴閬嶅け璐�: {exc}")
             conn.repeater_speed_queue = []
             return False
         return True
@@ -1185,7 +1376,7 @@
         play_params = await _fill_repeater_play_name(conn, play_params)
         await _execute_named_tool_play(conn, play_params)
     except Exception as exc:
-        logger.bind(tag=TAG).warning(f"澶嶈涓嬩竴棣栧け璐�: {exc}")
+        logger.bind(tag=TAG).warning(f"娉涘惉涓嬩竴棣栧け璐�: {exc}")
         conn.repeater_playlist = []
         return False
     return True
@@ -1246,13 +1437,45 @@
         if not play_args["name"]:
             return ActionResponse(
                 action=Action.RESPONSE,
-                response="澶嶈鏈哄凡鎵撳紑锛屼絾 SD 鍗′笂杩樻病鏈夊彲鎾斁鐨勮鏂囥��",
+                response="娉涘惉宸叉墦寮�锛屼絾 SD 鍗′笂杩樻病鏈夊彲鎾斁鐨勮鏂囥��",
             )
         return ActionResponse(
             action=Action.RESPONSE,
-            response=(result.response if result else None) or "杩欏彴璁惧娌℃湁鏈湴澶嶈鎾斁鑳藉姏",
+            response=(result.response if result else None) or "杩欏彴璁惧娌℃湁鏈湴娉涘惉鎾斁鑳藉姏",
         )
     return None
+
+
+def _apply_last_lesson_params(
+    conn: "ConnectionHandler",
+    task_id: str,
+    params: Optional[Dict[str, Any]],
+    source: str,
+) -> Dict[str, Any]:
+    params = dict(params or {})
+    if task_id not in ("repeater", "shadowing"):
+        return params
+    device_id = str(getattr(conn, "device_id", "") or "")
+    has_lesson = str(params.get("lesson_id") or params.get("lesson") or "").strip()
+    if has_lesson:
+        remember_last_lesson_params(device_id, task_id, params)
+        return params
+    saved = load_last_lesson_params(device_id, task_id)
+    if saved:
+        merged = dict(saved)
+        for key, value in params.items():
+            if value not in (None, "", []):
+                merged[key] = value
+        return merged
+    return params
+
+
+def _lesson_missing_speech(task_id: str, params: Optional[Dict[str, Any]], fallback: str) -> str:
+    kind = "娉涘惉" if task_id == "repeater" else "璺熻"
+    lesson = str((params or {}).get("lesson_id") or (params or {}).get("lesson") or "").strip()
+    if not lesson:
+        return f"杩樻病鏈変笅鍙戣繃{kind}璇炬枃锛岃鍏堝湪瀹堕暱绔�変竴璇俱��"
+    return fallback
 
 
 async def start_app_task(
@@ -1267,7 +1490,15 @@
 ) -> ActionResponse:
     """Single entry for MQTT / schedule / voice / default chat."""
     _ensure_runtime_fields(conn)
+    if await prompt_bind_if_needed(conn):
+        return ActionResponse(action=Action.NONE, response="璁惧鏈粦瀹�")
+    conn.client_abort = False
+    tts = getattr(conn, "tts", None)
+    if tts is not None:
+        tts.tts_stop_request = False
     params = params or {}
+    if params.get("announce") is False:
+        speak_switch = False
     app, task = get_task(app_id, task_id)
     if not app:
         return ActionResponse(action=Action.ERROR, response=f"鏈煡鐨� App: {app_id}")
@@ -1278,13 +1509,18 @@
         return ActionResponse(action=Action.ERROR, response=f"鏈煡鐨� Task: {app_id}/{task_id}")
 
     resolved_task_id = task_id or app.get("default_task") or "conversation"
+    params = _apply_last_lesson_params(conn, resolved_task_id, params, source)
     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:
+    # 鍙d护鍥炲璇濊鑳介��鍑哄闀垮嵆鏃�/璁″垝浠诲姟銆傚叾瀹冧綆浼樺厛绾у垏鎹粛鐒舵嫤浣忋��
+    voice_exit_to_chat = (
+        source == "voice" and app_id == "chat" and resolved_task_id == "conversation"
+    )
+    if new_p < cur_p and not same_task and not voice_exit_to_chat:
         logger.bind(tag=TAG).info(
             f"鎷掔粷浣庝紭鍏堢骇浠诲姟 {source}({new_p}) < {conn.current_source}({cur_p})"
         )
@@ -1292,6 +1528,9 @@
             action=Action.NONE,
             response="褰撳墠鏈夋洿楂樹紭鍏堢骇鐨勪换鍔★紝鍏堝畬鎴愬悗鍐嶅垏鎹�",
         )
+
+    if source in ("voice", "mqtt_immediate", "schedule"):
+        schedule_close_parent_except(conn, source, app_id, resolved_task_id)
 
     prev_app_id = getattr(conn, "active_app_id", "chat")
     prev_task_id = getattr(conn, "current_task_id", "conversation")
@@ -1310,29 +1549,56 @@
     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)
+    _apply_prompt(conn, app, params)
     _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}"
     )
 
+    device_ready = bool(params.get("sd_ready"))
+    if device_ready:
+        speak_switch = False
+        logger.bind(tag=TAG).info("璇炬枃宸插湪 SD锛屽厛鎾紑濮嬭锛屽啀鐢辫澶囨湰鍦板紑鎾�")
+        _speak_lesson_start(conn, resolved_task_id)
+
     if app_id == "chat":
         end_session(conn, "user_exit")
+        # 瀹堕暱涓嬪彂鐨勫璇濊璁颁竴鏉℃墽琛岃褰曪紱寮�鏈烘杩庤瘝 / 鍙d护鍥炲璇濅笉瑕佸彟寮� session銆�
+        if source in ("mqtt_immediate", "schedule"):
+            begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
     elif switching:
         begin_session(conn, app_id, resolved_task_id, source, conn.interaction_mode)
 
     pending_play = None
-    if execute_skills:
+    lesson_missing = False
+    if execute_skills and not device_ready:
         if app_sd_layout_paths(app_id, app):
             await ensure_app_sd_dirs(conn, app_id)
         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_params = _begin_repeater_playlist(conn, task, params)
-                pending_play = {"kind": "repeater", "params": play_params}
+                items = play_params.get("items") if isinstance(play_params, dict) else None
+                if not items and not (isinstance(play_params, dict) and play_params.get("name")):
+                    lesson_missing = True
+                else:
+                    names = _play_lesson_names(play_params)
+                    if not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
+                        lesson_missing = True
+                    else:
+                        pending_play = {"kind": "repeater", "params": play_params}
             elif skill_name == "self.power_off":
                 await _execute_named_tool(conn, "self_power_off", {})
+
+    if source == "schedule":
+        talk_phrase = _schedule_talk_start_phrase(resolved_task_id)
+        if talk_phrase:
+            conn.sentence_id = uuid.uuid4().hex
+            _speak(conn, talk_phrase)
+            speak_switch = False
+            if resolved_task_id == "conversation":
+                conn.chat_greeted = True
 
     announce = ""
     boot_chat = app_id == "chat" and source == "default_chat"
@@ -1348,17 +1614,48 @@
                 _speak(conn, _greeting_text(conn))
     elif switching:
         announce = _switch_announcement(prev_app, prev_task, app, task, app_id)
-        if conn.interaction_mode == "SHADOWING":
-            started = await _prepare_shadowing_sd(conn, params)
-            if not started:
+        if lesson_missing:
+            prefix = announce or "鐜板湪宸茬粡鍒囨崲鑷虫硾鍚ā寮忋��"
+            announce = _lesson_missing_speech(
+                resolved_task_id,
+                params,
+                f"{prefix}杩欒杩樻病鏈夋斁鍒癝D鍗°��",
+            )
+        if getattr(conn, "language_pair_fallback", False) and conn.interaction_mode in (
+            "BILINGUAL",
+            "TRANSLATION",
+        ):
+            announce = f"{announce or ''}杩欏璇█鍏堢敤涓嫳鏉ョ粌銆�"
+        if conn.interaction_mode == "SHADOWING" and not device_ready:
+            play_params = _begin_shadowing_playlist(conn, params)
+            names = _play_lesson_names(play_params)
+            if not names:
+                if await _prepare_shadowing_sd(conn, params):
+                    play_params = _begin_shadowing_playlist(conn, params)
+                    names = _play_lesson_names(play_params)
+            if not names:
                 prefix = announce or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忋��"
-                announce = f"{prefix}璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��"
+                announce = _lesson_missing_speech(
+                    resolved_task_id,
+                    params,
+                    f"{prefix}璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��",
+                )
+            elif not await _ensure_lesson_mp3s(conn, names, resolved_task_id, params):
+                prefix = announce or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忋��"
+                announce = _lesson_missing_speech(
+                    resolved_task_id,
+                    params,
+                    f"{prefix}璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��",
+                )
             else:
-                announce = announce or "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮忥紝璇疯窡鐫�璇炬枃璇汇��"
+                announce = _lesson_start_phrase(resolved_task_id) or announce or "寮�濮嬭窡璇讳换鍔�"
                 pending_play = {
                     "kind": "shadowing",
-                    "params": _begin_shadowing_playlist(conn, params),
+                    "params": play_params,
                 }
+        start_phrase = _lesson_start_phrase(resolved_task_id)
+        if start_phrase and pending_play and not lesson_missing:
+            announce = start_phrase
         if speak_switch and announce:
             conn.sentence_id = uuid.uuid4().hex
             conn.pending_play_after_tts = pending_play
@@ -1375,11 +1672,11 @@
             conn.chat_greeted = True
             conn.sentence_id = uuid.uuid4().hex
             _speak(conn, _greeting_text(conn))
-    elif conn.interaction_mode == "SHADOWING" and not getattr(conn, "shadowing_clips", None):
+    elif conn.interaction_mode == "SHADOWING" and not device_ready and not getattr(conn, "shadowing_clips", None):
         started = await _start_shadowing_sd(conn, params)
         if not started and speak_switch:
             conn.sentence_id = uuid.uuid4().hex
-            _speak(conn, "璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��")
+            _speak(conn, _lesson_missing_speech(resolved_task_id, params, "璺熻璇炬枃杩樻病鏈夋斁鍒癝D鍗°��"))
     elif pending_play:
         conn.pending_play_after_tts = pending_play
         await maybe_flush_pending_play_after_tts(conn)
@@ -1634,7 +1931,10 @@
         )
         return True
 
-    if mode == "PLAY_AUDIO" and name == "audio.completed":
+    repeater_done = name == "audio.completed" and (
+        mode == "PLAY_AUDIO" or task_id == "repeater"
+    )
+    if repeater_done:
         continued = await _continue_repeater_if_needed(conn)
         session = getattr(conn, "task_session", None)
         if session:
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py
index 33fb816..8d79cd6 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/app_runtime/session.py
@@ -240,6 +240,7 @@
     logger.bind(tag=TAG).info(
         f"TaskSession 缁撴潫 {session.task_session_id} status={session.status} reason={reason}"
     )
+    _schedule_close_parent_matching(conn, session, reason)
 
 
 def current_session(conn: "ConnectionHandler") -> Optional[TaskSession]:
@@ -249,6 +250,59 @@
     return None
 
 
+def schedule_close_parent_except(
+    conn: "ConnectionHandler",
+    reason: str,
+    except_app: str = "",
+    except_task: str = "",
+) -> None:
+    device_id = str(getattr(conn, "device_id", "") or "")
+    if not device_id:
+        return
+    payload: Dict[str, Any] = {
+        "device_id": device_id,
+        "reason": reason or "server",
+        "except_app_id": except_app or "",
+        "except_task_id": except_task or "",
+    }
+    _schedule_close_parent(payload)
+
+
+def _schedule_close_parent_matching(
+    conn: "ConnectionHandler",
+    session: TaskSession,
+    reason: str,
+) -> None:
+    device_id = str(getattr(conn, "device_id", "") or getattr(session, "device_id", "") or "")
+    if not device_id:
+        return
+    status = "done" if getattr(session, "status", "") == "completed" else "cancelled"
+    payload: Dict[str, Any] = {
+        "device_id": device_id,
+        "reason": reason or "",
+        "status": status,
+        "match_app_id": session.app_id or "",
+        "match_task_id": session.task_id or "",
+    }
+    _schedule_close_parent(payload)
+
+
+def _schedule_close_parent(payload: Dict[str, Any]) -> None:
+    async def _run():
+        try:
+            from config.manage_api_client import close_parent_immediate_tasks
+
+            await close_parent_immediate_tasks(payload)
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"鍏抽棴瀹堕暱鍗虫椂浠诲姟澶辫触: {exc}")
+
+    try:
+        loop = asyncio.get_running_loop()
+    except RuntimeError:
+        return
+    loop.create_task(_run())
+
+
 async def _upsert_task_instance(payload: Dict[str, Any]) -> bool:
     from config.manage_api_client import upsert_task_instance
 
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py
index 7e5e701..efeb8bb 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/audio_archive.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import json
 import os
 import time
 from typing import Any, Optional
@@ -59,14 +60,63 @@
     return os.path.join(get_project_dir(), rel)
 
 
+def plain_transcript(text: Any) -> str:
+    """ASR/TTS 褰掓。鏂囨湰銆侳unASR 鍙兘鏄� {content, speaker, ...} JSON銆�"""
+    if text is None:
+        return ""
+    raw = str(text)
+    stripped = raw.strip()
+    if stripped.startswith("{") and stripped.endswith("}"):
+        try:
+            data = json.loads(stripped)
+            if isinstance(data, dict) and "content" in data:
+                return str(data.get("content") or "")
+        except Exception:
+            pass
+    return raw
+
+
+def write_sidecar_text(wav_path: str, text: Optional[str]) -> Optional[str]:
+    """Write UTF-8 .txt next to a wav. Same stem, suffix .txt. None skips."""
+    if text is None or not wav_path:
+        return None
+    sidecar = os.path.splitext(wav_path)[0] + ".txt"
+    try:
+        body = plain_transcript(text)
+        with open(sidecar, "w", encoding="utf-8") as handle:
+            handle.write(body)
+            if body and not body.endswith("\n"):
+                handle.write("\n")
+        return sidecar
+    except Exception as exc:
+        logger.bind(tag=TAG).warning(f"鏂囨湰褰掓。澶辫触: {exc}")
+        return None
+
+
+def write_sidecar_for_relative(conn: Any, relative: str, text: Optional[str]) -> Optional[str]:
+    """Update the .txt next to an already archived wav (relative audio_id)."""
+    if not relative or text is None:
+        return None
+    rel = str(relative).replace("\\", "/").lstrip("/")
+    if "/" in rel:
+        path = os.path.join(archive_root(conn), *rel.split("/"))
+    else:
+        path = os.path.join(get_project_dir(), "data", "task_sessions", "audio", rel)
+    return write_sidecar_text(path, text)
+
+
 def archive_wav(
     conn: Any,
     wav_bytes: bytes,
     *,
     kind: str = "asr",
     extra: str = "",
+    text: Optional[str] = None,
 ) -> Optional[str]:
-    """Write WAV under recordings/{device}/{mode}/{date}/{time}_{kind}_{extra}.wav."""
+    """Write WAV under recordings/{device}/{mode}/{date}/{time}_{kind}_{extra}.wav.
+
+    If text is not None, also write the same stem with .txt for later analysis.
+    """
     if not wav_bytes or not archive_enabled(conn):
         return None
     try:
@@ -86,6 +136,8 @@
         path = os.path.join(directory, filename)
         with open(path, "wb") as handle:
             handle.write(wav_bytes)
+        if text is not None:
+            write_sidecar_text(path, text)
         relative = os.path.join(device, mode, day, filename).replace("\\", "/")
         logger.bind(tag=TAG).info(f"闊抽褰掓。 {relative} bytes={len(wav_bytes)}")
         return relative
@@ -94,10 +146,16 @@
         return None
 
 
-def archive_pcm_snapshot(conn: Any, snapshot: Any, *, extra: str = "") -> Optional[str]:
+def archive_pcm_snapshot(
+    conn: Any,
+    snapshot: Any,
+    *,
+    extra: str = "",
+    text: Optional[str] = None,
+) -> Optional[str]:
     from core.app_runtime.assessment import pcm_snapshot_to_wav
 
     wav_bytes = pcm_snapshot_to_wav(snapshot)
     if not wav_bytes:
         return None
-    return archive_wav(conn, wav_bytes, kind="asr", extra=extra)
+    return archive_wav(conn, wav_bytes, kind="asr", extra=extra, text=text)
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
index 69a53f3..2396416 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/connection.py
@@ -1616,9 +1616,10 @@
             if self.server is not None:
                 await self.server.unregister_device_handler(self.device_id, self)
             try:
-                from core.app_runtime.session import end_session
+                from core.app_runtime.session import end_session, schedule_close_parent_except
 
                 end_session(self, "disconnected")
+                schedule_close_parent_except(self, "disconnected")
             except Exception as session_error:
                 self.logger.bind(tag=TAG).warning(f"鏂嚎 flush TaskSession 澶辫触: {session_error}")
 
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
index e26fea1..f5bf0cb 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/receiveAudioHandle.py
@@ -25,7 +25,7 @@
         if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
             conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
         return
-    # 鏈嶅姟绔疉EC锛氫粎瀵硅瘽绫绘ā寮忓厑璁告墦鏂�傚璇�/璺熻/缈昏瘧鏃跺枃鍙洖澹颁笉鑳藉綋鎴愭彃璇濄��
+    # 鏈嶅姟绔疉EC锛氫粎瀵硅瘽绫绘ā寮忓厑璁告墦鏂�傛硾鍚�/璺熻/缈昏瘧鏃跺枃鍙洖澹颁笉鑳藉綋鎴愭彃璇濄��
     if conn.client_aec and have_voice:
         if (
             conn.client_is_speaking
@@ -107,7 +107,7 @@
 
     mode = getattr(conn, "interaction_mode", "CHAT")
 
-    # 鑿滃崟 TTS 閲岀殑銆岃窡璇�/澶嶈鏈�/缈昏瘧銆嶄笉鑳藉綋鎴愬垏 App锛涢��鍑鸿亰澶╁彛浠や粛瑕佺敓鏁�
+    # 鑿滃崟 TTS 閲岀殑銆岃窡璇�/娉涘惉/缈昏瘧銆嶄笉鑳藉綋鎴愬垏 App锛涢��鍑鸿亰澶╁彛浠や粛瑕佺敓鏁�
     if conn.client_is_speaking and getattr(conn, "awaiting_voice_app_choice", False):
         mapped = match_voice_switch(actual_text)
         if mapped and mapped[0] == "chat":
@@ -162,7 +162,7 @@
         return
 
     if mode == "PLAY_AUDIO":
-        conn.logger.bind(tag=TAG).info("澶嶈鏈烘ā寮忓拷鐣ヤ笂琛屽璇�")
+        conn.logger.bind(tag=TAG).info("娉涘惉妯″紡蹇界暐涓婅瀵硅瘽")
         return
 
     if mode == "SHADOWING":
@@ -250,6 +250,10 @@
 
 
 async def check_bind_device(conn: "ConnectionHandler"):
+    conn.last_bind_prompt_time = time.time()
+    if getattr(conn, "tts", None) is None:
+        conn.logger.bind(tag=TAG).error("TTS鏈氨缁紝鏃犳硶鎾斁缁戝畾鐮�")
+        return
     if conn.bind_code:
         # 纭繚bind_code鏄�6浣嶆暟瀛�
         if len(conn.bind_code) != 6:
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py
index 100094d..168928d 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/reportHandle.py
@@ -48,7 +48,7 @@
             try:
                 from core.audio_archive import archive_wav
 
-                archive_wav(conn, wav_data, kind="tts")
+                archive_wav(conn, wav_data, kind="tts", text=text or "")
             except Exception:
                 pass
         # 鎵ц寮傛涓婃姤
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py
index c2ecdb9..ac7c73a 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/sendAudioHandle.py
@@ -294,6 +294,9 @@
     message = {"type": "tts", "state": state, "session_id": conn.session_id}
     if text is not None:
         message["text"] = textUtils.check_emoji(text)
+    # 鍐嶈/瓒呮椂缁撴潫锛氳澶囨敹鍒� stop 鍚庡簲鐩存帴 idle 绛夊敜閱掞紝涓嶈鍏堝洖 listening銆�
+    if state == "stop" and getattr(conn, "close_after_chat", False):
+        message["session_end"] = True
 
     # TTS鎾斁缁撴潫
     if state == "stop":
@@ -321,6 +324,10 @@
 
     # 鍙戦�佹秷鎭埌瀹㈡埛绔�
     await conn.websocket.send(json.dumps(message))
+    if state == "stop":
+        from core.app_runtime.runtime import maybe_flush_pending_play_after_tts
+
+        await maybe_flush_pending_play_after_tts(conn)
 
 
 async def send_stt_message(conn: "ConnectionHandler", text):
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
index 8d1ced9..be36e20 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
@@ -87,10 +87,20 @@
                 # 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"
+                    parts = payload.split("|", 3)
+                    app_id = parts[0].strip() if parts else "chat"
+                    task_id = parts[1].strip() if len(parts) > 1 else ""
+                    source = parts[2].strip() if len(parts) > 2 else "mqtt_immediate"
+                    task_params = {}
+                    if len(parts) > 3 and parts[3].strip():
+                        try:
+                            import json
+
+                            parsed = json.loads(parts[3].strip())
+                            if isinstance(parsed, dict):
+                                task_params = parsed
+                        except Exception:
+                            task_params = {}
                     conn.logger.bind(tag=TAG).info(
                         f"鏀跺埌 app_task: {app_id}/{task_id} source={source}"
                     )
@@ -105,16 +115,39 @@
                         conn,
                         app_id,
                         task_id,
-                        {},
+                        task_params,
                         source=source,
-                        execute_skills=(source == "voice"),
+                        execute_skills=True,
                     )
                     return
 
-                if original_text.startswith("[voice_entry]"):
-                    from core.app_runtime.runtime import voice_menu_text, wait_for_tts_channels
+                if original_text.startswith("[lesson_sync]"):
+                    from core.app_runtime.runtime import wait_for_tts_channels
                     from core.handle.intentHandler import speak_txt
 
+                    conn.client_abort = False
+                    tts = getattr(conn, "tts", None)
+                    if tts is not None:
+                        tts.tts_stop_request = False
+                    conn.sentence_id = uuid.uuid4().hex
+                    conn.logger.bind(tag=TAG).info("鏀跺埌 lesson_sync锛屾挱鎶ュ悓姝ユ彁绀�")
+                    if await wait_for_tts_channels(conn, timeout=8):
+                        speak_txt(conn, "姝e湪鍚屾闊抽锛岃绋嶇瓑銆�")
+                    else:
+                        conn.logger.bind(tag=TAG).warning("TTS閫氶亾鏈氨缁紝璺宠繃鍚屾鎻愮ず")
+                    return
+
+                if original_text.startswith("[voice_entry]"):
+                    from core.app_runtime.runtime import (
+                        prompt_bind_if_needed,
+                        voice_menu_text,
+                        wait_for_tts_channels,
+                    )
+                    from core.handle.intentHandler import speak_txt
+
+                    if await prompt_bind_if_needed(conn):
+                        return
+
                     conn.awaiting_voice_app_choice = True
                     conn.sentence_id = uuid.uuid4().hex
                     if await wait_for_tts_channels(conn, timeout=8):
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py
index feb96f3..9360c9c 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/http_server.py
@@ -1,6 +1,7 @@
 import asyncio
 from aiohttp import web
 from config.logger import setup_logging
+from core.api.lesson_audio_handler import LessonAudioHandler
 from core.api.ota_handler import OTAHandler
 from core.api.vision_handler import VisionHandler
 
@@ -13,6 +14,7 @@
         self.logger = setup_logging()
         self.ota_handler = OTAHandler(config)
         self.vision_handler = VisionHandler(config)
+        self.lesson_audio_handler = LessonAudioHandler(config)
 
     def _get_websocket_url(self, local_ip: str, port: int) -> str:
         """鑾峰彇websocket鍦板潃
@@ -72,6 +74,14 @@
                         web.options(
                             "/mcp/vision/explain", self.vision_handler.handle_options
                         ),
+                        web.get(
+                            "/lesson-audio/{org_id}/index/{kind}/{level}/{story}/{lang}",
+                            self.lesson_audio_handler.handle_index,
+                        ),
+                        web.get(
+                            "/lesson-audio/{org_id}/{path:.*}",
+                            self.lesson_audio_handler.handle_get,
+                        ),
                     ]
                 )
 
@@ -80,6 +90,7 @@
                 await runner.setup()
                 site = web.TCPSite(runner, host, port)
                 await site.start()
+                self.lesson_audio_handler.log_ready()
 
                 # 淇濇寔鏈嶅姟杩愯
                 while True:
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py
index 32c79b4..d558dfb 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/providers/asr/base.py
@@ -192,7 +192,7 @@
                     try:
                         from core.audio_archive import archive_pcm_snapshot
 
-                        archive_pcm_snapshot(conn, audio_snapshot)
+                        archive_pcm_snapshot(conn, audio_snapshot, text=raw_text or enhanced_text)
                     except Exception:
                         pass
                     enqueue_asr_report(conn, enhanced_text, audio_snapshot)
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py b/xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py
index 23e9d17..ffe6d56 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/core/websocket_handshake.py
@@ -53,4 +53,9 @@
     if data.get("state") != "detect":
         return False
     text = str(data.get("text") or "")
-    return text.startswith("[app_task]") or text.startswith("[voice_entry]") or text.startswith("[skill_event]")
+    return (
+        text.startswith("[app_task]")
+        or text.startswith("[voice_entry]")
+        or text.startswith("[skill_event]")
+        or text.startswith("[lesson_sync]")
+    )
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
index 7a1e0e6..8e433a5 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_repeater.py
@@ -9,13 +9,13 @@
     "type": "function",
     "function": {
         "name": "start_repeater",
-        "description": "鎵撳紑澶嶈鏈猴紝鍦ㄨ澶� SD 鍗′笂寰幆鎾斁 MP3銆俷ame 涓� self.mp3.get_list 鐨� files[].name 涓�鑷达紱鐪佺暐 name 鏃惰澶囦細鎾斁涓婃闊抽鎴� SD 鍗$涓�棣栥�俽epeat 鏄暣棣栧惊鐜鏁般�俿peed 鏄挱鏀惧�嶇巼 0.5锝�2.0锛岄粯璁ょ敱闊抽鐔熸倝澶╂暟鍐冲畾銆備笉瑕佺敤 play_music銆傛挱鏀炬湡闂翠笉浼氳亰澶┿��",
+        "description": "鎵撳紑娉涘惉銆傜渷鐣ヨ鏂囨椂鎾斁瀹堕暱涓婃鍗虫椂/璁″垝浠诲姟涓嬪彂鐨勮鏂囷紱杩樻病鏈変笅鍙戣繃鏃朵細鎻愮ず鍏堝湪瀹堕暱绔�変竴璇俱�備笉瑕佸啀鐢ㄦ祴璇曟竻鍗曘�俷ame 涓� self.mp3.get_list 鐨� files[].name 涓�鑷淬�俽epeat 鏄暣棣栧惊鐜鏁般�俿peed 鏄挱鏀惧�嶇巼 0.5锝�2.0銆備笉瑕佺敤 play_music銆傛挱鏀炬湡闂翠笉浼氳亰澶┿��",
         "parameters": {
             "type": "object",
             "properties": {
                 "name": {
                     "type": "string",
-                    "description": "鍙�夈�係D 鍗� MP3 鏂囦欢鍚嶏紝蹇呴』涓� get_list 鐨� files[].name 涓�鑷达紱鐪佺暐鍒欐挱涓婃鎴栫涓�棣�",
+                    "description": "鍙�夈�係D 鍗� MP3 鏂囦欢鍚嶏紝蹇呴』涓� get_list 鐨� files[].name 涓�鑷达紱鐪佺暐鍒欐挱瀹堕暱涓婃涓嬪彂鐨勮鏂�",
                 },
                 "repeat": {
                     "type": "integer",
@@ -72,7 +72,7 @@
         return ActionResponse(
             action=Action.RESPONSE,
             result=result.result,
-            response=result.response or "鏃犳硶寮�濮嬪璇�",
+            response=result.response or "鏃犳硶寮�濮嬫硾鍚�",
         )
     return ActionResponse(
         action=Action.NONE,
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py
index 5d97816..b5bf92d 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/start_shadowing.py
@@ -9,7 +9,7 @@
     "type": "function",
     "function": {
         "name": "start_shadowing",
-        "description": "寮�濮嬭窡璇伙細璁惧鐢ㄨ闊宠涓�鍙ヨ嫳璇紝鐢ㄦ埛璺熺潃璇伙紝褰曢煶浼氫笂鎶ワ紝浣嗕笉浼氳繘鍏ヨ亰澶┿�傜敤鎴疯閫�鍑鸿窡璇绘垨鍥炲埌鑱婂ぉ鏃剁粨鏉熴��",
+        "description": "寮�濮嬭窡璇伙細鎾斁瀹堕暱涓婃鍗虫椂/璁″垝浠诲姟涓嬪彂鐨勮鏂囥�傝澶囪涓�鍙ワ紝鐢ㄦ埛璺熺潃璇伙紝褰曢煶浼氫笂鎶ワ紝浣嗕笉浼氳繘鍏ヨ亰澶┿�傝繕娌℃湁涓嬪彂杩囪窡璇昏鏂囨椂浼氭彁绀哄厛鍦ㄥ闀跨閫変竴璇俱�傜敤鎴疯閫�鍑鸿窡璇绘垨鍥炲埌鑱婂ぉ鏃剁粨鏉熴��",
         "parameters": {
             "type": "object",
             "properties": {},
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
index 63993f1..6b9e9ff 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/plugins_func/functions/switch_app.py
@@ -12,7 +12,8 @@
         "description": (
             "鍒囨崲璁惧褰撳墠 App銆�"
             "chat=瀵硅瘽锛沞nglish_translation=缈昏瘧锛沞nglish_conversation=涓嫳瀵硅瘽锛�"
-            "english_learning 闇�閰嶅悎 task_id锛歳epeater=澶嶈鏈猴紝shadowing=璺熻銆�"
+            "english_learning 闇�閰嶅悎 task_id锛歳epeater=娉涘惉锛宻hadowing=璺熻銆�"
+            "璇煶鍒囨崲娉涘惉/璺熻鏃舵墽琛屽闀夸笂娆′笅鍙戠殑璇炬枃锛屾病鏈変笂娆¤鏂囧垯鎻愮ず鍏堥�夎銆�"
             "閫�鍑哄綋鍓嶆ā寮忋�佸洖鍒拌亰澶╂椂 app_id=chat銆�"
         ),
         "parameters": {
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
index 6e4d490..6dc14fa 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
@@ -33,7 +33,7 @@
     assert match_voice_switch("閫�鍑虹炕璇�") == ("chat", "conversation")
     assert match_voice_switch("杩斿洖鑱婂ぉ") == ("chat", "conversation")
     assert match_voice_switch("閫�鍑鸿窡璇�") == ("chat", "conversation")
-    assert match_voice_switch("閫�鍑哄璇�") == ("chat", "conversation")
+    assert match_voice_switch("閫�鍑烘硾鍚�") == ("chat", "conversation")
 
 
 def test_enter_prompts_announce_mode_without_exit_echo():
@@ -43,8 +43,8 @@
     _, shadowing = get_task("english_learning", "shadowing")
     repeater_prompt = (repeater or {}).get("enter_prompt") or ""
     shadowing_prompt = (shadowing or {}).get("enter_prompt") or ""
-    assert "鐜板湪宸茬粡鍒囨崲鑷冲璇绘満妯″紡" in repeater_prompt
-    assert "閫�鍑哄璇�" not in repeater_prompt
+    assert "鐜板湪宸茬粡鍒囨崲鑷虫硾鍚ā寮�" in repeater_prompt
+    assert "閫�鍑烘硾鍚�" not in repeater_prompt
     assert "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮�" in shadowing_prompt
     assert "閫�鍑鸿窡璇�" not in shadowing_prompt
 
@@ -53,7 +53,7 @@
     from core.app_runtime.runtime import voice_menu_text
 
     menu = voice_menu_text()
-    assert "鍚璇绘満" in menu
+    assert "娉涘惉" in menu
     assert "璺熻" in menu
     assert "缈昏瘧" in menu
     assert "涓嫳瀵硅瘽" in menu
@@ -64,11 +64,11 @@
     from core.app_runtime.runtime import is_exit_to_chat
 
     assert match_voice_switch("璺熻") == ("english_learning", "shadowing")
-    assert match_voice_switch("鍚") == ("english_learning", "shadowing")
+    assert match_voice_switch("娉涘惉") == ("english_learning", "repeater")
     assert match_voice_switch("鎴戞兂閫�鍑鸿窡璇�") == ("chat", "conversation")
     assert is_exit_to_chat("閫�鍑虹炕璇�") is True
     assert is_exit_to_chat("寮�濮嬭窡璇�") is False
-    assert is_exit_to_chat("閫�鍑哄惉璇�") is True
+    assert is_exit_to_chat("閫�鍑烘硾鍚�") is True
 
 
 def test_new_task_phrases_come_from_registry_only(monkeypatch):
@@ -207,27 +207,27 @@
 
 def test_lesson_mp3_clips_picks_first_lesson_folder():
     names = [
-        "/english_learning/repeater/lessons/unit01/full.mp3",
-        "/english_learning/shadowing/lessons/unit02/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "/english_learning/shadowing/lessons/unit01/full.mp3",
-        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
+        "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+        "/english_learning/shadowing/lessons/l2/002/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+        "/english_learning/shadowing/lessons/l1/001/full.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
         "loose.mp3",
     ]
     clips = lesson_mp3_clips(names, "english_learning", "shadowing")
     assert clips == [
-        "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "/english_learning/shadowing/lessons/unit01/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
     ]
-    only_unit2 = lesson_mp3_clips(names, "english_learning", "shadowing", "unit02")
-    assert only_unit2 == ["/english_learning/shadowing/lessons/unit02/02.mp3"]
+    only_l2 = lesson_mp3_clips(names, "english_learning", "shadowing", "l2")
+    assert only_l2 == ["/english_learning/shadowing/lessons/l2/002/en/02.mp3"]
     assert repeater_lesson_mp3(names, "english_learning", "repeater") == (
-        "/english_learning/repeater/lessons/unit01/full.mp3"
+        "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
     )
     assert repeater_lesson_mp3(names, "english_learning", "shadowing") == (
-        "/english_learning/shadowing/lessons/unit01/full.mp3"
+        "/english_learning/shadowing/lessons/l1/001/full.mp3"
     )
 
 
@@ -244,7 +244,7 @@
         started_at=1,
         status="running",
     )
-    session.add_turn(0, "/english_learning/shadowing/lessons/unit01/01.mp3", "hello", False)
+    session.add_turn(0, "/english_learning/shadowing/lessons/l1/001/en/01.mp3", "hello", False)
     session.add_event("audio.completed", "english_learning", "shadowing")
     data = session.to_dict()
     assert data["task_session_id"] == "abc123"
@@ -252,6 +252,19 @@
     assert "agent_id" in data
     assert data["turns"][0]["prompt_text"].endswith("01.mp3")
     assert data["events"][0]["name"] == "audio.completed"
+
+
+def test_turn_audio_id_keeps_local_recording_path():
+    session = TaskSession(
+        task_session_id="abc123",
+        app_id="english_learning",
+        task_id="shadowing",
+        status="running",
+    )
+    long_path = "14-c1-9f-49-43-94/shadowing/2026-09-18/091728893_asr_b5a60ebacb9544bfba4782a5cc978fb9_0.wav"
+    session.upsert_turn(0, prompt_text="01.mp3", asr_text="hello", audio_id=long_path)
+    assert session.to_dict()["turns"][0]["audio_id"] == long_path
+    assert len(long_path) > 32
 
 
 def test_save_survives_upsert_scheduler_error(tmp_path, monkeypatch):
@@ -281,7 +294,7 @@
     assert len(loaded.turns) == 1
 
 
-def test_repeater_queues_play_until_listen_start(monkeypatch):
+def test_repeater_queues_play_until_listen_start(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
@@ -296,34 +309,86 @@
 
     async def fake_list(conn):
         return [
-            "/english_learning/shadowing/lessons/unit01/full.mp3",
-            "/english_learning/repeater/lessons/unit01/full.mp3",
-            "/english_learning/shadowing/lessons/unit01/01.mp3",
+            "/english_learning/shadowing/lessons/l1/001/full.mp3",
+            "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+            "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
         ]
 
     monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
     monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
     monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
     spoken = []
     monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
-    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
-    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
-    assert spoken and "澶嶈鏈�" in spoken[0]
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-queue")
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {"lesson_id": "l1.001"}, source="voice"))
+    assert spoken and "娉涘惉" in spoken[0]
     assert plays == []
     assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "repeater"
     asyncio.run(maybe_flush_pending_play_after_tts(conn))
     assert len(plays) == 1
-    assert plays[0]["name"] == "/english_learning/shadowing/lessons/unit01/01.mp3"
-    assert plays[0]["repeat"] == 10
+    assert plays[0]["name"] == "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
+    assert plays[0]["repeat"] == 1
+    assert plays[0]["speed"] == 1.0
     assert plays[0].get("finish_task") is False
-    assert len(plays[0]["items"]) == 2
-    assert plays[0]["items"][1]["name"].endswith("/02.mp3")
-    assert plays[0]["items"][1]["repeat"] == 5
+    assert len(plays[0]["items"]) == 4
+    assert plays[0]["items"][1]["name"].endswith("/l1/001/en/01.mp3")
+    assert plays[0]["items"][1]["speed"] == 0.8
+    assert plays[0]["items"][3]["repeat"] == 5
+    assert plays[0]["items"][3]["speed"] == 1.0
     assert conn.repeater_playlist == []
     assert conn.pending_play_after_tts is None
 
 
-def test_shadowing_queues_whole_playlist_until_listen_start(monkeypatch):
+def test_sd_ready_skips_sync_and_queued_play(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    plays = []
+    spoken = []
+    dirs = []
+    ensures = []
+
+    async def fake_play(conn, params):
+        plays.append(params)
+        return None
+
+    async def fake_dirs(conn, app_id):
+        dirs.append(app_id)
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        ensures.append(args)
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-sd-ready")
+    asyncio.run(start_app_task(
+        conn,
+        "english_learning",
+        "repeater",
+        {"lesson_id": "l1.001", "sd_ready": True},
+        source="mqtt_immediate",
+    ))
+    assert plays == []
+    assert dirs == []
+    assert ensures == []
+    assert getattr(conn, "pending_play_after_tts", None) is None
+    assert conn.interaction_mode == "PLAY_AUDIO"
+    assert spoken == ["寮�濮嬫硾鍚换鍔�"]
+    assert not any("鍚屾" in str(text) for text in spoken)
+
+
+def test_shadowing_queues_whole_playlist_until_listen_start(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
@@ -336,47 +401,192 @@
     async def fake_dirs(conn, app_id):
         return None
 
+    async def fake_ensure(*args, **kwargs):
+        return True
+
     monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
     monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
     spoken = []
     monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
-    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
-    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-queue")
+    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {"lesson_id": "l1.001"}, source="voice"))
     assert spoken and "璺熻" in spoken[0]
     assert plays == []
     assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
     queued = conn.pending_play_after_tts["params"]["items"]
-    assert len(queued) == 2
-    assert queued[0]["repeat"] == 5
-    assert queued[1]["name"].endswith("/02.mp3")
-    assert queued[1]["repeat"] == 10
+    assert len(queued) == 58
+    assert queued[0]["repeat"] == 1
+    assert queued[0]["speed"] == 0.8
+    assert queued[-1]["name"].endswith("/58.mp3")
+    assert queued[-1]["repeat"] == 1
     asyncio.run(maybe_flush_pending_play_after_tts(conn))
     assert len(plays) == 1
-    assert len(plays[0]["items"]) == 2
-    assert plays[0]["items"][0]["repeat"] == 5
-    assert plays[0]["items"][1]["repeat"] == 10
+    assert len(plays[0]["items"]) == 58
+    assert plays[0]["items"][0]["repeat"] == 1
+    assert plays[0]["items"][-1]["repeat"] == 1
     assert plays[0].get("finish_task") is False
     assert plays[0].get("listen_ratio") == 1.2
     assert conn.pending_play_after_tts is None
 
 
-def test_shadowing_expands_default_playlist_into_listen_turns():
+def test_shadowing_ensures_mp3s_even_when_sd_empty(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    ensure_calls = []
+
+    async def fake_play(conn, params):
+        return None
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_list(conn):
+        return []
+
+    async def fake_ensure(conn, names, task_id, params):
+        ensure_calls.append((list(names), task_id, dict(params or {})))
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: None)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-sync")
+    asyncio.run(start_app_task(
+        conn,
+        "english_learning",
+        "shadowing",
+        {"lesson_id": "l1.001"},
+        source="mqtt_immediate",
+    ))
+    assert len(ensure_calls) == 1
+    names, task_id, params = ensure_calls[0]
+    assert task_id == "shadowing"
+    assert params.get("lesson_id") == "l1.001"
+    assert len(names) == 58
+    assert names[0].endswith("/en/01.mp3")
+    assert names[-1].endswith("/58.mp3")
+    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
+
+
+def test_shadowing_expands_default_playlist_into_listen_turns(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import _prepare_shadowing_sd
 
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
     conn = SimpleNamespace(
         config={},
         active_app_id="english_learning",
         current_task_id="shadowing",
     )
-    ok = asyncio.run(_prepare_shadowing_sd(conn, {}))
+    ok = asyncio.run(_prepare_shadowing_sd(conn, {"lesson_id": "l1.001"}))
     assert ok is True
-    unit01_01 = "/english_learning/shadowing/lessons/unit01/01.mp3"
-    unit01_02 = "/english_learning/shadowing/lessons/unit01/02.mp3"
-    assert conn.shadowing_clips.count(unit01_01) == 5
-    assert conn.shadowing_clips.count(unit01_02) == 10
-    assert len(conn.shadowing_clips) == 15
-    assert conn.shadowing_prompt == unit01_01
+    clip_01 = "/english_learning/shadowing/lessons/l1/001/en/01.mp3"
+    clip_58 = "/english_learning/shadowing/lessons/l1/001/en/58.mp3"
+    assert conn.shadowing_clips.count(clip_01) == 1
+    assert conn.shadowing_clips[-1] == clip_58
+    assert len(conn.shadowing_clips) == 58
+    assert conn.shadowing_prompt == clip_01
+
+
+def test_voice_repeater_without_last_lesson_prompts_parent(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-empty")
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    assert spoken and "杩樻病鏈変笅鍙戣繃娉涘惉璇炬枃" in spoken[0]
+    assert getattr(conn, "pending_play_after_tts", None) is None
+
+
+def test_voice_repeater_replays_last_parent_task(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last")
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "repeater",
+            {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]},
+            source="mqtt_immediate",
+        )
+    )
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.current_source = "default_chat"
+    conn.interaction_mode = "CHAT"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    pending = conn.pending_play_after_tts
+    assert pending and pending["kind"] == "repeater"
+    assert pending["params"]["name"].endswith("/l2/010/cn/01.mp3")
+    assert spoken and "娉涘惉" in spoken[0]
+
+
+def test_voice_shadowing_replays_last_parent_task(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last-shd")
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "shadowing",
+            {"lesson_id": "l1.001", "language": "en", "speed": 0.8},
+            source="schedule",
+        )
+    )
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.current_source = "default_chat"
+    conn.interaction_mode = "CHAT"
+    conn.shadowing_clips = []
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
+    pending = conn.pending_play_after_tts
+    assert pending and pending["kind"] == "shadowing"
+    assert pending["params"]["items"][0]["name"].endswith("/l1/001/en/01.mp3")
+    assert spoken and "璺熻" in spoken[0] and "杩樻病鏈変笅鍙戣繃" not in spoken[0]
 
 
 def test_app_sd_layout_paths_from_registry():
@@ -422,6 +632,48 @@
     assert conn.chat_greeted is True
 
 
+def test_boot_unbound_skips_greeting_and_plays_bind_code(monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    bind_codes = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+
+    async def _ready(conn, timeout=8):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _ready)
+
+    async def fake_bind(conn):
+        bind_codes.append(conn.bind_code)
+
+    monkeypatch.setattr("core.app_runtime.runtime._play_bind_prompt", fake_bind)
+    conn = SimpleNamespace(
+        config={
+            "enable_proactive_greeting": True,
+            "proactive_chat_greeting": "鎴戜滑鏉ヨ亰澶╁惂銆�",
+        },
+        prompt="",
+        func_handler=None,
+        just_woken_up=False,
+        chat_greeted=False,
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        need_bind=True,
+        bind_code="123456",
+        bind_completed_event=asyncio.Event(),
+    )
+    conn.bind_completed_event.set()
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="default_chat"))
+    assert spoken == []
+    assert bind_codes == ["123456"]
+    assert conn.chat_greeted is False
+
+
 def test_client_hello_is_recognized_before_bind():
     from core.websocket_handshake import is_client_hello
 
@@ -449,6 +701,9 @@
         '{"type":"listen","state":"detect","text":"[app_task]chat|conversation|default_chat"}'
     ) is True
     assert is_priority_listen_detect(
+        '{"type":"listen","state":"detect","text":"[lesson_sync]"}'
+    ) is True
+    assert is_priority_listen_detect(
         '{"type":"listen","state":"detect","text":"浣犲ソ灏忔櫤"}'
     ) is False
 
@@ -472,3 +727,195 @@
         }
     }
     assert app_sd_layout_paths("story_time", app) == ["/story_time/listen/lessons"]
+
+
+def test_language_pair_uses_bilingual_prompt_and_unknown_falls_back():
+    from core.app_runtime.runtime import BILINGUAL_PROMPT, _apply_prompt
+
+    conn = SimpleNamespace(prompt="base", original_system_prompt=None)
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    app = {"system_prompt_mode": "bilingual"}
+    _apply_prompt(conn, app, {"language_pair": "zh-en"})
+    assert conn.prompt == BILINGUAL_PROMPT
+    assert conn.language_pair_fallback is False
+
+    conn2 = SimpleNamespace(prompt="base", original_system_prompt=None)
+    conn2.change_system_prompt = lambda text: setattr(conn2, "prompt", text)
+    _apply_prompt(conn2, app, {"language_pair": "zh-ko"})
+    assert conn2.prompt == BILINGUAL_PROMPT
+    assert conn2.language_pair_fallback is True
+    assert conn2.language_pair == "zh-ko"
+
+
+def test_unknown_repeater_lesson_does_not_queue_play(monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _true(*args, **kwargs):
+        return True
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _true)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", _true)
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="",
+        func_handler=None,
+        just_woken_up=False,
+        chat_greeted=True,
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        pending_play_after_tts=None,
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "repeater",
+            {"lesson_id": "unit99"},
+            source="mqtt_immediate",
+        )
+    )
+    assert conn.pending_play_after_tts is None
+    assert any("SD鍗�" in text for text in spoken)
+
+
+def test_parent_translation_clears_abort_and_announces(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-translate",
+        client_abort=True,
+        tts=SimpleNamespace(tts_stop_request=True),
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(start_app_task(
+        conn,
+        "english_translation",
+        "live_translate",
+        {"language_pair": "zh-en"},
+        source="mqtt_immediate",
+    ))
+    assert conn.client_abort is False
+    assert conn.tts.tts_stop_request is False
+    assert spoken and "缈昏瘧妯″紡" in spoken[0]
+    assert conn.interaction_mode == "TRANSLATION"
+
+
+def test_schedule_talk_announces_start_phrase(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={"enable_proactive_greeting": True},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-schedule-talk",
+        client_abort=False,
+        tts=SimpleNamespace(tts_stop_request=False),
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        chat_greeted=True,
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+
+    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="schedule"))
+    assert spoken == ["寮�濮嬬炕璇戜换鍔�"]
+    assert conn.interaction_mode == "TRANSLATION"
+
+    spoken.clear()
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.interaction_mode = "CHAT"
+    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
+    assert spoken == ["寮�濮嬩腑鑻卞璇濅换鍔�"]
+    assert conn.interaction_mode == "BILINGUAL"
+
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="schedule"))
+    assert spoken == ["寮�濮嬪璇濅换鍔�"]
+    assert conn.chat_greeted is True
+    assert conn.interaction_mode == "CHAT"
+
+
+def test_voice_exit_overrides_parent_bilingual_and_translation(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-exit",
+        client_abort=False,
+        tts=SimpleNamespace(tts_stop_request=False),
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+
+    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
+    assert conn.current_source == "schedule"
+    assert conn.interaction_mode == "BILINGUAL"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
+    assert conn.interaction_mode == "CHAT"
+    assert conn.current_source == "voice"
+    assert spoken and "瀵硅瘽妯″紡" in spoken[0]
+
+    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="mqtt_immediate"))
+    assert conn.interaction_mode == "TRANSLATION"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    assert conn.interaction_mode == "TRANSLATION"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
+    assert conn.interaction_mode == "CHAT"
+    assert spoken and "瀵硅瘽妯″紡" in spoken[0]
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py
index 51bed6e..c1aea83 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_assessment.py
@@ -14,15 +14,15 @@
 
 
 CLIPS = [
-    "/english_learning/shadowing/lessons/unit01/01.mp3",
-    "/english_learning/shadowing/lessons/unit01/02.mp3",
+    "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+    "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
 ]
 
 
 def test_resolve_expected_text_from_transcripts_not_path():
     task = {
         "transcripts": {"01.mp3": "Hello, how are you today?", "01": "Hello, how are you today?"},
-        "sentences": ["/english_learning/shadowing/lessons/unit01/01.mp3"],
+        "sentences": ["/english_learning/shadowing/lessons/l1/001/en/01.mp3"],
     }
     expected = resolve_expected_text(task, CLIPS, 0)
     assert expected == "Hello, how are you today?"
@@ -32,7 +32,7 @@
 
 def test_resolve_expected_text_rejects_path_as_original():
     task = {
-        "transcripts": {"01.mp3": "/english_learning/shadowing/lessons/unit01/01.mp3"},
+        "transcripts": {"01.mp3": "/english_learning/shadowing/lessons/l1/001/en/01.mp3"},
         "sentences": [CLIPS[0]],
     }
     assert resolve_expected_text(task, CLIPS, 0) is None
@@ -294,7 +294,7 @@
         )
     )
     assert switched == [("chat", "conversation", False, False)]
-    assert spoken == ["澶嶈缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"]
+    assert spoken == ["娉涘惉缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"]
 
 
 def test_late_asr_after_listen_window_still_scores_previous_sentence(tmp_path, monkeypatch):
@@ -320,8 +320,9 @@
 
     captured = {}
 
-    def fake_save(session_id, step_index, wav_bytes, conn=None):
+    def fake_save(session_id, step_index, wav_bytes, conn=None, text=None):
         captured["wav"] = wav_bytes
+        captured["text"] = text
         captured["id"] = f"{session_id}_{step_index}.wav"
         return captured["id"]
 
@@ -334,10 +335,33 @@
     turn = conn.task_session.find_turn(0)
     assert turn["metrics"][0]["method"] == "text_similarity_v0"
     assert turn["audio_id"] == captured["id"]
+    assert captured["text"] == "Hello, how are you today?"
     wav = pcm_snapshot_to_wav(conn.last_asr_audio_snapshot)
     assert captured["wav"][:4] == b"RIFF"
     assert wav[8:12] == b"WAVE"
     assert captured["wav"] == wav
+
+
+def test_later_asr_updates_sidecar_without_new_wav(tmp_path, monkeypatch):
+    from pathlib import Path
+    from core.app_runtime.runtime import (
+        apply_deferred_assessment,
+        apply_shadowing_user_text,
+        save_shadowing_listen_audio,
+    )
+
+    conn = _shadowing_conn(tmp_path, monkeypatch)
+    conn.shadowing_listen_pcm = [b"\x00\x00" * 80]
+    save_shadowing_listen_audio(conn, 0)
+    turn = conn.task_session.find_turn(0)
+    wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
+    txt_path = wav_path.with_suffix(".txt")
+    assert txt_path.read_text(encoding="utf-8").strip() == ""
+    apply_shadowing_user_text(conn, "Hello, how are you today?")
+    apply_deferred_assessment(conn, 0, persist_audio=False)
+    recordings = list((tmp_path / "data" / "recordings").rglob("*.wav"))
+    assert len(recordings) == 1
+    assert txt_path.read_text(encoding="utf-8").strip() == "Hello, how are you today?"
 
 
 def test_shadowing_playlist_end_announces_then_chat(tmp_path, monkeypatch):
@@ -373,6 +397,42 @@
     assert played == []
     assert switched == [("chat", "conversation", False)]
     assert spoken == ["璺熻缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"]
+
+
+def test_repeater_completed_returns_chat_after_reconnect(monkeypatch):
+    """WS 鎺夌嚎鍚庤澶囧彲鑳藉湪鏂扮殑 CHAT 杩炴帴涓婅ˉ鍙� audio.completed銆�"""
+    from core.app_runtime.runtime import handle_skill_event
+
+    switched = []
+    spoken = []
+
+    async def fake_start(conn, app_id, task_id, params, source="voice", **kwargs):
+        switched.append((app_id, task_id, kwargs.get("speak_switch")))
+        conn.interaction_mode = "CHAT"
+        return None
+
+    monkeypatch.setattr("core.app_runtime.runtime.start_app_task", fake_start)
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    conn = SimpleNamespace(
+        interaction_mode="CHAT",
+        active_app_id="chat",
+        current_task_id="conversation",
+        task_session=None,
+        repeater_speed_queue=[],
+        repeater_playlist=[],
+        shadowing_awaiting_user=False,
+        prompt="",
+        config={},
+        device_id="mac",
+        tts=None,
+    )
+    asyncio.run(
+        handle_skill_event(
+            conn, "[skill_event]audio.completed|english_learning|repeater|101290"
+        )
+    )
+    assert switched == [("chat", "conversation", False)]
+    assert spoken == ["娉涘惉缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"]
 
 
 def test_playlist_completed_returns_chat_after_reconnect(monkeypatch):
@@ -438,6 +498,9 @@
     wav_path = tmp_path / "data" / "recordings" / Path(turn["audio_id"])
     assert wav_path.is_file()
     assert wav_path.stat().st_size > 44
+    txt_path = wav_path.with_suffix(".txt")
+    assert txt_path.is_file()
+    assert txt_path.read_text(encoding="utf-8").strip() == ""
 
 
 def test_fifteen_listen_windows_write_fifteen_wavs(tmp_path, monkeypatch):
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py
index 0ed7d39..10643ba 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_audio_archive.py
@@ -3,7 +3,7 @@
 from pathlib import Path
 from types import SimpleNamespace
 
-from core.audio_archive import archive_pcm_snapshot, archive_wav
+from core.audio_archive import archive_pcm_snapshot, archive_wav, plain_transcript
 from core.app_runtime.assessment import pcm_snapshot_to_wav, save_turn_wav
 
 
@@ -30,7 +30,7 @@
         config={"audio_archive": {"enabled": False}},
     )
     wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
-    assert archive_wav(conn, wav) is None
+    assert archive_wav(conn, wav, text="hello") is None
 
 
 def test_save_turn_wav_uses_archive_layout(tmp_path, monkeypatch):
@@ -41,10 +41,12 @@
         config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
     )
     wav = pcm_snapshot_to_wav([b"\x00\x00" * 160])
-    audio_id = save_turn_wav("sess", 3, wav, conn=conn)
+    audio_id = save_turn_wav("sess", 3, wav, conn=conn, text="hello world")
     assert audio_id is not None
     assert "mac1/shadowing/" in audio_id
     assert audio_id.endswith(".wav")
+    txt = tmp_path / "data" / "recordings" / Path(audio_id).with_suffix(".txt")
+    assert txt.read_text(encoding="utf-8").strip() == "hello world"
 
 
 def test_chat_pcm_archive_uses_chat_dir(tmp_path, monkeypatch):
@@ -54,6 +56,44 @@
         interaction_mode="CHAT",
         config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
     )
-    relative = archive_pcm_snapshot(conn, [b"\x00\x00" * 80])
+    relative = archive_pcm_snapshot(conn, [b"\x00\x00" * 80], text="浣犲ソ灏忔櫤")
     assert relative is not None
     assert relative.startswith("dev2/chat/")
+    txt = tmp_path / "data" / "recordings" / Path(relative).with_suffix(".txt")
+    assert txt.read_text(encoding="utf-8").strip() == "浣犲ソ灏忔櫤"
+
+
+def test_archive_writes_sidecar_txt_for_asr_and_tts(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
+    conn = SimpleNamespace(
+        device_id="dev3",
+        interaction_mode="CHAT",
+        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
+    )
+    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
+    asr_rel = archive_wav(conn, wav, kind="asr", text="鐢ㄦ埛璇寸殑璇�")
+    tts_rel = archive_wav(conn, wav, kind="tts", text="娉涘惉缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��")
+    asr_txt = tmp_path / "data" / "recordings" / Path(asr_rel).with_suffix(".txt")
+    tts_txt = tmp_path / "data" / "recordings" / Path(tts_rel).with_suffix(".txt")
+    assert asr_txt.read_text(encoding="utf-8").strip() == "鐢ㄦ埛璇寸殑璇�"
+    assert tts_txt.read_text(encoding="utf-8").strip() == "娉涘惉缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"
+
+
+def test_plain_transcript_unwraps_funasr_json():
+    wrapped = '{"speaker": "tom", "content": "浠婂ぉ澶╂皵涓嶉敊"}'
+    assert plain_transcript(wrapped) == "浠婂ぉ澶╂皵涓嶉敊"
+    assert plain_transcript("鐩存帴鏂囨湰") == "鐩存帴鏂囨湰"
+
+
+def test_empty_asr_still_writes_sidecar(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.audio_archive.get_project_dir", lambda: str(tmp_path))
+    conn = SimpleNamespace(
+        device_id="dev4",
+        interaction_mode="SHADOWING",
+        config={"audio_archive": {"enabled": True, "dir": "data/recordings"}},
+    )
+    wav = pcm_snapshot_to_wav([b"\x00\x00" * 80])
+    relative = archive_wav(conn, wav, kind="asr", extra="sess_0", text="")
+    txt = tmp_path / "data" / "recordings" / Path(relative).with_suffix(".txt")
+    assert txt.is_file()
+    assert txt.read_text(encoding="utf-8") == ""
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py
new file mode 100644
index 0000000..9c6f526
--- /dev/null
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_lesson_audio.py
@@ -0,0 +1,205 @@
+"""Org-scoped lesson audio paths and one-time SD sync."""
+
+import asyncio
+from types import SimpleNamespace
+
+from core.app_runtime.lesson_audio import (
+    DEFAULT_ORG_ID,
+    course_id_for,
+    missing_lesson_names,
+    resolve_disk_file,
+    resolve_org_id,
+    sanitize_relative_path,
+    unique_lesson_names,
+)
+from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST, TEMP_SHADOWING_PLAYLIST
+
+
+def test_empty_org_is_platform_default():
+    assert resolve_org_id(None) == DEFAULT_ORG_ID
+    assert resolve_org_id("") == DEFAULT_ORG_ID
+    assert resolve_org_id("  ") == DEFAULT_ORG_ID
+    assert resolve_org_id("school_a") == "school_a"
+    assert resolve_org_id("../etc") == DEFAULT_ORG_ID
+
+
+def test_unique_repeater_names_drop_repeats():
+    names = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
+    assert names == [
+        "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+        "/english_learning/repeater/lessons/l1/001/en/01.mp3",
+    ]
+
+
+def test_unique_shadowing_names_keep_sd_relative_paths():
+    names = unique_lesson_names(TEMP_SHADOWING_PLAYLIST)
+    assert len(names) == 58
+    assert names[0] == "/english_learning/shadowing/lessons/l1/001/en/01.mp3"
+    assert names[-1].endswith("/58.mp3")
+    assert all(not name.startswith("/default_org") for name in names)
+
+
+def test_missing_lesson_names_ignore_present_files():
+    needed = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
+    present = [needed[0], "english_learning/repeater/lessons/l1/001/en/01.mp3"]
+    assert missing_lesson_names(needed, present) == []
+    assert missing_lesson_names(needed, [needed[0]]) == [needed[1]]
+
+
+def test_sanitize_rejects_traversal_and_non_lesson_paths():
+    assert sanitize_relative_path("english_learning/repeater/lessons/l1/001/cn/01.mp3")
+    assert sanitize_relative_path("/english_learning/shadowing/lessons/l1/001/en/01.mp3")
+    assert sanitize_relative_path("english_learning/../secret.mp3") is None
+    assert sanitize_relative_path("other/lessons/a.mp3") is None
+    assert sanitize_relative_path("english_learning/repeater/lessons/l1/001/cn.txt") is None
+
+
+def test_course_id_fits_firmware_limit():
+    assert course_id_for("repeater", "l1.001") == "l1-001-rpt"
+    assert course_id_for("shadowing", "l1.001") == "l1-001-shd"
+    assert len(course_id_for("shadowing", "l1.001")) < 32
+
+
+def test_resolve_disk_file_falls_back_to_default_org(tmp_path):
+    rel = "english_learning/repeater/lessons/l1/001/cn/01.mp3"
+    platform = tmp_path / "lesson_audio" / DEFAULT_ORG_ID
+    for part in rel.split("/"):
+        platform = platform / part
+    platform.parent.mkdir(parents=True)
+    platform.write_bytes(b"ID3")
+    found = resolve_disk_file({"log": {"data_dir": str(tmp_path)}}, "school_a", rel)
+    assert found and found.replace("\\", "/").endswith("cn/01.mp3")
+    assert DEFAULT_ORG_ID in found.replace("\\", "/")
+
+
+def test_ensure_lesson_mp3s_downloads_missing_once(monkeypatch):
+    from core.app_runtime import runtime
+
+    lists = [
+        [],
+        [
+            "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+            "/english_learning/repeater/lessons/l1/001/en/01.mp3",
+        ],
+    ]
+    posted = []
+
+    async def fake_list(conn):
+        return lists.pop(0) if lists else lists[-1:] or []
+
+    async def fake_sync(payload):
+        posted.append(payload)
+        return {"ok": True}
+
+    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
+    conn = SimpleNamespace(
+        config={
+            "app_runtime": {"lesson_sync_timeout_sec": 0},
+            "server": {
+                "http_port": 8003,
+                "lesson_audio_public_url": "http://127.0.0.1:8003/lesson-audio",
+            },
+        },
+        device_id="aa:bb:cc:dd:ee:ff",
+    )
+    names = unique_lesson_names(TEMP_REPEATER_PLAYLIST)
+    ok = asyncio.run(runtime._ensure_lesson_mp3s(conn, names, "repeater", {"lesson_id": "l1.001"}))
+    assert ok is True
+    assert len(posted) == 1
+    assert posted[0]["orgId"] == DEFAULT_ORG_ID
+    assert posted[0]["files"] == names
+    assert posted[0]["baseUrl"].endswith("/lesson-audio/" + DEFAULT_ORG_ID)
+    assert "default_org" not in posted[0]["files"][0][1:]
+
+
+def test_ensure_skips_preload_when_cached(monkeypatch):
+    from core.app_runtime import runtime
+
+    posted = []
+
+    async def fake_list(conn):
+        return unique_lesson_names(TEMP_REPEATER_PLAYLIST)
+
+    async def fake_sync(payload):
+        posted.append(payload)
+        return {"ok": True}
+
+    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
+    conn = SimpleNamespace(config={"app_runtime": {"lesson_sync_timeout_sec": 0}}, device_id="mac")
+    ok = asyncio.run(
+        runtime._ensure_lesson_mp3s(conn, unique_lesson_names(TEMP_REPEATER_PLAYLIST), "repeater", {})
+    )
+    assert ok is True
+    assert posted == []
+
+
+def test_ensure_skips_sync_when_list_raises(monkeypatch):
+    from core.app_runtime import runtime
+
+    posted = []
+    spoken = []
+
+    async def fake_list(conn):
+        raise RuntimeError("mcp timeout")
+
+    async def fake_sync(payload):
+        posted.append(payload)
+        return {"ok": True}
+
+    monkeypatch.setattr(runtime, "_list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("config.manage_api_client.sync_lesson_audio", fake_sync)
+    monkeypatch.setattr(runtime, "_speak", lambda conn, text: spoken.append(text))
+    conn = SimpleNamespace(config={"app_runtime": {"lesson_sync_timeout_sec": 0}}, device_id="mac")
+    ok = asyncio.run(
+        runtime._ensure_lesson_mp3s(conn, unique_lesson_names(TEMP_REPEATER_PLAYLIST), "repeater", {})
+    )
+    assert ok is True
+    assert posted == []
+    assert spoken == []
+
+
+def test_list_lesson_clips_uses_files_on_disk(tmp_path, monkeypatch):
+    from core.app_runtime.lesson_audio import list_lesson_clips
+    from core.app_runtime.playback import expand_lesson_playlist
+
+    monkeypatch.setattr(
+        "core.app_runtime.playback.list_lesson_clips",
+        lambda *_args, **_kwargs: list_lesson_clips(
+            {"log": {"data_dir": str(tmp_path)}}, None, _args[2], _args[3], _args[4]
+        ),
+    )
+    root = tmp_path / "lesson_audio" / "default_org"
+    cn = root / "english_learning" / "shadowing" / "lessons" / "l1" / "001" / "cn"
+    en = root / "english_learning" / "shadowing" / "lessons" / "l1" / "001" / "en"
+    rpt_cn = root / "english_learning" / "repeater" / "lessons" / "l1" / "002" / "cn"
+    rpt_en = root / "english_learning" / "repeater" / "lessons" / "l1" / "002" / "en"
+    for folder, names in (
+        (en, ["01.mp3", "03.mp3", "02.mp3"]),
+        (cn, ["01.mp3"]),
+        (rpt_cn, ["01.mp3", "02.mp3"]),
+        (rpt_en, ["01.mp3"]),
+    ):
+        folder.mkdir(parents=True)
+        for name in names:
+            (folder / name).write_bytes(b"ID3")
+
+    listed = list_lesson_clips({"log": {"data_dir": str(tmp_path)}}, None, "shadowing", "l1.001", "en")
+    assert listed == [
+        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
+    ]
+    shadowing = expand_lesson_playlist("shadowing", "l1.001", language="en", speed=0.8)
+    assert [item["name"] for item in shadowing] == listed
+    repeater = expand_lesson_playlist(
+        "repeater",
+        "l1.002",
+        en_passes=[{"speed": 0.8, "repeat": 1}],
+    )
+    assert [item["name"] for item in repeater] == [
+        "/english_learning/repeater/lessons/l1/002/cn/01.mp3",
+        "/english_learning/repeater/lessons/l1/002/cn/02.mp3",
+        "/english_learning/repeater/lessons/l1/002/en/01.mp3",
+    ]
diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
index 527fc0f..2879388 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
@@ -2,11 +2,14 @@
 
 from core.app_runtime.playback import (
     PLAY_SPEED_DEFAULT,
+    PLAYLIST_ITEM_MAX,
     clamp_play_speed,
     daily_base_speed,
+    expand_lesson_playlist,
     lesson_day_index,
     mark_lesson_played_today,
     parse_playlist,
+    playlist_from_params,
     speeds_for_repeats,
 )
 from core.app_runtime.runtime import (
@@ -54,11 +57,11 @@
 
 def test_lesson_day_index_counts_distinct_days(tmp_path, monkeypatch):
     monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
-    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 0
-    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
-    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
-    mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3")
-    assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1
+    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 0
+    mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3")
+    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1
+    mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3")
+    assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1
 
 
 def test_apply_playback_speed_queues_gradient():
@@ -72,7 +75,7 @@
         repeater_power_off_after=False,
     )
     play = _apply_playback_speed(
-        conn, task, {"name": "/english_learning/repeater/lessons/unit01/full.mp3", "repeat": 5}
+        conn, task, {"name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3", "repeat": 5}
     )
     assert play["speed"] == 0.8
     assert play["repeat"] == 1
@@ -142,6 +145,21 @@
     assert items[1]["speed"] == 0.9
 
 
+def test_parse_playlist_safety_cap_is_256_not_a_lesson_limit():
+    items = parse_playlist(
+        {
+            "items": [
+                {"name": f"/english_learning/shadowing/lessons/l1/001/en/{i:03d}.mp3"}
+                for i in range(1, PLAYLIST_ITEM_MAX + 4)
+            ]
+        }
+    )
+    assert PLAYLIST_ITEM_MAX == 256
+    assert len(items) == PLAYLIST_ITEM_MAX
+    assert items[0]["name"].endswith("001.mp3")
+    assert items[-1]["name"].endswith("256.mp3")
+
+
 def test_begin_repeater_queues_remaining_items():
     conn = SimpleNamespace(
         device_id="mac",
@@ -196,7 +214,22 @@
     assert asyncio.run(_continue_repeater_if_needed(conn)) is False
 
 
-def test_begin_repeater_uses_temp_unit01_playlist():
+def test_begin_repeater_without_lesson_has_no_playlist():
+    conn = SimpleNamespace(
+        device_id="mac",
+        active_app_id="english_learning",
+        current_task_id="repeater",
+        repeater_speed_queue=[],
+        repeater_play_name="",
+        repeater_power_off_after=False,
+        repeater_playlist=[],
+    )
+    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {})
+    assert first.get("items") == []
+    assert not first.get("name")
+
+
+def test_begin_repeater_uses_catalog_lesson():
     from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST
 
     conn = SimpleNamespace(
@@ -208,19 +241,26 @@
         repeater_power_off_after=False,
         repeater_playlist=[],
     )
-    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {})
+    first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {"lesson_id": "l1.001"})
     assert first["name"] == TEMP_REPEATER_PLAYLIST[0]["name"]
-    assert first["repeat"] == 10
+    assert first["repeat"] == 1
+    assert first["speed"] == 1.0
     assert first.get("finish_task") is False
-    assert len(first["items"]) == 2
-    assert first["items"][0]["repeat"] == 10
+    assert len(first["items"]) == 4
+    assert first["items"][0]["repeat"] == 1
     assert first["items"][1]["name"] == TEMP_REPEATER_PLAYLIST[1]["name"]
-    assert first["items"][1]["repeat"] == 5
+    assert first["items"][1]["repeat"] == 1
+    assert first["items"][1]["speed"] == 0.8
+    assert first["items"][2]["speed"] == 0.9
+    assert first["items"][3]["repeat"] == 5
+    assert first["items"][3]["speed"] == 1.0
     assert conn.repeater_playlist == []
 
 
-def test_begin_shadowing_playlist_one_shot():
+def test_begin_shadowing_playlist_one_shot(tmp_path, monkeypatch):
     from core.app_runtime.playback import TEMP_SHADOWING_PLAYLIST
+
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
 
     conn = SimpleNamespace(
         device_id="mac",
@@ -232,32 +272,33 @@
         shadowing_prompt="",
         shadowing_awaiting_user=True,
     )
-    play = _begin_shadowing_playlist(conn, {})
+    play = _begin_shadowing_playlist(conn, {"lesson_id": "l1.001"})
     assert play["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"]
-    assert play["repeat"] == 5
+    assert play["repeat"] == 1
+    assert play["speed"] == 0.8
     assert play.get("finish_task") is False
     assert play.get("listen_ratio") == 1.2
-    assert len(play["items"]) == 2
-    assert play["items"][0]["repeat"] == 5
-    assert play["items"][1]["name"] == TEMP_SHADOWING_PLAYLIST[1]["name"]
-    assert play["items"][1]["repeat"] == 10
-    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
-    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
-    assert len(conn.shadowing_clips) == 15
+    assert len(play["items"]) == 58
+    assert play["items"][0]["repeat"] == 1
+    assert play["items"][-1]["name"] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
+    assert play["items"][-1]["speed"] == 0.8
+    assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 1
+    assert conn.shadowing_clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
+    assert len(conn.shadowing_clips) == 58
     assert conn.shadowing_awaiting_user is False
 
 
-def test_expand_shadowing_playlist_one_clip_per_listen_window():
+def test_expand_shadowing_playlist_one_clip_per_listen_window(tmp_path, monkeypatch):
     from core.app_runtime.playback import (
         TEMP_SHADOWING_PLAYLIST,
-        default_shadowing_playlist,
         expand_playlist_plays,
     )
 
-    clips = expand_playlist_plays(default_shadowing_playlist())
-    assert clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5
-    assert clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10
-    assert len(clips) == 15
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
+    clips = expand_playlist_plays(expand_lesson_playlist("shadowing", "l1.001"))
+    assert clips[0] == TEMP_SHADOWING_PLAYLIST[0]["name"]
+    assert clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"]
+    assert len(clips) == 58
 
 
 def test_audio_completion_timeout_scales_with_queue():
@@ -287,18 +328,18 @@
         _execute_named_tool_play(
             conn,
             {
-                "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
+                "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
                 "repeat": 5,
                 "speed": 1.0,
                 "finish_task": True,
                 "items": [
                     {
-                        "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
+                        "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
                         "repeat": 10,
                         "speed": 1.0,
                     },
                     {
-                        "name": "/english_learning/shadowing/lessons/unit01/02.mp3",
+                        "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
                         "repeat": 5,
                         "speed": 1.0,
                     },
@@ -338,7 +379,7 @@
         _execute_named_tool_play(
             conn,
             {
-                "name": "/english_learning/shadowing/lessons/unit01/01.mp3",
+                "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
                 "repeat": 1,
                 "speed": 1.0,
                 "finish_task": False,
@@ -370,7 +411,7 @@
         current_task_id="shadowing",
         config={},
     )
-    ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/unit01/01.mp3"))
+    ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/l1/001/en/01.mp3"))
     assert ok is True
     assert captured["params"]["finish_task"] is False
     assert captured["params"]["repeat"] == 1
@@ -380,5 +421,151 @@
     from core.app_runtime.runtime import _lesson_end_speech
 
     conn = SimpleNamespace(config={})
-    assert _lesson_end_speech(conn, "澶嶈缁撴潫") == "澶嶈缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"
+    assert _lesson_end_speech(conn, "娉涘惉缁撴潫") == "娉涘惉缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"
     assert _lesson_end_speech(conn, "璺熻缁撴潫") == "璺熻缁撴潫銆傛垜浠潵鑱婂ぉ鍚с��"
+
+
+def test_expand_l1_001_repeater_and_shadowing(tmp_path, monkeypatch):
+    from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST, TEMP_SHADOWING_PLAYLIST
+
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
+    repeater = expand_lesson_playlist("repeater", "l1.001")
+    assert [item["name"] for item in repeater] == [item["name"] for item in TEMP_REPEATER_PLAYLIST]
+    assert repeater[3]["repeat"] == 5
+    assert "schedule" not in repeater[0]
+
+    overridden = expand_lesson_playlist("repeater", "l1.001", repeat=1)
+    assert all(item["repeat"] == 1 for item in overridden)
+
+    shadowing = expand_lesson_playlist("shadowing", "l1.001")
+    assert len(shadowing) == 58
+    assert shadowing[0]["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"]
+    assert shadowing[-1]["name"].endswith("/58.mp3")
+    assert all(item["speed"] == 0.8 for item in shadowing)
+
+
+def test_expand_catalog_story_en_passes():
+    from core.app_runtime.idiom_catalog import find_story
+
+    story = find_story("l1.002")
+    assert story is not None
+    assert story["id"] == "002"
+    items = expand_lesson_playlist(
+        "repeater",
+        "l1.002",
+        en_passes=[
+            {"speed": 0.8, "repeat": 1},
+            {"speed": 0.9, "repeat": 2},
+            {"speed": 1.0, "repeat": 5},
+        ],
+    )
+    assert items[0]["name"].endswith("/l1/002/cn/01.mp3")
+    assert items[0]["repeat"] == 1
+    assert items[0]["speed"] == 1.0
+    assert [item["speed"] for item in items[1:]] == [0.8, 0.9, 1.0]
+    assert [item["repeat"] for item in items[1:]] == [1, 2, 5]
+    assert all(item["name"].endswith("/l1/002/en/01.mp3") for item in items[1:])
+
+
+def test_expand_catalog_shadowing_sentence_files():
+    items = expand_lesson_playlist(
+        "shadowing",
+        "l1.079",
+        language="en",
+        speed=0.8,
+    )
+    assert len(items) == 30
+    assert items[0]["name"].endswith("/l1/079/en/01.mp3")
+    assert items[-1]["name"].endswith("/l1/079/en/30.mp3")
+    assert all(item["speed"] == 0.8 for item in items)
+
+    cn_items = expand_lesson_playlist(
+        "shadowing",
+        "l1.079",
+        language="cn",
+        speed=0.9,
+    )
+    assert cn_items[0]["name"].endswith("/l1/079/cn/01.mp3")
+    assert cn_items[0]["speed"] == 0.9
+
+
+def test_expand_l1_001_custom_en_pass(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
+    items = expand_lesson_playlist(
+        "repeater",
+        "l1.001",
+        en_passes=[{"speed": 0.8, "repeat": 1}],
+    )
+    assert items[0]["name"].endswith("/l1/001/cn/01.mp3")
+    assert items[1]["name"].endswith("/l1/001/en/01.mp3")
+
+    shadowing = expand_lesson_playlist(
+        "shadowing",
+        "l1.001",
+        language="en",
+        speed=0.8,
+        sentence_count_override=58,
+    )
+    assert shadowing[0]["name"].endswith("/l1/001/en/01.mp3")
+    assert len(shadowing) == 58
+
+
+def test_idiom_catalog_lists_all_chapters():
+    from core.app_runtime.idiom_catalog import find_story, load_catalog
+
+    data = load_catalog()
+    assert len(data["systems"]) == 7
+    assert len(data["stories"]) == 86
+    assert find_story("l1.001")["name"] == "寮�澶╄緹鍦�"
+    assert find_story("l1.001")["id"] == "001"
+    assert find_story("l3.048")["nameEn"].startswith("An ugly girl")
+    assert find_story("l6.086")["id"] == "086"
+
+
+def test_unknown_lesson_does_not_fallback_to_temp():
+    assert expand_lesson_playlist("repeater", "l9.999") == []
+    assert expand_lesson_playlist("shadowing", "missing") == []
+    items = playlist_from_params(
+        {"default_playlist": [{"name": "/x.mp3", "repeat": 1}]},
+        {"lesson_id": "l9.999"},
+        "repeater",
+        lambda task: [{"name": "/fallback.mp3", "repeat": 1}],
+    )
+    assert items == []
+
+
+def test_voice_without_lesson_does_not_use_temp_default():
+    from core.app_runtime.playback import default_repeater_playlist
+
+    items = playlist_from_params(None, {}, "repeater", default_repeater_playlist)
+    assert items == []
+
+
+def test_remember_and_load_last_lesson(tmp_path, monkeypatch):
+    from core.app_runtime.playback import load_last_lesson_params, remember_last_lesson_params
+
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+    remember_last_lesson_params("mac-1", "repeater", {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]})
+    remember_last_lesson_params("mac-1", "shadowing", {"lesson_id": "l3.048", "speed": 0.8, "language": "en"})
+    loaded = load_last_lesson_params("mac-1", "repeater")
+    assert loaded["lesson_id"] == "l2.010"
+    assert load_last_lesson_params("mac-1", "shadowing")["lesson_id"] == "l3.048"
+    assert load_last_lesson_params("mac-1", "chat") == {}
+    assert load_last_lesson_params("other", "repeater") == {}
+
+
+def test_begin_repeater_expands_lesson_id():
+    conn = SimpleNamespace(
+        device_id="mac",
+        active_app_id="english_learning",
+        current_task_id="repeater",
+        repeater_speed_queue=[],
+        repeater_play_name="",
+        repeater_power_off_after=False,
+        repeater_playlist=[],
+    )
+    play = _begin_repeater_playlist(conn, {}, {"lesson_id": "l1.001", "repeat": 1})
+    assert play["name"].endswith("/l1/001/cn/01.mp3")
+    assert len(play["items"]) == 4
+    assert all(item["repeat"] == 1 for item in play["items"])
+    assert play.get("finish_task") is False

--
Gitblit v1.9.1