"""Unified start_app_task / switch_app / mode helpers.""" from __future__ import annotations import uuid from typing import Any, Dict, Optional, TYPE_CHECKING from config.logger import setup_logging from plugins_func.register import Action, ActionResponse from .registry import default_app_id, get_app, get_task if TYPE_CHECKING: from core.connection import ConnectionHandler TAG = __name__ logger = setup_logging() SOURCE_PRIORITY = { "mqtt_immediate": 3, "schedule": 2, "voice": 1, "default_chat": 0, } TRANSLATOR_PROMPT = ( "你是一台翻译机。只输出译文,不要解释、不要拼音课、不要追问、不要闲聊。" "未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。" ) CHAT_PROACTIVE_HINT = ( "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。" "用户开口后按正常对话回答,不要反复催促。" ) VOICE_MENU = "想聊天、听复读,还是打开翻译?也可以说我们来聊天吧。" CHAT_PHRASES = ("我们来聊天吧", "聊会天吧", "回到聊天", "退出翻译", "结束翻译", "退出复读") TRANSLATE_PHRASES = ("打开翻译", "开始翻译", "英语翻译", "翻译模式") REPEATER_PHRASES = ("开始复读", "打开复读", "英语学习", "听复读") DEVICE_PLAY_TOOL = "self_mp3_play" DEVICE_STOP_TOOL = "self_mp3_stop" def source_priority(source: str) -> int: return SOURCE_PRIORITY.get(source or "voice", 1) def _ensure_runtime_fields(conn: "ConnectionHandler") -> None: if not hasattr(conn, "active_app_id"): conn.active_app_id = default_app_id() if not hasattr(conn, "current_task_id"): conn.current_task_id = "conversation" if not hasattr(conn, "interaction_mode"): conn.interaction_mode = "CHAT" if not hasattr(conn, "current_source"): conn.current_source = "default_chat" if not hasattr(conn, "tool_policy"): conn.tool_policy = "inherit_agent" if not hasattr(conn, "tool_allowlist"): conn.tool_allowlist = [] if not hasattr(conn, "original_system_prompt"): conn.original_system_prompt = conn.prompt def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]: """inherit_agent keeps the live agent toolset; allowlist keeps named tools only.""" _ensure_runtime_fields(conn) if getattr(conn, "tool_policy", "inherit_agent") != "allowlist": return tools allow = set(getattr(conn, "tool_allowlist", None) or []) if not allow: return {} filtered = {} for name, definition in tools.items(): if name in allow: filtered[name] = definition return filtered def _refresh_tools(conn: "ConnectionHandler") -> None: handler = getattr(conn, "func_handler", None) if handler and hasattr(handler, "upload_functions_desc"): handler.upload_functions_desc() def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None: mode = app.get("system_prompt_mode") or "none" if conn.original_system_prompt is None: conn.original_system_prompt = conn.prompt if mode == "translator": conn.change_system_prompt( "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。" "不要讲笑话、不要查天气、不要闲聊。" ) elif mode == "agent_proactive": base = conn.original_system_prompt or conn.prompt or "" if CHAT_PROACTIVE_HINT not in (base or ""): conn.change_system_prompt((base + "\n" + CHAT_PROACTIVE_HINT).strip()) else: conn.change_system_prompt(base) else: if conn.original_system_prompt: conn.change_system_prompt(conn.original_system_prompt) async def _execute_named_tool(conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]): handler = getattr(conn, "func_handler", None) if not handler or not hasattr(handler, "tool_manager"): return ActionResponse(action=Action.ERROR, response="工具处理器未就绪") try: return await handler.tool_manager.execute_tool(tool_name, arguments or {}) except Exception as e: logger.bind(tag=TAG).error(f"执行工具 {tool_name} 失败: {e}") return ActionResponse(action=Action.ERROR, response=str(e)) async def _stop_current_skills(conn: "ConnectionHandler") -> None: mode = getattr(conn, "interaction_mode", "CHAT") if mode == "PLAY_AUDIO": await _execute_named_tool(conn, DEVICE_STOP_TOOL, {}) conn.awaiting_voice_app_choice = False def _speak(conn: "ConnectionHandler", text: str) -> None: if not text: return from core.handle.intentHandler import speak_txt if not getattr(conn, "sentence_id", None): conn.sentence_id = uuid.uuid4().hex try: speak_txt(conn, text) except Exception as e: logger.bind(tag=TAG).warning(f"主动播报失败: {e}") def _greeting_text(conn: "ConnectionHandler") -> str: return (conn.config or {}).get("proactive_chat_greeting") or "我们来聊天吧。" async def start_app_task( conn: "ConnectionHandler", app_id: str, task_id: str = "", params: Optional[Dict[str, Any]] = None, source: str = "voice", execute_skills: bool = True, ) -> ActionResponse: """Single entry for MQTT / schedule / voice / default chat.""" _ensure_runtime_fields(conn) params = params or {} app, task = get_task(app_id, task_id) if not app: return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}") if not task: default_task = app.get("default_task") or "" app, task = get_task(app_id, default_task) if not task: return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}") resolved_task_id = task_id or app.get("default_task") or "conversation" new_p = source_priority(source) cur_p = source_priority(getattr(conn, "current_source", "default_chat")) same_task = ( getattr(conn, "active_app_id", "") == app_id and getattr(conn, "current_task_id", "") == resolved_task_id ) if new_p < cur_p and not same_task: logger.bind(tag=TAG).info( f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})" ) return ActionResponse( action=Action.NONE, response="当前有更高优先级的任务,先完成后再切换", ) if not same_task or new_p >= cur_p: await _stop_current_skills(conn) conn.active_app_id = app_id conn.current_task_id = resolved_task_id conn.interaction_mode = task.get("interaction_mode") or app.get("mode") or "CHAT" conn.current_source = source conn.tool_policy = app.get("tool_policy") or "inherit_agent" conn.tool_allowlist = list(app.get("tools") or []) _apply_prompt(conn, app) _refresh_tools(conn) logger.bind(tag=TAG).info( f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}" ) if execute_skills: for skill in task.get("skills") or []: skill_name = (skill or {}).get("name") or "" if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL): play_args = { "name": params.get("name") or "", "repeat": int(params.get("repeat") or 1), "power_off_after": bool(params.get("power_off_after") or False), } if params.get("speed") is not None: play_args["speed"] = params.get("speed") if not play_args["name"]: return ActionResponse( action=Action.RESPONSE, response="没有指定要复读的文件名,请先用 self.mp3.get_list 确认 files[].name", ) result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args) if result and result.action in (Action.ERROR, Action.NOTFOUND): return ActionResponse( action=Action.RESPONSE, response=result.response or "这台设备没有本地复读播放能力", ) elif skill_name == "self.power_off": await _execute_named_tool(conn, "self_power_off", {}) if conn.interaction_mode == "CHAT": enable = (conn.config or {}).get("enable_proactive_greeting", True) woken = bool(getattr(conn, "just_woken_up", False)) if enable and not woken: _speak(conn, _greeting_text(conn)) app_name = app.get("name") or app_id return ActionResponse( action=Action.NONE, result=f"{app_id}/{resolved_task_id}", response=f"已打开{app_name}", ) async def switch_app(conn: "ConnectionHandler", app_id: str, source: str = "voice") -> ActionResponse: app = get_app(app_id) if not app: return ActionResponse(action=Action.ERROR, response=f"不支持的 App: {app_id}") task_id = app.get("default_task") or "" return await start_app_task(conn, app_id, task_id, {}, source) async def on_user_wakeup(conn: "ConnectionHandler") -> None: """User wake word returns to chat; do not treat [app_task] as wakeup.""" _ensure_runtime_fields(conn) if getattr(conn, "interaction_mode", "CHAT") == "CHAT" and getattr(conn, "active_app_id", "chat") == "chat": return await start_app_task(conn, "chat", "conversation", {}, source="voice") def match_voice_switch(text: str) -> Optional[tuple]: """Map a short phrase to (app_id, task_id) or ('__menu__', '').""" if not text: return None _, filtered = _strip(text) if filtered in ("切换模式", "qie huan mo shi"): return ("__menu__", "") for p in CHAT_PHRASES: if p in filtered: return ("chat", "conversation") for p in TRANSLATE_PHRASES: if p in filtered: return ("english_translation", "live_translate") for p in REPEATER_PHRASES: if p in filtered: return ("english_learning", "repeater") if any(k in filtered for k in ("数字背诵", "成语接龙", "口算", "加减法")): return ("__reserved__", "") return None def _strip(text: str) -> tuple: from core.utils.util import remove_punctuation_and_length return remove_punctuation_and_length(text) def is_exit_translation(text: str) -> bool: _, filtered = _strip(text) return any(p in filtered for p in ("退出翻译", "结束翻译", "回到聊天", "我们来聊天吧", "聊会天吧")) def _plain_user_text(text: str) -> str: """ASR 可能把原文包在 JSON 里,翻译只要 content。""" if not text: return "" raw = str(text).strip() if raw.startswith("{") and raw.endswith("}"): try: import json data = json.loads(raw) if isinstance(data, dict) and data.get("content"): return str(data["content"]).strip() except Exception: pass return raw def _looks_like_tool_error(text: str) -> bool: if not text: return True s = str(text) if "Error executing tool" in s or "isError=True" in s or "isError=true" in s: return True if "未配置" in s and "api_key" in s: return True return False async def run_translation(conn: "ConnectionHandler", text: str) -> str: """Translate with the connection LLM first (智控台已配的模型). MCP is fallback only.""" text = _plain_user_text(text) if not text: return "" from plugins_func.register import all_function_registry item = all_function_registry.get("translate") if item: try: maybe = item.func(conn, text=text) if hasattr(maybe, "__await__"): maybe = await maybe if isinstance(maybe, ActionResponse) and maybe.action not in ( Action.ERROR, Action.NOTFOUND, ): out = (maybe.response or maybe.result or "").strip() if out and not _looks_like_tool_error(out): return out except Exception as e: logger.bind(tag=TAG).warning(f"翻译插件失败: {e}") handler = getattr(conn, "func_handler", None) if handler and handler.has_tool("translate"): result = await _execute_named_tool(conn, "translate", {"text": text}) if result and result.action not in (Action.ERROR, Action.NOTFOUND): out = _extract_translate_text(result.response or result.result or "") if out and not _looks_like_tool_error(out): return out llm = getattr(conn, "llm", None) if llm and hasattr(llm, "response_no_stream"): try: return ( llm.response_no_stream(TRANSLATOR_PROMPT, text, temperature=0.1) or "" ).strip() except Exception as e: logger.bind(tag=TAG).error(f"翻译 LLM 失败: {e}") return "" def _extract_translate_text(raw: Any) -> str: if raw is None: return "" if isinstance(raw, str): text = raw.strip() if text.startswith("{") and "text" in text: try: import json data = json.loads(text) if isinstance(data, dict): return str(data.get("text") or data.get("content") or text) except Exception: pass return text if hasattr(raw, "content"): content = raw.content if isinstance(content, list) and content: first = content[0] return getattr(first, "text", None) or str(first) return str(content) return str(raw).strip()