"""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
|