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/xiaozhi-server/core/app_runtime/runtime.py |  394 +++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 347 insertions(+), 47 deletions(-)

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:

--
Gitblit v1.9.1