Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d05815773 | |||
| fff7657fea | |||
| 52c4dd08f9 | |||
| 772ab685d6 | |||
| 9b24d5c8c9 | |||
| 344f662970 | |||
| f77dda70fe | |||
| 6d768aaef4 | |||
| 1a96d39426 | |||
| 0ec43052f3 |
@@ -4,6 +4,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
artifacts/runs/
|
||||
artifacts/mode-profiles/
|
||||
artifacts/server/
|
||||
artifacts/tmp/
|
||||
artifacts/self-repair/
|
||||
|
||||
@@ -113,6 +113,13 @@ config/gesture/default.yaml
|
||||
|
||||
同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。
|
||||
|
||||
Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用
|
||||
`zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口
|
||||
验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。
|
||||
同一页面也可选择本机 NanoMQ 无线链路,其中 CAN0 对应副驾座椅、CAN1 对应
|
||||
其余车辆状态和控制;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全
|
||||
门禁见同一文档。
|
||||
|
||||
## 认证与公网体验
|
||||
|
||||
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
# Web Console
|
||||
|
||||
Web 控制台提供一句话、快捷功能模式与手势控制入口,不在浏览器中生成 CAN。所有动作统一提交给后端编排、配置校验、安全门禁和执行链路。
|
||||
Web 控制台是车机端的远程自编排与同步显示界面。网页保留一句话指令和预设功能模式入口,请求通过同源服务发送至车机执行;同时同步显示车机任务、编排结果、设备反馈和 USB 扩展能力。真实 CAN 与硬件执行权限仍由服务端安全门禁控制。
|
||||
|
||||
当前页面包括:
|
||||
|
||||
- 顶部“自编排 / 自标定 / 自扩展 / 自修复”模块切换;user 进入自标定或自修复时自动切换为 admin,自修复停留期间不能降级为 user;自扩展包含外设学习与统一控制两种工作模式。
|
||||
- 六类外设玩具的添加、红外能力学习、测试发射、单动作执行与本地能力目录。
|
||||
- 基于已学习外设能力和车端能力的 GPT 联动计划,车端步骤复用现有安全编排链路。
|
||||
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。
|
||||
- 观影、午休、零重力快捷模式,以及当前副驾驶姿态的命名保存入口。
|
||||
- 六阶段执行链路、整车联合孪生、领域状态、配置、CAN 计划和执行结果。
|
||||
- 右侧手势控制抽屉,包含摄像头画面、关键点骨架、保持进度、动作速查与自定义映射。
|
||||
- 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。
|
||||
- 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
|
||||
- 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
|
||||
- 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
|
||||
- USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
|
||||
- 左侧运行日志集中显示网页/车机任务、链路、设备与外设同步事件。
|
||||
|
||||
UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。
|
||||
|
||||
@@ -54,49 +53,12 @@ TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的
|
||||
自动重试。`/api/extension/health` 的 `listening` 表示服务器已监听,只有收到
|
||||
真实设备会话后 `connected`/`peer_connected` 才会变为 `true`。
|
||||
|
||||
## N7 自标定
|
||||
车机与网页同步使用以下同源接口:
|
||||
|
||||
自标定前端、原始 Android `n7_seat.glb`、四轴 Hall 标定协议以及
|
||||
CAN/MQTT/RTSP 后端均由当前服务同源提供,不再启动原 `8766` 工作站或使用
|
||||
iframe。左侧“执行目标”复用自编排下拉控件,默认显示“真实台架”,也可以
|
||||
切换到“标定仿真台架”。选择目标后仍需显式点击连接,服务启动不会自动打开
|
||||
真实 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 的实物操作时,服务才会尝试运行配置的扩展脚本。
|
||||
- `GET /api/vehicle-display-sync`:网页轮询最新车机任务、结果、外设目录与扩展链路状态。
|
||||
- `POST /api/requests`:车机执行时附带 `client_type: "head_unit"`;网页控制台附带 `client_type: "web_console"`。两类任务的执行结果都会写入同步快照。
|
||||
- `POST /api/extension/display-sync`:车机刚识别 USB 外设时上报 `device` 与 `peripherals` 能力目录,无需等待首次执行。
|
||||
- `POST /api/extension/assistant/plan` 和 `/api/extension/vehicle/execute`:联动规划与车端执行结果同样写入同步快照。
|
||||
|
||||
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
|
||||
`/login`,主控制台提供退出按钮。固定入口是
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 115 KiB |
@@ -56,6 +56,8 @@ const state = {
|
||||
customizeOpen: false,
|
||||
engine: null,
|
||||
runtimeReady: false,
|
||||
enabled: true,
|
||||
disabledReason: "",
|
||||
cameraStatus: "checking",
|
||||
cameraMessage: "正在检测摄像头…",
|
||||
cameraCheckSequence: 0,
|
||||
@@ -114,7 +116,8 @@ function cameraFailureDetails(error) {
|
||||
function syncCameraButtonAvailability() {
|
||||
const button = $("#gestureStartButton");
|
||||
if (!button || state.stream) return;
|
||||
button.disabled = !state.runtimeReady
|
||||
button.disabled = !state.enabled
|
||||
|| !state.runtimeReady
|
||||
|| ["checking", "unavailable", "unsupported"].includes(state.cameraStatus);
|
||||
}
|
||||
|
||||
@@ -557,7 +560,6 @@ async function loadGestureRuntime() {
|
||||
state.runtime = await response.json();
|
||||
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
|
||||
renderMappingGrid();
|
||||
renderGestureDebugOptions();
|
||||
if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源");
|
||||
}
|
||||
|
||||
@@ -658,99 +660,6 @@ function renderMappingGrid() {
|
||||
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() {
|
||||
const token = state.lastRecognizedToken;
|
||||
$("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势";
|
||||
@@ -787,51 +696,60 @@ function resetMapping() {
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
$("#gestureDrawer").classList.add("open");
|
||||
$("#gestureDrawer").setAttribute("aria-hidden", "false");
|
||||
$("#gestureBackdrop").hidden = false;
|
||||
requestAnimationFrame(() => $("#gestureBackdrop").classList.add("visible"));
|
||||
$("#gestureToggleButton").setAttribute("aria-expanded", "true");
|
||||
if (!state.enabled) {
|
||||
notify(state.disabledReason || "当前运行模式未启用手势控制");
|
||||
return false;
|
||||
}
|
||||
const drawer = $("#gestureDrawer");
|
||||
if (!drawer) return false;
|
||||
drawer.classList.add("open");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("gesture-open");
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
stopCamera();
|
||||
$("#gestureDrawer").classList.remove("open");
|
||||
$("#gestureDrawer").setAttribute("aria-hidden", "true");
|
||||
$("#gestureBackdrop").classList.remove("visible");
|
||||
window.setTimeout(() => { $("#gestureBackdrop").hidden = true; }, 240);
|
||||
$("#gestureToggleButton").setAttribute("aria-expanded", "false");
|
||||
const drawer = $("#gestureDrawer");
|
||||
drawer?.classList.remove("open");
|
||||
drawer?.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("gesture-open");
|
||||
}
|
||||
|
||||
function setEnabled(enabled, { reason = "" } = {}) {
|
||||
state.enabled = Boolean(enabled);
|
||||
state.disabledReason = state.enabled ? "" : String(reason || "当前运行模式未启用手势控制");
|
||||
if (!state.enabled) closeDrawer();
|
||||
syncCameraButtonAvailability();
|
||||
const drawer = $("#gestureDrawer");
|
||||
if (drawer) {
|
||||
drawer.dataset.enabled = String(state.enabled);
|
||||
drawer.title = state.disabledReason;
|
||||
}
|
||||
}
|
||||
|
||||
window.CognitiveAgentGesture = {
|
||||
open: openDrawer,
|
||||
close: closeDrawer,
|
||||
isOpen: () => $("#gestureDrawer")?.classList.contains("open") === true,
|
||||
setEnabled,
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
$("#gestureStartButton").disabled = true;
|
||||
$("#gestureToggleButton")?.addEventListener("click", openDrawer);
|
||||
$("#gestureCloseButton")?.addEventListener("click", closeDrawer);
|
||||
$("#gestureBackdrop")?.addEventListener("click", closeDrawer);
|
||||
$("#gestureStartButton")?.addEventListener("click", toggleCamera);
|
||||
$("#gestureArmButton")?.addEventListener("click", toggleGestureDebug);
|
||||
$("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false));
|
||||
$("#gestureDebugSend")?.addEventListener("click", sendSimulatedGesture);
|
||||
$("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize);
|
||||
$("#gestureBindButton")?.addEventListener("click", bindCurrentGesture);
|
||||
$("#gestureResetMapping")?.addEventListener("click", resetMapping);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (!$("#gestureDebugPanel")?.hidden) {
|
||||
setGestureDebugOpen(false);
|
||||
return;
|
||||
}
|
||||
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(); });
|
||||
window.addEventListener("beforeunload", stopCamera);
|
||||
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
|
||||
syncGestureDebugRole();
|
||||
let runtimeError = null;
|
||||
try {
|
||||
await loadGestureRuntime();
|
||||
|
||||
@@ -8,50 +8,27 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||
<title>智舱随行 · 整车智能控制台</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-89" />
|
||||
<link rel="stylesheet" href="/self-extension.css?v=extension-12" />
|
||||
<link rel="stylesheet" href="/self-calibration.css?v=calibration-6" />
|
||||
<link rel="stylesheet" href="/self-repair.css?v=repair-17" />
|
||||
<title>511工程-智能座舱</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-40" />
|
||||
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
|
||||
<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/fflate.min.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="/app.js?v=dual-seat-67" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-7"></script>
|
||||
<script src="/pwa.js?v=android-pad-1" defer></script>
|
||||
<script src="/console-v2.js?v=desktop-refactor-30" defer></script>
|
||||
<script src="/self-extension.js?v=extension-18" defer></script>
|
||||
<script src="/self-calibration.js?v=calibration-3" defer></script>
|
||||
<script src="/self-repair.js?v=repair-17" defer></script>
|
||||
<script src="/self-repair-seat3d.js?v=repair-17" defer></script>
|
||||
<script src="/network.js?v=android-pad-2" defer></script>
|
||||
<script src="/app.js?v=dual-seat-101" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||
<script src="/console-v2.js?v=sync-layout-41" defer></script>
|
||||
<script src="/self-extension.js?v=sync-layout-26" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark" aria-hidden="true"><i data-lucide="brain-circuit"></i></span>
|
||||
<div>
|
||||
<h1>Cognitive Agent</h1>
|
||||
<p>CABIN ORCHESTRATION <span>/</span> MULTI-DOMAIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="runtime-badges" aria-label="运行环境">
|
||||
<button class="gesture-entry" id="gestureToggleButton" type="button" aria-controls="gestureDrawer" aria-expanded="false">
|
||||
<span class="gesture-entry-dot"></span><i data-lucide="scan-face"></i>手势控制
|
||||
</button>
|
||||
<span class="status-badge neutral connection-badge" id="connectionBadge"><i data-lucide="loader-circle"></i><span>连接中</span></span>
|
||||
<button class="status-badge neutral pwa-badge" id="pwaBadge" type="button" hidden></button>
|
||||
<span class="status-badge neutral" id="executionBadge"><i data-lucide="flask-conical"></i>DRY-RUN</span>
|
||||
<span class="status-badge" id="modelBadge"><i data-lucide="sparkles"></i>模型检测中</span>
|
||||
<span class="status-badge danger" id="hardwareBadge"><i data-lucide="shield-off"></i>硬件检测中</span>
|
||||
<button class="status-badge auth-logout" id="logoutButton" type="button" title="退出登录" aria-label="退出登录" hidden>
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<body class="console-v2">
|
||||
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
|
||||
<span class="v2-boot-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<strong>511工程-智能座舱</strong>
|
||||
<small>正在加载车机控制与实时状态</small>
|
||||
</div>
|
||||
<div class="workspace">
|
||||
<aside class="command-panel">
|
||||
<div class="panel-heading">
|
||||
@@ -131,10 +108,10 @@
|
||||
<div class="field-group">
|
||||
<span class="field-label">执行目标</span>
|
||||
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
|
||||
<button type="button" class="mode-option active" data-execution-mode="dry_run">
|
||||
<button type="button" class="mode-option" data-execution-mode="dry_run">
|
||||
<i data-lucide="flask-conical"></i>虚拟 CAN
|
||||
</button>
|
||||
<button type="button" class="mode-option" id="hardwareMode" data-execution-mode="hardware" disabled>
|
||||
<button type="button" class="mode-option active" id="hardwareMode" data-execution-mode="hardware" disabled>
|
||||
<i data-lucide="radio-tower"></i>真实台架
|
||||
</button>
|
||||
<button type="button" class="mode-option" id="simulationMode" data-execution-mode="simulated_hardware" disabled>
|
||||
@@ -145,6 +122,19 @@
|
||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group" id="vehicleTransportField">
|
||||
<span class="field-label">车辆连接方式</span>
|
||||
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
|
||||
<button type="button" class="mode-option active" data-control-transport="wired_can">
|
||||
<i data-lucide="cable"></i>有线 CAN
|
||||
</button>
|
||||
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
|
||||
<i data-lucide="wifi"></i>无线 CAN
|
||||
</button>
|
||||
</div>
|
||||
<p class="provider-note" id="vehicleTransportNote">正在检查车辆连接</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
||||
<label class="field-label" for="simulationFault">故障注入场景</label>
|
||||
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
||||
@@ -175,47 +165,6 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="example-group">
|
||||
<span class="field-label">示例请求</span>
|
||||
<button type="button" class="example-command featured-example" data-example="副驾座椅通风3档,同时打开空调,温度设为24度,风量3档">
|
||||
<i data-lucide="workflow"></i><span>副驾通风 + 空调 24℃ · 双通道联动</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="空调打开,车内温度调到24度,风量3档">
|
||||
<i data-lucide="fan"></i><span>空调 24℃ · 风量 3 档</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="天幕透光率调到30%,遮阳帘打开一半">
|
||||
<i data-lucide="panels-top-left"></i><span>天幕透光 30% · 遮阳帘 50%</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="打开氛围灯,调成冰蓝色,亮度60%">
|
||||
<i data-lucide="lamp-ceiling"></i><span>冰蓝氛围灯 · 亮度 60%</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command featured-example" data-example="进入观影状态:副驾调整为舒适观影姿态,空调24度、风量2档,天幕调暗,氛围灯换成暖橙色">
|
||||
<i data-lucide="clapperboard"></i><span>跨域观影场景</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="副驾有点热,有点闷">
|
||||
<i data-lucide="wind"></i><span>副驾有点热,有点闷</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="副驾靠背向后移动5度">
|
||||
<i data-lucide="move-horizontal"></i><span>副驾靠背向后 5°</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="副驾调整为零重力模式,要求靠背座椅夹角128度,坐垫脚托夹角133度">
|
||||
<i data-lucide="compass"></i><span>副驾零重力精确夹角</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command" data-example="将当前状态保存为零重力模式">
|
||||
<i data-lucide="bookmark-plus"></i><span>保存当前座椅姿态</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
<button type="button" class="example-command warning-example" data-example="副驾座椅加热开到三档">
|
||||
<i data-lucide="triangle-alert"></i><span>三档请求(档位虚拟)</span><i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="safety-strip" id="safetyStrip">
|
||||
<i data-lucide="shield-check"></i>
|
||||
<div>
|
||||
<strong id="safetyTitle">虚拟执行边界</strong>
|
||||
<span id="safetyText">四域 CAN 均为 dry-run,实车发送被强制关闭。</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-panel">
|
||||
@@ -234,20 +183,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pipeline" aria-label="执行链路">
|
||||
<div class="pipeline-step" data-stage="intent_parse"><span>01</span><i data-lucide="message-square-text"></i><b>意图抽取</b></div>
|
||||
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
|
||||
<div class="pipeline-step" data-stage="function_generate"><span>02</span><i data-lucide="file-code-2"></i><b>配置生成</b></div>
|
||||
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
|
||||
<div class="pipeline-step" data-stage="domain_validate"><span>03</span><i data-lucide="badge-check"></i><b>双层校验</b></div>
|
||||
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
|
||||
<div class="pipeline-step" data-stage="compile"><span>04</span><i data-lucide="binary"></i><b>CAN 编译</b></div>
|
||||
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
|
||||
<div class="pipeline-step" data-stage="safety_check"><span>05</span><i data-lucide="shield-check"></i><b>安全门禁</b></div>
|
||||
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
|
||||
<div class="pipeline-step" data-stage="execute"><span>06</span><i data-lucide="radio-tower"></i><b id="executeStageLabel">虚拟执行</b></div>
|
||||
</section>
|
||||
|
||||
<section class="cabin-band" aria-labelledby="cabinStateTitle">
|
||||
<div class="section-title-row cabin-heading">
|
||||
<div class="cabin-title-copy">
|
||||
@@ -266,27 +201,27 @@
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="fan"></i></span>
|
||||
<div><strong>空调</strong><small>CMD 0x1DE · FB 0x404 · CH1</small></div>
|
||||
<span class="device-state" id="climatePower">OFF</span>
|
||||
<span class="device-state" id="climatePower">UNKNOWN</span>
|
||||
</header>
|
||||
<p class="domain-capability-line"><span>可控参数</span>电源 · 温度 · 风量/风向</p>
|
||||
<div class="climate-display">
|
||||
<div class="zone-temperature">
|
||||
<span>主驾</span><strong id="driverTemperature">24<sup>℃</sup></strong>
|
||||
<span>主驾</span><strong id="driverTemperature">--<sup>℃</sup></strong>
|
||||
<small>风向<b id="driverAirDirection">-</b></small>
|
||||
</div>
|
||||
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
|
||||
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
||||
</div>
|
||||
<div class="zone-temperature passenger-temperature">
|
||||
<span>副驾</span><strong id="passengerTemperature">24<sup>℃</sup></strong>
|
||||
<span>副驾</span><strong id="passengerTemperature">--<sup>℃</sup></strong>
|
||||
<small>风向<b id="passengerAirDirection">-</b></small>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>风量</dt><dd id="fanLevel">0 档</dd></div>
|
||||
<div><dt>循环</dt><dd id="cycleMode">外循环</dd></div>
|
||||
<div><dt>A/C</dt><dd id="acState">关闭</dd></div>
|
||||
<div><dt>前除霜</dt><dd id="defrostState">关闭</dd></div>
|
||||
<div><dt>风量</dt><dd id="fanLevel">未知</dd></div>
|
||||
<div><dt>循环</dt><dd id="cycleMode">未知</dd></div>
|
||||
<div><dt>A/C</dt><dd id="acState">未知</dd></div>
|
||||
<div><dt>前除霜</dt><dd id="defrostState">未知</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@@ -313,17 +248,17 @@
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
|
||||
<div><strong>遮阳帘</strong><small>CMD 0x431 · FB 0x41A · CH1</small></div>
|
||||
<span class="device-state" id="roofState">READY</span>
|
||||
<span class="device-state" id="roofState">UNKNOWN</span>
|
||||
</header>
|
||||
<p class="domain-capability-line"><span>可控参数</span>开关 / 停止 · 位置</p>
|
||||
<div class="domain-summary-visual shade-summary-visual" aria-hidden="true">
|
||||
<span>闭合</span><i><b id="shadePositionMeter"></b></i><span>打开</span>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>遮阳帘</dt><dd id="shadePositionValue">0%</dd></div>
|
||||
<div><dt>动作</dt><dd id="shadeMotionState">停止</dd></div>
|
||||
<div><dt>遮阳帘</dt><dd id="shadePositionValue">未知</dd></div>
|
||||
<div><dt>动作</dt><dd id="shadeMotionState">未知</dd></div>
|
||||
<div><dt>初始化</dt><dd id="shadeInitializedState">未知</dd></div>
|
||||
<div><dt>热保护</dt><dd id="shadeThermalState">正常</dd></div>
|
||||
<div><dt>热保护</dt><dd id="shadeThermalState">未知</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@@ -331,7 +266,7 @@
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
|
||||
<div><strong>车内灯光</strong><small>INTERIOR / AMBIENT · CH1</small></div>
|
||||
<span class="device-state" id="ambientPower">OFF</span>
|
||||
<span class="device-state" id="ambientPower">UNKNOWN</span>
|
||||
</header>
|
||||
<p class="domain-capability-line"><span>可控参数</span>阅读灯 · 氛围亮度 · 色号</p>
|
||||
<div class="domain-summary-visual ambient-summary-visual" aria-hidden="true">
|
||||
@@ -339,9 +274,9 @@
|
||||
<span data-ambient-zone="rear"><i id="ambientRearMeter"></i><b>后排</b></span>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>氛围灯</dt><dd id="ambientMode">关闭</dd></div>
|
||||
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">关闭</dd></div>
|
||||
<div><dt>氛围亮度</dt><dd id="ambientBrightness">0 / 0</dd></div>
|
||||
<div><dt>氛围灯</dt><dd id="ambientMode">未知</dd></div>
|
||||
<div><dt>室内/阅读灯</dt><dd id="interiorLightPower">未知</dd></div>
|
||||
<div><dt>氛围亮度</dt><dd id="ambientBrightness">- / -</dd></div>
|
||||
<div><dt>氛围色号</dt><dd id="ambientColorIndex">- / -</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
@@ -350,7 +285,7 @@
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="gallery-vertical-end"></i></span>
|
||||
<div><strong>车窗</strong><small>CMD 0x431 · FB 0x388 · CH1</small></div>
|
||||
<span class="device-state" id="windowState">READY</span>
|
||||
<span class="device-state" id="windowState">UNKNOWN</span>
|
||||
</header>
|
||||
<p class="domain-capability-line"><span>可控参数</span>四窗独立 · 开/关/停 · 位置</p>
|
||||
<div class="domain-summary-visual window-summary-visual" aria-hidden="true">
|
||||
@@ -360,18 +295,18 @@
|
||||
<span data-window-meter="rear_right"><i></i><b>右后</b></span>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>左前窗</dt><dd id="frontLeftWindow">0%</dd></div>
|
||||
<div><dt>右前窗</dt><dd id="frontRightWindow">0%</dd></div>
|
||||
<div><dt>左后窗</dt><dd id="rearLeftWindow">0%</dd></div>
|
||||
<div><dt>右后窗</dt><dd id="rearRightWindow">0%</dd></div>
|
||||
<div><dt>左前窗</dt><dd id="frontLeftWindow">未知</dd></div>
|
||||
<div><dt>右前窗</dt><dd id="frontRightWindow">未知</dd></div>
|
||||
<div><dt>左后窗</dt><dd id="rearLeftWindow">未知</dd></div>
|
||||
<div><dt>右后窗</dt><dd id="rearRightWindow">未知</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="domain-panel lighting-panel" data-domain-panel="front_light" data-device-channel="ch1">
|
||||
<header>
|
||||
<span class="domain-icon"><i data-lucide="car-front"></i></span>
|
||||
<div><strong>前大灯</strong><small>FRONT LIGHT · SWITCH</small></div>
|
||||
<span class="device-state" id="frontLightState">OFF</span>
|
||||
<div><strong>前大灯</strong><small>FB 0x38A · CH1</small></div>
|
||||
<span class="device-state" id="frontLightState">UNKNOWN</span>
|
||||
</header>
|
||||
<p class="domain-capability-line"><span>可控参数</span>近/远光 · 雾灯 · 转向灯</p>
|
||||
<div class="domain-summary-visual lighting-summary-visual" aria-hidden="true">
|
||||
@@ -381,10 +316,10 @@
|
||||
<span data-light-meter="turnSignalState"><i></i><b>转向</b></span>
|
||||
</div>
|
||||
<dl class="domain-readouts">
|
||||
<div><dt>近光灯</dt><dd id="lowBeamState">关闭</dd></div>
|
||||
<div><dt>远光灯</dt><dd id="highBeamState">关闭</dd></div>
|
||||
<div><dt>雾灯</dt><dd id="fogLightState">关闭</dd></div>
|
||||
<div><dt>转向灯</dt><dd id="turnSignalState">关闭</dd></div>
|
||||
<div><dt>近光灯</dt><dd id="lowBeamState">未知</dd></div>
|
||||
<div><dt>远光灯</dt><dd id="highBeamState">未知</dd></div>
|
||||
<div><dt>雾灯</dt><dd id="fogLightState">未知</dd></div>
|
||||
<div><dt>转向灯</dt><dd id="turnSignalState">未知</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
@@ -393,7 +328,8 @@
|
||||
|
||||
<section class="twin-band" aria-labelledby="twinTitle">
|
||||
<div class="twin-heading">
|
||||
<div>
|
||||
<div class="twin-title-copy">
|
||||
<span class="section-kicker">LIVE VEHICLE DIGITAL TWIN</span>
|
||||
<h3 id="twinTitle">整车孪生界面</h3>
|
||||
<div class="preset-indicator" id="presetIndicator" hidden>
|
||||
<i data-lucide="bookmark-check"></i><span></span>
|
||||
@@ -467,11 +403,6 @@
|
||||
<div class="dynamic-mode-state" data-dynamic-mode>
|
||||
<i data-lucide="waves"></i><span>动态模式</span><b>未运行</b>
|
||||
</div>
|
||||
<div class="thermal-states">
|
||||
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
|
||||
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
|
||||
<span data-function="massage"><i data-lucide="waves"></i>按摩 <b>--</b></span>
|
||||
</div>
|
||||
<div class="motion-state-grid" aria-live="polite"></div>
|
||||
</article>
|
||||
<article class="seat-card" data-seat="driver">
|
||||
@@ -567,7 +498,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="gesture-backdrop" id="gestureBackdrop" hidden></div>
|
||||
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
|
||||
<header class="gesture-drawer-header">
|
||||
<div>
|
||||
@@ -604,15 +534,7 @@
|
||||
|
||||
<div class="gesture-actions">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<section class="gesture-guide-card">
|
||||
@@ -645,6 +567,51 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mode-editor-backdrop" id="modeEditorBackdrop" hidden>
|
||||
<section class="mode-editor" role="dialog" aria-modal="true" aria-labelledby="modeEditorTitle" aria-describedby="modeEditorDescription">
|
||||
<div class="mode-editor-setup">
|
||||
<header class="mode-editor-header">
|
||||
<div>
|
||||
<span>MODE WORKFLOW</span>
|
||||
<h2 id="modeEditorTitle">编辑功能模式</h2>
|
||||
<p id="modeEditorDescription">执行后生成 CAN 控制指令流;完成后永久保存到当前模式卡片。</p>
|
||||
</div>
|
||||
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
|
||||
</header>
|
||||
<div class="mode-editor-inputs">
|
||||
<label class="mode-editor-field mode-editor-name-field" for="modeEditorName">
|
||||
<span>模式名称</span>
|
||||
<input id="modeEditorName" maxlength="64" autocomplete="off" placeholder="输入模式名称" />
|
||||
</label>
|
||||
<label class="mode-editor-field mode-editor-instruction-field" for="modeEditorInstruction">
|
||||
<span>自然语言指令</span>
|
||||
<textarea id="modeEditorInstruction" maxlength="1000" rows="3" placeholder="例如:空调24度、风量2档,打开遮阳帘"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="mode-editor-flow" aria-live="polite">
|
||||
<header>
|
||||
<span><i data-lucide="workflow"></i>CAN 控制指令流</span>
|
||||
<div class="mode-editor-flow-tools">
|
||||
<b id="modeEditorFlowCount">尚未生成</b>
|
||||
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开 CAN 控制指令区域" title="展开 CAN 控制指令区域">
|
||||
<i data-lucide="maximize-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
|
||||
<p>点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。</p>
|
||||
</div>
|
||||
</section>
|
||||
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新执行,才能点击完成永久保存。</p>
|
||||
<footer class="mode-editor-actions">
|
||||
<button id="modeEditorCancel" type="button">取消</button>
|
||||
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="play"></i><span>执行</span></button>
|
||||
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save"></i><span>完成</span></button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>登录 · Cognitive Agent</title>
|
||||
<link rel="stylesheet" href="/login.css" />
|
||||
<script src="/network.js?v=android-pad-1" defer></script>
|
||||
<script src="/network.js?v=android-pad-2" defer></script>
|
||||
<script src="/login.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -12,30 +12,11 @@
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function renderBadge() {
|
||||
const badge = document.querySelector("#connectionBadge");
|
||||
if (!badge) return;
|
||||
const presentation = {
|
||||
connecting: ["neutral", "loader-circle", "连接中"],
|
||||
online: ["", "wifi", "服务已连接"],
|
||||
degraded: ["warning", "wifi-low", "连接不稳定"],
|
||||
offline: ["danger", "wifi-off", "服务已断开"],
|
||||
}[state.status];
|
||||
badge.className = `status-badge connection-badge ${presentation[0]}`.trim();
|
||||
badge.innerHTML = `<i data-lucide="${presentation[1]}"></i><span>${presentation[2]}</span>`;
|
||||
badge.title = state.lastError || presentation[2];
|
||||
window.lucide?.createIcons?.({
|
||||
attrs: { "aria-hidden": "true" },
|
||||
nodes: [badge],
|
||||
});
|
||||
}
|
||||
|
||||
function publish(status, error = null) {
|
||||
const changed = state.status !== status || state.lastError !== error;
|
||||
state.status = status;
|
||||
state.lastError = error;
|
||||
if (status === "online") state.lastSuccessAt = new Date().toISOString();
|
||||
renderBadge();
|
||||
if (!changed) return;
|
||||
const detail = snapshot();
|
||||
listeners.forEach((listener) => listener(detail));
|
||||
@@ -110,7 +91,6 @@
|
||||
|
||||
function startMonitoring() {
|
||||
if (probeTimer !== null) return;
|
||||
renderBadge();
|
||||
probe();
|
||||
probeTimer = window.setInterval(probe, 10000);
|
||||
window.addEventListener("online", () => {
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
}
|
||||
|
||||
function showUpdate(registration) {
|
||||
const badge = document.querySelector("#pwaBadge");
|
||||
if (!badge) return;
|
||||
badge.hidden = false;
|
||||
badge.innerHTML = '<i data-lucide="refresh-cw"></i><span>发现更新</span>';
|
||||
badge.onclick = () => registration.waiting?.postMessage({ type: "SKIP_WAITING" });
|
||||
window.lucide?.createIcons?.({ attrs: { "aria-hidden": "true" }, nodes: [badge] });
|
||||
notify("检测到前端更新,正在刷新页面");
|
||||
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
|
||||
}
|
||||
|
||||
async function register() {
|
||||
@@ -17,7 +13,9 @@
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/service-worker.js", {
|
||||
scope: "/",
|
||||
updateViaCache: "none",
|
||||
});
|
||||
await registration.update();
|
||||
if (registration.waiting) showUpdate(registration);
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const worker = registration.installing;
|
||||
|
||||
@@ -1,691 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const API_ROOT = "/api/calibration";
|
||||
const TARGET_KEY = "cognitive-agent.calibration-target";
|
||||
const pageTitles = {
|
||||
live: ["SEAT · ZED JOINT CALIBRATION", "座椅—双目联合标定", "座椅姿态、四轴 Hall、ZED 画面与双目位姿同步更新"],
|
||||
system: ["CONNECTION 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 modelRequested = false;
|
||||
let activeView = "live";
|
||||
|
||||
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>
|
||||
<span class="section-kicker" id="calibrationPageKicker">SEAT · ZED JOINT 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="seatModelHost" class="seat-model-host">
|
||||
<canvas id="seatCanvas" aria-label="N7 原始三维座椅模型,可拖动旋转并滚轮缩放"></canvas>
|
||||
<div id="seatModelLoading" class="model-loading">
|
||||
<span class="model-spinner"></span>
|
||||
<strong>正在加载原始 N7 座椅</strong>
|
||||
<small>ANDROID GLB · LOCAL</small>
|
||||
</div>
|
||||
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
|
||||
</div>
|
||||
<div class="v2-axis-tag tag-back">靠背 <b id="calibrationTagBack">0.0°</b></div>
|
||||
<div class="v2-axis-tag tag-base">前后 <b id="calibrationTagLong">0.00 cm</b></div>
|
||||
<div class="v2-axis-tag tag-leg">腿托 <b id="calibrationTagLeg">0.0°</b></div>
|
||||
<div class="v2-axis-tag tag-height">高度 <b id="calibrationTagHeight">0.00 cm</b></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>
|
||||
|
||||
<section class="v2-calibration-panel v2-calibration-mqtt-panel">
|
||||
<header><div><span class="section-kicker">ZED / SEATPOSE</span><h2>双目位姿数据</h2></div><span class="v2-calibration-badge wait" id="calibrationMqttBadge">等待数据</span></header>
|
||||
<div class="v2-calibration-metrics"><div><small>消息总数</small><strong id="calibrationMqttCount">0</strong></div><div><small>最新帧</small><strong id="calibrationMqttFrame">—</strong></div><div><small>解析错误</small><strong id="calibrationMqttErrors">0</strong></div></div>
|
||||
<p class="v2-calibration-detail" id="calibrationMqttDetail">MQTT 尚未连接</p>
|
||||
<div class="v2-calibration-pose-areas" id="calibrationPoseAreas"></div>
|
||||
<div class="v2-calibration-live-mapping">
|
||||
<div class="v2-calibration-subheading"><span>HALL ↔ 3D</span><strong>实时映射</strong></div>
|
||||
<div class="v2-calibration-mapping-rows" id="calibrationMappingRows"></div>
|
||||
</div>
|
||||
<div class="v2-calibration-actions"><button id="calibrationMqttConnect" type="button">连接 MQTT</button><button id="calibrationMqttDisconnect" type="button">断开</button><button id="calibrationMqttTest" 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);
|
||||
}
|
||||
|
||||
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);
|
||||
renderMqtt(mqtt);
|
||||
renderVideo(state.video);
|
||||
renderMappings(state.axes, mqtt.areas);
|
||||
$("#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 }));
|
||||
$("#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`;
|
||||
}
|
||||
|
||||
function renderMqtt(mqtt) {
|
||||
const friendly = {
|
||||
simulated: "仿真数据",
|
||||
subscribed: "已订阅",
|
||||
connecting: "连接中",
|
||||
disconnected: "未连接",
|
||||
error: "异常",
|
||||
};
|
||||
$("#calibrationMqttBadge").textContent = friendly[mqtt.state] || mqtt.state;
|
||||
$("#calibrationMqttBadge").className = `v2-calibration-badge ${
|
||||
mqtt.state === "error"
|
||||
? "error"
|
||||
: ["disconnected", "connecting"].includes(mqtt.state) ? "wait" : ""
|
||||
}`;
|
||||
$("#calibrationMqttCount").textContent = mqtt.count;
|
||||
$("#calibrationMqttFrame").textContent = mqtt.last_frame >= 0 ? mqtt.last_frame : "—";
|
||||
$("#calibrationMqttErrors").textContent = mqtt.parse_errors;
|
||||
$("#calibrationMqttDetail").textContent = mqtt.detail;
|
||||
const container = $("#calibrationPoseAreas");
|
||||
container.replaceChildren();
|
||||
const entries = Object.entries(mqtt.areas || {});
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "v2-calibration-empty";
|
||||
empty.textContent = "等待 Seat_cushion / Seat_backrest / Seat_leg_rest 位姿…";
|
||||
container.append(empty);
|
||||
}
|
||||
entries.forEach(([name, pose]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "v2-calibration-pose-row";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = name;
|
||||
const angle = document.createElement("span");
|
||||
angle.textContent = pose.angle == null ? "ANGLE —" : `${Number(pose.angle).toFixed(2)}°`;
|
||||
const translation = document.createElement("small");
|
||||
translation.textContent = pose.translation
|
||||
? `T [${pose.translation.slice(0, 3).map((value) => Number(value).toFixed(3)).join(", ")}]`
|
||||
: "T —";
|
||||
row.append(title, angle, translation);
|
||||
container.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
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 renderMappings(axes, areas = {}) {
|
||||
const container = $("#calibrationMappingRows");
|
||||
container.replaceChildren();
|
||||
const areaFor = ["Seat_cushion", "Seat_leg_rest", "Seat_backrest", "Seat_cushion"];
|
||||
axes.forEach((axis) => {
|
||||
const pose = areas[areaFor[axis.id]] || {};
|
||||
const observed = axis.id === 0 || axis.id === 3
|
||||
? pose.translation?.[axis.id === 0 ? 0 : 1]
|
||||
: pose.angle;
|
||||
const row = document.createElement("div");
|
||||
row.className = "v2-calibration-mapping-row";
|
||||
row.innerHTML = "<strong></strong><span></span><div><i></i></div><b></b>";
|
||||
$("strong", row).textContent = axis.name;
|
||||
$("span", row).textContent = `${axis.hall} Hall`;
|
||||
$("i", row).style.width = `${axis.permille / 10}%`;
|
||||
$("b", row).textContent = observed == null
|
||||
? "3D —"
|
||||
: `${Number(observed).toFixed(3)}${axis.id === 1 || axis.id === 2 ? "°" : " T"}`;
|
||||
container.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
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 loadSeatModel() {
|
||||
if (modelRequested || !$("#seatModelHost")) return;
|
||||
modelRequested = true;
|
||||
const script = document.createElement("script");
|
||||
script.src = "/assets/self-calibration-model.bundle.js";
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
modelRequested = false;
|
||||
$("#seatModelLoading")?.classList.add("error");
|
||||
};
|
||||
document.head.append(script);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
loadSeatModel();
|
||||
fetchState().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" }),
|
||||
"四轴自动标定已启动",
|
||||
));
|
||||
$("#calibrationMqttConnect").addEventListener("click", () => run(
|
||||
() => api("/mqtt/connect"),
|
||||
"正在连接 MQTT",
|
||||
));
|
||||
$("#calibrationMqttDisconnect").addEventListener("click", () => run(
|
||||
() => api("/mqtt/disconnect"),
|
||||
"MQTT 已断开",
|
||||
));
|
||||
$("#calibrationMqttTest").addEventListener("click", () => run(
|
||||
() => api("/mqtt/test"),
|
||||
"MQTT 测试帧已发送",
|
||||
));
|
||||
$("#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("beforeunload", closeEvents);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!buildShell()) return;
|
||||
bindEvents();
|
||||
updateTargetPresentation();
|
||||
showView(activeView);
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
activate();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
}());
|
||||
@@ -7,15 +7,6 @@
|
||||
[data-extension-type="mechanical"] { --extension-card-color: #758e94; }
|
||||
[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-footer,
|
||||
.console-v2 .v2-self-extension,
|
||||
@@ -23,41 +14,6 @@
|
||||
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-config-section,
|
||||
.console-v2[data-module="self-extension"] .v2-sidebar > .v2-sidebar-footer:not(.v2-extension-footer),
|
||||
.console-v2[data-module="self-extension"] .v2-operation-shell,
|
||||
.console-v2[data-module="self-extension"] .v2-output-shell {
|
||||
@@ -89,11 +45,219 @@
|
||||
.console-v2[data-module="self-extension"] .v2-self-extension {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: block;
|
||||
grid-row: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v2-extension-pet {
|
||||
--pet-x: 0px;
|
||||
--pet-y: 0px;
|
||||
width: clamp(154px, 15vw, 196px);
|
||||
aspect-ratio: 1;
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
z-index: 24;
|
||||
display: block;
|
||||
transform: translate3d(var(--pet-x), var(--pet-y), 0);
|
||||
filter: drop-shadow(0 12px 14px rgba(9, 66, 63, 0.18));
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.v2-extension-pet-bubble {
|
||||
position: absolute;
|
||||
right: calc(100% - 36px);
|
||||
bottom: calc(100% - 42px);
|
||||
width: 176px;
|
||||
max-width: calc(100vw - 24px);
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
color: #285153;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(14, 119, 115, 0.18);
|
||||
border-radius: 11px 11px 2px 11px;
|
||||
box-shadow: 0 9px 24px rgba(22, 72, 70, 0.12);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
opacity: 0;
|
||||
transform: translateY(5px) scale(0.96);
|
||||
transform-origin: right bottom;
|
||||
transition: 170ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.v2-extension-pet:hover .v2-extension-pet-bubble,
|
||||
.v2-extension-pet[data-mood="talking"] .v2-extension-pet-bubble,
|
||||
.v2-extension-pet[data-mood="happy"] .v2-extension-pet-bubble {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.v2-extension-pet-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
cursor: grab;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-dragging="true"] .v2-extension-pet-body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.v2-extension-pet-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.v2-extension-pet-entrance-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-entering="true"] .v2-extension-pet-entrance-canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-entering="true"] .v2-extension-pet-image,
|
||||
.v2-extension-pet[data-entering="true"] .v2-extension-pet-tail-canvas,
|
||||
.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;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-tail-canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-tail-waving="true"] .v2-extension-pet-image {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.v2-extension-pet-blink {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 28ms linear;
|
||||
}
|
||||
|
||||
.v2-extension-pet[data-blinking="true"] .v2-extension-pet-blink {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v2-extension-pet-blink-eye {
|
||||
position: absolute;
|
||||
top: 31.3%;
|
||||
height: 6.2%;
|
||||
background: #919baa;
|
||||
border-radius: 48%;
|
||||
}
|
||||
|
||||
.v2-extension-pet-blink-eye.left {
|
||||
left: 23.7%;
|
||||
width: 4%;
|
||||
}
|
||||
|
||||
.v2-extension-pet-blink-eye.right {
|
||||
left: 35.9%;
|
||||
width: 5.3%;
|
||||
}
|
||||
|
||||
.v2-extension-pet-blink-eye::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 48%;
|
||||
right: 12%;
|
||||
left: 12%;
|
||||
height: 27%;
|
||||
border-top: 2px solid #050606;
|
||||
border-radius: 50% 50% 0 0;
|
||||
}
|
||||
|
||||
.v2-extension-pet-mouth-closed {
|
||||
position: absolute;
|
||||
left: 27%;
|
||||
top: 39.8%;
|
||||
z-index: 3;
|
||||
width: 7%;
|
||||
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-mouth-closed::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 13%;
|
||||
left: 13%;
|
||||
width: 38%;
|
||||
height: 34%;
|
||||
border-bottom: 1.5px solid #050606;
|
||||
border-radius: 0 0 70% 70%;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
.v2-extension-pet-mouth-closed::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 13%;
|
||||
right: 13%;
|
||||
width: 38%;
|
||||
height: 34%;
|
||||
border-bottom: 1.5px solid #050606;
|
||||
border-radius: 0 0 70% 70%;
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
.v2-extension-sidebar > .v2-field-caption {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
@@ -109,7 +273,7 @@
|
||||
}
|
||||
|
||||
.v2-extension-mode-switch button {
|
||||
min-height: 52px;
|
||||
min-height: var(--v2-sidebar-item-height);
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
@@ -133,14 +297,15 @@
|
||||
|
||||
.v2-extension-mode-switch button:hover {
|
||||
color: var(--v2-accent-strong);
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
background: color-mix(in srgb, var(--v2-accent-soft) 34%, white);
|
||||
border-color: color-mix(in srgb, var(--v2-accent) 10%, white);
|
||||
}
|
||||
|
||||
.v2-extension-mode-switch button.active {
|
||||
color: #111f20;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 10px 28px rgba(34, 93, 92, 0.08);
|
||||
border-color: color-mix(in srgb, var(--v2-accent) 18%, white);
|
||||
box-shadow: 0 10px 28px color-mix(in srgb, var(--v2-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.v2-extension-mode-switch button.active svg {
|
||||
@@ -161,7 +326,7 @@
|
||||
|
||||
.v2-extension-config-card {
|
||||
min-width: 0;
|
||||
min-height: 43px;
|
||||
min-height: var(--v2-sidebar-card-height);
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@@ -169,7 +334,7 @@
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--v2-sidebar-card-radius);
|
||||
}
|
||||
|
||||
.v2-extension-config-card > svg {
|
||||
@@ -252,11 +417,12 @@
|
||||
}
|
||||
|
||||
.v2-extension-view {
|
||||
--v2-extension-view-pad: clamp(13px, 1.7vw, 24px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: clamp(13px, 1.7vw, 24px);
|
||||
padding: var(--v2-module-title-block) var(--v2-extension-view-pad) var(--v2-extension-view-pad);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 31% -10%, rgba(216, 244, 241, 0.72), transparent 26%),
|
||||
@@ -295,7 +461,7 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 0 3px;
|
||||
padding-inline: max(0px, calc(var(--v2-module-title-inline) - var(--v2-extension-view-pad)));
|
||||
}
|
||||
|
||||
.v2-extension-heading h1 {
|
||||
@@ -866,6 +1032,20 @@ button.v2-extension-capability.preview {
|
||||
border-radius: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.console-v2[data-module="self-extension"]
|
||||
.v2-extension-control
|
||||
.v2-extension-learned-action {
|
||||
--extension-card-color: var(--v2-accent);
|
||||
}
|
||||
|
||||
.v2-extension-control .v2-extension-learned-action:hover,
|
||||
.v2-extension-control .v2-extension-learned-action:focus-visible {
|
||||
background: color-mix(in srgb, var(--v2-accent) 10%, white);
|
||||
border-color: color-mix(in srgb, var(--v2-accent) 42%, #dce7e4);
|
||||
box-shadow: 0 5px 14px color-mix(in srgb, var(--v2-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.v2-extension-learned-action > span {
|
||||
@@ -1117,13 +1297,9 @@ button.v2-extension-capability.preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
padding: clamp(11px, 1.2vw, 16px);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e1e9e7;
|
||||
border-radius: 19px;
|
||||
box-shadow: 0 13px 34px rgba(29, 78, 78, 0.07);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 17px;
|
||||
}
|
||||
|
||||
.v2-extension-control > .v2-extension-control-grid,
|
||||
@@ -1132,20 +1308,20 @@ button.v2-extension-capability.preview {
|
||||
}
|
||||
|
||||
.v2-extension-control > .v2-extension-control-grid {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.v2-extension-control > .v2-extension-twin-board {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.v2-extension-control > #extensionVehicleStateSummary {
|
||||
.v2-extension-control > .v2-extension-twin-board {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.v2-extension-control > #extensionVehicleStateSummary {
|
||||
grid-column: 2;
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
.v2-extension-control > #extensionRunPlan {
|
||||
grid-column: 2;
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
@@ -1808,7 +1984,7 @@ form > .v2-extension-primary {
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.console-v2[data-module="self-extension"] .main-panel {
|
||||
grid-template-rows: 34px minmax(0, 1fr);
|
||||
grid-template-rows: 36px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.console-v2[data-module="self-extension"] .v2-extension-sidebar {
|
||||
@@ -1840,7 +2016,8 @@ form > .v2-extension-primary {
|
||||
}
|
||||
|
||||
.v2-extension-view {
|
||||
padding: 10px;
|
||||
--v2-extension-view-pad: 10px;
|
||||
padding: var(--v2-module-title-block) var(--v2-extension-view-pad) var(--v2-extension-view-pad);
|
||||
}
|
||||
|
||||
.v2-extension-heading h1 {
|
||||
@@ -1934,8 +2111,8 @@ form > .v2-extension-primary {
|
||||
}
|
||||
|
||||
.v2-extension-twin-board {
|
||||
gap: 5px;
|
||||
padding: 7px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-extension-run-plan {
|
||||
@@ -1986,3 +2163,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.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) {
|
||||
return { key, name, aliases, category, risk, visual };
|
||||
}
|
||||
@@ -278,7 +351,7 @@
|
||||
|
||||
const state = loadState();
|
||||
const pages = Object.fromEntries(Object.keys(TOY_TYPES).map((key) => [key, 0]));
|
||||
let activeMode = sessionStorage.getItem(MODE_KEY) === "control" ? "control" : "study";
|
||||
let activeMode = "study";
|
||||
let selectedAddType = "light";
|
||||
let learning = freshLearningSession();
|
||||
let isPlanning = false;
|
||||
@@ -290,6 +363,12 @@
|
||||
let latestVehicleStateTurn = -1;
|
||||
let sharedTwinBand = null;
|
||||
let sharedTwinHome = null;
|
||||
let petTimer = null;
|
||||
let petBlinkTimer = null;
|
||||
let petBlinkResetTimer = null;
|
||||
let petTailTimer = null;
|
||||
let petTailFrame = null;
|
||||
let petPhraseIndex = 0;
|
||||
|
||||
function uid(prefix) {
|
||||
if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
|
||||
@@ -320,12 +399,11 @@
|
||||
}
|
||||
|
||||
function initialState() {
|
||||
const device = clone(DEFAULT_DEVICE);
|
||||
return {
|
||||
version: 5,
|
||||
devices: [device],
|
||||
capabilities: seedPresets(device),
|
||||
selectedDeviceByType: { light: device.id },
|
||||
devices: [],
|
||||
capabilities: [],
|
||||
selectedDeviceByType: {},
|
||||
sessionStartedAt: null,
|
||||
settings: { actionDelayMs: 520 },
|
||||
history: [],
|
||||
@@ -341,12 +419,26 @@
|
||||
return initialState();
|
||||
}
|
||||
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.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
|
||||
? saved.vehicleExecutions
|
||||
: {};
|
||||
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
|
||||
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
|
||||
// Learned capabilities are durable configuration; execution lamps, history,
|
||||
// and protocol feedback belong to one virtual run and must never survive a restart.
|
||||
saved.history = [];
|
||||
saved.lastProtocol = null;
|
||||
saved.vehicleExecutions = {};
|
||||
saved.sessionStartedAt = null;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
sessionStorage.removeItem(MODE_KEY);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
console.warn("读取自扩展能力数据失败,已恢复默认状态。", error);
|
||||
@@ -465,7 +557,7 @@
|
||||
root.innerHTML = `
|
||||
<section class="v2-extension-view v2-extension-study" data-extension-view="study">
|
||||
<header class="v2-extension-heading">
|
||||
<div><span class="section-kicker">PERIPHERAL LEARNING</span><h1>外设玩具能力一览与学习</h1><p>为六类外设添加具体硬件,学习并验证遥控动作。</p></div>
|
||||
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具能力学习</h1><p>添加外设硬件,学习并验证可执行的遥控动作。</p></div>
|
||||
<div class="v2-extension-heading-actions">
|
||||
<button class="v2-extension-reset-action" type="button" id="extensionResetButton"><i data-lucide="rotate-ccw"></i>重置学习</button>
|
||||
<button type="button" data-add-type="light"><i data-lucide="plus"></i>添加玩具</button>
|
||||
@@ -475,7 +567,7 @@
|
||||
</section>
|
||||
<section class="v2-extension-view v2-extension-control" data-extension-view="control" hidden>
|
||||
<header class="v2-extension-heading">
|
||||
<div><span class="section-kicker">UNIFIED CONTROL</span><h1>说一句,联动外设玩具与车端设备</h1><p>GPT 只从已学习能力与受控车端能力中生成计划。</p></div>
|
||||
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具与车端联动</h1><p>用自然语言统一编排已学习的玩具动作与受控车端设备。</p></div>
|
||||
</header>
|
||||
<div class="v2-extension-command-row">
|
||||
<div class="v2-extension-composer">
|
||||
@@ -497,11 +589,7 @@
|
||||
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="v2-extension-twin-board" aria-labelledby="extensionTwinTitle">
|
||||
<header>
|
||||
<div><span class="section-kicker">SHARED VEHICLE DIGITAL TWIN</span><h2 id="extensionTwinTitle">整车孪生实时映射</h2><p>与自编排复用同一孪生实例,CH0 副驾座椅与 CH1 主驾座椅 / 车身状态持续同步。</p></div>
|
||||
<span><i></i>实际反馈 / 当前动作</span>
|
||||
</header>
|
||||
<section class="v2-extension-twin-board" aria-label="整车孪生界面">
|
||||
<div class="v2-extension-twin-host" id="extensionTwinHost">
|
||||
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
|
||||
</div>
|
||||
@@ -510,7 +598,20 @@
|
||||
<span><i>CH0 / CH1</i>等待权威车端状态</span>
|
||||
</div>
|
||||
<div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div>
|
||||
</section>`;
|
||||
</section>
|
||||
<aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite">
|
||||
<span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span>
|
||||
<button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动">
|
||||
<img class="v2-extension-pet-image" src="/assets/pets/self-extension-cat.png?v=3" alt="" draggable="false" />
|
||||
<canvas class="v2-extension-pet-entrance-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||||
<canvas class="v2-extension-pet-tail-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||||
<span class="v2-extension-pet-blink" aria-hidden="true">
|
||||
<i class="v2-extension-pet-blink-eye left"></i>
|
||||
<i class="v2-extension-pet-blink-eye right"></i>
|
||||
</span>
|
||||
<span class="v2-extension-pet-mouth-closed" aria-hidden="true"></span>
|
||||
</button>
|
||||
</aside>`;
|
||||
moduleSwitcher.insertAdjacentElement("afterend", root);
|
||||
|
||||
buildDialogs();
|
||||
@@ -1607,6 +1708,471 @@
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDesktopPet() {
|
||||
const pet = $("#extensionDesktopPet");
|
||||
const body = $("#extensionPetBody");
|
||||
const bubble = $("#extensionPetBubble");
|
||||
const image = $(".v2-extension-pet-image", body);
|
||||
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 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") => {
|
||||
bubble.textContent = message;
|
||||
pet.dataset.mood = mood;
|
||||
animateMouth();
|
||||
window.clearTimeout(Number(pet.dataset.moodTimer || 0));
|
||||
pet.dataset.moodTimer = String(window.setTimeout(() => {
|
||||
pet.dataset.mood = "idle";
|
||||
}, 2600));
|
||||
};
|
||||
|
||||
pet.addEventListener("pointerenter", () => {
|
||||
if (pet.dataset.entering === "true") return;
|
||||
requestTailWave();
|
||||
if (pet.dataset.mood === "idle") animateMouth();
|
||||
});
|
||||
|
||||
const clampPosition = (x, y) => {
|
||||
const edgeMargin = 18;
|
||||
const maxLeftShift = Math.max(0, window.innerWidth - pet.offsetWidth - edgeMargin * 2);
|
||||
const maxUpShift = Math.max(0, window.innerHeight - pet.offsetHeight - edgeMargin * 2);
|
||||
return {
|
||||
x: Math.max(-maxLeftShift, Math.min(0, x)),
|
||||
y: Math.max(-maxUpShift, Math.min(0, y)),
|
||||
};
|
||||
};
|
||||
|
||||
body.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || pet.dataset.entering === "true") return;
|
||||
pointer.active = true;
|
||||
pointer.moved = false;
|
||||
pointer.startX = event.clientX;
|
||||
pointer.startY = event.clientY;
|
||||
pointer.x = Number(pet.dataset.x || 0);
|
||||
pointer.y = Number(pet.dataset.y || 0);
|
||||
body.setPointerCapture?.(event.pointerId);
|
||||
pet.dataset.dragging = "true";
|
||||
});
|
||||
body.addEventListener("pointermove", (event) => {
|
||||
if (!pointer.active) return;
|
||||
const deltaX = event.clientX - pointer.startX;
|
||||
const deltaY = event.clientY - pointer.startY;
|
||||
pointer.moved ||= Math.hypot(deltaX, deltaY) > 4;
|
||||
const position = clampPosition(pointer.x + deltaX, pointer.y + deltaY);
|
||||
pet.dataset.x = String(position.x);
|
||||
pet.dataset.y = String(position.y);
|
||||
pet.style.setProperty("--pet-x", `${position.x}px`);
|
||||
pet.style.setProperty("--pet-y", `${position.y}px`);
|
||||
});
|
||||
const endDrag = () => {
|
||||
pointer.active = false;
|
||||
delete pet.dataset.dragging;
|
||||
};
|
||||
body.addEventListener("pointerup", endDrag);
|
||||
body.addEventListener("pointercancel", endDrag);
|
||||
body.addEventListener("click", () => {
|
||||
if (pointer.moved || pet.dataset.entering === "true") return;
|
||||
requestTailWave();
|
||||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||||
say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking");
|
||||
});
|
||||
window.addEventListener("resize", () => {
|
||||
const position = clampPosition(Number(pet.dataset.x || 0), Number(pet.dataset.y || 0));
|
||||
pet.dataset.x = String(position.x);
|
||||
pet.dataset.y = String(position.y);
|
||||
pet.style.setProperty("--pet-x", `${position.x}px`);
|
||||
pet.style.setProperty("--pet-y", `${position.y}px`);
|
||||
});
|
||||
window.addEventListener("cognitive-agent:module-change", (event) => {
|
||||
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(() => {
|
||||
if (document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension"
|
||||
|| pet.dataset.entering === "true") return;
|
||||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||||
say(phrases[petPhraseIndex]);
|
||||
}, 14000);
|
||||
window.addEventListener("pagehide", () => {
|
||||
window.clearInterval(petTimer);
|
||||
window.clearTimeout(petBlinkTimer);
|
||||
window.clearTimeout(petBlinkResetTimer);
|
||||
stopEntrance();
|
||||
stopTailWave();
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
$$('[data-extension-mode]').forEach((button) => {
|
||||
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
|
||||
@@ -1725,8 +2291,96 @@
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
if (buildUnifiedExtensionSync()) return;
|
||||
if (!buildShell()) return;
|
||||
initializeDesktopPet();
|
||||
bindEvents();
|
||||
renderAll();
|
||||
setMode(activeMode);
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
const THREE = window.THREE;
|
||||
const canvas = document.getElementById("repairSeatCanvas");
|
||||
const viewer = document.getElementById("repairSeatViewer");
|
||||
if (!canvas || !viewer || viewer.dataset.seat3dInitialized === "true") return;
|
||||
viewer.dataset.seat3dInitialized = "true";
|
||||
|
||||
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("seat-healing-state", (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) ?? 35)) * 0.18}px`);
|
||||
fallback.style.setProperty("--repair-lift", `${((axes.get(1) ?? 42) - 50) * 0.1}px`);
|
||||
fallback.style.setProperty("--repair-back", `${-8 - (axes.get(2) ?? 48) * 0.24}deg`);
|
||||
fallback.style.setProperty("--repair-leg", `${-5 + (axes.get(3) ?? 20) * 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");
|
||||
});
|
||||
viewer.classList.add("ready", "fallback");
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial);
|
||||
makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial);
|
||||
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);
|
||||
|
||||
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]]);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" };
|
||||
const desired = { axes: [35, 42, 48, 20], ventilation: false, heating: 0, active: false, state: "IDLE" };
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function onHealingState(event) {
|
||||
const detail = event.detail || {};
|
||||
(detail.axes || []).forEach((axis) => {
|
||||
const motor = Number(axis.motor);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
viewer.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
pointer.active = true;
|
||||
pointer.x = event.clientX;
|
||||
pointer.y = event.clientY;
|
||||
viewer.classList.add("dragging");
|
||||
});
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
if (!pointer.active) return;
|
||||
event.preventDefault();
|
||||
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 = () => {
|
||||
pointer.active = false;
|
||||
viewer.classList.remove("dragging");
|
||||
};
|
||||
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("seat-healing-state", onHealingState);
|
||||
window.addEventListener("self-repair-seat-reset", resetOrbit);
|
||||
|
||||
if (window.ResizeObserver) new ResizeObserver(resize).observe(viewer);
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
resetOrbit();
|
||||
animate();
|
||||
window.requestAnimationFrame(() => viewer.classList.add("ready"));
|
||||
|
||||
}
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
init();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
}
|
||||
}());
|
||||
@@ -1,616 +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 PHASE_INDEX = {
|
||||
IDLE: -1,
|
||||
BASELINE: 0,
|
||||
INJECTED: 1,
|
||||
DIAGNOSED: 2,
|
||||
REPAIRING: 3,
|
||||
VERIFYING: 4,
|
||||
PASSED: 5,
|
||||
FAILED: 2,
|
||||
};
|
||||
const STATE_TEXT = {
|
||||
OK: "正常",
|
||||
WARN: "告警",
|
||||
FAULT: "故障",
|
||||
UNKNOWN: "未检测",
|
||||
UNMONITORED: "关联待测",
|
||||
NOT_USED: "未启用",
|
||||
IDLE: "检测中",
|
||||
};
|
||||
const TIMELINE = [
|
||||
{ title: "开始正常任务", phases: ["BASELINE"] },
|
||||
{ title: "故障发生并停止", phases: ["INJECTED"] },
|
||||
{ title: "AI判断原因", phases: ["DIAGNOSED"] },
|
||||
{ title: "执行恢复策略", phases: ["REPAIRING"] },
|
||||
{ title: "继续原任务", phases: ["VERIFYING", "PASSED"] },
|
||||
];
|
||||
|
||||
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 = null;
|
||||
let selectedCaseId = null;
|
||||
let selectedPinId = "JC1-1";
|
||||
let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware"
|
||||
? "hardware"
|
||||
: "software";
|
||||
let executionMode = sessionStorage.getItem(MODE_KEY) === "bench"
|
||||
? "bench"
|
||||
: "shadow";
|
||||
let polling = false;
|
||||
let toastTimer = 0;
|
||||
let confirmationResolve = null;
|
||||
|
||||
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><span class="section-kicker">COGNITIVE SEAT / 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>正常任务 → 故障停止 → AI 判断 → 安全修复 → 继续原目标</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 type="button" id="repairRunAll"><i data-lucide="list-checks"></i>批量验证6项</button>
|
||||
<button class="primary" type="button" id="repairStart"><i data-lucide="play"></i>启动当前用例</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="v2-repair-software-grid">
|
||||
<section class="v2-repair-panel v2-repair-cases-panel">
|
||||
<header><div><span class="section-kicker">PRODUCTION CASES</span><h2>量产故障用例</h2></div><b id="repairBatchCount">0 / 6</b></header>
|
||||
<div class="v2-repair-overall-progress" aria-label="六项用例总体进度"><span id="repairBatchProgress"></span></div>
|
||||
<p id="repairBatchSummary">等待启动批量验证</p>
|
||||
<div class="v2-repair-case-list" id="repairCaseList"></div>
|
||||
</section>
|
||||
<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-timeline-panel">
|
||||
<header><div><span class="section-kicker">HEALING FLOW</span><h2>座椅任务自修复</h2></div><b id="repairElapsed">0.000 s</b></header>
|
||||
<div class="v2-repair-timeline" id="repairTimeline"></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 timelineDetail(index, scenario, healing) {
|
||||
const telemetry = healing.telemetry || {};
|
||||
const details = [
|
||||
scenario.normal_task,
|
||||
telemetry.bench_reaction || scenario.fault_reaction,
|
||||
scenario.diagnosis,
|
||||
scenario.repair,
|
||||
scenario.recovery_reaction,
|
||||
];
|
||||
const phases = TIMELINE[index].phases;
|
||||
const event = [...(healing.events || [])].reverse()
|
||||
.find((item) => phases.includes(item.phase));
|
||||
return event?.detail || event?.title || details[index] || "等待运行日志";
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const healing = latest.healing;
|
||||
const scenario = scenarioForDisplay();
|
||||
if (!scenario) return;
|
||||
const phaseIndex = PHASE_INDEX[healing.phase] ?? -1;
|
||||
const passed = healing.phase === "PASSED";
|
||||
const failed = healing.phase === "FAILED";
|
||||
$("#repairTimeline").innerHTML = TIMELINE.map((stage, index) => {
|
||||
let state = "pending";
|
||||
if (passed || phaseIndex > index) state = "complete";
|
||||
else if (phaseIndex === index && healing.phase !== "IDLE") state = failed ? "failed" : "active";
|
||||
const label = state === "complete" ? "已完成" : state === "active" ? "进行中" : state === "failed" ? "失败" : "等待";
|
||||
return `<article data-state="${state}"><i></i><div><header><strong>${esc(stage.title)}</strong><span>${label}</span></header><p>${esc(timelineDetail(index, scenario, healing))}</p></div></article>`;
|
||||
}).join("");
|
||||
$("#repairElapsed").textContent = `${Number(healing.elapsed_s || 0).toFixed(3)} s`;
|
||||
}
|
||||
|
||||
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();
|
||||
renderTimeline();
|
||||
renderTwin();
|
||||
});
|
||||
});
|
||||
const batch = latest.batch || {};
|
||||
const completed = Number(batch.completed || 0);
|
||||
const total = Number(batch.total || 6);
|
||||
$("#repairBatchProgress").style.width = `${Math.min(100, (completed / Math.max(1, total)) * 100)}%`;
|
||||
$("#repairBatchCount").textContent = `${completed} / ${total}`;
|
||||
$("#repairBatchSummary").textContent = batch.active
|
||||
? `正在验证 ${batch.current_case_id || "下一项"} · 通过 ${batch.passed} · 失败 ${batch.failed}`
|
||||
: completed
|
||||
? `批量验证完成 · 通过 ${batch.passed} · 失败 ${batch.failed}`
|
||||
: "等待启动批量验证";
|
||||
}
|
||||
|
||||
function renderTwin() {
|
||||
const healing = latest.healing;
|
||||
const scenario = scenarioForDisplay();
|
||||
if (!scenario) return;
|
||||
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"
|
||||
? `等待启动:${scenario.normal_task}` : scenario.normal_task;
|
||||
$("#repairTwinStage").textContent = stage || "待机";
|
||||
$("#repairTwinState").textContent = healing.phase_text;
|
||||
$("#repairTwinReaction").textContent = telemetry.bench_reaction
|
||||
|| (healing.phase === "IDLE" ? scenario.field_basis : 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 || {};
|
||||
$("#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
|
||||
: [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;
|
||||
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();
|
||||
renderTimeline();
|
||||
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 (!["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();
|
||||
});
|
||||
$("#repairStart").addEventListener("click", async () => {
|
||||
if (executionMode === "bench" && !(await confirmAction("即将向实物 ECU 写入受控故障并驱动座椅恢复。请确认运动区域无人、无遮挡且急停可用。"))) return;
|
||||
runAction(() => apiPost("/healing/start", { case_id: selectedCaseId, execution_mode: executionMode }), executionMode === "bench" ? "实物自修复用例已启动" : "影子故障已注入,开始自修复");
|
||||
});
|
||||
$("#repairRunAll").addEventListener("click", async () => {
|
||||
if (executionMode === "bench" && !(await confirmAction(
|
||||
"将依次执行6个实物故障用例;每项开始前会自动停止输出并完成四轴归零。请确认运动区域无人、无遮挡且急停可用。",
|
||||
"确认实物批量验证",
|
||||
))) return;
|
||||
runAction(() => apiPost("/healing/run-all", { execution_mode: executionMode }), "六项自动验证已启动");
|
||||
});
|
||||
$("#repairReset").addEventListener("click", async () => {
|
||||
if (executionMode === "bench" && !(await confirmAction("四轴将依次回到机械零位。请确认运动区域无人、无遮挡且急停可用。", "确认归零"))) return;
|
||||
runAction(() => apiPost("/healing/reset", { execution_mode: executionMode }), executionMode === "bench" ? "四轴归零请求已提交" : "自修复验证已重置");
|
||||
});
|
||||
$("#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-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);
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
if (isActive()) poll();
|
||||
window.setInterval(poll, 600);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
}());
|
||||
@@ -1,25 +1,20 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v80";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v202";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/styles.css?v=dual-seat-9",
|
||||
"/network.js?v=android-pad-1",
|
||||
"/app.js?v=dual-seat-67",
|
||||
"/gesture-control.js?v=android-pad-7",
|
||||
"/pwa.js?v=android-pad-1",
|
||||
"/console-v2.css?v=desktop-refactor-87",
|
||||
"/console-v2.js?v=desktop-refactor-28",
|
||||
"/self-extension.css?v=extension-12",
|
||||
"/self-extension.js?v=extension-18",
|
||||
"/self-calibration.css?v=calibration-6",
|
||||
"/self-calibration.js?v=calibration-3",
|
||||
"/self-repair.css?v=repair-17",
|
||||
"/self-repair.js?v=repair-17",
|
||||
"/self-repair-seat3d.js?v=repair-17",
|
||||
"/assets/self-calibration-model.bundle.js",
|
||||
"/assets/models/n7_seat.glb",
|
||||
"/styles.css?v=dual-seat-16",
|
||||
"/network.js?v=android-pad-2",
|
||||
"/app.js?v=dual-seat-101",
|
||||
"/gesture-control.js?v=android-pad-9",
|
||||
"/pwa.js?v=android-pad-3",
|
||||
"/console-v2.css?v=sync-layout-40",
|
||||
"/console-v2.js?v=sync-layout-41",
|
||||
"/self-extension.css?v=sync-layout-26",
|
||||
"/self-extension.js?v=sync-layout-26",
|
||||
"/assets/pets/self-extension-cat.png?v=3",
|
||||
"/assets/pets/self-extension-cat-entrance.png?v=3",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/app-icon-192.png",
|
||||
"/icons/app-icon-512.png",
|
||||
@@ -38,6 +33,7 @@ self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(APP_SHELL))
|
||||
.then(() => self.skipWaiting())
|
||||
.then(() => self.clients.matchAll({ includeUncontrolled: true }))
|
||||
.then((clients) => clients.forEach((client) => client.postMessage({ type: "OFFLINE_READY" })))
|
||||
);
|
||||
@@ -56,12 +52,13 @@ self.addEventListener("activate", (event) => {
|
||||
});
|
||||
|
||||
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;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
await runtimeCache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -75,7 +72,11 @@ async function navigationResponse(request) {
|
||||
}
|
||||
return response;
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -0,0 +1 @@
|
||||
9136
|
||||
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 187 KiB |