From 51ca12b62490b6bd23b6b4076b185d5a511daaa9 Mon Sep 17 00:00:00 2001 From: Miayui <774713819@qq.com> Date: Wed, 9 Sep 2026 14:37:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8B=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++ src/cognitive_agent.egg-info/SOURCES.txt | 9 + .../application/mode_profile.py | 26 ++ .../application/mode_scene_catalog.py | 248 ++++++++++++++ .../application/seat_service.py | 100 ++++++ src/cognitive_agent/lan_scene_server.py | 208 ++++++++++++ src/cognitive_agent/server.py | 57 ++++ tests/e2e/test_seat_web_api.py | 29 ++ tests/unit/test_mode_scene_catalog.py | 158 +++++++++ tools/start_n7web.py | 311 ++++++++++++++++++ 10 files changed, 1169 insertions(+) create mode 100644 src/cognitive_agent/application/mode_scene_catalog.py create mode 100644 src/cognitive_agent/lan_scene_server.py create mode 100644 tests/unit/test_mode_scene_catalog.py create mode 100644 tools/start_n7web.py diff --git a/README.md b/README.md index 5b23128..8bc7001 100755 --- a/README.md +++ b/README.md @@ -172,6 +172,29 @@ config/gesture/default.yaml 同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。 +### N7 模式卡片局域网目录 + +服务正常启动时,会把当前车型可见的工程预设模式和用户自定义模式原子同步到相邻目录 `../scenses/demo-scenes.json`,并提供无需登录、只读的 `GET /n7/scenes`。YAML 模式库仍是唯一真源;创建、更新、删除、恢复模式都会刷新 JSON,原有模式执行和安全校验链路不变。JSON 保留 `n7-scenes` v1 的 `id/label/background_time/description/actions` 字段,同时在动作中保留完整的 `domain/capability/target_scope/operation/parameters`,因此座椅、空调、天幕和氛围灯配置不会在导出时丢失。可用 `N7_SCENE_CATALOG_PATH` 或启动参数 `--scene-catalog` 覆盖输出路径。 + +主项目和客户端只读目录服务可以用一个监督脚本同时启动: + +```powershell +.\.venv\Scripts\python.exe tools\start_n7web.py +``` + +脚本自动设置 `PYTHONPATH`,主页面默认仅监听 `127.0.0.1:8000`,模式卡片 HTTP/UDP 服务监听 `0.0.0.0:8090`。需要让可信局域网内的设备同时访问主页面时,使用 `--host 0.0.0.0`;可通过 `--port`、`--scene-port`、`--catalog` 和 `--server-id` 覆盖其他参数。任一子服务异常退出或按一次 `Ctrl+C` 时,脚本会停止另一个服务。 + +同步由模式注册表的创建、更新、删除、恢复和显式重载事件触发,不会持续扫描目录。每次变更只重新生成并原子替换一份小型 JSON;独立目录服务在收到 HTTP 请求时才读取它。因此空闲时的额外开销主要是一个等待请求的 Python 进程和一个 UDP 发现线程,而不是持续的 CPU 或磁盘轮询。 + +如果只需单独启动 UDP 发现和只读目录服务,也可以在另一个 PowerShell 终端运行: + +```powershell +$env:PYTHONPATH = "src;domains/seat/src" +.\.venv\Scripts\python.exe -m cognitive_agent.lan_scene_server --bind 0.0.0.0 --port 8090 +``` + +客户端向 UDP `8090` 发送 `N7_SCENES_DISCOVER_V1`,收到 `N7_SCENES_V1` 后访问 `http://<服务端IP>:8090/n7/scenes`。独立服务每次请求都读取最新的 JSON,并为只读接口开放 CORS;它不提供模式创建、修改、删除或执行能力。 + Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用 `zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口 验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。 diff --git a/src/cognitive_agent.egg-info/SOURCES.txt b/src/cognitive_agent.egg-info/SOURCES.txt index 3517530..2f98f65 100755 --- a/src/cognitive_agent.egg-info/SOURCES.txt +++ b/src/cognitive_agent.egg-info/SOURCES.txt @@ -9,19 +9,25 @@ src/cognitive_agent.egg-info/SOURCES.txt src/cognitive_agent.egg-info/dependency_links.txt src/cognitive_agent.egg-info/requires.txt src/cognitive_agent.egg-info/top_level.txt +src/cognitive_agent/application/assistant_capabilities.py +src/cognitive_agent/application/assistant_plan_guard.py src/cognitive_agent/application/capability_registry.py src/cognitive_agent/application/chinese_values.py +src/cognitive_agent/application/codex_assistant.py src/cognitive_agent/application/conversation_state.py src/cognitive_agent/application/direct_can_control.py src/cognitive_agent/application/domain_runtime.py src/cognitive_agent/application/gesture_bridge.py src/cognitive_agent/application/mode_profile.py +src/cognitive_agent/application/mode_profile_intent.py src/cognitive_agent/application/model_plan_adapter.py src/cognitive_agent/application/mqtt_ota_seat_executor.py src/cognitive_agent/application/native_can_feedback.py src/cognitive_agent/application/native_can_intent.py src/cognitive_agent/application/native_can_relative.py src/cognitive_agent/application/native_can_runtime.py +src/cognitive_agent/application/scene_composer.py +src/cognitive_agent/application/scene_templates.py src/cognitive_agent/application/seat_calibration.py src/cognitive_agent/application/seat_service.py src/cognitive_agent/application/self_extension.py @@ -51,6 +57,8 @@ src/cognitive_agent/infrastructure/can/matrix.py src/cognitive_agent/infrastructure/can/mqtt_transport.py src/cognitive_agent/infrastructure/can/zlgcan_bridge.py src/cognitive_agent/infrastructure/can/zlgcan_linux_bridge.py +src/cognitive_agent/infrastructure/codex/app_server.py +src/cognitive_agent/infrastructure/codex/vehicle_mcp_server.py src/cognitive_agent/infrastructure/firmware/__init__.py src/cognitive_agent/infrastructure/firmware/t2g_seat.py src/cognitive_agent/infrastructure/llm/openai_responses.py @@ -68,6 +76,7 @@ src/cognitive_agent/infrastructure/speech/tracing.py src/cognitive_agent/infrastructure/speech/understanding.py src/cognitive_agent/infrastructure/speech/vad.py src/cognitive_agent/infrastructure/speech/vocabulary.py +src/cognitive_agent/infrastructure/speech/wake_word.py src/cognitive_agent/runtime/contracts.py src/cognitive_agent/testing/__init__.py src/cognitive_agent/testing/ch1_hil.py \ No newline at end of file diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index f2ca53e..2f0648b 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -5,6 +5,7 @@ from __future__ import annotations import hashlib import json import re +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from threading import RLock @@ -525,6 +526,7 @@ class ModeProfileRegistry: self.user_root = Path(user_root) if user_root is not None else self.profile_root self.session_user_profiles = user_root is not None self._profiles: list[ModeProfile] = [] + self._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = [] self._write_lock = RLock() self.reload() @@ -547,6 +549,30 @@ class ModeProfileRegistry: if path.is_file() ) self._profiles = profiles + self._notify_change_listeners() + + def add_change_listener( + self, + listener: Callable[["ModeProfileRegistry"], None], + *, + notify: bool = True, + ) -> None: + """Run ``listener`` after every successful registry reload. + + Mode profiles remain YAML-backed. Listeners are intended for derived, + read-only projections such as the LAN scene catalog; they do not replace + or alter the existing persistence path. + """ + + with self._write_lock: + if listener not in self._change_listeners: + self._change_listeners.append(listener) + if notify: + listener(self) + + def _notify_change_listeners(self) -> None: + for listener in tuple(self._change_listeners): + listener(self) def profiles(self) -> list[ModeProfile]: return list(self._profiles) diff --git a/src/cognitive_agent/application/mode_scene_catalog.py b/src/cognitive_agent/application/mode_scene_catalog.py new file mode 100644 index 0000000..f41f10a --- /dev/null +++ b/src/cognitive_agent/application/mode_scene_catalog.py @@ -0,0 +1,248 @@ +"""Project persisted mode cards into the read-only N7 scene catalog contract.""" + +from __future__ import annotations + +import hashlib +import json +import os +import threading +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from cognitive_agent.application.mode_profile import ( + ModeOperation, + ModeProfile, + ModeProfileRegistry, +) + +N7_SCENE_SERVICE = "n7-scenes" +N7_SCENE_SCHEMA_VERSION = 1 +DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent" +DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录" + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _background_time(profile: ModeProfile) -> str: + """Choose a compatible visual hint when the mode schema has no time field.""" + + text = f"{profile.name} {profile.description}" + if any(keyword in text for keyword in ("晨", "早间", "清晨")): + return "morning" + if any(keyword in text for keyword in ("小憩", "午睡", "睡眠", "夜", "冥想")): + return "evening" + return "noon" + + +def _action_type(capability: str) -> str: + legacy_types = { + "climate.set_power": "climate_power", + "climate.set_temperature": "climate_temperature", + "climate.set_fan_level": "climate_fan", + } + if capability in legacy_types: + return legacy_types[capability] + domain, separator, action = capability.partition(".") + if not separator: + return capability.replace(".", "_") + if action.startswith("set_"): + action = action.removeprefix("set_") + return f"{domain}_{action}".replace(".", "_") + + +def _legacy_zone(target_scope: str) -> str: + return { + "vehicle": "both", + "all": "both", + "passenger": "passenger", + "front_right": "passenger", + "driver": "driver", + "front_left": "driver", + }.get(target_scope, target_scope) + + +def _scene_action(operation: ModeOperation) -> dict[str, Any]: + """Preserve the complete operation and add fields understood by the v1 demo.""" + + parameters = deepcopy(operation.parameters) + action: dict[str, Any] = { + "type": _action_type(operation.capability), + "at_ms": 0, + "domain": operation.domain, + "capability": operation.capability, + "target_scope": operation.target_scope, + "operation": operation.operation, + "parameters": parameters, + } + + # The original demo catalog flattened these three climate actions. Keep + # those aliases so an existing v1 client can continue reading them, while + # the generic fields above retain every seat/roof/light/custom operation. + if operation.capability == "climate.set_power" and "state" in parameters: + state = parameters["state"] + action["enabled"] = ( + state + if isinstance(state, bool) + else str(state).strip().lower() in {"1", "true", "on", "enabled"} + ) + elif ( + operation.capability == "climate.set_temperature" + and "temperature_c" in parameters + ): + action["zone"] = _legacy_zone(operation.target_scope) + action["celsius"] = parameters["temperature_c"] + elif operation.capability == "climate.set_fan_level" and "level" in parameters: + action["level"] = parameters["level"] + return action + + +def _scene_from_profile( + registry: ModeProfileRegistry, + profile: ModeProfile, + public_profile: dict[str, Any], +) -> dict[str, Any]: + scene = { + "id": profile.id, + "label": profile.name, + "name": profile.name, + "background_time": _background_time(profile), + "description": profile.description, + "icon": profile.icon, + "aliases": list(profile.aliases), + "origin": public_profile.get("origin", "preset"), + "deletable": bool(public_profile.get("deletable", False)), + "behavior": public_profile.get("behavior", "static"), + "domains": list(public_profile.get("domains") or []), + "operation_count": len(profile.operations), + "scope": deepcopy(public_profile.get("scope") or {}), + "profile_revision": registry.profile_revision(profile), + "actions": [_scene_action(operation) for operation in profile.operations], + } + if public_profile.get("preset_override") is True: + scene["preset_override"] = True + if profile.timeline is not None: + scene["timeline"] = profile.timeline.public_dict() + return scene + + +def build_mode_scene_catalog( + registry: ModeProfileRegistry, + vehicle_id: str, + *, + server_id: str = DEFAULT_N7_SCENE_SERVER_ID, + name: str = DEFAULT_N7_SCENE_SERVER_NAME, + generated_at: str | None = None, +) -> dict[str, Any]: + """Build a lossless, client-safe projection of effective mode profiles.""" + + clean_vehicle_id = str(vehicle_id).strip() + if not clean_vehicle_id: + raise ValueError("scene catalog vehicle_id must not be empty") + clean_server_id = str(server_id).strip() + if not clean_server_id: + raise ValueError("scene catalog server_id must not be empty") + + public_profiles = registry.public_modes(clean_vehicle_id) + scenes = [ + _scene_from_profile( + registry, + registry.get_profile(str(public_profile["id"]), clean_vehicle_id), + public_profile, + ) + for public_profile in public_profiles + ] + revision_source = { + "service": N7_SCENE_SERVICE, + "schema_version": N7_SCENE_SCHEMA_VERSION, + "server_id": clean_server_id, + "name": str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME, + "vehicle_id": clean_vehicle_id, + "scenes": scenes, + } + canonical = json.dumps( + revision_source, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return { + **revision_source, + "catalog_revision": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16], + "generated_at": generated_at or _utc_now(), + "scene_count": len(scenes), + "read_only": True, + } + + +class ModeSceneCatalogPublisher: + """Atomically maintain the JSON projection consumed by LAN clients.""" + + def __init__( + self, + path: Path, + vehicle_id: str, + *, + server_id: str = DEFAULT_N7_SCENE_SERVER_ID, + name: str = DEFAULT_N7_SCENE_SERVER_NAME, + ) -> None: + self.path = Path(path) + self.vehicle_id = str(vehicle_id).strip() + self.server_id = str(server_id).strip() or DEFAULT_N7_SCENE_SERVER_ID + self.name = str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME + self._lock = threading.RLock() + self._catalog: dict[str, Any] | None = None + + def publish(self, registry: ModeProfileRegistry) -> dict[str, Any]: + catalog = build_mode_scene_catalog( + registry, + self.vehicle_id, + server_id=self.server_id, + name=self.name, + ) + with self._lock: + on_disk = self._read_catalog() + if ( + on_disk is not None + and on_disk.get("catalog_revision") == catalog["catalog_revision"] + and { + key: value for key, value in on_disk.items() if key != "generated_at" + } + == { + key: value for key, value in catalog.items() if key != "generated_at" + } + ): + self._catalog = on_disk + return deepcopy(on_disk) + + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name( + f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp" + ) + try: + temporary.write_text( + json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temporary.replace(self.path) + finally: + if temporary.exists(): + temporary.unlink() + self._catalog = catalog + return deepcopy(catalog) + + def snapshot(self) -> dict[str, Any] | None: + with self._lock: + return deepcopy(self._catalog) + + def _read_catalog(self) -> dict[str, Any] | None: + if not self.path.is_file(): + return None + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return document if isinstance(document, dict) else None diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 5a4a7ab..1a722d7 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -51,6 +51,12 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402 ModeProfile, ModeProfileRegistry, ) +from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 + DEFAULT_N7_SCENE_SERVER_ID, + DEFAULT_N7_SCENE_SERVER_NAME, + ModeSceneCatalogPublisher, + build_mode_scene_catalog, +) from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 is_mode_profile_query, normalize_mode_profile_name, @@ -414,6 +420,17 @@ class SeatDemoService: repository_root / "config" / "modes", user_root=self.mode_profile_user_root, ) + self._mode_scene_catalog_publisher: ModeSceneCatalogPublisher | None = None + self._mode_scene_catalog_status: dict[str, Any] = { + "enabled": False, + "state": "disabled", + "path": None, + "vehicle_id": self.vehicle_profiles.default().id, + "server_id": DEFAULT_N7_SCENE_SERVER_ID, + "catalog_revision": None, + "scene_count": 0, + "last_error": None, + } self.scene_templates = SceneTemplateRegistry( repository_root / "config" / "scene_templates" ) @@ -432,6 +449,88 @@ class SeatDemoService: if hasattr(self, "conversations"): self.conversations.preset_root = self._artifact_root / "user-presets" + def configure_mode_scene_catalog( + self, + path: Path, + *, + server_id: str = DEFAULT_N7_SCENE_SERVER_ID, + name: str = DEFAULT_N7_SCENE_SERVER_NAME, + ) -> dict[str, Any]: + """Enable the derived JSON catalog without changing YAML persistence.""" + + catalog_path = Path(path).expanduser() + if not catalog_path.is_absolute(): + catalog_path = self.root / catalog_path + vehicle_id = self.vehicle_profiles.default().id + self._mode_scene_catalog_publisher = ModeSceneCatalogPublisher( + catalog_path, + vehicle_id, + server_id=server_id, + name=name, + ) + self._mode_scene_catalog_status = { + "enabled": True, + "state": "initializing", + "path": str(catalog_path), + "vehicle_id": vehicle_id, + "server_id": self._mode_scene_catalog_publisher.server_id, + "catalog_revision": None, + "scene_count": 0, + "last_error": None, + } + # The registry notifies after create, update, delete, restore, and any + # explicit reload. The callback only updates a derived projection; YAML + # remains the authoritative store used by every existing execution path. + self.mode_profiles.add_change_listener(self._synchronize_mode_scene_catalog) + return self.mode_scene_catalog_status() + + def _synchronize_mode_scene_catalog(self, registry: ModeProfileRegistry) -> None: + publisher = self._mode_scene_catalog_publisher + if publisher is None: + return + try: + catalog = publisher.publish(registry) + except Exception as exc: # noqa: BLE001 - derived export must not block controls + self._mode_scene_catalog_status.update( + { + "state": "error", + "last_error": str(exc), + } + ) + return + self._mode_scene_catalog_status.update( + { + "state": "ready", + "catalog_revision": catalog["catalog_revision"], + "scene_count": catalog["scene_count"], + "last_error": None, + } + ) + + def mode_scene_catalog(self, vehicle_id: str | None = None) -> dict[str, Any]: + """Return the current read-only mode-card catalog for an N7 client.""" + + resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id + publisher = self._mode_scene_catalog_publisher + if publisher is not None and resolved_vehicle_id == publisher.vehicle_id: + self._synchronize_mode_scene_catalog(self.mode_profiles) + snapshot = publisher.snapshot() + if snapshot is not None and self._mode_scene_catalog_status["state"] == "ready": + return snapshot + return build_mode_scene_catalog( + self.mode_profiles, + resolved_vehicle_id, + server_id=( + publisher.server_id + if publisher is not None + else DEFAULT_N7_SCENE_SERVER_ID + ), + name=(publisher.name if publisher is not None else DEFAULT_N7_SCENE_SERVER_NAME), + ) + + def mode_scene_catalog_status(self) -> dict[str, Any]: + return deepcopy(self._mode_scene_catalog_status) + @property def driver_only_ch1_execution_active(self) -> bool: """Return whether deployment policy authorizes only the CH1 vehicle path.""" @@ -588,6 +687,7 @@ class SeatDemoService: "scene_templates": self.scene_templates.public_templates(), "mode_profile_storage": "persistent_yaml", "mode_profile_user_root": str(self.mode_profile_user_root), + "mode_scene_catalog": self.mode_scene_catalog_status(), "preset_storage": "conversation_durable", "inferred_level_range": [1, 3], "motion_position_range_pct": [0, 100], diff --git a/src/cognitive_agent/lan_scene_server.py b/src/cognitive_agent/lan_scene_server.py new file mode 100644 index 0000000..bb2415c --- /dev/null +++ b/src/cognitive_agent/lan_scene_server.py @@ -0,0 +1,208 @@ +"""Serve the synchronized N7 mode-card catalog over HTTP and UDP discovery.""" + +from __future__ import annotations + +import argparse +import json +import os +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from socketserver import BaseRequestHandler, ThreadingUDPServer +from typing import Any +from urllib.parse import unquote, urlparse + +DISCOVERY_REQUEST = b"N7_SCENES_DISCOVER_V1" +DISCOVERY_RESPONSE = b"N7_SCENES_V1" +DEFAULT_CATALOG_PATH = ( + Path(__file__).resolve().parents[2].parent / "scenses" / "demo-scenes.json" +) + + +class SceneCatalogError(ValueError): + """Raised when the synchronized catalog cannot satisfy the public contract.""" + + +class SceneCatalogSource: + def __init__(self, path: Path, server_id: str | None = None) -> None: + self.path = Path(path) + self.server_id = str(server_id or "").strip() or None + + def load(self) -> dict[str, Any]: + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + except OSError as exc: + raise SceneCatalogError(f"scene catalog is unavailable: {self.path}") from exc + except json.JSONDecodeError as exc: + raise SceneCatalogError(f"scene catalog is invalid JSON: {self.path}") from exc + if not isinstance(document, dict): + raise SceneCatalogError("scene catalog root must be an object") + if document.get("service") != "n7-scenes": + raise SceneCatalogError("scene catalog service must be n7-scenes") + if document.get("schema_version") != 1: + raise SceneCatalogError("scene catalog schema_version must be 1") + scenes = document.get("scenes") + if not isinstance(scenes, list): + raise SceneCatalogError("scene catalog scenes must be an array") + scene_ids = [ + str(scene.get("id") or "").strip() + for scene in scenes + if isinstance(scene, dict) + ] + if len(scene_ids) != len(scenes) or any(not scene_id for scene_id in scene_ids): + raise SceneCatalogError("every scene must be an object with a non-empty id") + if len(set(scene_ids)) != len(scene_ids): + raise SceneCatalogError("scene ids must be unique") + + payload = dict(document) + if self.server_id is not None: + payload["server_id"] = self.server_id + payload["scene_count"] = len(scenes) + return payload + + +class N7SceneHttpServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], source: SceneCatalogSource) -> None: + self.catalog_source = source + super().__init__(address, N7SceneRequestHandler) + + +class N7SceneRequestHandler(BaseHTTPRequestHandler): + server_version = "N7SceneCatalog/1.0" + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + path = unquote(urlparse(self.path).path) + if path == "/n7/scenes": + try: + self._json(self._catalog_source().load()) + except SceneCatalogError as exc: + self._json( + {"error": "catalog_unavailable", "message": str(exc)}, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + if path == "/n7/health": + try: + catalog = self._catalog_source().load() + except SceneCatalogError as exc: + self._json( + {"status": "unavailable", "message": str(exc)}, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + self._json( + { + "status": "ok", + "service": catalog["service"], + "server_id": catalog.get("server_id"), + "catalog_revision": catalog.get("catalog_revision"), + "scene_count": catalog["scene_count"], + } + ) + return + self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + + def do_OPTIONS(self) -> None: # noqa: N802 - stdlib handler API + path = unquote(urlparse(self.path).path) + if path not in {"/n7/scenes", "/n7/health"}: + self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + return + self.send_response(HTTPStatus.NO_CONTENT) + self._common_headers() + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Accept, Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + + def _catalog_source(self) -> SceneCatalogSource: + source = getattr(self.server, "catalog_source", None) + if not isinstance(source, SceneCatalogSource): + raise SceneCatalogError("scene catalog source is not configured") + return source + + def _json( + self, + payload: object, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + content = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self._common_headers() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def _common_headers(self) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("X-Content-Type-Options", "nosniff") + + +class N7SceneUdpServer(ThreadingUDPServer): + daemon_threads = True + allow_reuse_address = True + + +class N7SceneDiscoveryHandler(BaseRequestHandler): + def handle(self) -> None: + data, udp_socket = self.request + if bytes(data).strip() == DISCOVERY_REQUEST: + udp_socket.sendto(DISCOVERY_RESPONSE, self.client_address) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Serve the N7 LAN mode scene catalog") + parser.add_argument("--bind", default=os.getenv("N7_SCENE_BIND", "0.0.0.0")) + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("N7_SCENE_PORT", "8090")), + ) + parser.add_argument( + "--server-id", + default=os.getenv("N7_SCENE_SERVER_ID", "n7-cognitive-agent"), + ) + parser.add_argument( + "--catalog", + type=Path, + default=Path( + os.getenv("N7_SCENE_CATALOG_PATH", "").strip() or DEFAULT_CATALOG_PATH + ), + ) + args = parser.parse_args() + + source = SceneCatalogSource(args.catalog, args.server_id) + source.load() # Fail before binding when the shared file is missing or malformed. + http_server = N7SceneHttpServer((args.bind, args.port), source) + udp_server = N7SceneUdpServer( + (args.bind, http_server.server_port), + N7SceneDiscoveryHandler, + ) + udp_thread = threading.Thread( + target=udp_server.serve_forever, + name="n7-scene-discovery", + daemon=True, + ) + udp_thread.start() + print( + f"N7 scene catalog: http://{args.bind}:{http_server.server_port}/n7/scenes", + flush=True, + ) + try: + http_server.serve_forever() + except KeyboardInterrupt: + pass + finally: + http_server.server_close() + udp_server.shutdown() + udp_server.server_close() + udp_thread.join(timeout=2) + + +if __name__ == "__main__": + main() diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 0918f36..1b3ed83 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -10,6 +10,7 @@ import mimetypes import os import re import socket +import sys import threading import time from collections.abc import Callable, Mapping @@ -84,6 +85,9 @@ from cognitive_agent.infrastructure.speech import ( # noqa: E402 ) STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console" +DEFAULT_MODE_SCENE_CATALOG_PATH = ( + REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json" +) mimetypes.add_type("application/manifest+json", ".webmanifest") mimetypes.add_type("model/gltf-binary", ".glb") REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$") @@ -258,6 +262,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler): HTTPStatus.OK if assistant_available else HTTPStatus.SERVICE_UNAVAILABLE, ) return + if path == "/n7/scenes": + vehicle_id = str(parse_qs(parsed_url.query).get("vehicle_id", [""])[0]) or None + self._json( + self.service.mode_scene_catalog(vehicle_id), + headers={"Access-Control-Allow-Origin": "*"}, + ) + return if path in {"/login", "/login.html"}: if self.auth.config.enabled and self._session_username() is not None: self._redirect("/") @@ -440,6 +451,20 @@ class SeatDemoHandler(BaseHTTPRequestHandler): return self._serve_static(path, parsed_url.query) + def do_OPTIONS(self) -> None: # noqa: N802 - stdlib handler API + path = unquote(urlparse(self.path).path) + if path != "/n7/scenes": + self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + return + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Accept, Content-Type") + self.send_header("Content-Length", "0") + self.send_header("Cache-Control", "no-store") + self._send_security_headers() + self.end_headers() + def do_POST(self) -> None: # noqa: N802 - stdlib handler API path = unquote(urlparse(self.path).path) if not self._require_same_origin(): @@ -3306,7 +3331,25 @@ def main() -> None: parser = argparse.ArgumentParser(description="Run the cabin orchestration demo") parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1")) parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000"))) + parser.add_argument( + "--scene-catalog", + type=Path, + default=Path( + os.getenv("N7_SCENE_CATALOG_PATH", "").strip() + or DEFAULT_MODE_SCENE_CATALOG_PATH + ), + help="JSON path synchronized from preset and user-created mode cards", + ) + parser.add_argument( + "--scene-server-id", + default=os.getenv("N7_SCENE_SERVER_ID", "n7-cognitive-agent"), + help="server_id written to the N7 scene catalog", + ) args = parser.parse_args() + scene_catalog_status = SeatDemoHandler.service.configure_mode_scene_catalog( + args.scene_catalog, + server_id=args.scene_server_id, + ) server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler) SeatDemoHandler.speech.start() SeatDemoHandler.assistant.start( @@ -3331,6 +3374,20 @@ def main() -> None: ) else: print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True) + if scene_catalog_status["state"] == "ready": + print( + "N7 mode scene catalog: " + f"{scene_catalog_status['path']} " + f"({scene_catalog_status['scene_count']} scenes)", + flush=True, + ) + else: + print( + "N7 mode scene catalog synchronization unavailable: " + f"{scene_catalog_status.get('last_error') or 'unknown error'}", + file=sys.stderr, + flush=True, + ) hardware_execution_ready = any( ( SeatDemoHandler.service.hardware_settings.configured, diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 1c19d9d..39e8bc5 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -134,6 +134,35 @@ class SeatWebApiTests(unittest.TestCase): unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3) self.assertEqual(caught.exception.code, 401) + def test_n7_mode_scene_catalog_is_public_and_read_only(self) -> None: + unauthenticated = urllib.request.build_opener() + + with unauthenticated.open(f"{self.base_url}/n7/scenes", timeout=3) as response: + catalog = json.loads(response.read().decode("utf-8")) + + self.assertEqual(response.status, 200) + self.assertEqual(response.headers["Cache-Control"], "no-store") + self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*") + self.assertEqual(catalog["service"], "n7-scenes") + self.assertEqual(catalog["schema_version"], 1) + self.assertEqual(catalog["vehicle_id"], "denza_n7") + self.assertGreaterEqual(catalog["scene_count"], 13) + self.assertEqual(catalog["scene_count"], len(catalog["scenes"])) + office = next(scene for scene in catalog["scenes"] if scene["id"] == "office") + self.assertIn(office["origin"], {"preset", "customized"}) + self.assertTrue(office["actions"]) + self.assertIn("capability", office["actions"][0]) + self.assertNotIn("command_flow", json.dumps(catalog, ensure_ascii=False)) + + preflight = urllib.request.Request( + f"{self.base_url}/n7/scenes", + method="OPTIONS", + ) + with unauthenticated.open(preflight, timeout=3) as response: + self.assertEqual(response.status, 204) + self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*") + self.assertIn("GET", response.headers["Access-Control-Allow-Methods"]) + def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None: original_registry = self.service.mode_profiles with tempfile.TemporaryDirectory() as directory: diff --git a/tests/unit/test_mode_scene_catalog.py b/tests/unit/test_mode_scene_catalog.py new file mode 100644 index 0000000..f8ec607 --- /dev/null +++ b/tests/unit/test_mode_scene_catalog.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.mode_scene_catalog import ( + ModeSceneCatalogPublisher, + build_mode_scene_catalog, +) +from cognitive_agent.application.seat_service import REPOSITORY_ROOT + + +def _write_office_preset(path: Path) -> None: + path.parent.mkdir(parents=True) + path.write_text( + """ +id: office +name: 办公模式 +description: N7 办公场景。 +icon: briefcase +scope: + vehicle_ids: [denza_n7] +operations: + - domain: climate + capability: climate.set_temperature + target_scope: passenger + operation: set + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + + +def test_publisher_tracks_create_override_and_delete(tmp_path: Path) -> None: + preset_root = tmp_path / "presets" + user_root = tmp_path / "user" + _write_office_preset( + preset_root / "vehicles" / "denza_n7" / "preset" / "office.yaml" + ) + registry = ModeProfileRegistry(preset_root, user_root=user_root) + catalog_path = tmp_path / "scenses" / "demo-scenes.json" + publisher = ModeSceneCatalogPublisher( + catalog_path, + "denza_n7", + server_id="test-n7", + ) + registry.add_change_listener(publisher.publish) + + initial = json.loads(catalog_path.read_text(encoding="utf-8")) + assert initial["service"] == "n7-scenes" + assert initial["schema_version"] == 1 + assert initial["server_id"] == "test-n7" + assert initial["scene_count"] == 1 + assert initial["scenes"][0]["origin"] == "preset" + assert initial["scenes"][0]["actions"] == [ + { + "type": "climate_temperature", + "at_ms": 0, + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "passenger", + "operation": "set", + "parameters": {"temperature_c": 24}, + "zone": "passenger", + "celsius": 24, + } + ] + + created = registry.save_mode( + "阅读模式", + [ + { + "domain": "ambient_light", + "capability": "ambient_light.set_illumination", + "target_scope": "vehicle", + "parameters": {"color_hex": "#E8F3FF", "brightness_pct": 40}, + } + ], + vehicle_id="denza_n7", + ) + after_create = json.loads(catalog_path.read_text(encoding="utf-8")) + created_scene = next( + scene for scene in after_create["scenes"] if scene["id"] == created.id + ) + assert after_create["scene_count"] == 2 + assert created_scene["origin"] == "user" + assert created_scene["deletable"] is True + assert created_scene["actions"][0]["parameters"]["brightness_pct"] == 40 + + fan_mode = registry.save_mode( + "微风模式", + [ + { + "domain": "climate", + "capability": "climate.set_fan_level", + "target_scope": "vehicle", + "parameters": {"level": 2}, + } + ], + vehicle_id="denza_n7", + ) + after_fan_create = json.loads(catalog_path.read_text(encoding="utf-8")) + fan_scene = next( + scene for scene in after_fan_create["scenes"] if scene["id"] == fan_mode.id + ) + assert fan_scene["actions"][0]["type"] == "climate_fan" + assert fan_scene["actions"][0]["level"] == 2 + + registry.save_mode( + "专注办公模式", + [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "passenger", + "parameters": {"temperature_c": 26}, + } + ], + mode_id="office", + vehicle_id="denza_n7", + ) + after_override = json.loads(catalog_path.read_text(encoding="utf-8")) + office = next(scene for scene in after_override["scenes"] if scene["id"] == "office") + assert office["label"] == "专注办公模式" + assert office["origin"] == "customized" + assert office["preset_override"] is True + assert office["actions"][0]["celsius"] == 26 + + registry.delete_user_mode(created.id, "denza_n7") + registry.delete_user_mode(fan_mode.id, "denza_n7") + registry.delete_user_mode("office", "denza_n7") + after_delete = json.loads(catalog_path.read_text(encoding="utf-8")) + assert [scene["id"] for scene in after_delete["scenes"]] == ["office"] + assert after_delete["scenes"][0]["label"] == "办公模式" + assert after_delete["scenes"][0]["origin"] == "preset" + + +def test_repository_presets_are_exported_without_internal_command_flows() -> None: + registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes") + + catalog = build_mode_scene_catalog( + registry, + "denza_n7", + generated_at="2026-09-09T00:00:00Z", + ) + + assert catalog["scene_count"] >= 13 + assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"] + by_id = {scene["id"]: scene for scene in catalog["scenes"]} + assert by_id["office"]["operation_count"] == 10 + assert by_id["rocking_horse"]["behavior"] == "cyclic" + assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout" + encoded = json.dumps(catalog, ensure_ascii=False) + assert "command_flow" not in encoded + assert "source_request_id" not in encoded diff --git a/tools/start_n7web.py b/tools/start_n7web.py new file mode 100644 index 0000000..d7270c0 --- /dev/null +++ b/tools/start_n7web.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Start N7Web and its read-only LAN mode-card service together.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CATALOG_PATH = REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json" +PYTHON_SOURCE_PATHS = ( + REPOSITORY_ROOT / "src", + REPOSITORY_ROOT / "domains" / "seat" / "src", +) + + +@dataclass(frozen=True) +class ChildProcess: + name: str + process: subprocess.Popen[bytes] + + +def _port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("port must be an integer") from exc + if not 1 <= port <= 65_535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Start the N7Web application and the read-only LAN mode-card service. " + "Mode-card synchronization is event-driven inside the main service." + ) + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="main N7Web bind address (use 0.0.0.0 for trusted-LAN access)", + ) + parser.add_argument("--port", type=_port, default=8000, help="main N7Web port") + parser.add_argument( + "--scene-bind", + default="0.0.0.0", + help="read-only mode-card HTTP/UDP bind address", + ) + parser.add_argument( + "--scene-port", + type=_port, + default=8090, + help="read-only mode-card HTTP/UDP port", + ) + parser.add_argument( + "--catalog", + type=Path, + default=DEFAULT_CATALOG_PATH, + help="synchronized n7-scenes JSON path", + ) + parser.add_argument( + "--server-id", + default="n7-cognitive-agent", + help="server_id written to and served with the catalog", + ) + parser.add_argument( + "--python", + type=Path, + help="child Python executable (defaults to the repository .venv)", + ) + parser.add_argument( + "--catalog-ready-timeout", + type=float, + default=30.0, + help="seconds to wait for the main service to create a valid catalog", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the resolved commands without starting processes", + ) + return parser + + +def _resolve_path(path: Path) -> Path: + expanded = path.expanduser() + if not expanded.is_absolute(): + expanded = REPOSITORY_ROOT / expanded + return expanded.resolve() + + +def _resolve_python(configured: Path | None) -> Path: + if configured is not None: + executable = _resolve_path(configured) + if not executable.is_file(): + raise ValueError(f"Python executable does not exist: {executable}") + return executable + + candidates = ( + REPOSITORY_ROOT / ".venv" / "Scripts" / "python.exe", + REPOSITORY_ROOT / ".venv" / "bin" / "python", + ) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + return Path(sys.executable).resolve() + + +def _child_environment(base: Mapping[str, str] | None = None) -> dict[str, str]: + environment = dict(os.environ if base is None else base) + existing = [ + entry + for entry in environment.get("PYTHONPATH", "").split(os.pathsep) + if entry + ] + combined = [str(path.resolve()) for path in PYTHON_SOURCE_PATHS] + seen = {os.path.normcase(os.path.abspath(entry)) for entry in combined} + for entry in existing: + normalized = os.path.normcase(os.path.abspath(entry)) + if normalized not in seen: + combined.append(entry) + seen.add(normalized) + environment["PYTHONPATH"] = os.pathsep.join(combined) + environment.setdefault("PYTHONUNBUFFERED", "1") + return environment + + +def _commands( + args: argparse.Namespace, + python: Path, + catalog: Path, +) -> tuple[list[str], list[str]]: + main_command = [ + str(python), + "-m", + "cognitive_agent.server", + "--host", + args.host, + "--port", + str(args.port), + "--scene-catalog", + str(catalog), + "--scene-server-id", + args.server_id, + ] + scene_command = [ + str(python), + "-m", + "cognitive_agent.lan_scene_server", + "--bind", + args.scene_bind, + "--port", + str(args.scene_port), + "--catalog", + str(catalog), + "--server-id", + args.server_id, + ] + return main_command, scene_command + + +def _display_command(command: Sequence[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(command) + return shlex.join(command) + + +def _catalog_is_ready(path: Path) -> bool: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + return bool( + isinstance(document, dict) + and document.get("service") == "n7-scenes" + and document.get("schema_version") == 1 + and isinstance(document.get("scenes"), list) + ) + + +def _wait_for_catalog( + path: Path, + main_process: subprocess.Popen[bytes], + timeout: float, +) -> None: + if timeout <= 0: + raise ValueError("catalog-ready-timeout must be greater than zero") + deadline = time.monotonic() + timeout + while not _catalog_is_ready(path): + exit_code = main_process.poll() + if exit_code is not None: + raise RuntimeError( + f"main N7Web service exited before the catalog was ready " + f"(exit code {exit_code})" + ) + if time.monotonic() >= deadline: + raise RuntimeError( + f"timed out waiting for the synchronized catalog: {path}" + ) + time.sleep(0.1) + + +def _wait_for_exit(children: Sequence[ChildProcess]) -> tuple[str, int]: + while True: + for child in children: + exit_code = child.process.poll() + if exit_code is not None: + return child.name, exit_code + time.sleep(0.25) + + +def _stop_children( + children: Sequence[ChildProcess], + *, + interrupt_was_broadcast: bool, +) -> None: + # Console Ctrl+C is delivered to the parent and both children. Give the + # services time to run their own finally blocks before escalating. + if interrupt_was_broadcast: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if all(child.process.poll() is not None for child in children): + return + time.sleep(0.1) + + running = [child for child in children if child.process.poll() is None] + for child in running: + child.process.terminate() + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if all(child.process.poll() is not None for child in running): + return + time.sleep(0.1) + for child in running: + if child.process.poll() is None: + child.process.kill() + + +def run(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if not args.server_id.strip(): + print("error: server-id must not be empty", file=sys.stderr) + return 2 + + try: + python = _resolve_python(args.python) + catalog = _resolve_path(args.catalog) + if args.catalog_ready_timeout <= 0: + raise ValueError("catalog-ready-timeout must be greater than zero") + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + environment = _child_environment() + main_command, scene_command = _commands(args, python, catalog) + print(f"Repository: {REPOSITORY_ROOT}", flush=True) + print(f"Catalog: {catalog}", flush=True) + print(f"Main: {_display_command(main_command)}", flush=True) + print(f"Cards: {_display_command(scene_command)}", flush=True) + if args.dry_run: + return 0 + + children: list[ChildProcess] = [] + interrupted = False + try: + main_process = subprocess.Popen( + main_command, + cwd=REPOSITORY_ROOT, + env=environment, + ) + children.append(ChildProcess("main N7Web service", main_process)) + _wait_for_catalog(catalog, main_process, args.catalog_ready_timeout) + + scene_process = subprocess.Popen( + scene_command, + cwd=REPOSITORY_ROOT, + env=environment, + ) + children.append(ChildProcess("LAN mode-card service", scene_process)) + print( + "Both services are running; press Ctrl+C once to stop both.", + flush=True, + ) + name, exit_code = _wait_for_exit(children) + print(f"{name} exited (exit code {exit_code}); stopping the other service.") + return exit_code if exit_code != 0 else 1 + except KeyboardInterrupt: + interrupted = True + print("\nStopping N7Web services...", flush=True) + return 0 + except (OSError, RuntimeError, ValueError) as exc: + print(f"startup failed: {exc}", file=sys.stderr, flush=True) + return 1 + finally: + _stop_children(children, interrupt_was_broadcast=interrupted) + + +if __name__ == "__main__": + raise SystemExit(run()) From 3fc6f2c2a41bb0f7c944b93fe32ec749814fcb7e Mon Sep 17 00:00:00 2001 From: Miayui <774713819@qq.com> Date: Fri, 11 Sep 2026 10:22:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?mqtt=E5=88=87=E6=8D=A2=E4=B8=BA=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=83=A8=E7=BD=B2=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AE=80=E5=8D=95=E6=8C=87=E4=BB=A4=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-console/app.js | 40 - apps/web-console/assistant-chat.js | 22 + apps/web-console/console-v2.css | 179 +++- apps/web-console/console-v2.js | 538 ++++++++++- apps/web-console/index.html | 11 +- apps/web-console/service-worker.js | 10 +- .../n7web_remote_mqtt.example.env | 12 + domains/seat/src/seat_domain/intent.py | 3 +- src/cognitive_agent.egg-info/SOURCES.txt | 2 + .../agent/prompts/codex_cockpit_assistant.md | 1 + .../application/assistant_capabilities.py | 10 +- .../application/assistant_plan_guard.py | 6 +- .../application/codex_assistant.py | 677 +++++++++++++- .../application/native_can_intent.py | 20 +- .../application/native_can_relative.py | 3 + .../application/seat_service.py | 183 +++- .../application/self_extension.py | 243 +++-- .../infrastructure/can/hardware.py | 24 +- .../infrastructure/can/mqtt_transport.py | 16 +- .../infrastructure/llm/openai_responses.py | 5 + src/cognitive_agent/server.py | 860 ++++++++++++++---- tests/e2e/test_assistant_web_api.py | 15 +- tests/e2e/test_seat_web_api.py | 169 +++- tests/unit/assistant_stream_runtime.test.cjs | 53 ++ .../unit/test_assistant_frontend_contract.py | 11 +- ..._assistant_validated_control_projection.py | 410 +++++++++ tests/unit/test_codex_assistant_service.py | 308 ++++++- .../test_energy_monitor_frontend_contract.py | 6 +- tests/unit/test_frontend_theme_contract.py | 4 +- tests/unit/test_mqtt_can_transport.py | 59 ++ tests/unit/test_nfc_frontend_contract.py | 5 +- .../test_requested_frontend_enhancements.py | 381 +++++++- tests/unit/test_scene_composer_fast_path.py | 10 +- tests/unit/test_two_stage_can_parsing.py | 478 +++++++++- 34 files changed, 4290 insertions(+), 484 deletions(-) create mode 100644 config/environments/n7web_remote_mqtt.example.env create mode 100644 tests/unit/test_assistant_validated_control_projection.py diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 68eef7d..ada5115 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -4488,27 +4488,7 @@ function renderControlTransport() { ? runtime?.state_sync?.last_error || "" : `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`; }); - const selected = selectedVehicleTransport(); - const note = $("#vehicleTransportNote"); renderCabinLiveStatus(); - if (!note) return; - if (selected?.state_configured !== true) { - note.textContent = state.controlTransport === "wireless_mqtt" - ? "已手动选择无线控制 · MQTT 当前未配置或不可用" - : "已手动选择有线控制 · ZLG CAN 当前未配置或不可用"; - return; - } - if (state.controlTransport === "wireless_mqtt") { - const connection = transportConnectionOnline("wireless_mqtt", selected) ? "已连接" : "未连接"; - note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/n7-headunit/can/command"}`; - } else { - const connection = transportConnectionOnline("wired_can", selected) ? "已连接" : "未连接"; - const dualChannel = selected?.target_policy - === "dual_channel_wired_can0_passenger_can1_vehicle"; - note.textContent = dualChannel - ? `ZLG 有线 ${connection} · CAN0 副驾经典 CAN 500k · CAN1 车辆 CAN FD 500k/2M` - : `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`; - } } function selectControlTransport(transport) { @@ -6507,7 +6487,6 @@ async function loadRuntime() { mqttDualChannel ? vehicleCan.configured : hardware.configured, ); const vehicleHardwareReady = Boolean(vehicleCan.configured); - const linkedHardwareReady = seatHardwareReady && vehicleHardwareReady; const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady; const hardwareExecutionReady = Boolean(hardware.configured || vehicleCan.configured); const gptButton = $('[data-provider="openai"]'); @@ -6532,8 +6511,6 @@ async function loadRuntime() { ? "真实下发已通过后端执行配置校验" : `可手动选择真实下发;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`; const simulation = state.runtime.simulated_hardware || {}; - const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile"; - const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用"; const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast" ? " · 动作防冲突" : ""; @@ -6541,23 +6518,6 @@ async function loadRuntime() { && hardware.access_policy === "local_approval_idle_lease" ? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止` : ""; - const vehicleRouteText = mqttSelected - ? `MQTT ${vehicleCan.host || "127.0.0.1"}:${vehicleCan.port || 1883} · ${vehicleCan.condition_topic || "n7/n7-headunit/can/state"} / ${vehicleCan.control_topic || "n7/n7-headunit/can/command"}` - : `${vehicleCan.backend || "zlgcan"} · CH${vehicleCan.channel ?? 1} 车身 CAN FD ${Number(vehicleCan.arbitration?.bitrate || 500000) / 1000}k/${Number(vehicleCan.data?.bitrate || 2000000) / 1000000}M`; - $("#hardwareNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady - ? `${vehicleRouteText} · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}` - : linkedHardwareReady - ? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${profileText} · ${flashText}`}${accessText}${concurrencyText}` - : seatHardwareReady - ? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}` - : `可手动选择真实下发;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`; - $("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady - ? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止` - : linkedHardwareReady - ? `${mqttDualChannel ? "MQTT CAN0" : "CH0"} → 副驾座椅控制器 · ${mqttDualChannel ? "MQTT CAN1" : "CH1"} → 其余车辆 CAN` - : seatHardwareReady - ? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN` - : "尚未配置可执行的物理 CAN 通道"; $("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady ? `${mqttSelected ? "MQTT" : "CH1"} 主驾 + 车端设备 · CAN 字段回读${accessText}${concurrencyText}` : linkedHardwareReady diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 26b1d3f..74c67d7 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -916,6 +916,13 @@ } function applyAssistantEvent(event, message) { + if (event.type === "validated_control_commands" && event.payload) { + window.dispatchEvent(new CustomEvent( + "cognitive-agent:validated-can-commands", + { detail: event.payload }, + )); + return; + } if (event.type === "answer.delta") { setMessageContent(message, `${message.content}${event.delta || ""}`); return; @@ -1094,6 +1101,21 @@ return; } + window.dispatchEvent(new CustomEvent( + "cognitive-agent:control-request-start", + { + detail: { + utterance: messageText, + speech_utterance_id: suggestedMessage + ? "" + : String(input?.dataset.speechUtteranceId || ""), + speech_value: suggestedMessage + ? "" + : String(input?.dataset.speechValue || ""), + }, + }, + )); + createMessage("user", messageText); if (!suggestedMessage) { input.value = ""; diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css index b1ec1b9..9b41131 100644 --- a/apps/web-console/console-v2.css +++ b/apps/web-console/console-v2.css @@ -281,19 +281,6 @@ box-shadow: 0 5px 13px color-mix(in srgb, var(--v2-accent) 20%, transparent); } -.console-v2 .v2-transport-field > #vehicleTransportNote { - display: -webkit-box; - margin: 6px 1px 0; - overflow: hidden; - color: #809091; - font-size: 8px; - line-height: 1.35; - overflow-wrap: anywhere; - white-space: normal; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - .v2-sidebar-footer { min-height: 50px; display: grid; @@ -8233,28 +8220,6 @@ body.device-power-drawer-open { opacity: 0.72; } -.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field .provider-note { - min-height: 0; - margin: 5px 1px 0; - overflow: hidden; - color: #809091; - font-size: 8px; - line-height: 1.35; - overflow-wrap: anywhere; - white-space: normal; -} - -.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field #hardwareNote { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field #hardwareAliasNote { - text-overflow: ellipsis; - white-space: nowrap; -} - .console-v2.v2-passive-display.v2-interactive-sync .v2-sync-simulation-fault-field select { min-height: 34px; padding: 0 8px; @@ -8336,8 +8301,83 @@ body.device-power-drawer-open { transform: rotate(180deg); } +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing { + display: grid; + gap: 6px; + padding: 7px 9px; + color: #557270; + background: rgba(232, 244, 242, 0.7); + border-top: 1px solid rgba(19, 105, 99, 0.1); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing[hidden] { + display: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > p { + margin: 0; + font-size: 8px; + font-weight: 750; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header { + display: flex; + align-items: center; + gap: 6px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header strong { + font-size: 9px; + font-weight: 900; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header span { + padding: 1px 5px; + color: #2366a1; + background: #e4f0fb; + border-radius: 999px; + font-size: 7px; + font-weight: 850; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header b { + margin-left: auto; + color: #08766f; + font: 850 8px/1.4 "SFMono-Regular", Consolas, monospace; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing ol { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px 9px; + margin: 0; + padding: 0; + list-style: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li { + min-width: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 5px; + font-size: 7px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li b { + flex: 0 0 auto; + color: #315e5b; + font: 800 7px/1.4 "SFMono-Regular", Consolas, monospace; +} + .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list { - max-height: min(24dvh, 190px); + max-height: min(34dvh, 280px); padding: 7px; overflow: auto; border-top: 1px solid rgba(19, 105, 99, 0.1); @@ -8472,6 +8512,35 @@ body.device-power-drawer-open { overflow-wrap: anywhere; } +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-layer { + min-width: 0; + display: grid; + gap: 1px; + padding: 4px 5px; + border-radius: 5px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-layer > small { + color: #758886; + font-size: 7px; + font-weight: 900; + letter-spacing: 0.04em; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-semantic { + background: rgba(35, 102, 161, 0.07); + border-left: 2px solid rgba(35, 102, 161, 0.38); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-semantic code { + color: #245c8d; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-actual { + background: rgba(8, 118, 111, 0.07); + border-left: 2px solid rgba(8, 118, 111, 0.42); +} + .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-frame { display: block; color: #8a6b36; @@ -8479,6 +8548,10 @@ body.device-power-drawer-open { overflow-wrap: anywhere; } +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-frame > b { + font-weight: 900; +} + .console-v2.v2-passive-display.v2-interactive-sync .v2-sync-transport-field > .field-label { color: #466664; font-size: 10px; @@ -9530,6 +9603,40 @@ body.device-power-drawer-open { color: var(--command-frame); } +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing { + color: var(--muted-foreground); + background: color-mix(in oklch, var(--accent) 55%, transparent); + border-top-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header span { + color: var(--command-source); + background: color-mix(in oklch, var(--command-source) 12%, var(--card)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header b, +.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li b { + color: var(--primary); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-layer > small { + color: var(--muted-foreground); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-semantic { + background: color-mix(in oklch, var(--command-source) 8%, var(--card)); + border-left-color: color-mix(in oklch, var(--command-source) 52%, var(--border)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-semantic code { + color: var(--command-source); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-actual { + background: color-mix(in oklch, var(--primary) 8%, var(--card)); + border-left-color: color-mix(in oklch, var(--primary) 52%, var(--border)); +} + @media (max-width: 1120px) { .console-v2 .v2-mode-rail > .mode-rail-bar { grid-template-columns: auto minmax(150px, 1fr) auto; diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index ce92d6d..f023a70 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -23,15 +23,37 @@ let reasoningPickerControl = null; let validatedCommandsVisible = false; let validatedCommandContext = { requestId: "", utterance: "" }; + let pendingSpeechTiming = null; + let commandTimingState = { + inputMode: "text", + utterance: "", + requestId: "", + requestStartedAt: null, + validatedAt: null, + displayedAt: null, + renderDurationMs: null, + speechStartedAt: null, + speechResultAt: null, + speechTimings: null, + }; function resetValidatedCommandContext() { validatedCommandsVisible = false; validatedCommandContext = { requestId: "", utterance: "" }; } + function commandCorrelationIds(result = {}) { + return [...new Set([ + result.preview_request_id, + result.supplement_of, + result.request_id, + ].map((value) => String(value || "").trim()).filter(Boolean))]; + } + function rememberValidatedCommandContext(result = {}) { + const ids = commandCorrelationIds(result); validatedCommandContext = { - requestId: String(result.request_id || ""), + requestId: ids[0] || "", utterance: String(result.utterance || "").trim(), }; } @@ -39,10 +61,10 @@ function validatedCommandsBelongToTask(task = {}) { if (!validatedCommandsVisible) return false; const result = task.result && typeof task.result === "object" ? task.result : {}; - const requestId = String(result.request_id || ""); + const requestIds = commandCorrelationIds(result); const utterance = String(task.utterance || result.utterance || "").trim(); - if (validatedCommandContext.requestId && requestId - && validatedCommandContext.requestId !== requestId) return false; + if (validatedCommandContext.requestId && requestIds.length + && !requestIds.includes(validatedCommandContext.requestId)) return false; if (validatedCommandContext.utterance && utterance && validatedCommandContext.utterance !== utterance && !validatedCommandContext.utterance.includes(utterance) @@ -83,6 +105,123 @@ : null; }; + const monotonicNow = () => ( + typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now() + ); + + const finiteMilliseconds = (value) => { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + }; + + const semanticValue = (value) => { + if (value == null) return "null"; + if (["string", "number", "boolean"].includes(typeof value)) return String(value); + try { + return JSON.stringify(value); + } catch (_error) { + return String(value); + } + }; + + function semanticControlInstruction(operation = {}) { + const capability = String(operation.capability || "unknown"); + const rawTarget = operation.target || {}; + const target = rawTarget.zone || rawTarget.instance || rawTarget.device; + const argumentsList = []; + if (target != null && String(target).trim()) { + argumentsList.push(`target=${String(target).replaceAll("_", "-")}`); + } + Object.entries(operation.parameters || {}) + .sort(([left], [right]) => left.localeCompare(right)) + .forEach(([key, value]) => { + argumentsList.push(`${key}=${semanticValue(value)}`); + }); + return `${capability}(${argumentsList.join(", ")})`; + } + + function commandPlanEntries(result = {}) { + const plans = result.command_plans && typeof result.command_plans === "object" + ? Object.entries(result.command_plans) + : []; + if (!plans.length && result.command_plan) plans.push(["default", result.command_plan]); + return plans; + } + + function compiledCommandsForOperation(result, configKey, operation = {}) { + const operationId = String(operation.id || ""); + if (!operationId) return []; + const plans = commandPlanEntries(result); + const preferred = plans.filter(([key]) => key === configKey); + const operationRefs = (command) => { + const raw = command?.operation_ref; + const refs = Array.isArray(raw) ? raw : String(raw || "").split(","); + return refs.map((ref) => String(ref).trim()).filter(Boolean); + }; + const matches = (entries) => entries.flatMap(([planKey, plan]) => ( + (plan?.spec?.commands || []) + .filter((command) => operationRefs(command).includes(operationId)) + .map((command) => ({ command, plan, planKey })) + )); + if (preferred.length) return matches(preferred); + const configurations = result.function_configs && typeof result.function_configs === "object" + ? Object.entries(result.function_configs) + : result.function_config ? [["default", result.function_config]] : []; + return configurations.length === 1 && plans.length === 1 ? matches(plans) : []; + } + + function compiledCommandPresentation(entries, fallback = {}) { + if (!entries.length) return fallback; + const actualCommands = []; + const frames = []; + let channel = fallback.channel || "CAN1"; + entries.forEach(({ command, plan }) => { + const signals = command?.signals || {}; + const message = String(command?.message || ""); + const planDomain = String(plan?.spec?.domain || ""); + const bus = String(command?.bus || "").toLowerCase(); + const slot = boundedInteger(signals.slot, 1, 248); + if (message === "infrared.emit" || planDomain === "self_extension") { + channel = "IR"; + } else if (bus.includes("seat") || bus.includes("can0")) { + channel = "CAN0"; + } else if (bus.includes("body") || bus.includes("can1")) { + channel = "CAN1"; + } + const actual = String(signals.debug_command || "").trim() + || (message === "infrared.emit" && slot !== null ? `infrared emit ${slot}` : "") + || String(fallback.command || message).trim(); + if (actual && !actualCommands.includes(actual)) actualCommands.push(actual); + const frameId = String(signals.frame_id || "").trim(); + const dataHex = String(signals.data_hex || "").trim(); + if (frameId && dataHex) { + frames.push({ label: `${channel} 帧`, value: `${frameId} · ${dataHex}` }); + } else if (message === "infrared.emit" && slot !== null) { + frames.push({ + label: "GSCU1B TCP", + value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }), + }); + } + }); + const uniqueFrames = frames.filter((frame, index) => ( + frames.findIndex((candidate) => ( + candidate.label === frame.label && candidate.value === frame.value + )) === index + )); + return { + ...fallback, + channel, + command: actualCommands.join(";") || fallback.command || "", + ...(uniqueFrames.length ? { + frame: uniqueFrames[0].value, + frameLabel: uniqueFrames[0].label, + frames: uniqueFrames, + } : {}), + }; + } + function parsedCanCommandForOperation(operation = {}) { const capability = String(operation.capability || ""); const parameters = operation.parameters || {}; @@ -397,15 +536,68 @@ }; }; const commands = []; - configurationEntries.forEach(([_configKey, configuration]) => { - (configuration?.spec?.operations || []).forEach((operation) => { - const item = parsedCanCommandForOperation(operation); - if (item) commands.push({ ...item, ...deliveryForOperation(operation) }); + const validatedControlCommands = Array.isArray(result.validated_control_commands) + ? result.validated_control_commands + : []; + if (validatedControlCommands.length) { + validatedControlCommands.forEach((presentation) => { + const operation = presentation?.operation || {}; + const fallback = parsedCanCommandForOperation(operation) || { + channel: presentation?.domain === "self_extension" ? "IR" : "CAN1", + category: String(operation.capability || "控制指令"), + command: "", + ...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}), + }; + const channel = new Set(["CAN0", "CAN1", "IR"]).has(presentation?.channel) + ? presentation.channel + : fallback.channel; + const frames = (Array.isArray(presentation?.frames) ? presentation.frames : []) + .map((frame) => ({ + label: String(frame?.label || "控制帧"), + value: String(frame?.value || ""), + })) + .filter((frame) => frame.value); + const command = String(presentation?.actual_command || fallback.command || "").trim(); + if (!command) return; + commands.push({ + ...fallback, + channel, + command, + ...(frames.length ? { + frame: frames[0].value, + frameLabel: frames[0].label, + frames, + } : {}), + semantic: semanticControlInstruction(operation), + operationRef: String(operation.id || ""), + ...deliveryForOperation(operation), + }); }); - }); + } else { + configurationEntries.forEach(([configKey, configuration]) => { + (configuration?.spec?.operations || []).forEach((operation) => { + const fallback = parsedCanCommandForOperation(operation) || { + channel: configuration?.spec?.domain === "self_extension" ? "IR" : "CAN1", + category: String(operation.capability || "控制指令"), + command: "", + ...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}), + }; + const compiled = compiledCommandsForOperation(result, configKey, operation); + const item = compiledCommandPresentation(compiled, fallback); + if (item.command) { + commands.push({ + ...item, + semantic: semanticControlInstruction(operation), + operationRef: String(operation.id || ""), + ...deliveryForOperation(operation), + }); + } + }); + }); + } const unique = new Map(); commands.forEach((item) => { - const key = `${item.channel}:${item.command}`; + const key = `${item.operationRef || item.semantic}:${item.channel}:${item.command}`; const previous = unique.get(key); if (!previous || (previous.deliveryState !== "submitted" && item.deliveryState === "submitted") @@ -425,7 +617,192 @@ }[source] || "云端模型"; } - function renderParsedCanCommands(result, { message = "" } = {}) { + const commandTraceStageLabels = { + intent_parse: "意图解析", + context_resolve: "上下文解析", + domain_route: "控制域路由", + function_generate: "语义指令生成", + schema_validate: "Schema 校验", + domain_validate: "领域语义校验", + compile: "实际指令编译", + nfc_peripheral_route: "红外能力路由", + local_peripheral_plan: "红外本地规则编译", + nfc_peripheral_sequence: "红外指令序列生成", + }; + + const speechTimingLabels = { + audio_read_ms: "音频读取与重采样", + vad_ms: "语音活动检测", + asr_ms: "语音识别 ASR", + assembly_ms: "分段文本组装", + semantic_ms: "语音语义处理", + command_resolution_ms: "本地指令消歧", + recovery_ms: "语音识别恢复", + total_ms: "语音服务总耗时", + }; + + function parsedCommandTimingRows(result = {}, timingState = {}) { + const rows = []; + const push = (group, key, label, value) => { + const durationMs = finiteMilliseconds(value); + if (durationMs !== null) rows.push({ group, key, label, durationMs }); + }; + if (timingState.inputMode === "speech") { + Object.entries(speechTimingLabels).forEach(([key, label]) => { + push("speech", key, label, timingState.speechTimings?.[key]); + }); + if (timingState.speechStartedAt != null && timingState.speechResultAt != null) { + push( + "speech", + "speech_capture_to_text", + "语音开始 → 文本就绪", + timingState.speechResultAt - timingState.speechStartedAt, + ); + } + } + const traceEvents = Array.isArray(result.trace_events) ? result.trace_events : []; + const compileIndex = traceEvents.map((event) => event?.stage).lastIndexOf("compile"); + const validatedTraceEvents = compileIndex >= 0 + ? traceEvents.slice(0, compileIndex + 1) + : traceEvents; + const localRuleResult = String(result.provider_used || "").startsWith("local") + || parsedCanCommands(result).some((item) => item.parseSource === "local_rule"); + const localBudgetMs = finiteMilliseconds(result.local_control_budget_ms); + const localWithinBudget = result.local_control_within_budget === true; + push( + "backend", + "local_control_elapsed", + localBudgetMs === null + ? "本地规则解析与下发" + : `本地规则解析与下发(${localWithinBudget ? "1 秒内" : "超出 1 秒"})`, + result.local_control_elapsed_ms, + ); + validatedTraceEvents.forEach((event, index) => { + const stage = String(event?.stage || ""); + if (!(stage in commandTraceStageLabels)) return; + const label = stage === "intent_parse" && localRuleResult + ? "本地规则意图解析" + : commandTraceStageLabels[stage]; + push("backend", `${stage}-${index}`, label, event.duration_ms); + }); + if (timingState.requestStartedAt != null && timingState.validatedAt != null) { + push( + "frontend", + "request_to_validated", + "请求提交 → 编译结果到达", + timingState.validatedAt - timingState.requestStartedAt, + ); + } + push("frontend", "dom_render", "前端指令渲染", timingState.renderDurationMs); + if (timingState.requestStartedAt != null && timingState.displayedAt != null) { + push( + "total", + "request_to_display", + "文本提交 → 指令显示", + timingState.displayedAt - timingState.requestStartedAt, + ); + } + if (timingState.inputMode === "speech" + && timingState.speechStartedAt != null + && timingState.displayedAt != null) { + push( + "total", + "speech_to_display", + "语音开始 → 指令显示", + timingState.displayedAt - timingState.speechStartedAt, + ); + } + return rows; + } + + const formattedDuration = (value) => { + const duration = finiteMilliseconds(value); + if (duration === null) return "--"; + if (duration > 0 && duration < 0.1) return "<0.1 ms"; + return `${duration < 10 ? duration.toFixed(1) : Math.round(duration)} ms`; + }; + + function renderCommandTimings(result = null, { pending = false } = {}) { + const container = $("#v2CommandTiming"); + if (!container) return; + const rows = result ? parsedCommandTimingRows(result, commandTimingState) : []; + if (!rows.length && !pending) { + container.hidden = true; + container.innerHTML = ""; + return; + } + container.hidden = false; + if (!rows.length) { + container.innerHTML = `

阶段耗时计时中 · ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}

`; + return; + } + const total = [...rows].reverse().find((row) => row.group === "total"); + container.innerHTML = ` +
+ 阶段耗时 + ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"} + ${total ? `${escapeHtml(formattedDuration(total.durationMs))}` : ""} +
+
    ${rows.map((row) => ` +
  1. + ${escapeHtml(row.label)}${escapeHtml(formattedDuration(row.durationMs))} +
  2. `).join("")}
`; + } + + function beginCommandTiming(request = {}) { + const input = $("#utterance"); + const utterance = String(request.utterance ?? input?.value ?? "").trim(); + const speechUtteranceId = String( + request.speech_utterance_id ?? input?.dataset?.speechUtteranceId ?? "", + ); + const speechValue = String( + request.speech_value ?? input?.dataset?.speechValue ?? "", + ); + const speechMatch = Boolean( + speechUtteranceId + && pendingSpeechTiming?.utteranceId === speechUtteranceId + && speechValue === utterance, + ); + commandTimingState = { + inputMode: speechMatch ? "speech" : "text", + utterance, + requestId: "", + requestStartedAt: monotonicNow(), + validatedAt: null, + displayedAt: null, + renderDurationMs: null, + speechStartedAt: speechMatch ? pendingSpeechTiming.startedAt : null, + speechResultAt: speechMatch ? pendingSpeechTiming.resultAt : null, + speechTimings: speechMatch ? { ...(pendingSpeechTiming.timings || {}) } : null, + }; + } + + function commandResultMatchesTimedRequest(result = {}) { + if (commandTimingState.requestStartedAt == null) return true; + const expectedUtterance = String(commandTimingState.utterance || "") + .replace(/\s+/g, "") + .trim(); + const receivedUtterance = String(result.utterance || "") + .replace(/\s+/g, "") + .trim(); + if (expectedUtterance && receivedUtterance + && expectedUtterance !== receivedUtterance + && !expectedUtterance.includes(receivedUtterance) + && !receivedUtterance.includes(expectedUtterance)) return false; + const requestIds = commandCorrelationIds(result); + if (commandTimingState.requestId && requestIds.length + && !requestIds.includes(commandTimingState.requestId)) return false; + if (!commandTimingState.requestId && requestIds.length) { + [commandTimingState.requestId] = requestIds; + } + return true; + } + + function renderParsedCanCommands( + result, + { message = "", pending = false, recordTiming = false, reveal = false } = {}, + ) { + const renderStartedAt = monotonicNow(); const panel = $("#v2CanCommandPanel"); const list = $("#v2CanCommandList"); const count = $("#v2CanCommandCount"); @@ -442,12 +819,31 @@ ${escapeHtml(item.deliveryLabel)} ${escapeHtml(item.category)} - ${escapeHtml(item.command)} - ${item.frame ? `${escapeHtml(item.frameLabel || "固件原生 CAN0")} · ${escapeHtml(item.frame)}` : ""} +
+ 语义控制指令 + ${escapeHtml(item.semantic)} +
+
+ 实际控制指令 + ${escapeHtml(item.command)} +
+ ${(item.frames || (item.frame ? [{ label: item.frameLabel || "控制帧", value: item.frame }] : [])).map((frame) => `${escapeHtml(frame.label)} · ${escapeHtml(frame.value)}`).join("")} `).join("")}`; } else { list.innerHTML = `

${escapeHtml(message || "本次请求未解析出控制指令")}

`; } + if (reveal && commands.length) { + panel.open = true; + if (ui.sidebarCollapsed) setSidebarCollapsed(false); + } + if (recordTiming && commands.length) { + if (commandTimingState.validatedAt == null) commandTimingState.validatedAt = renderStartedAt; + if (commandTimingState.displayedAt == null) { + commandTimingState.displayedAt = monotonicNow(); + commandTimingState.renderDurationMs = commandTimingState.displayedAt - renderStartedAt; + } + } + renderCommandTimings(result, { pending }); requestAnimationFrame(syncSidebarLogBaseline); } @@ -767,6 +1163,11 @@ logKeys: new Set(), extensionFingerprint: "", }; + const nonDispatchTaskSources = new Set(["mode_editor_compile"]); + + function isDispatchTask(task = {}) { + return !nonDispatchTaskSources.has(String(task.source || "").trim()); + } const readText = (selector, fallback = "--") => $(selector)?.textContent?.trim() || fallback; @@ -1625,26 +2026,28 @@ if (revision !== displaySync.revision) { displaySync.revision = revision; const task = snapshot?.task || {}; - setTaskPresentation(task); - const sourceLabel = task.source === "web_console" ? "网页端" : "车机端"; - if (task.utterance) { - const submittedUnverified = task.result?.execution_outcome === "submitted_unverified" - || task.result?.execution_report?.spec?.verification_status === "submitted_unverified"; - appendSyncLog({ - key: `task:${revision}:${task.status}`, - category: "task", - title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`, - detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`, - status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info", - }); - } - const result = task.result; - const requestId = result?.request_id || null; - if (result && requestId !== displaySync.requestId) { - displaySync.requestId = requestId; - window.CognitiveAgentConsole?.applySyncedResult?.(result, { - utterance: task.utterance || "", - }); + if (isDispatchTask(task)) { + setTaskPresentation(task); + const sourceLabel = task.source === "web_console" ? "网页端" : "车机端"; + if (task.utterance) { + const submittedUnverified = task.result?.execution_outcome === "submitted_unverified" + || task.result?.execution_report?.spec?.verification_status === "submitted_unverified"; + appendSyncLog({ + key: `task:${revision}:${task.status}`, + category: "task", + title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`, + detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`, + status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info", + }); + } + const result = task.result; + const requestId = result?.request_id || null; + if (result && requestId !== displaySync.requestId) { + displaySync.requestId = requestId; + window.CognitiveAgentConsole?.applySyncedResult?.(result, { + utterance: task.utterance || "", + }); + } } } const extensionFingerprint = synchronizedCatalog @@ -1763,12 +2166,13 @@ insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field"); const canCommandPanel = make("details", "v2-can-command-panel", ` - 已解析控制指令 + 下发控制指令 0 +
-

等待自然语言解析结果

+

等待准备下发的控制指令

`); canCommandPanel.id = "v2CanCommandPanel"; syncOverview.append(canCommandPanel); @@ -1875,17 +2279,57 @@ if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target); }); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); - $("#requestForm")?.addEventListener("submit", () => { + const beginPendingControlRequest = (request = {}) => { + beginCommandTiming(request); resetValidatedCommandContext(); - renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" }); + renderParsedCanCommands(null, { + message: "正在解析自然语言并生成控制指令…", + pending: true, + }); + }; + $("#requestForm")?.addEventListener("submit", () => { + beginPendingControlRequest(); + }, true); + window.addEventListener("cognitive-agent:control-request-start", (event) => { + beginPendingControlRequest(event.detail || {}); + }); + window.addEventListener("cognitive-agent:speech-state", (event) => { + const phase = event.detail?.phase; + if (phase === "capturing" && !pendingSpeechTiming?.captureActive) { + pendingSpeechTiming = { + utteranceId: "", + startedAt: monotonicNow(), + resultAt: null, + timings: null, + captureActive: true, + }; + } else if (["finalizing", "success", "error"].includes(phase) + && pendingSpeechTiming) { + pendingSpeechTiming.captureActive = false; + } + }); + window.addEventListener("cognitive-agent:speech-result", (event) => { + const result = event.detail || {}; + pendingSpeechTiming = { + ...(pendingSpeechTiming || { startedAt: null }), + utteranceId: String(result.utterance_id || ""), + resultAt: monotonicNow(), + timings: result.timings && typeof result.timings === "object" + ? { ...result.timings } + : null, + captureActive: false, + }; }); window.addEventListener("cognitive-agent:validated-can-commands", (event) => { const result = event.detail || {}; + if (!commandResultMatchesTimedRequest(result)) return; validatedCommandsVisible = parsedCanCommands(result).length > 0; if (validatedCommandsVisible) rememberValidatedCommandContext(result); else resetValidatedCommandContext(); renderParsedCanCommands(result, { message: "控制指令已解析,正在等待传输结果…", + recordTiming: true, + reveal: true, }); }); window.addEventListener("cognitive-agent:result", (event) => { @@ -1893,11 +2337,15 @@ const submittedUnverified = result.execution_outcome === "submitted_unverified" || result.execution_report?.spec?.verification_status === "submitted_unverified"; const commands = parsedCanCommands(result); - if (commands.length) { + const matchesTimedRequest = commandResultMatchesTimedRequest(result); + if (matchesTimedRequest && commands.length) { validatedCommandsVisible = true; rememberValidatedCommandContext(result); - renderParsedCanCommands(result); - } else if (!validatedCommandsBelongToTask({ + renderParsedCanCommands(result, { + recordTiming: commandTimingState.displayedAt == null, + reveal: commandTimingState.requestStartedAt != null, + }); + } else if (matchesTimedRequest && !validatedCommandsBelongToTask({ utterance: result.utterance, result, })) { @@ -1917,7 +2365,15 @@ }); }); window.addEventListener("cognitive-agent:request-complete", (event) => { - if (event.detail?.error && !validatedCommandsVisible) { + const completedUtterance = String(event.detail?.utterance || "") + .replace(/\s+/g, "") + .trim(); + const expectedUtterance = String(commandTimingState.utterance || "") + .replace(/\s+/g, "") + .trim(); + const matchesTimedRequest = !expectedUtterance || !completedUtterance + || expectedUtterance === completedUtterance; + if (matchesTimedRequest && !validatedCommandsVisible) { renderParsedCanCommands(null, { message: "本次请求未生成控制指令" }); } if (event.detail?.error) { diff --git a/apps/web-console/index.html b/apps/web-console/index.html index e4d1c87..b5a8c09 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -10,7 +10,7 @@ 511工程-智能座舱 - + @@ -20,13 +20,13 @@ - + - + - + @@ -131,8 +131,6 @@ 真实下发 -

正在检查 CAN 适配器与 IAR 固件配置

-

所有座椅能力仅控制副驾实体电机、加热与通风执行器

@@ -145,7 +143,6 @@ 无线 CAN
-

正在检查车辆连接