From 0e4328f548e74243df81cc2c6f8f425e8767704a Mon Sep 17 00:00:00 2001 From: Miayui <774713819@qq.com> Date: Wed, 16 Sep 2026 12:52:07 +0800 Subject: [PATCH] feat(n7web): enhance control workflows and status feedback --- .gitignore | 2 + README.md | 58 + apps/web-console/app.js | 64 +- apps/web-console/assistant-chat.css | 2 +- apps/web-console/assistant-chat.js | 105 +- apps/web-console/codepen-glass-theme.css | 367 ++++- apps/web-console/console-v2.js | 278 +++- apps/web-console/gesture-control.js | 2 +- apps/web-console/index.html | 639 +------- apps/web-console/package-lock.json | 1394 +++++++++++++++++ apps/web-console/package.json | 18 + apps/web-console/service-worker.js | 12 +- apps/web-console/src/App.vue | 584 +++++++ apps/web-console/src/main.js | 33 + apps/web-console/src/styles/design-system.css | 275 ++++ apps/web-console/ui-consistency.css | 91 +- apps/web-console/vite.config.js | 94 ++ apps/web-console/vue-ui.js | 15 +- src/cognitive_agent.egg-info/SOURCES.txt | 5 +- .../application/assistant_capabilities.py | 8 +- .../application/extension_control.py | 638 ++++++++ .../application/extension_toy_publisher.py | 381 +++++ .../application/mode_scene_catalog.py | 326 +++- .../application/seat_service.py | 78 + .../application/self_extension.py | 160 +- .../infrastructure/can/mqtt_transport.py | 436 +++++- src/cognitive_agent/server.py | 146 +- tests/e2e/test_assistant_web_api.py | 68 + tests/e2e/test_seat_web_api.py | 435 +++-- ...t_assistant_capability_execution_matrix.py | 56 +- .../unit/test_assistant_capability_catalog.py | 39 +- .../unit/test_assistant_frontend_contract.py | 71 +- .../test_energy_monitor_frontend_contract.py | 11 +- tests/unit/test_extension_control.py | 363 +++++ tests/unit/test_extension_toy_publisher.py | 227 +++ tests/unit/test_frontend_theme_contract.py | 65 +- tests/unit/test_mode_gallery_grouping.py | 18 +- tests/unit/test_mode_scene_catalog.py | 247 +++ tests/unit/test_mqtt_can_transport.py | 189 ++- tests/unit/test_nfc_frontend_contract.py | 5 +- .../test_requested_frontend_enhancements.py | 147 +- tests/unit/test_self_extension.py | 41 +- .../test_spatial_ui_consistency_contract.py | 23 +- tests/unit/test_vue_frontend_contract.py | 52 +- tools/start_n7web.py | 50 + 45 files changed, 7317 insertions(+), 1001 deletions(-) create mode 100644 apps/web-console/package-lock.json create mode 100644 apps/web-console/package.json create mode 100644 apps/web-console/src/App.vue create mode 100644 apps/web-console/src/main.js create mode 100644 apps/web-console/src/styles/design-system.css create mode 100644 apps/web-console/vite.config.js create mode 100644 src/cognitive_agent/application/extension_control.py create mode 100644 src/cognitive_agent/application/extension_toy_publisher.py create mode 100644 tests/unit/test_extension_control.py create mode 100644 tests/unit/test_extension_toy_publisher.py diff --git a/.gitignore b/.gitignore index 1ca1739..02c63cc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ tmp/*.log .codex_tmp/ /build/ /output/ +/apps/web-console/node_modules/ +/apps/web-console/dist/ /tmp/imagegen/ firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/ firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/ diff --git a/README.md b/README.md index d27b010..5283485 100755 --- a/README.md +++ b/README.md @@ -8,6 +8,29 @@ Web 控制台与同源 API 由同一个 Python 服务提供。本地启动: .\.venv\Scripts\python.exe .\tools\start_n7web.py ``` +启动脚本会先安装缺失的前端依赖并执行 Vue 3 + Vite 生产构建,然后启动 `8000` 主服务和独立的 `8090` 只读模式卡服务。已经有可复用的 `apps/web-console/dist` 时,可使用 `--skip-web-build` 跳过本次构建。 + +### Vue 3 + Vite 前端 + +前端根应用位于 `apps/web-console/src/App.vue`,入口为 `src/main.js`。Vue 和 Vite 均由 npm 锁定版本管理,浏览器不再依赖 CDN 或全局 `window.Vue`。现有 MQTT、NFC、语音、模式卡和车辆控制脚本作为兼容运行层继续使用原 DOM 契约,由 Vue 根应用承载页面结构。 + +单独进行前端开发时,先保持 Python 主服务运行,再启动 Vite: + +```powershell +cd .\apps\web-console +npm ci +npm run dev +``` + +开发页面为 `http://127.0.0.1:5173/`,`/api` 与 `/artifacts` 会代理到 `http://127.0.0.1:8000`。生成生产静态资源: + +```powershell +cd .\apps\web-console +npm run build +``` + +构建输出位于 `apps/web-console/dist`;Python 主服务检测到该目录后会优先提供生产构建。上述流程不会改变主页的 `8000` 端口,也不会改变模式卡服务的 `8090` 端口。 + Windows PowerShell 未安装 `make` 时可直接运行: ```powershell @@ -186,6 +209,41 @@ config/gesture/default.yaml 同步由模式注册表的创建、更新、删除、恢复和显式重载事件触发,不会持续扫描目录。每次变更只重新生成并原子替换一份小型 JSON;独立目录服务在收到 HTTP 请求时才读取它。因此空闲时的额外开销主要是一个等待请求的 Python 进程和一个 UDP 发现线程,而不是持续的 CPU 或磁盘轮询。 +前端通过 `POST /api/mode-profiles` 新增模式卡片,或通过 `POST /api/mode-profiles/{id}` 修改模式卡控制流后,主服务会先完成 YAML 持久化和 `/n7/scenes` 原子同步,再从同一份目录投影中按模式 ID 提取一个 `scenes[]` 元素,并在 1 秒内以约 333 ms 间隔向 MQTT `from_pc/scenes` 发布 3 次。每帧都是完全相同的更新后单卡 JSON,不包含目录外层或其他已有卡片,QoS 沿用车辆 MQTT 配置且 `retain=false`。删除和页面刷新只更新原有持久化/目录链路,不会触发该 Topic。 + +检测到已绑定外设的 NFC 卡时,主服务会按英文稳定 `device_id` 对已学习且启用的红外能力分组,并向 MQTT `extension/toy` 发布 3 帧相同的设备能力消息(约在 0、333、666 ms 发布,QoS 1、`retain=false`)。`extension_name` 使用中文显示名,`actions[].target_scope` 使用英文 `device_id`;卡片持续停留不会重复发送,移开后再次检测才会重新发送。该消息仅广播能力目录,不会触发红外发射。 + +车端需要控制这些 TCP 红外外设时,向同一个车辆 MQTT Broker 的 `extension/ctl` 发布 QoS 1、`retain=false` 的 JSON。PC 主服务固定订阅该 Topic;消息外层必须且只能包含 `domain`、`operation_count`、`actions`,其中 `domain` 为 `self_extension`,`operation_count` 必须与动作数量一致(1–64),`actions` 使用 `/n7/scenes` 中的红外动作格式: + +```json +{ + "domain": "self_extension", + "operation_count": 2, + "actions": [ + { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "toy-light-001", + "operation": "emit", + "parameters": {"action": "打开", "slot": 2} + }, + { + "type": "infrared_emit", + "at_ms": 1000, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "toy-light-001", + "operation": "emit", + "parameters": {"action": "切换为蓝色", "slot": 5} + } + ] +} +``` + +服务在执行首条动作前会对完整控制流做原子鉴权:每一项都必须与本机目录中“已学习、已启用、具有持久 NFC 绑定”的 `device_id + action + slot` 完全一致;NFC 卡此时无需仍在感应区。执行前还会逐项重新鉴权,以阻止排队期间已被禁用或删除的能力。动作按 `at_ms` 排序后通过本机 TCP 红外网关串行下发,相邻发射至少间隔 800 ms;任一动作失败即停止剩余控制流。为避免重连误执行,retained 消息和 MQTT `dup` 重投消息都会被拒绝,消息体上限为 256 KiB。接收、鉴权、排队、执行结果和拒绝原因可在 `GET /api/extension/health` 的 `extension_control` 字段查看。 + 如果只需单独启动 UDP 发现和只读目录服务,也可以在另一个 PowerShell 终端运行: ```powershell diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 7bfa0a2..a8827bf 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -6136,6 +6136,7 @@ function ensureModeActionPopover() { const header = document.createElement("header"); const headingCopy = document.createElement("span"); const eyebrow = document.createElement("small"); + eyebrow.dataset.modeActionEyebrow = "true"; eyebrow.textContent = "执行动作预览"; const title = document.createElement("strong"); title.dataset.modeActionTitle = "true"; @@ -6153,7 +6154,12 @@ function ensureModeActionPopover() { function renderModeActionPopover(mode) { const popover = ensureModeActionPopover(); - const actions = modeExecutionActionViews(mode); + const commandFlow = modeEditorReadableCommands(mode?.command_flow); + const usesLatestCommandFlow = commandFlow.length > 0; + const actions = usesLatestCommandFlow ? commandFlow : modeExecutionActionViews(mode); + $(`[data-mode-action-eyebrow]`, popover).textContent = usesLatestCommandFlow + ? "最新控制指令流" + : "执行动作预览"; $("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式"; $("[data-mode-action-count]", popover).textContent = actions.length ? `${actions.length} 步` @@ -6163,7 +6169,7 @@ function renderModeActionPopover(mode) { if (!actions.length) { const empty = document.createElement("li"); empty.className = "mode-chip-action-empty"; - empty.textContent = "执行动作明细暂未加载,请刷新后重试。"; + empty.textContent = "控制指令流暂未加载,请刷新后重试。"; list.append(empty); return popover; } @@ -6175,18 +6181,28 @@ function renderModeActionPopover(mode) { const copy = document.createElement("span"); copy.className = "mode-chip-action-copy"; const actionName = document.createElement("strong"); - actionName.textContent = action.action; + actionName.textContent = usesLatestCommandFlow + ? String(action.category || "控制指令") + : action.action; const metadata = document.createElement("small"); - const device = document.createElement("span"); - device.innerHTML = "设备"; - device.append(document.createTextNode(action.device)); - const target = document.createElement("span"); - target.innerHTML = "目标"; - target.append(document.createTextNode(action.target)); - const parameters = document.createElement("span"); - parameters.innerHTML = "参数"; - parameters.append(document.createTextNode(action.parameters)); - metadata.append(device, target, parameters); + const appendMetadata = (label, value) => { + const text = String(value || "").trim(); + if (!text) return; + const item = document.createElement("span"); + const key = document.createElement("b"); + key.textContent = label; + item.append(key, document.createTextNode(text)); + metadata.append(item); + }; + if (usesLatestCommandFlow) { + appendMetadata("语义", action.semantic || action.operation_ref); + appendMetadata("实际", action.command); + appendMetadata("通道", action.channel); + } else { + appendMetadata("设备", action.device); + appendMetadata("目标", action.target); + appendMetadata("参数", action.parameters); + } copy.append(actionName, metadata); item.append(step, copy); list.append(item); @@ -6642,10 +6658,13 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) { closeModeGallery({ restoreFocus: false }); const runButton = $("#runButton"); const input = $("#utterance"); + const assistantModeHandle = assistant?.beginModeExecution?.(mode, { + command: modeCommand, + }) || null; runButton.disabled = true; setModeChipsDisabled(true); setModeExecutionState(mode.id, "running"); - assistant?.open?.(); + if (!assistantModeHandle) assistant?.open?.(); input.value = modeCommand; input.dispatchEvent(new Event("input", { bubbles: true })); setRunStatus("processing", "MODE COMMAND FLOW"); @@ -6677,7 +6696,13 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) { ? `${mode.name}执行完成` : `${mode.name}执行未完成`; setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); - assistant?.announceModeExecution?.(mode, payload, { command: modeCommand }); + if (assistantModeHandle && assistant?.completeModeExecution) { + assistant.completeModeExecution(assistantModeHandle, mode, payload, { + command: modeCommand, + }); + } else { + assistant?.announceModeExecution?.(mode, payload, { command: modeCommand }); + } if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); showToast(completed ? `${mode.name}已直接执行保存的控制指令流` @@ -6686,7 +6711,14 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) { setModeExecutionState(mode.id, "failed"); setRunStatus("failed", "MODE FAILED"); $("#resultTitle").textContent = `${mode.name}执行失败`; - assistant?.announceModeExecution?.(mode, null, { command: modeCommand, error }); + if (assistantModeHandle && assistant?.completeModeExecution) { + assistant.completeModeExecution(assistantModeHandle, mode, null, { + command: modeCommand, + error, + }); + } else { + assistant?.announceModeExecution?.(mode, null, { command: modeCommand, error }); + } showToast(error.message || "模式执行失败"); } finally { if (input.value === modeCommand) { diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css index e5db16e..df6ed08 100644 --- a/apps/web-console/assistant-chat.css +++ b/apps/web-console/assistant-chat.css @@ -73,7 +73,7 @@ bottom: auto; grid-column: 1; grid-row: 3; - margin-left: 10px; + margin-left: 0; border-radius: 18px; box-shadow: 0 14px 38px rgba(25, 44, 82, 0.12); transform-origin: left center; diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 11acb37..163cfb8 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -544,7 +544,27 @@ if (capability.startsWith("seat.")) { const target = String(operation.target?.zone || operation.target?.instance || "") .replaceAll("_", "-"); - return `${["passenger", "front-right"].includes(target) ? "副驾" : "主驾"}座椅`; + const seat = ["passenger", "front-right"].includes(target) ? "副驾座椅" : "主驾座椅"; + const component = { + "seat.set_longitudinal_position": "前后位置", + "seat.set_cushion_tilt": "坐盆", + "seat.set_backrest_recline": "靠背", + "seat.set_legrest_height": "腿托", + "seat.set_legrest_extension": "腿托", + }[capability]; + if (component) return `${seat}${component}`; + if (capability === "seat.set_driver_axis_native") { + const axis = String(operation.parameters?.axis || "").replaceAll("_", "-"); + const axisComponent = { + horizontal: "前后位置", + height: "坐盆", + basin: "坐盆", + backrest: "靠背", + legrest: "腿托", + }[axis]; + if (axisComponent) return `${seat}${axisComponent}`; + } + return seat; } if (capability.startsWith("outlet.")) return "空调风口"; return String(fallback || capability || "控制指令"); @@ -589,6 +609,24 @@ String(presentation?.operation?.id || ""), presentation?.operation || {}, ])); + const operationByRef = new Map(); + const configurations = result.function_configs && typeof result.function_configs === "object" + ? Object.values(result.function_configs) + : []; + if (result.function_config && typeof result.function_config === "object") { + configurations.push(result.function_config); + } + configurations.forEach((configuration) => { + const operations = Array.isArray(configuration?.spec?.operations) + ? configuration.spec.operations + : []; + operations.forEach((operation) => { + const operationRef = String(operation?.id || ""); + if (operationRef && !operationByRef.has(operationRef)) { + operationByRef.set(operationRef, operation); + } + }); + }); let commands = []; try { const sharedCommands = window.CognitiveAgentConsoleV2?.getParsedCanCommands?.(result); @@ -599,10 +637,19 @@ if (!commands.length) return fallbackAssistantControlCommands(result); return commands.map((item, index) => { const operation = projectionByRef.get(String(item.operationRef || "")) + || operationByRef.get(String(item.operationRef || "")) || projections[index]?.operation || {}; + const pendingDelivery = result.status === "processing" + ? { + deliveryState: "submitting", + deliveryLabel: "正在下发", + deliveryDetail: "", + } + : {}; return { ...item, + ...pendingDelivery, category: assistantControlCategory(operation, item.category), operation, }; @@ -1459,14 +1506,54 @@ } } - function announceModeExecution(profile, result = null, { command = "", error = null } = {}) { + function modeExecutionPreviewResult(profile) { + const flow = profile?.command_flow && typeof profile.command_flow === "object" + ? profile.command_flow + : {}; + return { + status: "processing", + function_configs: flow.function_configs && typeof flow.function_configs === "object" + ? flow.function_configs + : {}, + command_plans: flow.plans && typeof flow.plans === "object" ? flow.plans : {}, + control_transport: String(flow.control_transport || ""), + execution_report: null, + operation_results: [], + }; + } + + function beginModeExecution(profile, { command = "" } = {}) { const modeName = String(profile?.name || profile?.id || "功能模式"); const commandText = String(command || `进入${modeName}`).trim(); + setDrawerOpen(true); + createMessage("user", commandText); + const message = createMessage("assistant", `已加载${modeName}控制指令流,正在下发。`); + setActionStatus(message, "模式控制指令正在下发", "working"); + appendDispatchedControls(message, modeExecutionPreviewResult(profile)); + persistMessages(); + return { + message, + modeName, + commandText, + profileId: String(profile?.id || ""), + }; + } + + function completeModeExecution( + handle, + profile, + result = null, + { command = "", error = null } = {}, + ) { + const modeName = String(handle?.modeName || profile?.name || profile?.id || "功能模式"); + const commandText = String(handle?.commandText || command || `进入${modeName}`).trim(); + const activeHandle = handle?.message?.body + ? handle + : beginModeExecution(profile, { command: commandText }); + const message = activeHandle.message; const status = String(result?.status || ""); const dryRun = result?.execution_mode === "dry_run"; const waitingForFeedback = result?.execution_outcome === "submitted_unverified"; - setDrawerOpen(true); - createMessage("user", commandText); let content = `${modeName}执行未完成,请查看控制指令状态。`; let statusText = "功能模式执行未完成"; @@ -1491,10 +1578,16 @@ statusText = "功能模式部分完成"; } - const message = createMessage("assistant", content); + setMessageContent(message, content, { final: true }); setActionStatus(message, statusText, statusState); if (result) appendDispatchedControls(message, result); persistMessages(); + return activeHandle; + } + + function announceModeExecution(profile, result = null, { command = "", error = null } = {}) { + const handle = beginModeExecution(profile, { command }); + return completeModeExecution(handle, profile, result, { command, error }); } function renderInitialMessages() { @@ -1584,6 +1677,8 @@ close: () => setDrawerOpen(false), resetConversation, startModeCreation, + beginModeExecution, + completeModeExecution, announceModeExecution, announceModeCreated(profile) { if (!profile?.id) return; diff --git a/apps/web-console/codepen-glass-theme.css b/apps/web-console/codepen-glass-theme.css index f08c4dc..e50279d 100644 --- a/apps/web-console/codepen-glass-theme.css +++ b/apps/web-console/codepen-glass-theme.css @@ -85,6 +85,12 @@ body.console-v2 { --glass-highlight: oklch(1 0 0 / 0.14); --glass-shadow: 0 24px 70px oklch(0.06 0.04 285 / 0.42); --glass-panel-shadow: 0 12px 32px oklch(0.05 0.035 285 / 0.24); + --glass-card-radius: 14px; + --glass-card-lift: -3px; + --glass-card-rest-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 22px color-mix(in oklch, var(--foreground) 10%, transparent); + --glass-card-float-shadow: inset 0 1px 0 var(--glass-highlight), 0 16px 34px color-mix(in oklch, var(--foreground) 18%, transparent); + --glass-dialog-scrim: oklch(0.035 0.025 286 / 0.82); + --glass-dialog-scrim-filter: blur(18px) saturate(0.78) brightness(0.58); min-width: 1180px; min-height: 620px; overflow: hidden; @@ -126,6 +132,8 @@ html[data-glass-theme="light"] body.console-v2 { --sidebar-accent-foreground: var(--foreground); --sidebar-border: var(--border); --sidebar-ring: var(--ring); + --glass-dialog-scrim: oklch(0.26 0.035 252 / 0.34); + --glass-dialog-scrim-filter: blur(14px) saturate(0.9) brightness(0.92); --heading-foreground: oklch(0.235 0.068 255); --body-foreground: var(--card-foreground); --subtle-foreground: var(--muted-foreground); @@ -438,14 +446,14 @@ body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-s linear-gradient(160deg, oklch(0.18 0.05 285 / 0.2), oklch(0.09 0.03 286 / 0.32)) !important; } -body.console-v2 .joint-twin-shell :is(.twin-seat-switch, .twin-seat-switch button, .twin-reset, .twin-focus-badge) { +body.console-v2 .joint-twin-shell :is(.twin-selector, .twin-selector button, .twin-reset, .twin-focus-badge) { color: var(--muted-foreground) !important; background: var(--glass-card) !important; border-color: var(--glass-line) !important; box-shadow: inset 0 1px 0 var(--glass-highlight) !important; } -body.console-v2 .joint-twin-shell .twin-seat-switch button.active, +body.console-v2 .joint-twin-shell .twin-selector button.active, body.console-v2 .joint-twin-shell :is(.twin-reset, .twin-focus-badge):is(:hover, :focus-visible) { color: var(--primary-foreground) !important; background: color-mix(in oklch, var(--primary) 72%, transparent) !important; @@ -588,12 +596,18 @@ body.console-v2 :is(.v2-extension-linkage, .v2-sidebar-footer) { body.console-v2 :is( .mode-gallery-backdrop, .mode-editor-backdrop, - .v2-extension-dialog::backdrop, - .energy-monitor-layer + .energy-monitor-layer, + .device-power-backdrop ) { - background: oklch(0.055 0.025 286 / 0.62) !important; - -webkit-backdrop-filter: blur(14px) saturate(1.12); - backdrop-filter: blur(14px) saturate(1.12); + background: var(--glass-dialog-scrim) !important; + -webkit-backdrop-filter: var(--glass-dialog-scrim-filter) !important; + backdrop-filter: var(--glass-dialog-scrim-filter) !important; +} + +body.console-v2 .v2-extension-dialog::backdrop { + background: var(--glass-dialog-scrim) !important; + -webkit-backdrop-filter: var(--glass-dialog-scrim-filter) !important; + backdrop-filter: var(--glass-dialog-scrim-filter) !important; } body.console-v2 :is( @@ -631,6 +645,98 @@ body.console-v2 :is( box-shadow: inset 0 1px 0 var(--glass-highlight), 0 9px 24px oklch(0.04 0.03 286 / 0.2) !important; } +/* Unified floating cards ------------------------------------------------- + Card geometry never changes on interaction; transform-only elevation + keeps the dashboard stable while matching the shared shadcn card states. */ +body.console-v2 :is( + .v2-sync-transport-field, + .v2-sync-execution-field, + .v2-can-command-panel, + .v2-sync-log, + .v2-nfc-inline, + .v2-extension-gateway, + .v2-extension-synced-device, + .v2-extension-empty, + .v2-extension-config-card, + .v2-extension-type-card, + .v2-extension-vehicle-card, + .v2-pet-card-region, + .v2-pet-card, + .v2-pet-presence-card, + .v2-device-card, + .v2-synced-device, + .joint-seat-panel, + .domain-panel, + .coverage-card, + .decision-list li, + .frame-summary, + .execution-summary, + .mode-gallery-card, + .mode-editor-flow, + .mode-editor-command-card, + .v2-nfc-body, + .v2-nfc-existing, + .v2-nfc-pet-binding, + .energy-kpi, + .energy-trend-card, + .energy-devices-card, + .assistant-dispatched-controls, + .assistant-mode-draft, + .assistant-mode-created, + .assistant-control-suggestion +) { + border-radius: var(--glass-card-radius) !important; + box-shadow: var(--glass-card-rest-shadow) !important; + transform: translateY(0); + transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +body.console-v2 :is( + .v2-can-command-panel, + .v2-sync-log, + .v2-nfc-inline, + .v2-extension-gateway, + .v2-extension-synced-device, + .v2-extension-empty, + .v2-extension-config-card, + .v2-extension-type-card, + .v2-extension-vehicle-card, + .v2-pet-card, + .v2-pet-presence-card, + .v2-device-card, + .v2-synced-device, + .joint-seat-panel, + .domain-panel, + .coverage-card, + .decision-list li, + .frame-summary, + .execution-summary, + .mode-gallery-card, + .mode-editor-command-card, + .v2-nfc-existing, + .v2-nfc-pet-binding, + .energy-kpi, + .energy-trend-card, + .energy-devices-card, + .assistant-dispatched-controls, + .assistant-mode-draft, + .assistant-mode-created, + .assistant-control-suggestion +):is(:hover, :focus-within) { + background-color: var(--glass-card-hover) !important; + border-color: var(--glass-line-strong) !important; + box-shadow: var(--glass-card-float-shadow) !important; + transform: translateY(var(--glass-card-lift)); +} + +body.console-v2 :is( + .mode-gallery-card, + .assistant-control-suggestion +):active { + box-shadow: var(--glass-card-rest-shadow) !important; + transform: translateY(0); +} + body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose) { color: var(--muted-foreground) !important; background: var(--glass-card) !important; @@ -644,12 +750,12 @@ body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-exten /* Persistent day / night appearance ------------------------------------- */ .glass-theme-toggle { - width: 48px; - height: 48px; + width: 44px; + height: 44px; position: fixed; left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); - z-index: 1600; + z-index: 1100; display: grid; place-items: center; padding: 0; @@ -776,6 +882,206 @@ html[data-glass-theme="light"] body.console-v2 :is( box-shadow: 0 28px 84px color-mix(in oklch, var(--foreground) 20%, transparent), inset 0 1px 0 var(--glass-highlight) !important; } +/* Audited component hierarchy ------------------------------------------- + Keep shadcn-style roles visually distinct: panel > section > item > + control. These rules intentionally live after the theme variants so both + day and night modes consume the same geometry and typography contract. */ +body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync :is( + .main-panel > .v2-mode-rail, + #assistantDrawer, + .joint-twin-shell > .twin-band, + #v2DevicePanel, + #v2ExtensionPanel .v2-extension-sync-mount +) { + border-radius: var(--ui-radius-panel) !important; +} + +body.console-v2 :is( + .v2-sync-log, + .mode-gallery-group, + .v2-pet-card-region, + .energy-trend-card, + .energy-devices-card +) { + border-radius: var(--ui-radius-section) !important; +} + +body.console-v2 :is( + .v2-sync-transport-field, + .v2-sync-execution-field, + .v2-can-command-panel, + .v2-nfc-inline, + .v2-extension-gateway, + .v2-extension-synced-device, + .v2-extension-empty, + .v2-extension-config-card, + .v2-extension-type-card, + .v2-extension-vehicle-card, + .v2-pet-card, + .v2-pet-presence-card, + .v2-device-card, + .v2-synced-device, + .mode-gallery-card, + .mode-editor-flow, + .mode-editor-command-card, + .energy-kpi, + .assistant-dispatched-controls, + .assistant-mode-draft, + .assistant-mode-created, + .assistant-control-suggestion +) { + border-radius: var(--ui-radius-item) !important; +} + +body.console-v2 :is( + .mode-gallery, + .mode-editor, + .v2-nfc-dialog, + .v2-gscu-tcp-dialog, + .v2-pet-settings-dialog, + .energy-monitor-page +) { + border-radius: var(--ui-radius-modal) !important; +} + +/* Read-only containers must not imitate clickable cards on hover. */ +body.console-v2 :is( + .v2-sync-transport-field, + .v2-sync-execution-field, + .v2-sync-log, + .v2-pet-card-region, + .mode-editor-flow, + .energy-trend-card, + .energy-devices-card +):is(:hover, :focus-within) { + background-color: var(--glass-card) !important; + border-color: var(--glass-line) !important; + box-shadow: var(--glass-card-rest-shadow) !important; + transform: none; +} + +/* One title scale across peer panels and dialogs. */ +body.console-v2 :is( + #assistantDrawer .v2-operation-heading h2, + .joint-twin-shell .twin-heading h3, + .v2-device-sync-board > header h3, + .v2-extension-sync-heading h3 +) { + font-size: var(--ui-font-panel-title) !important; + font-weight: 700 !important; + line-height: 1.25 !important; +} + +body.console-v2 :is(.v2-sync-log h3, .v2-mode-rail .mode-rail-title) { + font-size: 14px !important; + font-weight: 700 !important; + line-height: 1.25 !important; +} + +body.console-v2 :is( + .mode-gallery-header h2, + .mode-editor-header h2, + .v2-extension-dialog h2, + .energy-monitor-heading h2 +) { + font-size: 22px !important; + font-weight: 700 !important; + line-height: 1.2 !important; + letter-spacing: -0.025em !important; +} + +body.console-v2 :is( + .mode-gallery-header p, + .mode-gallery-footer, + .mode-editor-header p, + .mode-editor-status, + .v2-pet-settings-footnote, + .v2-nfc-footnote +) { + font-size: 12px !important; + line-height: 1.5 !important; +} + +body.console-v2 :is( + .mode-gallery-card-heading strong, + .v2-pet-card > header > strong, + .v2-gscu-actions > header strong, + .v2-gscu-command-catalog > header strong +) { + font-size: var(--ui-font-card-title) !important; + line-height: 1.35 !important; +} + +body.console-v2 :is( + .mode-gallery-card-description, + .v2-pet-card-fields > label > span, + .v2-gscu-tcp-dialog label, + .energy-kpi > small +) { + font-size: 12px !important; + line-height: 1.4 !important; +} + +body.console-v2 :is(.mode-gallery-card-meta, .energy-kpi > em, .energy-devices-card > footer) { + font-size: 11px !important; + line-height: 1.4 !important; +} + +/* Consistent icon-button variants. */ +body.console-v2 :is( + .mode-gallery-close, + .mode-editor-header > button, + .v2-extension-dialog [data-dialog-close], + .v2-extension-dialog [data-close-extension-dialog], + .v2-extension-dialog [data-close-nfc-dialog], + #energyMonitorClose, + .twin-reset +) { + width: var(--ui-control-size-icon) !important; + height: var(--ui-control-size-icon) !important; + min-width: var(--ui-control-size-icon) !important; + min-height: var(--ui-control-size-icon) !important; + border-radius: var(--ui-radius-control) !important; +} + +body.console-v2 :is(.mode-gallery-edit, .mode-gallery-delete, .v2-pet-card > header > button) { + width: var(--ui-control-height-compact) !important; + height: var(--ui-control-height-compact) !important; + min-width: var(--ui-control-height-compact) !important; + min-height: var(--ui-control-height-compact) !important; + border-radius: var(--ui-radius-control-inner) !important; +} + +/* Make horizontal continuation discoverable without adding another control. */ +body.console-v2 .v2-mode-rail .mode-chips { + scroll-padding-inline: 18px; + -webkit-mask-image: linear-gradient(90deg, transparent, black 18px, black calc(100% - 26px), transparent); + mask-image: linear-gradient(90deg, transparent, black 18px, black calc(100% - 26px), transparent); +} + +/* Reserve scroll space beneath the required floating theme switch. */ +body.console-v2 .v2-sync-log ol { + padding-bottom: 58px !important; + scroll-padding-bottom: 58px; +} + +/* Offline is a destructive state, not an active primary state. */ +body.console-v2 .v2-nfc-stage[data-state="offline"] > header { + color: var(--status-error-foreground) !important; + background: color-mix(in oklch, var(--destructive) 16%, var(--card)) !important; + border-bottom: 1px solid color-mix(in oklch, var(--destructive) 30%, var(--border)); +} + +body.console-v2 .v2-nfc-stage[data-state="offline"] > header :is(span, small, h3, p, b) { + color: var(--status-error-foreground) !important; +} + +body.console-v2 .v2-nfc-stage[data-state="offline"] > header > span, +body.console-v2 .v2-nfc-stage[data-state="offline"] > header > b { + background: var(--status-error-soft) !important; + border-color: color-mix(in oklch, var(--destructive) 28%, var(--border)) !important; +} + /* Scrollbars ------------------------------------------------------------- */ body.console-v2 * { scrollbar-color: color-mix(in oklch, var(--primary) 70%, white 8%) color-mix(in oklch, var(--background) 66%, transparent); @@ -807,4 +1113,45 @@ body.console-v2 *::-webkit-scrollbar-thumb { body.console-v2::after { filter: blur(44px); } + + body.console-v2 :is( + .v2-sync-transport-field, + .v2-sync-execution-field, + .v2-can-command-panel, + .v2-sync-log, + .v2-nfc-inline, + .v2-extension-gateway, + .v2-extension-synced-device, + .v2-extension-empty, + .v2-extension-config-card, + .v2-extension-type-card, + .v2-extension-vehicle-card, + .v2-pet-card-region, + .v2-pet-card, + .v2-pet-presence-card, + .v2-device-card, + .v2-synced-device, + .joint-seat-panel, + .domain-panel, + .coverage-card, + .decision-list li, + .frame-summary, + .execution-summary, + .mode-gallery-card, + .mode-editor-flow, + .mode-editor-command-card, + .v2-nfc-body, + .v2-nfc-existing, + .v2-nfc-pet-binding, + .energy-kpi, + .energy-trend-card, + .energy-devices-card, + .assistant-dispatched-controls, + .assistant-mode-draft, + .assistant-mode-created, + .assistant-control-suggestion + ) { + transform: none !important; + transition: none !important; + } } diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 2bba596..ef69b15 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -266,7 +266,7 @@ if (isPassengerSeat) { const passengerAxes = { "seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0], - "seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1], + "seat.set_cushion_tilt": ["副驾座椅坐盆调节", "basin", 1], "seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2], "seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3], }; @@ -447,11 +447,19 @@ height: new Set(["up", "down", "stop"]), backrest: new Set(["forward", "backward", "stop"]), basin: new Set(["up", "down", "stop"]), + legrest: new Set(["up", "down", "forward", "backward", "stop"]), }; if (validDirections[axis]?.has(direction)) { - if (direction === "stop") return command("主驾座椅调节", `seat driver ${axis} stop`); + const axisCategory = { + horizontal: "主驾座椅前后调节", + height: "主驾座椅坐盆高度调节", + basin: "主驾座椅坐盆调节", + backrest: "主驾座椅靠背调节", + legrest: "主驾座椅腿托调节", + }[axis] || "主驾座椅调节"; + if (direction === "stop") return command(axisCategory, `seat driver ${axis} stop`); const duration = boundedInteger(parameters.duration_ms, 100, 2000); - if (duration !== null) return command("主驾座椅调节", `seat driver ${axis} ${direction} ${duration}`); + if (duration !== null) return command(axisCategory, `seat driver ${axis} ${direction} ${duration}`); } } if (capability === "seat.set_driver_position_native") { @@ -531,6 +539,7 @@ } }); const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]); + const abortedStates = new Set(["aborted", "cancelled", "canceled", "skipped"]); const deliveryForOperation = (operation) => { const capability = String(operation?.capability || ""); const operationId = String(operation?.id || ""); @@ -555,30 +564,59 @@ || telemetry.feedback_observed === true || telemetry.physical_effect_confirmed === true ); + const executionStatus = String(execution?.status || "").toLowerCase(); + const verificationStatus = String(telemetry.verification_status || "").toLowerCase(); const failureMessage = String(execution?.message || "").trim(); - const executionFailed = execution?.status === "failed" - || telemetry.verification_status === "failed_or_blocked"; - const safetyBlocked = executionFailed && !submitted && ( - /no fresh valid 0x41a shade position/i.test(failureMessage) - || /refusing (?:blind )?motion/i.test(failureMessage) - || /thermal protection/i.test(failureMessage) + const aborted = abortedStates.has(executionStatus); + const executionFailed = executionStatus === "failed" + || verificationStatus === "failed_or_blocked"; + const missingWindowFeedback = /0x388/i.test(failureMessage) && ( + /no fresh valid/i.test(failureMessage) + || /未收到.*(?:有效|新鲜).*车窗位置/.test(failureMessage) ); - const deliveryState = safetyBlocked + const missingShadeFeedback = /0x41a/i.test(failureMessage) && ( + /no fresh valid/i.test(failureMessage) + || /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage) + ); + const safetyBlocked = executionFailed && !submitted && ( + missingWindowFeedback + || missingShadeFeedback + || /refusing (?:blind )?motion/i.test(failureMessage) + || /拒绝(?:启动机构|盲动|执行)/.test(failureMessage) + || /thermal protection|热保护/i.test(failureMessage) + ); + const dispatchFailed = executionFailed && !submitted && !safetyBlocked; + const verified = !executionFailed && !aborted && ( + verificationStatus === "verified_success" + || telemetry.physical_effect_confirmed === true + || telemetry.feedback_match === true + ); + const deliveryState = aborted + ? "aborted" + : safetyBlocked ? "blocked" : executionFailed ? "failed" : submitted ? "submitted" : "not_submitted"; - const deliveryLabel = safetyBlocked + const deliveryLabel = aborted + ? "已中止" + : safetyBlocked ? "安全阻止" - : executionFailed + : dispatchFailed + ? "下发失败" + : executionFailed ? "执行失败" + : verified + ? "执行已确认" : submitted - ? "下发" + ? "已下发 · 待状态确认" : "未下发"; - const deliveryDetail = /no fresh valid 0x41a shade position/i.test(failureMessage) - ? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动" + const deliveryDetail = missingWindowFeedback + ? "未收到车窗 CAN1 / 0x388 实时位置,系统已阻止车窗盲动" + : missingShadeFeedback + ? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动" : failureMessage; const transportLabel = capability === "infrared.emit" ? "红外 TCP" @@ -614,18 +652,32 @@ .filter((frame) => frame.value); const command = String(presentation?.actual_command || fallback.command || "").trim(); if (!command) return; + const delivery = deliveryForOperation(operation); + const explicitDeliveryState = String(presentation?.delivery_state || "").trim(); + const explicitDeliveryLabel = String(presentation?.delivery_label || "").trim(); + const explicitDeliveryDetail = String(presentation?.delivery_detail || "").trim(); + const explicitDeliveryTransport = String(presentation?.delivery_transport || "").trim(); commands.push({ ...fallback, channel, command, + category: String(presentation?.category || fallback.category || "控制指令"), ...(frames.length ? { frame: frames[0].value, frameLabel: frames[0].label, frames, } : {}), - semantic: semanticControlInstruction(operation), + semantic: String( + presentation?.semantic_instruction || semanticControlInstruction(operation), + ), operationRef: String(operation.id || ""), - ...deliveryForOperation(operation), + ...delivery, + ...(explicitDeliveryState ? { deliveryState: explicitDeliveryState } : {}), + ...(explicitDeliveryLabel ? { deliveryLabel: explicitDeliveryLabel } : {}), + ...(explicitDeliveryDetail ? { deliveryDetail: explicitDeliveryDetail } : {}), + ...(explicitDeliveryTransport ? { + deliveryTransport: explicitDeliveryTransport, + } : {}), }); }); } else { @@ -669,6 +721,7 @@ saved_mode_command_flow: "模式卡片", local_rule: "本地规则", speech_semantic: "语音语义", + vehicle_mqtt_extension: "车端 MQTT", }[source] || "云端模型"; } @@ -874,6 +927,10 @@ ${escapeHtml(item.deliveryLabel)} ${escapeHtml(item.category)} + ${item.deliveryDetail && ["blocked", "failed", "aborted"].includes(item.deliveryState) ? ` + + 状态说明${escapeHtml(item.deliveryDetail)} + ` : ""}
语义控制指令 ${escapeHtml(item.semantic)} @@ -1214,6 +1271,13 @@ logKeys: new Set(), extensionFingerprint: "", }; + const extensionControlSync = { + revision: -1, + requestId: "", + running: false, + stopped: true, + controller: null, + }; const nonDispatchTaskSources = new Set(["mode_editor_compile"]); function isDispatchTask(task = {}) { @@ -2074,6 +2138,183 @@ } } + function extensionControlDelivery(action = {}) { + const state = String(action.state || "pending"); + const defaults = { + pending: ["not_submitted", "等待下发"], + executing: ["executing", "正在下发"], + submitted_unverified: ["submitted", "已下发 · 物理效果未确认"], + verified_success: ["submitted", "执行已确认"], + failed: ["failed", "下发失败"], + aborted: ["aborted", "已中止"], + }; + const [deliveryState, deliveryLabel] = defaults[state] || defaults.pending; + return { + deliveryState, + deliveryLabel: String(action.state_label || deliveryLabel), + deliveryDetail: String(action.state_detail || ""), + }; + } + + function extensionControlResult(snapshot = {}) { + const requestId = String(snapshot.last_request_id || ""); + const actions = Array.isArray(snapshot.action_statuses) + ? snapshot.action_statuses + : []; + return { + request_id: requestId, + utterance: "车端 MQTT 外设控制", + status: snapshot.state === "failed" ? "failed" : "succeeded", + execution_mode: "hardware", + control_transport: "extension_mqtt", + orchestration_route: "vehicle_mqtt_extension_control", + validated_control_commands: actions.map((action, position) => { + const index = Number.isInteger(Number(action.index)) + ? Number(action.index) + : position; + const atMs = Math.max(0, Number(action.at_ms) || 0); + const slot = Number(action.slot); + const extensionName = String(action.extension_name || "红外外设"); + const actionName = String(action.action || "红外动作"); + const targetScope = String(action.target_scope || "external-device"); + const delivery = extensionControlDelivery(action); + return { + domain: "self_extension", + channel: "IR", + category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`, + semantic_instruction: `${extensionName} · ${actionName}`, + actual_command: `infrared emit ${slot}`, + delivery_state: delivery.deliveryState, + delivery_label: delivery.deliveryLabel, + delivery_detail: [delivery.deliveryDetail, `at_ms=${atMs}`] + .filter(Boolean) + .join(" · "), + delivery_transport: "车端 MQTT → 红外 TCP", + frames: [{ + label: "GSCU1B TCP", + value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }), + }], + operation: { + id: `${requestId || "extension-ctl"}:${index}`, + domain: "self_extension", + capability: "infrared.emit", + target: { device: targetScope, name: extensionName }, + parameters: { action: actionName, slot }, + parse_source: "vehicle_mqtt_extension", + }, + }; + }), + }; + } + + function renderExtensionControlRejection(snapshot = {}) { + const panel = $("#v2CanCommandPanel"); + const list = $("#v2CanCommandList"); + const count = $("#v2CanCommandCount"); + if (!panel || !list || !count) return; + count.textContent = "0"; + panel.dataset.state = "error"; + panel.open = true; + list.innerHTML = `

车端 MQTT 外设控制已拒绝:${escapeHtml(snapshot.last_error || "消息未通过校验")}

`; + if (ui.sidebarCollapsed) setSidebarCollapsed(false); + requestAnimationFrame(syncSidebarLogBaseline); + } + + function applyExtensionControlSnapshot(snapshot = {}) { + const revision = Number(snapshot.revision); + if (!Number.isInteger(revision)) return; + if (revision < extensionControlSync.revision) { + extensionControlSync.revision = -1; + extensionControlSync.requestId = ""; + } + if (revision <= extensionControlSync.revision) return; + extensionControlSync.revision = revision; + const requestId = String(snapshot.last_request_id || ""); + const state = String(snapshot.state || ""); + const actions = Array.isArray(snapshot.action_statuses) + ? snapshot.action_statuses + : []; + if (requestId) extensionControlSync.requestId = requestId; + + if (actions.length) { + renderParsedCanCommands(extensionControlResult(snapshot), { reveal: true }); + appendSyncLog({ + key: `extension-control:${requestId}:${state}:${snapshot.executed_action_count || 0}`, + category: "extension", + title: state === "completed" + ? "车端 MQTT 外设控制已下发" + : state === "failed" + ? "车端 MQTT 外设控制下发失败" + : "正在执行车端 MQTT 外设控制", + detail: `${actions.length} 条红外指令 · ${snapshot.last_error || state}`, + status: state === "failed" ? "error" : state === "completed" ? "success" : "info", + }); + return; + } + if (requestId && ["rejected", "queue_full"].includes(state)) { + renderExtensionControlRejection(snapshot); + appendSyncLog({ + key: `extension-control:${requestId}:${state}`, + category: "extension", + title: "车端 MQTT 外设控制已拒绝", + detail: String(snapshot.last_error || "消息未通过校验"), + status: "error", + }); + } + } + + const extensionControlRetryDelay = () => new Promise((resolve) => { + window.setTimeout(resolve, 1000); + }); + + async function monitorExtensionControl() { + if (extensionControlSync.running) return; + extensionControlSync.running = true; + extensionControlSync.stopped = false; + try { + while (!extensionControlSync.stopped) { + const controller = new AbortController(); + extensionControlSync.controller = controller; + const query = new URLSearchParams({ + after_revision: String(extensionControlSync.revision), + timeout_ms: "25000", + }); + try { + const response = await fetch(`/api/extension/control/status?${query}`, { + credentials: "same-origin", + cache: "no-store", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + applyExtensionControlSnapshot(await response.json()); + } catch (error) { + if (extensionControlSync.stopped || error?.name === "AbortError") break; + console.warn("[extension-control-sync]", error); + await extensionControlRetryDelay(); + } finally { + if (extensionControlSync.controller === controller) { + extensionControlSync.controller = null; + } + } + } + } finally { + extensionControlSync.running = false; + } + } + + function startExtensionControlMonitor() { + if (extensionControlSync.running) return; + extensionControlSync.stopped = false; + void monitorExtensionControl(); + } + + function stopExtensionControlMonitor() { + extensionControlSync.stopped = true; + extensionControlSync.controller?.abort(); + extensionControlSync.controller = null; + } + function applyDisplaySnapshot(snapshot) { const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog) ? snapshot.extension.catalog @@ -2461,6 +2702,9 @@ appendSyncLog({ key: "client:ready", category: "sync", title: "同步控制台已就绪", detail: "正在连接车机任务与设备状态服务", status: "info" }); pollVehicleDisplaySync(); displaySync.pollTimer = window.setInterval(pollVehicleDisplaySync, 2000); + startExtensionControlMonitor(); + window.addEventListener("pagehide", stopExtensionControlMonitor); + window.addEventListener("pageshow", startExtensionControlMonitor); const updateClock = () => { if ($("#v2SyncClock")) $("#v2SyncClock").textContent = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, diff --git a/apps/web-console/gesture-control.js b/apps/web-console/gesture-control.js index cbe1946..652fb53 100755 --- a/apps/web-console/gesture-control.js +++ b/apps/web-console/gesture-control.js @@ -566,7 +566,7 @@ async function loadGestureRuntime() { async function loadRecognizer() { if (state.recognizer) return; $("#gestureSafetyText").textContent = "正在加载手势模型…"; - const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE); + const { FilesetResolver, GestureRecognizer } = await import(/* @vite-ignore */ VISION_MODULE); const fileset = await FilesetResolver.forVisionTasks(VISION_WASM); const modelResponse = await window.CognitiveAgentNetwork.request("/api/gesture/model", { cache: "force-cache", diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 2103283..e761b18 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -11,630 +11,45 @@ 511工程-智能座舱 - - - - - - - - - - - + - + - + -
- - 511工程-智能座舱 - 正在加载车机控制与实时状态 -
-
- - -
-
-
-
-

等待请求

- IDLE -
-

Cabin / reference_vehicle / dry-run

-
-
- -
10 / 57报文 / 信号
-
-
- -
-
-
-

整车设备状态

-
-
- CH0副驾座椅 - CH1主驾 / 车身 - MATRIX-BACKED · SIMULATED FEEDBACK -
-
-
-
-
- -
空调CMD 0x1DE · FB 0x404 · CH1
- UNKNOWN -
-

可控参数电源 · 温度 · 风量/风向

-
-
- 主驾--℃ - 风向- -
-
- -
-
- 副驾--℃ - 风向- -
-
-
-
风量
未知
-
循环
未知
-
A/C
未知
-
前除霜
未知
-
-
- -
-
- -
车辆状态FB 0x342 · 0x4EB · 0x407 · CH1
- 只读 -
-

只读信号档位 · 车速 · 四门锁状态

-
-
当前档位--
-
实时车速-- km/h
-
-
- 左前门未知 - 右前门未知 - 左后门未知 - 右后门未知 -
-
- -
-
- -
遮阳帘CMD 0x431 · FB 0x41A · CH1
- UNKNOWN -
-

可控参数开关 / 停止 · 位置

- -
-
遮阳帘
未知
-
动作
未知
-
初始化
未知
-
热保护
未知
-
-
- -
-
- -
车内灯光INTERIOR / AMBIENT · CH1
- UNKNOWN -
-

可控参数阅读灯 · 氛围亮度 · 色号

- -
-
氛围灯
未知
-
室内/阅读灯
未知
-
氛围亮度
- / -
-
氛围色号
- / -
-
-
- -
-
- -
车窗CMD 0x431 · FB 0x388 · CH1
- UNKNOWN -
-

可控参数四窗独立 · 开/关/停 · 位置

- -
-
左前窗
未知
-
右前窗
未知
-
左后窗
未知
-
右后窗
未知
-
-
- -
-
- -
前大灯FB 0x38A · CH1
- UNKNOWN -
-

可控参数近/远光 · 雾灯 · 转向灯

- -
-
近光灯
未知
-
远光灯
未知
-
雾灯
未知
-
转向灯
未知
-
-
- -
-
- -
-
-
-

整车孪生界面

- -
-
-
- - -
- -
-
-
- -
副驾座椅CH0 · 右前排
-
主驾座椅CH1 · 左前排
-
车头方向
- -
正在加载整车参考模型
-
-
- -
-
-
-

前排座椅状态

- CH0 / CH1 FEEDBACK -
-
-
-
副驾座椅右前排
- CH0 · 等待反馈 -
- 动态模式未运行 -
-
-
-
-
主驾座椅左前排
- CH1 · 等待反馈 -
- 车端 CAN FD只读同步 -
-
- 通风 -- - 加热 -- - 按摩 -- -
-
-
-
-
-
-

当前控制边界

-
-
空调 18–32℃ / 0–7 档
可编译
-
天幕 / 遮阳帘 0–100%
可编译
-
氛围灯 RGB / 亮度
可编译
-
副驾座椅统一执行器
t2g_primary_seat
-
主驾座椅车端执行器
CH1 · 左前排
-
真实台架 CAN 下发
未配置
-
-
-
- -
-
-

编排结果工作台

- REQUEST → CONFIG → CAN → RESULT -
- - -
-

决策轨迹与校验

IDLE
-
-
-

可审计决策摘要

--
-
  1. 提交请求后显示识别目标、能力选择和约束来源。
-
-
-

阶段事件

0 ms
-
暂无执行事件
-
-
-
-

校验结果

0 条
-
等待配置生成
-
-
- -
-
-

指令配置与硬件映射

-
-
-
-
等待生成配置提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。
-
-
- 查看原始配置functions/seat.yaml -
# 等待生成 FunctionConfig
-
-
- -
-

CAN 编译计划

-
Frame ID--Payload--ModeDRY-RUN
-
- 查看原始 CAN 计划command-plans/seat.yaml -
# 等待 CAN 编译
-
-
- -
-

执行结果与硬件回读

IDLE
-
-
尚未执行运行结果将在此处显示
-
-
-
-
-
-
- - - - - - - - -
+
+ + diff --git a/apps/web-console/package-lock.json b/apps/web-console/package-lock.json new file mode 100644 index 0000000..2741501 --- /dev/null +++ b/apps/web-console/package-lock.json @@ -0,0 +1,1394 @@ +{ + "name": "@n7/cognitive-agent-web-console", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@n7/cognitive-agent-web-console", + "version": "1.0.0", + "dependencies": { + "vue": "3.5.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.1", + "vite": "7.3.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", + "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", + "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", + "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/runtime-core": "3.5.21", + "@vue/shared": "3.5.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", + "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "vue": "3.5.21" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", + "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-sfc": "3.5.21", + "@vue/runtime-dom": "3.5.21", + "@vue/server-renderer": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/apps/web-console/package.json b/apps/web-console/package.json new file mode 100644 index 0000000..23f69db --- /dev/null +++ b/apps/web-console/package.json @@ -0,0 +1,18 @@ +{ + "name": "@n7/cognitive-agent-web-console", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173" + }, + "dependencies": { + "vue": "3.5.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.1", + "vite": "7.3.6" + } +} diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index d5ec45f..b2cf0e2 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v305"; +const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,22 +6,22 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-23", "/network.js?v=android-pad-2", - "/app.js?v=dual-seat-140", + "/app.js?v=dual-seat-141", "/gesture-control.js?v=android-pad-9", "/pwa.js?v=android-pad-3", "/console-v2.css?v=delivery-state-69", - "/console-v2.js?v=local-first-70", + "/console-v2.js?v=local-first-76", "/self-extension.css?v=pet-settings-52", "/self-extension.js?v=pet-settings-52", "/speech-input.css?v=sensevoice-7", "/speech-input.js?v=sensevoice-13", "/speech-audio-worklet.js?v=sensevoice-5", "/assistant-chat.css?v=codex-assistant-29", - "/assistant-chat.js?v=codex-assistant-33", + "/assistant-chat.js?v=codex-assistant-34", "/energy-monitor.css?v=energy-monitor-4", "/energy-monitor.js?v=energy-monitor-4", - "/ui-consistency.css?v=spatial-ui-5", - "/codepen-glass-theme.css?v=creative-cloud-2", + "/ui-consistency.css?v=spatial-ui-8", + "/codepen-glass-theme.css?v=creative-cloud-5", "/vue-ui.js?v=vue-presentation-3", "/assets/pets/self-extension-cat.png?v=3", "/assets/pets/self-extension-cat-entrance.png?v=3", diff --git a/apps/web-console/src/App.vue b/apps/web-console/src/App.vue new file mode 100644 index 0000000..6c6d0ed --- /dev/null +++ b/apps/web-console/src/App.vue @@ -0,0 +1,584 @@ + + + diff --git a/apps/web-console/src/main.js b/apps/web-console/src/main.js new file mode 100644 index 0000000..faa70b2 --- /dev/null +++ b/apps/web-console/src/main.js @@ -0,0 +1,33 @@ +import { createApp, nextTick } from "vue"; + +import App from "./App.vue"; +import "../styles.css"; +import "../console-v2.css"; +import "../self-extension.css"; +import "../speech-input.css"; +import "../assistant-chat.css"; +import "../energy-monitor.css"; +import "../ui-consistency.css"; +import "../codepen-glass-theme.css"; +import "./styles/design-system.css"; +import "../vue-ui.js"; + +const application = createApp(App); +application.config.errorHandler = (error, instance, info) => { + console.error("N7 Vue application error", { error, instance, info }); +}; +application.mount("#app"); + +document.querySelector("#app")?.removeAttribute("aria-busy"); +document.documentElement.classList.add("vue-vite-application"); +document.documentElement.dataset.vueVersion = application.version || "3"; + +nextTick(() => { + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + window.dispatchEvent(new CustomEvent("cognitive-agent:vue-mounted")); +}); + +window.CognitiveAgentVueApp = Object.freeze({ + application, + version: application.version || "3" +}); diff --git a/apps/web-console/src/styles/design-system.css b/apps/web-console/src/styles/design-system.css new file mode 100644 index 0000000..5327730 --- /dev/null +++ b/apps/web-console/src/styles/design-system.css @@ -0,0 +1,275 @@ +/* N7 Web Console design-system contract. + * Loaded last by Vite so every legacy surface consumes one geometry, + * typography, state and elevation scale while the runtime is migrated to + * Vue components incrementally. */ + +:root { + --n7-control-height-sm: 32px; + --n7-control-height: 38px; + --n7-icon-button-size: 38px; + --n7-radius-control: 10px; + --n7-radius-card: 14px; + --n7-radius-section: 16px; + --n7-radius-panel: 18px; + --n7-radius-dialog: 22px; + --n7-font-caption: 12px; + --n7-font-body: 13px; + --n7-font-card-title: 14px; + --n7-font-panel-title: 16px; + --n7-font-dialog-title: 22px; + --n7-shadow-card: inset 0 1px 0 var(--glass-highlight), 0 7px 20px color-mix(in oklch, var(--foreground) 8%, transparent); + --n7-shadow-panel: inset 0 1px 0 var(--glass-highlight), 0 14px 34px color-mix(in oklch, var(--foreground) 13%, transparent); + --n7-shadow-dialog: inset 0 1px 0 var(--glass-highlight), 0 28px 84px color-mix(in oklch, var(--foreground) 28%, transparent); +} + +#app { + min-width: inherit; + min-height: 100dvh; + display: contents; +} + +/* Workspace panels: one top-level geometry, distinct from their children. */ +body.console-v2.v2-passive-display.v2-interactive-sync :is( + .main-panel > .v2-mode-rail, + #assistantDrawer, + .joint-twin-shell > .twin-band, + #v2DevicePanel, + #v2ExtensionPanel .v2-extension-sync-mount +) { + border-radius: var(--n7-radius-panel) !important; + box-shadow: var(--n7-shadow-panel) !important; +} + +body.console-v2 :is( + .v2-sync-transport-field, + .v2-sync-execution-field, + .v2-can-command-panel, + .v2-nfc-inline, + .v2-extension-gateway, + .v2-extension-synced-device, + .v2-extension-empty, + .v2-pet-card, + .v2-pet-presence-card, + .v2-device-card, + .v2-synced-device, + .mode-gallery-card, + .mode-editor-command-card, + .energy-kpi, + .energy-device-row, + .assistant-dispatched-controls, + .assistant-mode-draft, + .assistant-mode-created, + .assistant-control-suggestion +) { + border-radius: var(--n7-radius-card) !important; + box-shadow: var(--n7-shadow-card) !important; +} + +body.console-v2 :is( + .v2-sync-log, + .v2-pet-card-region, + .mode-gallery-group, + .mode-editor-flow, + .v2-nfc-stage, + .energy-trend-card, + .energy-devices-card +) { + border-radius: var(--n7-radius-section) !important; +} + +/* Only actionable cards float. Read-only panels remain spatially stable. */ +body.console-v2 :is( + .v2-sync-log, + .v2-pet-card-region, + .mode-editor-flow, + .energy-trend-card, + .energy-devices-card +):is(:hover, :focus-within) { + transform: none !important; + box-shadow: var(--n7-shadow-card) !important; +} + +/* The current DOM uses .twin-selector; the previous glass selector targeted + * the removed .twin-seat-switch class and produced white-on-white tabs. */ +body.console-v2 .joint-twin-shell :is(.twin-selector, .twin-selector button, .twin-reset, .twin-focus-badge) { + color: var(--muted-foreground) !important; + background: var(--glass-card) !important; + border-color: var(--glass-line) !important; + box-shadow: inset 0 1px 0 var(--glass-highlight) !important; +} + +body.console-v2 .joint-twin-shell .twin-selector button.active { + color: var(--primary-foreground) !important; + background: var(--primary) !important; + border-color: color-mix(in oklch, var(--primary) 72%, white 18%) !important; +} + +/* Shared heading hierarchy. */ +body.console-v2 :is( + #assistantDrawer h2, + .joint-twin-shell .twin-heading h3, + .v2-device-sync-board > header h3, + .v2-extension-sync-heading h3, + .v2-mode-rail .mode-rail-title +) { + font-size: var(--n7-font-panel-title) !important; + font-weight: 750 !important; + line-height: 1.25 !important; + letter-spacing: -0.02em; +} + +/* The log title shares a 217 px header with its filter, so it needs the + * compact card-title scale instead of the full panel-title scale. */ +body.console-v2 .v2-sync-log h3 { + min-width: 0; + margin: 0; + overflow: hidden; + font-size: var(--n7-font-card-title) !important; + font-weight: 750 !important; + line-height: 1.25 !important; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +body.console-v2 :is( + .mode-gallery-header h2, + .mode-editor-header h2, + .v2-extension-dialog > header h2, + .v2-extension-dialog form > header h2, + .energy-monitor-heading h2 +) { + font-size: var(--n7-font-dialog-title) !important; + font-weight: 750 !important; + line-height: 1.2 !important; +} + +body.console-v2 :is( + .mode-gallery-card-heading strong, + .v2-device-card-focus strong, + .v2-nfc-inline strong, + .v2-extension-gateway strong, + .v2-pet-card > header > strong, + .energy-trend-card h3, + .energy-devices-card h3 +) { + font-size: var(--n7-font-card-title) !important; + line-height: 1.35 !important; +} + +body.console-v2 :is( + .mode-editor-status, + .mode-gallery-footer, + .v2-pet-settings-footnote, + .v2-pet-card-fields > label > span, + .v2-pet-nfc-field output, + .energy-kpi > small, + .energy-kpi > em, + .energy-devices-card > footer +) { + font-size: var(--n7-font-caption) !important; + line-height: 1.45 !important; +} + +/* One dialog radius, elevation and close-button size. */ +body.console-v2 :is( + .mode-gallery, + .mode-editor, + .v2-nfc-dialog, + .v2-gscu-tcp-dialog, + .v2-pet-settings-dialog, + .energy-monitor-page +) { + border-radius: var(--n7-radius-dialog) !important; + box-shadow: var(--n7-shadow-dialog) !important; +} + +body.console-v2 :is( + .mode-gallery-close, + .mode-editor-header > button, + .v2-extension-dialog [data-dialog-close], + .v2-extension-dialog [data-close-extension-dialog], + .v2-extension-dialog [data-close-nfc-dialog], + #energyMonitorClose +) { + width: var(--n7-icon-button-size) !important; + height: var(--n7-icon-button-size) !important; + min-width: var(--n7-icon-button-size); + min-height: var(--n7-icon-button-size); + border-radius: var(--n7-radius-control) !important; +} + +/* Compact icon actions still meet a usable hit target without becoming a + * second visual system. */ +body.console-v2 :is( + .twin-reset, + .mode-gallery-edit, + .mode-gallery-delete, + .v2-pet-card > header > button +) { + width: var(--n7-control-height-sm) !important; + height: var(--n7-control-height-sm) !important; + min-width: var(--n7-control-height-sm); + min-height: var(--n7-control-height-sm); + border-radius: var(--n7-radius-control) !important; +} + +/* Offline hardware is a destructive state, not a primary/active state. */ +body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header { + color: var(--status-error-foreground) !important; + background: color-mix(in oklch, var(--destructive) 16%, var(--card)) !important; + border-bottom: 1px solid color-mix(in oklch, var(--destructive) 32%, var(--border)); +} + +body.console-v2 .v2-nfc-stage:has(.v2-nfc-waiting.offline) > header :is(span, small, h3, p, b) { + color: var(--status-error-foreground) !important; +} + +/* Keep the global appearance control out of content and below every modal. */ +.glass-theme-toggle { + left: max(18px, env(safe-area-inset-left)) !important; + bottom: max(18px, env(safe-area-inset-bottom)) !important; + z-index: 1100 !important; +} + +body.mode-gallery-open .glass-theme-toggle, +body.mode-editor-open .glass-theme-toggle, +body:has(.v2-extension-dialog[open]) .glass-theme-toggle, +body:has(.energy-monitor-layer:not([hidden])) .glass-theme-toggle { + opacity: 0; + pointer-events: none; +} + +/* The sidebar reserves the floating control's footprint instead of allowing + * it to cover the log timeline. */ +body.console-v2 .v2-sync-log { + margin-bottom: 58px; +} + +/* Top mode rail communicates that more content is available. */ +body.console-v2 .v2-mode-rail .mode-rail-content { + position: relative; + min-width: 0; +} + +body.console-v2 .v2-mode-rail .mode-rail-content::after { + width: 28px; + content: ""; + position: absolute; + inset: 0 0 0 auto; + pointer-events: none; + background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--glass-panel) 94%, transparent)); +} + +@media (max-width: 1360px) { + .glass-theme-toggle { + width: 42px !important; + height: 42px !important; + } +} + +@media (prefers-reduced-motion: reduce) { + body.console-v2 * { + scroll-behavior: auto !important; + } +} diff --git a/apps/web-console/ui-consistency.css b/apps/web-console/ui-consistency.css index 06e381f..5baee8e 100644 --- a/apps/web-console/ui-consistency.css +++ b/apps/web-console/ui-consistency.css @@ -289,29 +289,96 @@ /* Delivery states use the same semantic feedback language in the sidebar and in the compact assistant command card. */ .console-v2.v2-passive-display.v2-interactive-sync - .v2-can-command-delivery:is( - [data-delivery-state="blocked"], - [data-delivery-state="failed"] - ) { + .v2-can-command-list + li[data-can-channel] + .v2-can-command-delivery[data-delivery-state="failed"] { color: var(--destructive); background: color-mix(in oklch, var(--destructive) 10%, var(--card)); border-color: color-mix(in oklch, var(--destructive) 28%, var(--border)); } -.assistant-dispatched-controls-list > li:is( - [data-delivery-state="blocked"], - [data-delivery-state="failed"] -) { +.console-v2.v2-passive-display.v2-interactive-sync + .v2-can-command-list + li[data-can-channel] + .v2-can-command-delivery[data-delivery-state="blocked"] { + color: var(--status-warning-foreground); + background: var(--status-warning-soft); + border-color: color-mix(in oklch, var(--status-warning) 38%, var(--border)); +} + +.console-v2.v2-passive-display.v2-interactive-sync + .v2-can-command-list + li[data-can-channel] + .v2-can-command-delivery[data-delivery-state="aborted"] { + color: var(--muted-foreground); + background: var(--muted); + border-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync + .v2-can-command-list + li[data-can-channel] + .v2-can-command-delivery-detail { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px; + margin: 1px 0 2px; + padding: 5px 7px; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--muted) 62%, transparent); + border-left: 2px solid var(--border); + border-radius: var(--ui-radius-control-inner); + font-size: 9px; + line-height: 1.45; + white-space: normal; +} + +.console-v2.v2-passive-display.v2-interactive-sync + .v2-can-command-list + li[data-delivery-state="failed"] + .v2-can-command-delivery-detail { + color: var(--status-error-foreground); + border-left-color: var(--destructive); +} + +.console-v2.v2-passive-display.v2-interactive-sync + .v2-can-command-list + li[data-delivery-state="blocked"] + .v2-can-command-delivery-detail { + color: var(--status-warning-foreground); + border-left-color: var(--status-warning); +} + +.assistant-dispatched-controls-list > li[data-delivery-state="failed"] { background: color-mix(in oklch, var(--destructive) 4%, var(--card)); } -.assistant-dispatched-controls-list > li:is( - [data-delivery-state="blocked"], - [data-delivery-state="failed"] -) .assistant-dispatched-control-icon { +.assistant-dispatched-controls-list + > li[data-delivery-state="failed"] + .assistant-dispatched-control-icon { color: var(--destructive); } +.assistant-dispatched-controls-list > li[data-delivery-state="blocked"] { + background: var(--status-warning-soft); +} + +.assistant-dispatched-controls-list + > li[data-delivery-state="blocked"] + .assistant-dispatched-control-icon { + color: var(--status-warning-foreground); +} + +.assistant-dispatched-controls-list > li[data-delivery-state="aborted"] { + background: color-mix(in oklch, var(--muted) 68%, transparent); +} + +.assistant-dispatched-controls-list + > li[data-delivery-state="aborted"] + .assistant-dispatched-control-icon { + color: var(--muted-foreground); +} + .console-v2 :is( #v2PanelControlDock > .v2-panel-control-button, .v2-mode-rail .mode-rail-actions > button, diff --git a/apps/web-console/vite.config.js b/apps/web-console/vite.config.js new file mode 100644 index 0000000..2eaf938 --- /dev/null +++ b/apps/web-console/vite.config.js @@ -0,0 +1,94 @@ +import { cpSync, copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +const webRoot = dirname(fileURLToPath(import.meta.url)); +const outputRoot = resolve(webRoot, "dist"); +const backendOrigin = "http://127.0.0.1:8000"; + +const runtimeDirectories = ["assets", "icons", "vendor"]; +const runtimeFiles = [ + "manifest.webmanifest", + "service-worker.js", + "speech-audio-worklet.js", + "login.html", + "login.css", + "login.js", + "hardware-control.html", + "hardware-control.css", + "hardware-control.js", + "network.js", + "app.js", + "gesture-control.js", + "pwa.js", + "console-v2.js", + "self-extension.js", + "speech-input.js", + "assistant-chat.js", + "energy-monitor.js", + "styles.css", + "console-v2.css", + "self-extension.css", + "speech-input.css", + "assistant-chat.css", + "energy-monitor.css", + "ui-consistency.css", + "codepen-glass-theme.css", + "vue-ui.js" +]; + +function copyRuntimeAssets() { + return { + name: "copy-n7-runtime-assets", + apply: "build", + closeBundle() { + for (const directory of runtimeDirectories) { + const source = resolve(webRoot, directory); + if (existsSync(source)) { + cpSync(source, resolve(outputRoot, directory), { recursive: true }); + } + } + for (const file of runtimeFiles) { + const source = resolve(webRoot, file); + if (!existsSync(source)) continue; + const target = resolve(outputRoot, file); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(source, target); + } + } + }; +} + +function backendProxy() { + return { + target: backendOrigin, + changeOrigin: true, + configure(proxy) { + proxy.on("proxyReq", (request) => request.setHeader("origin", backendOrigin)); + } + }; +} + +export default defineConfig({ + root: webRoot, + publicDir: false, + plugins: [vue(), copyRuntimeAssets()], + server: { + strictPort: true, + fs: { allow: [webRoot] }, + proxy: { + "/api": backendProxy(), + "/artifacts": backendProxy() + } + }, + build: { + outDir: outputRoot, + emptyOutDir: true, + assetsDir: "vite-assets", + target: "es2022", + sourcemap: true + } +}); diff --git a/apps/web-console/vue-ui.js b/apps/web-console/vue-ui.js index 2db757a..03ba581 100644 --- a/apps/web-console/vue-ui.js +++ b/apps/web-console/vue-ui.js @@ -1,20 +1,9 @@ +import { Fragment, createApp, h, nextTick, reactive } from "vue"; + (function () { "use strict"; const VUE_VERSION = "3.5.21"; - const vue = window.Vue; - const unavailable = { - available: false, - version: VUE_VERSION, - renderDispatchedControls: () => false, - renderModeChips: () => false, - }; - if (!vue?.createApp || !vue?.h || !vue?.reactive) { - window.CognitiveAgentVueUI = unavailable; - return; - } - - const { Fragment, createApp, h, nextTick, reactive } = vue; const modeMounts = new WeakMap(); const commandMounts = new WeakMap(); const text = (value, fallback = "") => String(value ?? fallback); diff --git a/src/cognitive_agent.egg-info/SOURCES.txt b/src/cognitive_agent.egg-info/SOURCES.txt index 88827ce..163753d 100755 --- a/src/cognitive_agent.egg-info/SOURCES.txt +++ b/src/cognitive_agent.egg-info/SOURCES.txt @@ -18,6 +18,8 @@ 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/extension_control.py +src/cognitive_agent/application/extension_toy_publisher.py src/cognitive_agent/application/gesture_bridge.py src/cognitive_agent/application/mode_profile.py src/cognitive_agent/application/mode_profile_intent.py @@ -28,6 +30,7 @@ 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/pet_cards.py src/cognitive_agent/application/scene_composer.py src/cognitive_agent/application/scene_templates.py src/cognitive_agent/application/seat_calibration.py @@ -81,4 +84,4 @@ 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 +src/cognitive_agent/testing/ch1_hil.py diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py index 52ae3ea..5b48cf4 100644 --- a/src/cognitive_agent/application/assistant_capabilities.py +++ b/src/cognitive_agent/application/assistant_capabilities.py @@ -1850,7 +1850,7 @@ class AssistantCapabilityCatalog: ] if not peripherals: return CapabilityAnswer( - "工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已启用且对应 NFC 卡在场”的可执行外设能力。", + "工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已学习、已启用且保留有效 NFC 绑定”的可执行外设能力。", "peripherals", ) labels = [ @@ -2321,7 +2321,9 @@ class AssistantCapabilityCatalog: try: nfc = dict(self._nfc_provider()) if self._nfc_provider else {} except Exception: # noqa: BLE001 - optional NFC reader may be offline - return [], "unavailable" + # The reader only reports current presence. Durable bindings remain + # callable even while the reader is offline or no card is present. + nfc = {} card = nfc.get("card") if isinstance(nfc.get("card"), Mapping) else {} active_uid = _normalized_nfc_uid(card.get("uid")) if isinstance(card, Mapping) else "" peripherals: list[dict[str, object]] = [] @@ -2350,7 +2352,7 @@ class AssistantCapabilityCatalog: active_uid and card_uid and active_uid == card_uid ), "assistant_callable": bool( - learned and enabled and active_uid and card_uid and active_uid == card_uid + learned and enabled and card_uid ), } ) diff --git a/src/cognitive_agent/application/extension_control.py b/src/cognitive_agent/application/extension_control.py new file mode 100644 index 0000000..37ab1d1 --- /dev/null +++ b/src/cognitive_agent/application/extension_control.py @@ -0,0 +1,638 @@ +"""Receive, authorize and execute car-originated infrared control requests.""" + +from __future__ import annotations + +import json +import math +import queue +import re +import threading +import time +import uuid +from collections.abc import Callable, Mapping +from copy import deepcopy +from datetime import UTC, datetime +from typing import Any + +from cognitive_agent.application.extension_toy_publisher import ( + normalize_extension_nfc_id, +) + + +EXTENSION_CONTROL_TOPIC = "extension/ctl" +MAX_EXTENSION_CONTROL_ACTIONS = 64 +MAX_EXTENSION_CONTROL_AT_MS = 60_000 +MAX_EXTENSION_CONTROL_PAYLOAD_BYTES = 256 * 1024 +DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE = 32 +DEFAULT_ADJACENT_EMIT_INTERVAL_S = 0.8 +_ACTION_REQUIRED_KEYS = frozenset( + { + "type", + "at_ms", + "domain", + "capability", + "target_scope", + "operation", + "parameters", + } +) +_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +class ExtensionControlError(ValueError): + """Base error for an invalid or unauthorized ``extension/ctl`` request.""" + + +class ExtensionControlAuthorizationError(ExtensionControlError): + """Raised when an action is absent from the local durable allowlist.""" + + +def _timestamp() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _json_object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ExtensionControlError(f"extension/ctl contains duplicate key: {key}") + result[key] = value + return result + + +def parse_extension_control_payload(raw_payload: bytes | str) -> dict[str, Any]: + """Parse the fixed car-to-PC control envelope and normalize its actions.""" + + if isinstance(raw_payload, bytes): + encoded = raw_payload + try: + text = raw_payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON") from exc + elif isinstance(raw_payload, str): + text = raw_payload + encoded = raw_payload.encode("utf-8") + else: + raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON") + if not encoded or len(encoded) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES: + raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB") + + try: + payload = json.loads(text, object_pairs_hook=_json_object_without_duplicate_keys) + except ExtensionControlError: + raise + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise ExtensionControlError("extension/ctl payload must be valid JSON") from exc + if not isinstance(payload, Mapping) or set(payload) != { + "domain", + "operation_count", + "actions", + }: + raise ExtensionControlError( + "extension/ctl requires exactly domain, operation_count and actions" + ) + if payload.get("domain") != "self_extension": + raise ExtensionControlError("extension/ctl domain must be self_extension") + + operation_count = payload.get("operation_count") + actions = payload.get("actions") + if ( + isinstance(operation_count, bool) + or not isinstance(operation_count, int) + or not 1 <= operation_count <= MAX_EXTENSION_CONTROL_ACTIONS + ): + raise ExtensionControlError( + f"extension/ctl operation_count must be in range 1-{MAX_EXTENSION_CONTROL_ACTIONS}" + ) + if not isinstance(actions, list) or len(actions) != operation_count: + raise ExtensionControlError( + "extension/ctl operation_count must equal the actions length" + ) + + normalized_actions: list[dict[str, Any]] = [] + for index, raw_action in enumerate(actions): + if not isinstance(raw_action, Mapping): + raise ExtensionControlError(f"extension/ctl action {index} must be an object") + missing = sorted(_ACTION_REQUIRED_KEYS - set(raw_action)) + if missing: + raise ExtensionControlError( + f"extension/ctl action {index} is missing: {', '.join(missing)}" + ) + if raw_action.get("type") != "infrared_emit": + raise ExtensionControlError( + f"extension/ctl action {index} type must be infrared_emit" + ) + at_ms = raw_action.get("at_ms") + if ( + isinstance(at_ms, bool) + or not isinstance(at_ms, int) + or not 0 <= at_ms <= MAX_EXTENSION_CONTROL_AT_MS + ): + raise ExtensionControlError( + f"extension/ctl action {index} at_ms must be in range " + f"0-{MAX_EXTENSION_CONTROL_AT_MS}" + ) + if raw_action.get("domain") != "self_extension": + raise ExtensionControlError( + f"extension/ctl action {index} domain must be self_extension" + ) + if raw_action.get("capability") != "infrared.emit": + raise ExtensionControlError( + f"extension/ctl action {index} capability must be infrared.emit" + ) + if raw_action.get("operation") != "emit": + raise ExtensionControlError( + f"extension/ctl action {index} operation must be emit" + ) + + target_scope = raw_action.get("target_scope") + if not isinstance(target_scope, str) or not _DEVICE_ID_PATTERN.fullmatch( + target_scope + ): + raise ExtensionControlError( + f"extension/ctl action {index} target_scope must be an English device id" + ) + parameters = raw_action.get("parameters") + if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}: + raise ExtensionControlError( + f"extension/ctl action {index} parameters require exactly action and slot" + ) + action_name = parameters.get("action") + slot = parameters.get("slot") + if ( + not isinstance(action_name, str) + or not action_name.strip() + or len(action_name.strip()) > 48 + ): + raise ExtensionControlError( + f"extension/ctl action {index} action name must contain 1-48 characters" + ) + if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248: + raise ExtensionControlError( + f"extension/ctl action {index} slot must be in range 1-248" + ) + normalized_actions.append( + { + "type": "infrared_emit", + "at_ms": at_ms, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": target_scope, + "operation": "emit", + "parameters": {"action": action_name.strip(), "slot": slot}, + } + ) + + return { + "domain": "self_extension", + "operation_count": operation_count, + "actions": normalized_actions, + } + + +def authorize_extension_control_actions( + request: Mapping[str, Any], + catalog: object, +) -> list[dict[str, Any]]: + """Authorize every action before any TCP command may be sent.""" + + actions = request.get("actions") + if not isinstance(actions, list): + raise ExtensionControlError("extension/ctl actions must be an array") + if not isinstance(catalog, list): + raise ExtensionControlError("local extension capability catalog is unavailable") + + allowlist: dict[tuple[str, int, str], dict[str, Any]] = {} + for capability in catalog: + if ( + not isinstance(capability, Mapping) + or capability.get("status") != "learned" + or capability.get("enabled") is not True + or not normalize_extension_nfc_id(capability.get("card_uid")) + ): + continue + device_id = str(capability.get("device_id") or "").strip() + action_name = str(capability.get("action") or "").strip() + slot = capability.get("slot") + if ( + not _DEVICE_ID_PATTERN.fullmatch(device_id) + or not action_name + or isinstance(slot, bool) + or not isinstance(slot, int) + or not 1 <= slot <= 248 + ): + continue + identity = (device_id, slot, action_name) + current = allowlist.get(identity) + if current is None or str(capability.get("id") or "") < str( + current.get("id") or "" + ): + allowlist[identity] = dict(capability) + + authorized: list[dict[str, Any]] = [] + for index, action in enumerate(actions): + parameters = action.get("parameters") if isinstance(action, Mapping) else None + if not isinstance(parameters, Mapping): + raise ExtensionControlError(f"extension/ctl action {index} is invalid") + target_scope = str(action.get("target_scope") or "").strip() + action_name = str(parameters.get("action") or "").strip() + slot = parameters.get("slot") + identity = (target_scope, slot, action_name) + capability = allowlist.get(identity) + if capability is None: + raise ExtensionControlAuthorizationError( + "extension/ctl action is not authorized by the local learned allowlist: " + f"{target_scope} / {action_name} / IR {slot}" + ) + authorized.append( + { + "index": index, + "at_ms": int(action.get("at_ms", 0)), + "action": deepcopy(dict(action)), + "capability_id": str(capability.get("id") or ""), + "extension_name": str(capability.get("device_name") or ""), + } + ) + return authorized + + +class ExtensionControlConsumer: + """Serialize validated car commands onto the computer's infrared TCP gateway.""" + + def __init__( + self, + *, + catalog_provider: Callable[[], list[dict[str, Any]]], + executor: Callable[[Mapping[str, Any]], Mapping[str, Any]], + queue_size: int = DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE, + adjacent_emit_interval_s: float = DEFAULT_ADJACENT_EMIT_INTERVAL_S, + ) -> None: + if queue_size <= 0: + raise ValueError("queue_size must be greater than zero") + if adjacent_emit_interval_s < 0: + raise ValueError("adjacent_emit_interval_s must not be negative") + self._catalog_provider = catalog_provider + self._executor = executor + self._queue: queue.Queue[tuple[bytes, float, str]] = queue.Queue(queue_size) + self._adjacent_emit_interval_s = float(adjacent_emit_interval_s) + self._lock = threading.RLock() + self._changed = threading.Condition(self._lock) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._revision = 0 + self._status: dict[str, Any] = { + "state": "stopped", + "topic": EXTENSION_CONTROL_TOPIC, + "source": "vehicle_mqtt_extension", + "source_label": "车端 MQTT", + "request_count": 0, + "processed_count": 0, + "rejected_count": 0, + "failed_count": 0, + "executed_action_count": 0, + "last_request_id": None, + "last_received_at": None, + "last_started_at": None, + "last_completed_at": None, + "last_latency_ms": None, + "last_request": None, + "action_statuses": [], + "active_action_index": None, + "last_rejection_kind": None, + "last_results": [], + "last_error": None, + } + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._set_status(state="waiting", last_error=None) + self._thread = threading.Thread( + target=self._run, + name="n7-extension-control-consumer", + daemon=True, + ) + self._thread.start() + + def close(self) -> None: + self._stop.set() + thread = self._thread + if thread and thread.is_alive(): + thread.join(timeout=2.0) + self._thread = None + self._set_status(state="stopped") + + def submit(self, raw_payload: bytes) -> None: + """Queue raw broker bytes without blocking the MQTT network loop.""" + + if self._stop.is_set(): + raise RuntimeError("extension control consumer is stopped") + payload = bytes(raw_payload) + if not payload or len(payload) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES: + raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB") + received_at = _timestamp() + try: + self._queue.put_nowait((payload, time.monotonic(), received_at)) + except queue.Full as exc: + self._set_status(state="queue_full", last_error="extension/ctl queue is full") + raise RuntimeError("extension/ctl queue is full") from exc + with self._lock: + request_count = int(self._status["request_count"]) + 1 + current_state = str(self._status["state"]) + self._set_status( + state="queued" if current_state not in {"validating", "executing"} else current_state, + request_count=request_count, + last_received_at=received_at, + last_error=None, + ) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return self._snapshot_locked() + + def reject_transport_message( + self, + reason: str, + rejection_kind: str = "transport", + ) -> None: + """Project an MQTT-layer rejection without executing any action.""" + + error = str(reason or "extension/ctl message was rejected").strip()[:240] + kind = str(rejection_kind or "transport").strip()[:48] + timestamp = _timestamp() + with self._lock: + request_count = int(self._status["request_count"]) + 1 + processed_count = int(self._status["processed_count"]) + 1 + rejected_count = int(self._status["rejected_count"]) + 1 + self._set_status( + state="rejected", + request_count=request_count, + processed_count=processed_count, + rejected_count=rejected_count, + last_request_id=f"extension-ctl-{uuid.uuid4().hex}", + last_received_at=timestamp, + last_started_at=timestamp, + last_completed_at=timestamp, + last_latency_ms=0, + last_request=None, + action_statuses=[], + active_action_index=None, + last_rejection_kind=kind, + last_results=[], + last_error=error, + ) + + def wait_for_snapshot( + self, + after_revision: int, + *, + timeout_s: float = 25.0, + ) -> dict[str, Any]: + """Long-poll until the display projection changes or the timeout expires.""" + + if isinstance(after_revision, bool) or not isinstance(after_revision, int): + raise ValueError("after_revision must be an integer") + if after_revision < -1: + raise ValueError("after_revision must be at least -1") + if ( + isinstance(timeout_s, bool) + or not isinstance(timeout_s, (int, float)) + or not math.isfinite(float(timeout_s)) + or not 0 <= float(timeout_s) <= 30 + ): + raise ValueError("timeout_s must be between 0 and 30 seconds") + with self._changed: + self._changed.wait_for( + lambda: self._revision > after_revision or self._stop.is_set(), + timeout=float(timeout_s), + ) + return self._snapshot_locked() + + def _snapshot_locked(self) -> dict[str, Any]: + return { + "revision": self._revision, + **deepcopy(self._status), + "queue_depth": self._queue.qsize(), + "queue_capacity": self._queue.maxsize, + } + + def _set_status(self, **changes: Any) -> None: + with self._changed: + next_status = {**self._status, **changes} + if next_status == self._status: + return + self._status = next_status + self._revision += 1 + self._changed.notify_all() + + def _record_rejection( + self, + error: Exception, + *, + request: Mapping[str, Any] | None = None, + rejection_kind: str = "validation", + ) -> None: + with self._lock: + processed = int(self._status["processed_count"]) + 1 + rejected = int(self._status["rejected_count"]) + 1 + self._set_status( + state="rejected", + processed_count=processed, + rejected_count=rejected, + last_completed_at=_timestamp(), + last_request=deepcopy(dict(request)) if request is not None else None, + action_statuses=[], + active_action_index=None, + last_rejection_kind=rejection_kind, + last_results=[], + last_error=str(error)[:240], + ) + + def _process(self, item: tuple[bytes, float, str]) -> None: + raw_payload, received_monotonic, received_at = item + request_id = f"extension-ctl-{uuid.uuid4().hex}" + self._set_status( + state="validating", + last_request_id=request_id, + last_received_at=received_at, + last_started_at=_timestamp(), + last_completed_at=None, + last_latency_ms=None, + last_request=None, + action_statuses=[], + active_action_index=None, + last_rejection_kind=None, + last_results=[], + last_error=None, + ) + request: dict[str, Any] | None = None + try: + request = parse_extension_control_payload(raw_payload) + actions = authorize_extension_control_actions( + request, + self._catalog_provider(), + ) + except Exception as exc: # noqa: BLE001 - reject complete request atomically + self._record_rejection(exc, request=request) + return + + ordered_actions = sorted(actions, key=lambda item: (item["at_ms"], item["index"])) + action_statuses = [ + { + "index": authorized["index"], + "execution_order": execution_order, + "at_ms": authorized["at_ms"], + "capability_id": authorized["capability_id"], + "extension_name": authorized["extension_name"], + "target_scope": authorized["action"]["target_scope"], + "action": authorized["action"]["parameters"]["action"], + "slot": authorized["action"]["parameters"]["slot"], + "state": "pending", + "state_label": "等待下发", + "state_detail": f"计划在接收后 {authorized['at_ms']} ms 下发", + "transport": "tcp", + "gateway_acknowledged": False, + "physical_state_confirmed": False, + "started_at": None, + "completed_at": None, + } + for execution_order, authorized in enumerate(ordered_actions) + ] + self._set_status( + state="executing", + last_request_id=request_id, + last_request=deepcopy(request), + action_statuses=deepcopy(action_statuses), + ) + results: list[dict[str, Any]] = [] + previous_emit_monotonic: float | None = None + failure: Exception | None = None + for item_index, authorized in enumerate(ordered_actions): + due_at = received_monotonic + authorized["at_ms"] / 1_000 + if previous_emit_monotonic is not None: + due_at = max( + due_at, + previous_emit_monotonic + self._adjacent_emit_interval_s, + ) + if self._stop.wait(max(0.0, due_at - time.monotonic())): + failure = RuntimeError("extension control consumer stopped") + action_statuses[item_index].update( + state="failed", + state_label="下发失败", + state_detail=str(failure), + completed_at=_timestamp(), + ) + break + action_statuses[item_index].update( + state="executing", + state_label="正在下发", + state_detail="正在通过本机 TCP 红外网关下发", + started_at=_timestamp(), + ) + self._set_status( + state="executing", + active_action_index=authorized["index"], + action_statuses=deepcopy(action_statuses), + ) + try: + result = self._executor(deepcopy(authorized["action"])) + if not isinstance(result, Mapping) or result.get("success") is False: + raise RuntimeError("infrared TCP gateway did not accept the command") + except Exception as exc: # noqa: BLE001 - abort remaining control flow + failure = exc + action_statuses[item_index].update( + state="failed", + state_label="下发失败", + state_detail=str(exc)[:240], + completed_at=_timestamp(), + ) + break + previous_emit_monotonic = time.monotonic() + gateway_acknowledged = result.get("gateway_acknowledged") is True + physical_state_confirmed = result.get("physical_state_confirmed") is True + action_statuses[item_index].update( + state=( + "verified_success" + if physical_state_confirmed + else "submitted_unverified" + ), + state_label=( + "执行已确认" + if physical_state_confirmed + else "已下发 · 物理效果未确认" + ), + state_detail=( + "设备物理效果已确认" + if physical_state_confirmed + else "网关已确认;物理效果未确认" + if gateway_acknowledged + else "TCP 已提交;设备未返回 ACK,物理效果未确认" + ), + transport=str(result.get("transport") or "tcp"), + gateway_acknowledged=gateway_acknowledged, + physical_state_confirmed=physical_state_confirmed, + completed_at=_timestamp(), + ) + results.append( + { + "index": authorized["index"], + "at_ms": authorized["at_ms"], + "capability_id": authorized["capability_id"], + "extension_name": authorized["extension_name"], + "target_scope": authorized["action"]["target_scope"], + "action": authorized["action"]["parameters"]["action"], + "slot": authorized["action"]["parameters"]["slot"], + "transport": str(result.get("transport") or "tcp"), + "gateway_acknowledged": gateway_acknowledged, + "physical_state_confirmed": physical_state_confirmed, + "executed_at": _timestamp(), + "execution_order": item_index, + } + ) + self._set_status( + action_statuses=deepcopy(action_statuses), + last_results=deepcopy(results), + ) + + if failure is not None: + for pending in action_statuses: + if pending["state"] == "pending": + pending.update( + state="aborted", + state_label="已中止", + state_detail="前序红外指令失败,未执行", + completed_at=_timestamp(), + ) + + completed_at = _timestamp() + latency_ms = round(max(0.0, time.monotonic() - received_monotonic) * 1_000) + with self._lock: + processed = int(self._status["processed_count"]) + 1 + failed = int(self._status["failed_count"]) + (1 if failure else 0) + executed = int(self._status["executed_action_count"]) + len(results) + self._set_status( + state="failed" if failure else "completed", + processed_count=processed, + failed_count=failed, + executed_action_count=executed, + active_action_index=None, + action_statuses=deepcopy(action_statuses), + last_completed_at=completed_at, + last_latency_ms=latency_ms, + last_results=deepcopy(results), + last_error=str(failure)[:240] if failure else None, + ) + + def _run(self) -> None: + while not self._stop.is_set(): + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + continue + try: + self._process(item) + except Exception as exc: # noqa: BLE001 - keep the consumer alive + self._record_rejection(exc) + finally: + self._queue.task_done() diff --git a/src/cognitive_agent/application/extension_toy_publisher.py b/src/cognitive_agent/application/extension_toy_publisher.py new file mode 100644 index 0000000..0a8c37d --- /dev/null +++ b/src/cognitive_agent/application/extension_toy_publisher.py @@ -0,0 +1,381 @@ +"""Bounded MQTT capability announcements for NFC-bound infrared extensions.""" + +from __future__ import annotations + +import re +import threading +import time +from collections.abc import Callable, Mapping +from copy import deepcopy +from datetime import UTC, datetime +from typing import Any + + +EXTENSION_TOY_MQTT_TOPIC = "extension/toy" +EXTENSION_TOY_BURST_COUNT = 3 +EXTENSION_TOY_BURST_WINDOW_S = 1.0 +_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_NFC_ID_PATTERN = re.compile(r"^[0-9A-F]{4,64}$") + + +def _timestamp() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def normalize_extension_nfc_id(value: Any) -> str: + """Return an uppercase hexadecimal NFC UID, or an empty string if invalid.""" + + raw_value = str(value or "").strip() + if not raw_value or re.search(r"[^0-9a-f\s:-]", raw_value, flags=re.IGNORECASE): + return "" + normalized = re.sub(r"[\s:-]", "", raw_value).upper() + return normalized if _NFC_ID_PATTERN.fullmatch(normalized) else "" + + +def _extension_display_name(capability: Mapping[str, Any]) -> str: + """Prefer the catalog's Chinese display identity for ``extension_name``.""" + + candidates: list[str] = [str(capability.get("device_name") or "").strip()] + for field in ("object_names", "device_aliases"): + values = capability.get(field) + if isinstance(values, str): + candidates.extend(re.split(r"[,,、;;|]", values)) + elif isinstance(values, list): + candidates.extend(str(value) for value in values) + normalized = [candidate.strip() for candidate in candidates if candidate.strip()] + chinese = next( + (candidate for candidate in normalized if re.search(r"[\u4e00-\u9fff]", candidate)), + None, + ) + return str(chinese or (normalized[0] if normalized else ""))[:64] + + +def build_extension_toy_messages( + card_uid: Any, + catalog: object, +) -> list[dict[str, Any]]: + """Project enabled learned abilities bound to one detected NFC card. + + One message is returned per stable English ``device_id``. Within a device, + actions are ordered by infrared slot and exact duplicate abilities are + removed. No action is executed by this projection. + """ + + normalized_uid = normalize_extension_nfc_id(card_uid) + if not normalized_uid: + return [] + if not isinstance(catalog, list): + raise ValueError("extension capability catalog must be an array") + + candidates: list[tuple[str, int, str, str]] = [] + seen: set[tuple[str, int, str]] = set() + for item in catalog: + if ( + not isinstance(item, Mapping) + or item.get("status") != "learned" + or item.get("enabled") is not True + or normalize_extension_nfc_id(item.get("card_uid")) != normalized_uid + ): + continue + device_id = str(item.get("device_id") or "").strip() + extension_name = _extension_display_name(item) + action_name = str(item.get("action") or "").strip() + raw_slot = item.get("slot") + if ( + not _DEVICE_ID_PATTERN.fullmatch(device_id) + or not extension_name + or not action_name + or len(action_name) > 48 + or isinstance(raw_slot, bool) + or not isinstance(raw_slot, int) + or not 1 <= raw_slot <= 248 + ): + continue + identity = (device_id, raw_slot, action_name) + if identity in seen: + continue + seen.add(identity) + candidates.append((device_id, raw_slot, action_name, extension_name)) + + grouped: dict[str, dict[str, Any]] = {} + for device_id, slot, action_name, extension_name in sorted( + candidates, + key=lambda item: (item[0], item[1], item[2]), + ): + message = grouped.setdefault( + device_id, + {"extension_name": extension_name, "actions": []}, + ) + message["actions"].append( + { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": device_id, + "operation": "emit", + "parameters": {"action": action_name, "slot": slot}, + } + ) + return [deepcopy(grouped[device_id]) for device_id in sorted(grouped)] + + +class NfcExtensionToyPublisher: + """Publish one three-frame ability burst for each NFC card-detection edge.""" + + def __init__( + self, + *, + nfc_provider: Callable[[], Mapping[str, Any]], + catalog_provider: Callable[[], list[dict[str, Any]]], + publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]], + burst_count: int = EXTENSION_TOY_BURST_COUNT, + burst_window_s: float = EXTENSION_TOY_BURST_WINDOW_S, + poll_interval_s: float = 0.1, + ) -> None: + if isinstance(burst_count, bool) or not isinstance(burst_count, int): + raise ValueError("burst_count must be an integer") + if burst_count <= 0: + raise ValueError("burst_count must be greater than zero") + if burst_window_s <= 0: + raise ValueError("burst_window_s must be greater than zero") + if poll_interval_s <= 0: + raise ValueError("poll_interval_s must be greater than zero") + + self.burst_count = burst_count + self.burst_window_s = float(burst_window_s) + self.burst_interval_s = self.burst_window_s / self.burst_count + self.poll_interval_s = float(poll_interval_s) + self._nfc_provider = nfc_provider + self._catalog_provider = catalog_provider + self._publisher = publisher + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._revision = 0 + self._status: dict[str, Any] = { + "state": "stopped", + "topic": EXTENSION_TOY_MQTT_TOPIC, + "qos": 1, + "retain": False, + "burst_count": self.burst_count, + "burst_window_ms": round(self.burst_window_s * 1_000), + "active_nfc_id": None, + "active_extension_names": [], + "active_target_scopes": [], + "message_count": 0, + "burst_frame_attempt_count": 0, + "burst_publish_attempt_count": 0, + "burst_published_count": 0, + "total_publish_count": 0, + "last_detection_at": None, + "last_burst_started_at": None, + "last_burst_completed_at": None, + "last_published_at": None, + "last_delivery_status": None, + "last_message_id": None, + "last_messages": [], + "last_error": None, + } + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._set_status( + state="waiting_for_nfc", + active_nfc_id=None, + active_extension_names=[], + active_target_scopes=[], + message_count=0, + last_error=None, + ) + self._thread = threading.Thread( + target=self._run, + name="n7-extension-toy-mqtt-publisher", + daemon=True, + ) + self._thread.start() + + def close(self) -> None: + self._stop.set() + thread = self._thread + if thread and thread.is_alive(): + thread.join(timeout=2.0) + self._thread = None + self._set_status( + state="stopped", + active_nfc_id=None, + active_extension_names=[], + active_target_scopes=[], + message_count=0, + ) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return {"revision": self._revision, **deepcopy(self._status)} + + def _set_status(self, **changes: Any) -> None: + with self._lock: + next_status = {**self._status, **changes} + if next_status == self._status: + return + self._status = next_status + self._revision += 1 + + def _active_nfc_id(self) -> str: + snapshot = self._nfc_provider() + if not isinstance(snapshot, Mapping): + raise TypeError("NFC provider returned an invalid snapshot") + card = snapshot.get("card") + if not isinstance(card, Mapping): + return "" + if str(card.get("action") or "present") != "present": + return "" + return normalize_extension_nfc_id(card.get("uid")) + + def _publish_burst( + self, + card_uid: str, + messages: list[dict[str, Any]], + ) -> None: + started = time.monotonic() + publish_attempts = 0 + published = 0 + last_delivery_status: str | None = None + last_message_id: Any = None + last_error: str | None = None + with self._lock: + total_publish_count = int(self._status["total_publish_count"]) + self._set_status( + state="bursting", + active_nfc_id=card_uid, + active_extension_names=[message["extension_name"] for message in messages], + active_target_scopes=[ + message["actions"][0]["target_scope"] for message in messages + ], + message_count=len(messages), + burst_frame_attempt_count=0, + burst_publish_attempt_count=0, + burst_published_count=0, + last_detection_at=_timestamp(), + last_burst_started_at=_timestamp(), + last_burst_completed_at=None, + last_delivery_status=None, + last_message_id=None, + last_messages=deepcopy(messages), + last_error=None, + ) + + completed_frames = 0 + for frame_index in range(self.burst_count): + due_at = started + frame_index * self.burst_interval_s + if self._stop.wait(max(0.0, due_at - time.monotonic())): + break + completed_frames += 1 + for message in messages: + publish_attempts += 1 + try: + result = self._publisher(deepcopy(message)) + if not isinstance(result, Mapping): + raise TypeError("extension/toy publisher returned an invalid result") + except Exception as exc: # noqa: BLE001 - finish the bounded burst + last_error = str(exc)[:240] + last_delivery_status = "failed" + else: + published += 1 + total_publish_count += 1 + last_delivery_status = str( + result.get("delivery_status") or "queued" + ) + last_message_id = result.get("message_id") + self._set_status(last_published_at=_timestamp()) + self._set_status( + state="bursting", + burst_frame_attempt_count=completed_frames, + burst_publish_attempt_count=publish_attempts, + burst_published_count=published, + total_publish_count=total_publish_count, + last_delivery_status=last_delivery_status, + last_message_id=last_message_id, + last_error=last_error, + ) + + expected_publications = self.burst_count * len(messages) + completed = completed_frames == self.burst_count + if completed and published != expected_publications: + last_delivery_status = "partial" if published else "failed" + self._set_status( + state=( + "stopped" + if self._stop.is_set() and not completed + else "burst_complete" + if published == expected_publications + else "burst_failed" + ), + burst_frame_attempt_count=completed_frames, + burst_publish_attempt_count=publish_attempts, + burst_published_count=published, + total_publish_count=total_publish_count, + last_delivery_status=last_delivery_status, + last_message_id=last_message_id, + last_burst_completed_at=_timestamp() if completed else None, + last_error=last_error, + ) + + def _run(self) -> None: + latched_uid = "" + while not self._stop.is_set(): + try: + card_uid = self._active_nfc_id() + except Exception as exc: # noqa: BLE001 - report and continue polling NFC + self._set_status(state="nfc_unavailable", last_error=str(exc)[:240]) + self._stop.wait(self.poll_interval_s) + continue + + if card_uid != latched_uid: + if not card_uid: + latched_uid = "" + self._set_status( + state="waiting_for_nfc", + active_nfc_id=None, + active_extension_names=[], + active_target_scopes=[], + message_count=0, + burst_frame_attempt_count=0, + burst_publish_attempt_count=0, + burst_published_count=0, + last_error=None, + ) + else: + try: + catalog = self._catalog_provider() + messages = build_extension_toy_messages(card_uid, catalog) + except Exception as exc: # noqa: BLE001 - retry while detection remains + self._set_status( + state="catalog_unavailable", + active_nfc_id=card_uid, + last_error=str(exc)[:240], + ) + else: + # Latch only after a successful catalog read. A card that + # remains present never starts a second burst. + latched_uid = card_uid + if messages: + self._publish_burst(card_uid, messages) + else: + self._set_status( + state="no_matching_extension", + active_nfc_id=card_uid, + active_extension_names=[], + active_target_scopes=[], + message_count=0, + burst_frame_attempt_count=0, + burst_publish_attempt_count=0, + burst_published_count=0, + last_detection_at=_timestamp(), + last_messages=[], + last_error=None, + ) + + self._stop.wait(self.poll_interval_s) diff --git a/src/cognitive_agent/application/mode_scene_catalog.py b/src/cognitive_agent/application/mode_scene_catalog.py index f41f10a..a4d72ce 100644 --- a/src/cognitive_agent/application/mode_scene_catalog.py +++ b/src/cognitive_agent/application/mode_scene_catalog.py @@ -6,6 +6,9 @@ import hashlib import json import os import threading +import time +from collections import Counter +from collections.abc import Callable, Mapping from copy import deepcopy from datetime import UTC, datetime from pathlib import Path @@ -21,6 +24,8 @@ N7_SCENE_SERVICE = "n7-scenes" N7_SCENE_SCHEMA_VERSION = 1 DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent" DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录" +MODE_SCENE_MQTT_BURST_COUNT = 3 +MODE_SCENE_MQTT_BURST_WINDOW_S = 1.0 def _utc_now() -> str: @@ -65,7 +70,12 @@ def _legacy_zone(target_scope: str) -> str: }.get(target_scope, target_scope) -def _scene_action(operation: ModeOperation) -> dict[str, Any]: +def _scene_action( + operation: ModeOperation, + *, + target: Mapping[str, Any] | None = None, + operation_ref: str = "", +) -> dict[str, Any]: """Preserve the complete operation and add fields understood by the v1 demo.""" parameters = deepcopy(operation.parameters) @@ -78,6 +88,14 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]: "operation": operation.operation, "parameters": parameters, } + if target: + # FunctionConfig targets carry details which cannot be represented by + # the legacy ``target_scope`` string (notably an infrared device and + # its persisted NFC card UID). Keep that compact target object in the + # public scene action without exposing the complete command flow. + action["target"] = deepcopy(dict(target)) + if operation_ref: + action["operation_ref"] = operation_ref # The original demo catalog flattened these three climate actions. Keep # those aliases so an existing v1 client can continue reading them, while @@ -100,11 +118,130 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]: return action +def _operation_verb(capability: str) -> str: + action = capability.partition(".")[2].strip() + return action.partition("_")[0] or "set" + + +def _operation_identity(operation: ModeOperation) -> str: + """Return a stable semantic key across profile and FunctionConfig shapes.""" + + target_scope = { + "driver": "front_left", + "passenger": "front_right", + }.get(operation.target_scope, operation.target_scope) + return json.dumps( + { + "domain": operation.domain, + "capability": operation.capability, + "target_scope": target_scope, + "parameters": operation.parameters, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def _command_flow_operations( + profile: ModeProfile, +) -> list[tuple[ModeOperation, dict[str, Any], str]]: + """Project the latest saved FunctionConfigs into scene operations. + + The top-level ``profile.operations`` list is retained for compatibility + with mode matching and native CAN execution. A saved editor flow can also + contain executable self-extension operations, so the client-facing scene + projection must read the complete FunctionConfig set used by the editor. + Malformed optional flow entries are ignored and later filled from the + canonical top-level operations. + """ + + command_flow = profile.command_flow + if not isinstance(command_flow, dict): + return [] + configurations = command_flow.get("function_configs") + if not isinstance(configurations, dict): + return [] + + projected: list[tuple[ModeOperation, dict[str, Any], str]] = [] + for configuration in configurations.values(): + if not isinstance(configuration, dict): + continue + spec = configuration.get("spec") + if not isinstance(spec, dict): + continue + raw_operations = spec.get("operations") + if not isinstance(raw_operations, list): + continue + configured_domain = str(spec.get("domain") or "").strip() + for raw_operation in raw_operations: + if not isinstance(raw_operation, dict): + continue + capability = str(raw_operation.get("capability") or "").strip() + if not capability: + continue + domain = str( + raw_operation.get("domain") + or configured_domain + or capability.partition(".")[0] + ).strip() + raw_parameters = raw_operation.get("parameters") or {} + raw_target = raw_operation.get("target") or {} + if not domain or not isinstance(raw_parameters, dict): + continue + target = dict(raw_target) if isinstance(raw_target, dict) else {} + target_scope = str( + raw_operation.get("target_scope") + or target.get("zone") + or target.get("scope") + or target.get("device") + or "vehicle" + ).strip() + operation = ModeOperation( + domain=domain, + capability=capability, + target_scope=target_scope, + operation=str( + raw_operation.get("operation") or _operation_verb(capability) + ).strip(), + parameters=deepcopy(raw_parameters), + reason=str(raw_operation.get("reason") or ""), + ) + projected.append( + (operation, target, str(raw_operation.get("id") or "").strip()) + ) + return projected + + +def _effective_scene_operations( + profile: ModeProfile, +) -> list[tuple[ModeOperation, dict[str, Any], str]]: + """Use the latest editor flow first, then fill any non-duplicated fallback.""" + + effective = _command_flow_operations(profile) + if not effective: + return [(operation, {}, "") for operation in profile.operations] + + # Keep the authoritative flow exactly as ordered, including intentional + # repeated steps. Top-level operations only fill semantic occurrences + # absent from that flow, so the two persistence shapes are not double-counted. + represented = Counter(_operation_identity(operation) for operation, _, _ in effective) + profile_occurrences: Counter[str] = Counter() + for operation in profile.operations: + identity = _operation_identity(operation) + profile_occurrences[identity] += 1 + if profile_occurrences[identity] <= represented[identity]: + continue + effective.append((operation, {}, "")) + return effective + + def _scene_from_profile( registry: ModeProfileRegistry, profile: ModeProfile, public_profile: dict[str, Any], ) -> dict[str, Any]: + effective_operations = _effective_scene_operations(profile) scene = { "id": profile.id, "label": profile.name, @@ -116,11 +253,18 @@ def _scene_from_profile( "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), + "domains": sorted({operation.domain for operation, _, _ in effective_operations}), + "operation_count": len(effective_operations), "scope": deepcopy(public_profile.get("scope") or {}), "profile_revision": registry.profile_revision(profile), - "actions": [_scene_action(operation) for operation in profile.operations], + "actions": [ + _scene_action( + operation, + target=target, + operation_ref=operation_ref, + ) + for operation, target, operation_ref in effective_operations + ], } if public_profile.get("preset_override") is True: scene["preset_override"] = True @@ -246,3 +390,177 @@ class ModeSceneCatalogPublisher: except (OSError, json.JSONDecodeError): return None return document if isinstance(document, dict) else None + + +class ModeSceneMqttBurstPublisher: + """Publish one newly saved scene three times without blocking its HTTP save.""" + + def __init__( + self, + publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]], + *, + topic: str, + burst_count: int = MODE_SCENE_MQTT_BURST_COUNT, + burst_window_s: float = MODE_SCENE_MQTT_BURST_WINDOW_S, + ) -> None: + if isinstance(burst_count, bool) or not isinstance(burst_count, int): + raise ValueError("burst_count must be an integer") + if burst_count <= 0: + raise ValueError("burst_count must be greater than zero") + if burst_window_s <= 0: + raise ValueError("burst_window_s must be greater than zero") + clean_topic = str(topic).strip() + if not clean_topic: + raise ValueError("topic must not be empty") + + self._publisher = publisher + self.topic = clean_topic + self.burst_count = burst_count + self.burst_window_s = float(burst_window_s) + self.burst_interval_s = self.burst_window_s / self.burst_count + self._lock = threading.RLock() + self._stop = threading.Event() + self._threads: set[threading.Thread] = set() + self._status: dict[str, Any] = { + "state": "idle", + "topic": self.topic, + "scene_id": None, + "burst_count": self.burst_count, + "burst_window_ms": round(self.burst_window_s * 1_000), + "burst_attempt_count": 0, + "burst_published_count": 0, + "last_delivery_status": None, + "last_message_id": None, + "last_error": None, + "last_burst_started_at": None, + "last_burst_completed_at": None, + } + + def queue(self, scene: Mapping[str, Any]) -> dict[str, Any]: + """Queue an immutable copy of exactly one scene-card object.""" + + if not isinstance(scene, Mapping): + raise ValueError("scene must be an object") + payload = deepcopy(dict(scene)) + scene_id = str(payload.get("id") or "").strip() + if not scene_id: + raise ValueError("scene id must not be empty") + if "scenes" in payload or "service" in payload: + raise ValueError("scene burst accepts one scene card, not a catalog") + if self._stop.is_set(): + raise RuntimeError("mode scene MQTT publisher is stopped") + + thread = threading.Thread( + target=self._run_burst, + args=(payload,), + name=f"n7-mode-scene-mqtt-{scene_id}", + daemon=True, + ) + with self._lock: + self._threads.add(thread) + self._status.update( + state="queued", + scene_id=scene_id, + burst_attempt_count=0, + burst_published_count=0, + last_delivery_status=None, + last_message_id=None, + last_error=None, + last_burst_started_at=None, + last_burst_completed_at=None, + ) + try: + thread.start() + except Exception: + with self._lock: + self._threads.discard(thread) + raise + return { + "state": "queued", + "topic": self.topic, + "scene_id": scene_id, + "burst_count": self.burst_count, + "burst_window_ms": round(self.burst_window_s * 1_000), + } + + def _run_burst(self, scene: dict[str, Any]) -> None: + started = time.monotonic() + attempts = 0 + published = 0 + last_delivery_status: str | None = None + last_message_id: Any = None + last_error: str | None = None + with self._lock: + self._status.update( + state="bursting", + last_burst_started_at=_utc_now(), + ) + try: + for index in range(self.burst_count): + due_at = started + index * self.burst_interval_s + if self._stop.wait(max(0.0, due_at - time.monotonic())): + break + attempts += 1 + try: + result = self._publisher(deepcopy(scene)) + if not isinstance(result, Mapping): + raise TypeError("mode scene publisher returned an invalid result") + except Exception as exc: # noqa: BLE001 - finish the bounded burst + last_error = str(exc)[:240] + last_delivery_status = "failed" + else: + published += 1 + last_delivery_status = str( + result.get("delivery_status") or "queued" + ) + last_message_id = result.get("message_id") + with self._lock: + self._status.update( + state="bursting", + burst_attempt_count=attempts, + burst_published_count=published, + last_delivery_status=last_delivery_status, + last_message_id=last_message_id, + last_error=last_error, + ) + finally: + completed = attempts == self.burst_count + if completed and published != self.burst_count: + last_delivery_status = "partial" if published else "failed" + with self._lock: + self._status.update( + state=( + "stopped" + if self._stop.is_set() and not completed + else "burst_complete" + if published == self.burst_count + else "burst_failed" + ), + burst_attempt_count=attempts, + burst_published_count=published, + last_delivery_status=last_delivery_status, + last_message_id=last_message_id, + last_error=last_error, + last_burst_completed_at=_utc_now() if completed else None, + ) + self._threads.discard(threading.current_thread()) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return deepcopy(self._status) + + def start(self) -> None: + with self._lock: + self._stop.clear() + if self._status["state"] == "stopped": + self._status["state"] = "idle" + + def close(self) -> None: + self._stop.set() + with self._lock: + threads = tuple(self._threads) + deadline = time.monotonic() + 1.1 + for thread in threads: + thread.join(timeout=max(0.0, deadline - time.monotonic())) + with self._lock: + self._status["state"] = "stopped" diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 380604c..17ce329 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -55,6 +55,7 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 DEFAULT_N7_SCENE_SERVER_ID, DEFAULT_N7_SCENE_SERVER_NAME, ModeSceneCatalogPublisher, + ModeSceneMqttBurstPublisher, build_mode_scene_catalog, ) from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 @@ -129,6 +130,7 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402 VehicleMqttSettings, ) from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402 + MQTT_MODE_SCENE_TOPIC, MqttCanTransport, ) from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402 @@ -440,6 +442,10 @@ class SeatDemoService: "scene_count": 0, "last_error": None, } + self._mode_scene_mqtt_publisher = ModeSceneMqttBurstPublisher( + lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene), + topic=MQTT_MODE_SCENE_TOPIC, + ) self.scene_templates = SceneTemplateRegistry( repository_root / "config" / "scene_templates" ) @@ -540,6 +546,51 @@ class SeatDemoService: def mode_scene_catalog_status(self) -> dict[str, Any]: return deepcopy(self._mode_scene_catalog_status) + def publish_saved_mode_scene( + self, + mode_id: str, + vehicle_id: str | None = None, + ) -> dict[str, Any]: + """Queue the just-saved card from the canonical scene projection. + + Both a new user mode and a user override of a repository preset are + projected by the same registry. Reading the card back from that + projection guarantees that MQTT and ``/n7/scenes`` expose exactly the + same post-save JSON object. + """ + + clean_mode_id = str(mode_id).strip() + if not clean_mode_id: + raise ValueError("mode scene id must not be empty") + resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id + catalog = self.mode_scene_catalog(resolved_vehicle_id) + catalog_publisher = self._mode_scene_catalog_publisher + if catalog_publisher is not None and ( + catalog_publisher.vehicle_id != resolved_vehicle_id + or self._mode_scene_catalog_status.get("state") != "ready" + ): + raise RuntimeError("mode scene catalog is not synchronized for this vehicle") + scene = next( + ( + item + for item in catalog.get("scenes", []) + if isinstance(item, dict) and item.get("id") == clean_mode_id + ), + None, + ) + if scene is None: + raise KeyError(clean_mode_id) + return self._mode_scene_mqtt_publisher.queue(scene) + + def publish_created_mode_scene( + self, + mode_id: str, + vehicle_id: str | None = None, + ) -> dict[str, Any]: + """Backward-compatible alias for callers predating update publishing.""" + + return self.publish_saved_mode_scene(mode_id, vehicle_id) + @property def driver_only_ch1_execution_active(self) -> bool: """Return whether deployment policy authorizes only the CH1 vehicle path.""" @@ -697,6 +748,7 @@ class SeatDemoService: "mode_profile_storage": "persistent_yaml", "mode_profile_user_root": str(self.mode_profile_user_root), "mode_scene_catalog": self.mode_scene_catalog_status(), + "mode_scene_mqtt": self._mode_scene_mqtt_publisher.snapshot(), "preset_storage": "conversation_durable", "inferred_level_range": [1, 3], "motion_position_range_pct": [0, 100], @@ -1326,11 +1378,13 @@ class SeatDemoService: def start(self) -> None: if self._vehicle_state_monitor is not None: self._vehicle_state_monitor.start() + self._mode_scene_mqtt_publisher.start() self._vehicle_mqtt_transport.start() def close(self) -> None: if self._vehicle_state_monitor is not None: self._vehicle_state_monitor.close() + self._mode_scene_mqtt_publisher.close() self._vehicle_mqtt_transport.close() def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]: @@ -1338,6 +1392,30 @@ class SeatDemoService: return self._vehicle_mqtt_transport.publish_pet_message(payload) + def publish_extension_toy_message( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + """Publish one NFC-detected extension capability announcement.""" + + return self._vehicle_mqtt_transport.publish_extension_toy_message(payload) + + def set_extension_control_handler( + self, + handler: Callable[[bytes], None] | None, + ) -> None: + """Route ``extension/ctl`` payloads from the shared broker connection.""" + + self._vehicle_mqtt_transport.set_extension_control_handler(handler) + + def set_extension_control_rejection_handler( + self, + handler: Callable[[str, str], None] | None, + ) -> None: + """Project broker-level ``extension/ctl`` rejections into the UI status.""" + + self._vehicle_mqtt_transport.set_extension_control_rejection_handler(handler) + def _synchronize_vehicle_state_once( self, conversation_id: str, diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py index 9407db8..c30f925 100644 --- a/src/cognitive_agent/application/self_extension.py +++ b/src/cognitive_agent/application/self_extension.py @@ -12,6 +12,7 @@ import ssl import threading import time import uuid +from collections.abc import Mapping from contextlib import closing from datetime import UTC, datetime from pathlib import Path @@ -955,7 +956,7 @@ def _peripheral_execution_identity_authorized( capability: dict[str, Any], ) -> bool: # Saved modes execute an immutable, previously validated command flow and - # are rebound to the same live NFC/device/slot tuple before reaching here. + # are rebound to the same persisted NFC/device/slot tuple before reaching here. if capability.get("parse_source") == "saved_mode_command_flow": return True return bool(_device_match_score(_compact_match_text(utterance), capability)) @@ -1089,7 +1090,7 @@ def _deterministic_peripheral_steps( utterance: str, catalog: list[dict[str, Any]], ) -> list[dict[str, Any]]: - """Build an ordered live-peripheral plan when every phrase is unambiguous.""" + """Build an ordered bound-peripheral plan when every phrase is unambiguous.""" compact_utterance = _compact_match_text(utterance) learned = [ @@ -1229,11 +1230,13 @@ def resolve_active_peripheral_command( catalog: list[dict[str, Any]], nfc_snapshot: dict[str, Any], ) -> dict[str, Any] | None: - """Resolve an exact learned action for the NFC card that is present now. + """Resolve an exact learned action from the durable NFC-bound catalog. An exact peripheral name is required so generic vehicle phrases such as ``打开氛围灯`` keep using the native vehicle domain. Action synonyms are - accepted only after the peripheral identity and live card binding match. + accepted only after the peripheral identity and persisted binding match. + ``nfc_snapshot`` is retained in the public signature for compatibility; + card presence is intentionally not an execution prerequisite. """ return _resolve_bound_peripheral_command( @@ -1251,20 +1254,17 @@ def _resolve_bound_peripheral_command( *, require_enabled: bool, ) -> dict[str, Any] | None: - """Resolve a card-bound command, optionally including disabled entries. + """Resolve a persistently card-bound command, including disabled entries. Disabled entries are inspected only so the partitioner can report that a - requested action was filtered by the NFC command allowlist. Execution + requested action was filtered by the persisted command allowlist. Execution continues to use :func:`resolve_active_peripheral_command`, which always requires ``enabled`` to be true. """ + del nfc_snapshot # Presence is display state, not persistent authorization. compact_utterance = _compact_match_text(utterance) - card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None - active_uid = _normalized_card_uid( - card.get("uid") if isinstance(card, dict) else None - ) - if not compact_utterance or not active_uid: + if not compact_utterance: return None matches: list[tuple[int, int, dict[str, Any]]] = [] @@ -1278,7 +1278,8 @@ def _resolve_bound_peripheral_command( ) ): continue - if _normalized_card_uid(raw_item.get("card_uid")) != active_uid: + bound_uid = _normalized_card_uid(raw_item.get("card_uid")) + if not bound_uid: continue device_score = _device_match_score(compact_utterance, raw_item) if not device_score: @@ -1308,7 +1309,7 @@ def _resolve_bound_peripheral_command( resolved = dict(matches[0][2]) resolved["slot"] = validate_slot(resolved.get("slot")) - resolved["card_uid"] = active_uid + resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid")) return resolved @@ -1326,17 +1327,13 @@ def partition_active_peripheral_intent( """Claim only explicit learned-peripheral clauses and preserve CAN text. The function is deliberately conservative: a clause must contain both a - live NFC-bound device identity and one learned action. When no peripheral + persistently NFC-bound device identity and one learned action. When no peripheral clause is claimed, ``residual_utterance`` is byte-for-byte the original trimmed input so the existing CAN parser observes exactly its old input. """ original = str(utterance or "").strip() - card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None - active_uid = _normalized_card_uid( - card.get("uid") if isinstance(card, dict) else None - ) - if not original or not active_uid: + if not original: return { "peripheral_actions": [], "residual_utterance": original, @@ -1350,7 +1347,7 @@ def partition_active_peripheral_intent( for item in catalog[:MAX_CATALOG_ITEMS] if isinstance(item, dict) and item.get("status") == "learned" - and _normalized_card_uid(item.get("card_uid")) == active_uid + and bool(_normalized_card_uid(item.get("card_uid"))) ] active_catalog = [ item @@ -1541,14 +1538,11 @@ def active_peripheral_plan_catalog( catalog: list[dict[str, Any]], nfc_snapshot: dict[str, Any], ) -> list[dict[str, Any]]: - """Return the live-card catalog when a phrase needs an ordered plan.""" + """Return the durable bound catalog when a phrase needs an ordered plan.""" + del nfc_snapshot # Presence is display state, not persistent authorization. compact_utterance = _compact_match_text(utterance) - card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None - active_uid = _normalized_card_uid( - card.get("uid") if isinstance(card, dict) else None - ) - if not compact_utterance or not active_uid: + if not compact_utterance: return [] has_timing = bool( re.search(r"\d+(?:\.\d+)?(?:ms|s|毫秒|秒|分钟|分)", compact_utterance) @@ -1568,13 +1562,14 @@ def active_peripheral_plan_catalog( or raw_item.get("enabled", True) is False ): continue - if _normalized_card_uid(raw_item.get("card_uid")) != active_uid: + bound_uid = _normalized_card_uid(raw_item.get("card_uid")) + if not bound_uid: continue if not _device_match_score(compact_utterance, raw_item): continue item = dict(raw_item) item["slot"] = validate_slot(item.get("slot")) - item["card_uid"] = active_uid + item["card_uid"] = bound_uid active.append(item) if _has_unshadowed_vehicle_device_phrase(compact_utterance, active): return [] @@ -2378,6 +2373,61 @@ class SelfExtensionService: result["learning_capture_confirmed"] = False return result + def execute_bound_infrared_action( + self, + action: Mapping[str, Any], + ) -> dict[str, Any]: + """Re-authorize and emit one car-originated ``extension/ctl`` action.""" + + if ( + not isinstance(action, Mapping) + or action.get("type") != "infrared_emit" + or action.get("domain") != "self_extension" + or action.get("capability") != "infrared.emit" + or action.get("operation") != "emit" + ): + raise ExtensionIntegrationError("车端外设控制动作格式无效。") + device_id = str(action.get("target_scope") or "").strip() + parameters = action.get("parameters") + if not device_id or not isinstance(parameters, Mapping): + raise ExtensionIntegrationError("车端外设控制动作缺少设备或参数。") + slot = validate_slot(parameters.get("slot")) + action_name = str(parameters.get("action") or "").strip() + if not action_name: + raise ExtensionIntegrationError("车端外设控制动作名称不能为空。") + + capability = next( + ( + item + for item in self.list_peripheral_capabilities() + if isinstance(item, dict) + and item.get("status") == "learned" + and item.get("enabled") is True + and bool(_normalized_card_uid(item.get("card_uid"))) + and str(item.get("device_id") or "").strip() == device_id + and item.get("slot") == slot + and str(item.get("action") or "").strip() == action_name + ), + None, + ) + if capability is None: + raise ExtensionIntegrationError( + "车端外设控制动作未通过本机持久能力鉴权:" + f"{device_id} / {action_name} / IR {slot}。" + ) + + result = self.execute_gateway("emit", {"slot": slot}) + return { + **result, + "authorization": { + "source": "local_persistent_extension_allowlist", + "capability_id": capability.get("id"), + "device_id": device_id, + "action": action_name, + "slot": slot, + }, + } + def execute_peripheral_request( self, utterance: str, @@ -2389,7 +2439,7 @@ class SelfExtensionService: provider_requested: str = "auto", on_commands_validated: Callable[[dict[str, Any]], None] | None = None, ) -> dict[str, Any]: - """Execute a live-NFC peripheral match through the main request API.""" + """Execute a persistently NFC-bound match through the main request API.""" if not _peripheral_execution_identity_authorized(utterance, capability): raise ExtensionIntegrationError( @@ -2536,14 +2586,14 @@ class SelfExtensionService: "execution_mode": execution_mode, "control_transport": control_transport, "decision_summary": [ - f"检测到在场 NFC {capability.get('card_uid')},已优先匹配绑定外设。", + f"已通过持久 NFC 绑定 {capability.get('card_uid')} 匹配外设能力。", f"命中已学习动作:{capability.get('device_name')} · {capability.get('action')}(槽位 {slot})。", ], "trace_events": [ { "stage": "nfc_peripheral_route", "status": "succeeded", - "summary": "在场 NFC、外设名称与已学习动作均匹配", + "summary": "持久 NFC 绑定、外设名称与已学习动作均匹配", "duration_ms": 0, } ], @@ -2592,13 +2642,13 @@ class SelfExtensionService: provider_requested: str = "auto", on_commands_validated: Callable[[dict[str, Any]], None] | None = None, ) -> dict[str, Any]: - """Execute a validated ordered peripheral/delay plan for the live NFC.""" + """Execute a validated ordered plan from durable NFC-bound abilities.""" steps = plan_result.get("steps", []) if not isinstance(steps, list) or not steps: raise ExtensionIntegrationError("GPT 没有生成可执行的外设动作。") if any(step.get("kind") == "vehicle" for step in steps if isinstance(step, dict)): - raise ExtensionIntegrationError("当前主编排只允许执行在场 NFC 外设计划。") + raise ExtensionIntegrationError("当前主编排只允许执行持久绑定的 NFC 外设计划。") catalog_by_id = { str(item.get("id")): item for item in catalog @@ -2639,9 +2689,14 @@ class SelfExtensionService: raise ExtensionIntegrationError("GPT 计划包含未知步骤类型。") capability_id = str(step.get("capability_id") or "") capability = catalog_by_id.get(capability_id) - if capability is None or capability.get("status") != "learned": + if ( + capability is None + or capability.get("status") != "learned" + or capability.get("enabled", True) is False + or not _normalized_card_uid(capability.get("card_uid")) + ): raise ExtensionIntegrationError( - "GPT 计划引用了不在当前 NFC 目录中的能力。" + "GPT 计划引用了未学习、未启用或缺少持久 NFC 绑定的能力。" ) if not _peripheral_execution_identity_authorized(utterance, capability): raise ExtensionIntegrationError( @@ -2880,6 +2935,37 @@ class SelfExtensionService: } operations.append(operation) if interrupted: + for remaining_index in range(index + 1, len(steps)): + remaining_step = steps[remaining_index] + if remaining_step.get("kind") != "peripheral": + continue + ( + _remaining_capability_id, + _remaining_capability, + remaining_slot, + remaining_operation_ref, + ) = compiled_peripheral_steps[remaining_index] + operations.append( + { + "ref": remaining_operation_ref, + "domain": "self_extension", + "status": "aborted", + "message": "前序红外指令失败,未执行", + "telemetry": { + "transport": transport, + "slot": remaining_slot, + "delivery_status": "not_submitted", + "transport_submitted": False, + "gateway_acknowledged": False, + "physical_effect_confirmed": False, + "command": build_gateway_command( + "emit", {"slot": remaining_slot} + ), + "effect_mode": "one_shot", + "abort_reason": "prior_operation_failed", + }, + } + ) break if ( hardware_request @@ -2943,10 +3029,10 @@ class SelfExtensionService: else "GPT 已生成外设顺序计划。" ) ), - f"计划仅引用当前在场 NFC {first_capability.get('card_uid')} 的已学习能力。", + f"计划仅引用持久 NFC 绑定 {first_capability.get('card_uid')} 的已学习能力。", *( [ - "当前 NFC 未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣," + "当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣," "闪烁已安全降级为定色保持。" ] if steady_blink_fallbacks diff --git a/src/cognitive_agent/infrastructure/can/mqtt_transport.py b/src/cognitive_agent/infrastructure/can/mqtt_transport.py index f145fde..2ba16e4 100644 --- a/src/cognitive_agent/infrastructure/can/mqtt_transport.py +++ b/src/cognitive_agent/infrastructure/can/mqtt_transport.py @@ -37,6 +37,12 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings MQTT_CAN_SCHEMA = "n7.can.frame.v1" MQTT_PET_TOPIC = "n7/pet" +MQTT_EXTENSION_TOY_TOPIC = "extension/toy" +MQTT_EXTENSION_TOY_QOS = 1 +MQTT_EXTENSION_CONTROL_TOPIC = "extension/ctl" +MQTT_EXTENSION_CONTROL_QOS = 1 +MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024 +MQTT_MODE_SCENE_TOPIC = "from_pc/scenes" MQTT_POLL_INTERVAL_MS = 500 WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1" MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset( @@ -500,7 +506,7 @@ def mqtt_control_payload( def mqtt_pet_payload(payload: Mapping[str, Any]) -> str: - """Serialize the only application-level MQTT message emitted by N7Web.""" + """Serialize one NFC-authorized pet presence message.""" if set(payload) != {"pet_name", "nfc_id", "action"}: raise CanGatewayError( @@ -524,6 +530,208 @@ def mqtt_pet_payload(payload: Mapping[str, Any]) -> str: ) +_EXTENSION_TOY_ACTION_KEYS = frozenset( + { + "type", + "at_ms", + "domain", + "capability", + "target_scope", + "operation", + "parameters", + } +) + + +def mqtt_extension_toy_payload(payload: Mapping[str, Any]) -> str: + """Serialize one NFC-detected extension capability announcement.""" + + if not isinstance(payload, Mapping) or set(payload) != { + "extension_name", + "actions", + }: + raise CanGatewayError( + "extension/toy MQTT payload requires exactly extension_name and actions" + ) + extension_name = payload.get("extension_name") + if ( + not isinstance(extension_name, str) + or not extension_name.strip() + or len(extension_name.strip()) > 64 + ): + raise CanGatewayError( + "extension_name must contain between 1 and 64 characters" + ) + raw_actions = payload.get("actions") + if not isinstance(raw_actions, list) or not 1 <= len(raw_actions) <= 248: + raise CanGatewayError("extension actions must contain between 1 and 248 items") + + actions: list[dict[str, Any]] = [] + for raw_action in raw_actions: + if not isinstance(raw_action, Mapping) or set(raw_action) != _EXTENSION_TOY_ACTION_KEYS: + raise CanGatewayError( + "extension action requires exactly type, at_ms, domain, capability, " + "target_scope, operation and parameters" + ) + if raw_action.get("type") != "infrared_emit": + raise CanGatewayError("extension action type must be infrared_emit") + at_ms = raw_action.get("at_ms") + if isinstance(at_ms, bool) or not isinstance(at_ms, int) or at_ms < 0: + raise CanGatewayError("extension action at_ms must be a non-negative integer") + if raw_action.get("domain") != "self_extension": + raise CanGatewayError("extension action domain must be self_extension") + if raw_action.get("capability") != "infrared.emit": + raise CanGatewayError("extension action capability must be infrared.emit") + if raw_action.get("operation") != "emit": + raise CanGatewayError("extension action operation must be emit") + + target_scope = raw_action.get("target_scope") + if ( + not isinstance(target_scope, str) + or not target_scope + or len(target_scope) > 128 + or not target_scope[0].isalnum() + or any( + not (character.isascii() and (character.isalnum() or character in "._:-")) + for character in target_scope + ) + ): + raise CanGatewayError( + "extension action target_scope must be an English stable device id" + ) + + parameters = raw_action.get("parameters") + if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}: + raise CanGatewayError( + "extension action parameters require exactly action and slot" + ) + action_name = parameters.get("action") + if ( + not isinstance(action_name, str) + or not action_name.strip() + or len(action_name.strip()) > 48 + ): + raise CanGatewayError( + "extension action parameter action must contain between 1 and 48 characters" + ) + slot = parameters.get("slot") + if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248: + raise CanGatewayError("extension action parameter slot must be in range 1-248") + + actions.append( + { + "type": "infrared_emit", + "at_ms": at_ms, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": target_scope, + "operation": "emit", + "parameters": {"action": action_name.strip(), "slot": slot}, + } + ) + + return json.dumps( + {"extension_name": extension_name.strip(), "actions": actions}, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + + +_MODE_SCENE_REQUIRED_KEYS = frozenset( + { + "id", + "label", + "name", + "background_time", + "description", + "icon", + "aliases", + "origin", + "deletable", + "behavior", + "domains", + "operation_count", + "scope", + "profile_revision", + "actions", + } +) +_MODE_SCENE_ACTION_REQUIRED_KEYS = frozenset( + { + "type", + "at_ms", + "domain", + "capability", + "target_scope", + "operation", + "parameters", + } +) + + +def mqtt_mode_scene_payload(payload: Mapping[str, Any]) -> str: + """Serialize exactly one ``/n7/scenes`` card, never the catalog wrapper.""" + + if not isinstance(payload, Mapping): + raise CanGatewayError("mode scene MQTT payload must be a JSON object") + keys = set(payload) + if {"service", "scenes", "scene_count"} & keys: + raise CanGatewayError("mode scene MQTT payload must contain one scene only") + missing = sorted(_MODE_SCENE_REQUIRED_KEYS - keys) + if missing: + raise CanGatewayError( + f"mode scene MQTT payload is missing required fields: {', '.join(missing)}" + ) + + scene_id = payload.get("id") + if not isinstance(scene_id, str) or not scene_id.strip() or len(scene_id) > 64: + raise CanGatewayError("mode scene id must contain between 1 and 64 characters") + if not isinstance(payload.get("name"), str) or not str(payload["name"]).strip(): + raise CanGatewayError("mode scene name must not be empty") + if not isinstance(payload.get("aliases"), list): + raise CanGatewayError("mode scene aliases must be an array") + if not isinstance(payload.get("domains"), list): + raise CanGatewayError("mode scene domains must be an array") + if not isinstance(payload.get("scope"), Mapping): + raise CanGatewayError("mode scene scope must be an object") + + operation_count = payload.get("operation_count") + actions = payload.get("actions") + if ( + isinstance(operation_count, bool) + or not isinstance(operation_count, int) + or operation_count < 1 + ): + raise CanGatewayError("mode scene operation_count must be a positive integer") + if not isinstance(actions, list) or len(actions) != operation_count: + raise CanGatewayError("mode scene actions must match operation_count") + for action in actions: + if not isinstance(action, Mapping): + raise CanGatewayError("mode scene actions must contain JSON objects") + action_missing = sorted(_MODE_SCENE_ACTION_REQUIRED_KEYS - set(action)) + if action_missing: + raise CanGatewayError( + "mode scene action is missing required fields: " + + ", ".join(action_missing) + ) + if not isinstance(action.get("parameters"), Mapping): + raise CanGatewayError("mode scene action parameters must be an object") + + try: + raw_payload = json.dumps( + deepcopy(dict(payload)), + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise CanGatewayError("mode scene MQTT payload is not valid JSON") from exc + if len(raw_payload.encode("utf-8")) > 512 * 1024: + raise CanGatewayError("mode scene MQTT payload exceeds 512 KiB") + return raw_payload + + def _validate_native_seat_motion_control(data: bytes) -> None: """Validate the firmware-native 0x300 semantic runtime subset. @@ -644,6 +852,17 @@ class MqttCanTransport: self._latest_bus_frames: dict[ tuple[int, int, int], tuple[float, CanFrame] ] = {} + self._extension_control_handler: Callable[[bytes], None] | None = None + self._extension_control_rejection_handler: ( + Callable[[str, str], None] | None + ) = None + self._extension_control_received_count = 0 + self._extension_control_dispatched_count = 0 + self._extension_control_rejected_count = 0 + self._extension_control_duplicate_count = 0 + self._extension_control_retained_count = 0 + self._extension_control_last_received_at: str | None = None + self._extension_control_last_error: str | None = None def _build_client(self) -> Any: if self.client_factory is not None: @@ -725,6 +944,40 @@ class MqttCanTransport: self._state = "stopped" self._condition.notify_all() + def set_extension_control_handler( + self, + handler: Callable[[bytes], None] | None, + ) -> None: + """Register the non-blocking application queue for ``extension/ctl``.""" + + if handler is not None and not callable(handler): + raise TypeError("extension control handler must be callable") + with self._condition: + self._extension_control_handler = handler + self._condition.notify_all() + + def set_extension_control_rejection_handler( + self, + handler: Callable[[str, str], None] | None, + ) -> None: + """Register a status observer for MQTT-layer control rejections.""" + + if handler is not None and not callable(handler): + raise TypeError("extension control rejection handler must be callable") + with self._condition: + self._extension_control_rejection_handler = handler + self._condition.notify_all() + + def _notify_extension_control_rejection(self, error: str, kind: str) -> None: + with self._condition: + handler = self._extension_control_rejection_handler + if handler is None: + return + try: + handler(error[:240], kind) + except Exception: # noqa: BLE001, S110 - display observer is best effort + pass + @staticmethod def _reason_code_value(reason_code: Any) -> int: try: @@ -747,15 +1000,37 @@ class MqttCanTransport: self._last_error = f"MQTT connection refused with reason {code}" self._condition.notify_all() return - result = client.subscribe(self.settings.condition_topic, qos=self.settings.qos) - result_code = result[0] if isinstance(result, tuple) else getattr(result, "rc", 0) + subscriptions = ( + (self.settings.condition_topic, self.settings.qos), + (MQTT_EXTENSION_CONTROL_TOPIC, MQTT_EXTENSION_CONTROL_QOS), + ) + failed_subscription: tuple[str, int] | None = None + try: + for topic, qos in subscriptions: + result = client.subscribe(topic, qos=qos) + result_code = ( + result[0] if isinstance(result, tuple) else getattr(result, "rc", 0) + ) + if int(result_code) != 0: + failed_subscription = (topic, int(result_code)) + break + except Exception as exc: # noqa: BLE001 - normalize client callback failures + failed_subscription = ("subscription", -1) + subscription_error = str(exc) + else: + subscription_error = "" with self._condition: - if int(result_code) == 0: + if failed_subscription is None: self._state = "online" self._last_error = None else: self._state = "error" - self._last_error = f"MQTT subscribe failed with code {result_code}" + failed_topic, failed_code = failed_subscription + self._last_error = ( + f"MQTT subscribe failed for {failed_topic}: {subscription_error}" + if subscription_error + else f"MQTT subscribe failed for {failed_topic} with code {failed_code}" + ) self._condition.notify_all() def _on_disconnect( @@ -775,7 +1050,11 @@ class MqttCanTransport: self._condition.notify_all() def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None: - if str(getattr(message, "topic", "")) != self.settings.condition_topic: + topic = str(getattr(message, "topic", "")) + if topic == MQTT_EXTENSION_CONTROL_TOPIC: + self._dispatch_extension_control_message(message) + return + if topic != self.settings.condition_topic: return try: frames = parse_mqtt_can_payload(bytes(message.payload)) @@ -787,6 +1066,74 @@ class MqttCanTransport: self._last_error = str(exc) self._condition.notify_all() + def _dispatch_extension_control_message(self, message: Any) -> None: + """Forward a fresh control payload to the application queue without blocking.""" + + received_at = _now() + transport_rejection: tuple[str, str] | None = None + with self._condition: + self._extension_control_received_count += 1 + self._extension_control_last_received_at = received_at + if bool(getattr(message, "retain", False)): + self._extension_control_retained_count += 1 + self._extension_control_rejected_count += 1 + self._extension_control_last_error = ( + "retained extension/ctl messages are not executable" + ) + transport_rejection = ( + self._extension_control_last_error, + "retained", + ) + self._condition.notify_all() + elif bool(getattr(message, "dup", False)): + self._extension_control_duplicate_count += 1 + self._extension_control_rejected_count += 1 + self._extension_control_last_error = ( + "duplicate extension/ctl delivery was ignored" + ) + transport_rejection = ( + self._extension_control_last_error, + "duplicate", + ) + self._condition.notify_all() + else: + handler = self._extension_control_handler + + if transport_rejection is not None: + self._notify_extension_control_rejection(*transport_rejection) + return + + try: + payload = bytes(message.payload) + except (AttributeError, TypeError, ValueError) as exc: + error = f"extension/ctl payload is not binary data: {exc}" + rejection_kind = "payload" + else: + if len(payload) > MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES: + error = "extension/ctl payload exceeds 256 KiB" + rejection_kind = "payload" + elif handler is None: + error = "extension/ctl handler is not configured" + rejection_kind = "unavailable" + else: + try: + handler(payload) + except Exception as exc: # noqa: BLE001 - isolate MQTT network loop + error = str(exc)[:240] + rejection_kind = "dispatch" + else: + with self._condition: + self._extension_control_dispatched_count += 1 + self._extension_control_last_error = None + self._condition.notify_all() + return + + with self._condition: + self._extension_control_rejected_count += 1 + self._extension_control_last_error = error[:240] + self._condition.notify_all() + self._notify_extension_control_rejection(error, rejection_kind) + def _record_frame(self, frame: CanFrame) -> None: if ( frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS @@ -1023,6 +1370,67 @@ class MqttCanTransport: "message_id": getattr(result, "mid", None), } + def publish_extension_toy_message( + self, + payload: Mapping[str, Any], + ) -> dict[str, Any]: + """Queue one validated extension capability announcement.""" + + raw_payload = mqtt_extension_toy_payload(payload) + with self._condition: + client = self._client + state = self._state + if client is None or state != "online": + raise CanGatewayError("extension/toy MQTT publisher is not connected") + result = client.publish( + MQTT_EXTENSION_TOY_TOPIC, + raw_payload, + qos=MQTT_EXTENSION_TOY_QOS, + retain=False, + ) + if int(getattr(result, "rc", 0)) != 0: + raise CanGatewayError( + f"extension/toy MQTT publish failed with code {result.rc}" + ) + is_published = getattr(result, "is_published", None) + broker_confirmed = bool(is_published()) if callable(is_published) else False + return { + "topic": MQTT_EXTENSION_TOY_TOPIC, + "qos": MQTT_EXTENSION_TOY_QOS, + "retain": False, + "delivery_status": "published" if broker_confirmed else "queued", + "message_id": getattr(result, "mid", None), + } + + def publish_mode_scene(self, payload: Mapping[str, Any]) -> dict[str, Any]: + """Queue one exact N7 scene-card projection on its fixed sync topic.""" + + raw_payload = mqtt_mode_scene_payload(payload) + with self._condition: + client = self._client + state = self._state + if client is None or state != "online": + raise CanGatewayError("mode scene MQTT publisher is not connected") + result = client.publish( + MQTT_MODE_SCENE_TOPIC, + raw_payload, + qos=self.settings.qos, + retain=False, + ) + if int(getattr(result, "rc", 0)) != 0: + raise CanGatewayError( + f"mode scene MQTT publish failed with code {result.rc}" + ) + is_published = getattr(result, "is_published", None) + broker_confirmed = bool(is_published()) if callable(is_published) else False + return { + "topic": MQTT_MODE_SCENE_TOPIC, + "qos": self.settings.qos, + "retain": False, + "delivery_status": "published" if broker_confirmed else "queued", + "message_id": getattr(result, "mid", None), + } + def _receive_after( self, cursor: int, @@ -1076,6 +1484,22 @@ class MqttCanTransport: "stale_frames": self._stale_frames, "last_error": self._last_error, } + info["extension_control"] = { + "mode": "persistent_mqtt_subscription", + "topic": MQTT_EXTENSION_CONTROL_TOPIC, + "qos": MQTT_EXTENSION_CONTROL_QOS, + "handler_configured": self._extension_control_handler is not None, + "rejection_handler_configured": ( + self._extension_control_rejection_handler is not None + ), + "received_count": self._extension_control_received_count, + "dispatched_count": self._extension_control_dispatched_count, + "rejected_count": self._extension_control_rejected_count, + "duplicate_count": self._extension_control_duplicate_count, + "retained_count": self._extension_control_retained_count, + "last_received_at": self._extension_control_last_received_at, + "last_error": self._extension_control_last_error, + } return info diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index c901426..d07b793 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -43,11 +43,23 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402 CodexAssistantService, CodexVehicleToolError, ) +from cognitive_agent.application.extension_control import ( # noqa: E402 + ExtensionControlConsumer, +) +from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402 + EXTENSION_TOY_BURST_COUNT, + EXTENSION_TOY_BURST_WINDOW_S, + NfcExtensionToyPublisher, +) from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402 from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 normalize_mode_profile_name, parse_mode_profile_update, ) +from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 + MODE_SCENE_MQTT_BURST_COUNT, + MODE_SCENE_MQTT_BURST_WINDOW_S, +) from cognitive_agent.application.pet_cards import PetCardService # noqa: E402 from cognitive_agent.application.seat_service import ( # noqa: E402 LOCAL_CONTROL_BUDGET_MS, @@ -78,6 +90,11 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402 HardwareBusyError, HardwareSafetyError, ) +from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402 + MQTT_EXTENSION_CONTROL_TOPIC, + MQTT_EXTENSION_TOY_TOPIC, + MQTT_MODE_SCENE_TOPIC, +) from cognitive_agent.infrastructure.codex.app_server import ( # noqa: E402 CodexAppServerClient, CodexAppServerError, @@ -89,7 +106,13 @@ from cognitive_agent.infrastructure.speech import ( # noqa: E402 SpeechTranscriber, ) -STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console" +WEB_CONSOLE_SOURCE_ROOT = REPOSITORY_ROOT / "apps" / "web-console" +WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist" +STATIC_ROOT = ( + WEB_CONSOLE_DIST_ROOT + if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file() + else WEB_CONSOLE_SOURCE_ROOT +) DEFAULT_MODE_SCENE_CATALOG_PATH = ( REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json" ) @@ -222,6 +245,19 @@ class SeatDemoHandler(BaseHTTPRequestHandler): nfc_provider=nfc.snapshot, publisher=service.publish_pet_message, ) + extension_toys = NfcExtensionToyPublisher( + nfc_provider=nfc.snapshot, + catalog_provider=extension.list_peripheral_capabilities, + publisher=service.publish_extension_toy_message, + ) + extension_control = ExtensionControlConsumer( + catalog_provider=extension.list_peripheral_capabilities, + executor=extension.execute_bound_infrared_action, + ) + service.set_extension_control_handler(extension_control.submit) + service.set_extension_control_rejection_handler( + extension_control.reject_transport_message + ) calibration = CalibrationService( REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration", repository_root=REPOSITORY_ROOT, @@ -328,6 +364,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path == "/api/vehicle-display-sync": self._json(self.vehicle_display_sync.snapshot(self._extension_health())) return + if path == "/api/extension/control/status": + query = parse_qs(parsed_url.query) + try: + after_revision = int(query.get("after_revision", ["-1"])[0]) + timeout_ms = int(query.get("timeout_ms", ["25000"])[0]) + if after_revision < -1: + raise ValueError("after_revision must be at least -1") + if not 0 <= timeout_ms <= 30_000: + raise ValueError("timeout_ms must be between 0 and 30000") + snapshot = self.extension_control.wait_for_snapshot( + after_revision, + timeout_s=timeout_ms / 1_000, + ) + except (TypeError, ValueError) as exc: + self._json( + {"error": "invalid_request", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + return + self._json(snapshot) + return if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: query = parse_qs(parsed_url.query) conversation_id = str(query.get("conversation_id", [""])[0]) @@ -403,6 +460,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): "success": True, **self.nfc.snapshot(), "pet_cards": self.pets.snapshot(), + "extension_toy": self.extension_toys.snapshot(), } ) return @@ -885,7 +943,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): on_commands_validated: Callable[[dict[str, object]], None] | None = None, natural_language_routing: Mapping[str, object] | None = None, ) -> dict[str, object] | None: - """Route a command to the NFC-bound peripheral when one is present.""" + """Route a command to a persistently NFC-bound peripheral ability.""" # The browser may disappear or reload between learning and execution. # Natural-language routing therefore reads the durable, validated @@ -986,7 +1044,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): Vehicle speech keeps using its cached structured semantic payload. A learned peripheral cannot be represented by the static vehicle capability catalog, so it may instead reuse the exact server-owned ASR - transcript only after the live NFC binding and learned action resolve. + transcript only after the durable NFC binding and learned action resolve. """ text = str(utterance or "").strip() @@ -1328,7 +1386,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): result: dict[str, object], entries: list[dict[str, object]], ) -> dict[str, object]: - """Expose command-level NFC denials without discarding allowed work.""" + """Expose disabled bound commands without discarding allowed work.""" if not entries: return result @@ -1358,7 +1416,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): for item in denied_actions ) summaries.append( - f"当前 NFC 未授权 {len(denied_actions)} 条红外命令,已过滤:{labels}。" + f"持久绑定中有 {len(denied_actions)} 条红外能力未启用,已过滤:{labels}。" ) result["decision_summary"] = summaries @@ -1487,7 +1545,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): re.search( r"然后|随后|接着|之后|" r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)" - r"(?:毫秒|秒|分钟)", + r"(?:ms|s|毫秒|秒|分钟)", residual, ) ) @@ -1777,7 +1835,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if isinstance(item, dict) ) raise ExtensionIntegrationError( - f"当前 NFC 未授权执行以下红外命令:{labels}。" + f"以下持久绑定红外能力未启用,无法执行:{labels}。" ) result["natural_language_routing"] = deepcopy(natural_language_routing) @@ -2653,6 +2711,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if create_mode else self.service.save_mode_profile_card(mode_id, *arguments) ) + saved_profile = result.get("mode_profile") or {} + saved_mode_id = str(saved_profile.get("id") or "").strip() + try: + result["mode_scene_mqtt"] = self.service.publish_saved_mode_scene( + saved_mode_id, + str(result.get("vehicle_id") or "") or None, + ) + except Exception as exc: # noqa: BLE001 - card persistence must survive MQTT loss + result["mode_scene_mqtt"] = { + "state": "queue_failed", + "topic": MQTT_MODE_SCENE_TOPIC, + "scene_id": saved_mode_id or None, + "burst_count": MODE_SCENE_MQTT_BURST_COUNT, + "burst_window_ms": round( + MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000 + ), + "last_error": str(exc)[:240], + } self._json(result, HTTPStatus.CREATED if create_mode else HTTPStatus.OK) except KeyError: self._json( @@ -2711,18 +2787,6 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if not operations: return [] - snapshot = self.nfc.snapshot() - card = snapshot.get("card") if isinstance(snapshot, dict) else None - active_uid = re.sub( - r"[^0-9A-Fa-f]", - "", - str((card or {}).get("uid") or ""), - ).upper() - if not active_uid or str((card or {}).get("action") or "present") != "present": - raise ExtensionIntegrationError( - "该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。" - ) - catalog = self.extension.list_peripheral_capabilities() entries: list[dict[str, object]] = [] for index, operation in enumerate(operations): @@ -2737,6 +2801,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler): "已保存模式包含无效的红外槽位。" ) from exc device_id = str(target.get("device") or "").strip() + bound_uid = re.sub( + r"[^0-9A-Fa-f]", + "", + str(target.get("card_uid") or ""), + ).upper() + if not bound_uid: + raise ExtensionIntegrationError( + f"已保存模式中的红外命令缺少持久 NFC 绑定:{device_id} / IR {slot}。" + ) capability = next( ( item @@ -2750,13 +2823,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler): "", str(item.get("card_uid") or ""), ).upper() - == active_uid + == bound_uid ), None, ) if capability is None: raise ExtensionIntegrationError( - f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。" + f"持久绑定目录未授权模式中的红外命令:{device_id} / IR {slot}。" ) entries.append( { @@ -2798,8 +2871,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler): utterance=utterance, source=source, ) - # Validate the complete saved flow, including the live NFC - # binding, before any transport is allowed to execute. + # Validate the complete saved flow against the durable NFC binding + # before any transport is allowed to execute. entries = self._saved_mode_infrared_entries(profile) can_result = self.service.execute_mode_profile_card( mode_id, @@ -3321,6 +3394,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler): **self.extension.health(), "nfc": self.nfc.snapshot(), "pet_cards": self.pets.snapshot(), + "extension_toy": self.extension_toys.snapshot(), + "extension_control": self.extension_control.snapshot(), } def _handle_hardware_access_post(self, path: str) -> None: @@ -3963,8 +4038,10 @@ def main() -> None: ) SeatDemoHandler.service.start() SeatDemoHandler.extension.start() + SeatDemoHandler.extension_control.start() SeatDemoHandler.nfc.start() SeatDemoHandler.pets.start() + SeatDemoHandler.extension_toys.start() SeatDemoHandler.calibration.start() SeatDemoHandler.repair.start() if args.host in {"0.0.0.0", "::"}: @@ -3995,6 +4072,25 @@ def main() -> None: file=sys.stderr, flush=True, ) + print( + "N7 new-mode MQTT sync: " + f"{MQTT_MODE_SCENE_TOPIC} " + f"({MODE_SCENE_MQTT_BURST_COUNT} frames within " + f"{round(MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000)} ms)", + flush=True, + ) + print( + "N7 extension capability MQTT: " + f"{MQTT_EXTENSION_TOY_TOPIC} " + f"({EXTENSION_TOY_BURST_COUNT} frames within " + f"{round(EXTENSION_TOY_BURST_WINDOW_S * 1_000)} ms per NFC detection)", + flush=True, + ) + print( + "N7 extension control MQTT: " + f"subscribe {MQTT_EXTENSION_CONTROL_TOPIC} -> local infrared gateway", + flush=True, + ) hardware_execution_ready = any( ( SeatDemoHandler.service.hardware_settings.configured, @@ -4017,6 +4113,10 @@ def main() -> None: server.server_close() SeatDemoHandler.speech.close() SeatDemoHandler.assistant.close() + SeatDemoHandler.service.set_extension_control_handler(None) + SeatDemoHandler.service.set_extension_control_rejection_handler(None) + SeatDemoHandler.extension_control.close() + SeatDemoHandler.extension_toys.close() SeatDemoHandler.pets.close() SeatDemoHandler.nfc.close() SeatDemoHandler.service.close() diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py index 911e6cd..981d67b 100644 --- a/tests/e2e/test_assistant_web_api.py +++ b/tests/e2e/test_assistant_web_api.py @@ -277,6 +277,7 @@ class FakeVehicleService: self.calls: list[dict[str, object]] = [] self.mode_save_calls: list[dict[str, object]] = [] self.mode_trial_calls: list[dict[str, object]] = [] + self.mode_scene_publish_calls: list[dict[str, object]] = [] self.fail = False def preview_native_can(self, **kwargs: object) -> dict[str, object]: @@ -481,6 +482,44 @@ class FakeVehicleService: "source_request_id": source_request_id, } + def save_mode_profile_card( + self, + mode_id: str, + name: str, + instruction: str, + source_request_id: str, + vehicle_id: str | None = None, + ) -> dict[str, object]: + return { + "status": "succeeded", + "mode_profile": { + "id": mode_id, + "name": name, + "origin": "user", + "operation_count": 1, + "instruction": instruction, + }, + "vehicle_id": vehicle_id, + "storage": "persistent_yaml", + "source_request_id": source_request_id, + } + + def publish_saved_mode_scene( + self, + mode_id: str, + vehicle_id: str | None = None, + ) -> dict[str, object]: + self.mode_scene_publish_calls.append( + {"mode_id": mode_id, "vehicle_id": vehicle_id} + ) + return { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": mode_id, + "burst_count": 3, + "burst_window_ms": 1_000, + } + def trial_mode_profile_draft( self, name: str, @@ -548,6 +587,7 @@ class TestCodexAssistantWebApi: AssistantApiHandler.service.calls.clear() AssistantApiHandler.service.mode_save_calls.clear() AssistantApiHandler.service.mode_trial_calls.clear() + AssistantApiHandler.service.mode_scene_publish_calls.clear() AssistantApiHandler.service.fail = False FAKE_CODEX.available = True FAKE_CODEX.turn_calls.clear() @@ -1386,6 +1426,34 @@ class TestCodexAssistantWebApi: assert saved["status"] == "succeeded" assert saved["mode_profile"]["origin"] == "user" assert saved["mode_profile"]["operation_count"] == 9 + assert AssistantApiHandler.service.mode_scene_publish_calls == [ + {"mode_id": "mode-movie", "vehicle_id": "denza_n7"} + ] + assert saved["mode_scene_mqtt"] == { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": "mode-movie", + "burst_count": 3, + "burst_window_ms": 1_000, + } + + update_status, _content_type, updated = self._request_json( + "/api/mode-profiles/mode-movie", + payload["mode_profile_draft"], + ) + assert update_status == 200 + assert updated["mode_profile"]["id"] == "mode-movie" + assert updated["mode_scene_mqtt"] == { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": "mode-movie", + "burst_count": 3, + "burst_window_ms": 1_000, + } + assert AssistantApiHandler.service.mode_scene_publish_calls == [ + {"mode_id": "mode-movie", "vehicle_id": "denza_n7"}, + {"mode_id": "mode-movie", "vehicle_id": "denza_n7"}, + ] def test_vehicle_failure_is_recorded_and_never_overwritten_by_codex_success(self) -> None: AssistantApiHandler.service.fail = True diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 182f892..7dfc749 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -4,6 +4,7 @@ import json import os import tempfile import threading +import time import unittest import urllib.error import urllib.request @@ -18,6 +19,7 @@ from cognitive_agent.application.mode_profile import ModeProfileRegistry from cognitive_agent.application.pet_cards import PetCardService from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService from cognitive_agent.application.self_extension import ( + ExtensionIntegrationError, GatewayError, SelfExtensionService, SimulationGateway, @@ -453,12 +455,55 @@ operations: ensure_ascii=False, ).encode("utf-8"), ) - with self.opener.open(request, timeout=3) as response: - payload = json.loads(response.read().decode("utf-8")) + updated_scenes: list[dict[str, object]] = [] + updated_at: list[float] = [] + update_burst_complete = threading.Event() + + def capture_updated_scene( + scene: dict[str, object], + ) -> dict[str, object]: + updated_scenes.append(json.loads(json.dumps(scene))) + updated_at.append(time.monotonic()) + if len(updated_scenes) == 3: + update_burst_complete.set() + return { + "delivery_status": "published", + "message_id": len(updated_scenes), + } + + with patch.object( + self.service._vehicle_mqtt_transport, + "publish_mode_scene", + side_effect=capture_updated_scene, + ): + with self.opener.open(request, timeout=3) as response: + payload = json.loads(response.read().decode("utf-8")) + self.assertTrue(update_burst_complete.wait(timeout=2)) self.assertEqual(response.status, 200) self.assertEqual(payload["storage"], "persistent_yaml") self.assertEqual(payload["mode_profile"]["origin"], "customized") self.assertEqual(payload["mode_profile"]["instruction"], instruction) + self.assertEqual( + payload["mode_scene_mqtt"], + { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": "office", + "burst_count": 3, + "burst_window_ms": 1_000, + }, + ) + expected_updated_scene = next( + scene + for scene in self.service.mode_scene_catalog("denza_n7")["scenes"] + if scene["id"] == "office" + ) + self.assertEqual(len(updated_scenes), 3) + self.assertLess(updated_at[-1] - updated_at[0], 1.0) + self.assertTrue( + all(scene == expected_updated_scene for scene in updated_scenes) + ) + self.assertTrue(all("scenes" not in scene for scene in updated_scenes)) self.assertTrue( (user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file() ) @@ -477,11 +522,50 @@ operations: ensure_ascii=False, ).encode("utf-8"), ) - with self.opener.open(create_request, timeout=3) as response: - created = json.loads(response.read().decode("utf-8")) + published_scenes: list[dict[str, object]] = [] + published_at: list[float] = [] + burst_complete = threading.Event() + + def capture_scene(scene: dict[str, object]) -> dict[str, object]: + published_scenes.append(json.loads(json.dumps(scene))) + published_at.append(time.monotonic()) + if len(published_scenes) == 3: + burst_complete.set() + return { + "delivery_status": "published", + "message_id": len(published_scenes), + } + + with patch.object( + self.service._vehicle_mqtt_transport, + "publish_mode_scene", + side_effect=capture_scene, + ): + with self.opener.open(create_request, timeout=3) as response: + created = json.loads(response.read().decode("utf-8")) + self.assertTrue(burst_complete.wait(timeout=2)) self.assertEqual(response.status, 201) self.assertEqual(created["mode_profile"]["origin"], "user") self.assertEqual(created["mode_profile"]["name"], "通勤模式") + self.assertEqual( + created["mode_scene_mqtt"], + { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": created["mode_profile"]["id"], + "burst_count": 3, + "burst_window_ms": 1_000, + }, + ) + expected_scene = next( + scene + for scene in self.service.mode_scene_catalog("denza_n7")["scenes"] + if scene["id"] == created["mode_profile"]["id"] + ) + self.assertEqual(len(published_scenes), 3) + self.assertLess(published_at[-1] - published_at[0], 1.0) + self.assertTrue(all(scene == expected_scene for scene in published_scenes)) + self.assertTrue(all("scenes" not in scene for scene in published_scenes)) self.assertTrue( ( user_root @@ -575,7 +659,7 @@ operations: snapshot=lambda: { "revision": 1, "reader": {"online": True}, - "card": {"action": "present", "uid": "2CC8B103"}, + "card": None, } ) try: @@ -629,7 +713,17 @@ operations: ensure_ascii=False, ).encode("utf-8"), ) - with self.opener.open(save_request, timeout=3) as response: + with patch.object( + self.service, + "publish_saved_mode_scene", + return_value={ + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": "office", + "burst_count": 3, + "burst_window_ms": 1_000, + }, + ), self.opener.open(save_request, timeout=3) as response: saved = json.loads(response.read().decode("utf-8")) execute_request = urllib.request.Request( @@ -721,14 +815,35 @@ operations: handler = object.__new__(SeatDemoHandler) handler.extension = extension handler.nfc = SimpleNamespace( - snapshot=lambda: { - "card": {"action": "present", "uid": "2CC8B103"} - } + snapshot=lambda: {"card": None} ) profile = SimpleNamespace( command_flow={ "function_configs": { "local_self_extension": { + "spec": { + "domain": "self_extension", + "operations": [ + { + "capability": "infrared.emit", + "target": { + "device": "tony-light-001", + "card_uid": "2CC8B103", + }, + "parameters": {"action": "打开", "slot": 1}, + } + ], + } + } + } + } + ) + + entries = handler._saved_mode_infrared_entries(profile) + missing_binding = SimpleNamespace( + command_flow={ + "function_configs": { + "self_extension": { "spec": { "domain": "self_extension", "operations": [ @@ -743,8 +858,11 @@ operations: } } ) - - entries = handler._saved_mode_infrared_entries(profile) + with self.assertRaisesRegex( + ExtensionIntegrationError, + "缺少持久 NFC 绑定", + ): + handler._saved_mode_infrared_entries(missing_binding) self.assertEqual([item["capability"]["slot"] for item in entries], [1]) self.assertEqual( @@ -807,14 +925,21 @@ operations: def test_static_console_and_runtime_endpoint(self) -> None: with self.opener.open(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") - with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-140", timeout=3) as response: + web_console_root = REPOSITORY_ROOT / "apps" / "web-console" + template = (web_console_root / "src" / "App.vue").read_text( + encoding="utf-8" + ) + vite_entry = (web_console_root / "src" / "main.js").read_text( + encoding="utf-8" + ) + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-141", timeout=3) as response: app_script = response.read().decode("utf-8") with self.opener.open( f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3 ) as response: gesture_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.js?v=local-first-70", timeout=3 + f"{self.base_url}/console-v2.js?v=local-first-76", timeout=3 ) as response: console_script = response.read().decode("utf-8") with self.opener.open( @@ -842,7 +967,7 @@ operations: ) as response: extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") with self.opener.open( - f"{self.base_url}/assistant-chat.js?v=codex-assistant-33", timeout=3 + f"{self.base_url}/assistant-chat.js?v=codex-assistant-34", timeout=3 ) as response: assistant_script = response.read().decode("utf-8") with self.opener.open( @@ -854,7 +979,7 @@ operations: ) as response: energy_styles = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-2", timeout=3 + f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-5", timeout=3 ) as response: glass_theme = response.read().decode("utf-8") with self.opener.open( @@ -869,50 +994,57 @@ operations: f"{self.base_url}/api/extension/nfc/status", timeout=3 ) as response: nfc_status = json.loads(response.read().decode("utf-8")) + with self.opener.open( + f"{self.base_url}/api/extension/control/status" + "?after_revision=-1&timeout_ms=0", + timeout=3, + ) as response: + extension_control_status = json.loads(response.read().decode("utf-8")) with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response: runtime = json.loads(response.read().decode("utf-8")) with self.opener.open(f"{self.base_url}/api/speech/status", timeout=3) as response: speech_status = json.loads(response.read().decode("utf-8")) self.assertIn("511工程-智能座舱", html) - self.assertIn('id="seatTwinCanvas"', html) - self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html) - self.assertIn("整车孪生界面", html) - self.assertIn("车内灯光", html) - self.assertIn('id="interiorLightPower"', html) - self.assertIn('id="ambientBrightness"', html) - self.assertIn('id="ambientColorIndex"', html) - self.assertIn('data-function="massage"', html) - self.assertIn("CMD 0x1DE · FB 0x404 · CH1", html) - self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) - self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) - self.assertNotIn("透明车体 · 实体设备", html) - self.assertIn('src="/app.js?v=dual-seat-140"', html) - self.assertIn('href="/styles.css?v=dual-seat-23"', html) - self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) - self.assertNotIn('class="vehicle-system-hud"', html) - self.assertIn('src="/console-v2.js?v=local-first-70"', html) - self.assertIn('href="/console-v2.css?v=delivery-state-69"', html) + self.assertIn('id="seatTwinCanvas"', template) + self.assertIn('data-vehicle-model="byd-tang-2024-reference"', template) + self.assertIn("整车孪生界面", template) + self.assertIn("车内灯光", template) + self.assertIn('id="interiorLightPower"', template) + self.assertIn('id="ambientBrightness"', template) + self.assertIn('id="ambientColorIndex"', template) + self.assertIn('data-function="massage"', template) + self.assertIn("CMD 0x1DE · FB 0x404 · CH1", template) + self.assertIn("CMD 0x431 · FB 0x41A · CH1", template) + self.assertIn("CMD 0x431 · FB 0x388 · CH1", template) + self.assertNotIn("透明车体 · 实体设备", template) + self.assertIn('src="/app.js?v=dual-seat-141"', html) + self.assertIn('import "../styles.css";', vite_entry) + self.assertNotIn('class="vehicle-system-hud"', template) + self.assertIn('src="/console-v2.js?v=local-first-76"', html) + self.assertIn('import "../console-v2.css";', vite_entry) self.assertIn('src="/speech-input.js?v=sensevoice-13"', html) - self.assertIn('href="/speech-input.css?v=sensevoice-7"', html) - self.assertIn('id="speechDictationButton"', html) - self.assertIn('class="speech-waveform"', html) + self.assertIn('import "../speech-input.css";', vite_entry) + self.assertIn('id="speechDictationButton"', template) + self.assertIn('class="speech-waveform"', template) self.assertIn('', html) - self.assertIn('id="v2BootScreen"', html) - self.assertIn('正在加载车机控制与实时状态', html) + self.assertIn('id="v2BootScreen"', template) + self.assertIn('正在加载车机控制与实时状态', template) self.assertIn('src="/self-extension.js?v=pet-settings-52"', html) - self.assertIn('href="/self-extension.css?v=pet-settings-52"', html) - self.assertNotIn("self-calibration", html) - self.assertNotIn("self-repair", html) - self.assertNotIn("self-evolution", html) - self.assertNotIn("滚动查看更多", html) - self.assertIn('aria-label="发送消息"', html) - self.assertIn('id="assistantTranscript"', html) - self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html) - self.assertIn('src="/assistant-chat.js?v=codex-assistant-33"', html) - self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html) + self.assertIn('import "../self-extension.css";', vite_entry) + self.assertNotIn("self-calibration", template) + self.assertNotIn("self-repair", template) + self.assertNotIn("self-evolution", template) + self.assertNotIn("滚动查看更多", template) + self.assertIn('aria-label="发送消息"', template) + self.assertIn('id="assistantTranscript"', template) + self.assertIn('import "../assistant-chat.css";', vite_entry) + self.assertIn('src="/assistant-chat.js?v=codex-assistant-34"', html) + self.assertIn('import "../energy-monitor.css";', vite_entry) self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html) - self.assertIn('href="/codepen-glass-theme.css?v=creative-cloud-2"', html) + self.assertIn('import "../codepen-glass-theme.css";', vite_entry) + self.assertEqual(extension_control_status["source_label"], "车端 MQTT") + self.assertIn("action_statuses", extension_control_status) self.assertIn("--glass-shell:", glass_theme) self.assertIn("backdrop-filter: blur(22px) saturate(1.25)", glass_theme) self.assertNotIn('"admin"', console_script) @@ -984,7 +1116,7 @@ operations: self.assertIn('const DEFAULT_EXECUTION_MODE = "hardware"', app_script) self.assertIn('preferredExecutionMode: DEFAULT_EXECUTION_MODE', app_script) self.assertIn('selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false })', app_script) - self.assertIn('class="mode-option active" id="hardwareMode"', html) + self.assertIn('class="mode-option active" id="hardwareMode"', template) self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles) self.assertNotIn('.v2-control-switch {', console_styles) self.assertIn('.v2-module-switcher {', console_styles) @@ -1036,12 +1168,12 @@ operations: self.assertNotIn('.v2-config-section', console_styles) self.assertNotIn('.console-v2 .v2-config-card {', console_styles) self.assertIn('.console-v2.v2-sidebar-collapsed .workspace', console_styles) - self.assertNotIn('id="hardwareAccessField"', html) - self.assertNotIn('id="hardwareClientLabel"', html) - self.assertNotIn('id="requestHardwareAccess"', html) - self.assertNotIn('id="releaseHardwareAccess"', html) - self.assertNotIn('id="approvalField"', html) - self.assertNotIn('id="approvalToken"', html) + self.assertNotIn('id="hardwareAccessField"', template) + self.assertNotIn('id="hardwareClientLabel"', template) + self.assertNotIn('id="requestHardwareAccess"', template) + self.assertNotIn('id="releaseHardwareAccess"', template) + self.assertNotIn('id="approvalField"', template) + self.assertNotIn('id="approvalToken"', template) self.assertNotIn("approval_token: approvalToken", app_script) self.assertNotIn("授权并真实执行", app_script) self.assertIn('const dynamicOrder = new Map([["rocking_horse", 0]', app_script) @@ -1052,19 +1184,19 @@ operations: 'if (mode === "hardware" && !hardwareExecutionReady) return false', app_script, ) - self.assertIn('data-control-transport="wired_can"', html) - self.assertIn('data-control-transport="wireless_mqtt"', html) - self.assertIn('
', html) - self.assertIn('id="modeRailToggle"', html) - self.assertIn('id="modeRailContent"', html) - self.assertIn('role="region"', html) - self.assertIn('aria-label="功能模式列表"', html) - self.assertIn('AI 新建', html) - self.assertIn('class="mode-rail-identity"', html) - self.assertIn('aria-expanded="true"', html) - self.assertIn('模拟下发', html) - self.assertIn('真实下发', html) - self.assertNotIn('id="simulationMode"', html) + self.assertIn('data-control-transport="wired_can"', template) + self.assertIn('data-control-transport="wireless_mqtt"', template) + self.assertIn('
', template) + self.assertIn('id="modeRailToggle"', template) + self.assertIn('id="modeRailContent"', template) + self.assertIn('role="region"', template) + self.assertIn('aria-label="功能模式列表"', template) + self.assertIn('AI 新建', template) + self.assertIn('class="mode-rail-identity"', template) + self.assertIn('aria-expanded="true"', template) + self.assertIn('模拟下发', template) + self.assertIn('真实下发', template) + self.assertNotIn('id="simulationMode"', template) self.assertIn('control_transport: state.controlTransport', app_script) self.assertIn('button.dataset.connectionState = connectionState', app_script) self.assertIn('信号链路${connectionState === "online" ? "正常" : "断开"}', app_script) @@ -1097,16 +1229,16 @@ operations: ) self.assertNotIn( 'id="hardwareMode" data-execution-mode="hardware" disabled', - html, + template, ) self.assertIn('const seat = twin.selected === "driver" ? "driver" : "passenger"', app_script) self.assertNotIn('data-mode-command="seat driver vent low"', app_script) self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script) self.assertIn("CH1 主驾实车测试不启用无闭环的座椅位置手势", app_script) self.assertNotIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script) - self.assertNotIn('id="hardwareNote"', html) - self.assertNotIn('id="hardwareAliasNote"', html) - self.assertNotIn('id="vehicleTransportNote"', html) + self.assertNotIn('id="hardwareNote"', template) + self.assertNotIn('id="hardwareAliasNote"', template) + self.assertNotIn('id="vehicleTransportNote"', template) self.assertIn("下发控制指令", console_script) self.assertIn('new Set(["mode_editor_compile"])', console_script) self.assertIn("function extractReferenceWindowPanes", app_script) @@ -1198,14 +1330,17 @@ operations: self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) - self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v305"', service_worker) + self.assertIn( + 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"', + service_worker, + ) self.assertIn('/console-v2.css?v=delivery-state-69', service_worker) - self.assertIn('/console-v2.js?v=local-first-70', service_worker) + self.assertIn('/console-v2.js?v=local-first-76', service_worker) self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker) - self.assertIn('/assistant-chat.js?v=codex-assistant-33', service_worker) + self.assertIn('/assistant-chat.js?v=codex-assistant-34', service_worker) self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker) self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker) - self.assertIn('"/app.js?v=dual-seat-140"', service_worker) + self.assertIn('"/app.js?v=dual-seat-141"', service_worker) self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker) self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker) self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker) @@ -1264,39 +1399,42 @@ operations: self.assertIn('error.code === "hardware_busy"', app_script) self.assertIn("BENCH BUSY", app_script) self.assertIn("FBXLoader.js", html) - self.assertIn('data-seat-layout="five-seat"', html) + self.assertIn('data-seat-layout="five-seat"', template) - self.assertIn('data-active-seat="front-right"', html) - self.assertNotIn('placeholder="请输入服务端配置令牌"', html) - self.assertNotIn('class="twin-callout-layer"', html) - self.assertNotIn('class="twin-callout', html) - self.assertNotIn('data-callout=', html) - self.assertIn('data-twin-function="ventilation"', html) - self.assertIn('data-twin-function="heating"', html) - self.assertIn('class="twin-level-meter"', html) - self.assertIn('id="presetIndicator"', html) - self.assertIn('id="vehicleSelect"', html) - self.assertIn('id="saveModeToggle"', html) - self.assertIn("AI 新建", html) - self.assertIn('aria-label="快捷功能模式,可横向滚动"', html) - self.assertIn('id="modeRailContent"', html) - self.assertIn('aria-label="功能模式列表"', html) - self.assertNotIn('id="modeRailPrev"', html) - self.assertNotIn('id="modeRailNext"', html) - self.assertGreater(html.index('id="saveModeToggle"'), html.index('id="modeRailContent"')) - self.assertIn('id="modeGalleryToggle"', html) - self.assertIn('id="modeGallery"', html) - self.assertIn('id="modeGalleryGroups"', html) - self.assertIn("管理功能模式", html) - self.assertIn('id="modeEditorBackdrop"', html) - self.assertIn('id="modeEditorName"', html) - self.assertIn('id="modeEditorInstruction"', html) - self.assertIn('id="modeEditorExecute"', html) - self.assertIn('id="modeEditorComplete"', html) - self.assertIn('id="modeEditorFlowPreview"', html) - self.assertIn('class="mode-editor-flow-preview empty"', html) - self.assertNotIn('id="saveModeSource"', html) - self.assertNotIn("主驾 24℃", html) + self.assertIn('data-active-seat="front-right"', template) + self.assertNotIn('placeholder="请输入服务端配置令牌"', template) + self.assertNotIn('class="twin-callout-layer"', template) + self.assertNotIn('class="twin-callout', template) + self.assertNotIn('data-callout=', template) + self.assertIn('data-twin-function="ventilation"', template) + self.assertIn('data-twin-function="heating"', template) + self.assertIn('class="twin-level-meter"', template) + self.assertIn('id="presetIndicator"', template) + self.assertIn('id="vehicleSelect"', template) + self.assertIn('id="saveModeToggle"', template) + self.assertIn("AI 新建", template) + self.assertIn('aria-label="快捷功能模式,可横向滚动"', template) + self.assertIn('id="modeRailContent"', template) + self.assertIn('aria-label="功能模式列表"', template) + self.assertNotIn('id="modeRailPrev"', template) + self.assertNotIn('id="modeRailNext"', template) + self.assertGreater( + template.index('id="saveModeToggle"'), + template.index('id="modeRailContent"'), + ) + self.assertIn('id="modeGalleryToggle"', template) + self.assertIn('id="modeGallery"', template) + self.assertIn('id="modeGalleryGroups"', template) + self.assertIn("管理功能模式", template) + self.assertIn('id="modeEditorBackdrop"', template) + self.assertIn('id="modeEditorName"', template) + self.assertIn('id="modeEditorInstruction"', template) + self.assertIn('id="modeEditorExecute"', template) + self.assertIn('id="modeEditorComplete"', template) + self.assertIn('id="modeEditorFlowPreview"', template) + self.assertIn('class="mode-editor-flow-preview empty"', template) + self.assertNotIn('id="saveModeSource"', template) + self.assertNotIn("主驾 24℃", template) populate_modes = app_script[ app_script.index("function populateModeChips()") : app_script.index("function setModeExecutionState") @@ -1345,21 +1483,21 @@ operations: self.assertIn("message.payload", app_script) self.assertIn("source_request_id: editor.sourceRequestId", app_script) self.assertIn('method: "POST"', app_script) - self.assertIn('data-domain-panel="climate"', html) - self.assertIn('data-domain-panel="roof"', html) - self.assertIn('data-domain-panel="ambient_light"', html) - self.assertIn('data-domain-panel="window"', html) - self.assertIn('data-domain-panel="front_light"', html) - self.assertIn('data-domain-panel="vehicle_status"', html) - self.assertIn('id="vehicleGear"', html) - self.assertIn('id="vehicleSpeed"', html) - self.assertNotIn('data-domain-panel="control_link"', html) - self.assertIn('id="cabinStateTitle">整车设备状态', html) + self.assertIn('data-domain-panel="climate"', template) + self.assertIn('data-domain-panel="roof"', template) + self.assertIn('data-domain-panel="ambient_light"', template) + self.assertIn('data-domain-panel="window"', template) + self.assertIn('data-domain-panel="front_light"', template) + self.assertIn('data-domain-panel="vehicle_status"', template) + self.assertIn('id="vehicleGear"', template) + self.assertIn('id="vehicleSpeed"', template) + self.assertNotIn('data-domain-panel="control_link"', template) + self.assertIn('id="cabinStateTitle">整车设备状态', template) self.assertIn('id="cabinCarouselPrev"', app_script) self.assertIn('id="cabinCarouselNext"', app_script) self.assertIn('aria-label="左右浏览设备卡片"', app_script) self.assertNotIn('class="cabin-category-tabs"', app_script) - self.assertNotIn('class="section-kicker"', html) + self.assertNotIn('class="section-kicker"', template) self.assertIn('.joint-twin-shell > .twin-band {', console_styles) self.assertIn('grid-template-rows: 62px minmax(0, 1fr)', console_styles) self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles) @@ -1394,18 +1532,18 @@ operations: self.assertIn('name: "遮阳帘", measurable: true, components: []', app_script) self.assertIn("仅支持设备总功率", app_script) self.assertIn('.device-power-drawer {', console_styles) - self.assertNotIn('class="roof-visual"', html) - self.assertNotIn('class="ambient-visual"', html) - self.assertIn('id="driverAirDirection"', html) - self.assertIn('id="passengerAirDirection"', html) - self.assertNotIn('id="transmittanceValue"', html) - self.assertNotIn('id="roofMotorState"', html) - self.assertIn('id="ambientBrightness"', html) - self.assertNotIn('class="example-group"', html) - self.assertNotIn('id="safetyStrip"', html) - self.assertNotIn('class="pipeline"', html) - self.assertNotIn('class="topbar"', html) - self.assertNotIn('data-axis="height"', html) + self.assertNotIn('class="roof-visual"', template) + self.assertNotIn('class="ambient-visual"', template) + self.assertIn('id="driverAirDirection"', template) + self.assertIn('id="passengerAirDirection"', template) + self.assertNotIn('id="transmittanceValue"', template) + self.assertNotIn('id="roofMotorState"', template) + self.assertIn('id="ambientBrightness"', template) + self.assertNotIn('class="example-group"', template) + self.assertNotIn('id="safetyStrip"', template) + self.assertNotIn('class="pipeline"', template) + self.assertNotIn('class="topbar"', template) + self.assertNotIn('data-axis="height"', template) self.assertEqual(runtime["domain"], "cabin") self.assertEqual( runtime["hardware_execution_allowed"], @@ -1662,7 +1800,7 @@ operations: ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-140", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-141", timeout=3 ) as response: app_script = response.read().decode("utf-8") @@ -1946,7 +2084,7 @@ operations: finally: SeatDemoHandler.extension = original_extension - def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None: + def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None: original_extension = SeatDemoHandler.extension original_nfc = SeatDemoHandler.nfc SeatDemoHandler.extension = SelfExtensionService( @@ -1959,7 +2097,7 @@ operations: snapshot=lambda: { "revision": 1, "reader": {"online": True}, - "card": {"action": "present", "uid": "1A53DD95"}, + "card": None, } ) catalog_request = urllib.request.Request( @@ -2021,7 +2159,7 @@ operations: self.assertEqual(operation["domain"], "self_extension") self.assertTrue(operation["telemetry"]["gateway_acknowledged"]) - def test_mode_creation_cannot_be_claimed_by_live_nfc_peripheral(self) -> None: + def test_mode_creation_cannot_be_claimed_by_bound_nfc_peripheral(self) -> None: original_extension = SeatDemoHandler.extension original_nfc = SeatDemoHandler.nfc with tempfile.TemporaryDirectory() as directory: @@ -2050,7 +2188,7 @@ operations: nfc_snapshot = { "revision": 1, "reader": {"online": True}, - "card": {"action": "present", "uid": "2CC8B103"}, + "card": None, } utterance = "创建一个打开闪灯玩具模式" partition = partition_active_peripheral_intent( @@ -2352,14 +2490,20 @@ operations: ] self.assertTrue(validated_payloads) self.assertTrue( - all( + any( {"native_climate", "self_extension"}.issubset( payload["function_configs"] ) for payload in validated_payloads ) ) - first_validated = validated_payloads[0] + first_validated = next( + payload + for payload in validated_payloads + if {"native_climate", "self_extension"}.issubset( + payload["function_configs"] + ) + ) self.assertTrue( {"native_climate", "self_extension"}.issubset( first_validated["command_plans"] @@ -2605,11 +2749,11 @@ operations: self.assertEqual(events[-1]["type"], "error") self.assertEqual(events[-1]["error"], "can_transport_failed") - validated = next( + validated = [ item["payload"] for item in events if item["type"] == "validated_commands" - ) + ][-1] self.assertEqual(validated["status"], "processing") self.assertEqual( set(validated["function_configs"]), @@ -3620,16 +3764,21 @@ operations: "provider": "local", "conversation_id": "conv-e2e-hardware-locked", "execution_mode": "hardware", + "hardware_client_id": "hwc-e2e-locked-client", "approval_token": "must-not-be-persisted", } ).encode("utf-8"), ) - with self.assertRaises(urllib.error.HTTPError) as caught: + with patch.object( + SeatDemoHandler, + "_hardware_access_bypass_allowed", + return_value=False, + ), self.assertRaises(urllib.error.HTTPError) as caught: self.opener.open(request, timeout=3) self.assertEqual(caught.exception.code, 403) payload = json.loads(caught.exception.read().decode("utf-8")) - self.assertEqual(payload["error"], "hardware_safety_gate") + self.assertEqual(payload["error"], "hardware_access_required") artifact_text = "\n".join( path.read_text(encoding="utf-8") for path in Path(self.temporary_directory.name).rglob("*") diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py index b6e43c1..839edbf 100644 --- a/tests/integration/test_assistant_capability_execution_matrix.py +++ b/tests/integration/test_assistant_capability_execution_matrix.py @@ -778,7 +778,7 @@ def test_preview_and_process_share_mode_target_safety_gate( } == {"mode_target_scope_mismatch"} -def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected( +def test_durable_nfc_peripheral_can_execute_after_bound_card_is_removed( vehicle_service: SeatDemoService, ) -> None: learned = { @@ -798,10 +798,10 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected( peripheral_provider=lambda: [learned], nfc_provider=lambda: {"card": {"uid": "a1b2c3d4"}}, ) - inactive_catalog = AssistantCapabilityCatalog( + detached_catalog = AssistantCapabilityCatalog( vehicle_service, peripheral_provider=lambda: [learned], - nfc_provider=lambda: {"card": {"uid": "different-card"}}, + nfc_provider=lambda: {"card": None}, ) active_client = _ToolCallingCodexClient() @@ -834,27 +834,37 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected( ) assert executed == ["打开星空投影仪"] - inactive_client = _ToolCallingCodexClient() - inactive_assistant = CodexAssistantService(inactive_client) - errors: list[str] = [] - - def attempt_inactive(token: str, _turn: ActiveCodexTurn) -> None: - try: - inactive_assistant.execute_cockpit_request(token) - except CodexVehicleToolError as exc: - errors.append(str(exc)) - - inactive_client.on_turn = attempt_inactive - _stream_with_catalog( - inactive_assistant, - inactive_catalog, - "打开星空投影仪", - execute_control=lambda _instruction, _progress: pytest.fail( - "inactive NFC peripheral must not execute" - ), - conversation_id="conv-inactive-peripheral-01", + detached_client = _ToolCallingCodexClient() + detached_assistant = CodexAssistantService(detached_client) + detached_executed: list[str] = [] + detached_client.on_turn = ( + lambda token, _turn: detached_assistant.execute_cockpit_request(token) ) - assert errors == ["vehicle control was not authorized by the original user request"] + _stream_with_catalog( + detached_assistant, + detached_catalog, + "打开星空投影仪", + execute_control=lambda instruction, _progress: ( + detached_executed.append(instruction) + or { + "status": "succeeded", + "execution_mode": "dry_run", + "utterance": instruction, + "infrared_actions": [ + { + "capability_id": "star-projector-on", + "device_id": "star-projector", + "device_name": "星空投影仪", + "action": "开启", + "slot": 7, + "transport": "infrared", + } + ], + } + ), + conversation_id="conv-detached-peripheral-01", + ) + assert detached_executed == ["打开星空投影仪"] @pytest.mark.parametrize( diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py index e4e35e7..bd211ec 100644 --- a/tests/unit/test_assistant_capability_catalog.py +++ b/tests/unit/test_assistant_capability_catalog.py @@ -451,7 +451,7 @@ def test_mode_provider_failure_degrades_to_unknown_instead_of_empty( assert "0 个" not in answer.answer -def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card( +def test_learned_peripheral_requires_enabled_capability_and_durable_nfc_binding( vehicle_service: SeatDemoService, ) -> None: capability = { @@ -470,8 +470,8 @@ def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card( absent_item = absent["learned_peripherals"][0] assert absent_item["enabled"] is True assert absent_item["active_for_current_nfc_card"] is False - assert absent_item["assistant_callable"] is False - assert "闪灯玩具" not in _catalog( + assert absent_item["assistant_callable"] is True + assert "闪灯玩具" in _catalog( vehicle_service, peripherals=lambda: [capability] ).control_terms(absent) @@ -910,6 +910,39 @@ def test_provider_failure_is_not_misreported_as_an_empty_catalog( assert "没有" not in answer.answer +def test_nfc_reader_failure_does_not_hide_durable_peripheral_bindings( + vehicle_service: SeatDemoService, +) -> None: + capability = { + "id": "lamp-on", + "status": "learned", + "enabled": True, + "device_id": "lamp-1", + "device_name": "闪灯玩具", + "object_names": ["闪灯玩具"], + "action": "开启", + "action_names": ["打开"], + "card_uid": "A1B2", + "slot": 1, + } + + def nfc_unavailable() -> dict[str, object]: + raise RuntimeError("reader offline") + + catalog = _catalog( + vehicle_service, + peripherals=lambda: [capability], + nfc=nfc_unavailable, + ) + snapshot = catalog.snapshot() + item = snapshot["learned_peripherals"][0] + + assert snapshot["source_status"]["learned_peripherals"] == "ready" + assert item["active_for_current_nfc_card"] is False + assert item["assistant_callable"] is True + assert "闪灯玩具" in catalog.control_terms(snapshot) + + def test_catalog_revision_is_stable_and_changes_with_live_capabilities( vehicle_service: SeatDemoService, ) -> None: diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index d7204fc..75041c9 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -3,25 +3,35 @@ from __future__ import annotations from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +WEB = ROOT / "apps" / "web-console" + + +def vue_shell() -> str: + return (WEB / "src" / "App.vue").read_text(encoding="utf-8") def test_console_contains_ai_assistant_surface(): - html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8") - assert 'id="assistantTranscript"' in html - assert 'id="assistantMessages"' in html - assert "你好,我是 AI助手。你可以向我提问" in html - assert 'aria-label="AI助手对话"' in html - assert 'placeholder="向 AI助手提问或交代工作"' in html - assert 'aria-label="发送消息"' in html - assert 'href="/assistant-chat.css?v=codex-assistant-29"' in html - assert 'src="/assistant-chat.js?v=codex-assistant-33"' in html + html = (WEB / "index.html").read_text(encoding="utf-8") + template = vue_shell() + vite_entry = (WEB / "src" / "main.js").read_text(encoding="utf-8") + assert 'id="assistantTranscript"' in template + assert 'id="assistantMessages"' in template + assert "你好,我是 AI助手。你可以向我提问" in template + assert 'aria-label="AI助手对话"' in template + assert 'placeholder="向 AI助手提问或交代工作"' in template + assert 'aria-label="发送消息"' in template + assert 'import "../assistant-chat.css";' in vite_entry + assert 'src="/assistant-chat.js?v=codex-assistant-34"' in html def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") - assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker + assert ( + 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + in service_worker + ) assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker - assert '"/assistant-chat.js?v=codex-assistant-33"' in service_worker + assert '"/assistant-chat.js?v=codex-assistant-34"' in service_worker def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events(): @@ -90,6 +100,18 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card(): assert "appendDispatchedControls(message, event.payload)" in script assert "function announceModeExecution(" in script assert "announceModeExecution," in script + assert "function beginModeExecution(" in script + assert "function completeModeExecution(" in script + assert "appendDispatchedControls(message, modeExecutionPreviewResult(profile))" in script + assert "beginModeExecution," in script + assert "completeModeExecution," in script + assert "const operationByRef = new Map()" in script + assert 'deliveryState: "submitting"' in script + assert 'deliveryLabel: "正在下发"' in script + assert '"seat.set_cushion_tilt": "坐盆"' in script + assert '"seat.set_backrest_recline": "靠背"' in script + assert '"seat.set_legrest_height": "腿托"' in script + assert '"seat.set_legrest_extension": "腿托"' in script assert '"ambient_light.set_illumination": "氛围灯颜色与亮度"' in script assert '.replaceAll("_", "-")' in script assert ".assistant-dispatched-controls > summary" in css @@ -98,6 +120,29 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card(): assert ".assistant-dispatched-controls[open]" in css +def test_saved_mode_execution_opens_with_pending_flow_before_http_completion(): + app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8") + flow = app.split( + "async function executeSavedModeProfile", 1 + )[1].split("function applyModeCommand", 1)[0] + + begin_index = flow.index("assistant?.beginModeExecution?.(mode") + request_index = flow.index("await postControlPhase(") + assert begin_index < request_index + assert "assistant.completeModeExecution(assistantModeHandle, mode, payload" in flow + assert "assistant.completeModeExecution(assistantModeHandle, mode, null" in flow + + +def test_seat_control_cards_name_the_controlled_seat_component(): + console = (ROOT / "apps/web-console/console-v2.js").read_text(encoding="utf-8") + + assert '"seat.set_cushion_tilt": ["副驾座椅坐盆调节"' in console + assert 'backrest: "主驾座椅靠背调节"' in console + assert 'basin: "主驾座椅坐盆调节"' in console + assert 'legrest: "主驾座椅腿托调节"' in console + assert 'horizontal: "主驾座椅前后调节"' in console + + def test_codex_request_keeps_trusted_vehicle_context_without_legacy_model_routing(): script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8") request_body = script.split("function requestBody(message)", 1)[1].split( @@ -332,7 +377,7 @@ def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content(): def test_assistant_composer_keeps_the_microphone_visible_and_aligned(): - html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8") + html = vue_shell() css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8") assert 'id="speechDictationButton"' in html assert 'data-lucide="mic"' in html @@ -345,7 +390,7 @@ def test_assistant_composer_keeps_the_microphone_visible_and_aligned(): def test_voice_input_supports_direct_manual_capture_and_background_wake_standby(): - html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8") + html = vue_shell() script = (ROOT / "apps/web-console/speech-input.js").read_text(encoding="utf-8") assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8") diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 02b80f5..077fee9 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -27,18 +27,23 @@ def node_runtime() -> str: def test_energy_monitor_assets_are_versioned_and_cached() -> None: html = source("index.html") + vite_entry = source("src/main.js") service_worker = source("service-worker.js") for asset in ( "/energy-monitor.css?v=energy-monitor-4", "/energy-monitor.js?v=energy-monitor-4", ): - assert asset in html assert f'"{asset}"' in service_worker - assert html.index("/assistant-chat.js?v=codex-assistant-33") < html.index( + assert 'import "../energy-monitor.css";' in vite_entry + assert "/energy-monitor.js?v=energy-monitor-4" in html + assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) - assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker + assert ( + 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + in service_worker + ) def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None: diff --git a/tests/unit/test_extension_control.py b/tests/unit/test_extension_control.py new file mode 100644 index 0000000..a481b92 --- /dev/null +++ b/tests/unit/test_extension_control.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import json +import tempfile +import time +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.extension_control import ( + ExtensionControlAuthorizationError, + ExtensionControlConsumer, + ExtensionControlError, + authorize_extension_control_actions, + parse_extension_control_payload, +) +from cognitive_agent.application.self_extension import ( + ExtensionIntegrationError, + SelfExtensionService, + SimulationGateway, +) + + +def wait_until(predicate, timeout: float = 1.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.005) + return bool(predicate()) + + +def action( + slot: int, + action_name: str, + *, + target_scope: str = "toy-light-001", + at_ms: int = 0, + **extra: Any, +) -> dict[str, Any]: + return { + "type": "infrared_emit", + "at_ms": at_ms, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": target_scope, + "operation": "emit", + "parameters": {"action": action_name, "slot": slot}, + **extra, + } + + +def request(*actions: dict[str, Any]) -> dict[str, Any]: + return { + "domain": "self_extension", + "operation_count": len(actions), + "actions": list(actions), + } + + +def capability( + capability_id: str, + slot: int, + action_name: str, + *, + device_id: str = "toy-light-001", + enabled: bool = True, + card_uid: str = "E113B203", +) -> dict[str, Any]: + return { + "id": capability_id, + "status": "learned", + "enabled": enabled, + "device_id": device_id, + "device_name": "闪烁灯", + "card_uid": card_uid, + "action": action_name, + "slot": slot, + "risk": "low", + } + + +def encoded(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, ensure_ascii=False).encode("utf-8") + + +def test_parser_accepts_scene_action_shape_and_strips_compatibility_fields() -> None: + payload = request( + action( + 2, + "打开", + target={"device": "toy-light-001", "card_uid": "E113B203"}, + operation_ref="toy-light-on", + ) + ) + + parsed = parse_extension_control_payload(encoded(payload)) + + assert parsed == request(action(2, "打开")) + assert "target" not in parsed["actions"][0] + assert "operation_ref" not in parsed["actions"][0] + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ( + { + "domain": "self_extension", + "operation_count": 2, + "actions": [action(2, "打开")], + }, + "must equal", + ), + (request({**action(2, "打开"), "domain": "seat"}), "self_extension"), + (request({**action(2, "打开"), "operation": "set"}), "must be emit"), + (request(action(2, "打开", target_scope="闪烁灯")), "English device id"), + ], +) +def test_parser_rejects_invalid_control_contract( + payload: dict[str, Any], + message: str, +) -> None: + with pytest.raises(ExtensionControlError, match=message): + parse_extension_control_payload(encoded(payload)) + + +def test_parser_rejects_duplicate_json_keys() -> None: + raw = ( + b'{"domain":"self_extension","domain":"self_extension",' + b'"operation_count":1,"actions":[]}' + ) + + with pytest.raises(ExtensionControlError, match="duplicate key"): + parse_extension_control_payload(raw) + + +def test_authorization_requires_exact_enabled_learned_bound_ability() -> None: + parsed = parse_extension_control_payload(encoded(request(action(2, "打开")))) + valid = capability("light-on", 2, "打开") + + [authorized] = authorize_extension_control_actions(parsed, [valid]) + + assert authorized["capability_id"] == "light-on" + assert authorized["action"] == action(2, "打开") + for disallowed in ( + [{**valid, "enabled": False}], + [{**valid, "status": "draft"}], + [{**valid, "card_uid": ""}], + [{**valid, "device_id": "another-device"}], + [{**valid, "action": "关闭"}], + [{**valid, "slot": 3}], + ): + with pytest.raises(ExtensionControlAuthorizationError, match="not authorized"): + authorize_extension_control_actions(parsed, disallowed) + + +def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None: + catalog = [ + capability("light-on", 2, "打开"), + capability("light-red", 3, "切换为红色"), + ] + executions: list[tuple[float, dict[str, Any]]] = [] + + def execute(value: dict[str, Any]) -> dict[str, Any]: + executions.append((time.monotonic(), value)) + return {"success": True, "transport": "tcp", "gateway_acknowledged": True} + + consumer = ExtensionControlConsumer( + catalog_provider=lambda: catalog, + executor=execute, + adjacent_emit_interval_s=0, + ) + consumer.start() + try: + consumer.submit( + encoded( + request( + action(2, "打开", at_ms=30), + action(3, "切换为红色", at_ms=0), + ) + ) + ) + assert wait_until(lambda: consumer.snapshot()["state"] == "completed") + assert [item[1]["parameters"]["slot"] for item in executions] == [3, 2] + status = consumer.snapshot() + assert status["processed_count"] == 1 + assert status["executed_action_count"] == 2 + assert [item["slot"] for item in status["last_results"]] == [3, 2] + assert [item["slot"] for item in status["action_statuses"]] == [3, 2] + assert [item["state"] for item in status["action_statuses"]] == [ + "submitted_unverified", + "submitted_unverified", + ] + assert all( + item["state_label"] == "已下发 · 物理效果未确认" + for item in status["action_statuses"] + ) + assert status["source_label"] == "车端 MQTT" + assert status["active_action_index"] is None + assert status["last_error"] is None + finally: + consumer.close() + + +def test_consumer_rejects_whole_request_before_first_tcp_command() -> None: + executions: list[dict[str, Any]] = [] + consumer = ExtensionControlConsumer( + catalog_provider=lambda: [capability("light-on", 2, "打开")], + executor=lambda value: executions.append(value) or {"success": True}, + adjacent_emit_interval_s=0, + ) + consumer.start() + try: + consumer.submit( + encoded( + request( + action(2, "打开"), + action(3, "切换为红色"), + ) + ) + ) + assert wait_until(lambda: consumer.snapshot()["state"] == "rejected") + assert executions == [] + assert consumer.snapshot()["rejected_count"] == 1 + assert "not authorized" in consumer.snapshot()["last_error"] + finally: + consumer.close() + + +def test_consumer_aborts_remaining_actions_after_tcp_failure() -> None: + catalog = [ + capability("light-on", 2, "打开"), + capability("light-red", 3, "切换为红色"), + capability("light-blue", 5, "切换为蓝色"), + ] + attempted_slots: list[int] = [] + + def execute(value: dict[str, Any]) -> dict[str, Any]: + slot = int(value["parameters"]["slot"]) + attempted_slots.append(slot) + if slot == 3: + raise RuntimeError("TCP disconnected") + return {"success": True, "transport": "tcp"} + + consumer = ExtensionControlConsumer( + catalog_provider=lambda: catalog, + executor=execute, + adjacent_emit_interval_s=0, + ) + consumer.start() + try: + consumer.submit( + encoded( + request( + action(2, "打开"), + action(3, "切换为红色"), + action(5, "切换为蓝色"), + ) + ) + ) + assert wait_until(lambda: consumer.snapshot()["state"] == "failed") + assert attempted_slots == [2, 3] + status = consumer.snapshot() + assert status["failed_count"] == 1 + assert status["executed_action_count"] == 1 + assert [item["state"] for item in status["action_statuses"]] == [ + "submitted_unverified", + "failed", + "aborted", + ] + assert status["last_error"] == "TCP disconnected" + finally: + consumer.close() + + +def test_consumer_projects_mqtt_transport_rejection_without_execution() -> None: + executions: list[dict[str, Any]] = [] + consumer = ExtensionControlConsumer( + catalog_provider=list, + executor=lambda value: executions.append(value) or {"success": True}, + ) + consumer.start() + try: + consumer.reject_transport_message( + "retained extension/ctl messages are not executable", + "retained", + ) + + status = consumer.snapshot() + assert status["state"] == "rejected" + assert status["request_count"] == 1 + assert status["processed_count"] == 1 + assert status["rejected_count"] == 1 + assert status["last_rejection_kind"] == "retained" + assert status["last_latency_ms"] == 0 + assert status["action_statuses"] == [] + assert executions == [] + finally: + consumer.close() + + +def test_self_extension_reauthorizes_immediately_before_gateway_emit() -> None: + with tempfile.TemporaryDirectory() as directory: + service = SelfExtensionService( + SimulationGateway(), + catalog_db_path=Path(directory, "infrared.sqlite3"), + ) + service.sync_peripheral_catalog([capability("light-on", 2, "打开")]) + with patch.object( + service, + "execute_gateway", + return_value={ + "success": True, + "transport": "tcp", + "gateway_acknowledged": True, + }, + ) as execute_gateway: + result = service.execute_bound_infrared_action(action(2, "打开")) + execute_gateway.assert_called_once_with("emit", {"slot": 2}) + assert result["authorization"]["capability_id"] == "light-on" + + with pytest.raises(ExtensionIntegrationError, match="未通过"): + service.execute_bound_infrared_action(action(3, "切换为红色")) + assert execute_gateway.call_count == 1 + service.close() + + +def test_server_wires_extension_control_subscription_and_health() -> None: + root = Path(__file__).resolve().parents[2] + server = (root / "src" / "cognitive_agent" / "server.py").read_text( + encoding="utf-8" + ) + seat_service = ( + root / "src" / "cognitive_agent" / "application" / "seat_service.py" + ).read_text(encoding="utf-8") + console = (root / "apps" / "web-console" / "console-v2.js").read_text( + encoding="utf-8" + ) + + assert "extension_control = ExtensionControlConsumer(" in server + assert "executor=extension.execute_bound_infrared_action" in server + assert "service.set_extension_control_handler(extension_control.submit)" in server + assert "extension_control.reject_transport_message" in server + assert '"extension_control": self.extension_control.snapshot()' in server + assert 'path == "/api/extension/control/status"' in server + assert "self.extension_control.wait_for_snapshot(" in server + assert "SeatDemoHandler.extension_control.start()" in server + assert "SeatDemoHandler.extension_control.close()" in server + assert "def set_extension_control_handler" in seat_service + assert "def set_extension_control_rejection_handler" in seat_service + assert 'fetch(`/api/extension/control/status?${query}`' in console + assert "function applyExtensionControlSnapshot" in console + assert "if (revision < extensionControlSync.revision)" in console + assert 'vehicle_mqtt_extension: "车端 MQTT"' in console + assert 'delivery_transport: "车端 MQTT → 红外 TCP"' in console + assert 'semantic_instruction: `${extensionName} · ${actionName}`' in console + assert ( + 'category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`' + in console + ) diff --git a/tests/unit/test_extension_toy_publisher.py b/tests/unit/test_extension_toy_publisher.py new file mode 100644 index 0000000..880d297 --- /dev/null +++ b/tests/unit/test_extension_toy_publisher.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import time +from collections import Counter +from typing import Any + +from cognitive_agent.application.extension_toy_publisher import ( + NfcExtensionToyPublisher, + build_extension_toy_messages, + normalize_extension_nfc_id, +) + + +def wait_until(predicate, timeout: float = 1.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.005) + return bool(predicate()) + + +def capability( + capability_id: str, + *, + device_id: str = "toy-light-001", + device_name: str = "闪烁灯", + card_uid: str = "E113B203", + slot: int = 2, + action: str = "打开", + enabled: bool = True, + status: str = "learned", + **extra: Any, +) -> dict[str, Any]: + return { + "id": capability_id, + "status": status, + "enabled": enabled, + "device_id": device_id, + "device_name": device_name, + "card_uid": card_uid, + "slot": slot, + "action": action, + **extra, + } + + +def test_message_projection_groups_devices_orders_slots_and_filters_catalog() -> None: + catalog = [ + capability("light-blue", slot=5, action="切换为蓝色"), + capability("fan-on", device_id="toy-fan-001", device_name="小风扇"), + capability("light-on", slot=2, action="打开"), + capability("duplicate", slot=2, action="打开"), + capability("disabled", slot=1, action="关闭", enabled=False), + capability("other-card", slot=6, action="设置为白色", card_uid="04A1B2C3"), + capability("draft", slot=3, action="切换为红色", status="draft"), + capability("missing-enabled", slot=4, action="切换为绿色", enabled=None), + ] + + messages = build_extension_toy_messages("e1:13-b2 03", catalog) + + assert [message["extension_name"] for message in messages] == ["小风扇", "闪烁灯"] + fan, light = messages + assert fan["actions"] == [ + { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "toy-fan-001", + "operation": "emit", + "parameters": {"action": "打开", "slot": 2}, + } + ] + assert [action["parameters"] for action in light["actions"]] == [ + {"action": "打开", "slot": 2}, + {"action": "切换为蓝色", "slot": 5}, + ] + assert {action["target_scope"] for action in light["actions"]} == { + "toy-light-001" + } + + +def test_projection_prefers_chinese_name_but_keeps_english_stable_scope() -> None: + catalog = [ + capability( + "desk-light-on", + device_id="desk-light-01", + device_name="Desk Light", + object_names=["闪烁灯"], + ) + ] + + [message] = build_extension_toy_messages("E113B203", catalog) + + assert message["extension_name"] == "闪烁灯" + assert message["actions"][0]["target_scope"] == "desk-light-01" + assert normalize_extension_nfc_id("04:a1-b2 c3") == "04A1B2C3" + assert normalize_extension_nfc_id("not-a-card") == "" + + +def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> None: + nfc: dict[str, Any] = { + "reader": {"online": True}, + "card": {"action": "present", "uid": "E113B203"}, + } + catalog = [ + capability("light-on"), + capability("light-blue", slot=5, action="切换为蓝色"), + capability("fan-on", device_id="toy-fan-001", device_name="小风扇"), + ] + publications: list[tuple[float, dict[str, Any]]] = [] + + def publish(payload: dict[str, Any]) -> dict[str, Any]: + publications.append((time.monotonic(), payload)) + return {"delivery_status": "queued", "message_id": len(publications)} + + service = NfcExtensionToyPublisher( + nfc_provider=lambda: nfc, + catalog_provider=lambda: catalog, + publisher=publish, + burst_window_s=0.12, + poll_interval_s=0.005, + ) + service.start() + try: + assert wait_until(lambda: len(publications) == 6) + assert publications[-1][0] - publications[0][0] <= 0.12 + assert Counter( + publication["actions"][0]["target_scope"] + for _at, publication in publications + ) == {"toy-fan-001": 3, "toy-light-001": 3} + status = service.snapshot() + assert status["state"] == "burst_complete" + assert status["burst_frame_attempt_count"] == 3 + assert status["burst_publish_attempt_count"] == 6 + assert status["burst_published_count"] == 6 + assert status["qos"] == 1 + assert status["retain"] is False + + time.sleep(0.14) + assert len(publications) == 6 + + nfc["card"] = None + assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc") + nfc["card"] = {"action": "present", "uid": "E113B203"} + assert wait_until(lambda: len(publications) == 12) + assert service.snapshot()["state"] == "burst_complete" + finally: + service.close() + + +def test_no_matching_ability_publishes_nothing() -> None: + nfc = {"card": {"action": "present", "uid": "E113B203"}} + publications: list[dict[str, Any]] = [] + service = NfcExtensionToyPublisher( + nfc_provider=lambda: nfc, + catalog_provider=lambda: [ + capability("other-card", card_uid="04A1B2C3"), + capability("disabled", enabled=False), + ], + publisher=lambda payload: publications.append(dict(payload)) or {}, + burst_window_s=0.03, + poll_interval_s=0.005, + ) + service.start() + try: + assert wait_until( + lambda: service.snapshot()["state"] == "no_matching_extension" + ) + time.sleep(0.05) + assert publications == [] + finally: + service.close() + + +def test_failed_burst_attempts_only_three_frames_until_redetected() -> None: + nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}} + attempts: list[float] = [] + + def fail_publish(_payload: dict[str, Any]) -> dict[str, Any]: + attempts.append(time.monotonic()) + raise RuntimeError("broker unavailable") + + service = NfcExtensionToyPublisher( + nfc_provider=lambda: nfc, + catalog_provider=lambda: [capability("light-on")], + publisher=fail_publish, + burst_window_s=0.06, + poll_interval_s=0.005, + ) + service.start() + try: + assert wait_until(lambda: len(attempts) == 3) + status = service.snapshot() + assert status["state"] == "burst_failed" + assert status["burst_publish_attempt_count"] == 3 + assert status["burst_published_count"] == 0 + assert status["last_error"] == "broker unavailable" + time.sleep(0.08) + assert len(attempts) == 3 + finally: + service.close() + + +def test_server_wires_publisher_without_invoking_infrared_execution() -> None: + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + server = (root / "src" / "cognitive_agent" / "server.py").read_text( + encoding="utf-8" + ) + service = ( + root / "src" / "cognitive_agent" / "application" / "seat_service.py" + ).read_text(encoding="utf-8") + + assert "extension_toys = NfcExtensionToyPublisher(" in server + assert "catalog_provider=extension.list_peripheral_capabilities" in server + assert "publisher=service.publish_extension_toy_message" in server + assert "SeatDemoHandler.extension_toys.start()" in server + assert "SeatDemoHandler.extension_toys.close()" in server + assert "publish_extension_toy_message" in service + method = service.split("def publish_extension_toy_message", 1)[1].split( + "\n def ", 1 + )[0] + assert "_vehicle_mqtt_transport.publish_extension_toy_message" in method + assert "execute_gateway" not in method diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index 672d883..41cb2a8 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -6,7 +6,10 @@ WEB = ROOT / "apps" / "web-console" def source(name: str) -> str: - return (WEB / name).read_text(encoding="utf-8") + content = (WEB / name).read_text(encoding="utf-8") + if name == "index.html": + content += "\n" + (WEB / "src" / "App.vue").read_text(encoding="utf-8") + return content def test_shadcn_semantic_theme_tokens_drive_the_console() -> None: @@ -36,19 +39,20 @@ def test_shadcn_semantic_theme_tokens_drive_the_console() -> None: def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: - html = source("index.html") + main = source("src/main.js") service_worker = source("service-worker.js") - for asset in ( - "/styles.css?v=dual-seat-23", - "/console-v2.css?v=delivery-state-69", - "/self-extension.css?v=pet-settings-52", - "/speech-input.css?v=sensevoice-7", - "/codepen-glass-theme.css?v=creative-cloud-2", + for source_asset, cached_asset in ( + ('import "../styles.css"', "/styles.css?v=dual-seat-23"), + ('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"), + ('import "../self-extension.css"', "/self-extension.css?v=pet-settings-52"), + ('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"), + ('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-5"), ): - assert asset in html - assert f'"{asset}"' in service_worker - assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker + assert source_asset in main + assert f'"{cached_asset}"' in service_worker + assert 'import "./styles/design-system.css"' in main + assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker def test_reference_glass_theme_covers_the_complete_workspace() -> None: @@ -91,6 +95,45 @@ def test_reference_glass_theme_covers_the_complete_workspace() -> None: assert "border-radius: 0 !important" in theme +def test_reference_theme_uses_one_transform_only_floating_card_contract() -> None: + theme = source("codepen-glass-theme.css") + + for contract in ( + "--glass-card-radius:", + "--glass-card-lift:", + "--glass-card-rest-shadow:", + "--glass-card-float-shadow:", + ".assistant-dispatched-controls", + ".assistant-mode-draft", + ".mode-gallery-card", + ".mode-editor-command-card", + ".v2-device-card", + ".v2-pet-card", + ".energy-kpi", + "transform: translateY(var(--glass-card-lift))", + "@media (prefers-reduced-motion: reduce)", + "transition: none !important", + ): + assert contract in theme + + +def test_dark_theme_uses_one_glass_scrim_for_every_modal_backdrop() -> None: + theme = source("codepen-glass-theme.css") + + for contract in ( + "--glass-dialog-scrim:", + "--glass-dialog-scrim-filter:", + ".mode-gallery-backdrop", + ".mode-editor-backdrop", + ".energy-monitor-layer", + ".device-power-backdrop", + ".v2-extension-dialog::backdrop", + "background: var(--glass-dialog-scrim) !important", + "backdrop-filter: var(--glass-dialog-scrim-filter) !important", + ): + assert contract in theme + + def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: styles = source("styles.css") console = source("console-v2.css") diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py index dabc51d..cf36abd 100644 --- a/tests/unit/test_mode_gallery_grouping.py +++ b/tests/unit/test_mode_gallery_grouping.py @@ -146,7 +146,7 @@ console.log(JSON.stringify(context.result)); def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None: app = (WEB / "app.js").read_text(encoding="utf-8") - html = (WEB / "index.html").read_text(encoding="utf-8") + template = (WEB / "src" / "App.vue").read_text(encoding="utf-8") styles = (WEB / "styles.css").read_text(encoding="utf-8") definitions = app[ app.index("const MODE_GALLERY_GROUPS") @@ -161,8 +161,8 @@ def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None assert 'if (group.id === "custom") grid.append(createModeGalleryAddCard())' in app assert "function createModeGalleryEmpty(group)" in app assert 'section.setAttribute("aria-labelledby", headingId)' in app - assert "自定义模式 · 静态模式 · 动态模式" in html - assert "按来源与运行方式整理,快速定位并管理模式" in html + assert "自定义模式 · 静态模式 · 动态模式" in template + assert "按来源与运行方式整理,快速定位并管理模式" in template assert "grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));" in styles @@ -173,11 +173,17 @@ def test_top_mode_cards_render_real_actions_in_a_body_level_hover_and_focus_prev assert "function modeExecutionActionViews(mode)" in app assert "Array.isArray(mode?.operations)" in app assert "function renderModeActionPopover(mode)" in app + assert "modeEditorReadableCommands(mode?.command_flow)" in app + assert 'eyebrow.dataset.modeActionEyebrow = "true"' in app + assert '"最新控制指令流"' in app assert 'popover.setAttribute("role", "tooltip")' in app assert "document.body.append(popover)" in app - assert 'device.innerHTML = "设备"' in app - assert 'target.innerHTML = "目标"' in app - assert 'parameters.innerHTML = "参数"' in app + assert 'appendMetadata("语义", action.semantic || action.operation_ref)' in app + assert 'appendMetadata("实际", action.command)' in app + assert 'appendMetadata("通道", action.channel)' in app + assert 'appendMetadata("设备", action.device)' in app + assert 'appendMetadata("目标", action.target)' in app + assert 'appendMetadata("参数", action.parameters)' in app assert 'button.setAttribute("aria-describedby", popover.id)' in app assert 'group.addEventListener("pointerover"' in app assert 'group.addEventListener("pointerout"' in app diff --git a/tests/unit/test_mode_scene_catalog.py b/tests/unit/test_mode_scene_catalog.py index f8ec607..d96e6a1 100644 --- a/tests/unit/test_mode_scene_catalog.py +++ b/tests/unit/test_mode_scene_catalog.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import threading +import time from pathlib import Path from cognitive_agent.application.mode_profile import ModeProfileRegistry from cognitive_agent.application.mode_scene_catalog import ( ModeSceneCatalogPublisher, + ModeSceneMqttBurstPublisher, build_mode_scene_catalog, ) from cognitive_agent.application.seat_service import REPOSITORY_ROOT @@ -156,3 +159,247 @@ def test_repository_presets_are_exported_without_internal_command_flows() -> Non encoded = json.dumps(catalog, ensure_ascii=False) assert "command_flow" not in encoded assert "source_request_id" not in encoded + + +def test_saved_editor_flow_projects_all_native_and_extension_operations( + tmp_path: Path, +) -> None: + registry = ModeProfileRegistry( + tmp_path / "presets", + user_root=tmp_path / "profiles", + ) + operations = [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "driver", + "parameters": {"temperature_c": 24}, + }, + { + "domain": "interior_light", + "capability": "interior_light.set_reading_power", + "target_scope": "all", + "parameters": {"state": "on"}, + }, + { + "domain": "roof", + "capability": "roof.set_shade_motion", + "target_scope": "vehicle", + "parameters": {"motion": "open"}, + }, + { + "domain": "window", + "capability": "window.set_position", + "target_scope": "front_right", + "parameters": {"position_pct": 50}, + }, + ] + + def configured_operation( + operation_id: str, + capability: str, + zone: str, + parameters: dict[str, object], + *, + device: str, + ) -> dict[str, object]: + return { + "id": operation_id, + "capability": capability, + "parameters": parameters, + "target": {"device": device, "zone": zone}, + } + + function_configs = { + "native_climate": { + "spec": { + "domain": "climate", + "operations": [ + configured_operation( + "driver_set_temperature_1", + "climate.set_temperature", + "driver", + {"temperature_c": 24}, + device="hvac", + ) + ], + } + }, + "native_interior_light": { + "spec": { + "domain": "interior_light", + "operations": [ + configured_operation( + "all_set_reading_power_1", + "interior_light.set_reading_power", + "all", + {"state": "on"}, + device="reading_light", + ) + ], + } + }, + "native_roof": { + "spec": { + "domain": "roof", + "operations": [ + configured_operation( + "vehicle_set_shade_motion_1", + "roof.set_shade_motion", + "vehicle", + {"motion": "open"}, + device="roof_shade", + ) + ], + } + }, + "native_window": { + "spec": { + "domain": "window", + "operations": [ + configured_operation( + "front_right_set_position_1", + "window.set_position", + "front_right", + {"position_pct": 50}, + device="window", + ) + ], + } + }, + "self_extension": { + "spec": { + "domain": "self_extension", + "operations": [ + { + "id": "ir-slot:toy-light-001:2-1", + "capability": "infrared.emit", + "parameters": {"action": "打开", "slot": 2}, + "target": { + "card_uid": "2CC8B103", + "device": "toy-light-001", + "name": "闪烁灯", + }, + }, + { + "id": "ir-slot:toy-light-001:6-2", + "capability": "infrared.emit", + "parameters": {"action": "设置为白色", "slot": 6}, + "target": { + "card_uid": "2CC8B103", + "device": "toy-light-001", + "name": "闪烁灯", + }, + }, + ], + } + }, + } + registry.save_mode( + "阅读模式", + operations, + mode_id="mode-reading", + vehicle_id="denza_n7", + command_flow={"function_configs": function_configs}, + ) + + catalog = build_mode_scene_catalog( + registry, + "denza_n7", + generated_at="2026-09-15T00:00:00Z", + ) + reading = next( + scene for scene in catalog["scenes"] if scene["id"] == "mode-reading" + ) + + assert reading["operation_count"] == 6 + assert len(reading["actions"]) == 6 + assert [action["capability"] for action in reading["actions"]] == [ + "climate.set_temperature", + "interior_light.set_reading_power", + "roof.set_shade_motion", + "window.set_position", + "infrared.emit", + "infrared.emit", + ] + assert reading["domains"] == [ + "climate", + "interior_light", + "roof", + "self_extension", + "window", + ] + first_infrared, second_infrared = reading["actions"][-2:] + assert first_infrared["parameters"] == {"action": "打开", "slot": 2} + assert second_infrared["parameters"] == {"action": "设置为白色", "slot": 6} + assert first_infrared["target"] == { + "card_uid": "2CC8B103", + "device": "toy-light-001", + "name": "闪烁灯", + } + assert second_infrared["target"] == first_infrared["target"] + assert first_infrared["operation"] == "emit" + + +def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> None: + publications: list[tuple[float, dict[str, object]]] = [] + complete = threading.Event() + scene: dict[str, object] = { + "id": "mode-reading", + "label": "阅读模式", + "name": "阅读模式", + "background_time": "noon", + "description": "由自然语言生成的自定义模式。", + "icon": "bookmark-plus", + "aliases": ["阅读模式", "阅读"], + "origin": "user", + "deletable": True, + "behavior": "static", + "domains": ["climate"], + "operation_count": 1, + "scope": {"common": False, "vehicle_ids": ["denza_n7"]}, + "profile_revision": "1234567890abcdef", + "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, + } + ], + } + + def publish(payload: dict[str, object]) -> dict[str, object]: + publications.append((time.monotonic(), payload)) + if len(publications) == 3: + complete.set() + return {"delivery_status": "published", "message_id": len(publications)} + + publisher = ModeSceneMqttBurstPublisher( + publish, + topic="from_pc/scenes", + burst_window_s=0.12, + ) + queued_at = time.monotonic() + try: + queued = publisher.queue(scene) + assert complete.wait(timeout=1.0) + assert queued == { + "state": "queued", + "topic": "from_pc/scenes", + "scene_id": "mode-reading", + "burst_count": 3, + "burst_window_ms": 120, + } + assert len(publications) == 3 + assert publications[-1][0] - queued_at < 0.2 + assert all(payload == scene for _, payload in publications) + assert all("scenes" not in payload for _, payload in publications) + assert publisher.snapshot()["state"] == "burst_complete" + finally: + publisher.close() diff --git a/tests/unit/test_mqtt_can_transport.py b/tests/unit/test_mqtt_can_transport.py index be395b9..ce555d3 100644 --- a/tests/unit/test_mqtt_can_transport.py +++ b/tests/unit/test_mqtt_can_transport.py @@ -17,8 +17,13 @@ from cognitive_agent.infrastructure.can.hardware import ( ) from cognitive_agent.infrastructure.can.mqtt_transport import ( MQTT_CAN_SCHEMA, + MQTT_EXTENSION_CONTROL_TOPIC, + MQTT_EXTENSION_TOY_TOPIC, + MQTT_MODE_SCENE_TOPIC, MQTT_PET_TOPIC, MqttCanTransport, + mqtt_extension_toy_payload, + mqtt_mode_scene_payload, mqtt_pet_payload, parse_mqtt_can_payload, ) @@ -80,10 +85,19 @@ class _FakeMqttClient: self.published.append((topic, payload, qos, retain)) return _PublishResult() - def emit(self, topic: str, payload: object) -> None: + def emit( + self, + topic: str, + payload: object, + *, + retain: bool = False, + dup: bool = False, + ) -> None: message = SimpleNamespace( topic=topic, payload=json.dumps(payload).encode("utf-8"), + retain=retain, + dup=dup, ) self.on_message(self, None, message) @@ -557,6 +571,174 @@ class MqttCanTransportTests(unittest.TestCase): {"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"} ) + def test_extension_toy_uses_fixed_topic_qos_one_and_never_retains(self) -> None: + # This application-level topic is contractually QoS 1 even if the + # vehicle CAN relay itself is configured with a different QoS. + self.transport.settings.qos = 0 + payload = { + "extension_name": "闪烁灯", + "actions": [ + { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "toy-light-001", + "operation": "emit", + "parameters": {"action": "打开", "slot": 2}, + } + ], + } + + publication = self.transport.publish_extension_toy_message(payload) + + topic, raw_payload, qos, retain = self.client.published[-1] + self.assertEqual(topic, MQTT_EXTENSION_TOY_TOPIC) + self.assertEqual(raw_payload, mqtt_extension_toy_payload(payload)) + self.assertEqual(json.loads(raw_payload), payload) + self.assertEqual(qos, 1) + self.assertFalse(retain) + self.assertEqual(publication["qos"], 1) + self.assertEqual(publication["delivery_status"], "published") + + def test_extension_toy_rejects_non_english_scope_and_extra_fields(self) -> None: + action = { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "闪烁灯", + "operation": "emit", + "parameters": {"action": "打开", "slot": 2}, + } + with self.assertRaisesRegex(Exception, "English stable device id"): + self.transport.publish_extension_toy_message( + {"extension_name": "闪烁灯", "actions": [action]} + ) + with self.assertRaisesRegex(Exception, "requires exactly"): + self.transport.publish_extension_toy_message( + { + "extension_name": "闪烁灯", + "actions": [{**action, "target_scope": "toy-light-001"}], + "execute": True, + } + ) + + def test_extension_control_is_dispatched_as_raw_bytes_without_blocking(self) -> None: + received: list[bytes] = [] + self.transport.set_extension_control_handler(received.append) + payload = { + "domain": "self_extension", + "operation_count": 1, + "actions": [ + { + "type": "infrared_emit", + "at_ms": 0, + "domain": "self_extension", + "capability": "infrared.emit", + "target_scope": "toy-light-001", + "operation": "emit", + "parameters": {"action": "打开", "slot": 2}, + } + ], + } + + self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload) + + self.assertEqual(len(received), 1) + self.assertEqual(json.loads(received[0]), payload) + status = self.transport.public_info()["extension_control"] + self.assertTrue(status["handler_configured"]) + self.assertEqual(status["received_count"], 1) + self.assertEqual(status["dispatched_count"], 1) + self.assertEqual(status["rejected_count"], 0) + + def test_extension_control_rejects_retained_and_duplicate_deliveries(self) -> None: + received: list[bytes] = [] + rejections: list[tuple[str, str]] = [] + self.transport.set_extension_control_handler(received.append) + self.transport.set_extension_control_rejection_handler( + lambda error, kind: rejections.append((error, kind)) + ) + payload = { + "domain": "self_extension", + "operation_count": 1, + "actions": [], + } + + self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, retain=True) + self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, dup=True) + + self.assertEqual(received, []) + status = self.transport.public_info()["extension_control"] + self.assertEqual(status["received_count"], 2) + self.assertEqual(status["dispatched_count"], 0) + self.assertEqual(status["rejected_count"], 2) + self.assertEqual(status["retained_count"], 1) + self.assertEqual(status["duplicate_count"], 1) + self.assertTrue(status["rejection_handler_configured"]) + self.assertEqual( + rejections, + [ + ("retained extension/ctl messages are not executable", "retained"), + ("duplicate extension/ctl delivery was ignored", "duplicate"), + ], + ) + + def test_mode_scene_uses_fixed_topic_exact_catalog_card_and_never_retains( + self, + ) -> None: + scene = { + "id": "mode-reading", + "label": "阅读模式", + "name": "阅读模式", + "background_time": "noon", + "description": "由自然语言生成的自定义模式。", + "icon": "bookmark-plus", + "aliases": ["阅读模式", "阅读"], + "origin": "user", + "deletable": True, + "behavior": "static", + "domains": ["climate"], + "operation_count": 1, + "scope": {"common": False, "vehicle_ids": ["denza_n7"]}, + "profile_revision": "1234567890abcdef", + "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, + } + ], + } + + publication = self.transport.publish_mode_scene(scene) + + topic, raw_payload, qos, retain = self.client.published[-1] + self.assertEqual(topic, MQTT_MODE_SCENE_TOPIC) + self.assertEqual(raw_payload, mqtt_mode_scene_payload(scene)) + self.assertEqual(json.loads(raw_payload), scene) + self.assertNotIn("scenes", json.loads(raw_payload)) + self.assertEqual(qos, 1) + self.assertFalse(retain) + self.assertEqual(publication["delivery_status"], "published") + + def test_mode_scene_rejects_catalog_wrapper(self) -> None: + with self.assertRaisesRegex(Exception, "one scene only"): + self.transport.publish_mode_scene( + { + "service": "n7-scenes", + "scene_count": 1, + "scenes": [{"id": "mode-reading"}], + } + ) + def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None: self.client.emit( "n7/n7-headunit/can/state", @@ -590,7 +772,10 @@ class MqttCanTransportTests(unittest.TestCase): ) self.assertEqual( self.client.subscriptions, - [("n7/n7-headunit/can/state", 1)], + [ + ("n7/n7-headunit/can/state", 1), + (MQTT_EXTENSION_CONTROL_TOPIC, 1), + ], ) def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None: diff --git a/tests/unit/test_nfc_frontend_contract.py b/tests/unit/test_nfc_frontend_contract.py index a50f9a2..64834f1 100644 --- a/tests/unit/test_nfc_frontend_contract.py +++ b/tests/unit/test_nfc_frontend_contract.py @@ -14,6 +14,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None: script = source("self-extension.js") styles = source("self-extension.css") html = source("index.html") + vite_entry = source("src/main.js") assert 'data-open-nfc-binding' in script assert '靠近读卡器后' in script @@ -61,7 +62,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None: assert '.v2-nfc-command-option' in styles assert '.v2-nfc-select-all' in styles assert 'self-extension.js?v=pet-settings-52' in html - assert 'self-extension.css?v=pet-settings-52' in html + assert 'import "../self-extension.css";' in vite_entry def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None: @@ -208,5 +209,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No assert "下发控制指令" in console assert "已解析控制指令" not in console assert 'li[data-can-channel="IR"]' in styles - assert "console-v2.js?v=local-first-70" in html + assert "console-v2.js?v=local-first-76" in html assert 'speech_semantic: "语音语义"' in console diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py index e060f9d..cf5a535 100644 --- a/tests/unit/test_requested_frontend_enhancements.py +++ b/tests/unit/test_requested_frontend_enhancements.py @@ -10,7 +10,11 @@ ROOT = Path(__file__).resolve().parents[2] def source(name: str) -> str: - return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8") + web_root = ROOT / "apps" / "web-console" + content = (web_root / name).read_text(encoding="utf-8") + if name == "index.html": + content += "\n" + (web_root / "src" / "App.vue").read_text(encoding="utf-8") + return content def test_frontend_only_exposes_user_orchestration_and_extension() -> None: @@ -827,8 +831,8 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non assert [commands[0]["deliveryLabel"] for commands in parsed] == [ "未下发", "未下发", - "下发", - "下发", + "已下发 · 待状态确认", + "已下发 · 待状态确认", "安全阻止", ] assert [commands[0]["deliveryState"] for commands in parsed] == [ @@ -848,6 +852,137 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non ] +def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() -> None: + node = shutil.which("node") + if node is None: + return + console = source("console-v2.js") + assert 'class="v2-can-command-delivery-detail"' in console + assert "状态说明" in console + start = console.index(" const normalizedCanTarget") + end = console.index("\n\n function renderParsedCanCommands", start) + helpers = console[start:end] + + def sample( + operation: dict, + *, + status: str, + message: str = "", + telemetry: dict | None = None, + ) -> dict: + return { + "execution_mode": "hardware", + "control_transport": "wireless_mqtt", + "function_configs": { + "sample": {"spec": {"operations": [operation]}} + }, + "execution_report": { + "spec": { + "results": [ + { + "ref": operation["id"], + "status": status, + "message": message, + "telemetry": telemetry or {}, + } + ] + } + }, + } + + samples = [ + sample( + { + "id": "window-blocked", + "capability": "window.set_position", + "target": {"zone": "front-right"}, + "parameters": {"position_pct": 20}, + }, + status="failed", + message="未收到有效且新鲜的 0x388 车窗位置,拒绝启动机构", + telemetry={"verification_status": "failed_or_blocked"}, + ), + sample( + { + "id": "infrared-dispatch-failed", + "capability": "infrared.emit", + "target": {"device": "reading-light", "name": "阅读灯"}, + "parameters": {"action": "开启", "slot": 2}, + }, + status="failed", + message="GSCU1B 尚未连接到本地 TCP 桥接", + telemetry={"delivery_status": "failed"}, + ), + sample( + { + "id": "infrared-aborted", + "capability": "infrared.emit", + "target": {"device": "reading-light", "name": "阅读灯"}, + "parameters": {"action": "切换为暖光", "slot": 5}, + }, + status="aborted", + message="前序红外指令失败,未执行", + telemetry={"delivery_status": "not_submitted"}, + ), + sample( + { + "id": "climate-verified", + "capability": "climate.set_power", + "target": {"zone": "vehicle"}, + "parameters": {"state": "on"}, + }, + status="succeeded", + telemetry={ + "delivery_status": "delivered", + "physical_effect_confirmed": True, + }, + ), + sample( + { + "id": "climate-post-submit-failed", + "capability": "climate.set_power", + "target": {"zone": "vehicle"}, + "parameters": {"state": "on"}, + }, + status="failed", + message="网关提交后执行失败", + telemetry={"delivery_status": "submitted"}, + ), + ] + script = ( + f"{helpers}\n" + f"const samples = {json.dumps(samples, ensure_ascii=False)};\n" + "process.stdout.write(JSON.stringify(samples.map((sample) => " + "parsedCanCommands(sample)[0])));" + ) + completed = subprocess.run( + [node, "-"], + input=script, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + + commands = json.loads(completed.stdout) + assert [item["deliveryLabel"] for item in commands] == [ + "安全阻止", + "下发失败", + "已中止", + "执行已确认", + "执行失败", + ] + assert [item["deliveryState"] for item in commands] == [ + "blocked", + "failed", + "aborted", + "submitted", + "failed", + ] + assert "0x388 实时位置" in commands[0]["deliveryDetail"] + assert commands[1]["deliveryDetail"] == "GSCU1B 尚未连接到本地 TCP 桥接" + + def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None: app = source("app.js") console = source("console-v2.js") @@ -870,6 +1005,12 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() assert 'executionMode: "dry_run"' in app assert 'clientType: "mode_editor_compile"' in app assert "previewOnly: true" in app + compile_flow = app.split( + "async function executeModeEditorInstruction", 1 + )[1].split("async function completeModeEditor", 1)[0] + assert "renderResult(" not in compile_flow + assert "cognitive-agent:validated-can-commands" not in compile_flow + assert "cognitive-agent:result" not in compile_flow assert 'const nonDispatchTaskSources = new Set(["mode_editor_compile"])' in console assert "if (isDispatchTask(task))" in console assert "下发控制指令" in console diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py index 4a797b2..4e3591b 100644 --- a/tests/unit/test_self_extension.py +++ b/tests/unit/test_self_extension.py @@ -943,7 +943,7 @@ class SelfExtensionTests(unittest.TestCase): self.assertFalse(result["physical_state_confirmed"]) self.assertEqual(len(result["device_response"]), 2) - def test_main_orchestration_resolves_only_live_bound_nfc_peripheral(self) -> None: + def test_main_orchestration_uses_durable_binding_after_card_is_removed(self) -> None: catalog = [ { "id": "cap-third-party-light-on", @@ -978,16 +978,25 @@ class SelfExtensionTests(unittest.TestCase): self.assertIsNone( resolve_active_peripheral_command("打开氛围灯", catalog, present) ) - self.assertIsNone( + self.assertEqual( resolve_active_peripheral_command( "打开第三方氛围灯", catalog, {"card": None} - ) + )["id"], + "cap-third-party-light-on", ) - self.assertIsNone( + self.assertEqual( resolve_active_peripheral_command( "打开第三方氛围灯", catalog, {"card": {"uid": "FFFFFFFF"}}, + )["id"], + "cap-third-party-light-on", + ) + self.assertIsNone( + resolve_active_peripheral_command( + "打开第三方氛围灯", + [{**catalog[0], "card_uid": ""}], + {"card": None}, ) ) @@ -1059,6 +1068,17 @@ class SelfExtensionTests(unittest.TestCase): ) self.assertEqual([item["id"] for item in selected], ["on", "red"]) + self.assertEqual( + [ + item["id"] + for item in active_peripheral_plan_catalog( + "打开第三方氛围灯,红灯闪烁5s后关闭", + catalog, + {"card": None}, + ) + ], + ["on", "red"], + ) self.assertEqual( active_peripheral_plan_catalog( "打开第三方氛围灯", @@ -1351,7 +1371,18 @@ class SelfExtensionTests(unittest.TestCase): ) self.assertEqual( [item["status"] for item in result["operation_results"]], - ["failed"], + ["failed", "aborted"], + ) + self.assertEqual( + result["operation_results"][1]["message"], + "前序红外指令失败,未执行", + ) + self.assertEqual( + result["operation_results"][1]["telemetry"]["delivery_status"], + "not_submitted", + ) + self.assertFalse( + result["operation_results"][1]["telemetry"]["transport_submitted"] ) def test_power_on_then_white_waits_800ms_and_continues_without_ack(self) -> None: diff --git a/tests/unit/test_spatial_ui_consistency_contract.py b/tests/unit/test_spatial_ui_consistency_contract.py index 6185c82..6b812bc 100644 --- a/tests/unit/test_spatial_ui_consistency_contract.py +++ b/tests/unit/test_spatial_ui_consistency_contract.py @@ -10,13 +10,18 @@ def source(name: str) -> str: def test_spatial_ui_contract_is_loaded_last_and_cached() -> None: - html = source("index.html") + main = source("src/main.js") service_worker = source("service-worker.js") - asset = "/ui-consistency.css?v=spatial-ui-5" + asset = "/ui-consistency.css?v=spatial-ui-8" - assert asset in html + assert 'import "../ui-consistency.css"' in main assert f'"{asset}"' in service_worker - assert html.index("/energy-monitor.css") < html.index(asset) + assert main.index('import "../energy-monitor.css"') < main.index( + 'import "../ui-consistency.css"' + ) + assert main.index('import "../ui-consistency.css"') < main.index( + 'import "./styles/design-system.css"' + ) def test_same_kind_surfaces_share_geometry_tokens() -> None: @@ -116,12 +121,16 @@ def test_pet_and_tcp_dialog_controls_keep_accessible_aligned_layout() -> None: def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None: styles = source("ui-consistency.css") + design_system = source("src/styles/design-system.css") assert ".v2-sync-execution-field," in styles assert ".v2-can-command-panel" in styles assert "border-radius: var(--ui-radius-item) !important" in styles assert ".v2-sync-log {" in styles assert "border-radius: var(--ui-radius-section) !important" in styles + assert "body.console-v2 .v2-sync-log h3" in design_system + assert "font-size: var(--n7-font-card-title) !important" in design_system + assert "white-space: nowrap" in design_system def test_assistant_composer_hint_stays_inside_narrow_input() -> None: @@ -133,9 +142,13 @@ def test_assistant_composer_hint_stays_inside_narrow_input() -> None: assert "max-width: calc(100% - 132px)" in styles -def test_failed_delivery_uses_one_semantic_status_treatment() -> None: +def test_delivery_failures_use_distinct_semantic_status_treatments() -> None: styles = source("ui-consistency.css") assert '[data-delivery-state="blocked"]' in styles assert '[data-delivery-state="failed"]' in styles + assert '[data-delivery-state="aborted"]' in styles + assert ".v2-can-command-delivery-detail" in styles assert "color: var(--destructive)" in styles + assert "color: var(--status-warning-foreground)" in styles + assert "color: var(--muted-foreground)" in styles diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py index bedce05..659aedd 100644 --- a/tests/unit/test_vue_frontend_contract.py +++ b/tests/unit/test_vue_frontend_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path @@ -11,25 +12,44 @@ def source(name: str) -> str: return (WEB / name).read_text(encoding="utf-8") -def test_vue_presentation_assets_are_local_ordered_and_cached() -> None: +def test_vue_vite_application_is_the_primary_frontend_entry() -> None: html = source("index.html") + main = source("src/main.js") + app = source("src/App.vue") + package = json.loads(source("package.json")) + vite = source("vite.config.js") service_worker = source("service-worker.js") - runtime = "/vendor/vue/3.5.21/vue.runtime.global.prod.js" - presentation = "/vue-ui.js?v=vue-presentation-3" - assert html.index(runtime) < html.index(presentation) < html.index( - "/app.js?v=dual-seat-140" - ) - assert html.index(presentation) < html.index( - "/assistant-chat.js?v=codex-assistant-33" - ) - assert f'"{runtime}"' in service_worker - assert f'"{presentation}"' in service_worker - assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker + assert '
' in html + assert '' in html + assert 'import { createApp, nextTick } from "vue"' in main + assert 'import App from "./App.vue"' in main + assert 'application.mount("#app")' in main + assert 'defineOptions({ name: "N7ConsoleApp" })' in app + assert 'class="workspace"' in app + assert package["dependencies"]["vue"] == "3.5.21" + assert package["scripts"]["build"] == "vite build" + assert 'import vue from "@vitejs/plugin-vue"' in vite + assert 'outDir: outputRoot' in vite + assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker assert "unpkg.com" not in html assert "cdn.jsdelivr.net" not in html +def test_python_and_one_click_startup_use_the_vite_distribution() -> None: + server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text( + encoding="utf-8" + ) + launcher = (ROOT / "tools" / "start_n7web.py").read_text(encoding="utf-8") + + assert 'WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"' in server + assert 'if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()' in server + assert 'parser.add_argument(\n "--skip-web-build"' in launcher + assert 'commands.append([str(npm), "ci"])' in launcher + assert 'commands.append([str(npm), "run", "build"])' in launcher + assert "_build_web_console(npm)" in launcher + + def test_vendored_vue_runtime_and_license_are_present() -> None: runtime = WEB / "vendor" / "vue" / "3.5.21" / "vue.runtime.global.prod.js" license_file = WEB / "vendor" / "vue" / "3.5.21" / "LICENSE" @@ -66,7 +86,7 @@ def test_vue_islands_render_modes_and_dispatched_controls() -> None: assert ".assistant-dispatched-controls-vue-host" in styles -def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None: +def test_existing_dom_renderers_remain_as_transition_fallbacks() -> None: app = source("app.js") assistant = source("assistant-chat.js") @@ -81,5 +101,7 @@ def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None: 'document.createElement("details")', command_vue_call ) assert command_fallback > command_vue_call - assert "renderDispatchedControls: () => false" in source("vue-ui.js") - assert "renderModeChips: () => false" in source("vue-ui.js") + vue_ui = source("vue-ui.js") + assert 'from "vue"' in vue_ui + assert "renderDispatchedControls: () => false" not in vue_ui + assert "renderModeChips: () => false" not in vue_ui diff --git a/tools/start_n7web.py b/tools/start_n7web.py index d7270c0..af025e4 100644 --- a/tools/start_n7web.py +++ b/tools/start_n7web.py @@ -7,6 +7,7 @@ import argparse import json import os import shlex +import shutil import subprocess import sys import time @@ -21,6 +22,7 @@ PYTHON_SOURCE_PATHS = ( REPOSITORY_ROOT / "src", REPOSITORY_ROOT / "domains" / "seat" / "src", ) +WEB_CONSOLE_ROOT = REPOSITORY_ROOT / "apps" / "web-console" @dataclass(frozen=True) @@ -90,6 +92,11 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="print the resolved commands without starting processes", ) + parser.add_argument( + "--skip-web-build", + action="store_true", + help="reuse the existing Vite dist output instead of rebuilding it", + ) return parser @@ -176,6 +183,37 @@ def _display_command(command: Sequence[str]) -> str: return shlex.join(command) +def _resolve_npm() -> Path: + executable = shutil.which("npm.cmd" if os.name == "nt" else "npm") + if not executable: + raise ValueError( + "npm is required to build the Vue 3 + Vite web console; " + "install Node.js or pass --skip-web-build to reuse an existing dist" + ) + return Path(executable).resolve() + + +def _web_build_commands(npm: Path) -> list[list[str]]: + commands: list[list[str]] = [] + vite_binary = WEB_CONSOLE_ROOT / "node_modules" / ".bin" / ( + "vite.cmd" if os.name == "nt" else "vite" + ) + if not vite_binary.is_file(): + commands.append([str(npm), "ci"]) + commands.append([str(npm), "run", "build"]) + return commands + + +def _build_web_console(npm: Path) -> None: + for command in _web_build_commands(npm): + print(f"Web build: {_display_command(command)}", flush=True) + completed = subprocess.run(command, cwd=WEB_CONSOLE_ROOT, check=False) + if completed.returncode != 0: + raise RuntimeError( + f"Vue 3 + Vite web build failed with exit code {completed.returncode}" + ) + + def _catalog_is_ready(path: Path) -> bool: try: document = json.loads(path.read_text(encoding="utf-8")) @@ -257,6 +295,7 @@ def run(argv: Sequence[str] | None = None) -> int: try: python = _resolve_python(args.python) catalog = _resolve_path(args.catalog) + npm = None if args.skip_web_build else _resolve_npm() if args.catalog_ready_timeout <= 0: raise ValueError("catalog-ready-timeout must be greater than zero") except ValueError as exc: @@ -269,9 +308,20 @@ def run(argv: Sequence[str] | None = None) -> int: 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 npm is not None: + for command in _web_build_commands(npm): + print(f"Web build: {_display_command(command)}", flush=True) if args.dry_run: return 0 + + if npm is not None: + try: + _build_web_console(npm) + except RuntimeError as exc: + print(f"startup failed: {exc}", file=sys.stderr, flush=True) + return 1 + children: list[ChildProcess] = [] interrupted = False try: