Files
Cognitive_Agent/apps/web-console/self-extension.js
T

1740 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const SLOT_MIN = 1;
const SLOT_MAX = 248;
const ACTION_PAGE_SIZE = 6;
const HISTORY_LIMIT = 40;
const MAX_PLAN_STEPS = 12;
const MIN_PLAN_DELAY_MS = 1;
const MAX_PLAN_DELAY_MS = 30000;
const MAX_TOTAL_PLAN_DELAY_MS = 60000;
const HEALTH_POLL_INTERVAL_MS = 3000;
const HEALTH_REQUEST_TIMEOUT_MS = 5000;
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
function preset(key, name, aliases, category = "custom", risk = "low", visual = null) {
return { key, name, aliases, category, risk, visual };
}
const TOY_TYPES = {
light: {
label: "灯光类玩具",
shortLabel: "灯光",
icon: "lightbulb",
color: "#eab858",
description: "颜色、亮灭与闪烁",
presets: [
preset("on", "开启", ["打开", "开灯"], "power"),
preset("off", "关闭", ["关掉", "关灯", "熄灭"], "power"),
preset("red", "红色灯光", ["红灯", "变红", "红色"], "color", "low", "#ef6267"),
preset("green", "绿色灯光", ["绿灯", "变绿", "绿色"], "color", "low", "#48b982"),
preset("blue", "蓝色灯光", ["蓝灯", "变蓝", "蓝色"], "color", "low", "#4f7fd2"),
preset("white", "白色灯光", ["白灯", "白色"], "color", "low", "#d9d5c8"),
preset("fast_flash", "快速闪烁", ["快闪", "快速闪灯"], "effect"),
preset("slow_flash", "慢速闪烁", ["慢闪", "慢速闪灯"], "effect"),
preset("warm", "暖色灯光", ["暖光", "暖色"], "color", "low", "#f0a958"),
preset("brightness_up", "亮度增加", ["调亮", "亮一点"], "effect"),
preset("brightness_down", "亮度降低", ["调暗", "暗一点"], "effect"),
preset("breath", "呼吸灯效", ["呼吸灯", "呼吸闪烁"], "effect"),
],
},
sound: {
label: "声音类玩具",
shortLabel: "声音",
icon: "volume-2",
color: "#658bd5",
description: "播放、音量与切换",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止播放"], "power"),
preset("play_pause", "播放 / 暂停", ["播放", "暂停"], "sound"),
preset("volume_up", "音量加", ["大声一点", "调高音量"], "sound"),
preset("volume_down", "音量减", ["小声一点", "调低音量"], "sound"),
preset("next", "下一首", ["下一个声音"], "sound"),
preset("previous", "上一首", ["上一个声音"], "sound"),
preset("mode", "模式切换", ["换个模式"], "effect"),
preset("mute", "静音", ["不要声音", "关闭声音"], "sound"),
preset("loop", "循环播放", ["单曲循环", "循环"], "sound"),
preset("random", "随机播放", ["随机切换"], "sound"),
preset("stop", "停止播放", ["停止", "停止声音"], "sound"),
],
},
action: {
label: "动作类玩具",
shortLabel: "动作",
icon: "car-front",
color: "#db7468",
description: "移动、转向与表演",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "关闭玩具"], "power"),
preset("forward", "前进", ["向前", "往前走"], "motion", "high"),
preset("backward", "后退", ["向后", "倒退"], "motion", "high"),
preset("left", "左转", ["向左", "左拐"], "direction", "high"),
preset("right", "右转", ["向右", "右拐"], "direction", "high"),
preset("stop", "停止", ["停下", "不要动"], "motion"),
preset("perform", "表演动作", ["表演", "做动作"], "effect", "medium"),
preset("speed_up", "速度增加", ["加速", "快一点"], "motion", "high"),
preset("speed_down", "速度降低", ["减速", "慢一点"], "motion"),
preset("spin", "原地旋转", ["转圈", "旋转"], "direction", "medium"),
preset("dance", "舞蹈动作", ["跳舞", "舞蹈"], "effect", "medium"),
],
},
doll: {
label: "玩偶类玩具",
shortLabel: "玩偶",
icon: "bot",
color: "#cb86a4",
description: "语音、舞蹈与互动",
presets: [
preset("on", "开启", ["打开", "唤醒"], "power"),
preset("off", "关闭", ["关掉", "休息"], "power"),
preset("speak", "说话", ["讲句话", "发声"], "sound"),
preset("sing", "唱歌", ["唱一首歌"], "sound"),
preset("dance", "跳舞", ["舞蹈"], "effect", "medium"),
preset("wave", "挥手", ["招手"], "motion"),
preset("interact", "互动", ["陪我玩", "互动一下"], "effect"),
preset("stop", "停止", ["停下", "停止动作"], "motion"),
preset("laugh", "笑声", ["笑一下", "开心"], "sound"),
preset("story", "讲故事", ["故事", "讲个故事"], "sound"),
preset("repeat", "复读", ["重复说", "跟我说"], "sound"),
preset("sleep", "睡眠模式", ["睡觉", "休眠"], "effect"),
],
},
mechanical: {
label: "机械类玩具",
shortLabel: "机械",
icon: "cog",
color: "#758e94",
description: "升降、伸缩与旋转",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止设备"], "power"),
preset("up", "上升", ["升高", "向上"], "motion", "medium"),
preset("down", "下降", ["降低", "向下"], "motion", "medium"),
preset("extend", "伸出", ["向外伸", "展开"], "motion", "medium"),
preset("retract", "收回", ["缩回", "收起"], "motion", "medium"),
preset("clockwise", "正转", ["顺时针", "正向旋转"], "direction", "medium"),
preset("counterclockwise", "反转", ["逆时针", "反向旋转"], "direction", "medium"),
preset("stop", "停止", ["停下", "急停"], "motion"),
preset("speed_up", "速度增加", ["加速", "快一点"], "motion", "medium"),
preset("speed_down", "速度降低", ["减速", "慢一点"], "motion"),
preset("home", "复位", ["回到原位", "归位"], "motion", "medium"),
],
},
other: {
label: "其他玩具",
shortLabel: "其他",
icon: "gamepad-2",
color: "#579a88",
description: "通用自定义功能",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止"], "power"),
preset("function_1", "功能一", ["第一个功能"]),
preset("function_2", "功能二", ["第二个功能"]),
preset("function_3", "功能三", ["第三个功能"]),
preset("function_4", "功能四", ["第四个功能"]),
preset("function_5", "功能五", ["第五个功能"]),
preset("function_6", "功能六", ["第六个功能"]),
preset("function_7", "功能七", ["第七个功能"]),
preset("function_8", "功能八", ["第八个功能"]),
preset("mode", "模式切换", ["换个模式"]),
preset("reset", "复位", ["恢复", "回到初始状态"]),
],
},
};
const steppedActions = (values, makeAction) => values.map(makeAction);
const percentSteps = [0, 25, 50, 75, 100];
const VEHICLE_DEVICES = [
{
id: "passenger-seat",
name: "副驾座椅",
code: "SEAT · CH0 T2G CAN",
channel: "CH0",
icon: "armchair",
parameters: ["通风 0–3 档", "加热 0–2 档", "水平 0–100%", "坐盆 0–100%", "靠背 95–163°", "腿托 95–167°"],
actions: [
...steppedActions([1, 2, 3], (level) => ({ name: `副驾通风 ${level} 档`, command: `副驾座椅通风${level}档` })),
{ name: "关闭副驾通风", command: "关闭副驾座椅通风" },
...steppedActions([1, 2], (level) => ({ name: `副驾加热 ${level} 档`, command: `副驾座椅加热${level}档` })),
{ name: "关闭副驾加热", command: "关闭副驾座椅加热" },
...["水平", "坐盆", "靠背", "腿托"].flatMap((axis) => steppedActions(percentSteps, (position) => ({
name: `副驾${axis} ${position}%`,
command: `副驾${axis}调到${position}%`,
}))),
],
},
{
id: "driver-seat",
name: "主驾座椅",
code: "SEAT · CH1 BODY CAN",
channel: "CH1",
icon: "armchair",
parameters: ["通风 / 加热", "按摩强度 / 模式", "水平前后", "整椅高低", "靠背前后", "坐盆高低"],
actions: [
{ name: "通风低档", command: "seat driver vent low" },
{ name: "通风高档", command: "seat driver vent high" },
{ name: "关闭通风", command: "seat driver vent off" },
{ name: "加热低档", command: "seat driver heat low" },
{ name: "加热高档", command: "seat driver heat high" },
{ name: "关闭加热", command: "seat driver heat off" },
...steppedActions(["weak", "medium", "strong"], (strength) => ({ name: `按摩 ${strength}`, command: `seat driver massage strength ${strength}` })),
...steppedActions(["wave", "pulse", "stretch", "slack", "relax"], (mode) => ({ name: `按摩 ${mode}`, command: `seat driver massage mode ${mode}` })),
{ name: "关闭按摩", command: "seat driver massage off" },
...["horizontal", "backrest"].flatMap((axis) => ["forward", "backward", "stop"].map((direction) => ({
name: `${axis} ${direction}`,
command: `seat driver ${axis} ${direction}${direction === "stop" ? "" : " 500"}`,
}))),
...["height", "basin"].flatMap((axis) => ["up", "down", "stop"].map((direction) => ({
name: `${axis} ${direction}`,
command: `seat driver ${axis} ${direction}${direction === "stop" ? "" : " 500"}`,
}))),
],
},
{
id: "climate",
name: "空调",
code: "CLIMATE · CH1 CAN",
channel: "CH1",
icon: "snowflake",
parameters: ["电源", "主 / 副驾 18–32℃", "风量 1–7", "出风模式", "内外循环", "前除霜"],
actions: [
{ name: "开启空调", command: "ac on" },
{ name: "关闭空调", command: "ac off" },
...["driver", "passenger"].flatMap((zone) => steppedActions(
Array.from({ length: 15 }, (_, index) => index + 18),
(temperature) => ({ name: `${zone} ${temperature}℃`, command: `ac temp ${zone} ${temperature}` }),
)),
...steppedActions(Array.from({ length: 7 }, (_, index) => index + 1), (level) => ({ name: `风量 ${level}`, command: `ac fan ${level}` })),
...steppedActions(["face", "feet", "face-feet", "defrost", "feet-defrost"], (mode) => ({ name: `出风 ${mode}`, command: `ac mode ${mode}` })),
{ name: "自动模式", command: "ac control auto" },
{ name: "手动模式", command: "ac control manual" },
{ name: "内循环", command: "ac cycle internal" },
{ name: "外循环", command: "ac cycle external" },
{ name: "开启前除霜", command: "ac defrost on" },
{ name: "关闭前除霜", command: "ac defrost off" },
],
},
{
id: "roof",
name: "遮阳帘",
code: "ROOF · CH1 CAN",
channel: "CH1",
icon: "panels-top-left",
parameters: ["位置 0–100%", "开启", "关闭", "暂停"],
actions: steppedActions(Array.from({ length: 11 }, (_, index) => index * 10), (position) => ({
name: `遮阳帘 ${position}%`,
command: `shade position ${position}`,
})),
},
{
id: "interior-light",
name: "车内灯光",
code: "INTERIOR · CH1 CAN",
channel: "CH1",
icon: "lamp-ceiling",
parameters: ["室内灯", "四区阅读灯", "氛围灯电源", "亮度 1–5", "色号 1–127"],
actions: [
{ name: "室内灯开启", command: "light indoor on" },
{ name: "室内灯关闭", command: "light indoor off" },
{ name: "阅读灯开启", command: "light reading all on" },
{ name: "阅读灯关闭", command: "light reading all off" },
{ name: "氛围灯开启", command: "light ambient on" },
{ name: "氛围灯关闭", command: "light ambient off" },
...steppedActions([1, 2, 3, 4, 5], (level) => ({ name: `氛围亮度 ${level}`, command: `light ambient brightness ${level}` })),
...steppedActions([1, 16, 32, 48, 64, 80, 96, 112, 127], (color) => ({ name: `氛围色号 ${color}`, command: `light ambient color ${color}` })),
],
},
{
id: "windows",
name: "车窗",
code: "WINDOW · CH1 CAN",
channel: "CH1",
icon: "gallery-vertical-end",
parameters: ["左前 0–100%", "右前 0–100%", "左后 0–100%", "右后 0–100%", "开 / 关 / 停"],
actions: ["front-left", "front-right", "rear-left", "rear-right"].flatMap((positionName) => steppedActions(percentSteps, (position) => ({
name: `${positionName} ${position}%`,
command: `window ${positionName} position ${position}`,
}))),
},
];
const DEFAULT_DEVICE = {
id: "toy-light-test",
name: "灯光测试玩具",
type: "light",
aliases: ["灯光测试玩具", "灯光玩具", "玩具灯"],
createdAt: new Date().toISOString(),
};
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 selectedAddType = "light";
let learning = freshLearningSession();
let isPlanning = false;
let isExecuting = false;
let healthState = null;
let healthRefreshPromise = null;
let healthPollTimer = null;
let healthToastRequested = false;
let latestVehicleStateTurn = -1;
let sharedTwinBand = null;
let sharedTwinHome = null;
function uid(prefix) {
if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[char]);
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function validSlot(value) {
const slot = Number(value);
return Number.isInteger(slot) && slot >= SLOT_MIN && slot <= SLOT_MAX;
}
function initialState() {
const device = clone(DEFAULT_DEVICE);
return {
version: 5,
devices: [device],
capabilities: seedPresets(device),
selectedDeviceByType: { light: device.id },
sessionStartedAt: null,
settings: { actionDelayMs: 520 },
history: [],
lastProtocol: null,
vehicleExecutions: {},
};
}
function loadState() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
if (!saved || saved.version !== 5 || !Array.isArray(saved.devices)) {
return initialState();
}
saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : [];
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 };
return saved;
} catch (error) {
console.warn("读取自扩展能力数据失败,已恢复默认状态。", error);
return initialState();
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function seedPresets(device) {
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
return type.presets.map((item) => ({
id: uid("cap"),
deviceId: device.id,
key: item.key,
name: item.name,
aliases: [item.name, ...item.aliases],
category: item.category,
risk: item.risk,
visual: item.visual,
slot: null,
status: "pending",
verified: false,
enabled: false,
createdAt: new Date().toISOString(),
}));
}
function freshLearningSession() {
return {
phase: "idle",
draft: null,
countdownTimer: null,
abortController: null,
tested: false,
};
}
function deviceCapabilities(deviceId) {
return state.capabilities.filter((item) => item.deviceId === deviceId);
}
function learnedCapabilities(deviceId = null) {
return state.capabilities.filter((item) => (
(!deviceId || item.deviceId === deviceId)
&& item.status === "learned"
&& item.verified
&& item.enabled !== false
&& validSlot(item.slot)
));
}
function allocateSlot() {
const used = new Set(learnedCapabilities().map((item) => Number(item.slot)));
for (let slot = SLOT_MIN; slot <= SLOT_MAX; slot += 1) {
if (!used.has(slot)) return slot;
}
return null;
}
function selectedDeviceForType(typeKey) {
const devices = state.devices.filter((item) => item.type === typeKey);
const selectedId = state.selectedDeviceByType[typeKey];
const selected = devices.find((item) => item.id === selectedId) || devices[0] || null;
if (selected && selected.id !== selectedId) state.selectedDeviceByType[typeKey] = selected.id;
return selected;
}
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher) return false;
const extensionSidebar = document.createElement("section");
extensionSidebar.className = "v2-sidebar-section v2-extension-sidebar";
extensionSidebar.innerHTML = `
<div class="v2-section-heading">
<span>EXTENSION DESK</span>
<h2>控制台</h2>
</div>
<span class="v2-field-caption">工作模式</span>
<div class="v2-extension-mode-switch" role="group" aria-label="工作模式">
<button type="button" data-extension-mode="study"><i data-lucide="book-open-check"></i><span>学习模式</span></button>
<button type="button" data-extension-mode="control"><i data-lucide="send"></i><span>控制模式</span></button>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">配置信息</span>
<div class="v2-extension-config-stack">
<div class="v2-extension-config-card" id="extensionVehicleCard" data-state="checking"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span id="extensionVehicleState">CHECK</span></div>
<div class="v2-extension-config-card" id="extensionModelCard"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong id="extensionModelName">模型检测中</strong></div><span id="extensionModelState">CHECK</span></div>
<div class="v2-extension-config-card" id="extensionStudyHealth" data-state="checking"><i data-lucide="radio-tower"></i><div><small>红外学习器</small><strong id="extensionStudyHealthText">正在检查 GSCU1B</strong></div><span id="extensionStudyHealthState">CHECK</span></div>
</div>
<div class="v2-divider"></div>
<span class="v2-field-caption">玩具信息</span>
<div class="v2-extension-metrics">
<div><i data-lucide="radio-tower"></i><span>已接入外设玩具</span><strong id="extensionHardwareCount">0</strong></div>
<div><i data-lucide="badge-check"></i><span>已学习能力</span><strong id="extensionCapabilityCount">0</strong></div>
</div>`;
sidebar.insertBefore(extensionSidebar, $(".v2-sidebar-footer"));
const extensionFooter = document.createElement("footer");
extensionFooter.className = "v2-sidebar-footer v2-extension-footer";
extensionFooter.innerHTML = `
<span class="v2-live-dot"></span>
<div><strong>自扩展控制链路</strong><small id="extensionConnectionText">正在检查 GSCU1B</small></div>
<button type="button" id="extensionFooterRefresh" title="刷新连接" aria-label="刷新自扩展连接"><i data-lucide="refresh-cw"></i></button>`;
sidebar.append(extensionFooter);
const root = document.createElement("section");
root.className = "v2-self-extension";
root.id = "v2SelfExtension";
root.setAttribute("aria-label", "自扩展工作台");
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-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>
</div>
</header>
<div class="v2-extension-hardware-grid" id="extensionHardwareGrid"></div>
</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>
</header>
<div class="v2-extension-command-row">
<div class="v2-extension-composer">
<textarea id="extensionUtterance" rows="1" maxlength="500" placeholder="例如:发出红光,然后打开车内阅读灯" aria-label="输入自扩展联动指令"></textarea>
<button class="primary-action" type="button" id="extensionExecuteButton" aria-label="执行自扩展联动指令"><i data-lucide="play"></i><span>执行</span></button>
</div>
<aside class="v2-extension-execution-state" id="extensionExecutionState" data-state="idle">
<span>执行情况</span><strong id="extensionRunTitle">等待指令</strong><small id="extensionRunNote">尚未生成联动计划</small>
<div class="v2-extension-run-progress"><i id="extensionRunProgressBar"></i></div>
</aside>
</div>
<div class="v2-extension-control-grid">
<section class="v2-extension-action-board">
<header><div><span class="section-kicker">LEARNED ACTIONS</span><h2>玩具动作</h2></div><strong id="extensionLearnedTotal">0 项可执行</strong></header>
<div class="v2-extension-learned-grid" id="extensionLearnedGrid"></div>
</section>
<section class="v2-extension-action-board">
<header><div><span class="section-kicker">VEHICLE ACTIONS</span><h2>车端设备动作</h2></div><strong>同源安全编排</strong></header>
<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>
<div class="v2-extension-twin-host" id="extensionTwinHost">
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
</div>
</section>
<div class="v2-extension-run-plan" id="extensionVehicleStateSummary" data-state="unknown">
<span><i>CH0 / CH1</i>等待权威车端状态</span>
</div>
<div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div>
</section>`;
moduleSwitcher.insertAdjacentElement("afterend", root);
buildDialogs();
return true;
}
function buildDialogs() {
const layer = document.createElement("div");
layer.className = "v2-extension-dialog-layer";
layer.innerHTML = `
<dialog class="v2-extension-dialog" id="extensionAddDialog">
<form id="extensionAddForm">
<header><div><span class="section-kicker">NEW PERIPHERAL</span><h2>添加外设玩具</h2><p>选择类别后会自动建立一组待学习动作。</p></div><button type="button" data-close-extension-dialog="extensionAddDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<div class="v2-extension-type-chooser" id="extensionTypeChooser"></div>
<label><span>玩具名称</span><input id="extensionHardwareName" maxlength="48" autocomplete="off" placeholder="例如:床头灯光玩具" required /></label>
<div class="v2-extension-preset-preview"><span>将建立的预设动作</span><div id="extensionPresetPreview"></div></div>
<button class="v2-extension-primary" type="submit">添加并开始学习</button>
</form>
</dialog>
<dialog class="v2-extension-dialog v2-extension-learn-dialog" id="extensionLearnDialog">
<header><div><span class="section-kicker">GSCU1B LEARNING</span><h2 id="extensionLearnTitle">学习遥控动作</h2><p id="extensionLearnPath">选择动作后开始</p></div><button type="button" data-close-extension-dialog="extensionLearnDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<div class="v2-extension-learn-steps"><span data-extension-learn-step="1"><b>1</b>配置</span><i></i><span data-extension-learn-step="2"><b>2</b>接收</span><i></i><span data-extension-learn-step="3"><b>3</b>验证</span></div>
<section class="v2-extension-learn-page" data-extension-learn-page="1">
<div class="v2-extension-learn-target"><span id="extensionLearnIcon"><i data-lucide="radio"></i></span><div><small>目标玩具</small><strong id="extensionLearnDevice">--</strong></div><i data-lucide="arrow-right"></i><div><small>待学习动作</small><strong id="extensionLearnAction">--</strong></div></div>
<div class="v2-extension-learn-fields">
<label><span>说法与别名</span><input id="extensionLearnAliases" /></label>
<label><span>风险级别</span><select id="extensionLearnRisk"><option value="low">低</option><option value="medium">中</option><option value="high">高</option></select></label>
<label><span>红外槽位</span><input id="extensionLearnSlot" type="number" min="1" max="248" /></label>
</div>
<button class="v2-extension-primary" type="button" id="extensionContinueLearn">继续接收遥控信号</button>
</section>
<section class="v2-extension-learn-page v2-extension-learning-page" data-extension-learn-page="2" hidden>
<div class="v2-extension-learn-animation" id="extensionLearnAnimation"><span><i data-lucide="radio-tower"></i></span><i></i><i></i></div>
<h3 id="extensionLearnStatusTitle">准备接收遥控信号</h3><p id="extensionLearnStatusMessage">请将遥控器对准学习器并按一下对应按键。</p><strong id="extensionLearnCountdown">15</strong>
<div class="v2-extension-dialog-actions"><button type="button" id="extensionCancelLearn">取消</button><button class="v2-extension-primary" type="button" id="extensionStartLearn">开始接收</button></div>
</section>
<section class="v2-extension-learn-page v2-extension-test-page" data-extension-learn-page="3" hidden>
<span><i data-lucide="badge-check"></i></span><h3 id="extensionTestTitle">信号已捕获</h3><p id="extensionTestMessage">先发射测试,确认玩具有正确反应后再保存。</p>
<div class="v2-extension-dialog-actions"><button type="button" id="extensionTestEmit">发射试一下</button><button class="v2-extension-primary" type="button" id="extensionConfirmLearn" disabled>确认并完成学习</button></div>
</section>
</dialog>
<dialog class="v2-extension-dialog v2-extension-reset-dialog" id="extensionResetDialog">
<header><div><span class="section-kicker">RESET LEARNING</span><h2>重置学习</h2><p>将擦除学习器并把所有动作恢复为待学习。</p></div><button type="button" data-close-extension-dialog="extensionResetDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<label><span>输入“清空”确认操作</span><input id="extensionResetConfirm" autocomplete="off" /></label>
<button class="v2-extension-danger" type="button" id="extensionConfirmReset" disabled>擦除并重新开始</button>
</dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
document.body.append(layer);
}
function openDialog(id) {
const dialog = $(`#${id}`);
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute("open", "");
}
function closeDialog(id) {
const dialog = $(`#${id}`);
if (dialog?.close) dialog.close();
else dialog?.removeAttribute("open");
}
function showToast(message, type = "") {
const toast = document.createElement("div");
toast.className = `v2-extension-toast ${type}`.trim();
toast.textContent = message;
$("#extensionToastStack")?.append(toast);
window.setTimeout(() => toast.remove(), 3800);
}
async function api(path, options = {}) {
let response;
try {
response = await fetch(`/api/extension/${path}`, {
credentials: "same-origin",
...options,
headers: options.body
? { "Content-Type": "application/json", ...(options.headers || {}) }
: options.headers,
});
} catch (_error) {
throw new Error("无法连接自扩展服务。");
}
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.success === false) {
const error = new Error(payload.message || `自扩展请求失败(HTTP ${response.status})`);
error.payload = payload;
throw error;
}
return payload;
}
function sharedConversationId() {
const fromConsole = window.CognitiveAgentConsole?.getConversationId?.();
if (typeof fromConsole === "string" && fromConsole.startsWith("conv-")) {
sessionStorage.setItem(CONVERSATION_KEY, fromConsole);
return fromConsole;
}
const stored = sessionStorage.getItem(CONVERSATION_KEY);
if (stored?.startsWith("conv-")) return stored;
const conversationId = uid("conv");
sessionStorage.setItem(CONVERSATION_KEY, conversationId);
return conversationId;
}
function isPhysicalVehicleSource(value) {
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "physical_partial"].includes(value);
}
function containsAuthoritativePhysicalCh1State(value) {
if (!value || typeof value !== "object") return false;
if (isPhysicalVehicleSource(value.source) || isPhysicalVehicleSource(value.feedback_source)) {
return true;
}
return Object.values(value).some((item) => containsAuthoritativePhysicalCh1State(item));
}
function isAuthoritativeVehicleStatePayload(payload) {
if (!payload || typeof payload !== "object") return false;
if (payload.conversation_id !== sharedConversationId()) return false;
return containsAuthoritativePhysicalCh1State(payload.actuator_state)
|| containsAuthoritativePhysicalCh1State(payload.domain_state);
}
function renderAuthoritativeVehicleState(payload) {
if (!isAuthoritativeVehicleStatePayload(payload)) return false;
const turn = Number(payload.conversation_turn);
if (Number.isFinite(turn) && turn < latestVehicleStateTurn) return false;
if (Number.isFinite(turn)) latestVehicleStateTurn = turn;
const summary = $("#extensionVehicleStateSummary");
if (!summary) return false;
const requestLabel = String(payload.request_id || "-").slice(-8);
const hasCh0 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.passenger);
const hasCh1 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.driver)
|| containsAuthoritativePhysicalCh1State(payload.domain_state);
const channelLabel = hasCh0 && hasCh1 ? "CH0 + CH1" : hasCh0 ? "CH0" : "CH1";
const items = [
`<span class="done"><i>${channelLabel}</i>真实车况 · TURN ${Number.isFinite(turn) ? turn : "-"} · ${escapeHtml(requestLabel)}</span>`,
];
const feedback = Array.isArray(payload.physical_feedback)
? payload.physical_feedback.filter((item) => (
item?.confirmed === true
&& [0, 1].includes(Number(item?.channel))
&& isPhysicalVehicleSource(item?.source)
))
: [];
if (feedback.length) {
const frames = [...new Set(feedback.map((item) => item.frame?.frame_id).filter(Boolean))];
items.push(`<span><i>FB</i>${feedback.length}/${payload.physical_feedback.length} MATCH · ${escapeHtml(frames.join(" / ") || channelLabel)}</span>`);
}
const driver = payload.actuator_state?.driver || {};
const passenger = payload.actuator_state?.passenger || {};
if (isPhysicalVehicleSource(passenger.ventilation?.feedback_source)) {
items.push(`<span><i>P-VENT</i>${passenger.ventilation.state === "on" ? `ON L${escapeHtml(passenger.ventilation.level)}` : "OFF"}</span>`);
}
if (isPhysicalVehicleSource(passenger.heating?.feedback_source)) {
items.push(`<span><i>P-HEAT</i>${passenger.heating.state === "on" ? `ON L${escapeHtml(passenger.heating.level)}` : "OFF"}</span>`);
}
const passengerPosition = passenger.position || {};
Object.entries(passengerPosition).forEach(([axis, value]) => {
if (!isPhysicalVehicleSource(value?.feedback_source)) return;
items.push(`<span><i>P-SEAT</i>${escapeHtml(axis)} ${escapeHtml(value.position_pct ?? "-")}%</span>`);
});
const ventilation = driver.ventilation;
const heating = driver.heating;
if (ventilation?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>VENT</i>${ventilation.state === "on" ? `ON L${escapeHtml(ventilation.level)}` : "OFF"}</span>`);
}
if (heating?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>HEAT</i>${heating.state === "on" ? `ON L${escapeHtml(heating.level)}` : "OFF"}</span>`);
}
const massage = driver.massage;
if (massage?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>MASSAGE</i>${escapeHtml(massage.mode || "off")} · ${escapeHtml(massage.strength || "off")}</span>`);
}
const climate = payload.domain_state?.climate;
if (climate?.feedback_source === "physical_can_ch1") {
const temperature = climate.zones?.driver?.temperature_c ?? "-";
const outletMode = climate.zones?.driver?.outlet_mode || "-";
items.push(`<span><i>AC</i>${escapeHtml(climate.power || climate.ac || "unknown")} · ${escapeHtml(temperature)}℃ · FAN ${escapeHtml(climate.fan_level ?? "-")} · ${escapeHtml(outletMode)}</span>`);
}
const roof = payload.domain_state?.roof;
if (roof?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>ROOF</i>${escapeHtml(roof.shade_position_pct ?? "-")}% · ${escapeHtml(roof.motion_state || "unknown")}</span>`);
}
const frontLeftWindow = payload.domain_state?.window?.positions?.front_left;
if (frontLeftWindow?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>WINDOW</i>左前 ${escapeHtml(frontLeftWindow.position_pct ?? "-")}%</span>`);
}
const interiorLight = payload.domain_state?.interior_light;
if (interiorLight?.feedback_source === "physical_can_ch1") {
items.push(`<span><i>INTERIOR</i>${escapeHtml(interiorLight.power || "unknown")}</span>`);
}
const ambientLight = payload.domain_state?.ambient_light;
if (ambientLight?.feedback_source === "physical_can_ch1") {
const brightness = `${ambientLight.front_brightness_level ?? "-"}/${ambientLight.rear_brightness_level ?? "-"}`;
const color = `${ambientLight.front_color_index ?? "-"}/${ambientLight.rear_color_index ?? "-"}`;
items.push(`<span><i>AMBIENT</i>${escapeHtml(ambientLight.power || "unknown")} · L${escapeHtml(brightness)} · C${escapeHtml(color)}</span>`);
}
summary.dataset.state = hasCh0 && hasCh1 ? "physical_can_dual" : hasCh0 ? "physical_can_ch0" : "physical_can_ch1";
summary.innerHTML = items.join("");
return true;
}
function syncSharedTwinMount() {
const host = $("#extensionTwinHost");
const shouldMount = document.body.dataset.module === "self-extension"
&& activeMode === "control";
if (!sharedTwinBand) {
sharedTwinBand = $("#seatTwin")?.closest(".twin-band") || null;
if (sharedTwinBand?.parentNode) {
sharedTwinHome = document.createComment("shared-vehicle-twin-home");
sharedTwinBand.parentNode.insertBefore(sharedTwinHome, sharedTwinBand);
}
}
if (!sharedTwinBand || !sharedTwinHome || !host) return;
if (shouldMount) {
if (sharedTwinBand.parentNode !== host) host.replaceChildren(sharedTwinBand);
sharedTwinBand.classList.add("v2-extension-shared-twin");
} else if (sharedTwinHome.parentNode && sharedTwinBand.parentNode !== sharedTwinHome.parentNode) {
sharedTwinHome.parentNode.insertBefore(sharedTwinBand, sharedTwinHome.nextSibling);
sharedTwinBand.classList.remove("v2-extension-shared-twin");
}
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
}
function setMode(mode) {
activeMode = mode === "control" ? "control" : "study";
sessionStorage.setItem(MODE_KEY, activeMode);
document.body.dataset.extensionMode = activeMode;
$$('[data-extension-mode]').forEach((button) => {
const active = button.dataset.extensionMode === activeMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
$$('[data-extension-view]').forEach((view) => {
view.hidden = view.dataset.extensionView !== activeMode;
});
if (activeMode === "control") renderControl();
syncSharedTwinMount();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function renderAll() {
renderMetrics();
renderHardwareGrid();
renderControl();
renderTypeChooser();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function renderMetrics() {
$("#extensionHardwareCount").textContent = String(state.devices.length);
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
}
function renderHardwareGrid() {
$("#extensionHardwareGrid").innerHTML = Object.keys(TOY_TYPES)
.map((typeKey) => renderTypeCard(typeKey))
.join("");
}
function renderTypeCard(typeKey) {
const type = TOY_TYPES[typeKey];
const devices = state.devices.filter((item) => item.type === typeKey);
const selected = selectedDeviceForType(typeKey);
const capabilities = selected
? deviceCapabilities(selected.id)
: type.presets.map((item) => ({ ...item, id: null, status: "pending", slot: null }));
const totalPages = Math.max(1, Math.ceil(capabilities.length / ACTION_PAGE_SIZE));
pages[typeKey] = Math.min(pages[typeKey] || 0, totalPages - 1);
const page = pages[typeKey];
const visible = capabilities.slice(page * ACTION_PAGE_SIZE, (page + 1) * ACTION_PAGE_SIZE);
const learned = selected ? learnedCapabilities(selected.id).length : 0;
const selector = devices.length
? `<select data-extension-device-select="${typeKey}" aria-label="选择${type.label}">${devices.map((device) => `<option value="${escapeHtml(device.id)}" ${device.id === selected?.id ? "selected" : ""}>${escapeHtml(device.name)}</option>`).join("")}</select>`
: `<span class="v2-extension-no-device">尚未添加具体硬件</span>`;
return `<article class="v2-extension-type-card" data-extension-type="${typeKey}" data-extension-connected="${Boolean(selected)}">
<header><span class="v2-extension-type-icon"><i data-lucide="${type.icon}"></i></span><div><small>${type.label}</small>${selector}</div><strong>${learned}/${capabilities.length}</strong><button type="button" data-add-type="${typeKey}" title="添加${type.label}" aria-label="添加${type.label}"><i data-lucide="plus"></i></button>${selected ? `<button type="button" data-delete-extension-device="${escapeHtml(selected.id)}" title="删除当前玩具" aria-label="删除当前玩具"><i data-lucide="trash-2"></i></button>` : ""}</header>
<div class="v2-extension-capability-grid">${visible.map((capability) => renderCapability(capability, typeKey, Boolean(selected))).join("")}</div>
<footer><span>${devices.length} 台硬件 · ${capabilities.length} 个动作</span><div><button type="button" data-extension-page="${typeKey}" data-page-direction="-1" ${page === 0 ? "disabled" : ""}><i data-lucide="chevron-left"></i></button><b>${page + 1}/${totalPages}</b><button type="button" data-extension-page="${typeKey}" data-page-direction="1" ${page >= totalPages - 1 ? "disabled" : ""}><i data-lucide="chevron-right"></i></button></div></footer>
</article>`;
}
function renderCapability(capability, typeKey, hasDevice) {
const learned = capability.status === "learned" && validSlot(capability.slot);
if (!hasDevice) {
return `<button class="v2-extension-capability preview" type="button" data-add-type="${typeKey}"><span>${escapeHtml(capability.name)}</span><small>添加硬件后可学习</small><i>添加</i></button>`;
}
return `<div class="v2-extension-capability ${learned ? "learned" : "pending"}">
<button type="button" ${learned ? `data-run-extension-capability="${escapeHtml(capability.id)}"` : `data-learn-extension-capability="${escapeHtml(capability.id)}"`}><span>${escapeHtml(capability.name)}</span><small>${learned ? `IR ${String(capability.slot).padStart(3, "0")} · 已学习` : "待学习"}</small></button>
<div>${learned ? `<button type="button" data-run-extension-capability="${escapeHtml(capability.id)}">执行</button><button type="button" data-forget-extension-capability="${escapeHtml(capability.id)}" aria-label="忘记动作"><i data-lucide="x"></i></button>` : `<button type="button" data-learn-extension-capability="${escapeHtml(capability.id)}">学习</button>`}</div>
</div>`;
}
function renderControl() {
const learned = learnedCapabilities();
$("#extensionLearnedTotal").textContent = `${learned.length} 项可执行`;
if (!learned.length) {
$("#extensionLearnedGrid").innerHTML = `<div class="v2-extension-empty"><i data-lucide="book-open-check"></i><strong>还没有已学习动作</strong><span>切换到学习模式,为外设玩具录入遥控能力。</span><button type="button" data-switch-extension-mode="study">前往学习</button></div>`;
} else {
$("#extensionLearnedGrid").innerHTML = learned.map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
return `<button class="v2-extension-learned-action" type="button" data-run-extension-capability="${escapeHtml(capability.id)}" data-extension-type="${escapeHtml(device?.type || "other")}"><span><i data-lucide="${type.icon}"></i></span><div><small>${escapeHtml(device?.name || type.label)}</small><strong>${escapeHtml(capability.name)}</strong></div><i data-lucide="send"></i></button>`;
}).join("");
}
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => {
const execution = state.vehicleExecutions?.[device.id];
const executionState = execution?.state || "idle";
return `
<article class="v2-extension-vehicle-card" data-extension-vehicle-device="${escapeHtml(device.id)}" data-execution-state="${escapeHtml(executionState)}">
<header>
<span><i data-lucide="${device.icon}"></i></span>
<div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.code)}</small></div>
<b>${escapeHtml(device.channel)}</b>
</header>
<div class="v2-extension-parameter-list" aria-label="${escapeHtml(device.name)}可控参数">
${device.parameters.map((parameter) => `<span>${escapeHtml(parameter)}</span>`).join("")}
</div>
<footer>
<small>当前动作</small>
<strong data-extension-current-action>${escapeHtml(execution?.action || "等待指令")}</strong>
<span data-extension-action-detail>${escapeHtml(execution?.detail || "输入自然语言后在此显示执行结果")}</span>
</footer>
</article>`;
}).join("");
}
function findVehicleAction(command) {
for (const device of VEHICLE_DEVICES) {
const action = device.actions.find((item) => item.command === command);
if (action) return { device, action };
}
const inferredDeviceId = /^副驾/.test(command)
? "passenger-seat"
: /^seat driver /.test(command)
? "driver-seat"
: /^ac /.test(command)
? "climate"
: /^shade /.test(command)
? "roof"
: /^light /.test(command)
? "interior-light"
: /^window /.test(command)
? "windows"
: null;
const device = VEHICLE_DEVICES.find((item) => item.id === inferredDeviceId);
if (device) return { device, action: { name: command, command } };
return null;
}
function updateVehicleCardExecution(command, executionState, detail) {
const match = findVehicleAction(command);
if (!match) return;
const actionText = match.action.name || command;
state.vehicleExecutions ||= {};
state.vehicleExecutions[match.device.id] = {
state: executionState,
action: actionText,
detail,
command,
at: new Date().toISOString(),
};
const card = $(`[data-extension-vehicle-device="${match.device.id}"]`);
if (!card) return;
card.dataset.executionState = executionState;
const actionNode = card.querySelector("[data-extension-current-action]");
const detailNode = card.querySelector("[data-extension-action-detail]");
if (actionNode) actionNode.textContent = actionText;
if (detailNode) detailNode.textContent = detail;
}
function renderTypeChooser() {
$("#extensionTypeChooser").innerHTML = Object.entries(TOY_TYPES).map(([key, type]) => `
<button class="${key === selectedAddType ? "active" : ""}" type="button" data-extension-type-choice="${key}" data-extension-type="${key}"><span><i data-lucide="${type.icon}"></i></span><div><strong>${type.shortLabel}</strong><small>${type.description}</small></div></button>`).join("");
$("#extensionPresetPreview").innerHTML = TOY_TYPES[selectedAddType].presets
.map((item) => `<i>${escapeHtml(item.name)}</i>`)
.join("");
}
function openAdd(typeKey = "light") {
selectedAddType = TOY_TYPES[typeKey] ? typeKey : "other";
$("#extensionHardwareName").value = "";
renderTypeChooser();
openDialog("extensionAddDialog");
window.setTimeout(() => $("#extensionHardwareName")?.focus(), 80);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function addHardware(event) {
event.preventDefault();
const name = $("#extensionHardwareName").value.trim();
if (!name) return;
if (state.devices.some((item) => item.name === name)) {
showToast("已经存在同名外设玩具。", "error");
return;
}
const device = {
id: uid("toy"),
name,
type: selectedAddType,
aliases: [name],
createdAt: new Date().toISOString(),
};
state.devices.push(device);
state.capabilities.push(...seedPresets(device));
state.selectedDeviceByType[device.type] = device.id;
pages[device.type] = 0;
saveState();
closeDialog("extensionAddDialog");
renderAll();
showToast(`已添加“${name}”,可以开始学习遥控动作。`, "success");
}
function deleteHardware(deviceId) {
const device = state.devices.find((item) => item.id === deviceId);
if (!device || !window.confirm(`删除“${device.name}”及其全部前端能力记录?`)) return;
state.devices = state.devices.filter((item) => item.id !== deviceId);
state.capabilities = state.capabilities.filter((item) => item.deviceId !== deviceId);
state.selectedDeviceByType[device.type] = state.devices.find(
(item) => item.type === device.type,
)?.id || null;
saveState();
renderAll();
showToast(`已删除“${device.name}”。`);
}
function showLearnPage(page) {
$$('[data-extension-learn-page]').forEach((element) => {
element.hidden = Number(element.dataset.extensionLearnPage) !== page;
});
$$('[data-extension-learn-step]').forEach((element) => {
element.classList.toggle("active", Number(element.dataset.extensionLearnStep) <= page);
});
}
function resetLearnStatus() {
window.clearInterval(learning.countdownTimer);
learning = freshLearningSession();
$("#extensionLearnAnimation").dataset.state = "ready";
$("#extensionLearnStatusTitle").textContent = "准备接收遥控信号";
$("#extensionLearnStatusMessage").textContent = "请将遥控器对准学习器并按一下对应按键。";
$("#extensionLearnCountdown").textContent = "15";
$("#extensionStartLearn").disabled = false;
$("#extensionStartLearn").textContent = "开始接收";
$("#extensionCancelLearn").hidden = false;
$("#extensionTestEmit").disabled = false;
$("#extensionTestEmit").textContent = "发射试一下";
$("#extensionConfirmLearn").disabled = true;
}
function openLearn(capabilityId) {
const capability = state.capabilities.find((item) => item.id === capabilityId);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
resetLearnStatus();
learning.draft = { capabilityId: capability.id, deviceId: device.id, slot: allocateSlot() };
$("#extensionLearnTitle").textContent = `学习“${capability.name}”`;
$("#extensionLearnPath").textContent = `${type.label} · ${device.name}`;
$("#extensionLearnIcon").innerHTML = `<i data-lucide="${type.icon}"></i>`;
$("#extensionLearnDevice").textContent = device.name;
$("#extensionLearnAction").textContent = capability.name;
$("#extensionLearnAliases").value = (capability.aliases || []).join("、");
$("#extensionLearnRisk").value = capability.risk || "low";
$("#extensionLearnSlot").value = allocateSlot() || "";
showLearnPage(1);
openDialog("extensionLearnDialog");
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function collectLearningDraft() {
const capability = state.capabilities.find(
(item) => item.id === learning.draft?.capabilityId,
);
const device = state.devices.find((item) => item.id === capability?.deviceId);
const slot = Number($("#extensionLearnSlot").value || allocateSlot());
if (!capability || !device) throw new Error("预设动作不存在。");
if (!validSlot(slot)) throw new Error("没有可用红外槽位。");
const occupied = learnedCapabilities().find(
(item) => Number(item.slot) === slot && item.id !== capability.id,
);
if (occupied) throw new Error(`槽位 ${slot} 已被“${occupied.name}”使用。`);
return {
capabilityId: capability.id,
deviceId: device.id,
slot,
aliases: $("#extensionLearnAliases").value
.split(/[,,、;;]+/)
.map((item) => item.trim())
.filter(Boolean),
risk: $("#extensionLearnRisk").value,
};
}
async function sendGatewayCommand(action, payload = {}, signal = undefined) {
const result = await api(`gscu/${action}`, {
method: "POST",
body: JSON.stringify(payload),
signal,
});
state.lastProtocol = {
at: new Date().toISOString(),
action,
payload,
response: result,
};
saveState();
return result;
}
async function startLearning() {
if (learning.phase === "listening") return;
try {
learning.draft = collectLearningDraft();
} catch (error) {
showToast(error.message, "error");
showLearnPage(1);
return;
}
const capability = state.capabilities.find((item) => item.id === learning.draft.capabilityId);
const device = state.devices.find((item) => item.id === learning.draft.deviceId);
learning.phase = "listening";
learning.abortController = new AbortController();
$("#extensionLearnAnimation").dataset.state = "listening";
$("#extensionLearnStatusTitle").textContent = `正在学习“${capability.name}”…`;
$("#extensionLearnStatusMessage").textContent = `请将“${device.name}”的遥控器对准学习器。`;
$("#extensionStartLearn").disabled = true;
$("#extensionStartLearn").textContent = "正在接收";
let countdown = 15;
$("#extensionLearnCountdown").textContent = String(countdown);
learning.countdownTimer = window.setInterval(() => {
countdown -= 1;
$("#extensionLearnCountdown").textContent = countdown > 0 ? String(countdown) : "…";
if (countdown <= 0) window.clearInterval(learning.countdownTimer);
}, 1000);
try {
await sendGatewayCommand(
"learn",
{ slot: learning.draft.slot },
learning.abortController.signal,
);
window.clearInterval(learning.countdownTimer);
learning.phase = "learned";
$("#extensionLearnAnimation").dataset.state = "success";
await delay(300);
showLearnPage(3);
} catch (error) {
if (error.name === "AbortError") return;
window.clearInterval(learning.countdownTimer);
learning.phase = "idle";
$("#extensionLearnAnimation").dataset.state = "ready";
$("#extensionLearnStatusTitle").textContent = "这次没有捕获到按键";
$("#extensionLearnStatusMessage").textContent = error.message;
$("#extensionLearnCountdown").textContent = "";
$("#extensionStartLearn").disabled = false;
$("#extensionStartLearn").textContent = "重新接收";
showToast(error.message, "error");
}
}
async function cancelLearning() {
window.clearInterval(learning.countdownTimer);
learning.abortController?.abort();
try {
await sendGatewayCommand("cancel");
} catch (error) {
console.warn(error);
}
resetLearnStatus();
showLearnPage(1);
}
async function testLearning() {
if (!validSlot(learning.draft?.slot)) return;
const button = $("#extensionTestEmit");
button.disabled = true;
button.textContent = "正在发射…";
try {
await sendGatewayCommand("emit", { slot: learning.draft.slot });
learning.tested = true;
$("#extensionConfirmLearn").disabled = false;
$("#extensionTestTitle").textContent = "信号已发射,请确认玩具反应";
$("#extensionTestMessage").textContent = "动作正确即可完成学习;不正确请重新接收。";
showToast("红外信号已发射,请观察玩具。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "再发射一次";
}
}
function confirmLearning() {
if (!learning.tested || !learning.draft) return;
const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId,
);
if (!capability) return;
Object.assign(capability, {
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
risk: learning.draft.risk,
slot: Number(learning.draft.slot),
status: "learned",
verified: true,
enabled: true,
learnedAt: new Date().toISOString(),
});
state.sessionStartedAt ||= new Date().toISOString();
saveState();
closeDialog("extensionLearnDialog");
renderAll();
showToast(`“${capability.name}”已学习完成。`, "success");
learning = freshLearningSession();
}
function forgetCapability(capabilityId) {
const capability = state.capabilities.find((item) => item.id === capabilityId);
if (!capability || !window.confirm(`忘记动作“${capability.name}”?`)) return;
Object.assign(capability, {
slot: null,
status: "pending",
verified: false,
enabled: false,
learnedAt: null,
});
saveState();
renderAll();
showToast(`“${capability.name}”已恢复为待学习。`);
}
function peripheralCatalog() {
return learnedCapabilities().map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
return {
id: capability.id,
status: "learned",
device_id: device?.id,
device_name: device?.name,
device_type: device?.type,
type_label: type.label,
action: capability.name,
aliases: capability.aliases || [],
risk: capability.risk || "low",
};
}).filter((item) => item.device_id);
}
function vehicleCatalog() {
return VEHICLE_DEVICES.map((device) => ({
id: device.id,
name: device.name,
code: device.code,
channel: device.channel,
parameters: device.parameters,
actions: device.actions,
}));
}
function hydratePlan(steps) {
if (!Array.isArray(steps)) throw new Error("联动计划步骤格式无效。");
if (steps.length > MAX_PLAN_STEPS) throw new Error("联动计划步骤过多。");
const plan = steps.map((step) => {
if (!step || typeof step !== "object" || Array.isArray(step)) {
throw new Error("联动计划包含无效步骤。");
}
if (step.kind === "delay") {
if (!Number.isInteger(step.delay_ms)
|| step.delay_ms < MIN_PLAN_DELAY_MS
|| step.delay_ms > MAX_PLAN_DELAY_MS) {
throw new Error("延时步骤必须使用安全范围内的整数毫秒。");
}
if ([step.capability_id, step.command].some(
(value) => value != null && String(value).trim() !== "",
)) {
throw new Error("延时步骤不能包含设备命令。");
}
return {
kind: "delay",
delayMs: step.delay_ms,
label: step.delay_ms % 1000 === 0
? `等待 ${step.delay_ms / 1000} 秒`
: `等待 ${step.delay_ms} 毫秒`,
risk: "low",
};
}
if (step.delay_ms !== undefined && step.delay_ms !== 0) {
throw new Error("非延时步骤不能携带延时值。");
}
if (step.kind === "vehicle") {
const command = String(step.command || "").trim();
if (!command) throw new Error("车端计划步骤缺少命令。");
return {
kind: "vehicle",
command,
label: step.label || command,
risk: step.risk || "medium",
};
}
if (step.kind !== "peripheral") throw new Error("联动计划包含未知步骤。");
const capability = state.capabilities.find((item) => item.id === step.capability_id);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device || !learnedCapabilities(device.id).some(
(item) => item.id === capability.id,
)) throw new Error("联动计划引用了尚未学习的外设能力。");
return {
kind: "peripheral",
device,
capability,
label: `${device.name} · ${capability.name}`,
risk: capability.risk || "low",
};
});
let totalDelayMs = 0;
plan.forEach((step, index) => {
if (step.kind !== "delay") return;
if (index === 0 || index === plan.length - 1) {
throw new Error("延时步骤必须位于两个动作之间。");
}
if (plan[index - 1].kind === "delay") {
throw new Error("联动计划不能包含连续延时。");
}
totalDelayMs += step.delayMs;
});
if (totalDelayMs > MAX_TOTAL_PLAN_DELAY_MS) {
throw new Error("联动计划累计延时超过安全上限。");
}
return plan;
}
function setExecutionState(stateName, title, note, progress = 0) {
const card = $("#extensionExecutionState");
card.dataset.state = stateName;
$("#extensionRunTitle").textContent = title;
$("#extensionRunNote").textContent = note;
$("#extensionRunProgressBar").style.width = `${Math.max(0, Math.min(100, progress))}%`;
}
function renderRunPlan(plan, active = -1) {
const rail = $("#extensionRunPlan");
rail.hidden = false;
rail.innerHTML = plan.map((step, index) => `
<span class="${index < active ? "done" : index === active ? "active" : ""}"><i>${String(index + 1).padStart(2, "0")}</i>${escapeHtml(step.label || "待执行动作")}</span>`).join("");
}
async function executeInstruction(text) {
const input = String(text || "").trim();
if (!input) {
showToast("请先输入要执行的动作。", "error");
return;
}
if (isPlanning || isExecuting) {
showToast("上一条指令还在处理,请稍等。", "error");
return;
}
const button = $("#extensionExecuteButton");
isPlanning = true;
button.disabled = true;
setExecutionState("running", "GPT 正在理解意图", "匹配已学习能力与车端能力", 10);
try {
const result = await api("assistant/plan", {
method: "POST",
body: JSON.stringify({
utterance: input,
peripherals: peripheralCatalog(),
vehicle_devices: vehicleCatalog(),
}),
});
if (result.needs_confirmation) {
setExecutionState("warning", "需要补充目标", result.question || "请说明具体硬件", 0);
showToast(result.question || "请说明具体硬件。", "error");
return;
}
const plan = hydratePlan(result.steps);
if (!plan.length) {
setExecutionState("warning", "没有可执行动作", result.summary || "未匹配到能力", 0);
showToast(result.summary || "没有找到可执行动作。", "error");
return;
}
const risky = plan.filter((item) => item.risk === "high");
if (risky.length && !window.confirm(
`将执行 ${risky.length} 个可能让玩具移动的动作,请确认周围安全。`,
)) {
setExecutionState("idle", "已取消执行", "高风险动作未获确认", 0);
return;
}
isPlanning = false;
await executePlan(plan);
} catch (error) {
setExecutionState("error", "联动计划生成失败", error.message, 0);
showToast(error.message, "error");
} finally {
isPlanning = false;
button.disabled = false;
}
}
function vehicleExecutionSucceeded(result) {
const operations = Array.isArray(result?.operation_results)
? result.operation_results
: [];
const everyOperationSucceeded = operations.length > 0
&& operations.every((item) => item?.status === "succeeded");
const hardwareRequest = result?.execution_mode === "hardware";
const feedback = Array.isArray(result?.physical_feedback)
? result.physical_feedback
: [];
const everyHardwareOperationConfirmed = !hardwareRequest || (
result?.physical_feedback_confirmed === true
&& feedback.length === operations.length
&& feedback.every((item) => item?.confirmed === true)
);
return result?.success === true
&& result?.status === "succeeded"
&& everyOperationSucceeded
&& everyHardwareOperationConfirmed;
}
function synchronizeVehicleExecution(command, result) {
if (!result || typeof result !== "object") return;
const completed = vehicleExecutionSucceeded(result);
state.lastVehicleExecution = {
at: new Date().toISOString(),
command,
success: completed,
status: result.status || "unknown",
executionMode: result.execution_mode || null,
requestId: result.request_id || null,
operationResults: result.operation_results || [],
executionReport: result.execution_report || null,
domainState: result.domain_state || null,
actuatorState: result.actuator_state || null,
physicalState: result.physical_state || null,
physicalFeedback: result.physical_feedback || [],
physicalFeedbackConfirmed: result.physical_feedback_confirmed === true,
conversationId: result.conversation_id || null,
conversationTurn: result.conversation_turn ?? null,
};
renderAuthoritativeVehicleState(result);
const operationCount = Array.isArray(result.operation_results)
? result.operation_results.length
: 0;
const physicalCount = Array.isArray(result.physical_feedback)
? result.physical_feedback.filter((item) => item?.confirmed === true).length
: 0;
const detail = completed
? result.execution_mode === "hardware"
? `物理回读已确认 · ${physicalCount || operationCount} 项`
: `安全演练已完成 · ${operationCount || 1} 项`
: vehicleExecutionError(result);
updateVehicleCardExecution(command, completed ? "succeeded" : "failed", detail);
saveState();
window.dispatchEvent(new CustomEvent("cognitive-agent:extension-vehicle-result", {
detail: result,
}));
}
function vehicleExecutionError(result) {
const failed = (result?.operation_results || []).find(
(item) => item?.status !== "succeeded",
);
return result?.message
|| failed?.message
|| `车端动作未成功完成(${result?.status || "unknown"})`;
}
async function executeVehicle(command) {
let result;
try {
result = await api("vehicle/execute", {
method: "POST",
// Channel, seat target, provider, vehicle profile and execution mode
// are intentionally absent: the server owns CH0/CH1 routing and safety policy.
body: JSON.stringify({ command, conversation_id: sharedConversationId() }),
});
} catch (error) {
if (error.payload) synchronizeVehicleExecution(command, error.payload);
throw error;
}
synchronizeVehicleExecution(command, result);
if (!vehicleExecutionSucceeded(result)) {
const error = new Error(vehicleExecutionError(result));
error.payload = result;
throw error;
}
return result;
}
async function executePlan(plan) {
if (isExecuting || !plan.length) return;
isExecuting = true;
renderRunPlan(plan);
let completed = 0;
let vehicleDryRun = false;
let vehicleHardwareFeedback = 0;
for (let index = 0; index < plan.length; index += 1) {
const step = plan[index];
renderRunPlan(plan, index);
setExecutionState(
"running",
step.label,
`正在处理第 ${index + 1} / ${plan.length} 个动作`,
(index / plan.length) * 100,
);
try {
if (step.kind === "delay") {
await delay(step.delayMs);
} else if (step.kind === "vehicle") {
updateVehicleCardExecution(step.command, "running", `正在执行:${step.label || step.command}`);
saveState();
const result = await executeVehicle(step.command);
vehicleDryRun ||= result.execution_mode === "dry_run";
if (result.execution_mode === "hardware") {
vehicleHardwareFeedback += result.physical_feedback.length;
}
} else {
await sendGatewayCommand("emit", { slot: step.capability.slot });
}
completed += 1;
addHistory(step, true);
} catch (error) {
addHistory(step, false, error.message);
setExecutionState("error", "联动执行已中断", error.message, (completed / plan.length) * 100);
showToast(error.message, "error");
break;
}
const nextStep = plan[index + 1];
if (nextStep && step.kind !== "delay" && nextStep.kind !== "delay") {
await delay(state.settings.actionDelayMs);
}
}
renderRunPlan(plan, completed);
if (completed === plan.length) {
setExecutionState(
"success",
"联动计划已全部处理",
vehicleDryRun
? "外设已发射,车端处于安全演练模式"
: vehicleHardwareFeedback > 0
? `车端 ${vehicleHardwareFeedback} 项动作均已同步物理反馈`
: "外设与车端链路处理完成",
100,
);
showToast("联动计划已全部处理。", "success");
}
isExecuting = false;
}
function addHistory(step, success, error = null) {
state.history.unshift({
id: uid("history"),
at: new Date().toISOString(),
kind: step.kind || "peripheral",
deviceId: step.device?.id || null,
capabilityName: step.label || step.capability?.name || step.command,
slot: step.capability?.slot || null,
success,
error,
});
state.history = state.history.slice(0, HISTORY_LIMIT);
saveState();
}
async function runCapability(capabilityId) {
const capability = state.capabilities.find((item) => item.id === capabilityId);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
if (capability.risk === "high" && !window.confirm(
`“${capability.name}”可能让玩具移动,请确认周围安全。`,
)) return;
await executePlan([{
kind: "peripheral",
device,
capability,
label: `${device.name} · ${capability.name}`,
risk: capability.risk || "low",
}]);
}
async function requestHealth() {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), HEALTH_REQUEST_TIMEOUT_MS);
try {
return await api("health", { signal: controller.signal });
} finally {
window.clearTimeout(timeoutId);
}
}
function refreshHealth(showMessage = false) {
healthToastRequested ||= showMessage;
if (healthRefreshPromise) return healthRefreshPromise;
healthRefreshPromise = (async () => {
if (!healthState || healthToastRequested) {
$("#extensionStudyHealth").dataset.state = "checking";
$("#extensionStudyHealthState").textContent = "CHECK";
$("#extensionVehicleCard").dataset.state = "checking";
$("#extensionVehicleState").textContent = "CHECK";
}
try {
healthState = await requestHealth();
} catch (error) {
healthState = {
connected: false,
listening: false,
transport: "unavailable",
message: error.message,
gpt: { configured: false },
vehicle: { connected: false },
};
}
const connected = Boolean(healthState.connected);
const listening = !connected && Boolean(healthState.listening);
const transport = String(healthState.transport || "--").toUpperCase();
let connectionLabel;
if (connected) {
const peer = String(healthState.peer || "").trim();
connectionLabel = healthState.transport === "simulation"
? "模拟学习器已就绪"
: `${transport} 学习器已连接${peer ? ` · ${peer}` : ""}`;
} else if (listening) {
connectionLabel = `${transport} 监听已就绪,等待 GSCU1B 接入`;
} else {
connectionLabel = healthState.message || "GSCU1B 尚未连接";
}
const studyHealthText = $("#extensionStudyHealthText");
studyHealthText.textContent = connectionLabel;
studyHealthText.title = healthState.message || connectionLabel;
$("#extensionStudyHealthState").textContent = connected
? "READY"
: (listening ? "WAIT" : "OFFLINE");
$("#extensionStudyHealth").dataset.state = connected
? "online"
: (listening ? "checking" : "offline");
const vehicleConnected = Boolean(healthState.vehicle?.connected);
$("#extensionVehicleState").textContent = vehicleConnected ? "READY" : "OFFLINE";
$("#extensionVehicleCard").dataset.state = vehicleConnected ? "online" : "offline";
const connectionText = $("#extensionConnectionText");
connectionText.textContent = connectionLabel;
connectionText.title = healthState.message || connectionLabel;
$(".v2-extension-footer").classList.toggle("offline", !connected);
$("#extensionModelName").textContent = healthState.gpt?.configured
? healthState.gpt.model || "GPT"
: "GPT 未配置";
$("#extensionModelState").textContent = healthState.gpt?.configured ? "READY" : "LOCAL";
$("#extensionModelCard").classList.toggle("unavailable", !healthState.gpt?.configured);
if (healthToastRequested) {
const ready = [connected, healthState.gpt?.configured, healthState.vehicle?.connected]
.filter(Boolean).length;
showToast(`自扩展链路:${ready} / 3 项就绪。`, ready === 3 ? "success" : "error");
}
healthToastRequested = false;
return healthState;
})().finally(() => {
healthRefreshPromise = null;
});
return healthRefreshPromise;
}
function startHealthPolling() {
if (healthPollTimer !== null) return;
healthPollTimer = window.setInterval(() => {
if (document.visibilityState === "visible"
&& document.body.dataset.module === "self-extension") refreshHealth();
}, HEALTH_POLL_INTERVAL_MS);
}
function stopHealthPolling() {
if (healthPollTimer !== null) window.clearInterval(healthPollTimer);
healthPollTimer = null;
}
async function resetSession() {
const button = $("#extensionConfirmReset");
button.disabled = true;
button.textContent = "正在擦除学习器…";
try {
await sendGatewayCommand("erase", { confirm: "ERASE_ALL" });
state.capabilities = state.devices.flatMap((device) => seedPresets(device));
state.history = [];
state.sessionStartedAt = new Date().toISOString();
saveState();
closeDialog("extensionResetDialog");
renderAll();
showToast("学习器已擦除,所有动作恢复为待学习。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
button.textContent = "擦除并重新开始";
button.disabled = $("#extensionResetConfirm").value.trim() !== "清空";
}
}
function bindEvents() {
$$('[data-extension-mode]').forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
});
$("#extensionHardwareGrid").addEventListener("click", (event) => {
const add = event.target.closest("[data-add-type]");
if (add) return openAdd(add.dataset.addType);
const pageButton = event.target.closest("[data-extension-page]");
if (pageButton) {
const typeKey = pageButton.dataset.extensionPage;
pages[typeKey] = Math.max(
0,
(pages[typeKey] || 0) + Number(pageButton.dataset.pageDirection),
);
renderHardwareGrid();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return;
}
const remove = event.target.closest("[data-delete-extension-device]");
if (remove) return deleteHardware(remove.dataset.deleteExtensionDevice);
const forget = event.target.closest("[data-forget-extension-capability]");
if (forget) return forgetCapability(forget.dataset.forgetExtensionCapability);
const learn = event.target.closest("[data-learn-extension-capability]");
if (learn) return openLearn(learn.dataset.learnExtensionCapability);
const run = event.target.closest("[data-run-extension-capability]");
if (run) runCapability(run.dataset.runExtensionCapability);
});
$("#extensionHardwareGrid").addEventListener("change", (event) => {
const selector = event.target.closest("[data-extension-device-select]");
if (!selector) return;
state.selectedDeviceByType[selector.dataset.extensionDeviceSelect] = selector.value;
pages[selector.dataset.extensionDeviceSelect] = 0;
saveState();
renderHardwareGrid();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
});
$("#extensionTypeChooser").addEventListener("click", (event) => {
const choice = event.target.closest("[data-extension-type-choice]");
if (!choice) return;
selectedAddType = choice.dataset.extensionTypeChoice;
renderTypeChooser();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
});
$("#extensionAddForm").addEventListener("submit", addHardware);
$("#extensionContinueLearn").addEventListener("click", () => {
try {
learning.draft = collectLearningDraft();
showLearnPage(2);
} catch (error) {
showToast(error.message, "error");
}
});
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", confirmLearning);
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
}
});
$("#extensionLearnedGrid").addEventListener("click", (event) => {
const switcher = event.target.closest("[data-switch-extension-mode]");
if (switcher) return setMode(switcher.dataset.switchExtensionMode);
const run = event.target.closest("[data-run-extension-capability]");
if (run) runCapability(run.dataset.runExtensionCapability);
});
$("#extensionExecuteButton").addEventListener(
"click",
() => executeInstruction($("#extensionUtterance").value),
);
$("#extensionUtterance").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
executeInstruction(event.currentTarget.value);
}
});
$("#extensionFooterRefresh").addEventListener("click", () => refreshHealth(true));
$("#extensionResetButton").addEventListener("click", () => {
$("#extensionResetConfirm").value = "";
$("#extensionConfirmReset").disabled = true;
openDialog("extensionResetDialog");
});
$("#extensionResetConfirm").addEventListener("input", (event) => {
$("#extensionConfirmReset").disabled = event.currentTarget.value.trim() !== "清空";
});
$("#extensionConfirmReset").addEventListener("click", resetSession);
$$('[data-close-extension-dialog]').forEach((button) => {
button.addEventListener("click", async () => {
if (button.dataset.closeExtensionDialog === "extensionLearnDialog"
&& learning.phase === "listening") await cancelLearning();
closeDialog(button.dataset.closeExtensionDialog);
});
});
window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-extension") {
setMode(activeMode);
refreshHealth();
} else {
syncSharedTwinMount();
}
});
window.addEventListener("cognitive-agent:result", (event) => {
renderAuthoritativeVehicleState(event.detail);
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible"
&& document.body.dataset.module === "self-extension") refreshHealth();
});
window.addEventListener("pagehide", stopHealthPolling);
window.addEventListener("pageshow", () => {
startHealthPolling();
if (document.visibilityState === "visible"
&& document.body.dataset.module === "self-extension") refreshHealth();
});
}
function init() {
if (!buildShell()) return;
bindEvents();
renderAll();
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
startHealthPolling();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
document.addEventListener("DOMContentLoaded", init);
}());