2 Commits

30 changed files with 5406 additions and 12595 deletions
+12 -50
View File
@@ -1,16 +1,15 @@
# Web Console # Web Console
Web 控制台提供一句话、快捷功能模式与手势控制入口,不在浏览器中生成 CAN。所有动作统一提交给后端编排、配置校验、安全门禁和执行链路。 Web 控制台是车机端的远程自编排与同步显示界面。网页保留一句话指令和预设功能模式入口,请求通过同源服务发送至车机执行;同时同步显示车机任务、编排结果、设备反馈和 USB 扩展能力。真实 CAN 与硬件执行权限仍由服务端安全门禁控制。
当前页面包括: 当前页面包括:
- 顶部“自编排 / 自标定 / 自扩展 / 自修复 / 自进化”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式,自进化同时面向 user 与 admin,以版本时间轴、改进证据和下一步计划呈现能力演进过程。 - 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。
- 六类外设玩具的添加、红外能力学习、测试发射、单动作执行与本地能力目录。 - 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
- 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。 - 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。 - 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
- 观影、午休、零重力快捷模式,以及当前副驾驶姿态的命名保存入口。 - USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
- 六阶段执行链路、整车联合孪生、领域状态、配置、CAN 计划和执行结果。 - 左侧运行日志集中显示网页/车机任务、链路、设备与外设同步事件。
- 右侧手势控制抽屉,包含摄像头画面、关键点骨架、保持进度、动作速查与自定义映射。
UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。 UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。
@@ -54,49 +53,12 @@ TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的
自动重试。`/api/extension/health` 的 `listening` 表示服务器已监听,只有收到 自动重试。`/api/extension/health` 的 `listening` 表示服务器已监听,只有收到
真实设备会话后 `connected`/`peer_connected` 才会变为 `true`。 真实设备会话后 `connected`/`peer_connected` 才会变为 `true`。
## N7 自标定 车机与网页同步使用以下同源接口:
自标定前端、原始 Android `n7_seat.glb`、四轴 Hall 标定协议以及 - `GET /api/vehicle-display-sync`:网页轮询最新车机任务、结果、外设目录与扩展链路状态。
CAN/MQTT/RTSP 后端均由当前服务同源提供,不再启动原 `8766` 工作站或使用 - `POST /api/requests`:车机执行时附带 `client_type: "head_unit"`;网页控制台附带 `client_type: "web_console"`。两类任务的执行结果都会写入同步快照。
iframe。左侧“执行目标”复用自编排下拉控件,默认显示“真实台架”,也可以 - `POST /api/extension/display-sync`:车机刚识别 USB 外设时上报 `device` 与 `peripherals` 能力目录,无需等待首次执行。
切换到“标定仿真台架”。选择目标后仍需显式点击连接,服务启动不会自动打开 - `POST /api/extension/assistant/plan` 和 `/api/extension/vehicle/execute`:联动规划与车端执行结果同样写入同步快照。
真实 CAN 适配器。
标定 API 位于 `/api/calibration/*`,包含状态流、CAN 连接、四轴/单轴标定、
绝对位置、端点、全轴 STOP、MQTT、RTSP 和连接设置。仿真台架实现同一套
CRC、ACK、ARM、心跳租约和 STOP 协议边界,并生成双目位姿数据;它与自编排
的 `dry_run`、`simulated_hardware` 执行模式相互独立。真实 ZLG CAN 连接复用
仓库现有的 ZCANPRO 32 位 bridge,实机运动接口另外限制为台架本机访问。
MQTT 与通用 CAN 依赖可安装为:
```powershell
python -m pip install -e ".[can,ir]"
```
RTSP 画面需要将 `ffmpeg.exe` 加入 `PATH`;未安装 FFmpeg 时,CAN、MQTT、
标定和 3D 数字孪生不受影响。
## 座椅自修复
原 `manual_hil` 工程中的六项量产软件故障闭环、硬件支路诊断、72 针连接器
定义(其中 21 针启用)、ZCANPRO bridge 以及固件资格检查已迁入当前服务,
统一使用 `/api/self-repair/*`,不再启动独立前端或 HTTP 服务。自修复入口始终
显示;user 点击入口后自动切换为 admin,停留在自修复期间权限选择器锁定。
后端对所有自修复 API 再次执行 admin 校验,实物诊断、实物故障注入、归零、
STOP ALL、日志目录和固件操作还要求台架本机决策点。
软件自修复默认使用 `shadow`,仅推进内存中的故障注入、AI 诊断、恢复和复测
状态机,不会写入 ECU。页面左侧显示六项量产故障用例及总体进度,中间程序化
3D 座椅同步四轴与热管理状态,右侧五节点以深蓝、浅蓝、灰色分别表示已完成、
进行中和待执行。硬件视图提供 CAN 分层状态、功能支路、JA1/JB1/JC1 物理针脚
排布与证据详情。
所有机器相关路径均通过环境变量配置,完整清单及示例见
[`config/self-repair/README.md`](../../config/self-repair/README.md)。未配置固件、
IAR 或 ZCANPRO 路径时,对应实物操作保持禁用;服务导入与启动不会自动拉起
ZCANPRO。只有显式设置 `SELF_REPAIR_AUTO_START_BRIDGE=true`,或管理员启动
需要 bridge 的实物操作时,服务才会尝试运行配置的扩展脚本。
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入 公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
`/login`,主控制台提供退出按钮。固定入口是 `/login`,主控制台提供退出按钮。固定入口是
+21 -52
View File
@@ -1553,44 +1553,8 @@ const TWIN_THEME_PALETTES = {
lampEmissive: 0x16aaa1, lampEmissive: 0x16aaa1,
lampEdge: 0x096d67, lampEdge: 0x096d67,
}, },
admin: {
accent: 0x315eaa,
accentStrong: 0x234785,
accentSoft: 0xe1e9fa,
sceneBackground: 0xe8eef8,
floor: 0xdfe6f3,
gridStrong: 0x7190c2,
gridSoft: 0xbdc9df,
fillLight: 0xa9bfe4,
vehicleOutline: 0x315eaa,
vehicleOccluded: 0x9eafd0,
seatShell: 0x315eaa,
seatInsert: 0xc9d8f2,
seatThermal: 0x91b2e4,
seatBolster: 0x234785,
seatStructure: 0x1d2f52,
seatEdge: 0x88a9df,
seatSeam: 0xe1e9fa,
glass: 0x477bc4,
glassEmissive: 0x1f427c,
glassEdge: 0x234f91,
glassGlow: 0x86b3ff,
roofClear: 0x7299d5,
roofDark: 0x1d3c72,
lamp: 0x315eaa,
lampEmissive: 0x5688dc,
lampEdge: 0x244a86,
},
}; };
function currentTwinThemeRole() {
if (document.body.dataset.module === "self-extension") return "user";
return document.body.dataset.demoRole === "admin"
|| sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin"
: "user";
}
function createTwinGroundTexture(THREE, palette, size = 768) { function createTwinGroundTexture(THREE, palette, size = 768) {
const textureCanvas = document.createElement("canvas"); const textureCanvas = document.createElement("canvas");
textureCanvas.width = size; textureCanvas.width = size;
@@ -1685,11 +1649,10 @@ function applyTwinGroundTheme(twin, palette) {
if (previousTexture && previousTexture !== nextTexture) previousTexture.dispose(); if (previousTexture && previousTexture !== nextTexture) previousTexture.dispose();
} }
function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) { function applyTwinVehicleTheme(systems) {
if (!systems) return; if (!systems) return;
const themeRole = role === "admin" ? "admin" : "user"; const palette = TWIN_THEME_PALETTES.user;
const palette = TWIN_THEME_PALETTES[themeRole]; systems.themeRole = "user";
systems.themeRole = themeRole;
systems.themePalette = palette; systems.themePalette = palette;
systems.colors.roofClear.setHex(palette.roofClear); systems.colors.roofClear.setHex(palette.roofClear);
systems.colors.roofDark.setHex(palette.roofDark); systems.colors.roofDark.setHex(palette.roofDark);
@@ -1774,13 +1737,10 @@ function applyTwinSeatTheme(model, palette) {
}); });
} }
function applyTwinSceneTheme(twin, role = currentTwinThemeRole()) { function applyTwinSceneTheme(twin) {
if (!twin) return; if (!twin) return;
const themeRole = document.body.dataset.module === "self-extension" const palette = TWIN_THEME_PALETTES.user;
? "user" applyTwinVehicleTheme(twin.vehicleSystems);
: role === "admin" ? "admin" : "user";
const palette = TWIN_THEME_PALETTES[themeRole];
applyTwinVehicleTheme(twin.vehicleSystems, themeRole);
twin.scene.background.setHex(palette.sceneBackground); twin.scene.background.setHex(palette.sceneBackground);
if (twin.scene.fog?.color) twin.scene.fog.color.setHex(palette.sceneBackground); if (twin.scene.fog?.color) twin.scene.fog.color.setHex(palette.sceneBackground);
applyTwinGroundTheme(twin, palette); applyTwinGroundTheme(twin, palette);
@@ -1797,11 +1757,11 @@ function applyTwinSceneTheme(twin, role = currentTwinThemeRole()) {
} }
})); }));
// Controllable glass and lamp entities use stronger semantic variants from // Controllable glass and lamp entities use stronger semantic variants from
// the same role palette after the neutral vehicle outline pass above. // the same palette after the neutral vehicle outline pass above.
applyTwinVehicleTheme(twin.vehicleSystems, themeRole); applyTwinVehicleTheme(twin.vehicleSystems);
const canvas = twin.renderer?.domElement; const canvas = twin.renderer?.domElement;
if (canvas) { if (canvas) {
canvas.dataset.themeRole = themeRole; canvas.dataset.themeRole = "user";
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`; canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
} }
} }
@@ -3942,9 +3902,6 @@ function initSeatTwin() {
pointer: { x: 0, y: 0 }, pointer: { x: 0, y: 0 },
}; };
applyTwinSceneTheme(state.seatTwin); applyTwinSceneTheme(state.seatTwin);
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
applyTwinSceneTheme(state.seatTwin, event.detail?.role);
});
window.addEventListener("cognitive-agent:module-change", () => { window.addEventListener("cognitive-agent:module-change", () => {
applyTwinSceneTheme(state.seatTwin); applyTwinSceneTheme(state.seatTwin);
}); });
@@ -6495,6 +6452,7 @@ async function submitRequest(event) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
utterance, utterance,
client_type: "web_console",
provider: state.provider, provider: state.provider,
conversation_id: state.conversationId, conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
@@ -6657,6 +6615,17 @@ window.CognitiveAgentConsole = {
getResult() { getResult() {
return state.result; return state.result;
}, },
applySyncedResult(result, { utterance = "" } = {}) {
if (!result || typeof result !== "object") return false;
if (result.conversation_id) {
state.conversationId = String(result.conversation_id);
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
}
const input = $("#utterance");
if (input && utterance && document.activeElement !== input) input.value = String(utterance);
renderResult(result);
return true;
},
notify(message) { notify(message) {
showToast(String(message)); showToast(String(message));
}, },
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-104
View File
@@ -557,7 +557,6 @@ async function loadGestureRuntime() {
state.runtime = await response.json(); state.runtime = await response.json();
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction); state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
renderMappingGrid(); renderMappingGrid();
renderGestureDebugOptions();
if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源"); if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源");
} }
@@ -658,99 +657,6 @@ function renderMappingGrid() {
select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => `<option value="${value}">${item.label}</option>`).join(""); select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => `<option value="${value}">${item.label}</option>`).join("");
} }
function renderGestureDebugOptions() {
const select = $("#gestureDebugSelect");
if (!select || !state.runtime) return;
const tokens = [...new Set([
"OPEN_PALM",
"CLOSED_FIST",
...Object.keys(state.runtime.actions || {}),
])];
select.innerHTML = tokens.map((token) => {
const action = mappingForToken(token);
const actionLabel = action ? ACTIONS[action]?.label || action : token === "OPEN_PALM" ? "激活控制" : "停止当前动作";
return `<option value="${token}">${TOKEN_NAMES[token] || token} → ${actionLabel}</option>`;
}).join("");
}
function setGestureDebugOpen(open) {
const panel = $("#gestureDebugPanel");
const button = $("#gestureArmButton");
if (!panel || !button) return;
const allowed = document.body.dataset.demoRole === "admin";
const nextOpen = allowed && open;
panel.hidden = !nextOpen;
button.classList.toggle("active", nextOpen);
button.setAttribute("aria-expanded", String(nextOpen));
}
function toggleGestureDebug() {
const panel = $("#gestureDebugPanel");
setGestureDebugOpen(panel?.hidden ?? true);
}
function syncGestureDebugRole(role = document.body.dataset.demoRole) {
const button = $("#gestureArmButton");
if (!button) return;
const isAdmin = role === "admin";
if (!isAdmin) setGestureDebugOpen(false);
button.disabled = !isAdmin;
button.classList.toggle("v2-decorative-control", !isAdmin);
if (isAdmin) {
button.removeAttribute("aria-hidden");
button.removeAttribute("tabindex");
button.setAttribute("aria-controls", "gestureDebugPanel");
button.setAttribute("aria-expanded", String(!$("#gestureDebugPanel")?.hidden));
button.setAttribute("aria-label", "手势调试");
button.setAttribute("title", "手势调试");
} else {
button.setAttribute("aria-hidden", "true");
button.setAttribute("tabindex", "-1");
button.removeAttribute("aria-controls");
button.removeAttribute("aria-expanded");
button.removeAttribute("aria-label");
button.removeAttribute("title");
}
}
function sendSimulatedGesture() {
if (document.body.dataset.demoRole !== "admin") {
setGestureDebugOpen(false);
notify("仅管理员可发送模拟手势");
return;
}
const token = $("#gestureDebugSelect")?.value;
if (!token || !state.engine) {
notify("手势调试能力尚未就绪");
return;
}
const tokenName = TOKEN_NAMES[token] || token;
$("#gestureDetected").textContent = `模拟 · ${tokenName}`;
if (token === "OPEN_PALM") {
state.engine.setArmed(true);
$("#gestureExpected").textContent = "激活控制";
notify("已发送模拟张掌手势");
return;
}
if (token === "CLOSED_FIST") {
state.engine.stopMotion("管理员模拟握拳 · 已停止当前动作");
$("#gestureExpected").textContent = "停止当前动作";
notify("已发送模拟握拳手势");
return;
}
const action = mappingForToken(token);
if (!action || !ACTIONS[action]) {
$("#gestureExpected").textContent = "未绑定动作";
notify("该模拟手势尚未绑定动作");
return;
}
state.lastRecognizedToken = token;
$("#gestureExpected").textContent = ACTIONS[action].label;
$("#gestureSafetyText").textContent = `管理员模拟手势:${tokenName}`;
executeGestureAction(action);
notify(`已发送模拟手势:${tokenName}`);
}
function updateCustomizeToken() { function updateCustomizeToken() {
const token = state.lastRecognizedToken; const token = state.lastRecognizedToken;
$("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势"; $("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势";
@@ -811,27 +717,17 @@ document.addEventListener("DOMContentLoaded", async () => {
$("#gestureCloseButton")?.addEventListener("click", closeDrawer); $("#gestureCloseButton")?.addEventListener("click", closeDrawer);
$("#gestureBackdrop")?.addEventListener("click", closeDrawer); $("#gestureBackdrop")?.addEventListener("click", closeDrawer);
$("#gestureStartButton")?.addEventListener("click", toggleCamera); $("#gestureStartButton")?.addEventListener("click", toggleCamera);
$("#gestureArmButton")?.addEventListener("click", toggleGestureDebug);
$("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false)); $("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false));
$("#gestureDebugSend")?.addEventListener("click", sendSimulatedGesture);
$("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize); $("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize);
$("#gestureBindButton")?.addEventListener("click", bindCurrentGesture); $("#gestureBindButton")?.addEventListener("click", bindCurrentGesture);
$("#gestureResetMapping")?.addEventListener("click", resetMapping); $("#gestureResetMapping")?.addEventListener("click", resetMapping);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
if (!$("#gestureDebugPanel")?.hidden) {
setGestureDebugOpen(false);
return;
}
if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer(); if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer();
}); });
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
syncGestureDebugRole(event.detail?.role);
});
document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); }); document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); });
window.addEventListener("beforeunload", stopCamera); window.addEventListener("beforeunload", stopCamera);
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange); navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
syncGestureDebugRole();
let runtimeError = null; let runtimeError = null;
try { try {
await loadGestureRuntime(); await loadGestureRuntime();
+8 -23
View File
@@ -8,27 +8,20 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" /> <link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 整车智能控制台</title> <title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" /> <link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-107" /> <link rel="stylesheet" href="/console-v2.css?v=sync-layout-26" />
<link rel="stylesheet" href="/self-evolution.css?v=seat-weighing-5" /> <link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
<link rel="stylesheet" href="/self-extension.css?v=extension-19" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-12" />
<link rel="stylesheet" href="/self-repair.css?v=repair-21" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script> <script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script> <script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script> <script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script> <script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" defer></script> <script src="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=dual-seat-82" defer></script> <script src="/app.js?v=dual-seat-85" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-7"></script> <script type="module" src="/gesture-control.js?v=android-pad-8"></script>
<script src="/pwa.js?v=android-pad-1" defer></script> <script src="/pwa.js?v=android-pad-2" defer></script>
<script src="/console-v2.js?v=desktop-refactor-36" defer></script> <script src="/console-v2.js?v=sync-layout-26" defer></script>
<script src="/self-evolution.js?v=seat-weighing-2" defer></script> <script src="/self-extension.js?v=sync-layout-26" defer></script>
<script src="/self-extension.js?v=extension-21" defer></script>
<script src="/self-calibration.js?v=calibration-8" defer></script>
<script src="/self-repair.js?v=repair-21" defer></script>
<script src="/self-repair-seat3d.js?v=repair-21" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -607,15 +600,7 @@
<div class="gesture-actions"> <div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button> <button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureArmButton" type="button" aria-controls="gestureDebugPanel" aria-expanded="false"><i data-lucide="bug-play"></i><span>手势调试</span></button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button> <button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
<div class="gesture-debug-panel" id="gestureDebugPanel" hidden>
<div class="gesture-debug-heading"><span>管理员手势调试</span><small>无需开启摄像头</small></div>
<div class="gesture-debug-controls">
<select id="gestureDebugSelect" aria-label="选择模拟手势"></select>
<button id="gestureDebugSend" type="button"><i data-lucide="send"></i>发送模拟手势</button>
</div>
</div>
</div> </div>
<section class="gesture-guide-card"> <section class="gesture-guide-card">
+2
View File
@@ -17,7 +17,9 @@
try { try {
const registration = await navigator.serviceWorker.register("/service-worker.js", { const registration = await navigator.serviceWorker.register("/service-worker.js", {
scope: "/", scope: "/",
updateViaCache: "none",
}); });
await registration.update();
if (registration.waiting) showUpdate(registration); if (registration.waiting) showUpdate(registration);
registration.addEventListener("updatefound", () => { registration.addEventListener("updatefound", () => {
const worker = registration.installing; const worker = registration.installing;
File diff suppressed because it is too large Load Diff
-665
View File
@@ -1,665 +0,0 @@
(function () {
"use strict";
const API_ROOT = "/api/calibration";
const TARGET_KEY = "cognitive-agent.calibration-target";
const pageTitles = {
live: ["SELF-CALIBRATION", "座椅与双目联合标定", "同步标定座椅姿态、四轴 Hall 与 ZED 实时画面"],
system: ["SELF-CALIBRATION · SETTINGS", "连接与运行设置", "配置 CAN、MQTT 与 RTSP 接入参数"],
};
const phaseLabels = {
0: "待命",
1: "寻找最近点",
2: "寻找最远点",
3: "返回最近点",
4: "完成",
5: "失败",
6: "已停止",
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
let currentState = null;
let eventSource = null;
let reconnectTimer = 0;
let toastTimer = 0;
let settingsHydrated = false;
let activeView = "live";
let bootstrapResetComplete = false;
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher || $("#v2SelfCalibration")) return false;
const calibrationSidebar = document.createElement("section");
calibrationSidebar.className = "v2-sidebar-section v2-calibration-sidebar";
calibrationSidebar.innerHTML = `
<div class="v2-section-heading">
<span>CALIBRATION DESK</span>
<h2>控制台</h2>
</div>
<span class="v2-field-caption">标定工作台</span>
<div class="v2-calibration-mode-switch" role="group" aria-label="标定工作台">
<button type="button" class="active" data-calibration-view="live"><i data-lucide="scan-eye"></i><span>联合实时标定</span></button>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">配置信息</span>
<div class="v2-calibration-config-stack">
<div class="v2-calibration-config-card"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span>N7</span></div>
<div class="v2-calibration-config-card"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong>GPT-5.6-sol</strong></div><span>READY</span></div>
<div class="v2-calibration-config-card v2-config-select-card" id="calibrationTargetCard">
<i data-lucide="workflow"></i>
<div class="v2-config-card-copy">
<small>执行目标</small>
<div class="v2-config-card-select">
<select id="calibrationExecutionSelect" aria-label="选择自标定执行目标">
<option value="hardware">真实台架</option>
<option value="simulation">标定仿真台架</option>
</select>
</div>
</div>
<span id="calibrationTargetState">IDLE</span>
</div>
</div>
<p class="v2-calibration-target-note" id="calibrationTargetNote">选择执行目标后连接台架</p>
<button class="v2-calibration-connect" id="calibrationConnectTarget" type="button"><i data-lucide="plug-zap"></i><span>连接真实台架</span></button>
<button class="v2-calibration-settings-link" id="calibrationSettingsLink" type="button"><i data-lucide="settings-2"></i><span>连接设置</span><i data-lucide="chevron-right"></i></button>`;
sidebar.insertBefore(calibrationSidebar, $(".v2-sidebar-footer"));
const calibrationFooter = document.createElement("footer");
calibrationFooter.className = "v2-sidebar-footer v2-calibration-footer";
calibrationFooter.innerHTML = `
<span class="v2-live-dot"></span>
<div><strong>自标定安全链路</strong><small id="calibrationConnectionText">尚未连接台架</small></div>
<button type="button" id="calibrationGlobalStop" title="全轴安全停止" aria-label="全轴安全停止"><i data-lucide="octagon"></i></button>`;
sidebar.append(calibrationFooter);
const root = document.createElement("section");
root.className = "v2-self-calibration";
root.id = "v2SelfCalibration";
root.setAttribute("aria-label", "N7 自标定工作台");
root.innerHTML = `
<header class="v2-calibration-heading">
<div class="v2-module-heading">
<span class="section-kicker" id="calibrationPageKicker">SELF-CALIBRATION</span>
<h1 id="calibrationPageTitle">座椅与双目联合标定</h1>
<p id="calibrationPageDescription">同步标定座椅姿态、四轴 Hall 与 ZED 实时画面</p>
</div>
<div class="v2-calibration-statuses">
<div><i id="calibrationCanDot"></i><span><small>CAN</small><strong id="calibrationCanState">未连接</strong></span></div>
<div><i id="calibrationMotorDot"></i><span><small>MOTOR</small><strong id="calibrationMotorState">等待状态</strong></span></div>
<div><i id="calibrationMqttDot"></i><span><small>MQTT</small><strong id="calibrationMqttState">等待数据</strong></span></div>
</div>
</header>
<section class="v2-calibration-view active v2-calibration-live-view" data-calibration-page="live">
<div class="v2-calibration-live-grid">
<section class="v2-calibration-panel v2-calibration-stage">
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>实时座椅姿态</h2></div><span class="v2-calibration-badge wait" id="calibrationPhaseBadge">待命</span></header>
<div class="v2-calibration-seat-scene">
<div class="v2-calibration-floor-grid"></div>
<div id="calibrationSeatViewer" class="seat-model-host calibration-cartoon-viewer">
<canvas id="calibrationSeatCanvas" aria-label="自标定三维座椅,拖动空白处旋转视角,拖动轴标签或座椅部件调节四轴"></canvas>
<div id="seatModelLoading" class="model-loading">
<span class="model-spinner"></span>
<strong>正在构建座椅数字孪生</strong>
<small>CALIBRATION · CARTOON TWIN</small>
</div>
<div class="orbit-hint">拖动空白处旋转 · 拖动轴标签或座椅部件调轴 · 滚轮缩放 · 双击复位</div>
</div>
<svg class="v2-calibration-label-lines" id="calibrationSeatLabelLines" aria-hidden="true">
<line data-seat-label-line="backrest"></line>
<line data-seat-label-line="longitudinal"></line>
<line data-seat-label-line="legrest"></line>
<line data-seat-label-line="height"></line>
</svg>
<div class="v2-axis-tag tag-back" data-seat-axis-label="backrest" role="slider" tabindex="0" aria-label="拖动调节靠背" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>靠背 <b id="calibrationTagBack">0.0°</b></span></div>
<div class="v2-axis-tag tag-base" data-seat-axis-label="longitudinal" role="slider" tabindex="0" aria-label="拖动调节前后位置" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>前后 <b id="calibrationTagLong">0.00 cm</b></span></div>
<div class="v2-axis-tag tag-leg" data-seat-axis-label="legrest" role="slider" tabindex="0" aria-label="拖动调节腿托" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>腿托 <b id="calibrationTagLeg">0.0°</b></span></div>
<div class="v2-axis-tag tag-height" data-seat-axis-label="height" role="slider" tabindex="0" aria-label="拖动调节高度" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>高度 <b id="calibrationTagHeight">0.00 cm</b></span></div>
</div>
<div class="v2-calibration-operation">
<i></i><div><small>当前任务</small><strong id="calibrationOperationLabel">等待设备检测</strong></div><span>JOB <b id="calibrationJobId">—</b></span>
</div>
</section>
<section class="v2-calibration-panel v2-calibration-video-panel">
<header><div><span class="section-kicker">ZED LIVE · RTSP</span><h2>ZED 实时画面</h2></div><span class="v2-calibration-badge wait" id="calibrationVideoBadge">未启动</span></header>
<div class="v2-calibration-video-frame"><img id="calibrationVideoImage" alt="ZED 双目实时画面"><div id="calibrationVideoPlaceholder"><i data-lucide="scan-line"></i><strong>ZED 双目画面</strong><small>RTSP → MJPEG 实时转发</small></div></div>
<p class="v2-calibration-detail" id="calibrationVideoDetail">视频接收尚未启动</p>
<div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div>
</section>
</div>
<section class="v2-calibration-panel v2-calibration-axis-section v2-calibration-live-axes">
<header class="v2-calibration-live-toolbar">
<div class="v2-calibration-axis-title"><span class="section-kicker">LIVE AXES</span><h2>四轴实时控制</h2><small>滑块设定位置 · 端点按钮单次执行</small></div>
<div class="v2-calibration-flow" aria-label="自动标定流程">
<div data-phase="1"><span>01</span><div><strong>最近点</strong><small>建立机械零位</small></div></div>
<i></i>
<div data-phase="2"><span>02</span><div><strong>最远点</strong><small>测量完整量程</small></div></div>
<i></i>
<div data-phase="3"><span>03</span><div><strong>返回近点</strong><small>验证端点一致</small></div></div>
</div>
<button class="v2-calibration-primary" id="calibrationStartAll" type="button" disabled><span>开始四轴自动标定</span><i data-lucide="arrow-right"></i></button>
</header>
<div class="v2-calibration-axis-grid" id="calibrationAxisCards"></div>
<div class="v2-calibration-safety v2-calibration-live-safety"><i data-lucide="shield-check"></i><p><strong>安全租约已启用</strong><span>运动期间每 500 ms 心跳;超时由固件自动 STOP。</span></p></div>
</section>
</section>
<section class="v2-calibration-view" data-calibration-page="system" hidden>
<div class="v2-calibration-settings-grid">
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationCanForm">
<header><span>CAN</span><div><small>TRANSPORT</small><h2>CAN 适配器</h2></div></header>
<p class="v2-calibration-form-note">执行目标由左侧下拉菜单选择。</p>
<div class="v2-calibration-two-col"><label>接口<input id="calibrationCanInterface" value="zlgcan"></label><label>通道<input id="calibrationCanChannel" value="0"></label></div>
<label>位速率<input id="calibrationCanBitrate" value="500000" readonly></label>
<label>ZCANPRO Python<input id="calibrationCanPython" value="C:\\Program Files (x86)\\ZCANPRO\\python38\\python.exe"></label>
<label>ZLG DLL<input id="calibrationCanDll" value="C:\\Program Files (x86)\\ZCANPRO\\zlgcan.dll"></label>
<div class="v2-calibration-two-col"><label>设备类型<input id="calibrationCanDeviceType" type="number" value="41"></label><label>设备序号<input id="calibrationCanDeviceIndex" type="number" value="0"></label></div>
<button class="v2-calibration-primary" type="submit">保存并连接台架</button>
<p class="v2-calibration-detail" id="calibrationTransportDetail"></p>
</form>
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationMqttForm">
<header><span>MQ</span><div><small>STEREO DATA</small><h2>MQTT Broker</h2></div></header>
<div class="v2-calibration-two-col"><label>主机<input id="calibrationMqttHost"></label><label>端口<input id="calibrationMqttPort" type="number"></label></div>
<label>主题<input id="calibrationMqttTopic"></label>
<button type="submit">保存 MQTT 设置</button>
<p class="v2-calibration-form-note">正式数据主题默认:zed/seatpose</p>
</form>
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationVideoForm">
<header><span>RT</span><div><small>VIDEO</small><h2>RTSP 视频</h2></div></header>
<div class="v2-calibration-two-col"><label>主机<input id="calibrationVideoHost"></label><label>端口<input id="calibrationVideoPort" type="number"></label></div>
<label>流路径<input id="calibrationVideoPath"></label>
<button type="submit">保存 RTSP 设置</button>
<p class="v2-calibration-form-note" id="calibrationFfmpegHint"></p>
</form>
</div>
</section>`;
moduleSwitcher.insertAdjacentElement("afterend", root);
const target = $("#calibrationExecutionSelect");
target.value = sessionStorage.getItem(TARGET_KEY) === "simulation"
? "simulation"
: "hardware";
window.CognitiveAgentConsoleV2?.enhanceSelect(target);
return true;
}
function isAdmin() {
return window.CognitiveAgentConsoleV2?.getRole() === "admin"
|| document.body.dataset.demoRole === "admin";
}
function isActive() {
return isAdmin() && document.body.dataset.module === "self-calibration";
}
function toast(message, error = false) {
const node = $("#toast");
if (!node) return;
node.textContent = message;
node.className = error ? "toast show error" : "toast show";
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
node.className = "toast";
}, 3600);
}
async function api(path, body = {}) {
const response = await fetch(`${API_ROOT}${path}`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Cognitive-Agent-Role": "admin",
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({
message: `HTTP ${response.status}`,
}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
if (data.state) render(data.state);
return data;
}
async function fetchState() {
const response = await fetch(`${API_ROOT}/state`, {
credentials: "same-origin",
headers: {
Accept: "application/json",
"X-Cognitive-Agent-Role": "admin",
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
render(data);
return data;
}
async function run(action, success = "") {
try {
await action();
if (success) toast(success);
} catch (error) {
toast(error.message || String(error), true);
}
}
function statusClass(state) {
if (["online", "subscribed", "simulated", "playing"].includes(state)) return "online";
if (["connecting", "idle"].includes(state)) return "wait";
if (state === "error") return "error";
return "";
}
function setStatusDot(selector, state) {
const node = $(selector);
if (node) node.className = statusClass(state);
}
function render(state) {
if (!state?.transport || !state?.controller) return;
currentState = state;
const { transport, controller, mqtt } = state;
setStatusDot("#calibrationCanDot", transport.state);
setStatusDot(
"#calibrationMotorDot",
controller.motor_ready ? "online" : transport.state === "online" ? "connecting" : transport.state,
);
setStatusDot("#calibrationMqttDot", mqtt.state);
$("#calibrationCanState").textContent = transport.state === "online"
? transport.mode === "simulation" ? "仿真在线" : "实机在线"
: transport.state === "connecting" ? "连接中"
: transport.state === "error" ? "连接异常" : "未连接";
$("#calibrationMotorState").textContent = controller.motor_ready
? controller.armed ? "已解锁" : "已就绪"
: "等待状态";
$("#calibrationMqttState").textContent = mqtt.state === "simulated"
? "仿真数据"
: mqtt.state === "subscribed" ? "已订阅"
: mqtt.state === "connecting" ? "连接中" : "未连接";
$("#calibrationPhaseBadge").textContent =
state.operation.phase_name || phaseLabels[state.operation.phase] || "待命";
$("#calibrationPhaseBadge").className = `v2-calibration-badge ${
[5, 6].includes(state.operation.phase)
? "error"
: state.operation.phase === 0 ? "wait" : ""
}`;
$("#calibrationOperationLabel").textContent = state.operation.label;
$("#calibrationJobId").textContent = controller.job_id || "—";
$("#calibrationTransportDetail").textContent = transport.detail;
$("#calibrationConnectionText").textContent = transport.detail;
const targetState = $("#calibrationTargetState");
targetState.textContent = transport.state === "online" ? "READY"
: transport.state === "connecting" ? "LINK" : transport.state === "error" ? "ERROR" : "IDLE";
$("#calibrationTargetCard").dataset.state = transport.state;
$("#calibrationTargetNote").textContent = transport.detail;
renderWorkflow(state.operation.phase);
renderAxes(state.axes, controller.job_active, transport.state === "online");
renderSeat(state.axes);
const seatViewer = $("#calibrationSeatViewer");
const axisControlEnabled = transport.state === "online" && !controller.job_active;
if (seatViewer) {
seatViewer.dataset.axisControlEnabled = String(axisControlEnabled);
}
$$('[data-seat-axis-label]').forEach((label) => {
label.dataset.controlEnabled = String(axisControlEnabled);
label.setAttribute("aria-disabled", String(!axisControlEnabled));
});
renderVideo(state.video);
$("#calibrationStartAll").disabled =
transport.state !== "online" || controller.job_active;
if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available);
}
function renderWorkflow(phase) {
$$(".v2-calibration-flow > div").forEach((step) => {
const value = Number(step.dataset.phase);
step.classList.toggle("active", phase === value);
step.classList.toggle("done", phase === 4 || (phase > value && phase < 5));
});
}
function createAxisCards(axes) {
const container = $("#calibrationAxisCards");
if (!container) return;
axes.forEach((axis) => {
const card = document.createElement("article");
card.className = "v2-calibration-axis-card";
card.dataset.axis = axis.id;
card.innerHTML = `
<header><span>AXIS 0${axis.id + 1}</span><strong></strong><b>就绪</b></header>
<div class="v2-calibration-axis-reading"><strong>0</strong><span>HALL 0 / 0</span></div>
<input type="range" min="0" max="1000" value="0" aria-label="${axis.name}位置">
<div class="v2-calibration-range-labels"><span>近端 0%</span><span>远端 100%</span></div>
<div class="v2-calibration-axis-actions"><button data-seek="near" type="button">近端</button><button data-axis-calibrate type="button">标定</button><button data-seek="far" type="button">远端</button></div>`;
$("header strong", card).textContent = axis.title;
const slider = $('input[type="range"]', card);
slider.addEventListener("input", () => {
$(".v2-calibration-axis-reading strong", card).textContent =
formatPhysical(axis, Number(slider.value));
});
slider.addEventListener("change", () => run(
() => api("/move", { axis: axis.id, permille: Number(slider.value) }),
));
$('[data-seek="near"]', card).addEventListener("click", () => run(
() => api("/seek", { axis: axis.id, far: false }),
));
$('[data-seek="far"]', card).addEventListener("click", () => run(
() => api("/seek", { axis: axis.id, far: true }),
));
$("[data-axis-calibrate]", card).addEventListener("click", () => run(
() => api("/start", { axis: axis.id }),
`${axis.name}自动标定已启动`,
));
container.append(card);
});
}
function renderAxes(axes, busy, online) {
if (!$("#calibrationAxisCards")?.children.length) createAxisCards(axes);
axes.forEach((axis) => {
const card = $(`.v2-calibration-axis-card[data-axis="${axis.id}"]`);
if (!card) return;
const slider = $('input[type="range"]', card);
if (document.activeElement !== slider) slider.value = axis.permille;
$(".v2-calibration-axis-reading strong", card).textContent =
formatPhysical(axis, Number(slider.value));
$(".v2-calibration-axis-reading span", card).textContent =
`HALL ${axis.hall} / ${axis.range || "—"}`;
const state = $("header b", card);
state.textContent = axis.fault ? "故障"
: axis.calibrating ? "标定中"
: axis.moving ? axis.direction
: axis.position_valid ? "就绪" : "位置未知";
state.className = axis.moving || axis.calibrating ? "moving" : "";
slider.disabled = busy || !online;
$$(".v2-calibration-axis-actions button", card).forEach((button) => {
button.disabled = busy || !online;
});
});
}
function formatPhysical(axis, permille) {
const value = axis.physical_range * permille / 1000;
return axis.unit === "cm" ? `${value.toFixed(2)} ${axis.unit}` : `${value.toFixed(1)}${axis.unit}`;
}
function renderSeat(axes) {
if (!axes?.length) return;
const longitudinal = axes.find((axis) => axis.id === 0) || axes[0];
const legrest = axes.find((axis) => axis.id === 1) || axes[1];
const backrest = axes.find((axis) => axis.id === 2) || axes[2];
const height = axes.find((axis) => axis.id === 3) || axes[3];
const pose = {
longitudinalM:
(longitudinal.permille / 1000 - 0.5) * longitudinal.physical_range * 0.01,
verticalM: (0.5 - height.permille / 1000) * height.physical_range * 0.01,
backrestDeg: backrest.physical_value,
legrestDeg: legrest.physical_value,
};
window.n7SeatPose = pose;
window.dispatchEvent(new CustomEvent("n7-seat-pose", { detail: pose }));
window.dispatchEvent(new CustomEvent("calibration-seat-state", {
detail: {
axes: [
{ motor: 0, position_pct: longitudinal.permille / 10 },
{ motor: 1, position_pct: height.permille / 10 },
{ motor: 2, position_pct: backrest.permille / 10 },
{ motor: 3, position_pct: legrest.permille / 10 },
],
state: axes.some((axis) => axis.fault)
? "FAILED"
: axes.some((axis) => axis.moving || axis.calibrating) ? "WORKING" : "IDLE",
},
}));
$("#calibrationTagLong").textContent = `${longitudinal.physical_value.toFixed(2)} cm`;
$("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`;
$("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`;
$("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`;
[
["longitudinal", longitudinal],
["legrest", legrest],
["backrest", backrest],
["height", height],
].forEach(([name, axis]) => {
const label = $(`[data-seat-axis-label="${name}"]`);
if (!label) return;
label.setAttribute("aria-valuenow", String(Math.round(axis.permille)));
label.setAttribute("aria-valuetext", formatPhysical(axis, axis.permille));
});
}
function previewSeatAxis(event) {
if (!currentState?.axes?.length) return;
const viewer = $("#calibrationSeatViewer");
if (viewer?.dataset.axisControlEnabled !== "true") return;
const axisId = Number(event.detail?.axis);
const permille = Math.max(0, Math.min(1000, Number(event.detail?.permille)));
if (!Number.isInteger(axisId) || !Number.isFinite(permille)) return;
const axes = currentState.axes.map((axis) => ({ ...axis }));
const axis = axes.find((item) => item.id === axisId);
if (!axis) return;
axis.permille = permille;
axis.physical_value = axis.physical_range * permille / 1000;
const card = $(`.v2-calibration-axis-card[data-axis="${axisId}"]`);
if (card) {
const slider = $('input[type="range"]', card);
slider.value = String(permille);
$(".v2-calibration-axis-reading strong", card).textContent = formatPhysical(axis, permille);
}
renderSeat(axes);
}
function commitSeatAxis(event) {
const viewer = $("#calibrationSeatViewer");
if (viewer?.dataset.axisControlEnabled !== "true") return;
const axis = Number(event.detail?.axis);
const permille = Math.max(0, Math.min(1000, Math.round(Number(event.detail?.permille))));
if (!Number.isInteger(axis) || !Number.isFinite(permille)) return;
run(() => api("/move", { axis, permille }));
}
function renderVideo(video) {
const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" };
$("#calibrationVideoBadge").textContent = friendly[video.state] || video.state;
$("#calibrationVideoBadge").className = `v2-calibration-badge ${
video.state === "error" ? "error" : video.state === "idle" ? "wait" : ""
}`;
$("#calibrationVideoDetail").textContent = video.detail;
const image = $("#calibrationVideoImage");
if (video.state === "playing" && !image.classList.contains("playing")) {
image.src = `${API_ROOT}/video/stream?role=admin&t=${Date.now()}`;
image.classList.add("playing");
}
if (!["playing", "connecting"].includes(video.state)) {
image.classList.remove("playing");
image.removeAttribute("src");
}
}
function hydrateSettings(settings, ffmpegAvailable) {
settingsHydrated = true;
$("#calibrationCanInterface").value = settings.can.interface;
$("#calibrationCanChannel").value = settings.can.channel;
$("#calibrationCanBitrate").value = settings.can.bitrate;
$("#calibrationCanPython").value = settings.can.python_executable || "";
$("#calibrationCanDll").value = settings.can.dll_path || "";
$("#calibrationCanDeviceType").value = settings.can.device_type ?? 41;
$("#calibrationCanDeviceIndex").value = settings.can.device_index ?? 0;
$("#calibrationMqttHost").value = settings.mqtt.host;
$("#calibrationMqttPort").value = settings.mqtt.port;
$("#calibrationMqttTopic").value = settings.mqtt.topic;
$("#calibrationVideoHost").value = settings.video.host;
$("#calibrationVideoPort").value = settings.video.port;
$("#calibrationVideoPath").value = settings.video.path;
$("#calibrationFfmpegHint").textContent = ffmpegAvailable
? "已检测到 FFmpeg,可转发 RTSP。"
: "未检测到 FFmpeg;其余功能不受影响。";
}
function collectSettings() {
return {
can: {
mode: $("#calibrationExecutionSelect").value,
interface: $("#calibrationCanInterface").value.trim(),
channel: $("#calibrationCanChannel").value.trim(),
bitrate: Number($("#calibrationCanBitrate").value),
python_executable: $("#calibrationCanPython").value.trim(),
dll_path: $("#calibrationCanDll").value.trim(),
device_type: Number($("#calibrationCanDeviceType").value),
device_index: Number($("#calibrationCanDeviceIndex").value),
resistance: 1,
},
mqtt: {
host: $("#calibrationMqttHost").value.trim(),
port: Number($("#calibrationMqttPort").value),
topic: $("#calibrationMqttTopic").value.trim(),
},
video: {
host: $("#calibrationVideoHost").value.trim(),
port: Number($("#calibrationVideoPort").value),
path: $("#calibrationVideoPath").value.trim(),
},
};
}
function showView(view) {
activeView = pageTitles[view] ? view : "live";
$$(".v2-calibration-view").forEach((page) => {
const active = page.dataset.calibrationPage === activeView;
page.hidden = !active;
page.classList.toggle("active", active);
});
$$("[data-calibration-view]").forEach((button) => {
button.classList.toggle("active", button.dataset.calibrationView === activeView);
});
$("#calibrationSettingsLink").classList.toggle("active", activeView === "system");
const [kicker, title, description] = pageTitles[activeView];
$("#calibrationPageKicker").textContent = kicker;
$("#calibrationPageTitle").textContent = title;
$("#calibrationPageDescription").textContent = description;
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
}
function updateTargetPresentation() {
const simulation = $("#calibrationExecutionSelect").value === "simulation";
sessionStorage.setItem(TARGET_KEY, simulation ? "simulation" : "hardware");
$("#calibrationConnectTarget span").textContent =
simulation ? "连接标定仿真台架" : "连接真实台架";
if (!currentState || currentState.transport.state === "disconnected") {
$("#calibrationTargetNote").textContent =
simulation ? "完整 CAN 协议仿真,不驱动实体设备" : "本机 admin 可连接真实 CAN 台架";
}
}
function connectTarget() {
const settings = collectSettings();
return run(async () => {
await api("/settings", settings);
await api("/can/connect", { can: settings.can });
}, settings.can.mode === "simulation" ? "标定仿真台架已连接" : "真实台架连接完成");
}
function closeEvents() {
window.clearTimeout(reconnectTimer);
reconnectTimer = 0;
eventSource?.close();
eventSource = null;
}
function connectEvents() {
if (!isActive() || eventSource) return;
eventSource = new EventSource(`${API_ROOT}/events?role=admin`);
eventSource.onmessage = (event) => {
try {
render(JSON.parse(event.data));
} catch (error) {
console.error("自标定状态解析失败", error);
}
};
eventSource.onerror = () => {
closeEvents();
if (isActive()) reconnectTimer = window.setTimeout(connectEvents, 1600);
};
}
function activate() {
if (!isActive()) {
closeEvents();
return;
}
fetchState().then(async (state) => {
if (bootstrapResetComplete) return;
bootstrapResetComplete = true;
if (state.transport?.mode === "simulation") {
await api("/session/reset");
}
}).catch((error) => toast(`自标定服务连接失败:${error.message}`, true));
connectEvents();
}
function bindEvents() {
$$("[data-calibration-view]").forEach((button) => {
button.addEventListener("click", () => showView(button.dataset.calibrationView));
});
$("#calibrationSettingsLink").addEventListener("click", () => showView("system"));
$("#calibrationExecutionSelect").addEventListener("change", updateTargetPresentation);
$("#calibrationConnectTarget").addEventListener("click", connectTarget);
$("#calibrationGlobalStop").addEventListener("click", () => run(
() => api("/stop"),
"全轴 STOP 已确认",
));
$("#calibrationStartAll").addEventListener("click", () => run(
() => api("/start", { axis: "all" }),
"四轴自动标定已启动",
));
$("#calibrationVideoStart").addEventListener("click", () => run(
() => api("/video/start"),
"正在启动 RTSP 接收",
));
$("#calibrationVideoStop").addEventListener("click", () => run(
() => api("/video/stop"),
"视频接收已停止",
));
$("#calibrationCanForm").addEventListener("submit", (event) => {
event.preventDefault();
connectTarget();
});
$("#calibrationMqttForm").addEventListener("submit", (event) => {
event.preventDefault();
run(() => api("/settings", collectSettings()), "MQTT 设置已保存");
});
$("#calibrationVideoForm").addEventListener("submit", (event) => {
event.preventDefault();
run(() => api("/settings", collectSettings()), "RTSP 设置已保存");
});
window.addEventListener("cognitive-agent:module-change", activate);
window.addEventListener("cognitive-agent:demo-role-change", activate);
window.addEventListener("calibration-seat-axis-preview", previewSeatAxis);
window.addEventListener("calibration-seat-axis-commit", commitSeatAxis);
window.addEventListener("beforeunload", closeEvents);
}
function init() {
if (!buildShell()) return;
bindEvents();
updateTargetPresentation();
showView(activeView);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
activate();
}
document.addEventListener("DOMContentLoaded", init);
}());
File diff suppressed because it is too large Load Diff
-494
View File
@@ -1,494 +0,0 @@
(function () {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const familyModels = [
{
id: "user-a",
name: "用户 A",
code: "USER-A",
version: "v1.7",
state: "enabled",
stateLabel: "已启用",
statusNote: "专属模型已启用",
error: 0.18,
samples: 8,
sampleTarget: 11,
sync: "2026.08.14 · 08:29",
errors: [0.50, 0.44, 0.38, 0.32, 0.27, 0.23, 0.20, 0.18],
parameters: [
{ name: "零点偏移补偿", user: "已优化", before: "+0.38", after: "+0.11", progress: 88 },
{ name: "坐姿角度补偿", user: "稳定", before: "0.64", after: "0.91", progress: 91 },
{ name: "压力分布权重", user: "已优化", before: "0.72", after: "0.93", progress: 93 },
{ name: "温度漂移补偿", user: "学习中", before: "42%", after: "76%", progress: 76 },
],
events: [
{ time: "08.14 08:29", title: "模型 v1.7 验证通过并启用", meta: "有效样本 8", result: "±0.22 → ±0.18 kg", state: "已发布" },
{ time: "08.14 08:27", title: "家庭称重与座椅采样配对", meta: "身份匹配成功", result: "采样质量 96%", state: "已进入学习" },
{ time: "08.12 19:06", title: "模型 v1.6 参数更新", meta: "有效样本 7", result: "±0.24 → ±0.22 kg", state: "已验证" },
],
adminReference: "家庭体脂秤 62.40 kg · 座椅估计 62.58 kg",
},
{
id: "user-b",
name: "用户 B",
code: "USER-B",
version: "v1.3",
state: "learning",
stateLabel: "学习中",
statusNote: "4 / 6 个有效样本",
error: 0.31,
samples: 4,
sampleTarget: 6,
sync: "2026.08.13 · 20:18",
errors: [0.50, 0.43, 0.37, 0.31],
parameters: [
{ name: "零点偏移补偿", user: "学习中", before: "+0.41", after: "+0.24", progress: 62 },
{ name: "坐姿角度补偿", user: "学习中", before: "0.61", after: "0.76", progress: 58 },
{ name: "压力分布权重", user: "已优化", before: "0.69", after: "0.82", progress: 70 },
{ name: "温度漂移补偿", user: "等待样本", before: "18%", after: "34%", progress: 34 },
],
events: [
{ time: "08.13 20:22", title: "新参数验证未优于 v1.3,保持旧模型", meta: "本次未参与发布", result: "±0.31 → ±0.34 kg", state: "已回滚", tone: "warning" },
{ time: "08.13 20:19", title: "检测到坐姿不稳定", meta: "异常样本已隔离", result: "采样质量 61%", state: "未参与更新", tone: "warning" },
{ time: "08.10 07:51", title: "第 4 组有效样本完成配对", meta: "身份匹配成功", result: "±0.37 → ±0.31 kg", state: "学习中" },
],
adminReference: "家庭体脂秤 71.25 kg · 异常座椅估计 71.59 kg",
},
{
id: "user-c",
name: "用户 C",
code: "USER-C",
version: "—",
state: "cold",
stateLabel: "冷启动",
statusNote: "等待首次家庭称重",
error: 0.50,
samples: 0,
sampleTarget: 6,
sync: "尚未同步",
errors: [],
parameters: [
{ name: "零点偏移补偿", user: "等待数据", before: "—", after: "—", progress: 0 },
{ name: "坐姿角度补偿", user: "等待数据", before: "—", after: "—", progress: 0 },
{ name: "压力分布权重", user: "等待数据", before: "—", after: "—", progress: 0 },
{ name: "温度漂移补偿", user: "等待数据", before: "—", after: "—", progress: 0 },
],
events: [
{ time: "等待中", title: "尚无可用的车家配对样本", meta: "需要先在家庭体脂秤完成称重", result: "使用通用模型", state: "±0.50 kg" },
],
adminReference: "尚无家庭体脂秤参考值",
},
{
id: "guest",
name: "访客",
code: "GENERIC",
version: "通用",
state: "generic",
stateLabel: "通用模型",
statusNote: "未识别用户使用通用模型",
error: 0.50,
samples: 0,
sampleTarget: 0,
sync: "无需同步",
errors: [0.50],
parameters: [
{ name: "零点偏移补偿", user: "通用参数", before: "0.00", after: "0.00", progress: 50 },
{ name: "坐姿角度补偿", user: "通用参数", before: "0.50", after: "0.50", progress: 50 },
{ name: "压力分布权重", user: "通用参数", before: "0.68", after: "0.68", progress: 50 },
{ name: "温度漂移补偿", user: "通用参数", before: "50%", after: "50%", progress: 50 },
],
events: [
{ time: "当前策略", title: "未识别用户不创建专属模型", meta: "不保存个人参考体重", result: "通用模型基线", state: "±0.50 kg" },
],
adminReference: "访客模式不记录精确体重",
},
];
const systemEntries = [
{
version: "v0.5", date: "2026.08.12", status: "当前版本", title: "统一主题与权限体验",
summary: "把权限、主题与核心交互收束到同一套体验规则,让不同模块看起来、用起来都属于同一个系统。",
changes: [["主题统一", "自扩展固定使用 user 绿色主题,避免权限语义混用。"], ["孪生同步", "3D 模型渲染、玩具动作和车端动作统一跟随当前主题。"], ["能力扩充", "新增自进化入口,让 user 与 admin 共同查看演进过程。"]],
modules: ["自扩展", "数字孪生", "权限体系"], metrics: [["主题一致性", "72%", "100%", 100], ["可见能力模块", "4 个", "5 个", 92], ["待修正视觉项", "4 项", "0 项", 100]],
userValidation: "核心板块已完成主题复核,权限色与交互反馈保持一致。", adminValidation: "前端契约 11/11 · 自标定/自修复检查 8/8",
},
{
version: "v0.4", date: "2026.08.11", status: "稳定版本", title: "自修复闭环增强",
summary: "从发现异常走向诊断、修复与验证闭环,同时补齐离线场景的默认内容,确保功能状态始终可见。",
changes: [["故障覆盖", "补充六类量产故障用例,覆盖更真实的车端异常。"], ["修复闭环", "形成发现、定位、决策、执行、验证五阶段流程。"], ["离线韧性", "服务暂时离线时仍保留默认案例和演示路径。"]],
modules: ["自修复", "故障诊断", "离线体验"], metrics: [["故障用例", "2 类", "6 类", 86], ["修复阶段", "3 阶段", "5 阶段", 92], ["离线可用性", "空白", "可演示", 100]],
userValidation: "断开服务后仍可理解自修复能力,并可继续浏览默认案例。", adminValidation: "默认数据回退、流程状态与接口异常路径均已验证。",
},
{
version: "v0.3", date: "2026.08.10", status: "能力升级", title: "外设与车端统一编排",
summary: "将玩具动作、车端设备动作与数字孪生放进同一个控制流程,减少跨区域理解和操作成本。",
changes: [["动作融合", "玩具动作与车端设备动作进入统一动作界面。"], ["布局优化", "孪生界面位于左侧,动作界面固定在右侧。"], ["映射精简", "移除重复外框,让实时映射关系更直接。"]],
modules: ["自扩展", "外设学习", "整车控制"], metrics: [["动作入口", "2 处", "1 处", 100], ["映射层级", "3 层", "2 层", 88], ["协同对象", "玩具", "玩具 + 车端", 94]],
userValidation: "动作与孪生左右分区清晰,学习结果可以直接用于统一控制。", adminValidation: "外设连接状态、动作映射与车端执行链路保持独立可追踪。",
},
{
version: "v0.2", date: "2026.08.09", status: "体系建立", title: "多权限主题体系",
summary: "建立 user 与 admin 的视觉身份,使权限差异不只体现在可用功能,也能被用户直观感知。",
changes: [["角色主题", "user 使用绿色,admin 使用蓝色,形成稳定的权限认知。"], ["能力边界", "按角色控制模块入口,同时保留清晰的切换反馈。"], ["模型联动", "数字孪生模型与界面主题保持同色语义。"]],
modules: ["权限体系", "主题系统", "数字孪生"], metrics: [["角色主题", "1 套", "2 套", 100], ["权限反馈", "文字", "视觉 + 文字", 90], ["主题联动", "界面", "界面 + 模型", 96]],
userValidation: "绿色主题贯穿 user 可用能力,角色身份更加明确。", adminValidation: "角色切换事件可同步驱动界面变量与模型渲染参数。",
},
{
version: "v0.1", date: "2026.08.08", status: "起点版本", title: "整车认知控制台诞生",
summary: "完成从自然语言意图到整车状态回读的第一条闭环,建立后续能力持续进化的共同底座。",
changes: [["自然语言控制", "用贴近日常表达的指令控制座舱功能。"], ["整车孪生", "把座椅、空调、车窗等状态集中映射到一个界面。"], ["执行回读", "串联 CAN 计划、设备执行与状态反馈。"]],
modules: ["自编排", "数字孪生", "CAN 执行"], metrics: [["能力模块", "0 个", "1 个", 100], ["控制链路", "分散", "闭环", 92], ["状态视图", "单设备", "整车", 96]],
userValidation: "自然语言、执行过程与结果反馈首次形成连续体验。", adminValidation: "意图解析、CAN 编译计划与硬件回读具备完整追踪链路。",
},
];
let playbackTimer = null;
let activeModelId = "user-a";
let visibleSamples = null;
function make(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
}
function formatError(value) {
return `±${Number(value).toFixed(2)}`;
}
function memberButtons() {
return familyModels.map((model, index) => `
<button type="button" class="v2-family-model${index === 0 ? " active" : ""}" data-family-model="${model.id}" aria-pressed="${index === 0 ? "true" : "false"}">
<span class="v2-family-marker" aria-hidden="true"></span>
<span class="v2-family-copy"><span><strong>${model.name}</strong><em data-model-state="${model.state}">${model.stateLabel}</em><small>${model.version}</small></span><small>${model.statusNote}</small></span>
<span class="v2-family-error"><strong>${formatError(model.error)}</strong><small>kg</small></span>
</button>`).join("");
}
function systemVersionButtons() {
return systemEntries.map((entry, index) => `
<button type="button" class="v2-evolution-version${index === 0 ? " active" : ""}" data-system-evolution-index="${index}" aria-pressed="${index === 0 ? "true" : "false"}">
<span><strong>${entry.version}</strong><time datetime="${entry.date.replaceAll(".", "-")}">${entry.date.slice(5)}</time></span>
<b>${entry.title}</b><small>${entry.status}</small>
</button>`).join("");
}
function shellTemplate() {
return `
<div class="v2-evolution-shell">
<header class="v2-evolution-overview">
<div class="v2-evolution-heading v2-module-heading">
<div class="v2-evolution-title-row">
<div><span class="section-kicker" id="v2EvolutionKicker">SELF-EVOLUTION · SEAT WEIGHING</span><h1 id="v2EvolutionPageTitle">座椅称重模型进化</h1></div>
<span class="v2-demo-data" id="v2EvolutionDataBadge"><i data-lucide="flask-conical"></i>演示数据</span>
</div>
<p id="v2EvolutionPageIntro">通过车家联动持续校准家庭成员专属模型,让称重误差从 ±0.50 kg 逐步收敛至 ±0.10 kg。</p>
</div>
<dl class="v2-evolution-summary" id="v2EvolutionSummary" aria-label="当前称重模型概览"></dl>
</header>
<section class="v2-evolution-page active" data-evolution-page="weighing">
<div class="v2-weighing-workspace">
<aside class="v2-family-panel" aria-label="家庭成员专属模型">
<div class="v2-family-heading"><span>FAMILY MODELS</span><strong>家庭成员模型</strong></div>
<div class="v2-family-list">${memberButtons()}</div>
<div class="v2-privacy-note"><i data-lucide="shield-check"></i><p>每个家庭成员拥有独立参数空间。精确体重仅向本人或已授权管理员展示。</p></div>
<div class="v2-model-state-guide" aria-label="模型状态说明"><span data-state="cold">冷启动</span><span data-state="learning">学习中</span><span data-state="enabled">已启用</span><span data-state="warning">退化回滚</span></div>
</aside>
<article class="v2-weighing-detail" aria-live="polite">
<header class="v2-weighing-detail-heading">
<div><span class="v2-model-badge" id="v2ModelStateBadge"></span><span class="v2-model-code" id="v2ModelCode"></span></div>
<div class="v2-home-sync"><strong><i></i><span id="v2HomeSyncState">车家同步正常</span></strong><small id="v2HomeSyncTime"></small></div>
</header>
<div class="v2-model-title-row">
<div><h2 id="v2ModelTitle"></h2><p id="v2ModelLead"></p></div>
<button type="button" class="v2-play-evolution" id="v2PlayEvolution"><i data-lucide="play"></i><span>演示进化过程</span></button>
</div>
<div class="v2-weighing-core">
<section class="v2-convergence-card" aria-labelledby="v2ConvergenceTitle">
<header><h3 id="v2ConvergenceTitle">验证误差收敛趋势</h3><div class="v2-chart-legend"><span data-line="baseline">通用模型</span><span data-line="personal">专属模型</span><span data-line="target">目标误差</span></div></header>
<div class="v2-chart-wrap" id="v2EvolutionChart"></div>
<p class="v2-sample-insight" id="v2SampleInsight"></p>
</section>
<section class="v2-linkage-card" aria-labelledby="v2LinkageTitle">
<header><h3 id="v2LinkageTitle">本轮车家联动闭环</h3><span id="v2LinkageProgress"></span></header>
<ol id="v2LinkageSteps"></ol>
<div class="v2-linkage-guard" id="v2LinkageGuard"></div>
</section>
</div>
<section class="v2-parameter-section" aria-labelledby="v2ParameterTitle">
<header><h3 id="v2ParameterTitle">模型参数持续进化</h3><p><span data-evolution-scope="user">展示可解释参数状态</span><span data-evolution-scope="admin">管理员可见参数前后值与发布保障</span></p></header>
<div class="v2-parameter-grid" id="v2ParameterGrid"></div>
</section>
<section class="v2-model-events" aria-labelledby="v2EventsTitle">
<header><h3 id="v2EventsTitle">最近进化记录</h3><p><span data-evolution-scope="user">仅显示与当前用户相关的状态</span><span data-evolution-scope="admin" id="v2AdminReference"></span></p></header>
<div id="v2EventList"></div>
</section>
</article>
</div>
</section>
<section class="v2-evolution-page" data-evolution-page="system" hidden>
<div class="v2-evolution-workspace">
<aside class="v2-evolution-timeline" aria-label="系统版本进化时间轴"><div class="v2-evolution-timeline-heading"><span>EVOLUTION PATH</span><strong>从起点到现在</strong></div><div class="v2-evolution-version-list">${systemVersionButtons()}</div></aside>
<article class="v2-evolution-detail" aria-live="polite">
<header class="v2-evolution-detail-heading"><div><span class="v2-evolution-status" id="v2SystemStatus"></span><span class="v2-evolution-detail-version" id="v2SystemVersion"></span></div><time id="v2SystemDate"></time></header>
<h2 id="v2SystemTitle"></h2><p class="v2-evolution-lead" id="v2SystemLead"></p>
<div class="v2-evolution-detail-grid">
<section class="v2-evolution-changes"><h3><i data-lucide="git-commit-horizontal"></i> 本次进化</h3><div id="v2SystemChangeList"></div></section>
<section class="v2-evolution-evidence"><h3><i data-lucide="trending-up"></i> 变好的证据</h3><div id="v2SystemMetrics"></div></section>
</div>
<footer class="v2-evolution-validation"><div class="v2-evolution-module-tags" id="v2SystemModules"></div><p><i data-lucide="badge-check"></i><span data-evolution-scope="user" id="v2SystemUserValidation"></span><span data-evolution-scope="admin" id="v2SystemAdminValidation"></span></p></footer>
</article>
</div>
</section>
<footer class="v2-evolution-next" id="v2EvolutionFooter">
<span class="v2-evolution-next-icon"><i data-lucide="flag"></i></span>
<div><small id="v2MilestoneKicker">NEXT MILESTONE · TARGET ±0.10 kg</small><strong id="v2MilestoneTitle">继续积累高质量车家配对样本</strong><p id="v2MilestoneCopy"></p></div>
<div class="v2-milestone-progress"><strong id="v2MilestoneCount"></strong><span><i id="v2MilestoneBar"></i></span><small id="v2MilestonePercent"></small></div>
</footer>
</div>`;
}
function summaryTemplate(model, view) {
if (view === "system") {
return `<div class="v2-evolution-current"><dt>当前版本</dt><dd>v0.5</dd><small>能力融合期</small></div><div><dt>累计迭代</dt><dd>5</dd><small>个版本</small></div><div><dt>能力改进</dt><dd>12</dd><small>项记录</small></div><div><dt>最近更新</dt><dd>08.12</dd><small>2026</small></div>`;
}
return `<div><dt>当前用户</dt><dd>${model.name}</dd><small>${model.state === "generic" ? "隐私访客模式" : model.statusNote}</small></div><div class="v2-evolution-current"><dt>当前验证误差</dt><dd>${formatError(model.error)}</dd><small>kg · 演示数据</small></div><div><dt>目标误差</dt><dd>±0.10</dd><small>kg</small></div><div><dt>有效样本</dt><dd>${model.samples}</dd><small>${model.sampleTarget ? `组车家配对` : "无需配对"}</small></div>`;
}
function chartTemplate(model, count = model.errors.length) {
const values = model.errors.slice(0, count);
const width = 720;
const left = 48;
const right = 698;
const top = 18;
const bottom = 184;
const y = (error) => top + ((0.52 - error) / 0.44) * (bottom - top);
const x = (index) => values.length <= 1 ? left : left + (index / (Math.max(model.errors.length, 2) - 1)) * (right - left);
const grid = [0.5, 0.4, 0.3, 0.2, 0.1].map((error) => `
<g class="v2-chart-grid"><line x1="${left}" y1="${y(error)}" x2="${right}" y2="${y(error)}"></line><text x="0" y="${y(error) + 3}">±${error.toFixed(2)}</text></g>`).join("");
const points = values.map((error, index) => `${x(index)},${y(error)}`).join(" ");
const circles = values.map((error, index) => `
<g class="v2-chart-point${index === values.length - 1 ? " current" : ""}" data-sample-index="${index}" tabindex="0" role="button" aria-label="样本 ${index + 1},验证误差 ${formatError(error)} kg">
<circle cx="${x(index)}" cy="${y(error)}" r="5"></circle><circle class="hit" cx="${x(index)}" cy="${y(error)}" r="13"></circle>
</g>`).join("");
const labels = model.errors.map((_, index) => `<text x="${left + (index / (Math.max(model.errors.length, 2) - 1)) * (right - left)}" y="207">样本${index + 1}</text>`).join("");
const area = values.length > 1 ? `<polygon class="v2-chart-area" points="${left},${bottom} ${points} ${x(values.length - 1)},${bottom}"></polygon>` : "";
const line = values.length > 1 ? `<polyline class="v2-personal-line" points="${points}"></polyline>` : "";
const current = values.length ? values[values.length - 1] : null;
return `<svg viewBox="0 0 ${width} 216" role="img" aria-label="${model.name} 专属称重模型验证误差收敛曲线">
${grid}<line class="v2-baseline-line" x1="${left}" y1="${y(0.5)}" x2="${right}" y2="${y(0.5)}"></line><line class="v2-target-line" x1="${left}" y1="${y(0.1)}" x2="${right}" y2="${y(0.1)}"></line>
${area}${line}${circles}<g class="v2-chart-labels">${labels}</g>
${current != null ? `<g class="v2-current-label" transform="translate(${Math.min(x(values.length - 1) - 58, 630)} ${Math.max(y(current) - 31, 4)})"><rect width="70" height="24" rx="12"></rect><text x="35" y="16">当前 ${formatError(current)}</text></g>` : ""}
</svg>${values.length ? "" : `<div class="v2-chart-empty"><i data-lucide="scale"></i><strong>等待首组车家配对样本</strong><span>当前继续使用通用模型 ±0.50 kg</span></div>`}`;
}
function linkageTemplate(model) {
const baseSteps = [
["家庭体脂秤", "收到用户参考体重"], ["用户 ID 匹配", "家庭账号与车端身份一致"], ["座椅稳定采样", "坐姿、温度、座椅位置质量合格"], ["专属参数更新", "生成候选参数并离线验证"], ["验证并发布", "优于旧模型后正式生效"],
];
let done = model.state === "enabled" ? 5 : model.state === "learning" ? 3 : 0;
if (model.state === "generic") done = 0;
const list = baseSteps.map(([title, copy], index) => {
const state = index < done ? "done" : index === done && model.state === "learning" ? "active" : "pending";
const label = state === "done" ? "已完成" : state === "active" ? "进行中" : "等待";
return `<li data-step-state="${state}"><span>${index + 1}</span><p><strong>${title}</strong><small>${copy}</small></p><em>${label}</em></li>`;
}).join("");
const guard = model.id === "user-b"
? `<i data-lucide="rotate-ccw"></i><p><strong>退化保护已触发</strong><span>候选模型误差上升至 ±0.34 kg,已自动回滚并保留 v1.3。</span></p>`
: model.state === "enabled"
? `<i data-lucide="shield-check"></i><p><strong>发布保护正常</strong><span>只有验证结果优于当前模型时才会发布新参数。</span></p>`
: `<i data-lucide="info"></i><p><strong>专属模型尚未发布</strong><span>样本不足时保持通用模型,不以未验证参数影响称重。</span></p>`;
return { list, guard, done };
}
function parameterTemplate(model) {
return model.parameters.map((item) => `
<article class="v2-parameter-card">
<div><strong>${item.name}</strong><span data-evolution-scope="user">${item.user}</span><span data-evolution-scope="admin"><em>${item.before}</em><i data-lucide="arrow-right"></i><b>${item.after}</b></span></div>
<small data-evolution-scope="user">${item.user === "等待数据" ? "等待有效车家样本" : "根据近期有效样本持续校准"}</small><small data-evolution-scope="admin">参数窗口 8 组 · 可回滚 · MODEL ${model.version}</small>
<span class="v2-parameter-progress" style="--parameter-progress:${item.progress}%"><i></i></span>
</article>`).join("");
}
function eventTemplate(model) {
return model.events.map((event) => `
<article class="v2-model-event${event.tone === "warning" ? " warning" : ""}"><time>${event.time}</time><strong>${event.title}</strong><span>${event.meta}</span><span>${event.result}</span><em>${event.state}</em></article>`).join("");
}
function renderModel(root, modelId, { keepPlayback = false } = {}) {
const model = familyModels.find((item) => item.id === modelId) || familyModels[0];
activeModelId = model.id;
if (!keepPlayback) {
clearInterval(playbackTimer);
playbackTimer = null;
visibleSamples = null;
$("#v2PlayEvolution", root)?.classList.remove("playing");
const playLabel = $("#v2PlayEvolution span", root);
if (playLabel) playLabel.textContent = "演示进化过程";
}
$("#v2EvolutionSummary", root).innerHTML = summaryTemplate(model, "weighing");
$$("[data-family-model]", root).forEach((button) => {
const active = button.dataset.familyModel === model.id;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
const badge = $("#v2ModelStateBadge", root);
badge.textContent = model.state === "enabled" ? "专属模型已启用" : model.stateLabel;
badge.dataset.modelState = model.state;
$("#v2ModelCode", root).textContent = `MODEL ${model.version} · ${model.code}`;
$("#v2HomeSyncState", root).textContent = model.state === "generic" ? "访客隐私模式" : model.sync === "尚未同步" ? "等待车家同步" : "车家同步正常";
$("#v2HomeSyncTime", root).textContent = model.sync;
$("#v2ModelTitle", root).textContent = `${model.name} · ${model.state === "generic" ? "通用称重模型" : "个性化称重模型"}`;
$("#v2ModelLead", root).textContent = model.state === "enabled" ? "最近一次家庭参考值与车端座椅采样已完成配对,新参数通过验证并正式生效。" : model.state === "learning" ? "专属参数仍在学习;样本不足或验证退化时,车端继续使用已验证模型。" : model.state === "cold" ? "尚未收到家庭体脂秤参考值,当前安全使用通用模型。" : "未识别用户不会创建个人参数或保存精确体重。";
const play = $("#v2PlayEvolution", root);
play.disabled = model.errors.length < 2;
play.title = play.disabled ? "当前没有可回放的专属模型样本" : "按有效样本顺序回放误差收敛过程";
$("#v2EvolutionChart", root).innerHTML = chartTemplate(model, visibleSamples ?? model.errors.length);
$("#v2SampleInsight", root).textContent = model.errors.length > 1 ? `已从通用模型基线 ±0.50 kg 收敛至 ${formatError(model.error)} kg;点击曲线节点可查看该次样本。` : "个性化模型仅使用身份匹配、时间有效且座椅采样稳定的数据。";
const linkage = linkageTemplate(model);
$("#v2LinkageSteps", root).innerHTML = linkage.list;
$("#v2LinkageGuard", root).innerHTML = linkage.guard;
$("#v2LinkageProgress", root).textContent = `${linkage.done} / 5 已完成`;
$("#v2ParameterGrid", root).innerHTML = parameterTemplate(model);
$("#v2EventList", root).innerHTML = eventTemplate(model);
$("#v2AdminReference", root).textContent = model.adminReference;
const remaining = Math.max(0, model.sampleTarget - model.samples);
const percent = model.sampleTarget ? Math.min(100, Math.round((model.samples / model.sampleTarget) * 100)) : 100;
$("#v2MilestoneCopy", root).textContent = model.state === "generic" ? "访客称重不会进入任何家庭成员的参数空间。" : model.state === "cold" ? "完成家庭体脂秤绑定后,首次有效配对将启动专属模型学习。" : remaining ? `预计再完成 ${remaining} 组有效样本后进入目标精度验证阶段。` : "样本数量已满足要求,正在持续验证 ±0.10 kg 目标。";
$("#v2MilestoneCount", root).textContent = model.sampleTarget ? `${model.samples} / ${model.sampleTarget} 组` : "隐私模式";
$("#v2MilestonePercent", root).textContent = model.sampleTarget ? `当前学习进度 ${percent}%` : "不留存个人数据";
$("#v2MilestoneBar", root).style.width = `${percent}%`;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function renderSystemEntry(root, index) {
const entry = systemEntries[index];
if (!entry) return;
$("#v2SystemStatus", root).textContent = entry.status;
$("#v2SystemVersion", root).textContent = entry.version;
const date = $("#v2SystemDate", root);
date.textContent = entry.date;
date.dateTime = entry.date.replaceAll(".", "-");
$("#v2SystemTitle", root).textContent = entry.title;
$("#v2SystemLead", root).textContent = entry.summary;
$("#v2SystemChangeList", root).innerHTML = entry.changes.map(([label, copy]) => `<div><i data-lucide="check"></i><p><strong>${label}</strong><span>${copy}</span></p></div>`).join("");
$("#v2SystemMetrics", root).innerHTML = entry.metrics.map(([label, before, after, progress]) => `<div class="v2-evolution-metric"><div><span>${label}</span><p><del>${before}</del><i data-lucide="arrow-right"></i><strong>${after}</strong></p></div><span class="v2-evolution-progress" style="--evolution-progress:${progress}%"><i></i></span></div>`).join("");
$("#v2SystemModules", root).innerHTML = entry.modules.map((module) => `<span>${module}</span>`).join("");
$("#v2SystemUserValidation", root).textContent = entry.userValidation;
$("#v2SystemAdminValidation", root).textContent = entry.adminValidation;
$$("[data-system-evolution-index]", root).forEach((button, buttonIndex) => {
const active = buttonIndex === index;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function setView(root, view) {
const system = view === "system";
clearInterval(playbackTimer);
playbackTimer = null;
$$("[data-evolution-page]", root).forEach((page) => {
const active = page.dataset.evolutionPage === view;
page.classList.toggle("active", active);
page.hidden = !active;
});
$("#v2EvolutionKicker", root).textContent = system ? "SELF-EVOLUTION · SYSTEM JOURNAL" : "SELF-EVOLUTION · SEAT WEIGHING";
$("#v2EvolutionPageTitle", root).textContent = system ? "系统能力日志" : "座椅称重模型进化";
$("#v2EvolutionPageIntro", root).textContent = system ? "保留每次问题发现、能力升级与验证结果,持续记录控制台系统能力演进。" : "通过车家联动持续校准家庭成员专属模型,让称重误差从 ±0.50 kg 逐步收敛至 ±0.10 kg。";
$("#v2EvolutionDataBadge", root).innerHTML = system
? '<i data-lucide="history"></i>历史记录'
: '<i data-lucide="flask-conical"></i>演示数据';
$("#v2EvolutionSummary", root).innerHTML = summaryTemplate(familyModels.find((item) => item.id === activeModelId), view);
const footer = $("#v2EvolutionFooter", root);
footer.hidden = system;
root.dataset.evolutionView = view;
window.dispatchEvent(new CustomEvent("cognitive-agent:evolution-view-change", {
detail: { view },
}));
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function playEvolution(root) {
const model = familyModels.find((item) => item.id === activeModelId);
if (!model || model.errors.length < 2) return;
clearInterval(playbackTimer);
let count = 1;
visibleSamples = count;
const button = $("#v2PlayEvolution", root);
button.classList.add("playing");
$("#v2PlayEvolution span", root).textContent = "正在回放";
renderModel(root, model.id, { keepPlayback: true });
playbackTimer = window.setInterval(() => {
count += 1;
visibleSamples = count;
renderModel(root, model.id, { keepPlayback: true });
if (count >= model.errors.length) {
clearInterval(playbackTimer);
playbackTimer = null;
button.classList.remove("playing");
$("#v2PlayEvolution span", root).textContent = "再次回放";
}
}, 520);
}
function bind(root) {
$(".v2-family-list", root).addEventListener("click", (event) => {
const button = event.target.closest("[data-family-model]");
if (button) renderModel(root, button.dataset.familyModel);
});
$(".v2-evolution-version-list", root).addEventListener("click", (event) => {
const button = event.target.closest("[data-system-evolution-index]");
if (button) renderSystemEntry(root, Number(button.dataset.systemEvolutionIndex));
});
$("#v2PlayEvolution", root).addEventListener("click", () => playEvolution(root));
$("#v2EvolutionChart", root).addEventListener("click", (event) => {
const point = event.target.closest("[data-sample-index]");
const model = familyModels.find((item) => item.id === activeModelId);
if (!point || !model) return;
const index = Number(point.dataset.sampleIndex);
$("#v2SampleInsight", root).textContent = `样本 ${index + 1}:验证误差 ${formatError(model.errors[index])} kg。该节点来自固定演示数据,不代表实时算法结果。`;
});
$("#v2EvolutionChart", root).addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
const point = event.target.closest("[data-sample-index]");
if (point) { event.preventDefault(); point.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
});
}
function mount(mainPanel) {
const existing = $("#v2SelfEvolution", mainPanel);
if (existing) return existing;
const evolution = make("section", "v2-self-evolution", shellTemplate());
evolution.id = "v2SelfEvolution";
evolution.dataset.evolutionView = "weighing";
evolution.setAttribute("aria-label", "自进化 · 座椅称重个性化模型进化");
mainPanel.append(evolution);
bind(evolution);
renderModel(evolution, activeModelId);
renderSystemEntry(evolution, 0);
setView(evolution, "weighing");
return evolution;
}
window.CognitiveAgentSelfEvolution = {
mount,
getView: () => $("#v2SelfEvolution")?.dataset.evolutionView || "weighing",
setView: (view) => {
const root = $("#v2SelfEvolution");
if (root) setView(root, view === "system" ? "system" : "weighing");
},
};
}());
+389 -207
View File
@@ -7,15 +7,6 @@
[data-extension-type="mechanical"] { --extension-card-color: #758e94; } [data-extension-type="mechanical"] { --extension-card-color: #758e94; }
[data-extension-type="other"] { --extension-card-color: #579a88; } [data-extension-type="other"] { --extension-card-color: #579a88; }
.console-v2[data-module="self-extension"] {
--v2-accent: #0e7773;
--v2-accent-strong: #095d5a;
--v2-accent-soft: #d8f4f1;
--v2-sidebar: #d8f4f2;
--v2-sidebar-deep: #c7eeeb;
--v2-line: rgba(18, 85, 83, 0.14);
}
.console-v2 .v2-extension-sidebar, .console-v2 .v2-extension-sidebar,
.console-v2 .v2-extension-footer, .console-v2 .v2-extension-footer,
.console-v2 .v2-self-extension, .console-v2 .v2-self-extension,
@@ -23,39 +14,6 @@
display: none; display: none;
} }
/* 自扩展不区分演示角色;admin/user 切换仅属于自编排。 */
.console-v2[data-module="self-extension"] .v2-role-select {
display: none;
}
.console-v2[data-module="self-extension"] .v2-extension-profile-title {
min-width: 0;
display: flex;
align-items: baseline;
gap: 5px;
color: #091c1e;
font-size: clamp(20px, 1.6vw, 26px);
font-weight: 900;
letter-spacing: -0.04em;
}
.console-v2[data-module="self-extension"] .v2-extension-profile-title strong {
color: inherit;
font: inherit;
}
.console-v2[data-module="self-extension"][data-demo-role="admin"] .v2-sidebar {
background:
radial-gradient(circle at 14% 3%, rgba(255, 255, 255, 0.8), transparent 24%),
linear-gradient(158deg, var(--v2-sidebar) 0%, #e8f8f6 60%, var(--v2-sidebar-deep) 130%);
}
.console-v2[data-module="self-extension"][data-demo-role="admin"] .v2-avatar {
background:
radial-gradient(circle at 28% 25%, rgba(255, 255, 255, 0.95), transparent 18%),
linear-gradient(135deg, #0b4d4b 3%, #70b7b1 49%, #d1efeb 52%, #276d69 100%);
}
.console-v2[data-module="self-extension"] .v2-control-section, .console-v2[data-module="self-extension"] .v2-control-section,
.console-v2[data-module="self-extension"] .v2-config-section, .console-v2[data-module="self-extension"] .v2-config-section,
.console-v2[data-module="self-extension"] .v2-sidebar > .v2-sidebar-footer:not(.v2-extension-footer), .console-v2[data-module="self-extension"] .v2-sidebar > .v2-sidebar-footer:not(.v2-extension-footer),
@@ -98,34 +56,36 @@
.v2-extension-pet { .v2-extension-pet {
--pet-x: 0px; --pet-x: 0px;
--pet-y: 0px; --pet-y: 0px;
width: 126px; width: clamp(154px, 15vw, 196px);
height: 126px; aspect-ratio: 1;
position: absolute; position: fixed;
right: 18px; right: max(18px, env(safe-area-inset-right));
bottom: 16px; bottom: max(18px, env(safe-area-inset-bottom));
z-index: 24; z-index: 24;
display: grid; display: block;
place-items: end center;
transform: translate3d(var(--pet-x), var(--pet-y), 0); transform: translate3d(var(--pet-x), var(--pet-y), 0);
filter: drop-shadow(0 14px 16px rgba(9, 66, 63, 0.2)); filter: drop-shadow(0 12px 14px rgba(9, 66, 63, 0.18));
touch-action: none; touch-action: none;
user-select: none; user-select: none;
} }
.v2-extension-pet-bubble { .v2-extension-pet-bubble {
max-width: 158px;
position: absolute; position: absolute;
right: 54px; right: calc(100% - 36px);
bottom: 89px; bottom: calc(100% - 42px);
padding: 7px 9px; width: 176px;
max-width: calc(100vw - 24px);
box-sizing: border-box;
padding: 10px 12px;
color: #285153; color: #285153;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(14, 119, 115, 0.18); border: 1px solid rgba(14, 119, 115, 0.18);
border-radius: 11px 11px 2px 11px; border-radius: 11px 11px 2px 11px;
box-shadow: 0 9px 24px rgba(22, 72, 70, 0.12); box-shadow: 0 9px 24px rgba(22, 72, 70, 0.12);
font-size: 8px; font-size: 13px;
font-weight: 800; font-weight: 800;
line-height: 1.45; line-height: 1.5;
overflow-wrap: break-word;
opacity: 0; opacity: 0;
transform: translateY(5px) scale(0.96); transform: translateY(5px) scale(0.96);
transform-origin: right bottom; transform-origin: right bottom;
@@ -141,191 +101,163 @@
} }
.v2-extension-pet-body { .v2-extension-pet-body {
width: 76px; width: 100%;
height: 77px; height: 100%;
position: relative; position: relative;
display: block; display: block;
padding: 0; padding: 0;
color: inherit; color: inherit;
background: linear-gradient(145deg, #f4fffd 0%, #bfe9e5 54%, #7bc3bd 100%); background: transparent;
border: 2px solid rgba(7, 86, 82, 0.48); border: 0;
border-radius: 25px 25px 22px 22px; border-radius: 0;
box-shadow: box-shadow: none;
inset 0 3px 0 rgba(255, 255, 255, 0.82),
inset 0 -6px 12px rgba(9, 93, 89, 0.13),
0 6px 0 #326f6c;
cursor: grab; cursor: grab;
animation: extension-pet-float 3.2s ease-in-out infinite; appearance: none;
} }
.v2-extension-pet[data-dragging="true"] .v2-extension-pet-body { .v2-extension-pet[data-dragging="true"] .v2-extension-pet-body {
cursor: grabbing; cursor: grabbing;
animation-play-state: paused;
} }
.v2-extension-pet-ear { .v2-extension-pet-image {
width: 22px; width: 100%;
height: 24px; height: 100%;
display: block;
object-fit: contain;
pointer-events: none;
}
.v2-extension-pet-entrance-canvas {
width: 100%;
height: 100%;
position: absolute; position: absolute;
top: -12px; inset: 0;
z-index: -1; z-index: 4;
background: linear-gradient(145deg, #dff8f5, #75bbb6); display: block;
border: 2px solid rgba(7, 86, 82, 0.48); opacity: 0;
border-radius: 7px 15px 5px 13px; pointer-events: none;
will-change: transform;
} }
.v2-extension-pet-ear.left { left: 3px; transform: rotate(-20deg); } .v2-extension-pet[data-entering="true"] .v2-extension-pet-entrance-canvas {
.v2-extension-pet-ear.right { right: 3px; transform: scaleX(-1) rotate(-20deg); } opacity: 1;
}
.v2-extension-pet-antenna { .v2-extension-pet[data-entering="true"] .v2-extension-pet-image,
width: 2px; .v2-extension-pet[data-entering="true"] .v2-extension-pet-tail-canvas,
height: 17px; .v2-extension-pet[data-entering="true"] .v2-extension-pet-blink,
.v2-extension-pet[data-entering="true"] .v2-extension-pet-mouth-closed {
opacity: 0;
}
.v2-extension-pet[data-entering="true"] .v2-extension-pet-bubble {
opacity: 0;
transform: translateY(5px) scale(0.96);
}
.v2-extension-pet[data-entering="true"] .v2-extension-pet-body {
cursor: default;
}
.v2-extension-pet-tail-canvas {
width: 100%;
height: 100%;
position: absolute; position: absolute;
top: -21px; inset: 0;
left: 50%; z-index: 1;
background: #397f7b; display: block;
transform: translateX(-50%); object-fit: contain;
opacity: 0;
pointer-events: none;
} }
.v2-extension-pet-antenna i { .v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-tail-canvas {
width: 8px; opacity: 1;
height: 8px; }
.v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-image {
opacity: 0;
}
.v2-extension-pet-blink {
position: absolute; position: absolute;
top: -5px; inset: 0;
left: -3px; z-index: 2;
background: #ffbd62; opacity: 0;
border: 1px solid #9f6630; pointer-events: none;
border-radius: 50%; transition: opacity 28ms linear;
box-shadow: 0 0 0 4px rgba(255, 189, 98, 0.17);
animation: extension-pet-signal 1.8s ease-in-out infinite;
} }
.v2-extension-pet-face { .v2-extension-pet[data-blinking="true"] .v2-extension-pet-blink {
width: 58px; opacity: 1;
height: 34px; }
.v2-extension-pet-blink-eye {
position: absolute; position: absolute;
top: 9px; top: 31.3%;
left: 7px; height: 6.2%;
background: linear-gradient(180deg, #164e50, #0d383a); background: #919baa;
border: 2px solid rgba(255, 255, 255, 0.65); border-radius: 48%;
border-radius: 16px;
box-shadow: inset 0 4px 10px rgba(0, 0, 0, 0.2);
} }
.v2-extension-pet-face .eye { .v2-extension-pet-blink-eye.left {
width: 8px; left: 23.7%;
height: 10px; width: 4%;
}
.v2-extension-pet-blink-eye.right {
left: 35.9%;
width: 5.3%;
}
.v2-extension-pet-blink-eye::after {
content: "";
position: absolute; position: absolute;
top: 9px; top: 48%;
background: #bdfaf2; right: 12%;
border-radius: 50%; left: 12%;
box-shadow: 0 0 7px rgba(141, 255, 241, 0.72); height: 27%;
animation: extension-pet-blink 4.6s infinite; border-top: 2px solid #050606;
border-radius: 50% 50% 0 0;
} }
.v2-extension-pet-face .eye.left { left: 12px; } .v2-extension-pet-mouth-closed {
.v2-extension-pet-face .eye.right { right: 12px; }
.v2-extension-pet-face b {
width: 6px;
height: 4px;
position: absolute; position: absolute;
top: 19px; left: 27%;
left: 50%; top: 39.8%;
background: #ffc17a; z-index: 3;
border-radius: 50% 50% 65% 65%; width: 7%;
transform: translateX(-50%); height: 5.9%;
border-radius: 46%;
background:
linear-gradient(#050606, #050606) center top / 1.5px 34% no-repeat,
#fbfaf7;
pointer-events: none;
opacity: 1;
} }
.v2-extension-pet-face em { .v2-extension-pet-mouth-closed::before {
width: 13px; content: "";
height: 6px;
position: absolute; position: absolute;
top: 22px; top: 13%;
left: 50%; left: 13%;
border-bottom: 2px solid #bdfaf2; width: 38%;
border-radius: 50%; height: 34%;
transform: translateX(-50%); border-bottom: 1.5px solid #050606;
border-radius: 0 0 70% 70%;
transform: rotate(-7deg);
} }
.v2-extension-pet[data-mood="happy"] .v2-extension-pet-face .eye { .v2-extension-pet-mouth-closed::after {
height: 5px; content: "";
top: 13px;
background: transparent;
border-top: 2px solid #bdfaf2;
border-radius: 50%;
}
.v2-extension-pet-screen {
width: 36px;
height: 17px;
position: absolute; position: absolute;
bottom: 7px; top: 13%;
left: 18px; right: 13%;
display: flex; width: 38%;
align-items: center; height: 34%;
justify-content: center; border-bottom: 1.5px solid #050606;
gap: 4px; border-radius: 0 0 70% 70%;
color: #eafffb; transform: rotate(7deg);
background: #277c78;
border: 1px solid rgba(6, 79, 75, 0.5);
border-radius: 7px;
font: 900 6px Consolas, monospace;
}
.v2-extension-pet-screen i {
width: 5px;
height: 5px;
background: #80f0bd;
border-radius: 50%;
box-shadow: 0 0 5px rgba(128, 240, 189, 0.8);
}
.v2-extension-pet-foot {
width: 21px;
height: 10px;
position: absolute;
bottom: -12px;
background: #5ba5a0;
border: 2px solid #326f6c;
border-radius: 4px 4px 9px 9px;
}
.v2-extension-pet-foot.left { left: 8px; }
.v2-extension-pet-foot.right { right: 8px; }
.v2-extension-pet-tail {
width: 27px;
height: 11px;
position: absolute;
right: -21px;
bottom: 12px;
z-index: -1;
border: 5px solid #4d9994;
border-left: 0;
border-radius: 0 13px 13px 0;
transform-origin: left center;
animation: extension-pet-tail 1.1s ease-in-out infinite alternate;
}
@keyframes extension-pet-float {
0%, 100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-5px) rotate(1deg); }
}
@keyframes extension-pet-blink {
0%, 45%, 49%, 100% { transform: scaleY(1); }
47% { transform: scaleY(0.12); }
}
@keyframes extension-pet-signal {
0%, 100% { opacity: 0.72; transform: scale(0.86); }
50% { opacity: 1; transform: scale(1.15); }
}
@keyframes extension-pet-tail {
from { transform: rotate(-14deg); }
to { transform: rotate(16deg); }
} }
.v2-extension-sidebar > .v2-field-caption { .v2-extension-sidebar > .v2-field-caption {
@@ -2233,3 +2165,253 @@ form > .v2-extension-primary {
} }
} }
/* Unified extension capability mirror ------------------------------------ */
.v2-extension-sync-mount {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 6px;
padding: 10px;
overflow: hidden;
background: linear-gradient(145deg, #102b2d, #173c3d);
border: 1px solid rgba(40, 165, 154, 0.2);
border-radius: 17px;
box-shadow: 0 14px 34px rgba(17, 49, 50, 0.15);
}
.v2-extension-sync-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.v2-extension-sync-heading small {
color: #66bdb5;
font: 850 8px/1.2 "SFMono-Regular", Consolas, monospace;
letter-spacing: 0.14em;
}
.v2-extension-sync-heading h3 {
margin: 3px 0 0;
color: #f3fffd;
font-size: 14px;
}
.v2-extension-sync-heading > span {
display: inline-flex;
align-items: center;
gap: 6px;
color: #a9c3c0;
font-size: 8px;
font-weight: 800;
white-space: nowrap;
}
.v2-extension-sync-heading > span i {
width: 6px;
height: 6px;
background: #758e8c;
border-radius: 50%;
}
.v2-extension-sync-heading > span[data-state="online"] i {
background: #25c896;
box-shadow: 0 0 0 5px rgba(37, 200, 150, 0.1);
}
.v2-extension-sync-heading > span[data-state="listening"] i {
background: #e6b45f;
}
.v2-extension-gateway {
min-width: 0;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
background: rgba(255, 255, 255, 0.055);
border: 1px solid rgba(130, 210, 202, 0.09);
border-radius: 11px;
}
.v2-extension-gateway > span {
width: 34px;
height: 34px;
display: grid;
place-items: center;
color: #77d3c9;
background: rgba(77, 190, 179, 0.1);
border-radius: 10px;
}
.v2-extension-gateway > span svg {
width: 16px;
}
.v2-extension-gateway > div {
min-width: 0;
display: grid;
gap: 2px;
}
.v2-extension-gateway strong {
color: #effbf9;
font-size: 10px;
}
.v2-extension-gateway small {
overflow: hidden;
color: #91aaa8;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-extension-gateway > b {
padding: 4px 6px;
color: #74c5bd;
background: rgba(90, 186, 177, 0.08);
border-radius: 6px;
font: 800 7px "SFMono-Regular", Consolas, monospace;
}
.v2-extension-device-list {
min-height: 0;
max-height: 124px;
overflow: auto;
}
.v2-extension-sync-mount[data-has-devices="true"] .v2-extension-gateway {
display: none;
}
.v2-extension-synced-device {
padding: 8px;
background: rgba(255, 255, 255, 0.045);
border-radius: 10px;
}
.v2-extension-synced-device header {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.v2-extension-synced-device header > span {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: #76cfc6;
background: rgba(87, 191, 180, 0.1);
border-radius: 8px;
}
.v2-extension-synced-device header svg {
width: 14px;
}
.v2-extension-synced-device header > div {
display: grid;
gap: 1px;
}
.v2-extension-synced-device header strong {
color: #effaf8;
font-size: 9px;
}
.v2-extension-synced-device header small,
.v2-extension-synced-device header b {
color: #8da7a5;
font-size: 7px;
}
.v2-extension-synced-device > div {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 7px;
}
.v2-extension-synced-device > div span {
padding: 3px 6px;
color: #b9d8d4;
background: rgba(95, 190, 180, 0.08);
border-radius: 999px;
font-size: 7px;
}
.v2-extension-sync-mount .v2-extension-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 34px;
color: #668986;
background: rgba(255, 255, 255, 0.035);
border: 0;
border-radius: 9px;
}
.v2-extension-empty > svg {
width: 19px;
}
.v2-extension-empty div {
display: grid;
gap: 2px;
}
.v2-extension-empty strong {
color: #bad0cd;
font-size: 9px;
}
.v2-extension-empty span {
font-size: 7px;
}
.v2-extension-linkage {
min-width: 0;
display: grid;
grid-template-columns: 25px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding-top: 7px;
border-top: 1px solid rgba(134, 203, 197, 0.1);
}
.v2-extension-linkage > svg {
width: 15px;
color: #6faaa5;
}
.v2-extension-linkage > div {
min-width: 0;
display: grid;
gap: 1px;
}
.v2-extension-linkage small {
color: #708f8c;
font-size: 7px;
}
.v2-extension-linkage strong {
overflow: hidden;
color: #c2d8d5;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-extension-linkage > span {
color: #69bfb6;
font: 800 7px "SFMono-Regular", Consolas, monospace;
}
+572 -21
View File
@@ -18,6 +18,79 @@
const $ = (selector, root = document) => root.querySelector(selector); const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
function rgbToHsl(red, green, blue) {
const r = red / 255;
const g = green / 255;
const b = blue / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
const delta = max - min;
if (!delta) return [0, 0, lightness];
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
let hue;
if (max === r) hue = ((g - b) / delta) % 6;
else if (max === g) hue = (b - r) / delta + 2;
else hue = (r - g) / delta + 4;
return [((hue * 60) + 360) % 360, saturation, lightness];
}
function hslToRgb(hue, saturation, lightness) {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const section = hue / 60;
const intermediate = chroma * (1 - Math.abs((section % 2) - 1));
let rgb = [0, 0, 0];
if (section < 1) rgb = [chroma, intermediate, 0];
else if (section < 2) rgb = [intermediate, chroma, 0];
else if (section < 3) rgb = [0, chroma, intermediate];
else if (section < 4) rgb = [0, intermediate, chroma];
else if (section < 5) rgb = [intermediate, 0, chroma];
else rgb = [chroma, 0, intermediate];
const match = lightness - chroma / 2;
return rgb.map((channel) => Math.round((channel + match) * 255));
}
function parseCssRgb(value) {
const color = String(value || "").trim();
const shortHex = color.match(/^#([\da-f])([\da-f])([\da-f])$/i);
if (shortHex) return shortHex.slice(1).map((channel) => parseInt(channel + channel, 16));
const hex = color.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) return hex.slice(1).map((channel) => parseInt(channel, 16));
const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
return rgb ? rgb.slice(1).map(Number) : null;
}
function createThemedPetDataUrl(sourceImage, accentColor) {
const accent = parseCssRgb(accentColor);
if (!accent || !sourceImage.naturalWidth || !sourceImage.naturalHeight) return "";
const [targetHue] = rgbToHsl(...accent);
const canvas = document.createElement("canvas");
canvas.width = sourceImage.naturalWidth;
canvas.height = sourceImage.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return "";
context.drawImage(sourceImage, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const { data } = imageData;
for (let index = 0; index < data.length; index += 4) {
if (data[index + 3] === 0) continue;
const red = data[index];
const green = data[index + 1];
const blue = data[index + 2];
const maximum = Math.max(red, green, blue);
const minimum = Math.min(red, green, blue);
const saturation = maximum ? (maximum - minimum) / maximum : 0;
const blueDominance = blue - Math.max(red, green);
// Only the deliberately saturated blue apparel is themed. The gray-blue fur stays intact.
if (blueDominance < 24 || saturation < 0.32) continue;
const [, sourceSaturation, lightness] = rgbToHsl(red, green, blue);
const themed = hslToRgb(targetHue, sourceSaturation, lightness);
[data[index], data[index + 1], data[index + 2]] = themed;
}
context.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
function preset(key, name, aliases, category = "custom", risk = "low", visual = null) { function preset(key, name, aliases, category = "custom", risk = "low", visual = null) {
return { key, name, aliases, category, risk, visual }; return { key, name, aliases, category, risk, visual };
} }
@@ -291,6 +364,10 @@
let sharedTwinBand = null; let sharedTwinBand = null;
let sharedTwinHome = null; let sharedTwinHome = null;
let petTimer = null; let petTimer = null;
let petBlinkTimer = null;
let petBlinkResetTimer = null;
let petTailTimer = null;
let petTailFrame = null;
let petPhraseIndex = 0; let petPhraseIndex = 0;
function uid(prefix) { function uid(prefix) {
@@ -322,12 +399,11 @@
} }
function initialState() { function initialState() {
const device = clone(DEFAULT_DEVICE);
return { return {
version: 5, version: 5,
devices: [device], devices: [],
capabilities: seedPresets(device), capabilities: [],
selectedDeviceByType: { light: device.id }, selectedDeviceByType: {},
sessionStartedAt: null, sessionStartedAt: null,
settings: { actionDelayMs: 520 }, settings: { actionDelayMs: 520 },
history: [], history: [],
@@ -343,6 +419,12 @@
return initialState(); return initialState();
} }
saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : []; saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : [];
const removedDefaultDevice = saved.devices.some((item) => item.id === DEFAULT_DEVICE.id);
saved.devices = saved.devices.filter((item) => item.id !== DEFAULT_DEVICE.id);
if (removedDefaultDevice) {
saved.capabilities = saved.capabilities.filter((item) => item.deviceId !== DEFAULT_DEVICE.id);
delete saved.selectedDeviceByType?.light;
}
saved.history = Array.isArray(saved.history) ? saved.history : []; saved.history = Array.isArray(saved.history) ? saved.history : [];
saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object" saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
? saved.vehicleExecutions ? saved.vehicleExecutions
@@ -520,12 +602,14 @@
<aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite"> <aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite">
<span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span> <span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span>
<button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动"> <button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动">
<span class="v2-extension-pet-antenna"><i></i></span> <img class="v2-extension-pet-image" src="/assets/pets/self-extension-cat.png?v=3" alt="" draggable="false" />
<span class="v2-extension-pet-ear left"></span><span class="v2-extension-pet-ear right"></span> <canvas class="v2-extension-pet-entrance-canvas" width="512" height="512" aria-hidden="true"></canvas>
<span class="v2-extension-pet-face"><i class="eye left"></i><i class="eye right"></i><b></b><em></em></span> <canvas class="v2-extension-pet-tail-canvas" width="512" height="512" aria-hidden="true"></canvas>
<span class="v2-extension-pet-screen"><i></i><b>EXT</b></span> <span class="v2-extension-pet-blink" aria-hidden="true">
<span class="v2-extension-pet-foot left"></span><span class="v2-extension-pet-foot right"></span> <i class="v2-extension-pet-blink-eye left"></i>
<span class="v2-extension-pet-tail"></span> <i class="v2-extension-pet-blink-eye right"></i>
</span>
<span class="v2-extension-pet-mouth-closed" aria-hidden="true"></span>
</button> </button>
</aside>`; </aside>`;
moduleSwitcher.insertAdjacentElement("afterend", root); moduleSwitcher.insertAdjacentElement("afterend", root);
@@ -1628,8 +1712,340 @@
const pet = $("#extensionDesktopPet"); const pet = $("#extensionDesktopPet");
const body = $("#extensionPetBody"); const body = $("#extensionPetBody");
const bubble = $("#extensionPetBubble"); const bubble = $("#extensionPetBubble");
const host = $("#v2SelfExtension"); const image = $(".v2-extension-pet-image", body);
if (!pet || !body || !bubble || !host) return; const entranceCanvas = $(".v2-extension-pet-entrance-canvas", body);
const tailCanvas = $(".v2-extension-pet-tail-canvas", body);
const mouth = $(".v2-extension-pet-mouth-closed", body);
const entranceContext = entranceCanvas?.getContext("2d");
const tailContext = tailCanvas?.getContext("2d");
if (!pet || !body || !bubble || !image || !entranceCanvas || !entranceContext
|| !tailCanvas || !tailContext || !mouth) return;
const sourceImage = new Image();
const entranceSourceImage = new Image();
const themedEntranceImage = new Image();
const ENTRANCE_KEYFRAME_COUNT = 12;
const ENTRANCE_CELL_SIZE = 512;
const ENTRANCE_DURATION_MS = 2300;
const ENTRANCE_WALK_END = 0.6;
const ENTRANCE_WALK_STEPS = 5;
const ENTRANCE_SETTLE_START_FRAME = 5;
const ENTRANCE_WALK_POSES = [
0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4,
];
let tailWaveQueued = false;
let tailWaveSequenceActive = false;
let tailWaveReplaying = false;
let entranceAnimationFrame = null;
let entranceRequested = document.body.dataset.module === "self-extension";
let entranceThemeReady = false;
let entranceThemeRevision = 0;
let extensionWasActive = document.body.dataset.module === "self-extension";
const applyPetTheme = () => {
if (!sourceImage.complete || !sourceImage.naturalWidth) return;
const accent = getComputedStyle(document.body).getPropertyValue("--v2-accent").trim()
|| "#0e7773";
const themedSource = createThemedPetDataUrl(sourceImage, accent);
if (themedSource) image.src = themedSource;
image.dataset.themeRole = "user";
if (!entranceSourceImage.complete || !entranceSourceImage.naturalWidth) return;
const revision = ++entranceThemeRevision;
entranceThemeReady = false;
const themedEntranceSource = createThemedPetDataUrl(entranceSourceImage, accent);
if (!themedEntranceSource) return;
themedEntranceImage.addEventListener("load", () => {
if (revision !== entranceThemeRevision) return;
entranceThemeReady = true;
if (entranceRequested) playEntrance();
}, { once: true });
themedEntranceImage.src = themedEntranceSource;
};
sourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
sourceImage.src = image.getAttribute("src");
entranceSourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
entranceSourceImage.src = "/assets/pets/self-extension-cat-entrance.png?v=3";
const tailSourceCanvas = document.createElement("canvas");
const tailSourceContext = tailSourceCanvas.getContext("2d");
const tailPoints = [
[350, 165], [400, 166], [416, 190], [414, 400], [399, 450], [342, 464],
[330, 442], [347, 419], [361, 380], [367, 228], [345, 198],
];
const tailMotionFrames = [
[0, 0, 0, 0, 0, 0],
[0, -5, -11, -7, 7, 17],
[0, -9, -20, -13, 13, 30],
[0, -13, -27, -17, 19, 41],
[0, -16, -32, -19, 24, 48],
[0, -9, -19, -11, 13, 31],
[0, -4, -9, -5, 6, 14],
[0, 0, 0, 0, 0, 0],
[0, 2, 5, 3, -2, -6],
[0, 3, 7, 4, -3, -8],
[0, 1, 3, 2, -1, -3],
[0, 0, 0, 0, 0, 0],
];
const interpolateCubic = (before, start, end, after, amount, clampToSegment = false) => {
const amountSquared = amount * amount;
const amountCubed = amountSquared * amount;
const interpolated = 0.5 * (
(2 * start)
+ (-before + end) * amount
+ (2 * before - 5 * start + 4 * end - after) * amountSquared
+ (-before + 3 * start - 3 * end + after) * amountCubed
);
if (!clampToSegment) return interpolated;
return Math.max(Math.min(start, end), Math.min(Math.max(start, end), interpolated));
};
const getTailPose = (progress) => {
const framePosition = Math.max(0, Math.min(1, progress)) * (tailMotionFrames.length - 1);
const frameIndex = Math.min(tailMotionFrames.length - 2, Math.floor(framePosition));
const amount = framePosition - frameIndex;
const beforeFrame = tailMotionFrames[Math.max(0, frameIndex - 1)];
const startFrame = tailMotionFrames[frameIndex];
const endFrame = tailMotionFrames[frameIndex + 1];
const afterFrame = tailMotionFrames[Math.min(tailMotionFrames.length - 1, frameIndex + 2)];
return startFrame.map((offset, index) => interpolateCubic(
beforeFrame[index], offset, endFrame[index], afterFrame[index], amount, true,
));
};
const sampleTailOffset = (pose, distanceFromBase) => {
const position = Math.max(0, Math.min(1, distanceFromBase)) * (pose.length - 1);
const index = Math.min(pose.length - 2, Math.floor(position));
const amount = position - index;
const before = pose[Math.max(0, index - 1)];
const start = pose[index];
const end = pose[index + 1];
const after = pose[Math.min(pose.length - 1, index + 2)];
return interpolateCubic(before, start, end, after, amount);
};
const traceTail = (context, scaleX, scaleY) => {
context.beginPath();
tailPoints.forEach(([x, y], index) => {
const method = index ? "lineTo" : "moveTo";
context[method](x * scaleX, y * scaleY);
});
context.closePath();
};
const stopTailWave = () => {
window.clearTimeout(petTailTimer);
petTailTimer = null;
if (petTailFrame !== null) window.cancelAnimationFrame(petTailFrame);
petTailFrame = null;
tailWaveQueued = false;
tailWaveSequenceActive = false;
tailWaveReplaying = false;
delete pet.dataset.tailWaving;
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
};
const stopEntrance = () => {
if (entranceAnimationFrame !== null) {
window.cancelAnimationFrame(entranceAnimationFrame);
entranceAnimationFrame = null;
}
delete pet.dataset.entering;
entranceCanvas.style.removeProperty("transform");
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
};
const drawEntranceKeyframe = (frameIndex) => {
entranceContext.drawImage(
themedEntranceImage,
frameIndex * ENTRANCE_CELL_SIZE,
0,
ENTRANCE_CELL_SIZE,
ENTRANCE_CELL_SIZE,
0,
0,
entranceCanvas.width,
entranceCanvas.height,
);
};
const drawEntrancePose = (progress) => {
let activeFrame;
let travelProgress = 1;
let stepBob = 0;
if (progress < ENTRANCE_WALK_END) {
const walkProgress = Math.max(0, progress / ENTRANCE_WALK_END);
const walkStepPosition = walkProgress * ENTRANCE_WALK_STEPS;
const completedSteps = Math.min(
ENTRANCE_WALK_STEPS - 1,
Math.floor(walkStepPosition),
);
const stepProgress = walkStepPosition - completedSteps;
const easedStepProgress = 0.5 - (Math.cos(Math.PI * stepProgress) / 2);
travelProgress = (completedSteps + easedStepProgress) / ENTRANCE_WALK_STEPS;
stepBob = -Math.sin(Math.PI * stepProgress) * 2.8;
const walkPoseIndex = Math.min(
ENTRANCE_WALK_POSES.length - 1,
Math.round(walkProgress * (ENTRANCE_WALK_POSES.length - 1)),
);
activeFrame = ENTRANCE_WALK_POSES[walkPoseIndex];
} else {
const settleProgress = (progress - ENTRANCE_WALK_END) / (1 - ENTRANCE_WALK_END);
activeFrame = ENTRANCE_SETTLE_START_FRAME + Math.round(
settleProgress * (ENTRANCE_KEYFRAME_COUNT - ENTRANCE_SETTLE_START_FRAME - 1),
);
}
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
drawEntranceKeyframe(activeFrame);
return { travelProgress, stepBob };
};
function playEntrance() {
if (!entranceRequested || !entranceThemeReady
|| document.visibilityState !== "visible"
|| document.body.dataset.module !== "self-extension") return;
entranceRequested = false;
stopEntrance();
stopTailWave();
delete pet.dataset.blinking;
pet.dataset.mood = "idle";
pet.dataset.entering = "true";
const startedAt = performance.now();
const renderEntrance = (now) => {
if (document.visibilityState !== "visible"
|| document.body.dataset.module !== "self-extension") {
stopEntrance();
return;
}
const progress = Math.min(1, (now - startedAt) / ENTRANCE_DURATION_MS);
const entrancePose = drawEntrancePose(progress);
const horizontalOffset = (1 - entrancePose.travelProgress) * (pet.offsetWidth + 34);
entranceCanvas.style.transform = `translate3d(${horizontalOffset}px, ${entrancePose.stepBob}px, 0)`;
if (progress < 1) {
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
return;
}
entranceAnimationFrame = null;
delete pet.dataset.entering;
entranceCanvas.style.removeProperty("transform");
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
if (document.body.dataset.module === "self-extension") {
say("自扩展工作台已就绪", "happy");
}
};
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
}
const requestEntrance = () => {
entranceRequested = true;
if (entranceThemeReady) playEntrance();
};
const drawTailWaveFrame = (progress) => {
if (!image.complete || !image.naturalWidth || !tailSourceContext) return false;
const width = image.naturalWidth;
const height = image.naturalHeight;
if (tailCanvas.width !== width || tailCanvas.height !== height) {
tailCanvas.width = width;
tailCanvas.height = height;
}
if (tailSourceCanvas.width !== width || tailSourceCanvas.height !== height) {
tailSourceCanvas.width = width;
tailSourceCanvas.height = height;
}
const scaleX = width / 512;
const scaleY = height / 512;
const tailTop = 171 * scaleY;
const tailBottom = 459 * scaleY;
const sliceHeight = Math.max(2, Math.round(2 * scaleY));
tailContext.clearRect(0, 0, width, height);
tailContext.drawImage(image, 0, 0, width, height);
tailContext.save();
tailContext.globalCompositeOperation = "destination-out";
traceTail(tailContext, scaleX, scaleY);
tailContext.fill();
tailContext.lineWidth = 10 * Math.max(scaleX, scaleY);
tailContext.lineJoin = "round";
tailContext.stroke();
tailContext.restore();
tailSourceContext.clearRect(0, 0, width, height);
tailSourceContext.save();
traceTail(tailSourceContext, scaleX, scaleY);
tailSourceContext.clip();
tailSourceContext.drawImage(image, 0, 0, width, height);
tailSourceContext.restore();
const tailPose = getTailPose(progress);
for (let y = tailTop; y < tailBottom; y += sliceHeight) {
const distanceFromBase = (tailBottom - y) / (tailBottom - tailTop);
const offset = sampleTailOffset(tailPose, distanceFromBase) * scaleX;
tailContext.drawImage(
tailSourceCanvas,
0, y, width, sliceHeight + 1,
offset, y, width, sliceHeight + 1,
);
}
return true;
};
const runTailWave = (isReplay = false) => {
petTailTimer = null;
if (document.visibilityState !== "visible"
|| document.body.dataset.module !== "self-extension"
|| !image.complete
|| !drawTailWaveFrame(0)) {
stopTailWave();
return;
}
const startedAt = performance.now();
tailWaveSequenceActive = true;
tailWaveReplaying = isReplay;
pet.dataset.tailWaving = "true";
const renderTailWave = (now) => {
const progress = Math.min(1, (now - startedAt) / 1900);
if (!drawTailWaveFrame(progress)) {
stopTailWave();
return;
}
if (progress < 1) {
petTailFrame = window.requestAnimationFrame(renderTailWave);
return;
}
petTailFrame = null;
delete pet.dataset.tailWaving;
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
if (!tailWaveReplaying && tailWaveQueued) {
tailWaveQueued = false;
tailWaveReplaying = true;
petTailTimer = window.setTimeout(() => runTailWave(true), 180);
return;
}
tailWaveSequenceActive = false;
tailWaveReplaying = false;
tailWaveQueued = false;
};
petTailFrame = window.requestAnimationFrame(renderTailWave);
};
const requestTailWave = () => {
if (pet.dataset.entering === "true") return;
if (tailWaveSequenceActive) {
if (!tailWaveReplaying) tailWaveQueued = true;
return;
}
runTailWave();
};
const scheduleBlink = (delay = 2800 + Math.random() * 4200) => {
window.clearTimeout(petBlinkTimer);
petBlinkTimer = window.setTimeout(() => {
if (document.visibilityState !== "visible"
|| document.body.dataset.module !== "self-extension"
|| pet.dataset.entering === "true") {
scheduleBlink();
return;
}
pet.dataset.blinking = "true";
window.clearTimeout(petBlinkResetTimer);
petBlinkResetTimer = window.setTimeout(() => {
delete pet.dataset.blinking;
if (Math.random() < 0.22) {
petBlinkTimer = window.setTimeout(() => {
pet.dataset.blinking = "true";
petBlinkResetTimer = window.setTimeout(() => {
delete pet.dataset.blinking;
scheduleBlink();
}, 135);
}, 145);
} else {
scheduleBlink();
}
}, 135);
}, delay);
};
scheduleBlink(1200 + Math.random() * 1600);
const phrases = [ const phrases = [
"嗨,我会陪你学习新能力", "嗨,我会陪你学习新能力",
"拖动我可以换个位置", "拖动我可以换个位置",
@@ -1639,26 +2055,58 @@
]; ];
const pointer = { active: false, moved: false, x: 0, y: 0, startX: 0, startY: 0 }; const pointer = { active: false, moved: false, x: 0, y: 0, startX: 0, startY: 0 };
const animateMouth = () => {
if (pet.dataset.entering === "true") return;
mouth.getAnimations?.().forEach((animation) => animation.cancel());
mouth.animate?.(
[
{ opacity: 1, offset: 0 },
{ opacity: 1, offset: 0.06 },
{ opacity: 0, offset: 0.09 },
{ opacity: 0, offset: 0.23 },
{ opacity: 1, offset: 0.26 },
{ opacity: 1, offset: 0.38 },
{ opacity: 0, offset: 0.41 },
{ opacity: 0, offset: 0.55 },
{ opacity: 1, offset: 0.58 },
{ opacity: 1, offset: 0.7 },
{ opacity: 0, offset: 0.73 },
{ opacity: 0, offset: 0.87 },
{ opacity: 1, offset: 0.9 },
{ opacity: 1, offset: 1 },
],
{ duration: 1260, easing: "linear" },
);
};
const say = (message, mood = "talking") => { const say = (message, mood = "talking") => {
bubble.textContent = message; bubble.textContent = message;
pet.dataset.mood = mood; pet.dataset.mood = mood;
animateMouth();
window.clearTimeout(Number(pet.dataset.moodTimer || 0)); window.clearTimeout(Number(pet.dataset.moodTimer || 0));
pet.dataset.moodTimer = String(window.setTimeout(() => { pet.dataset.moodTimer = String(window.setTimeout(() => {
pet.dataset.mood = "idle"; pet.dataset.mood = "idle";
}, 2600)); }, 2600));
}; };
pet.addEventListener("pointerenter", () => {
if (pet.dataset.entering === "true") return;
requestTailWave();
if (pet.dataset.mood === "idle") animateMouth();
});
const clampPosition = (x, y) => { const clampPosition = (x, y) => {
const hostRect = host.getBoundingClientRect(); const edgeMargin = 18;
const petRect = pet.getBoundingClientRect(); const maxLeftShift = Math.max(0, window.innerWidth - pet.offsetWidth - edgeMargin * 2);
const maxUpShift = Math.max(0, window.innerHeight - pet.offsetHeight - edgeMargin * 2);
return { return {
x: Math.max(-hostRect.width + petRect.width + 10, Math.min(0, x)), x: Math.max(-maxLeftShift, Math.min(0, x)),
y: Math.max(-hostRect.height + petRect.height + 10, Math.min(0, y)), y: Math.max(-maxUpShift, Math.min(0, y)),
}; };
}; };
body.addEventListener("pointerdown", (event) => { body.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return; if (event.button !== 0 || pet.dataset.entering === "true") return;
pointer.active = true; pointer.active = true;
pointer.moved = false; pointer.moved = false;
pointer.startX = event.clientX; pointer.startX = event.clientX;
@@ -1686,7 +2134,8 @@
body.addEventListener("pointerup", endDrag); body.addEventListener("pointerup", endDrag);
body.addEventListener("pointercancel", endDrag); body.addEventListener("pointercancel", endDrag);
body.addEventListener("click", () => { body.addEventListener("click", () => {
if (pointer.moved) return; if (pointer.moved || pet.dataset.entering === "true") return;
requestTailWave();
petPhraseIndex = (petPhraseIndex + 1) % phrases.length; petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking"); say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking");
}); });
@@ -1698,15 +2147,30 @@
pet.style.setProperty("--pet-y", `${position.y}px`); pet.style.setProperty("--pet-y", `${position.y}px`);
}); });
window.addEventListener("cognitive-agent:module-change", (event) => { window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-extension") say("自扩展工作台已就绪", "happy"); const extensionIsActive = event.detail?.module === "self-extension";
const enteredExtension = extensionIsActive && !extensionWasActive;
extensionWasActive = extensionIsActive;
if (enteredExtension) {
requestEntrance();
} else if (!extensionIsActive) {
entranceRequested = false;
stopEntrance();
}
}); });
petTimer = window.setInterval(() => { petTimer = window.setInterval(() => {
if (document.visibilityState !== "visible" if (document.visibilityState !== "visible"
|| document.body.dataset.module !== "self-extension") return; || document.body.dataset.module !== "self-extension"
|| pet.dataset.entering === "true") return;
petPhraseIndex = (petPhraseIndex + 1) % phrases.length; petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
say(phrases[petPhraseIndex]); say(phrases[petPhraseIndex]);
}, 14000); }, 14000);
window.addEventListener("pagehide", () => window.clearInterval(petTimer)); window.addEventListener("pagehide", () => {
window.clearInterval(petTimer);
window.clearTimeout(petBlinkTimer);
window.clearTimeout(petBlinkResetTimer);
stopEntrance();
stopTailWave();
});
} }
function bindEvents() { function bindEvents() {
@@ -1827,7 +2291,94 @@
}); });
} }
function buildUnifiedExtensionSync() {
const mount = $("#vehicleExtensionSync");
if (!mount) return false;
let snapshot = null;
const render = () => {
const health = snapshot?.extension_health || {};
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
const sharedCatalog = Array.isArray(snapshot?.extension?.catalog)
? snapshot.extension.catalog
: [];
const mergedCatalog = [...sharedCatalog, ...peripheralCatalog()]
.filter((item, index, catalog) => {
const identity = item.id || `${item.device_id}:${item.action}`;
return catalog.findIndex((candidate) => (
candidate.id || `${candidate.device_id}:${candidate.action}`
) === identity) === index;
});
const grouped = new Map();
mergedCatalog.forEach((item) => {
const deviceId = item.device_id || "external-device";
if (!grouped.has(deviceId)) grouped.set(deviceId, {
id: deviceId,
name: item.device_name || item.type_label || "USB 扩展设备",
type: item.type_label || item.device_type || "扩展硬件",
actions: [],
});
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
});
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = physicalConnected
? "USB 外设已接入"
: devices.length
? "外设目录已同步"
: health.transport === "simulation"
? "扩展仿真链路"
: listening
? "等待 USB 外设接入"
: "扩展链路检查中";
const statusNote = physicalConnected
? `${health.peer || "GSCU1B"} · 已同步 ${mergedCatalog.length} 项扩展能力`
: devices.length
? `车机已同步 ${devices.length} 个外设、${mergedCatalog.length} 项扩展能力`
: health.message || "插入外设后,车机将自动识别并同步到此处";
mount.dataset.hasDevices = String(devices.length > 0);
mount.innerHTML = `
<header class="v2-extension-sync-heading">
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3></div>
<span data-state="${physicalConnected || devices.length ? "online" : listening ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
</header>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
<div><strong>${statusLabel}</strong><small>${escapeHtml(statusNote)}</small></div>
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
</div>
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
<article class="v2-extension-synced-device">
<header><span><i data-lucide="cpu"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}</small></div><b>${device.actions.length} 项能力</b></header>
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : `
<div class="v2-extension-empty">
<i data-lucide="cable"></i><div><strong>暂无已识别外设</strong><span>外设名称、类型和已学习能力将由车机同步显示</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(lastAction))}">
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(lastAction?.command || lastAction?.type || "等待外设与车端设备联动")}</strong></div>
<span>${escapeHtml(lastResult?.status || lastResult?.message || "STANDBY")}</span>
</footer>`;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
};
window.addEventListener("cognitive-agent:display-sync", (event) => {
snapshot = event.detail || null;
render();
});
render();
return true;
}
function init() { function init() {
if (buildUnifiedExtensionSync()) return;
if (!buildShell()) return; if (!buildShell()) return;
initializeDesktopPet(); initializeDesktopPet();
bindEvents(); bindEvents();
-930
View File
@@ -1,930 +0,0 @@
(function () {
"use strict";
function viewerHasLayout(viewer) {
const bounds = viewer.getBoundingClientRect();
return viewer.isConnected && bounds.width >= 2 && bounds.height >= 2;
}
function deferSeat3dUntilVisible(viewer, options) {
if (viewer.dataset.seat3dPending === "true") return;
viewer.dataset.seat3dPending = "true";
let observer = null;
const retry = () => {
if (!viewerHasLayout(viewer)) return;
observer?.disconnect();
window.removeEventListener("resize", scheduleRetry);
window.removeEventListener("cognitive-agent:module-change", scheduleRetry);
delete viewer.dataset.seat3dPending;
initSeat3d(options);
};
const scheduleRetry = () => window.requestAnimationFrame(retry);
if (window.ResizeObserver) {
observer = new ResizeObserver(retry);
observer.observe(viewer);
}
window.addEventListener("resize", scheduleRetry);
window.addEventListener("cognitive-agent:module-change", scheduleRetry);
scheduleRetry();
}
function initSeat3d(options) {
const {
canvasId,
viewerId,
stateEvent,
resetEvent,
initialAxes = [35, 42, 48, 20],
readyClass = "ready",
axisMode = "repair",
enableAxisDrag = false,
labelBindings = null,
} = options;
const THREE = window.THREE;
const canvas = document.getElementById(canvasId);
const viewer = document.getElementById(viewerId);
if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return;
if (!viewerHasLayout(viewer)) {
deferSeat3dUntilVisible(viewer, options);
return;
}
viewer.dataset.seat3dInitialized = "true";
const markReady = (fallback = false) => {
viewer.classList.add(readyClass);
if (fallback) viewer.classList.add("fallback");
};
const buildFallback = () => {
const fallback = document.createElement("div");
fallback.className = "v2-repair-seat-fallback";
fallback.setAttribute("aria-hidden", "true");
fallback.innerHTML = `
<div class="repair-css-seat">
<div class="repair-css-headrest"></div>
<div class="repair-css-back"><i></i></div>
<div class="repair-css-cushion"><i></i></div>
<div class="repair-css-legrest"></div>
<div class="repair-css-base"></div>
<div class="repair-css-rails"><i></i><i></i></div>
</div>
<span class="repair-css-halo"></span>`;
viewer.insertBefore(fallback, canvas.nextSibling);
window.addEventListener(stateEvent, (event) => {
const detail = event.detail || {};
const axes = new Map((detail.axes || []).map((axis) => [Number(axis.motor), Number(axis.position_pct || 0)]));
fallback.style.setProperty("--repair-slide", `${(50 - (axes.get(0) ?? initialAxes[0])) * 0.18}px`);
fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? initialAxes[1]) - 50) * 0.1}px`);
fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? initialAxes[2]) * 0.24}deg`);
fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? initialAxes[3]) * 0.58}deg`);
fallback.classList.toggle("heating", Number(detail.heatingLevel || 0) > 0);
fallback.classList.toggle("ventilating", Boolean(detail.ventilation));
fallback.classList.toggle("active", !["IDLE", "PASSED"].includes(detail.state));
fallback.classList.toggle("failed", detail.state === "FAILED");
});
markReady(true);
};
if (!THREE) {
buildFallback();
return;
}
const themeProbe = document.createElement("div");
themeProbe.className = "console-v2";
themeProbe.dataset.demoRole = "admin";
themeProbe.style.display = "none";
document.body.append(themeProbe);
const consoleStyles = window.getComputedStyle(themeProbe);
const cssThemeColor = (name, fallback) => {
const value = consoleStyles?.getPropertyValue(name).trim() || fallback;
try {
return new THREE.Color(value);
} catch (_error) {
return new THREE.Color(fallback);
}
};
const white = new THREE.Color(0xffffff);
const black = new THREE.Color(0x000000);
const themeAccent = cssThemeColor("--v2-accent", "#315eaa");
const themeStrong = cssThemeColor("--v2-accent-strong", "#234785");
const themeSoft = cssThemeColor("--v2-accent-soft", "#e1e9fa");
themeProbe.remove();
const mixHex = (from, to, amount) => from.clone().lerp(to, amount).getHex();
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(mixHex(themeSoft, white, 0.12), 8, 15);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch (_error) {
buildFallback();
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.92;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40);
const target = new THREE.Vector3(0, 1.15, 0);
const orbit = { yaw: 0.67, pitch: 0.21, distance: 6.8 };
const pointer = {
active: false,
x: 0,
y: 0,
startX: 0,
startY: 0,
startValue: 0,
axis: null,
mode: "orbit",
moved: false,
dragVector: { x: 1, y: 0 },
dragSource: "model",
};
scene.add(new THREE.HemisphereLight(0xffffff, mixHex(themeStrong, white, 0.46), 1.35));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.8);
keyLight.position.set(-4, 7, 6);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(mixHex(themeAccent, white, 0.42), 0.72);
fillLight.position.set(5, 3, -4);
scene.add(fillLight);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(4.8, 80),
new THREE.MeshStandardMaterial({ color: mixHex(themeSoft, white, 0.18), roughness: 0.94 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(8, 20, themeAccent.getHex(), mixHex(themeAccent, white, 0.7));
grid.position.y = -0.153;
grid.material.transparent = true;
grid.material.opacity = 0.14;
scene.add(grid);
const seatShell = new THREE.MeshPhysicalMaterial({
color: mixHex(themeStrong, black, 0.06),
roughness: 0.54,
metalness: 0.02,
clearcoat: 0.16,
clearcoatRoughness: 0.7,
emissive: mixHex(themeStrong, black, 0.64),
emissiveIntensity: 0.08,
});
const seatInsert = new THREE.MeshPhysicalMaterial({
color: mixHex(themeAccent, white, 0.48),
roughness: 0.72,
metalness: 0,
clearcoat: 0.06,
emissive: 0x000000,
emissiveIntensity: 0,
});
const thermalInsert = seatInsert.clone();
const bolsterMaterial = new THREE.MeshStandardMaterial({
color: themeAccent.getHex(),
roughness: 0.76,
emissive: 0x000000,
emissiveIntensity: 0,
});
const structureMaterial = new THREE.MeshStandardMaterial({
color: mixHex(themeStrong, black, 0.34),
roughness: 0.62,
metalness: 0.35,
});
const railMaterial = new THREE.MeshStandardMaterial({ color: 0x7b8798, roughness: 0.42, metalness: 0.72 });
const edgeMaterial = new THREE.LineBasicMaterial({
color: mixHex(themeStrong, black, 0.38),
transparent: true,
opacity: 0.3,
});
const seamMaterial = new THREE.LineBasicMaterial({
color: mixHex(themeAccent, white, 0.42),
transparent: true,
opacity: 0.72,
});
const axisHitTargets = [];
function markAxisTarget(object, axis) {
if (!object || !Number.isInteger(axis)) return object;
object.traverse((child) => {
if (!child.isMesh) return;
child.userData.calibrationAxis = axis;
axisHitTargets.push(child);
});
return object;
}
function addEdges(mesh, geometry, opacity = 0.3) {
const material = edgeMaterial.clone();
material.opacity = opacity;
mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 38), material));
}
function makeBox(parent, size, position, material) {
const geometry = new THREE.BoxGeometry(...size);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
addEdges(mesh, geometry, 0.26);
parent.add(mesh);
return mesh;
}
function makePanel(parent, shape, depth, position, rotation, material) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 1,
curveSegments: 20,
bevelEnabled: true,
bevelSegments: 4,
bevelSize: 0.045,
bevelThickness: 0.04,
});
geometry.translate(0, 0, -depth / 2);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.rotation.set(...rotation);
mesh.castShadow = true;
mesh.receiveShadow = true;
addEdges(mesh, geometry);
parent.add(mesh);
return mesh;
}
function cushionShape(inset = 0) {
const shape = new THREE.Shape();
const half = 0.69 - inset;
shape.moveTo(-0.52 + inset, 0.61 - inset);
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
shape.lineTo(-half + 0.025, -0.39 + inset);
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
return shape;
}
function backrestShape(inset = 0) {
const shape = new THREE.Shape();
const half = 0.7 - inset;
shape.moveTo(-0.52 + inset, 0);
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
shape.lineTo(-half + 0.035, 1.28 - inset);
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
return shape;
}
function makeSeam(parent, points) {
const geometry = new THREE.BufferGeometry().setFromPoints(points.map((point) => new THREE.Vector3(...point)));
parent.add(new THREE.Line(geometry, seamMaterial));
}
const seatRoot = new THREE.Group();
seatRoot.rotation.y = -0.38;
seatRoot.position.set(0, 0.02, 0);
scene.add(seatRoot);
const leftRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial);
const rightRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial);
markAxisTarget(leftRail, 0);
markAxisTarget(rightRail, 0);
makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structureMaterial);
makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structureMaterial);
const slideRoot = new THREE.Group();
seatRoot.add(slideRoot);
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structureMaterial);
base.rotation.x = Math.PI / 2;
base.scale.set(1.42, 1, 0.55);
base.position.set(0, 0.34, -0.02);
base.castShadow = true;
slideRoot.add(base);
markAxisTarget(base, 3);
const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06);
slideRoot.add(cushionPivot);
makePanel(cushionPivot, cushionShape(), 0.22, [0, 0, 0.08], [-Math.PI / 2, 0, 0], seatShell);
makePanel(cushionPivot, cushionShape(0.16), 0.055, [0, 0.145, 0.03], [-Math.PI / 2, 0, 0], thermalInsert);
[-1, 1].forEach((side) => {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolsterMaterial);
wing.rotation.x = Math.PI / 2;
wing.position.set(side * 0.61, 0.17, 0.08);
wing.castShadow = true;
cushionPivot.add(wing);
});
makeSeam(cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]]);
makeSeam(cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]]);
markAxisTarget(cushionPivot, 3);
const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5);
backrestPivot.rotation.x = -0.09;
slideRoot.add(backrestPivot);
makePanel(backrestPivot, backrestShape(), 0.3, [0, 0, 0], [0, 0, 0], seatShell);
makePanel(backrestPivot, backrestShape(0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert);
[-1, 1].forEach((side) => {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolsterMaterial);
wing.position.set(side * 0.61, 0.78, 0.18);
wing.scale.set(1, 1, 0.72);
wing.castShadow = true;
backrestPivot.add(wing);
});
makeSeam(backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]]);
makeSeam(backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]]);
makeSeam(backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]]);
makeBox(backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], railMaterial);
makeBox(backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], railMaterial);
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), seatShell);
headrest.scale.set(1.35, 1, 0.5);
headrest.position.set(0, 1.98, 0.01);
headrest.castShadow = true;
backrestPivot.add(headrest);
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), seatInsert);
headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18);
backrestPivot.add(headrestInsert);
markAxisTarget(backrestPivot, 2);
const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7);
legrestPivot.rotation.x = 0.1;
slideRoot.add(legrestPivot);
const legShell = makePanel(legrestPivot, cushionShape(0.08), 0.18, [0, 0, 0.36], [-Math.PI / 2, 0, 0], seatShell);
legShell.scale.set(0.9, 0.72, 0.54);
const legInsert = makePanel(legrestPivot, cushionShape(0.2), 0.045, [0, 0.12, 0.4], [-Math.PI / 2, 0, 0], seatInsert);
legInsert.scale.set(0.92, 0.74, 0.56);
markAxisTarget(legrestPivot, 1);
const labelAnchors = {
longitudinal: new THREE.Object3D(),
height: new THREE.Object3D(),
backrest: new THREE.Object3D(),
legrest: new THREE.Object3D(),
};
labelAnchors.longitudinal.position.set(0.62, 0.16, 0.78);
slideRoot.add(labelAnchors.longitudinal);
labelAnchors.height.position.set(-0.72, 0.34, -0.08);
slideRoot.add(labelAnchors.height);
labelAnchors.backrest.position.set(0.62, 1.16, 0.2);
backrestPivot.add(labelAnchors.backrest);
labelAnchors.legrest.position.set(-0.48, 0.14, 0.42);
legrestPivot.add(labelAnchors.legrest);
const statusHalo = new THREE.Mesh(
new THREE.RingGeometry(1.12, 1.18, 72),
new THREE.MeshBasicMaterial({
color: themeAccent.getHex(),
transparent: true,
opacity: 0.12,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
statusHalo.position.set(0, 1.35, -0.35);
statusHalo.renderOrder = 2;
seatRoot.add(statusHalo);
const palettes = {
normal: {
shell: mixHex(themeStrong, black, 0.06),
insert: mixHex(themeAccent, white, 0.48),
bolster: themeAccent.getHex(),
emissive: mixHex(themeStrong, black, 0.7),
},
working: {
shell: themeAccent.getHex(),
insert: mixHex(themeAccent, white, 0.58),
bolster: mixHex(themeAccent, themeStrong, 0.38),
emissive: mixHex(themeAccent, black, 0.54),
},
passed: {
shell: themeStrong.getHex(),
insert: mixHex(themeAccent, white, 0.52),
bolster: themeAccent.getHex(),
emissive: mixHex(themeStrong, black, 0.62),
},
failed: { shell: 0x7a3f43, insert: 0xc97a78, bolster: 0x85494c, emissive: 0x4a100d },
};
const current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" };
const desired = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" };
const labelLines = labelBindings?.lineRoot
? document.querySelector(labelBindings.lineRoot)
: null;
const labelAxisByName = { longitudinal: 0, legrest: 1, backrest: 2, height: 3 };
const labelLayout = labelBindings
? Object.entries(labelBindings.items || {}).map(([name, binding]) => ({
name,
axis: labelAxisByName[name],
side: binding.side === "left" ? "left" : "right",
yOffset: Number(binding.yOffset || 0),
anchor: labelAnchors[name],
tag: document.querySelector(binding.selector),
line: labelLines?.querySelector(`[data-seat-label-line="${name}"]`) || null,
})).filter((item) => item.anchor && item.tag)
: [];
const projected = new THREE.Vector3();
const worldPoint = new THREE.Vector3();
function setLabelHidden(item, hidden) {
item.tag.dataset.hidden = String(hidden);
if (item.line) item.line.dataset.hidden = String(hidden);
}
function layoutSeatLabels() {
if (!labelLayout.length || !viewerHasLayout(viewer)) return;
const width = viewer.clientWidth;
const height = viewer.clientHeight;
const margin = 10;
const gap = 8;
const lanes = { left: [], right: [] };
labelLayout.forEach((item) => {
item.anchor.getWorldPosition(worldPoint);
projected.copy(worldPoint).project(camera);
const visible = projected.z >= -1 && projected.z <= 1;
if (!visible) {
setLabelHidden(item, true);
return;
}
const anchorX = (projected.x * 0.5 + 0.5) * width;
const anchorY = (-projected.y * 0.5 + 0.5) * height;
const tagWidth = Math.max(54, item.tag.offsetWidth);
const tagHeight = Math.max(20, item.tag.offsetHeight);
const desiredLeft = item.side === "left"
? anchorX - tagWidth - 44
: anchorX + 44;
lanes[item.side].push({
...item,
anchorX,
anchorY,
tagWidth,
tagHeight,
left: Math.max(margin, Math.min(width - tagWidth - margin, desiredLeft)),
desiredTop: Math.max(
margin,
Math.min(height - tagHeight - margin, anchorY - tagHeight / 2 + item.yOffset),
),
});
setLabelHidden(item, false);
});
Object.values(lanes).forEach((lane) => {
lane.sort((a, b) => a.desiredTop - b.desiredTop);
let cursor = margin;
lane.forEach((item) => {
item.top = Math.max(item.desiredTop, cursor);
cursor = item.top + item.tagHeight + gap;
});
const overflow = cursor - gap + margin - height;
if (overflow > 0) lane.forEach((item) => { item.top -= overflow; });
cursor = margin;
lane.forEach((item) => {
item.top = Math.max(cursor, Math.min(height - item.tagHeight - margin, item.top));
cursor = item.top + item.tagHeight + gap;
});
lane.forEach((item) => {
item.tag.style.transform = `translate3d(${item.left.toFixed(1)}px, ${item.top.toFixed(1)}px, 0)`;
if (!item.line) return;
const endX = item.side === "left" ? item.left + item.tagWidth : item.left;
const endY = item.top + item.tagHeight / 2;
item.line.setAttribute("x1", item.anchorX.toFixed(1));
item.line.setAttribute("y1", item.anchorY.toFixed(1));
item.line.setAttribute("x2", endX.toFixed(1));
item.line.setAttribute("y2", endY.toFixed(1));
});
});
}
function setActiveAxis(axis) {
labelLayout.forEach((item) => {
item.tag.classList.toggle("active", item.axis === axis);
});
}
function updateCamera() {
const cosPitch = Math.cos(orbit.pitch);
camera.position.set(
target.x + Math.sin(orbit.yaw) * cosPitch * orbit.distance,
target.y + Math.sin(orbit.pitch) * orbit.distance,
target.z + Math.cos(orbit.yaw) * cosPitch * orbit.distance,
);
camera.lookAt(target);
viewer.dataset.orbitYaw = orbit.yaw.toFixed(3);
viewer.dataset.orbitPitch = orbit.pitch.toFixed(3);
}
function resetOrbit() {
const aspect = Math.max(0.55, viewer.clientWidth / Math.max(1, viewer.clientHeight));
orbit.yaw = 0.67;
orbit.pitch = 0.21;
orbit.distance = 6.8 * Math.max(1, Math.min(1.3, 0.78 / aspect));
updateCamera();
}
function resize() {
const width = Math.max(1, viewer.clientWidth);
const height = Math.max(1, viewer.clientHeight);
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
let viewerWasVisible = false;
function synchronizeViewport({ reset = false } = {}) {
if (!viewerHasLayout(viewer)) {
viewerWasVisible = false;
return false;
}
resize();
if (reset || !viewerWasVisible) resetOrbit();
viewerWasVisible = true;
return true;
}
const scheduleViewportSync = () => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
synchronizeViewport();
}));
};
function onHealingState(event) {
const detail = event.detail || {};
(detail.axes || []).forEach((axis) => {
const motor = Number(axis.motor);
if (pointer.active && pointer.mode === "axis"
&& axisToModelIndex[pointer.axis] === motor) return;
if (motor >= 0 && motor < desired.axes.length) {
desired.axes[motor] = Math.max(0, Math.min(100, Number(axis.position_pct || 0)));
}
});
desired.ventilation = Boolean(detail.ventilation);
desired.heating = Math.max(0, Math.min(3, Number(detail.heatingLevel || 0)));
desired.active = !["IDLE", "PASSED"].includes(detail.state);
desired.state = String(detail.state || "IDLE");
statusHalo.material.color.setHex(detail.state === "FAILED" ? 0xc94d51 : themeAccent.getHex());
}
function animate() {
window.requestAnimationFrame(animate);
if (!viewerHasLayout(viewer) || canvas.dataset.contextLost === "true") {
viewerWasVisible = false;
return;
}
if (!viewerWasVisible) synchronizeViewport({ reset: true });
for (let index = 0; index < current.axes.length; index += 1) {
current.axes[index] += (desired.axes[index] - current.axes[index]) * 0.055;
}
current.heating += (desired.heating - current.heating) * 0.05;
current.ventilation = desired.ventilation;
current.active = desired.active;
current.state = desired.state;
slideRoot.position.z = THREE.MathUtils.mapLinear(current.axes[0], 0, 100, -0.46, 0.46);
if (axisMode === "calibration") {
slideRoot.position.y = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.12, 0.16);
cushionPivot.rotation.x = 0;
} else {
slideRoot.position.y = 0;
cushionPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[1], 0, 100, -0.08, 0.16);
}
backrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[2], 0, 100, 0.16, -0.44);
legrestPivot.rotation.x = THREE.MathUtils.mapLinear(current.axes[3], 0, 100, 0.08, -0.68);
const paletteName = current.state === "FAILED"
? "failed"
: current.state === "PASSED"
? "passed"
: current.active
? "working"
: "normal";
const palette = palettes[paletteName];
seatShell.color.lerp(new THREE.Color(palette.shell), 0.09);
seatInsert.color.lerp(new THREE.Color(palette.insert), 0.09);
thermalInsert.color.lerp(new THREE.Color(palette.insert), 0.09);
bolsterMaterial.color.lerp(new THREE.Color(palette.bolster), 0.09);
const heatRatio = current.heating / 3;
seatShell.emissive.lerp(new THREE.Color(heatRatio > 0 ? 0x8e2f18 : palette.emissive), 0.08);
seatShell.emissiveIntensity = 0.08 + heatRatio * 0.42;
thermalInsert.emissive.lerp(new THREE.Color(current.ventilation ? themeAccent.getHex() : palette.emissive), 0.08);
thermalInsert.emissiveIntensity = current.ventilation ? 0.32 : 0.06;
bolsterMaterial.emissive.lerp(new THREE.Color(palette.emissive), 0.08);
bolsterMaterial.emissiveIntensity = current.active ? 0.12 : 0.04;
statusHalo.rotation.z += current.active ? 0.006 : 0.0015;
statusHalo.material.opacity = current.state === "FAILED"
? 0.2 + Math.sin(performance.now() * 0.005) * 0.06
: current.active
? 0.12 + Math.sin(performance.now() * 0.005) * 0.04
: 0.06;
scene.updateMatrixWorld(true);
layoutSeatLabels();
renderer.render(scene, camera);
}
const raycaster = new THREE.Raycaster();
const rayPointer = new THREE.Vector2();
const axisToModelIndex = { 0: 0, 1: 3, 2: 2, 3: 1 };
const labelNameByAxis = { 0: "longitudinal", 1: "legrest", 2: "backrest", 3: "height" };
const fallbackDragVectors = {
0: { x: 1, y: 0 },
1: { x: -0.7, y: -0.7 },
2: { x: 0.8, y: 0.6 },
3: { x: 0, y: -1 },
};
function applyAxisModelPose(axis, value) {
if (axis === 0) {
slideRoot.position.z = THREE.MathUtils.mapLinear(value, 0, 100, -0.46, 0.46);
} else if (axis === 1) {
legrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.08, -0.68);
} else if (axis === 2) {
backrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.16, -0.44);
} else if (axis === 3 && axisMode === "calibration") {
slideRoot.position.y = THREE.MathUtils.mapLinear(value, 0, 100, -0.12, 0.16);
}
}
function projectAxisAnchor(anchor) {
anchor.getWorldPosition(worldPoint);
projected.copy(worldPoint).project(camera);
return {
x: (projected.x * 0.5 + 0.5) * viewer.clientWidth,
y: (-projected.y * 0.5 + 0.5) * viewer.clientHeight,
};
}
function calculateAxisDragVector(axis, value) {
const anchor = labelAnchors[labelNameByAxis[axis]];
if (!anchor) return fallbackDragVectors[axis];
const low = Math.max(0, value - 8);
const high = Math.min(100, value + 8);
if (high - low < 1) return fallbackDragVectors[axis];
const saved = {
slideY: slideRoot.position.y,
slideZ: slideRoot.position.z,
backrestX: backrestPivot.rotation.x,
legrestX: legrestPivot.rotation.x,
};
applyAxisModelPose(axis, low);
scene.updateMatrixWorld(true);
const start = projectAxisAnchor(anchor);
applyAxisModelPose(axis, high);
scene.updateMatrixWorld(true);
const end = projectAxisAnchor(anchor);
slideRoot.position.y = saved.slideY;
slideRoot.position.z = saved.slideZ;
backrestPivot.rotation.x = saved.backrestX;
legrestPivot.rotation.x = saved.legrestX;
scene.updateMatrixWorld(true);
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.hypot(dx, dy);
return length > 0.5
? { x: dx / length, y: dy / length }
: fallbackDragVectors[axis];
}
function startAxisDrag(axis, event, source = "model") {
if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true"
|| !Number.isInteger(axis)) return false;
event.preventDefault();
event.stopPropagation();
pointer.active = true;
pointer.x = event.clientX;
pointer.y = event.clientY;
pointer.startX = event.clientX;
pointer.startY = event.clientY;
pointer.moved = false;
pointer.axis = axis;
pointer.mode = "axis";
pointer.dragSource = source;
const modelIndex = axisToModelIndex[axis];
pointer.startValue = desired.axes[modelIndex];
pointer.dragVector = calculateAxisDragVector(axis, pointer.startValue);
viewer.dataset.dragAxis = String(axis);
viewer.dataset.axisDragSource = source;
viewer.classList.add("axis-dragging");
viewer.classList.remove("axis-hover");
labelLayout.forEach((item) => {
item.tag.classList.toggle("dragging", source === "label" && item.axis === axis);
});
setActiveAxis(axis);
return true;
}
function axisAtPoint(clientX, clientY) {
if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true") return null;
const bounds = viewer.getBoundingClientRect();
if (clientX < bounds.left || clientX > bounds.right
|| clientY < bounds.top || clientY > bounds.bottom) return null;
rayPointer.set(
((clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1,
-((clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1,
);
raycaster.setFromCamera(rayPointer, camera);
const hit = raycaster.intersectObjects(axisHitTargets, false)[0];
return Number.isInteger(hit?.object?.userData?.calibrationAxis)
? hit.object.userData.calibrationAxis
: null;
}
function updateAxisHover(event) {
if (pointer.active) return;
const axis = axisAtPoint(event.clientX, event.clientY);
viewer.classList.toggle("axis-hover", axis !== null);
if (axis === null) delete viewer.dataset.hoverAxis;
else viewer.dataset.hoverAxis = String(axis);
setActiveAxis(axis);
}
viewer.addEventListener("pointermove", updateAxisHover);
viewer.addEventListener("pointerleave", () => {
if (pointer.active) return;
viewer.classList.remove("axis-hover");
delete viewer.dataset.hoverAxis;
setActiveAxis(null);
});
viewer.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const axis = axisAtPoint(event.clientX, event.clientY);
if (axis !== null && startAxisDrag(axis, event)) return;
event.preventDefault();
pointer.active = true;
pointer.x = event.clientX;
pointer.y = event.clientY;
pointer.startX = event.clientX;
pointer.startY = event.clientY;
pointer.moved = false;
pointer.axis = null;
pointer.mode = "orbit";
pointer.dragSource = "model";
viewer.classList.add("dragging");
});
labelLayout.forEach((item) => {
item.tag.dataset.axis = String(item.axis);
item.tag.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
startAxisDrag(item.axis, event, "label");
});
item.tag.addEventListener("pointerenter", () => {
if (!pointer.active && viewer.dataset.axisControlEnabled === "true") {
setActiveAxis(item.axis);
}
});
item.tag.addEventListener("pointerleave", () => {
if (!pointer.active) setActiveAxis(null);
});
item.tag.addEventListener("keydown", (event) => {
if (viewer.dataset.axisControlEnabled !== "true") return;
const direction = ["ArrowRight", "ArrowUp"].includes(event.key)
? 1
: ["ArrowLeft", "ArrowDown"].includes(event.key) ? -1 : 0;
if (!direction) return;
event.preventDefault();
const modelIndex = axisToModelIndex[item.axis];
const value = Math.max(0, Math.min(100, desired.axes[modelIndex] + direction * 2.5));
desired.axes[modelIndex] = value;
const detail = { axis: item.axis, permille: Math.round(value * 10) };
window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", { detail }));
window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", { detail }));
});
});
window.addEventListener("pointermove", (event) => {
if (!pointer.active) return;
event.preventDefault();
const totalX = event.clientX - pointer.startX;
const totalY = event.clientY - pointer.startY;
pointer.moved ||= Math.hypot(totalX, totalY) > 3;
if (pointer.mode === "axis") {
const delta = (
totalX * pointer.dragVector.x + totalY * pointer.dragVector.y
) * 0.55;
const value = Math.max(0, Math.min(100, pointer.startValue + delta));
desired.axes[axisToModelIndex[pointer.axis]] = value;
window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", {
detail: { axis: pointer.axis, permille: Math.round(value * 10) },
}));
return;
}
orbit.yaw -= (event.clientX - pointer.x) * 0.008;
orbit.pitch = Math.max(-0.08, Math.min(0.72, orbit.pitch + (event.clientY - pointer.y) * 0.006));
pointer.x = event.clientX;
pointer.y = event.clientY;
updateCamera();
}, { passive: false });
const endOrbit = () => {
if (pointer.active && pointer.mode === "axis" && pointer.moved) {
const value = desired.axes[axisToModelIndex[pointer.axis]];
window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", {
detail: { axis: pointer.axis, permille: Math.round(value * 10) },
}));
}
pointer.active = false;
pointer.axis = null;
pointer.mode = "orbit";
pointer.dragSource = "model";
delete viewer.dataset.dragAxis;
delete viewer.dataset.axisDragSource;
viewer.classList.remove("dragging");
viewer.classList.remove("axis-dragging");
labelLayout.forEach((item) => item.tag.classList.remove("dragging"));
setActiveAxis(null);
};
window.addEventListener("pointerup", endOrbit);
window.addEventListener("pointercancel", endOrbit);
window.addEventListener("blur", endOrbit);
viewer.addEventListener("wheel", (event) => {
event.preventDefault();
orbit.distance = Math.max(5.1, Math.min(10, orbit.distance + event.deltaY * 0.003));
updateCamera();
}, { passive: false });
viewer.addEventListener("dblclick", resetOrbit);
window.addEventListener(stateEvent, onHealingState);
window.addEventListener(resetEvent, resetOrbit);
if (window.ResizeObserver) {
const resizeObserver = new ResizeObserver(() => synchronizeViewport());
resizeObserver.observe(viewer);
}
window.addEventListener("resize", scheduleViewportSync);
window.addEventListener("cognitive-agent:module-change", scheduleViewportSync);
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
canvas.dataset.contextLost = "true";
viewer.classList.add("context-lost");
});
canvas.addEventListener("webglcontextrestored", () => {
delete canvas.dataset.contextLost;
viewer.classList.remove("context-lost");
synchronizeViewport({ reset: true });
});
synchronizeViewport({ reset: true });
animate();
window.requestAnimationFrame(() => markReady());
}
function init() {
initSeat3d({
canvasId: "repairSeatCanvas",
viewerId: "repairSeatViewer",
stateEvent: "seat-healing-state",
resetEvent: "self-repair-seat-reset",
});
initSeat3d({
canvasId: "calibrationSeatCanvas",
viewerId: "calibrationSeatViewer",
stateEvent: "calibration-seat-state",
resetEvent: "self-calibration-seat-reset",
initialAxes: [50, 50, 20, 0],
readyClass: "is-ready",
axisMode: "calibration",
enableAxisDrag: true,
labelBindings: {
lineRoot: "#calibrationSeatLabelLines",
items: {
backrest: { selector: '[data-seat-axis-label="backrest"]', side: "right", yOffset: -28 },
longitudinal: { selector: '[data-seat-axis-label="longitudinal"]', side: "right", yOffset: 24 },
legrest: { selector: '[data-seat-axis-label="legrest"]', side: "left", yOffset: 24 },
height: { selector: '[data-seat-axis-label="height"]', side: "left", yOffset: -18 },
},
},
});
}
if (document.readyState === "complete") {
init();
} else {
document.addEventListener("DOMContentLoaded", init, { once: true });
}
}());
File diff suppressed because it is too large Load Diff
-797
View File
@@ -1,797 +0,0 @@
(function () {
"use strict";
const API_ROOT = "/api/self-repair";
const VIEW_KEY = "cognitive-agent.self-repair-view";
const MODE_KEY = "cognitive-agent.self-repair-execution";
const STATE_TEXT = {
OK: "正常",
WARN: "告警",
FAULT: "故障",
UNKNOWN: "未检测",
UNMONITORED: "关联待测",
NOT_USED: "未启用",
IDLE: "检测中",
};
const EVENT_META = {
BASELINE: { kind: "system", label: "运行" },
INJECTED: { kind: "fault", label: "注入" },
DIAGNOSED: { kind: "diagnosis", label: "诊断" },
REPAIRING: { kind: "repair", label: "修复" },
VERIFYING: { kind: "repair", label: "复测" },
PASSED: { kind: "success", label: "恢复" },
FAILED: { kind: "fault", label: "失败" },
};
const FALLBACK_SCENARIOS = [
["FI-FIELD-01", "位置标定丢失", "座椅移动 / 位置记忆"],
["FI-FIELD-02", "位置记录CRC回退", "四轴位置 / SMV8持久化"],
["FI-FIELD-03", "瞬态过载误判", "电机移动 / 防夹保护"],
["FI-FIELD-04", "热管理唤醒失败", "通风 / 加热"],
["FI-FIELD-05", "风扇关闭异常", "座椅通风"],
["FI-FIELD-06", "WDT复位后失能", "座椅控制器 / 多功能协同"],
].map(([id, shortTitle, system]) => ({
id,
short_title: shortTitle,
system,
normal_task: "等待连接后加载正常任务",
fault_reaction: "等待连接后加载故障停止状态",
diagnosis: "等待连接后加载 AI 诊断",
repair: "等待连接后加载恢复策略",
recovery_reaction: "等待连接后加载恢复结果",
field_basis: "自修复服务连接后显示实时用例说明",
}));
function fallbackState(message = "正在连接自修复服务") {
return {
scenarios: FALLBACK_SCENARIOS,
healing: {
phase: "IDLE",
phase_text: "等待服务连接",
elapsed_s: 0,
case_id: null,
telemetry: {},
events: [],
},
batch: { active: false, completed: 0, total: 6, passed: 0, failed: 0, results: [] },
runtime: {
active: false,
execution_mode: "shadow",
started_epoch: null,
stopped_epoch: null,
elapsed_s: 0,
healthy_elapsed_s: 0,
motion_profile: "四电机协同自由运动",
cycle_count: 0,
injected_count: 0,
repaired_count: 0,
events: [],
},
bridge: { online: false, protocol_online: false, bus_traffic: false },
bridge_manager: { state: "STARTING", message },
integration: { can_runtime_configured: false, firmware_tools_configured: false },
hardware: { groups: [], connectors: {} },
hardware_session: { active: false, events: [] },
bench_twin: {},
};
}
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const esc = (value) => String(value == null ? "" : value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
let latest = fallbackState();
let selectedCaseId = null;
let selectedPinId = "JC1-1";
let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware"
? "hardware"
: "software";
sessionStorage.removeItem(MODE_KEY);
let executionMode = "shadow";
let polling = false;
let bootstrapRuntimeReset = false;
let toastTimer = 0;
let confirmationResolve = null;
let eventFilter = "all";
function isAdmin() {
return window.CognitiveAgentConsoleV2?.getRole() === "admin"
|| document.body.dataset.demoRole === "admin";
}
function isActive() {
return isAdmin() && document.body.dataset.module === "self-repair";
}
function toast(message, error = false) {
const node = $("#toast");
if (!node) return;
node.textContent = message;
node.className = error ? "toast show error" : "toast show";
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => { node.className = "toast"; }, 3600);
}
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher || $("#v2SelfRepair")) return false;
const repairSidebar = document.createElement("section");
repairSidebar.className = "v2-sidebar-section v2-repair-sidebar";
repairSidebar.innerHTML = `
<div class="v2-section-heading"><span>SELF-REPAIR DESK</span><h2>控制台</h2></div>
<span class="v2-field-caption">修复对象</span>
<div class="v2-repair-view-switch" role="group" aria-label="修复对象">
<button type="button" data-repair-view="software"><i data-lucide="shield-check"></i><span><strong>软件修复</strong><small>注入 · 判断 · 修复 · 验证</small></span></button>
<button type="button" data-repair-view="hardware"><i data-lucide="cable"></i><span><strong>硬件诊断</strong><small>CAN · 支路 · 连接器针脚</small></span></button>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">配置信息</span>
<div class="v2-repair-config-stack">
<div class="v2-repair-config-card"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span>N7</span></div>
<div class="v2-repair-config-card"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong>GPT-5.6-sol</strong></div><span>READY</span></div>
<div class="v2-repair-config-card"><i data-lucide="cpu"></i><div><small>座椅控制器</small><strong>T2G 4BF</strong></div><span>4BF</span></div>
<div class="v2-repair-config-card v2-config-select-card repair-execution-card">
<i data-lucide="workflow"></i>
<div class="v2-config-card-copy"><small>验证方式</small><div class="v2-config-card-select">
<select id="repairExecutionMode" aria-label="选择自修复验证方式">
<option value="shadow">影子仿真</option>
<option value="bench">实物台架</option>
</select>
</div></div><span id="repairExecutionState">SAFE</span>
</div>
</div>
<p class="v2-repair-config-note" id="repairConfigNote">影子仿真不会写入 ECU 或驱动实体设备</p>`;
sidebar.insertBefore(repairSidebar, $(".v2-sidebar-footer"));
const repairFooter = document.createElement("footer");
repairFooter.className = "v2-sidebar-footer v2-repair-footer offline";
repairFooter.innerHTML = `
<span class="v2-live-dot"></span>
<div><strong>自修复安全链路</strong><small id="repairConnectionText">CAN 桥接离线</small></div>
<button type="button" id="repairEmergencyStop" title="STOP ALL + 热管理关闭" aria-label="自修复紧急停止"><i data-lucide="octagon"></i></button>`;
sidebar.append(repairFooter);
const root = document.createElement("section");
root.className = "v2-self-repair";
root.id = "v2SelfRepair";
root.setAttribute("aria-label", "座椅 AI 自修复验证台");
root.innerHTML = `
<header class="v2-repair-heading">
<div class="v2-module-heading"><span class="section-kicker">SELF-REPAIR</span><h1>座椅 AI 自修复验证</h1><p>座椅持续自由运行 · 故障独立注入 · 全过程事件留痕</p></div>
<div class="v2-repair-status-grid">
<div><i id="repairCanDot"></i><span><small>CAN桥接</small><strong id="repairCanStatus">离线</strong></span></div>
<div><i id="repairProtocolDot"></i><span><small>座椅ECU</small><strong id="repairProtocolStatus">未检测</strong></span></div>
<div><i id="repairAiDot"></i><span><small>AI状态</small><strong id="repairAiStatus">等待验证</strong></span></div>
</div>
</header>
<section class="v2-repair-view" data-repair-page="software">
<header class="v2-repair-toolbar">
<div><span>01</span><div><h2>连续运行与自修复验证</h2><p>运行任务与故障注入相互独立;选择用例不会启动或重置座椅</p></div></div>
<div>
<button type="button" id="repairSoftwareRecords"><i data-lucide="history"></i>验证记录</button>
<button type="button" id="repairFirmwareFlash"><i data-lucide="microchip"></i>构建/烧录</button>
<button type="button" id="repairReset"><i data-lucide="rotate-ccw"></i>重置统计</button>
<button class="primary" type="button" id="repairRuntimeToggle"><i data-lucide="play"></i>启动座椅运行</button>
</div>
</header>
<div class="v2-repair-software-grid">
<section class="v2-repair-panel v2-repair-twin-panel">
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>座椅3D孪生</h2></div><b id="repairTwinState">正常基线</b></header>
<div class="v2-repair-seat-viewer" id="repairSeatViewer">
<canvas id="repairSeatCanvas" aria-label="自修复座椅三维孪生,可拖动旋转并滚轮缩放"></canvas>
<div class="v2-repair-model-loading" id="repairModelLoading"><span></span><strong>正在构建座椅孪生</strong><small>坐垫 · 靠背 · 腿托 · 滑轨</small></div>
<div class="v2-repair-orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
<div class="v2-repair-twin-live"><div><small id="repairTwinSource">影子动作仿真</small><strong id="repairTwinTask">等待启动座椅任务</strong><b id="repairTwinStage">待机</b></div><div id="repairAxisStrip"></div><p id="repairTwinReaction">实际动作将同步显示在座椅模型</p></div>
</div>
</section>
<section class="v2-repair-panel v2-repair-cases-panel">
<header><div><span class="section-kicker">FAULT INJECTION</span><h2>量产故障注入</h2></div><b id="repairCaseCount">6 个用例</b></header>
<p class="v2-repair-injection-note">先启动座椅持续运行,再按需选择故障。注入事件会明确标记为管理员操作。</p>
<div class="v2-repair-case-list" id="repairCaseList"></div>
<footer class="v2-repair-injection-footer">
<small id="repairSelectedCase">当前选择:—</small>
<button class="primary" type="button" id="repairInject"><i data-lucide="syringe"></i>注入所选故障</button>
<span id="repairInjectionCount">本次尚未注入故障</span>
</footer>
</section>
<section class="v2-repair-panel v2-repair-log-panel">
<header><div><span class="section-kicker">EVENT STREAM</span><h2>运行与自修复日志</h2></div><b id="repairLogState" data-state="idle">等待启动</b></header>
<div class="v2-repair-log-summary">
<div><small>本次连续运行</small><strong id="repairRuntimeElapsed">00:00:00</strong></div>
<div><small>累计健康运行</small><strong id="repairHealthyElapsed">00:00:00</strong></div>
<div><small>故障 / 已修复</small><strong id="repairFaultSummary">0 / 0</strong></div>
</div>
<div class="v2-repair-log-toolbar">
<div role="group" aria-label="日志筛选">
<button class="active" type="button" data-repair-log-filter="all">全部事件</button>
<button type="button" data-repair-log-filter="fault">仅故障与修复</button>
</div>
<span><i></i>自动跟随最新日志</span>
</div>
<div class="v2-repair-event-stream" id="repairEventStream" aria-live="polite"></div>
</section>
</div>
</section>
<section class="v2-repair-view" data-repair-page="hardware" hidden>
<header class="v2-repair-toolbar">
<div><span>02</span><div><h2>硬件自诊断</h2><p>固件资格检查 → CAN 分层诊断 → 21 个启用针脚主动检测</p></div></div>
<div>
<button type="button" id="repairHardwareSelfTest"><i data-lucide="list-checks"></i>规则自检</button>
<button type="button" id="repairHardwareRecords"><i data-lucide="history"></i>诊断记录</button>
<button class="primary" type="button" id="repairHardwareToggle"><i data-lucide="activity"></i>启动硬件诊断</button>
</div>
</header>
<section class="v2-repair-panel v2-repair-link-panel">
<header><div><span class="section-kicker">CAN LAYERS</span><h2>CAN分层诊断</h2></div><p id="repairBridgeMessage">等待桥接状态</p></header>
<div class="v2-repair-link-layers" id="repairLinkLayers"></div>
<p id="repairLinkDetail">尚未收到实时 CAN 帧,针脚保持未检测状态。</p>
</section>
<div class="v2-repair-hardware-grid">
<section class="v2-repair-panel v2-repair-groups-panel"><header><div><span class="section-kicker">FUNCTION BRANCHES</span><h2>功能支路诊断</h2></div><b id="repairHardwareOverall">等待诊断</b></header><div id="repairHardwareGroups"></div></section>
<section class="v2-repair-panel v2-repair-connectors-panel"><header><div><span class="section-kicker">CONNECTOR PINOUT</span><h2>JA1 / JB1 / JC1 全针脚视图</h2></div><div class="v2-repair-pin-legend"><span data-state="OK"></span>正常<span data-state="UNKNOWN"></span>未检测<span data-state="IDLE"></span>检测中<span data-state="WARN"></span>告警<span data-state="FAULT"></span>故障</div></header><div id="repairConnectors"></div></section>
<section class="v2-repair-panel v2-repair-pin-panel"><header><div><span class="section-kicker">PIN EVIDENCE</span><h2 id="repairPinTitle">JC1-1</h2></div><b id="repairPinState">未检测</b></header><div id="repairPinDetail"></div></section>
</div>
</section>
<dialog class="v2-repair-dialog" id="repairSoftwareDialog"><header><div><strong>软件自修复验证记录</strong><small>成功用例自动保存诊断快照</small></div><button type="button" data-repair-close="repairSoftwareDialog">×</button></header><div id="repairSoftwareRecordsBody"></div></dialog>
<dialog class="v2-repair-dialog" id="repairHardwareDialog"><header><div><strong>硬件自诊断记录</strong><small>支路状态变化与最终报告</small></div><button type="button" data-repair-close="repairHardwareDialog">×</button></header><div id="repairHardwareRecordsBody"></div></dialog>
<dialog class="v2-repair-dialog v2-repair-confirm-dialog" id="repairConfirmDialog" aria-labelledby="repairConfirmTitle">
<header><div><strong id="repairConfirmTitle">确认台架安全条件</strong><small>确认后才会向实物链路提交请求</small></div></header>
<div><p id="repairConfirmMessage"></p><div class="v2-repair-confirm-actions"><button type="button" id="repairConfirmCancel">取消</button><button class="primary" type="button" id="repairConfirmAccept">确认执行</button></div></div>
</dialog>`;
moduleSwitcher.insertAdjacentElement("afterend", root);
const select = $("#repairExecutionMode");
select.value = executionMode;
window.CognitiveAgentConsoleV2?.enhanceSelect(select);
return true;
}
async function apiPost(path, body = {}) {
const response = await fetch(`${API_ROOT}${path}`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Cognitive-Agent-Role": "admin",
},
body: JSON.stringify(body),
});
const value = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
if (!response.ok || value.ok === false) {
throw new Error(value.message || value.error || `HTTP ${response.status}`);
}
return value;
}
function showView(view) {
activeView = view === "hardware" ? "hardware" : "software";
sessionStorage.setItem(VIEW_KEY, activeView);
$$('[data-repair-view]').forEach((button) => {
const active = button.dataset.repairView === activeView;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
$$(".v2-repair-view").forEach((page) => {
const active = page.dataset.repairPage === activeView;
page.hidden = !active;
page.classList.toggle("active", active);
});
window.setTimeout(() => {
window.dispatchEvent(new Event("resize"));
if (activeView === "software") {
window.dispatchEvent(new Event("self-repair-seat-reset"));
}
}, 80);
}
function scenarioForDisplay() {
if (!latest?.scenarios?.length) return null;
return latest.scenarios.find((item) => item.id === selectedCaseId)
|| latest.scenarios[0];
}
function formatDuration(seconds) {
const whole = Math.max(0, Math.floor(Number(seconds || 0)));
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const remainder = whole % 60;
return [hours, minutes, remainder]
.map((value) => String(value).padStart(2, "0"))
.join(":");
}
function formatEventTime(epoch) {
if (!epoch) return "--:--:--.---";
const value = new Date(Number(epoch) * 1000);
const base = [value.getHours(), value.getMinutes(), value.getSeconds()]
.map((item) => String(item).padStart(2, "0"))
.join(":");
return `${base}.${String(value.getMilliseconds()).padStart(3, "0")}`;
}
function runtimeState() {
return latest.runtime || fallbackState().runtime;
}
function renderEventStream() {
const runtime = runtimeState();
const healing = latest.healing || {};
const scenario = (latest.scenarios || []).find((item) => item.id === healing.case_id)
|| scenarioForDisplay();
const runtimeEvents = (runtime.events || []).map((event) => ({
...event,
kind: event.kind || "system",
label: event.label || "运行",
}));
const healingEvents = (healing.events || []).map((event) => {
const meta = EVENT_META[event.phase] || EVENT_META.BASELINE;
const injected = event.phase === "INJECTED";
return {
...event,
kind: meta.kind,
label: meta.label,
title: injected
? `管理员注入「${scenario?.short_title || "故障用例"}」`
: event.title,
tags: [
injected ? "来源:admin" : null,
healing.run_id ? `事件:${healing.run_id}` : null,
healing.diagnosed_code && ["DIAGNOSED", "REPAIRING", "VERIFYING", "PASSED", "FAILED"].includes(event.phase)
? `诊断码:${healing.diagnosed_code}` : null,
].filter(Boolean),
};
});
const activeRepair = ["INJECTED", "DIAGNOSED", "REPAIRING", "VERIFYING"].includes(healing.phase);
const twin = latest.bench_twin || {};
const naturalFault = runtime.active && !activeRepair && ["IDLE", "PASSED"].includes(healing.phase)
&& Number(twin.system?.faulted_mask || 0) !== 0;
const liveEvents = runtime.active && !activeRepair ? [{
epoch: latest.generated_epoch,
time: formatEventTime(latest.generated_epoch),
kind: naturalFault ? "fault" : "heartbeat",
label: naturalFault ? "自然故障" : "运行心跳",
title: naturalFault
? "运行中检测到座椅异常"
: `座椅已正常运行 ${formatDuration(runtime.healthy_elapsed_s)}`,
detail: naturalFault
? `ECU FaultedMask=0x${Number(twin.system.faulted_mask).toString(16).toUpperCase()},AI正在持续监测。`
: `${runtime.motion_profile} · 已完成 ${runtime.cycle_count || 0} 个动作周期。`,
tags: (twin.axes?.length ? twin.axes : [35, 42, 48, 20].map((position_pct, motor) => ({ motor, position_pct })))
.map((axis) => `M${axis.motor} ${Number(axis.position_pct || 0).toFixed(0)}%`),
}] : [];
const events = [...runtimeEvents, ...healingEvents, ...liveEvents]
.filter((event) => eventFilter === "all" || ["fault", "diagnosis", "repair", "success"].includes(event.kind))
.sort((left, right) => Number(left.epoch || 0) - Number(right.epoch || 0));
$("#repairEventStream").innerHTML = events.length
? events.map((event) => `<article data-kind="${esc(event.kind)}"><time>${esc(event.time || "--:--:--.---")}</time><i></i><div><header><span>${esc(event.label)}</span><strong>${esc(event.title)}</strong></header><p>${esc(event.detail || "状态已更新")}</p>${event.tags?.length ? `<footer>${event.tags.map((tag) => `<span>${esc(tag)}</span>`).join("")}</footer>` : ""}</div></article>`).join("")
: `<div class="v2-repair-log-empty"><i data-lucide="scroll-text"></i><strong>${runtime.active ? "座椅正在正常运行" : "等待启动座椅运行"}</strong><span>${runtime.active ? "运行心跳、故障、诊断和修复事件将在这里持续追加。" : "启动后将记录运行时长、四轴状态和后续故障注入。"}</span></div>`;
$("#repairEventStream").scrollTop = $("#repairEventStream").scrollHeight;
$("#repairRuntimeElapsed").textContent = formatDuration(runtime.elapsed_s);
$("#repairHealthyElapsed").textContent = formatDuration(runtime.healthy_elapsed_s);
$("#repairFaultSummary").textContent = `${Number(runtime.injected_count || 0)} / ${Number(runtime.repaired_count || 0)}`;
const failed = healing.phase === "FAILED";
const repairing = activeRepair;
const logState = $("#repairLogState");
logState.textContent = failed ? "需要处理" : repairing ? "自修复中" : healing.phase === "PASSED" && runtime.active ? "已恢复运行" : runtime.active ? "健康运行" : "等待启动";
logState.dataset.state = failed ? "fault" : repairing ? "repairing" : runtime.active ? "healthy" : "idle";
}
function renderCases() {
if (!selectedCaseId && latest.scenarios?.length) selectedCaseId = latest.scenarios[0].id;
const results = new Map((latest.batch?.results || []).map((item) => [item.case_id, item]));
$("#repairCaseList").innerHTML = (latest.scenarios || []).map((scenario, index) => {
const result = results.get(scenario.id);
const running = latest.batch?.current_case_id === scenario.id
|| (latest.healing?.case_id === scenario.id
&& !["IDLE", "PASSED", "FAILED"].includes(latest.healing.phase));
const state = result?.result === "PASS" ? "complete"
: result?.result === "FAIL" ? "failed"
: running ? "active" : "pending";
return `<button type="button" data-repair-case="${esc(scenario.id)}" data-state="${state}" class="${selectedCaseId === scenario.id ? "selected" : ""}"><span>${String(index + 1).padStart(2, "0")}</span><div><strong>${esc(scenario.short_title)}</strong><small>${esc(scenario.system)}</small></div><i></i></button>`;
}).join("");
$$('[data-repair-case]').forEach((button) => {
button.addEventListener("click", () => {
if (latest.batch?.active) return;
selectedCaseId = button.dataset.repairCase;
renderCases();
renderTwin();
});
});
const selected = scenarioForDisplay();
const runtime = runtimeState();
$("#repairCaseCount").textContent = `${(latest.scenarios || []).length} 个用例`;
$("#repairSelectedCase").textContent = selected
? `当前选择:${selected.short_title}` : "当前选择:—";
$("#repairInjectionCount").textContent = Number(runtime.injected_count || 0)
? `本次已注入 ${runtime.injected_count} 次 · 已修复 ${runtime.repaired_count || 0} 次`
: "本次尚未注入故障";
const activeRepair = !["IDLE", "PASSED", "FAILED"].includes(latest.healing?.phase);
$("#repairInject").disabled = !runtime.active || activeRepair || latest.batch?.active;
$("#repairInject").innerHTML = `<i data-lucide="syringe"></i>${activeRepair ? "自修复处理中" : runtime.active ? "注入所选故障" : "请先启动座椅运行"}`;
}
function renderTwin() {
const healing = latest.healing;
const scenario = scenarioForDisplay();
if (!scenario) return;
const runtime = runtimeState();
const telemetry = healing.telemetry || {};
const live = latest.bench_twin || {};
const bench = executionMode === "bench";
const positions = Array.isArray(telemetry.axis_positions)
? telemetry.axis_positions : [35, 42, 48, 20];
const axes = bench && live.axes?.length === 4
? live.axes
: positions.map((position, motor) => ({
motor,
label: ["水平滑轨", "坐盆升降", "靠背角度", "腿托角度"][motor],
position_pct: Number(position),
active: (telemetry.active_axes || []).includes(motor),
position_valid: telemetry.position_valid !== false,
}));
const stage = {
NORMAL_TASK: "正常任务",
FAULT_REACTION: "故障停止",
REPAIR: "执行修复",
VERIFYING: "安全复测",
RESUMING: "继续原任务",
COMPLETE: "任务已恢复",
SAFE_STOP: "安全停止",
}[telemetry.operation_stage] || healing.phase_text;
$("#repairTwinSource").textContent = bench
? (live.online ? "实物 CAN 同步" : "实物 CAN 暂离线")
: "影子动作仿真";
$("#repairTwinTask").textContent = healing.phase === "IDLE"
? (runtime.active ? runtime.motion_profile : "等待启动座椅运行")
: scenario.normal_task;
$("#repairTwinStage").textContent = healing.phase === "IDLE" && runtime.active
? "自由运动" : stage || "待机";
$("#repairTwinState").textContent = healing.phase === "IDLE"
? (runtime.active ? "持续运行中" : "等待启动")
: healing.phase === "PASSED" && runtime.active ? "已恢复运行" : healing.phase_text;
$("#repairTwinReaction").textContent = telemetry.bench_reaction
|| (healing.phase === "IDLE"
? (runtime.active
? `四电机按设定轨迹协同运动 · 已完成 ${runtime.cycle_count || 0} 个周期`
: "实际动作将同步显示在座椅模型")
: scenario.fault_reaction);
$("#repairAxisStrip").innerHTML = axes.map((axis) => {
const position = Math.max(0, Math.min(100, Number(axis.position_pct || 0)));
return `<span class="${axis.active ? "active" : ""}"><b>M${axis.motor}</b><i style="--position:${position}%"></i><em>${position.toFixed(0)}%</em></span>`;
}).join("");
window.dispatchEvent(new CustomEvent("seat-healing-state", { detail: {
state: healing.phase,
axes: axes.map((axis) => ({
motor: axis.motor,
position_pct: Number(axis.position_pct || 0),
active: Boolean(axis.active),
valid: axis.position_valid !== false,
})),
ventilation: Boolean((bench ? live.thermal : telemetry).fan_running),
heatingLevel: Number((bench ? live.thermal : telemetry).heater_level || 0),
} }));
}
function connectorRows(name, pins) {
if (name === "JA1") return Array.from({ length: 7 }, (_, index) => [index + 1, index + 8]);
if (name === "JB1") return Array.from({ length: 9 }, (_, index) => [(index + 1) * 2, index * 2 + 1]);
return Array.from({ length: 20 }, (_, index) => [index + 1, index + 21]);
}
function pinButton(pin) {
if (!pin) return "<span></span>";
const state = pin.enabled === false ? "NOT_USED" : pin.state;
return `<button type="button" data-repair-pin="${esc(pin.id)}" data-state="${esc(state)}" class="${selectedPinId === pin.id ? "selected" : ""}" aria-label="${esc(`${pin.id} ${pin.net} ${STATE_TEXT[state] || state}`)}"><b>${String(pin.number).padStart(2, "0")}</b><small>${esc(pin.net)}</small></button>`;
}
function renderConnectors(hardware) {
const connectors = hardware.connectors || {};
const names = ["JA1", "JB1", "JC1"];
$("#repairConnectors").innerHTML = names.map((name) => {
const pins = connectors[name] || [];
const byNumber = new Map(pins.map((pin) => [Number(pin.number), pin]));
const ok = pins.filter((pin) => pin.state === "OK").length;
const abnormal = pins.filter((pin) => ["WARN", "FAULT"].includes(pin.state)).length;
return `<section class="v2-repair-connector" data-connector="${name}"><header><strong>${name}</strong><span>已验 ${ok} · 异常 ${abnormal}</span></header><div class="v2-repair-connector-body">${name === "JC1" ? "<i class=\"keying k1\"></i><i class=\"keying k2\"></i><i class=\"keying k3\"></i><i class=\"keying k4\"></i>" : ""}<div>${connectorRows(name, pins).map((row) => `<span>${pinButton(byNumber.get(row[0]))}${pinButton(byNumber.get(row[1]))}</span>`).join("")}</div></div></section>`;
}).join("");
$$('[data-repair-pin]').forEach((button) => button.addEventListener("click", () => {
selectedPinId = button.dataset.repairPin;
renderHardware();
}));
}
function renderPinDetail(hardware) {
const allPins = Object.values(hardware.connectors || {}).flat();
const pin = allPins.find((item) => item.id === selectedPinId) || allPins[0];
if (!pin) return;
selectedPinId = pin.id;
const state = pin.enabled === false ? "NOT_USED" : pin.state;
$("#repairPinTitle").textContent = pin.id;
$("#repairPinState").textContent = STATE_TEXT[state] || state;
$("#repairPinState").dataset.state = state;
$("#repairPinDetail").innerHTML = `
<dl><div><dt>原理图网络</dt><dd>${esc(pin.net)}</dd></div><div><dt>座椅侧功能</dt><dd>${esc(pin.function)}</dd></div><div><dt>板内二级溯源</dt><dd>${esc(pin.mcu || "—")}</dd></div><div><dt>本工程启用</dt><dd>${pin.enabled === false ? "否" : "是"}</dd></div></dl>
<section><h3>判定依据</h3><ul>${(pin.evidence || ["等待诊断证据"]).map((item) => `<li>${esc(item)}</li>`).join("")}</ul></section>
<section><h3>建议检查顺序</h3><ol>${(pin.actions || ["启动硬件诊断后生成建议"]).map((item) => `<li>${esc(item)}</li>`).join("")}</ol></section>`;
}
function renderHardware() {
const hardware = latest.hardware || {};
const session = latest.hardware_session || {};
const bridge = latest.bridge || {};
const manager = latest.bridge_manager || {};
const adapterOnline = hardware.link?.adapter === "OK"
|| Boolean(bridge.bus?.opened)
|| (bridge.online && bridge.bus_traffic);
const layers = [
["1", "CAN Runtime", bridge.online ? "正常" : "离线", bridge.online ? "OK" : "UNKNOWN"],
["2", "CAN设备 / 通道", adapterOnline ? "设备与通道已打开" : "未连接", adapterOnline ? "OK" : "UNKNOWN"],
["3", "总线实时流量", bridge.bus_traffic ? "正常" : "未检测", bridge.bus_traffic ? "OK" : "WARN"],
["4", "座椅ECU协议", bridge.protocol_online ? "0x301~0x305在线" : "未检测", bridge.protocol_online ? "OK" : "UNKNOWN"],
];
$("#repairLinkLayers").innerHTML = layers.map((item) => `<div data-state="${item[3]}"><small>${item[0]} · ${item[1]}</small><strong>${item[2]}</strong></div>`).join("");
$("#repairBridgeMessage").textContent = manager.message || "等待桥接状态";
$("#repairLinkDetail").textContent = bridge.protocol_online
? `协议在线 · 最近帧 ${(bridge.recent_frame_ids || []).join(" / ")}`
: bridge.bus_traffic
? "CAN 有实时流量,但尚未识别本工程 0x301~0x305 协议。"
: "尚未收到实时 CAN 帧,启用针脚保持未检测状态,不表示针脚故障。";
$("#repairHardwareGroups").innerHTML = (hardware.groups || []).map((group) => `<button type="button" data-state="${esc(group.state)}"><i></i><div><strong>${esc(group.name)}</strong><small>${esc(group.summary)}</small></div><span>${esc(STATE_TEXT[group.state] || group.state)}</span></button>`).join("");
$("#repairHardwareOverall").textContent = `已验证 ${hardware.connector_ok_count || 0}/21 · 异常 ${(hardware.connector_fault_count || 0) + (hardware.connector_warn_count || 0)}`;
const toggle = $("#repairHardwareToggle");
toggle.innerHTML = session.active
? '<i data-lucide="square"></i>停止硬件诊断'
: '<i data-lucide="activity"></i>启动硬件诊断';
renderConnectors(hardware);
renderPinDetail(hardware);
const events = session.events || [];
$("#repairHardwareRecordsBody").innerHTML = `<p class="v2-repair-report-path">${esc(session.report_path || "停止诊断后自动保存硬件报告")}</p>${events.length ? `<table><thead><tr><th>时间</th><th>支路</th><th>状态</th><th>摘要</th></tr></thead><tbody>${[...events].reverse().map((event) => `<tr><td>${esc(event.time)}</td><td>${esc(event.name)}</td><td>${esc(STATE_TEXT[event.state] || event.state)}</td><td>${esc(event.summary)}</td></tr>`).join("")}</tbody></table>` : "<p>尚无硬件诊断事件。</p>"}`;
}
function renderGlobal() {
const bridge = latest.bridge || {};
const manager = latest.bridge_manager || {};
const healing = latest.healing || {};
const hardwarePhase = latest.hardware_session?.progress?.phase;
const integration = latest.integration || {};
const runtime = runtimeState();
$("#repairCanStatus").textContent = bridge.online ? "在线" : "离线";
$("#repairCanDot").dataset.state = bridge.online ? "online" : manager.state === "STARTING" ? "working" : "offline";
$("#repairProtocolStatus").textContent = bridge.protocol_online ? "协议在线" : "未检测";
$("#repairProtocolDot").dataset.state = bridge.protocol_online ? "online" : "offline";
const hardwareState = {
BASELINE: ["硬件检测中", "working"],
RUNNING: ["硬件检测中", "working"],
PASSED: ["21针通过", "online"],
FAILED: ["硬件异常", "offline"],
}[hardwarePhase];
const aiState = activeView === "hardware" && hardwareState
? hardwareState
: runtime.active && healing.phase === "IDLE"
? ["持续监控", "online"]
: runtime.active && healing.phase === "PASSED"
? ["已恢复监控", "online"]
: [healing.phase_text || "等待验证", healing.phase === "PASSED"
? "online"
: healing.phase === "IDLE" ? "offline" : "working"];
$("#repairAiStatus").textContent = aiState[0];
$("#repairAiDot").dataset.state = aiState[1];
$("#repairConnectionText").textContent = bridge.online ? "CAN 桥接在线" : manager.message || "CAN 桥接离线";
$(".v2-repair-footer").classList.toggle("offline", !bridge.online);
const benchOption = $('#repairExecutionMode option[value="bench"]');
if (benchOption) benchOption.disabled = !integration.can_runtime_configured;
if (executionMode === "bench" && !integration.can_runtime_configured) {
executionMode = "shadow";
$("#repairExecutionMode").value = "shadow";
sessionStorage.setItem(MODE_KEY, executionMode);
}
$("#repairExecutionState").textContent = executionMode === "bench" ? "HIL" : "SAFE";
$("#repairConfigNote").textContent = executionMode === "bench"
? "真实台架动作受本机 admin、安全确认与桥接状态约束"
: "影子仿真不会写入 ECU 或驱动实体设备";
$("#repairFirmwareFlash").disabled = !integration.firmware_tools_configured;
const runtimeToggle = $("#repairRuntimeToggle");
const repairActive = ["INJECTED", "DIAGNOSED", "REPAIRING", "VERIFYING"].includes(healing.phase);
runtimeToggle.innerHTML = runtime.active
? '<i data-lucide="square"></i>停止座椅运行'
: '<i data-lucide="play"></i>启动座椅运行';
runtimeToggle.classList.toggle("danger", runtime.active);
runtimeToggle.classList.toggle("primary", !runtime.active);
runtimeToggle.disabled = repairActive;
window.CognitiveAgentConsoleV2?.enhanceSelect($("#repairExecutionMode"));
}
function renderRecords() {
const results = latest.batch?.results || [];
const events = latest.healing?.events || [];
$("#repairSoftwareRecordsBody").innerHTML = `<p class="v2-repair-report-path">${esc(latest.latest_report_path || "成功用例将自动保存诊断快照")}</p>${results.length ? `<table><thead><tr><th>用例</th><th>结果</th><th>耗时</th><th>诊断码</th></tr></thead><tbody>${results.map((item) => `<tr><td>${esc(item.title)}</td><td>${esc(item.result)}</td><td>${Number(item.duration_s || 0).toFixed(2)} s</td><td>${esc(item.diagnosed_code || "—")}</td></tr>`).join("")}</tbody></table>` : "<p>尚无批量验证记录。</p>"}<div class="v2-repair-event-list">${events.map((event) => `<article><strong>${esc(event.title)}</strong><span>${esc(event.detail)}</span></article>`).join("")}</div>`;
}
function renderAll() {
renderGlobal();
renderCases();
renderEventStream();
renderTwin();
renderHardware();
renderRecords();
// Polling refreshes data only. Reapplying the active view here would also
// reset the user's current 3D orbit every 600 ms.
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
async function poll() {
if (!isActive() || polling) return;
polling = true;
try {
const response = await fetch(`${API_ROOT}/state`, {
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json", "X-Cognitive-Agent-Role": "admin" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
latest = await response.json();
if (!bootstrapRuntimeReset) {
bootstrapRuntimeReset = true;
const backendMode = latest.runtime?.execution_mode === "bench" ? "bench" : "shadow";
if (backendMode === "bench") {
// A physical session is authoritative: retain it and render bench feedback.
executionMode = "bench";
sessionStorage.setItem(MODE_KEY, executionMode);
if ($("#repairExecutionMode")) $("#repairExecutionMode").value = executionMode;
} else {
// Shadow state is one-run-only. A full web runtime restart always returns
// counters, injected faults, and the twin to the initial safe state.
if (latest.runtime?.active) {
await apiPost("/runtime", { active: false, execution_mode: "shadow" });
}
await apiPost("/runtime/reset-statistics");
const resetResponse = await fetch(`${API_ROOT}/state`, {
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json", "X-Cognitive-Agent-Role": "admin" },
});
if (resetResponse.ok) latest = await resetResponse.json();
}
}
if (!["IDLE"].includes(latest.healing?.phase)) selectedCaseId = latest.healing.case_id;
renderAll();
} catch (error) {
$("#repairConnectionText").textContent = `自修复服务连接失败:${error.message}`;
$(".v2-repair-footer").classList.add("offline");
} finally {
polling = false;
}
}
async function runAction(action, successMessage) {
try {
await action();
toast(successMessage);
await poll();
} catch (error) {
toast(error.message, true);
}
}
function finishConfirmation(accepted) {
const dialog = $("#repairConfirmDialog");
const resolve = confirmationResolve;
confirmationResolve = null;
if (dialog?.open) dialog.close(accepted ? "confirmed" : "cancelled");
if (resolve) resolve(accepted);
}
function confirmAction(message, confirmLabel = "确认执行") {
const dialog = $("#repairConfirmDialog");
if (!dialog || confirmationResolve) return Promise.resolve(false);
$("#repairConfirmMessage").textContent = message;
$("#repairConfirmAccept").textContent = confirmLabel;
dialog.showModal();
return new Promise((resolve) => {
confirmationResolve = resolve;
});
}
function bindEvents() {
$$('[data-repair-view]').forEach((button) => button.addEventListener("click", () => showView(button.dataset.repairView)));
$("#repairExecutionMode").addEventListener("change", (event) => {
executionMode = event.target.value === "bench" ? "bench" : "shadow";
sessionStorage.setItem(MODE_KEY, executionMode);
if (latest) renderAll();
});
$("#repairRuntimeToggle").addEventListener("click", async () => {
const active = Boolean(runtimeState().active);
if (executionMode === "bench" && !(await confirmAction(
active
? "即将停止实物座椅连续运行。请确认可安全停止。"
: "即将启动实物座椅四电机协同自由运动。请确认运动区域无人、无遮挡且急停可用。",
active ? "确认停止" : "确认启动",
))) return;
runAction(
() => apiPost("/runtime", { active: !active, execution_mode: executionMode }),
active ? "座椅连续运行已停止" : "座椅连续运行已启动",
);
});
$("#repairInject").addEventListener("click", async () => {
const scenario = scenarioForDisplay();
if (!runtimeState().active || !scenario) return;
if (executionMode === "bench" && !(await confirmAction(
`即将向实物 ECU 注入「${scenario.short_title}」并驱动座椅自修复。请确认运动区域无人、无遮挡且急停可用。`,
"确认注入",
))) return;
runAction(
() => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }),
executionMode === "bench" ? "实物故障已注入" : "影子故障已注入,开始自修复",
);
});
$("#repairReset").addEventListener("click", async () => {
runAction(() => apiPost("/runtime/reset-statistics"), "本次运行统计已重置");
});
$("#repairFirmwareFlash").addEventListener("click", async () => {
if (!(await confirmAction("即将构建并烧录台架测试固件。请确认座椅运动区域无人、急停可用且 CMSIS-DAP 已连接。", "确认构建/烧录"))) return;
runAction(() => apiPost("/firmware/build-flash", { confirmed_bench_clear: true }), "固件构建/烧录流程已完成");
});
$("#repairHardwareToggle").addEventListener("click", async () => {
if (!latest) return;
if (latest.hardware_session?.active) {
runAction(() => apiPost("/hardware/stop"), "硬件诊断已停止并保存报告");
return;
}
if (!(await confirmAction("硬件诊断可能自动烧录测试固件,并短时驱动四路电机、风扇与低档加热。请确认台架区域安全且急停可用。", "确认启动诊断"))) return;
runAction(() => apiPost("/hardware/start", { confirmed_bench_clear: true }), "21 针主动硬件诊断已启动");
});
$("#repairHardwareSelfTest").addEventListener("click", () => runAction(() => apiPost("/hardware/self-test"), "72 针映射和诊断规则自检完成"));
$("#repairEmergencyStop").addEventListener("click", async () => {
if (!(await confirmAction("确认提交 STOP ALL 和热管理关闭请求?", "确认紧急停止"))) return;
runAction(() => apiPost("/emergency-stop"), "紧急停止请求已提交");
});
$("#repairConfirmCancel").addEventListener("click", () => finishConfirmation(false));
$("#repairConfirmAccept").addEventListener("click", () => finishConfirmation(true));
$("#repairConfirmDialog").addEventListener("cancel", (event) => {
event.preventDefault();
finishConfirmation(false);
});
$("#repairConfirmDialog").addEventListener("close", () => {
if (confirmationResolve) finishConfirmation(false);
});
$("#repairSoftwareRecords").addEventListener("click", () => $("#repairSoftwareDialog").showModal());
$("#repairHardwareRecords").addEventListener("click", () => $("#repairHardwareDialog").showModal());
$$('[data-repair-log-filter]').forEach((button) => button.addEventListener("click", () => {
eventFilter = button.dataset.repairLogFilter === "fault" ? "fault" : "all";
$$('[data-repair-log-filter]').forEach((item) => item.classList.toggle("active", item === button));
renderEventStream();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}));
$$('[data-repair-close]').forEach((button) => button.addEventListener("click", () => $(`#${button.dataset.repairClose}`).close()));
window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-repair") poll();
});
window.addEventListener("cognitive-agent:demo-role-change", () => {
if (isActive()) poll();
});
}
function init() {
if (!buildShell()) return;
bindEvents();
showView(activeView);
renderAll();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
if (isActive()) poll();
window.setInterval(poll, 600);
}
document.addEventListener("DOMContentLoaded", init);
}());
+20 -20
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v120"; const CACHE_VERSION = "cognitive-agent-pad-v178";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -6,21 +6,15 @@ const APP_SHELL = [
"/index.html", "/index.html",
"/styles.css?v=dual-seat-9", "/styles.css?v=dual-seat-9",
"/network.js?v=android-pad-1", "/network.js?v=android-pad-1",
"/app.js?v=dual-seat-82", "/app.js?v=dual-seat-85",
"/gesture-control.js?v=android-pad-7", "/gesture-control.js?v=android-pad-8",
"/pwa.js?v=android-pad-1", "/pwa.js?v=android-pad-2",
"/console-v2.css?v=desktop-refactor-107", "/console-v2.css?v=sync-layout-26",
"/console-v2.js?v=desktop-refactor-36", "/console-v2.js?v=sync-layout-26",
"/self-evolution.css?v=seat-weighing-5", "/self-extension.css?v=sync-layout-26",
"/self-evolution.js?v=seat-weighing-2", "/self-extension.js?v=sync-layout-26",
"/self-extension.css?v=extension-19", "/assets/pets/self-extension-cat.png?v=3",
"/self-extension.js?v=extension-21", "/assets/pets/self-extension-cat-entrance.png?v=3",
"/self-calibration.css?v=calibration-12",
"/self-calibration.js?v=calibration-8",
"/self-repair.css?v=repair-21",
"/self-repair.js?v=repair-21",
"/self-repair-seat3d.js?v=repair-21",
"/assets/models/n7_seat.glb?v=e7f50f03",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icons/app-icon-192.png", "/icons/app-icon-192.png",
"/icons/app-icon-512.png", "/icons/app-icon-512.png",
@@ -39,6 +33,7 @@ self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(SHELL_CACHE) caches.open(SHELL_CACHE)
.then((cache) => cache.addAll(APP_SHELL)) .then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
.then(() => self.clients.matchAll({ includeUncontrolled: true })) .then(() => self.clients.matchAll({ includeUncontrolled: true }))
.then((clients) => clients.forEach((client) => client.postMessage({ type: "OFFLINE_READY" }))) .then((clients) => clients.forEach((client) => client.postMessage({ type: "OFFLINE_READY" })))
); );
@@ -57,12 +52,13 @@ self.addEventListener("activate", (event) => {
}); });
async function cacheFirst(request) { async function cacheFirst(request) {
const cached = await caches.match(request); const shellCache = await caches.open(SHELL_CACHE);
const runtimeCache = await caches.open(RUNTIME_CACHE);
const cached = (await shellCache.match(request)) || (await runtimeCache.match(request));
if (cached) return cached; if (cached) return cached;
const response = await fetch(request); const response = await fetch(request);
if (response.ok) { if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE); await runtimeCache.put(request, response.clone());
await cache.put(request, response.clone());
} }
return response; return response;
} }
@@ -76,7 +72,11 @@ async function navigationResponse(request) {
} }
return response; return response;
} catch (_error) { } catch (_error) {
return (await caches.match("/index.html")) || Response.error(); const shellCache = await caches.open(SHELL_CACHE);
const runtimeCache = await caches.open(RUNTIME_CACHE);
return (await runtimeCache.match("/index.html"))
|| (await shellCache.match("/index.html"))
|| Response.error();
} }
} }
@@ -12,7 +12,6 @@ from typing import Any
from . import protocol from . import protocol
from .mqtt_client import MqttSeatPoseClient from .mqtt_client import MqttSeatPoseClient
from .transports import PythonCanTransport, SimulationTransport, ZlgBridgeTransport from .transports import PythonCanTransport, SimulationTransport, ZlgBridgeTransport
from .video import VideoBridge
AXES = ( AXES = (
{"id": 0, "name": "前后", "title": "前后电机", "unit": "cm", "physical_range": 21.5, "nominal": 480}, {"id": 0, "name": "前后", "title": "前后电机", "unit": "cm", "physical_range": 21.5, "nominal": 480},
@@ -33,8 +32,14 @@ DEFAULT_SETTINGS: dict[str, Any] = {
"dll_path": r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll", "dll_path": r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
"resistance": 1, "resistance": 1,
}, },
"mqtt": {"host": "172.20.10.12", "port": 1883, "topic": "zed/seatpose"}, "mqtt": {"host": "172.20.10.6", "port": 1883, "topic": "zed/seatpose"},
"video": {"host": "172.20.10.12", "port": 8554, "path": "zed"}, "video": {
"scheme": "http",
"host": "172.20.10.6",
"port": 8889,
"path": "zed",
"transport": "webrtc",
},
} }
@@ -60,7 +65,6 @@ class CalibrationService:
self._mqtt: MqttSeatPoseClient | None = None self._mqtt: MqttSeatPoseClient | None = None
self._saved_job = -1 self._saved_job = -1
self.settings = self._load_settings() self.settings = self._load_settings()
self.video = VideoBridge(self._on_video_state)
self.state: dict[str, Any] = self._initial_state() self.state: dict[str, Any] = self._initial_state()
def start(self, *, auto_connect: bool = False) -> None: def start(self, *, auto_connect: bool = False) -> None:
@@ -84,7 +88,6 @@ class CalibrationService:
if self._mqtt: if self._mqtt:
self._mqtt.close() self._mqtt.close()
self._mqtt = None self._mqtt = None
self.video.stop()
if self._transport: if self._transport:
self._transport.close() self._transport.close()
self._transport = None self._transport = None
@@ -103,7 +106,6 @@ class CalibrationService:
mode = str(self.state["transport"].get("mode", "hardware")) mode = str(self.state["transport"].get("mode", "hardware"))
if mode != "simulation": if mode != "simulation":
raise ApiError("真实台架会话不能通过虚拟重启接口复位", 409) raise ApiError("真实台架会话不能通过虚拟重启接口复位", 409)
self.video.stop()
if self._mqtt: if self._mqtt:
self._mqtt.close() self._mqtt.close()
self._mqtt = None self._mqtt = None
@@ -285,12 +287,24 @@ class CalibrationService:
mqtt = self.settings["mqtt"] mqtt = self.settings["mqtt"]
video = self.settings["video"] video = self.settings["video"]
self._validate_host_port(mqtt.get("host"), mqtt.get("port"), "MQTT") self._validate_host_port(mqtt.get("host"), mqtt.get("port"), "MQTT")
self._validate_host_port(video.get("host"), video.get("port"), "RTSP") self._validate_host_port(video.get("host"), video.get("port"), "WebRTC")
scheme = str(video.get("scheme", "http")).strip().lower()
if scheme not in {"http", "https"}:
raise ApiError("WebRTC 信令协议必须是 http 或 https")
path = str(video.get("path", "")).strip().strip("/") path = str(video.get("path", "")).strip().strip("/")
if not path or any(char in path for char in " ?#\\"): if not path or any(char in path for char in " ?#\\"):
raise ApiError("RTSP 流路径无效") raise ApiError("WebRTC 流路径无效")
video["scheme"] = scheme
video["path"] = path video["path"] = path
video["transport"] = "webrtc"
self.state["settings"] = json.loads(json.dumps(self.settings)) self.state["settings"] = json.loads(json.dumps(self.settings))
self.state["video"].update(
{
"transport": "webrtc",
"url": self._video_url(),
"detail": "WebRTC 由浏览器直接接收",
}
)
self._save_settings() self._save_settings()
self._touch() self._touch()
return self.snapshot() return self.snapshot()
@@ -331,19 +345,19 @@ class CalibrationService:
return self.snapshot() return self.snapshot()
def start_video(self) -> dict[str, Any]: def start_video(self) -> dict[str, Any]:
config = self.settings["video"] self._on_video_state("ready", f"浏览器可直接连接 {self._video_url()}")
uri = f"rtsp://{config['host']}:{int(config['port'])}/{str(config['path']).strip('/')}"
try:
self.video.start(uri)
except Exception as error:
self._on_video_state("error", str(error))
raise ApiError(str(error), 503) from error
return self.snapshot() return self.snapshot()
def stop_video(self) -> dict[str, Any]: def stop_video(self) -> dict[str, Any]:
self.video.stop() self._on_video_state("idle", "浏览器 WebRTC 接收已停止")
return self.snapshot() return self.snapshot()
def _video_url(self) -> str:
config = self.settings["video"]
scheme = str(config.get("scheme", "http")).strip().lower()
path = str(config["path"]).strip("/")
return f"{scheme}://{config['host']}:{int(config['port'])}/{path}/"
def _initial_state(self) -> dict[str, Any]: def _initial_state(self) -> dict[str, Any]:
return { return {
"version": 0, "version": 0,
@@ -399,9 +413,10 @@ class CalibrationService:
"areas": {}, "areas": {},
}, },
"video": { "video": {
"state": "idle", "state": "ready",
"detail": "视频接收尚未启动", "detail": "WebRTC 由浏览器直接接收",
"ffmpeg_available": self.video.available, "transport": "webrtc",
"url": self._video_url(),
}, },
"settings": json.loads(json.dumps(self.settings)), "settings": json.loads(json.dumps(self.settings)),
"last_result": self._load_result(), "last_result": self._load_result(),
@@ -704,6 +719,23 @@ class CalibrationService:
for section in settings: for section in settings:
if isinstance(stored.get(section), dict): if isinstance(stored.get(section), dict):
settings[section].update(stored[section]) settings[section].update(stored[section])
stored_video = stored.get("video")
legacy_video_endpoint = (
isinstance(stored_video, dict)
and str(stored_video.get("host", "")).strip() == "172.20.10.12"
and str(stored_video.get("port", "")).strip() == "8554"
)
if isinstance(stored_video, dict) and (
"scheme" not in stored_video or legacy_video_endpoint
):
# Settings written by the retired RTSP/FFmpeg bridge are not
# valid WHEP endpoints. Migrate that schema to the known ZED
# MediaMTX endpoint instead of trying port 8554 as WebRTC. The
# explicit endpoint check also catches legacy settings that a
# newer UI already decorated with scheme/transport fields.
settings["video"] = json.loads(json.dumps(DEFAULT_SETTINGS["video"]))
if settings["mqtt"].get("host") == "172.20.10.12":
settings["mqtt"]["host"] = DEFAULT_SETTINGS["mqtt"]["host"]
except (OSError, ValueError): except (OSError, ValueError):
pass pass
return settings return settings
+169 -26
View File
@@ -10,6 +10,8 @@ import mimetypes
import os import os
import re import re
import socket import socket
import threading
import time
from http.cookies import CookieError, SimpleCookie from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -63,6 +65,82 @@ PUBLIC_AUTH_PATHS = {
} }
class VehicleDisplaySync:
"""Process-local snapshot consumed by passive vehicle display clients."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._revision = 0
self._snapshot: dict[str, object] = {
"revision": 0,
"updated_at": None,
"task": {
"status": "idle",
"utterance": "",
"source": "head_unit",
"result": None,
"error": None,
},
"extension": {
"catalog": [],
"last_action": None,
"result": None,
},
}
@staticmethod
def _safe_copy(value: object) -> object:
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
def update_task(
self,
*,
status: str,
utterance: str,
source: str = "head_unit",
result: object = None,
error: object = None,
) -> None:
with self._lock:
self._revision += 1
self._snapshot["revision"] = self._revision
self._snapshot["updated_at"] = time.time()
self._snapshot["task"] = {
"status": status,
"utterance": utterance,
"source": source or "head_unit",
"result": self._safe_copy(result),
"error": self._safe_copy(error),
}
def update_extension(
self,
*,
catalog: object | None = None,
action: object | None = None,
result: object | None = None,
) -> None:
with self._lock:
self._revision += 1
current = dict(self._snapshot.get("extension", {}))
if catalog is not None:
current["catalog"] = self._safe_copy(catalog)
if action is not None:
current["last_action"] = self._safe_copy(action)
if result is not None:
current["result"] = self._safe_copy(result)
self._snapshot["revision"] = self._revision
self._snapshot["updated_at"] = time.time()
self._snapshot["extension"] = current
def snapshot(self, health: dict[str, object]) -> dict[str, object]:
with self._lock:
payload = self._safe_copy(self._snapshot)
assert isinstance(payload, dict)
payload["extension_health"] = self._safe_copy(health)
return payload
def _lan_ipv4_address() -> str | None: def _lan_ipv4_address() -> str | None:
"""Return the IPv4 address selected by the host's default route.""" """Return the IPv4 address selected by the host's default route."""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -86,6 +164,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
repair = SelfRepairService() repair = SelfRepairService()
auth = AuthRuntime(AuthConfig.from_env()) auth = AuthRuntime(AuthConfig.from_env())
vehicle_display_sync = VehicleDisplaySync()
server_version = "CognitiveAgentCabinDemo/2.0" server_version = "CognitiveAgentCabinDemo/2.0"
def do_GET(self) -> None: # noqa: N802 - stdlib handler API def do_GET(self) -> None: # noqa: N802 - stdlib handler API
@@ -151,6 +230,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/runtime": if path == "/api/runtime":
self._json(self.service.runtime_info()) self._json(self.service.runtime_info())
return return
if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self.extension.health()))
return
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
query = parse_qs(parsed_url.query) query = parse_qs(parsed_url.query)
conversation_id = str(query.get("conversation_id", [""])[0]) conversation_id = str(query.get("conversation_id", [""])[0])
@@ -277,6 +359,19 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return return
utterance = ""
source = "head_unit"
def fail_display_sync(code: str, error: Exception) -> None:
if not utterance:
return
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": code, "message": str(error)},
)
try: try:
username = self._session_username() or "local" username = self._session_username() or "local"
retry_after = self.auth.api_requests.retry_after(username) retry_after = self.auth.api_requests.retry_after(username)
@@ -293,8 +388,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if self.auth.config.enabled: if self.auth.config.enabled:
self.auth.api_requests.record(username) self.auth.api_requests.record(username)
payload = self._read_json_body() payload = self._read_json_body()
utterance = str(payload.get("utterance", ""))
source = str(payload.get("client_type", "head_unit"))
self.vehicle_display_sync.update_task(
status="running",
utterance=utterance,
source=source,
)
result = self.service.process( result = self.service.process(
str(payload.get("utterance", "")), utterance,
str(payload.get("provider", "auto")), str(payload.get("provider", "auto")),
str(payload["conversation_id"]) if payload.get("conversation_id") else None, str(payload["conversation_id"]) if payload.get("conversation_id") else None,
str(payload.get("execution_mode", "dry_run")), str(payload.get("execution_mode", "dry_run")),
@@ -319,8 +421,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else None else None
), ),
) )
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
utterance=utterance,
source=source,
result=result,
)
self._json(result, HTTPStatus.CREATED) self._json(result, HTTPStatus.CREATED)
except HardwareAccessRequiredError as exc: except HardwareAccessRequiredError as exc:
fail_display_sync("hardware_access_required", exc)
self._json( self._json(
{ {
"error": "hardware_access_required", "error": "hardware_access_required",
@@ -330,6 +439,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
) )
except HardwareBusyError as exc: except HardwareBusyError as exc:
fail_display_sync("hardware_busy", exc)
self._json( self._json(
{ {
"error": "hardware_busy", "error": "hardware_busy",
@@ -340,13 +450,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{"Retry-After": str(exc.retry_after_seconds)}, {"Retry-After": str(exc.retry_after_seconds)},
) )
except HardwareSafetyError as exc: except HardwareSafetyError as exc:
fail_display_sync("hardware_safety_gate", exc)
self._json( self._json(
{"error": "hardware_safety_gate", "message": str(exc)}, {"error": "hardware_safety_gate", "message": str(exc)},
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
) )
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
fail_display_sync("invalid_request", exc)
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST) self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
except OpenAIIntentError as exc: except OpenAIIntentError as exc:
fail_display_sync("openai_request_failed", exc)
self.log_error("OpenAI intent request failed: %s", exc) self.log_error("OpenAI intent request failed: %s", exc)
self._json( self._json(
{ {
@@ -356,6 +469,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_GATEWAY, HTTPStatus.BAD_GATEWAY,
) )
except Exception as exc: # noqa: BLE001 - keep the demo API responsive except Exception as exc: # noqa: BLE001 - keep the demo API responsive
fail_display_sync("internal_error", exc)
self.log_error("request failed: %s", exc) self.log_error("request failed: %s", exc)
self._json( self._json(
{ {
@@ -412,8 +526,31 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if action: if action:
self._handle_extension_gateway(action, payload) self._handle_extension_gateway(action, payload)
return return
if path == "/api/extension/display-sync":
catalog = payload.get("peripherals", payload.get("catalog", []))
if not isinstance(catalog, list) or len(catalog) > 248:
raise ValueError("外设能力目录必须是至多 248 项的数组。")
sync_result = {"status": "ready", "count": len(catalog)}
self.vehicle_display_sync.update_extension(
catalog=catalog,
action={
"type": "hardware_detected",
"device": payload.get("device"),
},
result=sync_result,
)
self._json({"success": True, **sync_result})
return
if path == "/api/extension/assistant/plan": if path == "/api/extension/assistant/plan":
self._json(self.extension.plan(payload)) self.vehicle_display_sync.update_extension(
catalog=payload.get("peripherals", payload.get("catalog", [])),
action={"type": "plan", "command": payload.get("utterance", "")},
)
result = self.extension.plan(payload)
self.vehicle_display_sync.update_extension(
result=result,
)
self._json(result)
return return
if path == "/api/extension/vehicle/execute": if path == "/api/extension/vehicle/execute":
result = self.extension.execute_vehicle( result = self.extension.execute_vehicle(
@@ -421,6 +558,17 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.service, self.service,
hardware_access_bypass=self._is_unproxied_loopback_request(), hardware_access_bypass=self._is_unproxied_loopback_request(),
) )
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
utterance=str(payload.get("command", payload.get("utterance", ""))),
source=str(payload.get("client_type", "head_unit")),
result=result,
)
self.vehicle_display_sync.update_extension(
catalog=payload.get("catalog") if "catalog" in payload else None,
action={"type": "vehicle_execute"},
result=result,
)
self._json(result) self._json(result)
return return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -694,31 +842,23 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
pass pass
def _serve_calibration_video(self) -> None: def _serve_calibration_video(self) -> None:
self.send_response(HTTPStatus.OK) self._json(
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=n7frame") {
self.send_header("Cache-Control", "no-store") "error": "legacy_video_transport_removed",
self.send_header("Connection", "close") "message": "ZED 画面已改为浏览器直连 WebRTC/WHEP。",
self._send_security_headers() },
self.end_headers() HTTPStatus.GONE,
sequence = -1 )
try:
while True:
sequence, frame = self.calibration.video.wait_frame(sequence)
if frame is None:
continue
header = (
b"--n7frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ str(len(frame)).encode("ascii")
+ b"\r\n\r\n"
)
self.wfile.write(header + frame + b"\r\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
pass
def _handle_extension_gateway(self, action: str, payload: dict[str, object]) -> None: def _handle_extension_gateway(self, action: str, payload: dict[str, object]) -> None:
try: try:
self._json(self.extension.execute_gateway(action, dict(payload))) result = self.extension.execute_gateway(action, dict(payload))
if action != "info":
self.vehicle_display_sync.update_extension(
action={"type": action, **dict(payload)},
result=result,
)
self._json(result)
except GatewayError as exc: except GatewayError as exc:
self._json( self._json(
{"success": False, "error": "gscu_unavailable", "message": str(exc)}, {"success": False, "error": "gscu_unavailable", "message": str(exc)},
@@ -986,7 +1126,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
cache_control = "public, max-age=31536000, immutable" cache_control = "public, max-age=31536000, immutable"
elif relative_path.startswith("assets/"): elif relative_path.startswith("assets/"):
cache_control = "no-cache" cache_control = "no-cache"
elif candidate.name in {"index.html", "login.html", "service-worker.js"}: elif candidate.name == "service-worker.js":
cache_control = "no-store"
elif candidate.name in {"index.html", "login.html"}:
cache_control = "no-cache" cache_control = "no-cache"
self.send_header("Cache-Control", cache_control) self.send_header("Cache-Control", cache_control)
if candidate.name == "service-worker.js": if candidate.name == "service-worker.js":
@@ -1050,7 +1192,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"script-src 'self' 'wasm-unsafe-eval'; " "script-src 'self' 'wasm-unsafe-eval'; "
"style-src 'self'; " "style-src 'self'; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"connect-src 'self' blob:; " "media-src 'self' blob:; "
"connect-src 'self' blob: http://172.20.10.6:8889; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'", "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
) )
if self._is_https(): if self._is_https():
+104 -103
View File
@@ -174,37 +174,81 @@ operations:
finally: finally:
self.service.mode_profiles = original_registry self.service.mode_profiles = original_registry
def test_head_unit_display_sync_tracks_tasks_and_usb_catalog(self) -> None:
task_request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": "副驾有点热,有点闷",
"provider": "local",
"conversation_id": "conv-head-unit-display-sync",
"vehicle_id": "denza_n7",
"execution_mode": "dry_run",
"client_type": "head_unit",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(task_request, timeout=3) as response:
task_result = json.loads(response.read().decode("utf-8"))
peripheral = {
"id": "cap-usb-light-on",
"device_id": "usb-light-1",
"device_name": "USB 氛围灯条",
"device_type": "light",
"type_label": "灯光外设",
"action": "开启灯带",
}
extension_request = urllib.request.Request(
f"{self.base_url}/api/extension/display-sync",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"device": {"id": "usb-light-1"}, "peripherals": [peripheral]},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(extension_request, timeout=3) as response:
extension_result = json.loads(response.read().decode("utf-8"))
with self.opener.open(
f"{self.base_url}/api/vehicle-display-sync", timeout=3
) as response:
snapshot = json.loads(response.read().decode("utf-8"))
self.assertEqual(task_result["status"], "succeeded")
self.assertTrue(extension_result["success"])
self.assertEqual(snapshot["task"]["utterance"], "副驾有点热,有点闷")
self.assertEqual(snapshot["task"]["result"]["request_id"], task_result["request_id"])
self.assertEqual(snapshot["extension"]["catalog"], [peripheral])
self.assertEqual(snapshot["extension"]["last_action"]["type"], "hardware_detected")
self.assertIn("extension_health", snapshot)
def test_static_console_and_runtime_endpoint(self) -> None: def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response: with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-82", timeout=3) as response: with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-85", timeout=3) as response:
app_script = response.read().decode("utf-8") app_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 f"{self.base_url}/gesture-control.js?v=android-pad-8", timeout=3
) as response: ) as response:
gesture_script = response.read().decode("utf-8") gesture_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/console-v2.js?v=desktop-refactor-36", timeout=3 f"{self.base_url}/console-v2.js?v=sync-layout-26", timeout=3
) as response: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/console-v2.css?v=desktop-refactor-107", timeout=3 f"{self.base_url}/console-v2.css?v=sync-layout-26", timeout=3
) as response: ) as response:
console_styles = response.read().decode("utf-8") console_styles = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-evolution.js?v=seat-weighing-2", timeout=3 f"{self.base_url}/self-extension.js?v=sync-layout-26", timeout=3
) as response:
evolution_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-evolution.css?v=seat-weighing-5", timeout=3
) as response:
evolution_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=extension-21", timeout=3
) as response: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.css?v=extension-19", timeout=3 f"{self.base_url}/self-extension.css?v=sync-layout-26", timeout=3
) as response: ) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open( with self.opener.open(
@@ -231,75 +275,48 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html) self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-82"', html) self.assertIn('src="/app.js?v=dual-seat-85"', html)
self.assertIn('href="/styles.css?v=dual-seat-9"', html) self.assertIn('href="/styles.css?v=dual-seat-9"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertIn('src="/gesture-control.js?v=android-pad-8"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=desktop-refactor-36"', html) self.assertIn('src="/console-v2.js?v=sync-layout-26"', html)
self.assertIn('href="/console-v2.css?v=desktop-refactor-107"', html) self.assertIn('href="/console-v2.css?v=sync-layout-26"', html)
self.assertIn('src="/self-evolution.js?v=seat-weighing-2"', html) self.assertIn('src="/self-extension.js?v=sync-layout-26"', html)
self.assertIn('href="/self-evolution.css?v=seat-weighing-5"', html) self.assertIn('href="/self-extension.css?v=sync-layout-26"', html)
self.assertIn('src="/self-extension.js?v=extension-21"', html) self.assertNotIn("self-calibration", html)
self.assertIn('href="/self-extension.css?v=extension-19"', html) self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
self.assertNotIn("滚动查看更多", html) self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('aria-label="执行车舱控制指令"', html)
self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) self.assertNotIn('"admin"', console_script)
self.assertNotIn("v2RoleSelect", console_script)
self.assertIn('id="v2SidebarToggle"', console_script) self.assertIn('id="v2SidebarToggle"', console_script)
self.assertIn('id = "v2ModuleSwitcher"', console_script) self.assertIn('id = "v2ModuleSwitcher"', console_script)
self.assertIn( self.assertIn(
'panelHeading.classList.add("v2-operation-heading", "v2-module-heading")', 'panelHeading.classList.add("v2-operation-heading", "v2-module-heading")',
console_script, console_script,
) )
self.assertIn('kicker.textContent = "SELF-ORCHESTRATION"', console_script) self.assertIn('kicker.textContent = "REMOTE ORCHESTRATION · LIVE SYNC"', console_script)
self.assertIn( self.assertIn('fetch("/api/vehicle-display-sync"', console_script)
self.assertIn('document.body.classList.add("v2-passive-display", "v2-interactive-sync")', console_script)
self.assertIn('input.readOnly = false', console_script)
self.assertIn('function buildTwinDeviceInteractions()', console_script)
self.assertIn('id="v2SyncLogList"', console_script)
self.assertIn('data-panel-toggle="devices"', console_script)
self.assertIn('data-panel-toggle="extensions"', console_script)
self.assertIn('id="vehicleExtensionSync"', console_script)
self.assertNotIn(
'if (ui.activeModule === "self-extension" && ui.role !== "user")', 'if (ui.activeModule === "self-extension" && ui.role !== "user")',
console_script, console_script,
) )
self.assertIn( self.assertNotIn(
'if (ui.activeModule === "self-extension" && role !== "user" && !allowModuleTransition)', 'if (ui.activeModule === "self-extension" && role !== "user"',
console_script, console_script,
) )
self.assertIn('label: "自编排", available: true', console_script) self.assertIn('511工程-智能座舱', console_script)
self.assertIn('id: "self-calibration"', console_script) self.assertIn('网页控制链路', console_script)
self.assertIn('label: "自标定"', console_script)
self.assertLess(
console_script.index('label: "自标定"'),
console_script.index('label: "自编排"'),
)
self.assertIn("adminOnly: true", console_script)
self.assertIn(
"$('.v2-module-tab[data-module=\"self-calibration\"]')",
console_script,
)
self.assertIn('label: "自扩展", available: true', console_script)
self.assertIn('aria-label="自扩展面向所有登录用户"', console_script)
self.assertIn('<span>Hi,</span><strong>user</strong>', console_script) self.assertIn('<span>Hi,</span><strong>user</strong>', console_script)
self.assertIn('id: "self-repair"', console_script) self.assertIn('["self-orchestration", "self-extension"]', console_script)
self.assertIn('label: "自修复"', console_script)
self.assertIn('id: "self-evolution", label: "自进化", available: true', console_script)
self.assertIn(
'window.CognitiveAgentSelfEvolution?.mount(mainPanel)',
console_script,
)
self.assertIn('caption.textContent = evolutionMode ? "进化视图" : "控制方式"', console_script)
self.assertIn('{ label: "座椅称重进化", icon: "scale"', console_script)
self.assertIn('{ label: "系统能力日志", icon: "scroll-text"', console_script)
self.assertIn('aria-label", "自进化 · 座椅称重个性化模型进化"', evolution_script)
self.assertIn('SELF-EVOLUTION · SEAT WEIGHING', evolution_script)
self.assertIn('座椅称重模型进化', evolution_script)
self.assertIn('系统能力日志', evolution_script)
self.assertIn('data-family-model="${model.id}"', evolution_script)
self.assertIn('用户 A', evolution_script)
self.assertIn('用户 B', evolution_script)
self.assertIn('用户 C', evolution_script)
self.assertIn('访客', evolution_script)
self.assertIn('退化保护已触发', evolution_script)
self.assertIn('本次未参与发布', evolution_script)
self.assertIn('演示进化过程', evolution_script)
self.assertIn('家庭体脂秤', evolution_script)
self.assertIn('目标误差', evolution_script)
self.assertIn('["self-orchestration", "self-extension", "self-evolution"]', console_script)
self.assertIn('roleSelect.disabled = ui.activeModule === "self-repair"', console_script)
self.assertNotIn('label: "自诊断"', console_script) self.assertNotIn('label: "自诊断"', console_script)
self.assertIn('<small>手势模型</small>', console_script) self.assertIn('<small>手势模型</small>', console_script)
self.assertIn('id="v2GestureModelValue">MediaPipe GR</strong>', console_script) self.assertIn('id="v2GestureModelValue">MediaPipe GR</strong>', console_script)
@@ -316,13 +333,12 @@ operations:
self.assertIn('const dryRunButton = $(\'[data-execution-mode="dry_run"]\')', console_script) self.assertIn('const dryRunButton = $(\'[data-execution-mode="dry_run"]\')', console_script)
self.assertIn('if (!dryRunButton.classList.contains("active")) dryRunButton.click()', console_script) self.assertIn('if (!dryRunButton.classList.contains("active")) dryRunButton.click()', console_script)
self.assertIn('userTargetText.textContent = "执行目标:虚拟 CAN"', console_script) self.assertIn('userTargetText.textContent = "执行目标:虚拟 CAN"', console_script)
self.assertIn("真实台架仅由管理员显式启用", console_script) self.assertIn("user 固定使用虚拟 CAN 安全执行环境", console_script)
self.assertNotIn("user 演示固定使用真实台架", console_script) self.assertNotIn("user 演示固定使用真实台架", console_script)
self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles) self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
self.assertIn('.v2-control-switch {', console_styles) self.assertIn('.v2-control-switch {', console_styles)
self.assertIn('.v2-module-switcher {', console_styles) self.assertIn('.v2-module-switcher {', console_styles)
self.assertIn('grid-template-columns: repeat(5, minmax(0, 1fr))', console_styles) self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
self.assertIn('[data-module="self-evolution"] .v2-self-evolution', evolution_styles)
self.assertIn( self.assertIn(
'.console-v2 .v2-module-heading.v2-module-heading h1', '.console-v2 .v2-module-heading.v2-module-heading h1',
console_styles, console_styles,
@@ -334,22 +350,6 @@ operations:
'grid-template-rows: 36px clamp(236px, 33dvh, 246px) minmax(0, 1fr)', 'grid-template-rows: 36px clamp(236px, 33dvh, 246px) minmax(0, 1fr)',
console_styles, console_styles,
) )
self.assertIn(
'[data-module="self-calibration"] .v2-self-calibration',
console_styles,
)
self.assertIn(
'[data-module="self-repair"] .v2-self-repair',
console_styles,
)
self.assertIn('.v2-evolution-version.active', evolution_styles)
self.assertIn('[data-evolution-scope="admin"]', evolution_styles)
self.assertIn('.v2-weighing-workspace', evolution_styles)
self.assertIn('.v2-family-model.active', evolution_styles)
self.assertIn('.v2-personal-line', evolution_styles)
self.assertIn('.v2-linkage-guard', evolution_styles)
self.assertIn('.v2-parameter-grid', evolution_styles)
self.assertIn('[data-demo-role="admin"] [data-evolution-scope="admin"]', evolution_styles)
self.assertIn( self.assertIn(
'color-mix(in srgb, var(--v2-accent-soft) 86%, white)', 'color-mix(in srgb, var(--v2-accent-soft) 86%, white)',
console_styles, console_styles,
@@ -362,14 +362,13 @@ operations:
self.assertIn('background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft))', console_styles) self.assertIn('background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft))', console_styles)
self.assertIn('width: clamp(48px, 45%, 72px)', console_styles) self.assertIn('width: clamp(48px, 45%, 72px)', console_styles)
self.assertIn('font-weight: 800', console_styles) self.assertIn('font-weight: 800', console_styles)
self.assertIn('width: clamp(300px, 28vw, 360px)', console_styles)
self.assertIn('white-space: nowrap', console_styles) self.assertIn('white-space: nowrap', console_styles)
self.assertIn('.v2-gesture-model-field', console_styles) self.assertIn('.v2-gesture-model-field', console_styles)
self.assertIn('.v2-inline-gesture .v2-camera-toggle', console_styles) self.assertIn('.v2-inline-gesture .v2-camera-toggle', console_styles)
self.assertIn('.v2-inline-gesture .v2-gesture-action-rail', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-action-rail', console_styles)
self.assertIn('grid-template-rows: 42px 10px 42px', console_styles) self.assertIn('grid-template-rows: 42px 10px 42px', console_styles)
self.assertIn('background: var(--v2-accent);', console_styles) self.assertIn('background: var(--v2-accent);', console_styles)
self.assertIn('[data-demo-role="user"] .v2-inline-gesture #gestureArmButton svg', console_styles) self.assertNotIn("gestureArmButton", console_styles)
self.assertIn('.v2-inline-gesture #gestureStopButton', console_styles) self.assertIn('.v2-inline-gesture #gestureStopButton', console_styles)
self.assertIn('.v2-inline-gesture .v2-gesture-result-stack', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-result-stack', console_styles)
self.assertIn('.v2-inline-gesture .v2-gesture-execution-slot', console_styles) self.assertIn('.v2-inline-gesture .v2-gesture-execution-slot', console_styles)
@@ -380,7 +379,7 @@ operations:
self.assertIn('.v2-edge-fade-right', console_styles) self.assertIn('.v2-edge-fade-right', console_styles)
self.assertIn('.mode-group-divider', console_styles) self.assertIn('.mode-group-divider', console_styles)
self.assertIn('.joint-twin-shell > .cabin-band', console_styles) self.assertIn('.joint-twin-shell > .cabin-band', console_styles)
self.assertIn('[data-demo-role="user"] .v2-page-index', console_styles) self.assertIn('.console-v2 .v2-page-index', console_styles)
self.assertIn('.joint-twin-shell .twin-readout', console_styles) self.assertIn('.joint-twin-shell .twin-readout', console_styles)
self.assertIn('.joint-seat-panel .thermal-states', console_styles) self.assertIn('.joint-seat-panel .thermal-states', console_styles)
self.assertIn('.joint-twin-shell .domain-readouts', console_styles) self.assertIn('.joint-twin-shell .domain-readouts', console_styles)
@@ -421,7 +420,7 @@ operations:
self.assertIn("function styleReferencePassiveGlass", app_script) self.assertIn("function styleReferencePassiveGlass", app_script)
self.assertIn("function styleReferencePassiveRearExterior", app_script) self.assertIn("function styleReferencePassiveRearExterior", app_script)
self.assertIn('twinRole: "reference_passive_rear_bumper"', app_script) self.assertIn('twinRole: "reference_passive_rear_bumper"', app_script)
self.assertIn('"cognitive-agent:demo-role-change"', console_script) self.assertNotIn('"cognitive-agent:demo-role-change"', console_script)
self.assertIn('resultStack.id = "v2GestureResultStack"', console_script) self.assertIn('resultStack.id = "v2GestureResultStack"', console_script)
self.assertIn('executionSlot.id = "v2GestureExecutionSlot"', console_script) self.assertIn('executionSlot.id = "v2GestureExecutionSlot"', console_script)
self.assertIn('statusTarget.append(requestStatus)', console_script) self.assertIn('statusTarget.append(requestStatus)', console_script)
@@ -450,9 +449,9 @@ operations:
self.assertIn('healthState.message || "GSCU1B 尚未连接"', extension_script) self.assertIn('healthState.message || "GSCU1B 尚未连接"', extension_script)
self.assertIn('window.addEventListener("pageshow"', extension_script) self.assertIn('window.addEventListener("pageshow"', extension_script)
self.assertIn('[data-module="self-extension"]', extension_styles) self.assertIn('[data-module="self-extension"]', extension_styles)
self.assertIn('[data-module="self-extension"] .v2-role-select', extension_styles) self.assertNotIn('[data-module="self-extension"] .v2-role-select', extension_styles)
self.assertIn('.v2-extension-composer .primary-action {', extension_styles) self.assertIn('.v2-extension-composer .primary-action {', extension_styles)
self.assertIn('--v2-accent: #0e7773', extension_styles) self.assertNotIn('--v2-accent:', extension_styles)
self.assertIn('.v2-sidebar-collapsed[data-module="self-extension"] .v2-extension-sidebar', extension_styles) self.assertIn('.v2-sidebar-collapsed[data-module="self-extension"] .v2-extension-sidebar', extension_styles)
self.assertIn('.v2-extension-capability span {', extension_styles) self.assertIn('.v2-extension-capability span {', extension_styles)
self.assertIn('.v2-extension-parameter-list > span {', extension_styles) self.assertIn('.v2-extension-parameter-list > span {', extension_styles)
@@ -482,14 +481,17 @@ operations:
) )
self.assertIn("const stageHasLayout = () =>", app_script) self.assertIn("const stageHasLayout = () =>", app_script)
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script) self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
self.assertIn('client_type: "web_console"', app_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v120"', service_worker) self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v178"', service_worker)
self.assertIn('"/self-evolution.js?v=seat-weighing-2"', service_worker) self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn('"/self-evolution.css?v=seat-weighing-5"', service_worker) self.assertIn(".then(() => self.skipWaiting())", service_worker)
self.assertIn( self.assertNotIn("const cached = await caches.match(request)", service_worker)
'"/assets/models/n7_seat.glb?v=e7f50f03"', service_worker self.assertNotIn("self-evolution", service_worker)
) self.assertNotIn("self-calibration", service_worker)
self.assertNotIn("self-repair", service_worker)
self.assertNotIn("n7_seat.glb", service_worker)
self.assertIn("hardware_access_required", app_script) self.assertIn("hardware_access_required", app_script)
self.assertIn('button.setAttribute("aria-label", "关闭摄像头")', gesture_script) self.assertIn('button.setAttribute("aria-label", "关闭摄像头")', gesture_script)
self.assertIn("function refreshCameraAvailability", gesture_script) self.assertIn("function refreshCameraAvailability", gesture_script)
@@ -499,9 +501,8 @@ operations:
self.assertIn('case "NotAllowedError":', gesture_script) self.assertIn('case "NotAllowedError":', gesture_script)
self.assertIn('case "NotReadableError":', gesture_script) self.assertIn('case "NotReadableError":', gesture_script)
self.assertIn('case "OverconstrainedError":', gesture_script) self.assertIn('case "OverconstrainedError":', gesture_script)
self.assertIn("function sendSimulatedGesture()", gesture_script) self.assertNotIn("sendSimulatedGesture", gesture_script)
self.assertIn('document.body.dataset.demoRole !== "admin"', gesture_script) self.assertNotIn("gestureDebug", gesture_script)
self.assertIn("function syncGestureDebugRole", gesture_script)
self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script) self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script)
self.assertIn( self.assertIn(
"requestTimeoutForExecutionMode(state.executionMode)", app_script "requestTimeoutForExecutionMode(state.executionMode)", app_script
@@ -695,11 +696,11 @@ operations:
def test_self_extension_vehicle_success_contract_is_strict(self) -> None: def test_self_extension_vehicle_success_contract_is_strict(self) -> None:
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.js?v=extension-21", timeout=3 f"{self.base_url}/self-extension.js?v=extension-44", timeout=3
) as response: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-82", timeout=3 f"{self.base_url}/app.js?v=dual-seat-83", timeout=3
) as response: ) as response:
app_script = response.read().decode("utf-8") app_script = response.read().decode("utf-8")
+12 -67
View File
@@ -130,73 +130,18 @@ class TestSelfCalibrationApi:
assert reset["transport"]["state"] == "disconnected" assert reset["transport"]["state"] == "disconnected"
assert [axis["permille"] for axis in reset["axes"]] == [0, 0, 0, 0] assert [axis["permille"] for axis in reset["axes"]] == [0, 0, 0, 0]
def test_frontend_and_original_model_are_served_by_existing_service(self) -> None: def test_frontend_and_original_model_are_removed(self) -> None:
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen( assert "self-calibration" not in html
f"{self.base_url}/self-calibration.js?v=calibration-8", assert "vendor/mediamtx" not in html
timeout=3,
) as response:
script = response.read().decode("utf-8")
with urllib.request.urlopen(
f"{self.base_url}/self-calibration.css?v=calibration-12",
timeout=3,
) as response:
styles = response.read().decode("utf-8")
with urllib.request.urlopen(
f"{self.base_url}/console-v2.js?v=desktop-refactor-31",
timeout=3,
) as response:
console_script = response.read().decode("utf-8")
with urllib.request.urlopen(
f"{self.base_url}/assets/models/n7_seat.glb",
timeout=3,
) as response:
model = response.read()
content_type = response.headers["Content-Type"]
content_security_policy = response.headers["Content-Security-Policy"]
unversioned_cache_control = response.headers["Cache-Control"]
with urllib.request.urlopen(
f"{self.base_url}/assets/models/n7_seat.glb?v=e7f50f03",
timeout=3,
) as response:
versioned_model_prefix = response.read(4)
versioned_cache_control = response.headers["Cache-Control"]
with pytest.raises(urllib.error.HTTPError) as missing_model:
urllib.request.urlopen(
f"{self.base_url}/assets/models/missing-seat.glb?v=missing",
timeout=3,
)
assert 'href="/self-calibration.css?v=calibration-12"' in html for path in (
assert 'src="/self-calibration.js?v=calibration-8"' in html "/self-calibration.js",
assert 'data-calibration-page="live"' in script "/self-calibration.css",
assert 'data-calibration-view="live"' in script "/vendor/mediamtx/reader.js",
assert 'data-calibration-page="hall"' not in script "/assets/models/n7_seat.glb",
assert 'data-calibration-page="stereo"' not in script ):
assert "座椅与双目联合标定" in script with pytest.raises(urllib.error.HTTPError) as missing_asset:
assert 'class="v2-module-heading"' in script urllib.request.urlopen(f"{self.base_url}{path}", timeout=3)
assert 'SELF-CALIBRATION' in script assert missing_asset.value.code == 404
assert 'padding: var(--v2-module-title-block) 0 0' in styles
assert 'margin-inline: var(--v2-module-title-inline)' in styles
assert "实时座椅姿态" in script
assert "四轴实时控制" in script
assert "ZED 实时画面" in script
assert "双目位姿数据" not in script
assert 'class="v2-calibration-panel v2-calibration-mqtt-panel"' not in script
assert "grid-template-columns: minmax(420px, 1.06fr) minmax(380px, 0.94fr)" in styles
assert "标定仿真台架" in script
assert "X-Cognitive-Agent-Role" in script
assert "--v2-sidebar: #dae3f5" in styles
assert "--v2-sidebar-deep: #b6c7ea" in styles
assert "--v2-accent-strong: #1e386b" in styles
assert '["self-calibration", "self-repair"].includes(module)' in console_script
assert 'setRole("admin", { allowModuleTransition: true })' in console_script
assert len(model) == 845_992
assert versioned_model_prefix == model[:4]
assert content_type.startswith("model/gltf-binary")
assert unversioned_cache_control == "no-cache"
assert versioned_cache_control == "public, max-age=31536000, immutable"
assert missing_model.value.code == 404
assert "img-src 'self' data: blob:;" in content_security_policy
assert "connect-src 'self' blob:;" in content_security_policy
+11 -61
View File
@@ -142,74 +142,24 @@ class TestSelfRepairApi:
assert result["ok"] is True assert result["ok"] is True
assert result["self_test"]["result"] == "PASS" assert result["self_test"]["result"] == "PASS"
def test_frontend_assets_and_role_transition_are_served(self) -> None: def test_frontend_assets_and_role_transition_are_removed(self) -> None:
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen(
f"{self.base_url}/self-repair.js?v=repair-21", timeout=3
) as response:
script = response.read().decode("utf-8").replace("\r\n", "\n")
with urllib.request.urlopen(
f"{self.base_url}/self-repair.css?v=repair-21", timeout=3
) as response:
styles = response.read().decode("utf-8")
with urllib.request.urlopen(
f"{self.base_url}/self-repair-seat3d.js?v=repair-21", timeout=3
) as response:
seat3d = response.read().decode("utf-8")
with urllib.request.urlopen( with urllib.request.urlopen(
f"{self.base_url}/console-v2.js?v=desktop-refactor-31", timeout=3 f"{self.base_url}/console-v2.js?v=desktop-refactor-31", timeout=3
) as response: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
assert 'href="/self-repair.css?v=repair-21"' in html assert "self-repair" not in html
assert 'src="/self-repair.js?v=repair-21"' in html assert '"admin"' not in console_script
assert 'class="v2-module-heading"' in script for path in (
assert 'SELF-REPAIR' in script "/self-repair.js",
assert 'padding: var(--v2-module-title-block) 0 0' in styles "/self-repair.css",
assert 'margin-inline: var(--v2-module-title-inline)' in styles "/self-repair-seat3d.js",
assert 'src="/self-repair-seat3d.js?v=repair-21"' in html ):
assert "量产故障注入" in script with pytest.raises(urllib.error.HTTPError) as missing_asset:
assert "运行与自修复日志" in script urllib.request.urlopen(f"{self.base_url}{path}", timeout=3)
assert "启动座椅运行" in script assert missing_asset.value.code == 404
assert "开始正常任务" not in script
assert 'id="repairSeatCanvas"' in script
assert 'id="repairSeatViewer"' in script
assert "self-repair-seat-reset" in script
assert "seat-healing-state" in script
assert 'id="repairConfirmDialog"' in script
assert "confirmAction(" in script
assert "window.confirm" not in script
assert 'hardware.link?.adapter === "OK"' in script
assert 'PASSED: ["21针通过", "online"]' in script
assert "connectorRows" in script
assert "Polling refreshes data only" in script
assert "renderRecords();\n showView(activeView);" not in script
assert 'let latest = fallbackState();' in script
assert 'renderAll();\n window.lucide?.createIcons' in script
assert '"WDT复位后失能"' in script
assert "--repair-deep-blue" in styles
assert '.console-v2[data-module="self-repair"] .section-kicker' in styles
assert 'grid-template-areas: "twin cases logs"' in styles
assert ".v2-repair-event-stream" in styles
assert "background: rgba(255, 255, 255, 0.94)" in styles
assert "border-color: color-mix(in srgb, var(--v2-accent) 18%, white)" in styles
assert ".v2-repair-toolbar button.primary:hover:not(:disabled)" in styles
assert "color: #fff;" in styles
assert "seat-healing-state" in seat3d
assert "function backrestShape" in seat3d
assert "new THREE.CapsuleGeometry" in seat3d
assert 'cssThemeColor("--v2-accent", "#315eaa")' in seat3d
assert 'viewer.addEventListener("pointerdown"' in seat3d
assert 'window.addEventListener("pointermove"' in seat3d
assert "window.addEventListener(resetEvent" in seat3d
assert "function viewerHasLayout(viewer)" in seat3d
assert "function deferSeat3dUntilVisible(viewer, options)" in seat3d
assert "if (!viewerHasLayout(viewer))" in seat3d
assert 'window.addEventListener("cognitive-agent:module-change"' in seat3d
assert 'canvas.addEventListener("webglcontextlost"' in seat3d
assert '["self-calibration", "self-repair"].includes(module)' in console_script
assert 'roleSelect.disabled = ui.activeModule === "self-repair"' in console_script
def test_self_repair_bridge_uses_the_backend_runtime_directory() -> None: def test_self_repair_bridge_uses_the_backend_runtime_directory() -> None:
@@ -47,27 +47,16 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
self.extension_source, self.extension_source,
) )
def test_shared_twin_theme_tracks_demo_role_and_extension_is_always_user(self) -> None: def test_shared_twin_theme_is_fixed_to_user(self) -> None:
self.assertIn("function applyTwinSceneTheme(", self.app_source) self.assertIn("function applyTwinSceneTheme(", self.app_source)
self.assertIn('canvas.dataset.themeRole = themeRole', self.app_source) self.assertIn('canvas.dataset.themeRole = "user"', self.app_source)
self.assertIn( self.assertIn(
'canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`', 'canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`',
self.app_source, self.app_source,
) )
self.assertIn("accent: 0x0e7773", self.app_source) self.assertIn("accent: 0x0e7773", self.app_source)
self.assertIn("accent: 0x315eaa", self.app_source) self.assertNotIn("accent: 0x315eaa", self.app_source)
self.assertIn( self.assertNotIn("cognitive-agent:demo-role-change", self.app_source)
'if (document.body.dataset.module === "self-extension") return "user"',
self.app_source,
)
self.assertIn(
'const themeRole = document.body.dataset.module === "self-extension"',
self.app_source,
)
self.assertIn(
'window.addEventListener("cognitive-agent:demo-role-change"',
self.app_source,
)
def test_shared_twin_ground_uses_theme_radial_alpha_texture(self) -> None: def test_shared_twin_ground_uses_theme_radial_alpha_texture(self) -> None:
self.assertIn("function createTwinGroundTexture(", self.app_source) self.assertIn("function createTwinGroundTexture(", self.app_source)
@@ -10,27 +10,29 @@ def source(name: str) -> str:
return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8") return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8")
def test_calibration_twin_tracks_labels_and_commits_direct_axis_drag() -> None: def test_frontend_only_exposes_user_orchestration_and_extension() -> None:
calibration = source("self-calibration.js") web_root = ROOT / "apps" / "web-console"
seat3d = source("self-repair-seat3d.js") html = source("index.html")
styles = source("self-calibration.css") console = source("console-v2.js")
assert 'data-seat-axis-label="backrest"' in calibration for removed in (
assert 'data-seat-label-line="legrest"' in calibration "self-calibration.js",
assert 'window.addEventListener("calibration-seat-axis-preview"' in calibration "self-calibration.css",
assert 'window.addEventListener("calibration-seat-axis-commit"' in calibration "self-repair.js",
assert "function layoutSeatLabels()" in seat3d "self-repair.css",
assert "makePanel(legrestPivot, cushionShape(0.08)" in seat3d "self-repair-seat3d.js",
assert "function legrestShape(" not in seat3d "self-evolution.js",
assert 'enableAxisDrag: true' in seat3d "self-evolution.css",
assert 'function startAxisDrag(axis, event, source = "model")' in seat3d ):
assert 'startAxisDrag(item.axis, event, "label")' in seat3d assert not (web_root / removed).exists()
assert "calculateAxisDragVector(axis, value)" in seat3d assert removed not in html
assert "Math.max(0, Math.min(100," in seat3d
assert 'new CustomEvent("calibration-seat-axis-commit"' in seat3d assert "511工程-智能座舱" in console
assert ".v2-calibration-label-lines line" in styles assert '["self-orchestration", "self-extension"]' in console
assert '.v2-axis-tag[data-control-enabled="true"]' in styles assert 'id="vehicleExtensionSync"' in console
assert "pointer-events: auto;" in styles assert 'getRole: () => "user"' in console
assert '"admin"' not in console
assert "v2RoleSelect" not in console
def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None: def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None:
@@ -41,17 +43,173 @@ def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None:
assert "function initializeDesktopPet()" in extension assert "function initializeDesktopPet()" in extension
assert "saved.vehicleExecutions = {};" in extension assert "saved.vehicleExecutions = {};" in extension
assert "saved.lastProtocol = null;" in extension assert "saved.lastProtocol = null;" in extension
assert 'src="/assets/pets/self-extension-cat.png?v=3"' in extension
assert 'class="v2-extension-pet-entrance-canvas"' in extension
assert '"/assets/pets/self-extension-cat-entrance.png?v=3"' in extension
assert "const ENTRANCE_KEYFRAME_COUNT = 12" in extension
assert "const ENTRANCE_DURATION_MS = 2300" in extension
assert "const ENTRANCE_WALK_STEPS = 5" in extension
assert "const ENTRANCE_SETTLE_START_FRAME = 5" in extension
assert "0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4" in extension
assert "const easedStepProgress = 0.5 - (Math.cos(Math.PI * stepProgress) / 2)" in extension
assert "function playEntrance()" in extension
assert 'pet.dataset.entering = "true"' in extension
assert "const drawEntrancePose =" in extension
assert "window.requestAnimationFrame(renderEntrance)" in extension
assert "function createThemedPetDataUrl(" in extension
assert "blueDominance < 24" in extension
assert 'window.addEventListener("cognitive-agent:demo-role-change"' not in extension
assert 'class="v2-extension-pet-blink"' in extension
assert "const scheduleBlink =" in extension
assert 'pet.dataset.blinking = "true"' in extension
assert 'class="v2-extension-pet-mouth-closed"' in extension
assert "const animateMouth = () =>" in extension
assert "mouth.animate?.(" in extension
assert "{ opacity: 0, offset: 0.09 }" in extension
assert "{ opacity: 0, offset: 0.41 }" in extension
assert "{ opacity: 0, offset: 0.73 }" in extension
assert "{ duration: 1260" in extension
assert 'class="v2-extension-pet-tail-canvas"' in extension
assert "const runTailWave =" in extension
assert "const requestTailWave =" in extension
assert 'prefers-reduced-motion: reduce' not in extension
assert "tailWaveQueued = true" in extension
assert "window.setTimeout(() => runTailWave(true), 180)" in extension
assert "scheduleTailWave" not in extension
assert 'pet.dataset.tailWaving = "true"' in extension
assert "const tailMotionFrames = [" in extension
assert "const sampleTailOffset =" in extension
assert "sampleTailOffset(tailPose, distanceFromBase)" in extension
assert "clampToSegment = false" in extension
assert "Math.max(Math.min(start, end)" in extension
assert "const sampleTravelingTailOffset =" not in extension
assert 'document.visibilityState !== "visible"' in extension
assert ".v2-extension-pet-body" in styles assert ".v2-extension-pet-body" in styles
assert "@keyframes extension-pet-blink" in styles assert ".v2-extension-pet-entrance-canvas" in styles
assert '.v2-extension-pet[data-entering="true"] .v2-extension-pet-image' in styles
assert '.v2-extension-pet[data-blinking="true"] .v2-extension-pet-blink' in styles
assert ".v2-extension-pet-mouth-closed" in styles
assert "width: 176px;" in styles
assert "font-size: 13px;" in styles
assert '.v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-tail-canvas' in styles
assert '.v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-image' in styles
assert "position: fixed;" in styles
assert "extension-pet-float" not in styles
assert '[data-module="self-extension"][data-demo-role="admin"] .v2-sidebar' not in styles
assert '[data-module="self-extension"][data-demo-role="admin"] .v2-avatar' not in styles
def test_virtual_modules_reset_but_physical_sessions_remain_authoritative() -> None: def test_orchestration_session_reset_remains_available() -> None:
orchestration = source("app.js") orchestration = source("app.js")
calibration = source("self-calibration.js")
repair = source("self-repair.js")
assert 'sessionStorage.removeItem("cognitive-agent.conversation-id")' in orchestration assert 'sessionStorage.removeItem("cognitive-agent.conversation-id")' in orchestration
assert 'api("/session/reset")' in calibration
assert 'if (state.transport?.mode === "simulation")' in calibration
assert 'if (backendMode === "bench")' in repair def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls() -> None:
assert 'await apiPost("/runtime/reset-statistics")' in repair console = source("console-v2.js")
styles = source("console-v2.css")
passenger = console.index('id: "passenger", name: "副驾座椅"')
driver = console.index('id: "driver", name: "主驾座椅"')
windows = console.index('id: "windows", name: "车窗"')
assert passenger < driver < windows
for key_status in (
'["左前窗", readText("#frontLeftWindow")]',
'["右前窗", readText("#frontRightWindow")]',
'["左后窗", readText("#rearLeftWindow")]',
'["右后窗", readText("#rearRightWindow")]',
'["当前位置", readText("#shadePositionValue")]',
'["按摩模式", massageMode]',
'["按摩强度", massageStrength]',
'["前排亮度", frontBrightness]',
'["前排颜色", frontColor]',
'["后排亮度", rearBrightness]',
'["后排颜色", rearColor]',
):
assert key_status in console
assert 'data-device-control-toggle="${device.id}"' in console
assert 'data-device-control-apply="${device.id}"' in console
assert 'new CustomEvent("cognitive-agent:vehicle-control-request"' in console
assert "window.CognitiveAgentConsole?.executeUtterance?.(utterance" in console
assert '.v2-device-control-row input[type="range"]' in styles
assert ".v2-device-control-select" in styles
assert ".v2-device-control-apply" in styles
def test_interactive_console_keeps_actions_inside_composer_and_collapsed_rails_aligned() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
assert 'const resetButton = $("#newConversation", originalForm);' in console
assert 'const runButton = $("#runButton", originalForm);' in console
assert "if (resetButton) composer.append(resetButton);" in console
assert "if (runButton) composer.append(runButton);" in console
assert ".v2-request-form .command-composer" in styles
assert "overflow: hidden;" in styles
assert "transform: translateY(-50%);" in styles
assert '.joint-twin-shell[data-open-panels="none"]' in styles
assert "grid-template-columns: 48px minmax(520px, 1fr) 48px;" in styles
assert "writing-mode: horizontal-tb;" in styles
assert "writing-mode: vertical-rl;" in styles
def test_left_sync_rail_can_collapse_and_log_aligns_with_lower_workspace() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
assert 'make("section", "v2-sync-sidebar"' in console
assert '<div class="v2-sync-sidebar-overview">' in console
assert '$(".v2-sync-sidebar-overview", syncSidebar)?.append(sidebarFooter);' in console
assert '$(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);' in console
assert "function syncSidebarLogBaseline()" in console
assert 'sidebar.style.setProperty("--v2-sync-log-top"' in console
assert 'window.addEventListener("resize", syncSidebarLogBaseline);' in console
assert "requestAnimationFrame(syncSidebarLogBaseline);" in console
assert "--v2-sync-log-top: 291px;" in styles
assert "grid-template-rows: calc(var(--v2-sync-log-top) - 10px) minmax(0, 1fr);" in styles
assert ".v2-sync-sidebar > .v2-sync-log" in styles
assert "grid-template-columns: 64px minmax(0, 1fr);" in styles
assert ".v2-sidebar-collapsed #v2SidebarToggle" in styles
def test_twin_device_status_popover_has_clear_close_paths() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
assert 'data-twin-popover-close aria-label="关闭${escapeHtml(device.name)}状态详情"' in console
assert 'popover.setAttribute("role", "dialog");' in console
assert 'popover.setAttribute("aria-label", "车端设备状态详情");' in console
assert 'event.target.closest("[data-twin-popover-close]")' in console
assert 'event.target.closest("[data-twin-device], #v2TwinDevicePopover")' in console
assert "const samePinned = displaySync.pinnedDevice === focus.dataset.deviceFocus;" in console
assert "clearTwinDevicePreview({ force: true });" in console
assert ".v2-twin-popover-close" in styles
assert ".v2-twin-popover-close:focus-visible" in styles
def test_twin_device_hotspots_use_responsive_hover_and_leader_labels() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
assert "previewClearTimer: null" in console
assert "hoverSuppressedUntil: 0" in console
assert 'class="v2-twin-hotspot-label"' in console
assert 'button.addEventListener("pointerenter"' in console
assert 'button.addEventListener("pointerleave"' in console
assert "scheduleTwinDevicePreviewClear();" in console
assert "}, 45);" in console
assert "performance.now() + 160" in console
assert 'hotspots.addEventListener("pointerover"' not in console
assert 'hotspots.addEventListener("pointerout"' not in console
assert ".v2-twin-hotspots button::before" in styles
assert ".v2-twin-hotspots button::after" in styles
assert ".v2-twin-hotspot-label" in styles
assert "--v2-hotspot-label-x: -115px" in styles
assert "--v2-hotspot-line-angle: -150deg" in styles
assert '.v2-twin-device-popover[data-pinned="false"]' in styles
assert "pointer-events: none;" in styles
+36
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
@@ -10,6 +11,41 @@ from cognitive_agent.application.calibration import protocol
from cognitive_agent.application.calibration.service import ApiError, CalibrationService from cognitive_agent.application.calibration.service import ApiError, CalibrationService
def test_video_settings_use_webrtc_and_migrate_legacy_rtsp() -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
data_dir = root / "data"
data_dir.mkdir()
(data_dir / "settings.json").write_text(
json.dumps(
{
"mqtt": {"host": "172.20.10.12", "port": 1883, "topic": "zed/seatpose"},
"video": {
"scheme": "http",
"host": "172.20.10.12",
"port": 8554,
"path": "zed",
"transport": "webrtc",
},
}
),
encoding="utf-8",
)
service = CalibrationService(root)
settings = service.snapshot()["settings"]
assert settings["mqtt"]["host"] == "172.20.10.6"
assert settings["video"] == {
"scheme": "http",
"host": "172.20.10.6",
"port": 8889,
"path": "zed",
"transport": "webrtc",
}
assert service.start_video()["video"]["state"] == "ready"
def test_protocol_matches_android_golden_vectors() -> None: def test_protocol_matches_android_golden_vectors() -> None:
assert protocol.crc8(b"123456789") == 0x4B assert protocol.crc8(b"123456789") == 0x4B
vectors = ( vectors = (
Binary file not shown.