feat(web-console): rebuild intelligent cockpit sync interface
This commit is contained in:
+12
-50
@@ -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
@@ -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
+2056
-159
File diff suppressed because it is too large
Load Diff
+557
-197
@@ -11,25 +11,11 @@
|
|||||||
|
|
||||||
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)];
|
||||||
const initialRole = sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
|
|
||||||
? "admin"
|
|
||||||
: "user";
|
|
||||||
const storedModule = sessionStorage.getItem("cognitive-agent.active-module");
|
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
role: initialRole,
|
|
||||||
controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
|
controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
|
||||||
? "gesture"
|
? "gesture"
|
||||||
: "natural",
|
: "natural",
|
||||||
activeModule: storedModule === "self-evolution"
|
activeModule: "self-orchestration",
|
||||||
? "self-evolution"
|
|
||||||
: storedModule === "self-extension"
|
|
||||||
? "self-extension"
|
|
||||||
: storedModule === "self-calibration"
|
|
||||||
? "self-calibration"
|
|
||||||
: storedModule === "self-repair"
|
|
||||||
? "self-repair"
|
|
||||||
: "self-orchestration",
|
|
||||||
activePage: 0,
|
activePage: 0,
|
||||||
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
|
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
|
||||||
};
|
};
|
||||||
@@ -44,6 +30,15 @@
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
function closeCustomDropdowns(except = null) {
|
function closeCustomDropdowns(except = null) {
|
||||||
dropdownControls.forEach((control) => {
|
dropdownControls.forEach((control) => {
|
||||||
if (control !== except) control.close();
|
if (control !== except) control.close();
|
||||||
@@ -246,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enhanceConfiguredDropdowns() {
|
function enhanceConfiguredDropdowns() {
|
||||||
[$("#v2RoleSelect"), $("#vehicleSelect"), $("#v2ExecutionSelect")]
|
[$("#vehicleSelect")]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach(enhanceSelect);
|
.forEach(enhanceSelect);
|
||||||
}
|
}
|
||||||
@@ -260,13 +255,6 @@
|
|||||||
<div class="v2-profile-copy">
|
<div class="v2-profile-copy">
|
||||||
<small>WELCOME TO THE CABIN</small>
|
<small>WELCOME TO THE CABIN</small>
|
||||||
<div class="v2-role-select">
|
<div class="v2-role-select">
|
||||||
<span>Hi,</span>
|
|
||||||
<select id="v2RoleSelect" aria-label="切换演示权限">
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="v2-extension-profile-title" aria-label="自扩展面向所有登录用户">
|
|
||||||
<span>Hi,</span><strong>user</strong>
|
<span>Hi,</span><strong>user</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +284,6 @@
|
|||||||
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
||||||
|
|
||||||
commandPanel.append(profile, control, config, footer);
|
commandPanel.append(profile, control, config, footer);
|
||||||
$("#v2RoleSelect").value = ui.role;
|
|
||||||
return $("#v2ConfigStack");
|
return $("#v2ConfigStack");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,21 +342,6 @@
|
|||||||
configStack.append(providerField);
|
configStack.append(providerField);
|
||||||
}
|
}
|
||||||
|
|
||||||
const executionSelect = make("div", "v2-config-field v2-execution-field", `
|
|
||||||
<div class="v2-config-card v2-config-select-card">
|
|
||||||
<i data-lucide="workflow"></i>
|
|
||||||
<div class="v2-config-card-copy"><small>执行目标</small><div class="v2-config-card-select">
|
|
||||||
<select id="v2ExecutionSelect" aria-label="选择执行目标">
|
|
||||||
<option value="dry_run">虚拟 CAN</option>
|
|
||||||
<option value="simulated_hardware">模拟台架</option>
|
|
||||||
<option value="hardware">真实台架</option>
|
|
||||||
</select>
|
|
||||||
</div></div>
|
|
||||||
<span class="v2-field-state">ADMIN</span>
|
|
||||||
</div>
|
|
||||||
<p class="provider-note" id="v2ExecutionNote">管理员可切换执行环境</p>`);
|
|
||||||
configStack.append(executionSelect);
|
|
||||||
|
|
||||||
if (executionField) {
|
if (executionField) {
|
||||||
executionField.classList.add("v2-native-execution-field");
|
executionField.classList.add("v2-native-execution-field");
|
||||||
executionField.hidden = true;
|
executionField.hidden = true;
|
||||||
@@ -398,9 +370,9 @@
|
|||||||
const kicker = $(".section-kicker", panelHeading);
|
const kicker = $(".section-kicker", panelHeading);
|
||||||
const title = $("h2", panelHeading);
|
const title = $("h2", panelHeading);
|
||||||
const intro = $(".panel-intro", panelHeading);
|
const intro = $(".panel-intro", panelHeading);
|
||||||
if (kicker) kicker.textContent = "SELF-ORCHESTRATION";
|
if (kicker) kicker.textContent = "REMOTE ORCHESTRATION · LIVE SYNC";
|
||||||
if (title) title.textContent = "一句话,调好整车";
|
if (title) title.textContent = "一句话远程编排";
|
||||||
if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成";
|
if (intro) intro.textContent = "网页指令将通过同步链路发送至车机;车机任务与执行反馈在右侧实时更新";
|
||||||
naturalPanel.append(panelHeading);
|
naturalPanel.append(panelHeading);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +400,6 @@
|
|||||||
gestureDrawer.classList.add("v2-inline-gesture");
|
gestureDrawer.classList.add("v2-inline-gesture");
|
||||||
const cameraStage = $(".gesture-camera-stage", gestureDrawer);
|
const cameraStage = $(".gesture-camera-stage", gestureDrawer);
|
||||||
const cameraToggle = $("#gestureStartButton", gestureDrawer);
|
const cameraToggle = $("#gestureStartButton", gestureDrawer);
|
||||||
const debugToggle = $("#gestureArmButton", gestureDrawer);
|
|
||||||
const actionRail = $(".gesture-actions", gestureDrawer);
|
const actionRail = $(".gesture-actions", gestureDrawer);
|
||||||
const statusStrip = $(".gesture-status-strip", gestureDrawer);
|
const statusStrip = $(".gesture-status-strip", gestureDrawer);
|
||||||
const gestureNow = $(".gesture-now", gestureDrawer);
|
const gestureNow = $(".gesture-now", gestureDrawer);
|
||||||
@@ -438,9 +409,8 @@
|
|||||||
cameraToggle.setAttribute("aria-label", "开启摄像头");
|
cameraToggle.setAttribute("aria-label", "开启摄像头");
|
||||||
cameraToggle.setAttribute("title", "开启摄像头");
|
cameraToggle.setAttribute("title", "开启摄像头");
|
||||||
}
|
}
|
||||||
if (actionRail && cameraToggle && debugToggle) {
|
if (actionRail && cameraToggle) {
|
||||||
actionRail.classList.add("v2-gesture-action-rail");
|
actionRail.classList.add("v2-gesture-action-rail");
|
||||||
actionRail.insertBefore(debugToggle, cameraToggle);
|
|
||||||
actionRail.append(cameraToggle);
|
actionRail.append(cameraToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,42 +435,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildModuleSwitcher(mainPanel) {
|
function buildModuleSwitcher(mainPanel) {
|
||||||
const modules = [
|
|
||||||
{
|
|
||||||
id: "self-calibration",
|
|
||||||
label: "自标定",
|
|
||||||
available: true,
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{ id: "self-orchestration", label: "自编排", available: true },
|
|
||||||
{ id: "self-extension", label: "自扩展", available: true },
|
|
||||||
{
|
|
||||||
id: "self-repair",
|
|
||||||
label: "自修复",
|
|
||||||
available: true,
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{ id: "self-evolution", label: "自进化", available: true },
|
|
||||||
];
|
|
||||||
const switcher = make("nav", "v2-module-switcher", "");
|
const switcher = make("nav", "v2-module-switcher", "");
|
||||||
switcher.id = "v2ModuleSwitcher";
|
switcher.id = "v2ModuleSwitcher";
|
||||||
switcher.setAttribute("role", "tablist");
|
switcher.dataset.unifiedSync = "true";
|
||||||
switcher.setAttribute("aria-label", "能力模块切换");
|
switcher.setAttribute("aria-label", "智能座舱远程控制与同步状态");
|
||||||
modules.forEach((module, index) => {
|
switcher.innerHTML = `
|
||||||
const button = make("button", "v2-module-tab", `<span>${module.label}</span>`);
|
<div class="v2-unified-brand">
|
||||||
button.type = "button";
|
<span class="v2-unified-brand-mark"><i data-lucide="waypoints"></i></span>
|
||||||
button.id = `v2ModuleTab${index}`;
|
<div><small>COCKPIT REMOTE · LIVE MIRROR</small><h1>511工程-智能座舱</h1></div>
|
||||||
button.dataset.module = module.id;
|
</div>
|
||||||
button.setAttribute("role", "tab");
|
<div class="v2-remote-control-state"><i data-lucide="radio-tower"></i><span>网页控制链路</span><strong>已启用</strong></div>
|
||||||
button.setAttribute("aria-selected", "false");
|
<div class="v2-unified-live"><i></i><span>LIVE</span><b id="v2SyncClock">--:--:--</b></div>`;
|
||||||
if (module.adminOnly) button.dataset.adminOnly = "true";
|
|
||||||
if (!module.available) {
|
|
||||||
button.disabled = true;
|
|
||||||
button.setAttribute("aria-disabled", "true");
|
|
||||||
button.title = module.adminOnly ? `${module.label}仅对 admin 开放` : `${module.label}模块待接入`;
|
|
||||||
}
|
|
||||||
switcher.append(button);
|
|
||||||
});
|
|
||||||
mainPanel.prepend(switcher);
|
mainPanel.prepend(switcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +460,7 @@
|
|||||||
<span class="v2-output-state"><i></i><span id="v2OutputState">LIVE STATE</span></span>
|
<span class="v2-output-state"><i></i><span id="v2OutputState">LIVE STATE</span></span>
|
||||||
</header>
|
</header>
|
||||||
<div class="v2-output-content" id="v2OutputContent"></div>
|
<div class="v2-output-content" id="v2OutputContent"></div>
|
||||||
<nav class="v2-page-rail" id="v2PageRail" aria-label="管理员信息分页"></nav>`);
|
<nav class="v2-page-rail" id="v2PageRail" aria-label="信息分页" hidden></nav>`);
|
||||||
mainPanel.append(output);
|
mainPanel.append(output);
|
||||||
|
|
||||||
const content = $("#v2OutputContent");
|
const content = $("#v2OutputContent");
|
||||||
@@ -543,9 +488,523 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displaySync = {
|
||||||
|
revision: -1,
|
||||||
|
requestId: null,
|
||||||
|
pollTimer: null,
|
||||||
|
deviceFrame: null,
|
||||||
|
activeDevice: null,
|
||||||
|
pinnedDevice: null,
|
||||||
|
logs: [],
|
||||||
|
logKeys: new Set(),
|
||||||
|
extensionFingerprint: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const readText = (selector, fallback = "--") => $(selector)?.textContent?.trim() || fallback;
|
||||||
|
|
||||||
|
function seatDeviceRows(seat) {
|
||||||
|
const card = $(`.seat-card[data-seat="${seat}"]`);
|
||||||
|
if (!card) return [["状态", "等待反馈"]];
|
||||||
|
const rows = [
|
||||||
|
["反馈通道", $("[data-seat-feedback]", card)?.textContent?.trim() || "等待反馈"],
|
||||||
|
...$$('[data-function]', card).map((item) => [
|
||||||
|
item.textContent.replace(item.querySelector("b")?.textContent || "", "").trim(),
|
||||||
|
$("b", item)?.textContent?.trim() || "--",
|
||||||
|
]),
|
||||||
|
...$$('.motion-axis', card).map((item) => [
|
||||||
|
$("span", item)?.textContent?.trim() || "姿态",
|
||||||
|
$("b", item)?.textContent?.trim() || "--",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
return rows.filter(([label]) => label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedDevices = [
|
||||||
|
{
|
||||||
|
id: "windows", name: "车窗", icon: "gallery-vertical-end",
|
||||||
|
rows: () => [["设备状态", readText("#windowState", "等待反馈")], ["左前窗", readText("#frontLeftWindow")], ["右前窗", readText("#frontRightWindow")], ["左后窗", readText("#rearLeftWindow")], ["右后窗", readText("#rearRightWindow")]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shade", name: "遮阳帘", icon: "panels-top-left",
|
||||||
|
rows: () => [["设备状态", readText("#roofState", "等待反馈")], ["当前位置", readText("#shadePositionValue")], ["动作", readText("#shadeMotionState")], ["初始化", readText("#shadeInitializedState")], ["热保护", readText("#shadeThermalState")]],
|
||||||
|
},
|
||||||
|
{ id: "driver", name: "主驾座椅", icon: "armchair", rows: () => seatDeviceRows("driver") },
|
||||||
|
{ id: "passenger", name: "副驾座椅", icon: "armchair", rows: () => seatDeviceRows("passenger") },
|
||||||
|
{
|
||||||
|
id: "reading", name: "阅读灯", icon: "book-open",
|
||||||
|
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")], ["控制通道", "CH1 · INTERIOR"], ["控制能力", "开 / 关"]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ambient", name: "氛围灯", icon: "sparkles",
|
||||||
|
rows: () => [["电源", readText("#ambientPower", "等待反馈")], ["模式", readText("#ambientMode")], ["前/后排亮度", readText("#ambientBrightness")], ["前/后排色号", readText("#ambientColorIndex")]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "headlight", name: "前大灯", icon: "car-front",
|
||||||
|
rows: () => [["设备状态", readText("#frontLightState", "等待反馈")], ["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "climate", name: "空调", icon: "fan",
|
||||||
|
rows: () => [["电源", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["主驾风向", readText("#driverAirDirection")], ["副驾风向", readText("#passengerAirDirection")], ["循环", readText("#cycleMode")], ["A/C", readText("#acState")], ["前除霜", readText("#defrostState")]],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function deviceSummary(device) {
|
||||||
|
const values = device.rows().map(([, value]) => value).filter(Boolean);
|
||||||
|
return values.slice(0, 3).join(" · ") || "等待反馈";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSyncedDevices() {
|
||||||
|
const grid = $("#v2DeviceSyncGrid");
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = syncedDevices.map((device) => {
|
||||||
|
const rows = device.rows();
|
||||||
|
const detail = deviceSummary(device);
|
||||||
|
const waiting = /--|等待|同步中|未知/.test(detail);
|
||||||
|
return `<button class="v2-synced-device" type="button" data-device="${device.id}" data-ready="${String(!waiting)}" aria-label="查看${device.name}完整状态">
|
||||||
|
<span><i data-lucide="${device.icon}"></i></span>
|
||||||
|
<div><strong>${device.name}</strong><small>${escapeHtml(detail)}</small></div>
|
||||||
|
<i class="v2-device-signal"></i>
|
||||||
|
<dl>${rows.slice(0, 6).map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>
|
||||||
|
</button>`;
|
||||||
|
}).join("");
|
||||||
|
if (displaySync.activeDevice) {
|
||||||
|
$(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active");
|
||||||
|
}
|
||||||
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSyncedDeviceRender() {
|
||||||
|
if (displaySync.deviceFrame) return;
|
||||||
|
displaySync.deviceFrame = requestAnimationFrame(() => {
|
||||||
|
displaySync.deviceFrame = null;
|
||||||
|
renderSyncedDevices();
|
||||||
|
if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, {
|
||||||
|
pinned: Boolean(displaySync.pinnedDevice),
|
||||||
|
selectSeat: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSyncLog({ key = "", category = "sync", title, detail = "", status = "info" }) {
|
||||||
|
if (!title || (key && displaySync.logKeys.has(key))) return;
|
||||||
|
const item = {
|
||||||
|
key,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
status,
|
||||||
|
time: new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
||||||
|
}).format(new Date()),
|
||||||
|
};
|
||||||
|
if (key) displaySync.logKeys.add(key);
|
||||||
|
displaySync.logs.unshift(item);
|
||||||
|
if (displaySync.logs.length > 40) {
|
||||||
|
const removed = displaySync.logs.pop();
|
||||||
|
if (removed?.key) displaySync.logKeys.delete(removed.key);
|
||||||
|
}
|
||||||
|
renderSyncLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSyncLogs() {
|
||||||
|
const list = $("#v2SyncLogList");
|
||||||
|
if (!list) return;
|
||||||
|
const filter = $("#v2LogFilter")?.value || "all";
|
||||||
|
const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter);
|
||||||
|
const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" };
|
||||||
|
list.innerHTML = items.length ? items.map((item) => `
|
||||||
|
<li data-log-status="${escapeHtml(item.status)}">
|
||||||
|
<time>${escapeHtml(item.time)}</time>
|
||||||
|
<span><i data-lucide="${icons[item.category] || "circle-dot"}"></i></span>
|
||||||
|
<div><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.detail)}</small></div>
|
||||||
|
</li>`).join("") : '<li class="v2-sync-log-empty"><div><strong>暂无记录</strong><small>车机任务、设备反馈与外设事件会显示在这里</small></div></li>';
|
||||||
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelVisible(key) {
|
||||||
|
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
||||||
|
if (panel) return !panel.classList.contains("is-collapsed");
|
||||||
|
return sessionStorage.getItem(`cognitive-agent.sync-panel.${key}`) !== "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidePanelVisibility(key, visible) {
|
||||||
|
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
||||||
|
if (!panel) return;
|
||||||
|
panel.hidden = false;
|
||||||
|
panel.classList.toggle("is-collapsed", !visible);
|
||||||
|
panel.dataset.collapsed = String(!visible);
|
||||||
|
sessionStorage.setItem(`cognitive-agent.sync-panel.${key}`, visible ? "visible" : "hidden");
|
||||||
|
$$(`[data-panel-toggle="${key}"], [data-panel-close="${key}"]`).forEach((button) => {
|
||||||
|
button.classList.toggle("active", visible);
|
||||||
|
button.setAttribute("aria-pressed", String(visible));
|
||||||
|
button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${key === "devices" ? "车端设备状态" : "外设与扩展能力"}`);
|
||||||
|
const actionLabel = $("[data-panel-action-label]", button);
|
||||||
|
if (actionLabel) actionLabel.textContent = visible ? "收起" : "展开";
|
||||||
|
const stateLabel = $("[data-panel-state-label]", button);
|
||||||
|
if (stateLabel) stateLabel.textContent = visible ? "已展开" : "已收起";
|
||||||
|
});
|
||||||
|
const openPanels = ["devices", "extensions"].filter((panelKey) => {
|
||||||
|
const candidate = panelKey === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
||||||
|
return candidate && !candidate.classList.contains("is-collapsed");
|
||||||
|
});
|
||||||
|
$(".joint-twin-shell")?.setAttribute("data-open-panels", openPanels.join(" ") || "none");
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentTwinDevice(deviceId, { pinned = false, selectSeat = pinned } = {}) {
|
||||||
|
const device = syncedDevices.find((item) => item.id === deviceId);
|
||||||
|
const popover = $("#v2TwinDevicePopover");
|
||||||
|
if (!device || !popover) return;
|
||||||
|
displaySync.activeDevice = deviceId;
|
||||||
|
if (pinned) displaySync.pinnedDevice = deviceId;
|
||||||
|
const rows = device.rows();
|
||||||
|
popover.hidden = false;
|
||||||
|
popover.dataset.device = deviceId;
|
||||||
|
popover.dataset.pinned = String(Boolean(displaySync.pinnedDevice));
|
||||||
|
popover.innerHTML = `
|
||||||
|
<header><span><i data-lucide="${device.icon}"></i></span><div><small>LIVE DEVICE FEEDBACK</small><strong>${escapeHtml(device.name)}</strong></div><b>${pinned ? "已固定" : "靠近查看"}</b></header>
|
||||||
|
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>`;
|
||||||
|
$$("[data-twin-device]").forEach((button) => button.classList.toggle("active", button.dataset.twinDevice === deviceId));
|
||||||
|
$$(".v2-synced-device").forEach((button) => button.classList.toggle("active", button.dataset.device === deviceId));
|
||||||
|
if (selectSeat && ["driver", "passenger"].includes(deviceId)) {
|
||||||
|
$(`[data-twin-seat="${deviceId}"]`)?.click();
|
||||||
|
}
|
||||||
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTwinDevicePreview({ force = false } = {}) {
|
||||||
|
if (displaySync.pinnedDevice && !force) return;
|
||||||
|
displaySync.activeDevice = null;
|
||||||
|
if (force) displaySync.pinnedDevice = null;
|
||||||
|
const popover = $("#v2TwinDevicePopover");
|
||||||
|
if (popover) popover.hidden = true;
|
||||||
|
$$("[data-twin-device], .v2-synced-device").forEach((button) => button.classList.remove("active"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTwinDeviceInteractions() {
|
||||||
|
const stage = $("#seatTwin");
|
||||||
|
if (!stage || $("#v2TwinHotspots")) return;
|
||||||
|
const hotspots = make("div", "v2-twin-hotspots", syncedDevices.map((device) => `
|
||||||
|
<button type="button" data-twin-device="${device.id}" aria-label="查看${device.name}状态"><i data-lucide="${device.icon}"></i><span>${device.name}</span></button>`).join(""));
|
||||||
|
hotspots.id = "v2TwinHotspots";
|
||||||
|
const popover = make("aside", "v2-twin-device-popover", "");
|
||||||
|
popover.id = "v2TwinDevicePopover";
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.setAttribute("aria-live", "polite");
|
||||||
|
stage.append(hotspots, popover);
|
||||||
|
|
||||||
|
hotspots.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||||
|
hotspots.addEventListener("pointerover", (event) => {
|
||||||
|
const button = event.target.closest("[data-twin-device]");
|
||||||
|
if (button && !displaySync.pinnedDevice) presentTwinDevice(button.dataset.twinDevice);
|
||||||
|
});
|
||||||
|
hotspots.addEventListener("focusin", (event) => {
|
||||||
|
const button = event.target.closest("[data-twin-device]");
|
||||||
|
if (button) presentTwinDevice(button.dataset.twinDevice);
|
||||||
|
});
|
||||||
|
hotspots.addEventListener("pointerout", (event) => {
|
||||||
|
if (event.relatedTarget?.closest?.("#v2TwinDevicePopover")) return;
|
||||||
|
window.setTimeout(() => clearTwinDevicePreview(), 100);
|
||||||
|
});
|
||||||
|
hotspots.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-twin-device]");
|
||||||
|
if (!button) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
const samePinned = displaySync.pinnedDevice === button.dataset.twinDevice;
|
||||||
|
if (samePinned) clearTwinDevicePreview({ force: true });
|
||||||
|
else presentTwinDevice(button.dataset.twinDevice, { pinned: true });
|
||||||
|
});
|
||||||
|
popover.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||||
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskKind(utterance, result, source = "") {
|
||||||
|
if (source === "web_console") return "网页远程编排";
|
||||||
|
if (result?.mode_profile) return "自定义功能模式";
|
||||||
|
const preset = $$(".mode-chips [data-mode-command]").find((button) => (
|
||||||
|
String(utterance).includes(button.textContent.trim())
|
||||||
|
|| button.dataset.modeCommand === utterance
|
||||||
|
));
|
||||||
|
return preset ? "预设功能模式" : utterance ? "一句话自定义编排" : "等待编排任务";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskTimeline(task) {
|
||||||
|
const timeline = $("#v2TaskTimeline");
|
||||||
|
if (!timeline) return;
|
||||||
|
const result = task?.result;
|
||||||
|
const trace = Array.isArray(result?.trace_events) ? result.trace_events : [];
|
||||||
|
const domains = Object.keys(result?.function_configs || {});
|
||||||
|
const stages = task?.status === "running"
|
||||||
|
? ["接收编排指令", "解析意图", "构建功能模式", "等待设备反馈"]
|
||||||
|
: trace.length
|
||||||
|
? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤")
|
||||||
|
: domains.length
|
||||||
|
? ["接收编排指令", `编排 ${domains.length} 个设备域`, "下发执行计划", "同步执行结果"]
|
||||||
|
: ["等待车机或网页端下发编排任务"];
|
||||||
|
timeline.innerHTML = stages.map((stage, index) => `
|
||||||
|
<span class="${task?.status === "running" && index === stages.length - 1 ? "active" : "done"}">
|
||||||
|
<i>${String(index + 1).padStart(2, "0")}</i><b>${escapeHtml(stage)}</b>
|
||||||
|
</span>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTaskPresentation(task = {}) {
|
||||||
|
const utterance = String(task.utterance || "");
|
||||||
|
const result = task.result && typeof task.result === "object" ? task.result : null;
|
||||||
|
const input = $("#utterance");
|
||||||
|
if (input && utterance && document.activeElement !== input) input.value = utterance;
|
||||||
|
const kind = taskKind(utterance, result, String(task.source || ""));
|
||||||
|
if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind;
|
||||||
|
if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令";
|
||||||
|
const normalizedStatus = String(task.status || "idle").toLowerCase();
|
||||||
|
const statusMap = {
|
||||||
|
idle: ["等待任务", "idle"],
|
||||||
|
running: ["构建 / 执行中", "running"],
|
||||||
|
succeeded: ["执行完成", "succeeded"],
|
||||||
|
partially_succeeded: ["部分完成", "partial"],
|
||||||
|
needs_confirmation: ["等待确认", "partial"],
|
||||||
|
failed: ["执行失败", "failed"],
|
||||||
|
};
|
||||||
|
const [label, state] = statusMap[normalizedStatus] || statusMap.idle;
|
||||||
|
const badge = $("#v2TaskState");
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = label;
|
||||||
|
badge.dataset.state = state;
|
||||||
|
}
|
||||||
|
$$(".mode-chips [data-mode-command]").forEach((button) => {
|
||||||
|
const active = Boolean(utterance) && (
|
||||||
|
utterance === button.dataset.modeCommand
|
||||||
|
|| utterance.includes(button.textContent.trim())
|
||||||
|
);
|
||||||
|
button.classList.toggle("v2-sync-active", active);
|
||||||
|
});
|
||||||
|
renderTaskTimeline(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDisplaySnapshot(snapshot) {
|
||||||
|
const health = snapshot?.extension_health || {};
|
||||||
|
const physicallyConnected = health.transport !== "simulation"
|
||||||
|
&& Boolean(health.connected || health.peer_connected);
|
||||||
|
const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog)
|
||||||
|
? snapshot.extension.catalog
|
||||||
|
: [];
|
||||||
|
const connected = physicallyConnected || synchronizedCatalog.length > 0;
|
||||||
|
const listening = Boolean(health.listening);
|
||||||
|
const usbState = $("#v2UsbState");
|
||||||
|
if (usbState) {
|
||||||
|
const usbLabel = $("span", usbState);
|
||||||
|
if (usbLabel) usbLabel.textContent = physicallyConnected
|
||||||
|
? "USB 外设已接入"
|
||||||
|
: synchronizedCatalog.length
|
||||||
|
? `${new Set(synchronizedCatalog.map((item) => item.device_id)).size} 个外设已同步`
|
||||||
|
: listening ? "等待 USB 外设" : "扩展链路检查中";
|
||||||
|
usbState.dataset.state = connected ? "online" : listening ? "listening" : "offline";
|
||||||
|
}
|
||||||
|
if ($("#v2SyncTransport")) {
|
||||||
|
$("#v2SyncTransport").textContent = `${String(health.transport || "GSCU").toUpperCase()} · ${health.peer || health.listen || "等待连接"}`;
|
||||||
|
}
|
||||||
|
const vehicleOnline = health.vehicle?.connected !== false;
|
||||||
|
const vehicleBadge = $("#v2HeadUnitState");
|
||||||
|
if (vehicleBadge) {
|
||||||
|
vehicleBadge.dataset.state = vehicleOnline ? "online" : "offline";
|
||||||
|
$("span", vehicleBadge).textContent = vehicleOnline ? "车机链路在线" : "车机链路离线";
|
||||||
|
}
|
||||||
|
const revision = Number(snapshot?.revision ?? -1);
|
||||||
|
if (revision !== displaySync.revision) {
|
||||||
|
displaySync.revision = revision;
|
||||||
|
const task = snapshot?.task || {};
|
||||||
|
setTaskPresentation(task);
|
||||||
|
const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
|
||||||
|
if (task.utterance) {
|
||||||
|
appendSyncLog({
|
||||||
|
key: `task:${revision}:${task.status}`,
|
||||||
|
category: "task",
|
||||||
|
title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
|
||||||
|
detail: `${task.utterance} · ${task.status || "idle"}`,
|
||||||
|
status: ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = task.result;
|
||||||
|
const requestId = result?.request_id || null;
|
||||||
|
if (result && requestId !== displaySync.requestId) {
|
||||||
|
displaySync.requestId = requestId;
|
||||||
|
window.CognitiveAgentConsole?.applySyncedResult?.(result, {
|
||||||
|
utterance: task.utterance || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extensionFingerprint = synchronizedCatalog
|
||||||
|
.map((item) => `${item.device_id || "external"}:${item.action || item.name || "capability"}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
if (extensionFingerprint && extensionFingerprint !== displaySync.extensionFingerprint) {
|
||||||
|
displaySync.extensionFingerprint = extensionFingerprint;
|
||||||
|
appendSyncLog({
|
||||||
|
key: `extension:${extensionFingerprint}`,
|
||||||
|
category: "extension",
|
||||||
|
title: "外设能力目录已同步",
|
||||||
|
detail: `${new Set(synchronizedCatalog.map((item) => item.device_id || "external")).size} 个外设 · ${synchronizedCatalog.length} 项能力`,
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("cognitive-agent:display-sync", { detail: snapshot }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollVehicleDisplaySync() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/vehicle-display-sync", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
applyDisplaySnapshot(await response.json());
|
||||||
|
$("#v2MirrorState")?.setAttribute("data-state", "online");
|
||||||
|
if ($("#v2MirrorState span")) $("#v2MirrorState span").textContent = "同步正常";
|
||||||
|
appendSyncLog({ key: "sync:online", category: "sync", title: "网页同步链路已建立", detail: "车机任务与设备状态每 2 秒同步", status: "success" });
|
||||||
|
} catch (_error) {
|
||||||
|
console.warn("[vehicle-display-sync]", _error);
|
||||||
|
$("#v2MirrorState")?.setAttribute("data-state", "offline");
|
||||||
|
if ($("#v2MirrorState span")) $("#v2MirrorState span").textContent = "等待同步服务";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableRemoteOrchestration() {
|
||||||
|
const form = $("#requestForm");
|
||||||
|
const input = $("#utterance");
|
||||||
|
if (input) {
|
||||||
|
input.readOnly = false;
|
||||||
|
input.removeAttribute("aria-readonly");
|
||||||
|
input.setAttribute("aria-label", "输入一句话远程编排车端功能");
|
||||||
|
input.placeholder = "例如:副驾调到舒适姿态,空调 24℃,氛围灯改成暖橙色";
|
||||||
|
}
|
||||||
|
form?.addEventListener("submit", () => {
|
||||||
|
const utterance = input?.value.trim();
|
||||||
|
if (utterance) appendSyncLog({
|
||||||
|
category: "task",
|
||||||
|
title: "网页端发送编排指令",
|
||||||
|
detail: utterance,
|
||||||
|
status: "info",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$$("[data-mode-command]").forEach((button) => {
|
||||||
|
button.removeAttribute("aria-disabled");
|
||||||
|
button.title = "点击后由网页端发送至车机执行";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSyncControlLayer() {
|
||||||
|
document.body.classList.add("v2-passive-display", "v2-interactive-sync");
|
||||||
|
document.body.dataset.module = "self-orchestration";
|
||||||
|
enableRemoteOrchestration();
|
||||||
|
|
||||||
|
const sidebar = $(".v2-sidebar");
|
||||||
|
const sidebarFooter = $(".v2-sidebar-footer", sidebar);
|
||||||
|
const syncSidebar = make("section", "v2-sync-sidebar", `
|
||||||
|
<div class="v2-sync-sidebar-heading"><small>REMOTE CLIENT</small><h2>511工程-智能座舱</h2><p>网页可远程编排,车机任务、设备反馈与 USB 外设能力保持同步。</p></div>
|
||||||
|
<div class="v2-sync-link-stack">
|
||||||
|
<div class="v2-sync-link" id="v2HeadUnitState" data-state="checking"><i data-lucide="monitor-smartphone"></i><div><strong>车机链路</strong><span>连接检查中</span></div><b></b></div>
|
||||||
|
<div class="v2-sync-link" id="v2MirrorState" data-state="checking"><i data-lucide="refresh-cw"></i><div><strong>网页同步</strong><span>建立同步中</span></div><b></b></div>
|
||||||
|
<div class="v2-sync-link" id="v2UsbState" data-state="checking"><i data-lucide="usb"></i><div><strong>USB 扩展</strong><span>检查外设中</span></div><b></b></div>
|
||||||
|
</div>
|
||||||
|
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle">
|
||||||
|
<header><div><small>RUNTIME / SYNC</small><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div><select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select></header>
|
||||||
|
<ol id="v2SyncLogList" aria-live="polite"></ol>
|
||||||
|
</section>
|
||||||
|
<div class="v2-sync-protocol"><i data-lucide="radio-tower"></i><div><small>扩展链路</small><strong id="v2SyncTransport">GSCU · 等待连接</strong></div></div>`);
|
||||||
|
if (sidebar && sidebarFooter) sidebar.insertBefore(syncSidebar, sidebarFooter);
|
||||||
|
|
||||||
|
const naturalPanel = $("#v2NaturalPanel");
|
||||||
|
const taskOverview = make("section", "v2-task-overview", `
|
||||||
|
<div class="v2-task-copy"><span id="v2TaskKind">当前车机任务</span><strong id="v2TaskCommand">等待车机端或网页端选择预设模式、输入一句话指令</strong></div>
|
||||||
|
<span class="v2-task-state" id="v2TaskState" data-state="idle">等待任务</span>
|
||||||
|
<div class="v2-task-timeline" id="v2TaskTimeline"><span><i>01</i><b>等待车机或网页端下发编排任务</b></span></div>`);
|
||||||
|
naturalPanel?.append(taskOverview);
|
||||||
|
|
||||||
|
const jointShell = $(".joint-twin-shell");
|
||||||
|
const cabinBand = $(".cabin-band", jointShell);
|
||||||
|
if (jointShell && cabinBand) {
|
||||||
|
const rightRail = make("aside", "v2-unified-right-rail", `
|
||||||
|
<section class="v2-device-sync-board v2-side-status-panel" id="v2DevicePanel">
|
||||||
|
<header><div><small>LIVE VEHICLE DEVICES</small><h3>车端设备状态</h3></div><span><i></i>实时反馈</span><button type="button" data-panel-toggle="devices" aria-label="收起车端设备状态"><i data-lucide="panel-left-close"></i><span data-panel-action-label>收起</span></button></header>
|
||||||
|
<div class="v2-device-sync-grid" id="v2DeviceSyncGrid"></div>
|
||||||
|
</section>
|
||||||
|
<section class="v2-extension-panel v2-side-status-panel" id="v2ExtensionPanel"><div class="v2-extension-sync-mount" id="vehicleExtensionSync" aria-live="polite"></div></section>`);
|
||||||
|
jointShell.insertBefore(rightRail, cabinBand);
|
||||||
|
const panelControls = make("div", "v2-panel-visibility-controls", `
|
||||||
|
<button type="button" data-panel-toggle="devices" aria-pressed="true"><i data-lucide="sliders-horizontal"></i><span>设备状态</span><b data-panel-state-label>已展开</b></button>
|
||||||
|
<button type="button" data-panel-toggle="extensions" aria-pressed="true"><i data-lucide="usb"></i><span>外设能力</span><b data-panel-state-label>已展开</b></button>`);
|
||||||
|
$(".twin-toolbar", jointShell)?.prepend(panelControls);
|
||||||
|
buildTwinDeviceInteractions();
|
||||||
|
cabinBand.classList.add("v2-source-state-only");
|
||||||
|
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
characterData: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
const stateBand = $(".state-band");
|
||||||
|
if (stateBand) new MutationObserver(scheduleSyncedDeviceRender).observe(stateBand, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
characterData: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSidePanelVisibility("devices", panelVisible("devices"));
|
||||||
|
setSidePanelVisibility("extensions", panelVisible("extensions"));
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const toggle = event.target.closest("[data-panel-toggle], [data-panel-close]");
|
||||||
|
if (toggle) {
|
||||||
|
const key = toggle.dataset.panelToggle || toggle.dataset.panelClose;
|
||||||
|
setSidePanelVisibility(key, !panelVisible(key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("#v2DeviceSyncGrid")?.addEventListener("click", (event) => {
|
||||||
|
const card = event.target.closest("[data-device]");
|
||||||
|
if (!card) return;
|
||||||
|
presentTwinDevice(card.dataset.device, { pinned: true });
|
||||||
|
});
|
||||||
|
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
|
||||||
|
window.addEventListener("cognitive-agent:result", (event) => {
|
||||||
|
const result = event.detail || {};
|
||||||
|
appendSyncLog({
|
||||||
|
key: result.request_id ? `result:${result.request_id}` : "",
|
||||||
|
category: "task",
|
||||||
|
title: ["succeeded", "partially_succeeded"].includes(result.status) ? "编排任务执行完成" : "编排任务返回结果",
|
||||||
|
detail: `${result.summary || result.message || "执行反馈已同步"} · ${result.status || "unknown"}`,
|
||||||
|
status: ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||||||
|
if (!event.detail?.error) return;
|
||||||
|
appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" });
|
||||||
|
});
|
||||||
|
window.addEventListener("cognitive-agent:connection", (event) => {
|
||||||
|
const online = event.detail?.status === "online";
|
||||||
|
appendSyncLog({
|
||||||
|
key: `connection:${event.detail?.status}`,
|
||||||
|
category: "sync",
|
||||||
|
title: online ? "车机服务连接正常" : "车机服务连接异常",
|
||||||
|
detail: online ? "远程控制与状态同步通道可用" : String(event.detail?.status || "offline"),
|
||||||
|
status: online ? "success" : "error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderSyncedDevices();
|
||||||
|
setTaskPresentation({ status: "idle" });
|
||||||
|
appendSyncLog({ key: "client:ready", category: "sync", title: "同步控制台已就绪", detail: "正在连接车机任务与设备状态服务", status: "info" });
|
||||||
|
pollVehicleDisplaySync();
|
||||||
|
displaySync.pollTimer = window.setInterval(pollVehicleDisplaySync, 2000);
|
||||||
|
const updateClock = () => {
|
||||||
|
if ($("#v2SyncClock")) $("#v2SyncClock").textContent = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
||||||
|
}).format(new Date());
|
||||||
|
};
|
||||||
|
updateClock();
|
||||||
|
window.setInterval(updateClock, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function setPage(pageIndex) {
|
function setPage(pageIndex) {
|
||||||
const maxPage = ui.role === "admin" ? pageDefinitions.length - 1 : 0;
|
ui.activePage = 0;
|
||||||
ui.activePage = Math.max(0, Math.min(Number(pageIndex) || 0, maxPage));
|
|
||||||
const definition = pageDefinitions[ui.activePage];
|
const definition = pageDefinitions[ui.activePage];
|
||||||
$(".v2-output-shell")?.classList.toggle("v2-twin-active", definition.index === 0);
|
$(".v2-output-shell")?.classList.toggle("v2-twin-active", definition.index === 0);
|
||||||
$$(".v2-output-page").forEach((page) => {
|
$$(".v2-output-page").forEach((page) => {
|
||||||
@@ -654,128 +1113,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSidebarControlForModule(evolutionView = window.CognitiveAgentSelfEvolution?.getView?.()) {
|
|
||||||
const evolutionMode = ui.activeModule === "self-evolution";
|
|
||||||
const caption = $(".v2-control-section > .v2-field-caption");
|
|
||||||
if (caption) caption.textContent = evolutionMode ? "进化视图" : "控制方式";
|
|
||||||
const definitions = evolutionMode
|
|
||||||
? [
|
|
||||||
{ label: "座椅称重进化", icon: "scale", active: evolutionView !== "system" },
|
|
||||||
{ label: "系统能力日志", icon: "scroll-text", active: evolutionView === "system" },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ label: "自然语言控制", icon: "message-square-text", active: ui.controlMode === "natural" },
|
|
||||||
{ label: "手势控制", icon: "scan-face", active: ui.controlMode === "gesture" },
|
|
||||||
];
|
|
||||||
$$('[data-v2-control]').forEach((button, index) => {
|
|
||||||
const definition = definitions[index];
|
|
||||||
if (!definition) return;
|
|
||||||
const label = $("span", button);
|
|
||||||
const icon = $("svg, i", button);
|
|
||||||
if (label) label.textContent = definition.label;
|
|
||||||
if (icon) icon.setAttribute("data-lucide", definition.icon);
|
|
||||||
button.classList.toggle("active", definition.active);
|
|
||||||
button.setAttribute("aria-pressed", String(definition.active));
|
|
||||||
});
|
|
||||||
const switcher = $(".v2-control-switch");
|
|
||||||
switcher?.setAttribute("aria-label", evolutionMode ? "自进化视图" : "控制方式");
|
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setModule(module, { persist = true } = {}) {
|
function setModule(module, { persist = true } = {}) {
|
||||||
if (["self-calibration", "self-repair"].includes(module) && ui.role !== "admin") {
|
const allowed = new Set(["self-orchestration", "self-extension"]);
|
||||||
setRole("admin", { allowModuleTransition: true });
|
|
||||||
}
|
|
||||||
const allowed = new Set(["self-orchestration", "self-extension", "self-evolution"]);
|
|
||||||
if (ui.role === "admin") {
|
|
||||||
allowed.add("self-calibration");
|
|
||||||
allowed.add("self-repair");
|
|
||||||
}
|
|
||||||
ui.activeModule = allowed.has(module) ? module : "self-orchestration";
|
ui.activeModule = allowed.has(module) ? module : "self-orchestration";
|
||||||
if (ui.activeModule === "self-extension" && ui.role !== "user") {
|
|
||||||
setRole("user");
|
|
||||||
}
|
|
||||||
if (persist) {
|
if (persist) {
|
||||||
sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule);
|
sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule);
|
||||||
}
|
}
|
||||||
document.body.dataset.module = ui.activeModule;
|
document.body.dataset.module = ui.activeModule;
|
||||||
const roleSelect = $("#v2RoleSelect");
|
|
||||||
if (roleSelect) {
|
|
||||||
roleSelect.disabled = ui.activeModule === "self-repair";
|
|
||||||
dropdownControls.get(roleSelect)?.sync();
|
|
||||||
}
|
|
||||||
$$('.v2-module-tab').forEach((button) => {
|
$$('.v2-module-tab').forEach((button) => {
|
||||||
const active = button.dataset.module === ui.activeModule;
|
const active = button.dataset.module === ui.activeModule;
|
||||||
button.classList.toggle("active", active);
|
button.classList.toggle("active", active);
|
||||||
button.setAttribute("aria-selected", String(active));
|
button.setAttribute("aria-selected", String(active));
|
||||||
button.tabIndex = active ? 0 : -1;
|
button.tabIndex = active ? 0 : -1;
|
||||||
});
|
});
|
||||||
if (["self-extension", "self-calibration", "self-repair", "self-evolution"].includes(ui.activeModule)) {
|
if (ui.activeModule === "self-extension") {
|
||||||
const drawer = $("#gestureDrawer");
|
const drawer = $("#gestureDrawer");
|
||||||
if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click();
|
if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click();
|
||||||
} else {
|
} else {
|
||||||
setControlMode(ui.controlMode, { triggerGesture: false });
|
setControlMode(ui.controlMode, { triggerGesture: false });
|
||||||
}
|
}
|
||||||
syncSidebarControlForModule();
|
|
||||||
window.dispatchEvent(new CustomEvent("cognitive-agent:module-change", {
|
window.dispatchEvent(new CustomEvent("cognitive-agent:module-change", {
|
||||||
detail: { module: ui.activeModule },
|
detail: { module: ui.activeModule },
|
||||||
}));
|
}));
|
||||||
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120);
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRole(role, { allowModuleTransition = false } = {}) {
|
|
||||||
if (ui.activeModule === "self-extension" && role !== "user" && !allowModuleTransition) {
|
|
||||||
role = "user";
|
|
||||||
}
|
|
||||||
if (ui.activeModule === "self-repair" && role !== "admin") {
|
|
||||||
const roleSelect = $("#v2RoleSelect");
|
|
||||||
if (roleSelect) {
|
|
||||||
roleSelect.value = "admin";
|
|
||||||
dropdownControls.get(roleSelect)?.sync();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.role = role === "admin" ? "admin" : "user";
|
|
||||||
sessionStorage.setItem("cognitive-agent.demo-role", ui.role);
|
|
||||||
document.body.dataset.demoRole = ui.role;
|
|
||||||
window.dispatchEvent(new CustomEvent("cognitive-agent:demo-role-change", {
|
|
||||||
detail: { role: ui.role },
|
|
||||||
}));
|
|
||||||
$("#v2RoleSelect").value = ui.role;
|
|
||||||
dropdownControls.get($("#v2RoleSelect"))?.sync();
|
|
||||||
$("#v2PageRail").hidden = ui.role !== "admin";
|
|
||||||
const calibrationTab = $('.v2-module-tab[data-module="self-calibration"]');
|
|
||||||
if (calibrationTab) {
|
|
||||||
calibrationTab.disabled = false;
|
|
||||||
calibrationTab.setAttribute("aria-disabled", "false");
|
|
||||||
calibrationTab.title = ui.role === "admin"
|
|
||||||
? ""
|
|
||||||
: "进入自标定时将自动切换为 admin";
|
|
||||||
}
|
|
||||||
const repairTab = $('.v2-module-tab[data-module="self-repair"]');
|
|
||||||
if (repairTab) {
|
|
||||||
repairTab.hidden = false;
|
|
||||||
repairTab.disabled = false;
|
|
||||||
repairTab.setAttribute("aria-disabled", "false");
|
|
||||||
repairTab.title = ui.role === "admin"
|
|
||||||
? ""
|
|
||||||
: "进入自修复时将自动切换为 admin";
|
|
||||||
}
|
|
||||||
if (ui.role !== "admin") {
|
|
||||||
if (["self-calibration", "self-repair"].includes(ui.activeModule)) {
|
|
||||||
setModule("self-orchestration");
|
|
||||||
}
|
|
||||||
setPage(0);
|
|
||||||
requestUserSafeExecutionMode();
|
|
||||||
} else {
|
|
||||||
const note = $("#v2ExecutionNote");
|
|
||||||
if (note) note.textContent = "管理员可切换虚拟 CAN、模拟台架与真实台架";
|
|
||||||
const runButton = $("#runButton");
|
|
||||||
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
|
||||||
syncExecutionSelector();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebarCollapsed(collapsed) {
|
function setSidebarCollapsed(collapsed) {
|
||||||
ui.sidebarCollapsed = Boolean(collapsed);
|
ui.sidebarCollapsed = Boolean(collapsed);
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -804,7 +1166,7 @@
|
|||||||
const runButton = $("#runButton");
|
const runButton = $("#runButton");
|
||||||
if (!dryRunButton) return;
|
if (!dryRunButton) return;
|
||||||
if (!dryRunButton.classList.contains("active")) dryRunButton.click();
|
if (!dryRunButton.classList.contains("active")) dryRunButton.click();
|
||||||
if (note) note.textContent = "测试用户使用虚拟 CAN;真实台架仅由管理员显式启用";
|
if (note) note.textContent = "user 固定使用虚拟 CAN 安全执行环境";
|
||||||
if (userTarget) userTarget.dataset.state = "ready";
|
if (userTarget) userTarget.dataset.state = "ready";
|
||||||
if (userTargetText) userTargetText.textContent = "执行目标:虚拟 CAN";
|
if (userTargetText) userTargetText.textContent = "执行目标:虚拟 CAN";
|
||||||
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
||||||
@@ -847,7 +1209,7 @@
|
|||||||
$(".v2-sidebar-footer")?.classList.toggle("offline", !online);
|
$(".v2-sidebar-footer")?.classList.toggle("offline", !online);
|
||||||
}
|
}
|
||||||
syncExecutionSelector();
|
syncExecutionSelector();
|
||||||
if (ui.role === "user") requestUserSafeExecutionMode();
|
requestUserSafeExecutionMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVehicleLinkState(connected) {
|
function setVehicleLinkState(connected) {
|
||||||
@@ -877,21 +1239,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
$("#v2RoleSelect")?.addEventListener("change", (event) => setRole(event.target.value));
|
|
||||||
$$('[data-v2-control]').forEach((button) => {
|
$$('[data-v2-control]').forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (ui.activeModule === "self-evolution") {
|
|
||||||
const view = button.dataset.v2Control === "gesture" ? "system" : "weighing";
|
|
||||||
window.CognitiveAgentSelfEvolution?.setView?.(view);
|
|
||||||
syncSidebarControlForModule(view);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setControlMode(button.dataset.v2Control);
|
setControlMode(button.dataset.v2Control);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.addEventListener("cognitive-agent:evolution-view-change", (event) => {
|
|
||||||
if (ui.activeModule === "self-evolution") syncSidebarControlForModule(event.detail?.view);
|
|
||||||
});
|
|
||||||
$$('.v2-module-tab').forEach((button) => {
|
$$('.v2-module-tab').forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (!button.disabled) setModule(button.dataset.module);
|
if (!button.disabled) setModule(button.dataset.module);
|
||||||
@@ -932,6 +1284,16 @@
|
|||||||
() => requestAnimationFrame(updateModeEdgeHints),
|
() => requestAnimationFrame(updateModeEdgeHints),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
|
modeChips?.addEventListener("wheel", (event) => {
|
||||||
|
if (modeChips.scrollWidth <= modeChips.clientWidth + 2) return;
|
||||||
|
const distance = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||||
|
? event.deltaX
|
||||||
|
: event.deltaY;
|
||||||
|
if (!distance) return;
|
||||||
|
event.preventDefault();
|
||||||
|
modeChips.scrollBy({ left: distance, behavior: "smooth" });
|
||||||
|
window.requestAnimationFrame(updateModeEdgeHints);
|
||||||
|
}, { passive: false });
|
||||||
if (modeChips) {
|
if (modeChips) {
|
||||||
new MutationObserver(() => requestAnimationFrame(updateModeEdgeHints))
|
new MutationObserver(() => requestAnimationFrame(updateModeEdgeHints))
|
||||||
.observe(modeChips, { childList: true });
|
.observe(modeChips, { childList: true });
|
||||||
@@ -990,16 +1352,14 @@
|
|||||||
gestureDrawer,
|
gestureDrawer,
|
||||||
);
|
);
|
||||||
buildModuleSwitcher(mainPanel);
|
buildModuleSwitcher(mainPanel);
|
||||||
window.CognitiveAgentSelfEvolution?.mount(mainPanel);
|
|
||||||
|
|
||||||
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
|
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
|
||||||
$(".topbar")?.classList.add("v2-legacy-hidden");
|
$(".topbar")?.classList.add("v2-legacy-hidden");
|
||||||
$(".gesture-backdrop")?.classList.add("v2-legacy-hidden");
|
$(".gesture-backdrop")?.classList.add("v2-legacy-hidden");
|
||||||
buildOutput(mainPanel);
|
buildOutput(mainPanel);
|
||||||
|
buildSyncControlLayer();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
setControlMode(ui.controlMode, { triggerGesture: false });
|
setControlMode(ui.controlMode, { triggerGesture: false });
|
||||||
setModule(ui.activeModule, { persist: false });
|
setModule(ui.activeModule, { persist: false });
|
||||||
setRole(ui.role);
|
|
||||||
setSidebarCollapsed(ui.sidebarCollapsed);
|
setSidebarCollapsed(ui.sidebarCollapsed);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
syncRuntimePresentation();
|
syncRuntimePresentation();
|
||||||
@@ -1014,7 +1374,7 @@
|
|||||||
window.CognitiveAgentConsoleV2 = {
|
window.CognitiveAgentConsoleV2 = {
|
||||||
enhanceSelect,
|
enhanceSelect,
|
||||||
getModule: () => ui.activeModule,
|
getModule: () => ui.activeModule,
|
||||||
getRole: () => ui.role,
|
getRole: () => "user",
|
||||||
setModule,
|
setModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,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-11" />
|
||||||
<link rel="stylesheet" href="/self-evolution.css?v=seat-weighing-5" />
|
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-11" />
|
||||||
<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-11" defer></script>
|
||||||
<script src="/self-evolution.js?v=seat-weighing-2" defer></script>
|
<script src="/self-extension.js?v=sync-layout-11" 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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}());
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v120";
|
const CACHE_VERSION = "cognitive-agent-pad-v163";
|
||||||
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-11",
|
||||||
"/console-v2.js?v=desktop-refactor-36",
|
"/console-v2.js?v=sync-layout-11",
|
||||||
"/self-evolution.css?v=seat-weighing-5",
|
"/self-extension.css?v=sync-layout-11",
|
||||||
"/self-evolution.js?v=seat-weighing-2",
|
"/self-extension.js?v=sync-layout-11",
|
||||||
"/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
@@ -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
@@ -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-11", 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-11", 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-11", 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-11", 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-11"', html)
|
||||||
self.assertIn('href="/console-v2.css?v=desktop-refactor-107"', html)
|
self.assertIn('href="/console-v2.css?v=sync-layout-11"', html)
|
||||||
self.assertIn('src="/self-evolution.js?v=seat-weighing-2"', html)
|
self.assertIn('src="/self-extension.js?v=sync-layout-11"', html)
|
||||||
self.assertIn('href="/self-evolution.css?v=seat-weighing-5"', html)
|
self.assertIn('href="/self-extension.css?v=sync-layout-11"', 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-v163"', 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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,63 @@ 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
|
|
||||||
assert 'await apiPost("/runtime/reset-statistics")' in repair
|
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user