wangzhibo
2026-08-31 17ac2ce0ed52874616c79c910667e41e34604826
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
"""Unified start_app_task / switch_app / mode helpers."""
 
from __future__ import annotations
 
import uuid
from typing import Any, Dict, Optional, TYPE_CHECKING
 
from config.logger import setup_logging
from plugins_func.register import Action, ActionResponse
 
from .registry import default_app_id, get_app, get_task
 
if TYPE_CHECKING:
    from core.connection import ConnectionHandler
 
TAG = __name__
logger = setup_logging()
 
SOURCE_PRIORITY = {
    "mqtt_immediate": 3,
    "schedule": 2,
    "voice": 1,
    "default_chat": 0,
}
 
TRANSLATOR_PROMPT = (
    "你是一台翻译机。只输出译文,不要解释、不要拼音课、不要追问、不要闲聊。"
    "未指定语言时:中文译成英文,英文译成中文;其它语言默认译成中文。"
)
 
CHAT_PROACTIVE_HINT = (
    "你是用户的语音伙伴。进入对话后可以先主动短句邀请聊天,例如「我们来聊天吧」。"
    "用户开口后按正常对话回答,不要反复催促。"
)
 
VOICE_MENU = "想聊天、听复读,还是打开翻译?也可以说我们来聊天吧。"
 
CHAT_PHRASES = ("我们来聊天吧", "聊会天吧", "回到聊天", "退出翻译", "结束翻译", "退出复读")
TRANSLATE_PHRASES = ("打开翻译", "开始翻译", "英语翻译", "翻译模式")
REPEATER_PHRASES = ("开始复读", "打开复读", "英语学习", "听复读")
 
DEVICE_PLAY_TOOL = "self_mp3_play"
DEVICE_STOP_TOOL = "self_mp3_stop"
 
 
def source_priority(source: str) -> int:
    return SOURCE_PRIORITY.get(source or "voice", 1)
 
 
def _ensure_runtime_fields(conn: "ConnectionHandler") -> None:
    if not hasattr(conn, "active_app_id"):
        conn.active_app_id = default_app_id()
    if not hasattr(conn, "current_task_id"):
        conn.current_task_id = "conversation"
    if not hasattr(conn, "interaction_mode"):
        conn.interaction_mode = "CHAT"
    if not hasattr(conn, "current_source"):
        conn.current_source = "default_chat"
    if not hasattr(conn, "tool_policy"):
        conn.tool_policy = "inherit_agent"
    if not hasattr(conn, "tool_allowlist"):
        conn.tool_allowlist = []
    if not hasattr(conn, "original_system_prompt"):
        conn.original_system_prompt = conn.prompt
 
 
def filter_tools(conn: "ConnectionHandler", tools: Dict[str, Any]) -> Dict[str, Any]:
    """inherit_agent keeps the live agent toolset; allowlist keeps named tools only."""
    _ensure_runtime_fields(conn)
    if getattr(conn, "tool_policy", "inherit_agent") != "allowlist":
        return tools
    allow = set(getattr(conn, "tool_allowlist", None) or [])
    if not allow:
        return {}
    filtered = {}
    for name, definition in tools.items():
        if name in allow:
            filtered[name] = definition
    return filtered
 
 
def _refresh_tools(conn: "ConnectionHandler") -> None:
    handler = getattr(conn, "func_handler", None)
    if handler and hasattr(handler, "upload_functions_desc"):
        handler.upload_functions_desc()
 
 
def _apply_prompt(conn: "ConnectionHandler", app: Dict[str, Any]) -> None:
    mode = app.get("system_prompt_mode") or "none"
    if conn.original_system_prompt is None:
        conn.original_system_prompt = conn.prompt
    if mode == "translator":
        conn.change_system_prompt(
            "你现在是英语翻译机。用户说话后应调用 translate;用户说退出翻译、回到聊天时调用 switch_app,app_id=chat。"
            "不要讲笑话、不要查天气、不要闲聊。"
        )
    elif mode == "agent_proactive":
        base = conn.original_system_prompt or conn.prompt or ""
        if CHAT_PROACTIVE_HINT not in (base or ""):
            conn.change_system_prompt((base + "\n" + CHAT_PROACTIVE_HINT).strip())
        else:
            conn.change_system_prompt(base)
    else:
        if conn.original_system_prompt:
            conn.change_system_prompt(conn.original_system_prompt)
 
 
async def _execute_named_tool(conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]):
    handler = getattr(conn, "func_handler", None)
    if not handler or not hasattr(handler, "tool_manager"):
        return ActionResponse(action=Action.ERROR, response="工具处理器未就绪")
    try:
        return await handler.tool_manager.execute_tool(tool_name, arguments or {})
    except Exception as e:
        logger.bind(tag=TAG).error(f"执行工具 {tool_name} 失败: {e}")
        return ActionResponse(action=Action.ERROR, response=str(e))
 
 
async def _stop_current_skills(conn: "ConnectionHandler") -> None:
    mode = getattr(conn, "interaction_mode", "CHAT")
    if mode == "PLAY_AUDIO":
        await _execute_named_tool(conn, DEVICE_STOP_TOOL, {})
    conn.awaiting_voice_app_choice = False
 
 
def _speak(conn: "ConnectionHandler", text: str) -> None:
    if not text:
        return
    from core.handle.intentHandler import speak_txt
 
    if not getattr(conn, "sentence_id", None):
        conn.sentence_id = uuid.uuid4().hex
    try:
        speak_txt(conn, text)
    except Exception as e:
        logger.bind(tag=TAG).warning(f"主动播报失败: {e}")
 
 
def _greeting_text(conn: "ConnectionHandler") -> str:
    return (conn.config or {}).get("proactive_chat_greeting") or "我们来聊天吧。"
 
 
async def start_app_task(
    conn: "ConnectionHandler",
    app_id: str,
    task_id: str = "",
    params: Optional[Dict[str, Any]] = None,
    source: str = "voice",
    execute_skills: bool = True,
) -> ActionResponse:
    """Single entry for MQTT / schedule / voice / default chat."""
    _ensure_runtime_fields(conn)
    params = params or {}
    app, task = get_task(app_id, task_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"未知的 App: {app_id}")
    if not task:
        default_task = app.get("default_task") or ""
        app, task = get_task(app_id, default_task)
    if not task:
        return ActionResponse(action=Action.ERROR, response=f"未知的 Task: {app_id}/{task_id}")
 
    resolved_task_id = task_id or app.get("default_task") or "conversation"
    new_p = source_priority(source)
    cur_p = source_priority(getattr(conn, "current_source", "default_chat"))
    same_task = (
        getattr(conn, "active_app_id", "") == app_id
        and getattr(conn, "current_task_id", "") == resolved_task_id
    )
    if new_p < cur_p and not same_task:
        logger.bind(tag=TAG).info(
            f"拒绝低优先级任务 {source}({new_p}) < {conn.current_source}({cur_p})"
        )
        return ActionResponse(
            action=Action.NONE,
            response="当前有更高优先级的任务,先完成后再切换",
        )
 
    if not same_task or new_p >= cur_p:
        await _stop_current_skills(conn)
 
    conn.active_app_id = app_id
    conn.current_task_id = resolved_task_id
    conn.interaction_mode = task.get("interaction_mode") or app.get("mode") or "CHAT"
    conn.current_source = source
    conn.tool_policy = app.get("tool_policy") or "inherit_agent"
    conn.tool_allowlist = list(app.get("tools") or [])
    _apply_prompt(conn, app)
    _refresh_tools(conn)
 
    logger.bind(tag=TAG).info(
        f"start_app_task app={app_id} task={resolved_task_id} mode={conn.interaction_mode} source={source}"
    )
 
    if execute_skills:
        for skill in task.get("skills") or []:
            skill_name = (skill or {}).get("name") or ""
            if skill_name in ("self.mp3.play", DEVICE_PLAY_TOOL):
                play_args = {
                    "name": params.get("name") or "",
                    "repeat": int(params.get("repeat") or 1),
                    "power_off_after": bool(params.get("power_off_after") or False),
                }
                if params.get("speed") is not None:
                    play_args["speed"] = params.get("speed")
                if not play_args["name"]:
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response="没有指定要复读的文件名,请先用 self.mp3.get_list 确认 files[].name",
                    )
                result = await _execute_named_tool(conn, DEVICE_PLAY_TOOL, play_args)
                if result and result.action in (Action.ERROR, Action.NOTFOUND):
                    return ActionResponse(
                        action=Action.RESPONSE,
                        response=result.response or "这台设备没有本地复读播放能力",
                    )
            elif skill_name == "self.power_off":
                await _execute_named_tool(conn, "self_power_off", {})
 
    if conn.interaction_mode == "CHAT":
        enable = (conn.config or {}).get("enable_proactive_greeting", True)
        woken = bool(getattr(conn, "just_woken_up", False))
        if enable and not woken:
            _speak(conn, _greeting_text(conn))
 
    app_name = app.get("name") or app_id
    return ActionResponse(
        action=Action.NONE,
        result=f"{app_id}/{resolved_task_id}",
        response=f"已打开{app_name}",
    )
 
 
async def switch_app(conn: "ConnectionHandler", app_id: str, source: str = "voice") -> ActionResponse:
    app = get_app(app_id)
    if not app:
        return ActionResponse(action=Action.ERROR, response=f"不支持的 App: {app_id}")
    task_id = app.get("default_task") or ""
    return await start_app_task(conn, app_id, task_id, {}, source)
 
 
async def on_user_wakeup(conn: "ConnectionHandler") -> None:
    """User wake word returns to chat; do not treat [app_task] as wakeup."""
    _ensure_runtime_fields(conn)
    if getattr(conn, "interaction_mode", "CHAT") == "CHAT" and getattr(conn, "active_app_id", "chat") == "chat":
        return
    await start_app_task(conn, "chat", "conversation", {}, source="voice")
 
 
def match_voice_switch(text: str) -> Optional[tuple]:
    """Map a short phrase to (app_id, task_id) or ('__menu__', '')."""
    if not text:
        return None
    _, filtered = _strip(text)
    if filtered in ("切换模式", "qie huan mo shi"):
        return ("__menu__", "")
    for p in CHAT_PHRASES:
        if p in filtered:
            return ("chat", "conversation")
    for p in TRANSLATE_PHRASES:
        if p in filtered:
            return ("english_translation", "live_translate")
    for p in REPEATER_PHRASES:
        if p in filtered:
            return ("english_learning", "repeater")
    if any(k in filtered for k in ("数字背诵", "成语接龙", "口算", "加减法")):
        return ("__reserved__", "")
    return None
 
 
def _strip(text: str) -> tuple:
    from core.utils.util import remove_punctuation_and_length
 
    return remove_punctuation_and_length(text)
 
 
def is_exit_translation(text: str) -> bool:
    _, filtered = _strip(text)
    return any(p in filtered for p in ("退出翻译", "结束翻译", "回到聊天", "我们来聊天吧", "聊会天吧"))
 
 
def _plain_user_text(text: str) -> str:
    """ASR 可能把原文包在 JSON 里,翻译只要 content。"""
    if not text:
        return ""
    raw = str(text).strip()
    if raw.startswith("{") and raw.endswith("}"):
        try:
            import json
 
            data = json.loads(raw)
            if isinstance(data, dict) and data.get("content"):
                return str(data["content"]).strip()
        except Exception:
            pass
    return raw
 
 
def _looks_like_tool_error(text: str) -> bool:
    if not text:
        return True
    s = str(text)
    if "Error executing tool" in s or "isError=True" in s or "isError=true" in s:
        return True
    if "未配置" in s and "api_key" in s:
        return True
    return False
 
 
async def run_translation(conn: "ConnectionHandler", text: str) -> str:
    """Translate with the connection LLM first (智控台已配的模型). MCP is fallback only."""
    text = _plain_user_text(text)
    if not text:
        return ""
 
    from plugins_func.register import all_function_registry
 
    item = all_function_registry.get("translate")
    if item:
        try:
            maybe = item.func(conn, text=text)
            if hasattr(maybe, "__await__"):
                maybe = await maybe
            if isinstance(maybe, ActionResponse) and maybe.action not in (
                Action.ERROR,
                Action.NOTFOUND,
            ):
                out = (maybe.response or maybe.result or "").strip()
                if out and not _looks_like_tool_error(out):
                    return out
        except Exception as e:
            logger.bind(tag=TAG).warning(f"翻译插件失败: {e}")
 
    handler = getattr(conn, "func_handler", None)
    if handler and handler.has_tool("translate"):
        result = await _execute_named_tool(conn, "translate", {"text": text})
        if result and result.action not in (Action.ERROR, Action.NOTFOUND):
            out = _extract_translate_text(result.response or result.result or "")
            if out and not _looks_like_tool_error(out):
                return out
 
    llm = getattr(conn, "llm", None)
    if llm and hasattr(llm, "response_no_stream"):
        try:
            return (
                llm.response_no_stream(TRANSLATOR_PROMPT, text, temperature=0.1) or ""
            ).strip()
        except Exception as e:
            logger.bind(tag=TAG).error(f"翻译 LLM 失败: {e}")
    return ""
 
 
def _extract_translate_text(raw: Any) -> str:
    if raw is None:
        return ""
    if isinstance(raw, str):
        text = raw.strip()
        if text.startswith("{") and "text" in text:
            try:
                import json
 
                data = json.loads(text)
                if isinstance(data, dict):
                    return str(data.get("text") or data.get("content") or text)
            except Exception:
                pass
        return text
    if hasattr(raw, "content"):
        content = raw.content
        if isinstance(content, list) and content:
            first = content[0]
            return getattr(first, "text", None) or str(first)
        return str(content)
    return str(raw).strip()