10 Commits

Author SHA1 Message Date
minghanchen 8d05815773 优化前端,优化前端指令,优化自定义控制 2026-08-24 09:40:16 +08:00
minghanchen fff7657fea merge: integrate wired and wireless CAN into N7Web 2026-08-19 10:21:27 +08:00
minghanchen 52c4dd08f9 添加无线模块 2026-08-19 09:59:36 +08:00
vee 772ab685d6 feat(web-console): refine intelligent cockpit sync interface 2026-08-19 08:36:38 +08:00
vee 9b24d5c8c9 feat(web-console): rebuild intelligent cockpit sync interface 2026-08-18 13:24:36 +08:00
vee 344f662970 feat(web-console): add personalized seat weighing evolution 2026-08-14 15:53:08 +08:00
vee f77dda70fe update 2026-08-14 15:01:29 +08:00
vee 6d768aaef4 update model 2026-08-14 11:17:17 +08:00
vee 1a96d39426 feat: default users to safe virtual CAN execution 2026-08-14 09:56:22 +08:00
vee 0ec43052f3 feat: refine vehicle console and self-repair workflow 2026-08-13 17:49:37 +08:00
206 changed files with 18386 additions and 11889 deletions
+1
View File
@@ -4,6 +4,7 @@
__pycache__/
*.py[cod]
artifacts/runs/
artifacts/mode-profiles/
artifacts/server/
artifacts/tmp/
artifacts/self-repair/
+7
View File
@@ -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`。
+12 -50
View File
@@ -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`,主控制台提供退出按钮。固定入口是
+2046 -328
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35 -117
View File
@@ -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();
+104 -137
View File
@@ -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>
+1 -1
View File
@@ -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>
-20
View File
@@ -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 -6
View File
@@ -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;
File diff suppressed because it is too large Load Diff
-691
View File
@@ -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);
}());
+496 -69
View File
@@ -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;
}
+667 -13
View File
@@ -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);
-483
View File
@@ -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 });
}
}());
File diff suppressed because it is too large Load Diff
-616
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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);
}());
+22 -21
View File
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
9136
Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Some files were not shown because too many files have changed in this diff Show More