可控参数电源 · 温度 · 风量/风向
--
-
- 风量
- 未知
- 循环
- 未知
- A/C
- 未知
- 前除霜
- 未知
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 @@Cabin / reference_vehicle / dry-run
-可控参数电源 · 温度 · 风量/风向
-只读信号档位 · 车速 · 四门锁状态
-可控参数开关 / 停止 · 位置
- -可控参数阅读灯 · 氛围亮度 · 色号
- -可控参数四窗独立 · 开/关/停 · 位置
- -可控参数近/远光 · 雾灯 · 转向灯
- -# 等待生成 FunctionConfig
- --ModeDRY-RUN# 等待 CAN 编译
- 按来源与运行方式整理,快速定位并管理模式
-点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。
-修改自然语言后需要重新生成指令,才能点击完成永久保存。
- -