wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
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
"""Load the Phase 1 App / Task registry from JSON.
 
Registry lives on xiaozhi-server only (bundled apps.json, optional data/apps.json).
Task voice_enter / voice_exit / enter_prompt live here so new Apps do not need a
firmware flash. The device only keeps a small offline MultiNet set (mode switch,
repeater, exit repeater, return to chat) plus NVS pending / last-repeater state.
"""
 
import json
import os
from typing import Any, Dict, Optional, Tuple
 
from config.config_loader import get_project_dir
from config.logger import setup_logging
 
TAG = __name__
logger = setup_logging()
 
_REGISTRY: Optional[Dict[str, Any]] = None
_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
_EXIT_PRIO = 1
_CHAT_ENTER_PRIO = 2
_ENTER_PRIO = 3
_RESERVED_PRIO = 4
 
 
def _registry_candidates() -> list:
    return [
        os.path.join(get_project_dir(), "data", "apps.json"),
        _BUNDLED_PATH,
    ]
 
 
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",
        "apps": {
            "chat": {
                "name": "聊天",
                "mode": "CHAT",
                "default_task": "conversation",
                "tool_policy": "inherit_agent",
                "system_prompt_mode": "agent_proactive",
                "tasks": {"conversation": {"interaction_mode": "CHAT", "skills": []}},
            }
        },
    }
 
 
def get_registry() -> Dict[str, Any]:
    global _REGISTRY, _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
 
 
def reset_registry_cache() -> None:
    """Tests may swap the registry; drop derived indexes."""
    global _VOICE_INDEX
    _VOICE_INDEX = None
 
 
def iter_tasks():
    apps = get_registry().get("apps") or {}
    for app_id, app in apps.items():
        if not isinstance(app, dict):
            continue
        for task_id, task in (app.get("tasks") or {}).items():
            if isinstance(task, dict):
                yield app_id, app, task_id, task
 
 
def _phrase_list(raw) -> list:
    if raw is None:
        return []
    if isinstance(raw, str):
        text = raw.strip()
        return [text] if text else []
    phrases = []
    for item in raw:
        text = str(item or "").strip()
        if text:
            phrases.append(text)
    return phrases
 
 
def _build_voice_index() -> list:
    """Longer phrase first, then menu/exit/chat-enter/enter/reserved."""
    entries = []
    registry = get_registry()
    for phrase in _phrase_list(registry.get("voice_menu_phrases")):
        entries.append((phrase, ("__menu__", ""), _MENU_PRIO))
    for app_id, app, task_id, task in iter_tasks():
        default_task = app.get("default_task") or task_id
        for phrase in _phrase_list(task.get("voice_exit")):
            entries.append((phrase, ("chat", "conversation"), _EXIT_PRIO))
        for phrase in _phrase_list(task.get("voice_enter")):
            if app_id == "chat":
                entries.append((phrase, ("chat", default_task or task_id), _CHAT_ENTER_PRIO))
            else:
                entries.append((phrase, (app_id, task_id), _ENTER_PRIO))
    for phrase in _phrase_list(registry.get("reserved_voice_enter")):
        entries.append((phrase, ("__reserved__", ""), _RESERVED_PRIO))
    entries.sort(key=lambda item: (-len(item[0]), item[2]))
    return entries
 
 
def _voice_index() -> list:
    global _VOICE_INDEX
    if _VOICE_INDEX is None:
        _VOICE_INDEX = _build_voice_index()
    return _VOICE_INDEX
 
 
def match_registry_voice(filtered: str) -> Optional[Tuple[str, str]]:
    """Map stripped ASR text to (app_id, task_id) or ('__menu__'|'__reserved__', '')."""
    if not filtered:
        return None
    for phrase, target, _prio in _voice_index():
        if phrase and phrase in filtered:
            return target
    return None
 
 
def voice_menu_text() -> str:
    labels = []
    chat_hint = "我们来聊天吧"
    for app_id, _app, task_id, task in iter_tasks():
        enters = _phrase_list(task.get("voice_enter"))
        if app_id == "chat" and task_id == "conversation" and enters:
            chat_hint = enters[0]
        name = str(task.get("menu_name") or "").strip()
        if name:
            labels.append(name)
    if not labels:
        return f"想聊天?也可以说{chat_hint}。"
    if len(labels) == 1:
        body = labels[0]
    else:
        body = "、".join(labels[:-1]) + f",还是{labels[-1]}"
    return f"想{body}?也可以说{chat_hint}。"
 
 
def default_app_id() -> str:
    return get_registry().get("default_app_id") or "chat"
 
 
def get_app(app_id: str) -> Optional[Dict[str, Any]]:
    if not app_id:
        return None
    return get_registry().get("apps", {}).get(app_id)
 
 
def get_task(app_id: str, task_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
    app = get_app(app_id)
    if not app:
        return None, None
    tasks = app.get("tasks") or {}
    if not task_id:
        task_id = app.get("default_task") or ""
    task = tasks.get(task_id)
    if not task:
        return app, None
    return app, task