Files
Cognitive_Agent/apps/web-console/self-extension.js
T
2026-09-21 16:01:42 +08:00

4703 lines
209 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 SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1";
const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const PERIPHERAL_CARD_UI_KEY = "cognitive-agent.extension-peripheral-cards.v1";
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 NFC_POLL_INTERVAL_MS = 700;
const LEGACY_PROFILE_KEY = "ir-lab-profiles-v2";
const LEGACY_PROFILE_BRIDGE_URLS = [
"http://localhost:3001/n7web-profile-bridge.html",
"http://127.0.0.1:3001/n7web-profile-bridge.html",
];
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 };
}
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 = "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 nfcSnapshot = {
revision: -1,
reader: { online: false, state: "starting", port: "auto", error: null },
card: null,
legacyProfiles: [],
};
let extensionToySnapshot = {
revision: -1,
session_id: null,
state: "stopped",
topic: "extension/toy",
burst_published_count: 0,
detected_nfc_ids: [],
detection_count: 0,
active_nfc_id: null,
last_error: null,
};
let peripheralCatalogRevision = null;
let peripheralCatalogSyncQueue = Promise.resolve();
let durablePeripheralCatalog = [];
let durablePeripheralCatalogHydrated = false;
let petCardSnapshot = {
revision: -1,
cards: [],
publisher: {
state: "stopped",
topic: "n7/pet",
burst_count: 3,
burst_window_ms: 1000,
burst_attempt_count: 0,
burst_published_count: 0,
active_pet_id: null,
},
};
let selectedPetCardId = null;
const petCardDrafts = new Map();
let nfcPollTimer = null;
let nfcDialogFingerprint = "";
let nfcDialogDirty = true;
let unifiedExtensionFingerprint = "";
let unifiedExtensionDirty = true;
let gscuHealthFingerprint = "";
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set();
let renderUnifiedExtensionView = null;
const hiddenPeripheralDevices = new Map();
let expandedPeripheralDeviceId = null;
let peripheralCardSessionId = "";
let runningPeripheralQuickAction = false;
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()}`;
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 infraredSignalFromResponse(value) {
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
if (["ir_data", "irData", "infraredData"].includes(key)) {
if (typeof child === "string" && child.trim().length > 4) return child.trim();
if (Array.isArray(child) && child.length) return JSON.stringify(child);
}
const nested = infraredSignalFromResponse(child);
if (nested) return nested;
}
return null;
}
async function infraredFingerprint(signal) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(signal),
);
return Array.from(new Uint8Array(digest))
.slice(0, 6)
.map((value) => value.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
}
function validSlot(value) {
const slot = Number(value);
return Number.isInteger(slot) && slot >= SLOT_MIN && slot <= SLOT_MAX;
}
function initialState() {
return {
version: 5,
devices: [],
capabilities: [],
selectedDeviceByType: {},
sessionStartedAt: null,
settings: { actionDelayMs: 520 },
history: [],
lastProtocol: null,
vehicleExecutions: {},
infraredCommands: [],
};
}
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 : [];
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.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => (
validSlot(item?.slot)
&& (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
)
)).map(normalizedInfraredSlotRecord)
: [];
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);
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,
fingerprint: null,
};
}
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">
<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 class="v2-module-heading"><h1>外设玩具能力学习</h1></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 class="v2-module-heading"><h1>外设玩具与车端联动</h1></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><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><h2>车端设备动作</h2></div><strong>同源安全编排</strong></header>
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
</section>
</div>
<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>
</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>
<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>`;
const homepageHeader = moduleSwitcher.closest(".v2-mode-rail") || moduleSwitcher;
homepageHeader.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><h2>添加外设玩具</h2></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><h2 id="extensionLearnTitle">学习遥控动作</h2></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><h2>重置学习</h2></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>
<dialog class="v2-extension-dialog v2-gscu-tcp-dialog" id="extensionGscuTcpDialog">
<header><div><h2>红外学习器 TCP 控制</h2></div><button type="button" data-close-extension-dialog="extensionGscuTcpDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-gscu-status" id="extensionGscuTcpStatus" data-state="checking"><i data-lucide="radio-tower"></i><div><strong>正在读取连接状态</strong><small>--</small></div><b>CHECK</b></section>
<form id="extensionGscuTcpForm" class="v2-gscu-config-form">
<label><span>本机监听 IP</span><input id="extensionGscuTcpHost" inputmode="decimal" maxlength="45" placeholder="0.0.0.0" aria-describedby="extensionGscuTcpHostHint" required /><small class="visually-hidden" id="extensionGscuTcpHostHint">填写 0.0.0.0 可监听所有网卡;模块端应填写这台 Linux 的实际局域网 IP。</small></label>
<label><span>监听端口</span><input id="extensionGscuTcpPort" type="number" min="1" max="65535" value="4444" aria-describedby="extensionGscuTcpPortHint" required /><small class="visually-hidden" id="extensionGscuTcpPortHint">必须与 GSCU1B 自定义 TCP 服务器端口一致。</small></label>
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form>
<section class="v2-gscu-actions">
<header><div><strong>TCP 动作调试</strong></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>设备 ID</span><input id="extensionGscuDeviceId" maxlength="64" placeholder="toy-light-001" /></label><label><span>控制对象名称</span><input id="extensionGscuObjectNames" maxlength="240" placeholder="闪烁灯、闪光灯、闪灯玩具" /></label><label><span>执行动作</span><input id="extensionGscuActionNames" maxlength="240" placeholder="打开、开启、启动" /></label></header>
<div class="v2-gscu-action-grid">
<button type="button" data-gscu-action="info">查询设备</button>
<button type="button" data-gscu-action="learn">开始学习</button>
<button type="button" data-gscu-action="cancel">取消学习</button>
<button type="button" data-gscu-action="emit">发射槽位</button>
<button type="button" data-gscu-action="read">读取槽位</button>
<button type="button" data-gscu-action="restart">软重启</button>
</div>
<details class="v2-gscu-advanced">
<summary>高级动作</summary>
<label><span>原始红外码</span><textarea id="extensionGscuIrData" rows="3" placeholder="粘贴 read 返回的 ir_data"></textarea></label>
<button type="button" data-gscu-action="write">写入当前槽位</button>
<label><span>WiFi 配网锁</span><select id="extensionGscuWifiLock"><option value="1">开启(1)</option><option value="0">关闭(0)</option></select></label>
<button type="button" data-gscu-action="wifi-lock">设置配网锁</button>
<label><span>原始 JSON</span><textarea id="extensionGscuRawCommand" rows="4" placeholder='例如 {"type":"info"}'></textarea></label>
<button type="button" data-gscu-action="raw">发送原始 JSON</button>
<label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong></header><div id="extensionGscuNamedCommands"></div></section>
<details class="v2-gscu-result" id="extensionGscuTcpResultDetails"><summary>JSON 调试结果</summary><pre id="extensionGscuTcpResult">等待操作</pre></details>
</section>
</dialog>
<dialog class="v2-extension-dialog v2-pet-settings-dialog" id="extensionPetSettingsDialog" aria-labelledby="extensionPetSettingsTitle" aria-describedby="extensionPetSettingsDescription">
<header><div><h2 id="extensionPetSettingsTitle">宠物设置</h2><p class="visually-hidden" id="extensionPetSettingsDescription">添加或编辑宠物资料;保存后可在 NFC 设备授权中绑定卡片。</p></div><button type="button" data-close-extension-dialog="extensionPetSettingsDialog" data-dialog-close aria-label="关闭宠物设置"><i data-lucide="x"></i></button></header>
<div class="v2-pet-settings-body" id="extensionPetSettingsBody"></div>
<footer class="v2-pet-settings-footnote"><i data-lucide="database"></i><span>宠物资料由后端永久保存,只有在此处手动删除才会移除。</span></footer>
</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 renderGscuTcpDialog(health = healthState || {}, refreshControls = true) {
const status = $("#extensionGscuTcpStatus");
if (!status) return;
const connected = Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
const listenHost = String(health.listen_host || "0.0.0.0");
const listenPort = Number(health.port || 4444);
if (refreshControls) {
$("#extensionGscuTcpHost").value = listenHost;
$("#extensionGscuTcpPort").value = Number.isInteger(listenPort) ? String(listenPort) : "4444";
}
status.dataset.state = connected ? "online" : listening ? "listening" : "offline";
$("strong", status).textContent = connected
? "GSCU1B TCP 已连接"
: listening
? "TCP 监听已就绪"
: "TCP 监听尚未就绪";
$("small", status).textContent = connected
? `${health.peer || "设备会话"} → ${health.listen || `${listenHost}:${listenPort}`}`
: health.message || `等待 GSCU1B 接入 ${listenHost}:${listenPort}`;
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) {
renderGscuNamedCommands();
syncGscuCommandNamesFromSlot();
}
}
function namedInfraredCommands() {
state.infraredCommands ||= [];
return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot));
}
function normalizedInfraredNames(value) {
const values = Array.isArray(value)
? value
: String(value || "").split(/[,,、;;|]+/);
return [...new Set(values
.map((item) => String(item || "").trim().slice(0, 48))
.filter(Boolean))].slice(0, 24);
}
function normalizedInfraredDeviceId(value) {
return String(value || "").trim().slice(0, 64);
}
function normalizedInfraredSlotRecord(item) {
const objectNames = normalizedInfraredNames(item.object_names ?? item.objectNames);
const actionNames = normalizedInfraredNames(item.action_names ?? item.actionNames);
const legacyName = String(item.name || "").trim().slice(0, 48);
return {
slot: Number(item.slot),
deviceId: normalizedInfraredDeviceId(item.device_id ?? item.deviceId),
name: legacyName || `${objectNames[0] || "未命名对象"} · ${actionNames[0] || "未命名动作"}`,
objectNames,
actionNames: actionNames.length || objectNames.length ? actionNames : normalizedInfraredNames(legacyName),
source: String(item.source || "tcp"),
learnedAt: item.learned_at || item.learnedAt || null,
updatedAt: item.updated_at || item.updatedAt || null,
};
}
function infraredDeviceGroups() {
const groups = new Map();
namedInfraredCommands().forEach((command) => {
const deviceId = normalizedInfraredDeviceId(command.deviceId);
const key = deviceId || "";
if (!groups.has(key)) {
groups.set(key, { deviceId, objectNames: [], commands: [] });
}
const group = groups.get(key);
group.commands.push(command);
group.objectNames = normalizedInfraredNames([
...group.objectNames,
...command.objectNames,
]);
});
return [...groups.values()].sort((left, right) => {
if (!left.deviceId) return 1;
if (!right.deviceId) return -1;
return left.deviceId.localeCompare(right.deviceId);
});
}
function reconcileDevicesAndCapabilitiesFromSlots() {
const configuredKeys = new Set();
infraredDeviceGroups().filter((group) => group.deviceId).forEach((group) => {
let device = state.devices.find((item) => item.id === group.deviceId);
if (!device) {
device = {
id: group.deviceId,
name: group.objectNames[0] || group.deviceId,
type: "other",
aliases: group.objectNames.length ? group.objectNames : [group.deviceId],
toyNumber: null,
cardUid: null,
createdAt: new Date().toISOString(),
source: "infrared_slot_catalog",
};
state.devices.push(device);
} else {
device.name = group.objectNames[0] || device.name || group.deviceId;
device.aliases = group.objectNames.length ? group.objectNames : [device.name];
}
group.commands.forEach((command) => {
const key = `${group.deviceId}\u0000${command.slot}`;
configuredKeys.add(key);
let capability = state.capabilities.find((item) => (
item.deviceId === group.deviceId && Number(item.slot) === Number(command.slot)
));
if (!capability) {
capability = {
id: `ir-slot:${group.deviceId}:${command.slot}`,
deviceId: group.deviceId,
key: `slot_${command.slot}`,
createdAt: new Date().toISOString(),
enabled: true,
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: group.deviceId,
name: command.actionNames[0] || command.name,
aliases: command.actionNames.length ? command.actionNames : [command.name],
category: "infrared_slot",
risk: capability.risk || "low",
slot: Number(command.slot),
status: "learned",
verified: true,
enabled: capability.enabled !== false,
signalSource: "slot_catalog",
});
});
});
state.capabilities = state.capabilities.filter((capability) => {
if (!["slot_catalog", "backend_catalog"].includes(capability.signalSource)) return true;
return configuredKeys.has(`${capability.deviceId}\u0000${Number(capability.slot)}`);
});
}
function configuredCapabilities(deviceId = null) {
const configuredKeys = new Set(
namedInfraredCommands()
.filter((command) => command.deviceId)
.map((command) => `${command.deviceId}\u0000${Number(command.slot)}`),
);
return state.capabilities.filter((item) => (
(!deviceId || item.deviceId === deviceId)
&& item.status === "learned"
&& item.verified
&& validSlot(item.slot)
&& configuredKeys.has(`${item.deviceId}\u0000${Number(item.slot)}`)
));
}
function replaceInfraredSlotCatalog(items) {
state.infraredCommands = (Array.isArray(items) ? items : [])
.filter((item) => validSlot(item?.slot) && (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
))
.map(normalizedInfraredSlotRecord)
.sort((left, right) => left.slot - right.slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
syncGscuCommandNamesFromSlot();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
}
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
const shouldMigrate = migrateLegacy
&& localStorage.getItem(SLOT_CATALOG_MIGRATION_KEY) !== "done";
const legacy = shouldMigrate ? namedInfraredCommands() : [];
let result = await api("gscu/slots");
const serverSlots = Array.isArray(result.slots) ? result.slots : [];
const existingSlots = new Set(serverSlots.map((item) => Number(item.slot)));
const missing = legacy.filter((item) => !existingSlots.has(Number(item.slot)));
if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
method: "PUT",
body: JSON.stringify(
item.deviceId && item.objectNames?.length && item.actionNames?.length
? {
device_id: item.deviceId,
object_names: item.objectNames,
action_names: item.actionNames,
source: item.source || "browser-migration",
}
: { name: item.name, source: item.source || "browser-migration" },
),
})));
result = await api("gscu/slots");
}
if (shouldMigrate) localStorage.setItem(SLOT_CATALOG_MIGRATION_KEY, "done");
replaceInfraredSlotCatalog(result.slots);
return namedInfraredCommands();
}
async function upsertNamedInfraredCommand(
slot,
deviceId,
objectNames,
actionNames,
source = "tcp",
) {
const normalizedSlot = Number(slot);
const normalizedDeviceId = normalizedInfraredDeviceId(deviceId);
const normalizedObjects = normalizedInfraredNames(objectNames);
const normalizedActions = normalizedInfraredNames(actionNames);
if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。");
if (!normalizedDeviceId) throw new Error("请填写唯一的设备 ID。");
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalizedDeviceId)) {
throw new Error("设备 ID 必须以字母或数字开头,且只能包含字母、数字、点、下划线、冒号或连字符。");
}
if (!normalizedObjects.length) throw new Error("请至少填写一个控制对象名称。");
if (!normalizedActions.length) throw new Error("请至少填写一个执行动作。");
const result = await api(`gscu/slots/${normalizedSlot}`, {
method: "PUT",
body: JSON.stringify({
device_id: normalizedDeviceId,
object_names: normalizedObjects,
action_names: normalizedActions,
source,
}),
});
const value = normalizedInfraredSlotRecord(result.slot);
const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value);
reconcileDevicesAndCapabilitiesFromSlots();
const boundDevice = state.devices.find((item) => item.id === value.deviceId);
saveState();
renderGscuNamedCommands();
if (boundDevice) {
renderAll();
await syncPeripheralCatalog(boundDevice);
}
return value;
}
function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands");
if (!container) return;
const groups = infraredDeviceGroups();
container.innerHTML = groups.length
? groups.map((group) => `<section class="v2-gscu-device-group" data-device-id="${escapeHtml(group.deviceId)}"><header><div><small>${group.deviceId ? "DEVICE ID" : "UNASSIGNED"}</small><strong>${escapeHtml(group.deviceId || "未分配设备 ID")}</strong><span>${escapeHtml(group.objectNames.join("、") || "尚无控制对象描述")}</span></div><b>${group.commands.length} 条命令</b></header><div class="v2-gscu-device-command-grid">${group.commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>设备 ID</span><input data-gscu-device-id maxlength="64" value="${escapeHtml(command.deviceId)}" placeholder="唯一设备 ID" aria-label="IR ${command.slot} 设备 ID" /></label><label><span>控制对象名称 / 别名</span><input data-gscu-object-names maxlength="240" value="${escapeHtml(command.objectNames.join("、"))}" placeholder="用逗号分隔多个名称" aria-label="IR ${command.slot} 控制对象名称" /></label><label><span>执行动作 / 别名</span><input data-gscu-action-names maxlength="240" value="${escapeHtml(command.actionNames.join("、"))}" placeholder="颜色等动作需包含结果值" aria-label="IR ${command.slot} 执行动作" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存设置</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")}</div></section>`).join("")
: "<p>学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。</p>";
}
function syncGscuCommandNamesFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuDeviceId")) {
$("#extensionGscuDeviceId").value = command?.deviceId || "";
}
if ($("#extensionGscuObjectNames")) {
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
}
if ($("#extensionGscuActionNames")) {
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
}
}
function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health);
$("#extensionGscuTcpResultDetails")?.removeAttribute("open");
openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function showGscuResult(value) {
const output = $("#extensionGscuTcpResult");
if (!output) return;
output.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
async function configureGscuTcp(event) {
event.preventDefault();
const button = $("#extensionGscuTcpApply");
button.disabled = true;
button.textContent = "正在重启监听…";
try {
const result = await api("gscu/config", {
method: "POST",
body: JSON.stringify({
listen_host: $("#extensionGscuTcpHost").value.trim(),
port: Number($("#extensionGscuTcpPort").value),
}),
});
healthState = result;
renderGscuTcpDialog(result, false);
showGscuResult(result);
showToast(`TCP 已切换到 ${result.listen || `${result.listen_host}:${result.port}`}`, "success");
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "应用并开始监听";
}
}
async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction;
const slot = Number($("#extensionGscuSlot").value);
const deviceId = normalizedInfraredDeviceId($("#extensionGscuDeviceId").value);
const objectNames = normalizedInfraredNames($("#extensionGscuObjectNames").value);
const actionNames = normalizedInfraredNames($("#extensionGscuActionNames").value);
const payload = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
if (action === "wifi-lock") payload.state = Number($("#extensionGscuWifiLock").value);
if (action === "raw") {
try {
payload.command = JSON.parse($("#extensionGscuRawCommand").value);
} catch (_error) {
showToast("原始指令不是有效 JSON。", "error");
return;
}
}
const confirmation = $("#extensionGscuDangerConfirm").value.trim();
if (action === "erase") {
const count = namedInfraredCommands().length;
const confirmed = window.confirm(
`确认擦除 GSCU1B 中的全部红外码吗?本机数据库中的 ${count} 条槽位记录也会被永久清空,操作无法恢复。`,
);
if (!confirmed) return;
payload.confirm = "ERASE_ALL";
}
if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !deviceId) {
showToast("请输入该红外槽位对应的唯一设备 ID。", "error");
$("#extensionGscuDeviceId").focus();
return;
}
if (action === "learn" && !objectNames.length) {
showToast("请输入该红外槽位对应的控制对象名称。", "error");
$("#extensionGscuObjectNames").focus();
return;
}
if (action === "learn" && !actionNames.length) {
showToast("请输入该红外槽位对应的执行动作。", "error");
$("#extensionGscuActionNames").focus();
return;
}
button.disabled = true;
showGscuResult(`正在执行 ${action}…`);
try {
const endpoint = action === "erase" ? "gscu/erase-all" : `gscu/${action}`;
let result = await api(endpoint, {
method: "POST",
body: JSON.stringify(payload),
});
if (action === "learn") {
showToast("学习指令已下发,请立即短按遥控器目标按键。", "success");
let readResult = null;
let learnedSignal = null;
let readError = null;
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
readResult = await api("gscu/read", {
method: "POST",
body: JSON.stringify({ slot }),
});
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 5) await delay(1400);
}
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习指令已下发,但槽位回读失败:${readError.message}`
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
);
}
await upsertNamedInfraredCommand(slot, deviceId, objectNames, actionNames, "tcp");
result = {
...result,
learning_capture_confirmed: true,
verification: "slot_readback",
slot_readback: readResult,
};
}
if (action === "erase") {
replaceInfraredSlotCatalog([]);
state.capabilities.forEach((capability) => {
if (!validSlot(capability.slot)) return;
Object.assign(capability, {
slot: null,
status: "pending",
verified: false,
enabled: false,
fingerprint: null,
signalBytes: null,
learnedAt: null,
relearnedAt: null,
});
});
saveState();
renderAll();
void syncPeripheralCatalog();
}
showGscuResult(result);
showToast(
action === "learn"
? `IR ${slot} 学习完成并已加入自然语言解析;实际下发仍需 NFC 授权。`
: `${action} 指令处理完成。`,
"success",
);
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
}
}
function bindGscuTcpDialogEvents() {
const dialog = $("#extensionGscuTcpDialog");
if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNamesFromSlot);
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog");
return;
}
const action = event.target.closest("[data-gscu-action]");
if (action) {
void runGscuTcpAction(action);
return;
}
const emit = event.target.closest("[data-gscu-named-emit]");
if (emit) {
$("#extensionGscuSlot").value = emit.dataset.gscuNamedEmit;
const command = namedInfraredCommands().find(
(item) => item.slot === Number(emit.dataset.gscuNamedEmit),
);
$("#extensionGscuDeviceId").value = command?.deviceId || "";
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
const emitButton = $("[data-gscu-action=\"emit\"]", dialog);
if (emitButton) void runGscuTcpAction(emitButton);
return;
}
const save = event.target.closest("[data-gscu-named-save]");
if (save) {
const card = save.closest("[data-gscu-command-slot]");
save.disabled = true;
void upsertNamedInfraredCommand(
Number(save.dataset.gscuNamedSave),
$("[data-gscu-device-id]", card).value,
$("[data-gscu-object-names]", card).value,
$("[data-gscu-action-names]", card).value,
"tcp",
).then((value) => {
showToast(
`IR ${value.slot} 已保存并立即加入自然语言解析;实际下发仍需 NFC 授权。`,
"success",
);
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
return;
}
const remove = event.target.closest("[data-gscu-named-remove]");
if (remove) {
const slot = Number(remove.dataset.gscuNamedRemove);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
const affectedDeviceId = command?.deviceId;
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
renderAll();
void syncPeripheralCatalog(
state.devices.find((item) => item.id === affectedDeviceId) || null,
);
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => {
remove.disabled = false;
showToast(error.message, "error");
});
}
});
}
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", "wireless_mqtt_ch0", "wireless_mqtt_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() {
if ($("#extensionHardwareCount")) renderMetrics();
if ($("#extensionHardwareGrid")) renderHardwareGrid();
if ($("#extensionLearnedGrid")) renderControl();
if ($("#extensionTypeChooser")) renderTypeChooser();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function normalizeCardUid(value) {
return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase();
}
function persistPeripheralCardUi() {
try {
sessionStorage.setItem(PERIPHERAL_CARD_UI_KEY, JSON.stringify({
sessionId: peripheralCardSessionId,
hidden: [...hiddenPeripheralDevices].map(([deviceId, value]) => ({
deviceId,
cardUid: value.cardUid,
detectionCount: value.detectionCount,
})),
}));
} catch (_error) {
// The in-memory state remains authoritative when storage is unavailable.
}
}
function restorePeripheralCardUi(sessionId) {
hiddenPeripheralDevices.clear();
expandedPeripheralDeviceId = null;
peripheralCardSessionId = String(sessionId || "");
try {
const saved = JSON.parse(sessionStorage.getItem(PERIPHERAL_CARD_UI_KEY) || "null");
if (!saved || saved.sessionId !== peripheralCardSessionId) {
persistPeripheralCardUi();
return;
}
(Array.isArray(saved.hidden) ? saved.hidden : []).forEach((item) => {
const deviceId = String(item?.deviceId || "").trim();
const cardUid = normalizeCardUid(item?.cardUid);
const detectionCount = Number(item?.detectionCount ?? -1);
if (deviceId && cardUid && Number.isInteger(detectionCount)) {
hiddenPeripheralDevices.set(deviceId, { cardUid, detectionCount });
}
});
} catch (_error) {
persistPeripheralCardUi();
}
}
function revealRedetectedPeripheral(value) {
const cardUid = normalizeCardUid(value?.active_nfc_id);
const detectionCount = Number(value?.detection_count ?? -1);
if (!cardUid || !Number.isInteger(detectionCount)) return false;
let changed = false;
hiddenPeripheralDevices.forEach((hidden, deviceId) => {
if (hidden.cardUid === cardUid && hidden.detectionCount !== detectionCount) {
hiddenPeripheralDevices.delete(deviceId);
changed = true;
}
});
if (changed) persistPeripheralCardUi();
return changed;
}
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;
const relearning = capability.status === "learned"
&& capability.verified
&& validSlot(capability.slot);
const targetSlot = relearning ? Number(capability.slot) : allocateSlot();
resetLearnStatus();
learning.draft = {
capabilityId: capability.id,
deviceId: device.id,
slot: targetSlot,
relearning,
};
$("#extensionLearnTitle").textContent = `${relearning ? "重新学习" : "学习"}“${capability.name}”`;
$("#extensionLearnIcon").innerHTML = `<i data-lucide="${type.icon}"></i>`;
$("#extensionLearnDevice").textContent = device.name;
$("#extensionLearnAction").textContent = `${capability.name}${relearning ? "(重新学习)" : ""}`;
$("#extensionLearnAliases").value = (capability.aliases || []).join("、");
$("#extensionLearnRisk").value = capability.risk || "low";
$("#extensionLearnSlot").value = targetSlot || "";
$("#extensionContinueLearn").textContent = relearning
? "继续并覆盖原红外信号"
: "继续接收遥控信号";
$("#extensionConfirmLearn").textContent = relearning
? "确认并完成重新学习"
: "确认并完成学习";
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 {
const learnResult = await sendGatewayCommand(
"learn",
{ slot: learning.draft.slot },
learning.abortController.signal,
);
let learnedSignal = null;
let readError = null;
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
const readResult = await sendGatewayCommand("read", { slot: learning.draft.slot });
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 5) await delay(1400);
}
learnedSignal ||= infraredSignalFromResponse(learnResult);
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习后无法回读槽位:${readError.message}`
: "学习指令已下发,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
);
}
learning.fingerprint = await infraredFingerprint(learnedSignal);
learning.signalBytes = learnedSignal.length;
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 = "再发射一次";
}
}
async function confirmLearning() {
if (!learning.tested || !learning.draft) return;
const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId,
);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const button = $("#extensionConfirmLearn");
button.disabled = true;
const relearning = Boolean(learning.draft.relearning);
const learnedAt = new Date().toISOString();
try {
await upsertNamedInfraredCommand(
Number(learning.draft.slot),
device.id,
[device.name, ...(device.aliases || [])],
[capability.name, ...learning.draft.aliases],
"capability",
);
} catch (error) {
button.disabled = false;
showToast(`保存红外槽位数据库失败:${error.message}`, "error");
return false;
}
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,
fingerprint: learning.fingerprint || capability.fingerprint || null,
signalBytes: learning.signalBytes || capability.signalBytes || null,
learnedAt,
relearnedAt: relearning ? learnedAt : capability.relearnedAt || null,
signalSource: "n7web",
});
state.sessionStartedAt ||= new Date().toISOString();
saveState();
closeDialog("extensionLearnDialog");
renderAll();
showToast(
relearning
? `“${capability.name}”已重新学习并覆盖 IR ${String(capability.slot).padStart(3, "0")}。`
: `“${capability.name}”已学习完成。`,
"success",
);
learning = freshLearningSession();
return true;
}
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();
void syncPeripheralCatalog();
showToast(`“${capability.name}”已恢复为待学习。`);
}
function peripheralCatalog() {
return configuredCapabilities().map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
const slotRecord = namedInfraredCommands().find(
(item) => item.deviceId === capability.deviceId
&& Number(item.slot) === Number(capability.slot),
);
const objectNames = slotRecord?.objectNames?.length
? slotRecord.objectNames
: normalizedInfraredNames([device?.name, ...(device?.aliases || [])]);
const actionNames = slotRecord?.actionNames?.length
? slotRecord.actionNames
: normalizedInfraredNames([capability.name, ...(capability.aliases || [])]);
return {
id: capability.id,
status: "learned",
device_id: device?.id,
device_name: device?.name,
device_aliases: device?.aliases || [],
object_names: objectNames,
device_type: device?.type,
card_uid: device?.cardUid || null,
toy_number: device?.toyNumber || null,
type_label: type.label,
action: capability.name,
aliases: capability.aliases || [],
action_names: actionNames,
risk: capability.risk || "low",
slot: capability.slot,
enabled: capability.enabled !== false,
transport: "gscu_tcp",
};
}).filter((item) => item.device_id && item.card_uid && validSlot(item.slot));
}
function applyDurablePeripheralCatalog(
catalog,
revision = null,
{ render = true } = {},
) {
const nextCatalog = (Array.isArray(catalog) ? catalog : [])
.filter((item) => item && typeof item === "object"
&& item.id && item.device_id && normalizeCardUid(item.card_uid)
&& validSlot(item.slot))
.map((item) => ({ ...item, card_uid: normalizeCardUid(item.card_uid) }));
const changed = JSON.stringify(durablePeripheralCatalog) !== JSON.stringify(nextCatalog)
|| !durablePeripheralCatalogHydrated;
durablePeripheralCatalog = nextCatalog;
durablePeripheralCatalogHydrated = true;
if (revision != null && String(revision).trim()) {
peripheralCatalogRevision = String(revision).trim();
}
if (changed) unifiedExtensionDirty = true;
if (render && changed) renderUnifiedExtensionView?.({ force: true });
return changed;
}
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
applyDurablePeripheralCatalog(
catalog,
payload.catalog_revision,
{ render: false },
);
if (!catalog.length) {
renderAll();
return 0;
}
const restoredDeviceIds = new Set();
catalog.forEach((item) => {
if (!item || typeof item !== "object" || !item.id || !validSlot(item.slot)) return;
const objectNames = normalizedInfraredNames(item.object_names);
const actionNames = normalizedInfraredNames(item.action_names);
let device = state.devices.find((candidate) => candidate.id === item.device_id);
if (!device) {
device = {
id: item.device_id,
name: objectNames[0] || item.device_name,
type: TOY_TYPES[item.device_type] ? item.device_type : "other",
aliases: objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: [item.device_name],
toyNumber: item.toy_number || nextToyNumber(),
cardUid: normalizeCardUid(item.card_uid),
createdAt: new Date().toISOString(),
};
state.devices.push(device);
restoredDeviceIds.add(device.id);
} else {
device.name = objectNames[0] || item.device_name || device.name;
device.aliases = objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: device.aliases || [device.name];
device.cardUid = normalizeCardUid(item.card_uid) || device.cardUid;
}
let capability = state.capabilities.find((candidate) => candidate.id === item.id);
if (!capability) {
capability = {
id: item.id,
deviceId: device.id,
key: "restored",
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : [item.action],
category: "restored",
risk: item.risk || "low",
visual: null,
createdAt: new Date().toISOString(),
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: device.id,
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : capability.aliases || [],
risk: item.risk || "low",
slot: Number(item.slot),
status: "learned",
verified: true,
enabled: item.enabled !== false,
signalSource: "backend_catalog",
});
});
restoredDeviceIds.forEach((deviceId) => {
const device = state.devices.find((item) => item.id === deviceId);
if (!device) return;
const knownNames = new Set(deviceCapabilities(deviceId).map((item) => item.name));
seedPresets(device).forEach((capability) => {
if (!knownNames.has(capability.name)) state.capabilities.push(capability);
});
});
saveState();
renderAll();
return catalog.length;
}
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.infraredCommands = [];
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 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() {
bindGscuTcpDialogEvents();
bindPetSettingsDialogEvents();
$$('[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", () => { void 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 linkedDeviceForCard(uidValue) {
const uidValueNormalized = normalizeCardUid(uidValue);
if (!uidValueNormalized) return null;
return state.devices.find(
(device) => normalizeCardUid(device.cardUid) === uidValueNormalized,
) || null;
}
function inferLegacyToyType(profile) {
const text = [
profile?.name,
profile?.model,
...(Array.isArray(profile?.actions)
? profile.actions.flatMap((action) => [action?.name, action?.code])
: []),
].join(" ").toLowerCase();
if (/(氛围|灯|亮度|颜色|rgb|light|colour|color)/i.test(text)) return "light";
if (/(声音|音乐|音量|播放|sound|music|volume)/i.test(text)) return "sound";
if (/(玩偶|娃娃|唱歌|说话|doll)/i.test(text)) return "doll";
if (/(机械|升降|伸缩|正转|反转|mechanical)/i.test(text)) return "mechanical";
if (/(前进|后退|左转|右转|移动|小车|action|motion)/i.test(text)) return "action";
return "other";
}
function legacyIdentifier(prefix, value) {
const suffix = String(value || "profile")
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "profile";
let candidate = `${prefix}-${suffix}`;
let index = 2;
while (state.devices.some((device) => device.id === candidate)) {
candidate = `${prefix}-${suffix}-${index}`;
index += 1;
}
return candidate;
}
function legacyCapabilityRisk(action) {
const text = `${action?.name || ""} ${action?.code || ""}`;
return /(前进|后退|左转|右转|移动|旋转|升降|伸缩)/.test(text) ? "high" : "low";
}
function importLegacyProfiles(value) {
if (!Array.isArray(value)) return { devices: 0, actions: 0 };
let importedDevices = 0;
let importedActions = 0;
let changed = false;
value.forEach((profile, profileIndex) => {
if (!profile || typeof profile !== "object") return;
const legacyActions = Array.isArray(profile.actions)
? profile.actions.filter((action) => action?.learned && validSlot(action.slot))
: [];
const cardUid = normalizeCardUid(profile.cardUid);
if (!cardUid && !legacyActions.length) return;
const legacyProfileId = String(profile.id || `profile-${profileIndex}`);
const type = inferLegacyToyType(profile);
let device = cardUid ? linkedDeviceForCard(cardUid) : null;
device ||= state.devices.find((item) => item.legacyProfileId === legacyProfileId) || null;
if (!device) {
device = {
id: legacyIdentifier("legacy-toy", legacyProfileId),
name: String(profile.name || `玩具 ${profile.toyNumber || nextToyNumber()}`),
type,
aliases: [String(profile.name || "玩具")],
toyNumber: String(profile.toyNumber || nextToyNumber()),
cardUid: cardUid || null,
model: String(profile.model || "ir-toy-learning 导入外设"),
legacyProfileId,
importedFrom: "ir-toy-learning",
createdAt: new Date().toISOString(),
};
state.devices.push(device);
importedDevices += 1;
changed = true;
} else {
const before = JSON.stringify([
device.name,
device.type,
device.toyNumber,
device.cardUid,
device.legacyProfileId,
]);
if (!device.cardUid && cardUid) device.cardUid = cardUid;
if (!device.toyNumber && profile.toyNumber) device.toyNumber = String(profile.toyNumber);
if (!device.legacyProfileId) device.legacyProfileId = legacyProfileId;
if (device.type === "other" && type !== "other") device.type = type;
if (/^玩具\s*\d+$/i.test(device.name) && profile.name) device.name = String(profile.name);
device.importedFrom ||= "ir-toy-learning";
changed ||= before !== JSON.stringify([
device.name,
device.type,
device.toyNumber,
device.cardUid,
device.legacyProfileId,
]);
}
state.selectedDeviceByType[device.type] = device.id;
legacyActions.forEach((action, actionIndex) => {
const slot = Number(action.slot);
const name = String(action.name || action.code || `红外动作 ${slot}`);
const legacyActionId = String(action.id || `${legacyProfileId}:${actionIndex}:${slot}`);
const aliases = [...new Set([
name,
typeof action.code === "string" ? action.code : "",
].filter(Boolean))];
let capability = state.capabilities.find((item) => (
item.deviceId === device.id
&& (item.legacyActionId === legacyActionId
|| item.name === name)
));
if (!capability) {
capability = {
id: uid("legacy-cap"),
deviceId: device.id,
key: `legacy_${slot}`,
createdAt: new Date().toISOString(),
};
state.capabilities.push(capability);
importedActions += 1;
changed = true;
}
// A signal learned again in N7Web is authoritative. Repeated NFC snapshots
// may still contain the old ir-toy-learning metadata and must not roll it back.
if (capability.signalSource === "n7web") return;
const before = JSON.stringify(capability);
Object.assign(capability, {
name,
aliases,
category: type === "light" ? "effect" : "custom",
risk: legacyCapabilityRisk(action),
slot,
status: "learned",
verified: true,
enabled: true,
fingerprint: action.fingerprint || capability.fingerprint || null,
learnedAt: action.updatedAt || capability.learnedAt || new Date().toISOString(),
legacyActionId,
importedFrom: "ir-toy-learning",
});
changed ||= before !== JSON.stringify(capability);
});
});
if (changed) {
state.sessionStartedAt ||= new Date().toISOString();
saveState();
renderAll();
void syncPeripheralCatalog();
}
return { devices: importedDevices, actions: importedActions };
}
function readSameOriginLegacyProfiles() {
try {
return JSON.parse(localStorage.getItem(LEGACY_PROFILE_KEY) || "null");
} catch (_error) {
return null;
}
}
function requestLegacyProfiles(bridgeUrl) {
return new Promise((resolve) => {
const iframe = document.createElement("iframe");
const bridgeOrigin = new URL(bridgeUrl).origin;
const requestId = uid("legacy-import");
let settled = false;
const cleanup = (profiles = null) => {
if (settled) return;
settled = true;
window.clearTimeout(timer);
window.removeEventListener("message", receive);
iframe.remove();
resolve(profiles);
};
const request = () => iframe.contentWindow?.postMessage({
type: "N7WEB_REQUEST_IR_TOY_PROFILES",
requestId,
}, bridgeOrigin);
const receive = (event) => {
if (event.origin !== bridgeOrigin || event.source !== iframe.contentWindow) return;
if (event.data?.type === "IR_TOY_LEGACY_BRIDGE_READY") {
request();
return;
}
if (event.data?.type !== "IR_TOY_LEGACY_PROFILES"
|| event.data.requestId !== requestId) return;
cleanup(Array.isArray(event.data.profiles) ? event.data.profiles : []);
};
const timer = window.setTimeout(() => cleanup(), 1800);
iframe.src = bridgeUrl;
iframe.hidden = true;
iframe.setAttribute("aria-hidden", "true");
iframe.addEventListener("load", request, { once: true });
window.addEventListener("message", receive);
document.body.append(iframe);
});
}
function hasImportableLegacyProfiles(profiles) {
return Array.isArray(profiles) && profiles.some((profile) => (
normalizeCardUid(profile?.cardUid)
|| (Array.isArray(profile?.actions)
&& profile.actions.some((action) => action?.learned && validSlot(action.slot)))
));
}
function legacyBridgeUrls(addresses = []) {
const currentHostUrl = window.location.hostname
? `http://${window.location.hostname}:3001/n7web-profile-bridge.html`
: null;
const addressUrls = (Array.isArray(addresses) ? addresses : [])
.filter((address) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(String(address)))
.map((address) => `http://${address}:3001/n7web-profile-bridge.html`);
return [...new Set([currentHostUrl, ...LEGACY_PROFILE_BRIDGE_URLS, ...addressUrls].filter(Boolean))];
}
async function migrateLegacyProfiles(addresses = []) {
if (legacyMigrationCompleted) return;
if (!legacySameOriginChecked) {
legacySameOriginChecked = true;
const sameOriginProfiles = readSameOriginLegacyProfiles();
if (hasImportableLegacyProfiles(sameOriginProfiles)) {
const imported = importLegacyProfiles(sameOriginProfiles);
legacyMigrationCompleted = true;
if (imported.devices || imported.actions) {
showToast(`已迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
}
return;
}
}
for (const bridgeUrl of legacyBridgeUrls(addresses)) {
if (legacyBridgeAttempts.has(bridgeUrl)) continue;
legacyBridgeAttempts.add(bridgeUrl);
const profiles = await requestLegacyProfiles(bridgeUrl);
if (!hasImportableLegacyProfiles(profiles)) continue;
const imported = importLegacyProfiles(profiles);
legacyMigrationCompleted = true;
if (imported.devices || imported.actions) {
showToast(`已从 ir-toy-learning 迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
}
return;
}
}
function nextToyNumber() {
const highest = state.devices.reduce((current, device) => {
const parsed = Number.parseInt(device.toyNumber || "", 10);
return Number.isFinite(parsed) ? Math.max(current, parsed) : current;
}, 0);
return String(highest + 1).padStart(3, "0");
}
function petCards() {
return Array.isArray(petCardSnapshot.cards) ? petCardSnapshot.cards : [];
}
function petCardHasUnsavedChanges(card) {
const draft = petCardDrafts.get(card?.id);
if (!card || !draft) return false;
return String(draft.pet_name ?? "") !== String(card.pet_name ?? "")
|| String(draft.action ?? "") !== String(card.action ?? "");
}
function petCardCanBind(card) {
return Boolean(card?.saved) && !petCardHasUnsavedChanges(card);
}
function linkedPetForCard(uidValue) {
const normalizedUid = normalizeCardUid(uidValue);
if (!normalizedUid) return null;
return petCards().find(
(card) => normalizeCardUid(card.nfc_id) === normalizedUid,
) || null;
}
function petCardStatus(card) {
const publisher = petCardSnapshot.publisher || {};
const active = publisher.active_pet_id === card.id;
if (!card.saved || petCardHasUnsavedChanges(card)) {
return { state: "editing", label: "待保存" };
}
if (active && publisher.state === "bursting") {
return { state: "bursting", label: "3 帧发送中" };
}
if (active && publisher.state === "burst_complete") {
return { state: "complete", label: "已发送 3 帧" };
}
if (active && publisher.state === "burst_failed") {
return { state: "error", label: "3 帧发送失败" };
}
if (card.nfc_id) return { state: "bound", label: "已绑定 NFC" };
return { state: "unbound", label: "待绑定" };
}
function applyPetCardSnapshot(value, { render = true } = {}) {
if (!value || typeof value !== "object") return false;
const parsedRevision = Number(value.revision ?? -1);
const nextRevision = Number.isFinite(parsedRevision) ? parsedRevision : -1;
if (nextRevision < petCardSnapshot.revision) return false;
const cards = Array.isArray(value.cards)
? value.cards.filter((card) => card && typeof card === "object")
: [];
const publisher = value.publisher && typeof value.publisher === "object"
? value.publisher
: {};
const next = {
revision: nextRevision,
cards,
publisher: {
state: String(publisher.state || "stopped"),
topic: String(publisher.topic || "n7/pet"),
burst_count: Number(publisher.burst_count || 3),
burst_window_ms: Number(publisher.burst_window_ms || 1000),
burst_attempt_count: Number(publisher.burst_attempt_count || 0),
burst_published_count: Number(publisher.burst_published_count || 0),
active_pet_id: publisher.active_pet_id || null,
active_nfc_id: publisher.active_nfc_id || null,
last_published_at: publisher.last_published_at || null,
last_delivery_status: publisher.last_delivery_status || null,
publish_count: Number(publisher.publish_count || 0),
last_error: publisher.last_error || null,
},
};
const cardsChanged = JSON.stringify(petCardSnapshot.cards) !== JSON.stringify(cards);
const publisherChanged = JSON.stringify(petCardSnapshot.publisher) !== JSON.stringify(next.publisher);
if (!cardsChanged && !publisherChanged && next.revision === petCardSnapshot.revision) {
return false;
}
petCardSnapshot = next;
if (selectedPetCardId && !cards.some((card) => card.id === selectedPetCardId)) {
selectedPetCardId = null;
}
if (render) {
renderNfcDialog();
if (cardsChanged) {
renderPetSettingsDialog();
renderUnifiedExtensionView?.();
}
else updatePetPublisherIndicators();
}
return true;
}
async function hydratePetCards() {
applyPetCardSnapshot(await api("pets"));
}
function nextPetCardName() {
const used = new Set(petCards().map((card) => String(card.pet_name || "")));
let index = petCards().length + 1;
while (used.has(`宠物 ${index}`)) index += 1;
return `宠物 ${index}`;
}
async function createPetCard(button = null) {
if (button) button.disabled = true;
try {
const response = await api("pets", {
method: "POST",
body: JSON.stringify({
pet_name: nextPetCardName(),
action: "待设置",
}),
});
selectedPetCardId = response.card?.id || null;
applyPetCardSnapshot(response);
showToast("已添加宠物卡片,请编辑并保存后再绑定 NFC。", "success");
window.setTimeout(() => {
const card = $(`[data-pet-card-id="${selectedPetCardId}"]`, $("#extensionPetSettingsDialog"));
card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
$('[data-pet-field="pet_name"]', card)?.focus();
}, 0);
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function savePetCard(article, button = null) {
const cardId = article?.dataset.petCardId;
const petName = $('[data-pet-field="pet_name"]', article)?.value || "";
const action = $('[data-pet-field="action"]', article)?.value || "";
if (!cardId) return;
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(cardId)}`, {
method: "PUT",
body: JSON.stringify({ pet_name: petName, action }),
});
petCardDrafts.delete(cardId);
applyPetCardSnapshot(response);
showToast("宠物卡片已保存,可通过 NFC 设备授权继续绑定。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function deletePetCard(article, button = null) {
const cardId = article?.dataset.petCardId;
const petName = $('[data-pet-field="pet_name"]', article)?.value || "该宠物";
if (!cardId || !window.confirm(`确认删除“${petName}”宠物卡片吗?`)) return;
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(cardId)}`, {
method: "DELETE",
});
petCardDrafts.delete(cardId);
applyPetCardSnapshot(response);
showToast("宠物卡片已删除。", "success");
} catch (error) {
showToast(error.message, "error");
if (button?.isConnected) button.disabled = false;
}
}
async function bindCurrentNfcToPet() {
const petId = $("#extensionNfcExistingPet")?.value || selectedPetCardId;
if (!petId) return;
const petCard = petCards().find((card) => card.id === petId);
if (!petCardCanBind(petCard)) {
showToast("请先在“宠物设置”中完成编辑并保存。", "error");
return;
}
const button = $("[data-nfc-bind-pet]");
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(petId)}/bind-current-nfc`, {
method: "POST",
body: "{}",
});
selectedPetCardId = petId;
applyPetCardSnapshot(response);
renderNfcDialog();
showToast(`NFC 已绑定宠物“${response.card?.pet_name || "宠物"}”。`, "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function unbindPetCard(petId) {
if (!petId) return;
const button = $(`[data-nfc-unbind-pet="${petId}"]`);
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(petId)}/unbind`, {
method: "POST",
body: "{}",
});
applyPetCardSnapshot(response);
renderNfcDialog();
showToast("已解除宠物卡片的 NFC 绑定。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
function renderNfcPetBinding(linkedPet) {
if (linkedPet) {
const status = petCardStatus(linkedPet);
return `<section class="v2-nfc-pet-binding" data-state="${escapeHtml(status.state)}">
<div><span><i data-lucide="paw-print"></i></span><div><small>已绑定宠物卡片</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>${escapeHtml(linkedPet.action)} · 本次识别仅发送 3 帧</p></div></div>
<div><code>${escapeHtml(linkedPet.nfc_id)}</code><b>${escapeHtml(status.label)}</b><button type="button" data-nfc-unbind-pet="${escapeHtml(linkedPet.id)}">解绑 NFC</button></div>
</section>`;
}
if (!petCards().length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="paw-print"></i><div><strong>暂无宠物卡片</strong><span>请先打开“宠物设置”添加并保存宠物资料。</span></div><button type="button" data-open-pet-settings>打开宠物设置</button></div>`;
}
const bindableCards = petCards().filter(petCardCanBind);
if (!bindableCards.length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="save"></i><div><strong>暂无可绑定的已保存卡片</strong><span>请先在“宠物设置”中完成宠物名与动作编辑并保存。</span></div><button type="button" data-open-pet-settings>打开宠物设置</button></div>`;
}
const selected = bindableCards.some((card) => card.id === selectedPetCardId)
? selectedPetCardId
: bindableCards[0].id;
return `<div class="v2-nfc-existing v2-nfc-pet-existing"><span>绑定宠物卡片</span><div><select id="extensionNfcExistingPet" data-nfc-pet-select>${bindableCards.map((card) => `<option value="${escapeHtml(card.id)}" ${card.id === selected ? "selected" : ""}>${escapeHtml(card.pet_name)} — ${escapeHtml(card.action)}${card.nfc_id ? ` — 当前已绑定 ${escapeHtml(card.nfc_id)}` : ""}</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-pet>关联宠物卡片</button></div><small>卡片 ID 由当前读卡器中的 NFC 自动写入,不能手动编辑;同一 NFC 只绑定一张宠物卡片。</small></div>`;
}
function renderPetCardEditor() {
const publisher = petCardSnapshot.publisher || {};
const cards = petCards();
return `<section class="v2-pet-card-region" aria-labelledby="v2PetCardEditorTitle">
<header><div><span><i data-lucide="paw-print"></i></span><h4 id="v2PetCardEditorTitle">宠物卡片</h4></div><button type="button" data-pet-add><i data-lucide="plus" data-icon="inline-start"></i><span>添加宠物</span></button></header>
<div class="v2-pet-card-list">
${cards.length ? cards.map((card) => {
const draft = petCardDrafts.get(card.id) || card;
const status = petCardStatus(card);
const activeError = publisher.active_pet_id === card.id
? String(publisher.last_error || "")
: "";
return `<article class="v2-pet-card" data-pet-card-id="${escapeHtml(card.id)}" data-pet-state="${escapeHtml(status.state)}">
<header><span><i data-lucide="paw-print"></i></span><strong>${escapeHtml(draft.pet_name || card.pet_name)}</strong><b data-pet-publish-state="${escapeHtml(card.id)}" data-state="${escapeHtml(status.state)}" title="${escapeHtml(activeError || status.label)}">${escapeHtml(status.label)}</b><button type="button" data-pet-delete aria-label="删除宠物卡片" title="删除宠物卡片"><i data-lucide="trash-2"></i></button></header>
<div class="v2-pet-card-fields">
<label><span>宠物名</span><input type="text" maxlength="64" value="${escapeHtml(draft.pet_name || "")}" data-pet-field="pet_name" autocomplete="off" /></label>
<label><span>宠物动作</span><textarea maxlength="160" rows="2" data-pet-field="action">${escapeHtml(draft.action || "")}</textarea></label>
<label class="v2-pet-nfc-field"><span>卡片 ID</span><div><output>${card.nfc_id ? escapeHtml(card.nfc_id) : "未绑定"}</output></div></label>
</div>
<footer><button type="button" data-pet-save><i data-lucide="save"></i><span>保存修改</span></button></footer>
</article>`;
}).join("") : `<div class="v2-pet-card-empty"><i data-lucide="paw-print"></i><strong>还没有宠物卡片</strong><button type="button" data-pet-add>添加第一张</button></div>`}
</div>
</section>`;
}
function renderPetSettingsDialog() {
const body = $("#extensionPetSettingsBody");
if (!body) return;
body.innerHTML = renderPetCardEditor();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function openPetSettingsDialog() {
renderPetSettingsDialog();
openDialog("extensionPetSettingsDialog");
}
function bindPetSettingsDialogEvents() {
const dialog = $("#extensionPetSettingsDialog");
if (!dialog || dialog.dataset.petSettingsBound === "true") return;
dialog.dataset.petSettingsBound = "true";
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionPetSettingsDialog"]')) {
closeDialog("extensionPetSettingsDialog");
return;
}
const addPet = event.target.closest("[data-pet-add]");
const article = event.target.closest("[data-pet-card-id]");
const savePet = event.target.closest("[data-pet-save]");
const deletePet = event.target.closest("[data-pet-delete]");
if (addPet) void createPetCard(addPet);
else if (savePet) void savePetCard(article, savePet);
else if (deletePet) void deletePetCard(article, deletePet);
});
dialog.addEventListener("input", (event) => {
const field = event.target.closest("[data-pet-field]");
const article = field?.closest("[data-pet-card-id]");
if (!field || !article) return;
const current = petCardDrafts.get(article.dataset.petCardId)
|| petCards().find((card) => card.id === article.dataset.petCardId)
|| {};
petCardDrafts.set(article.dataset.petCardId, {
...current,
[field.dataset.petField]: field.value,
});
const title = $("header strong", article);
if (field.dataset.petField === "pet_name" && title) {
title.textContent = field.value.trim() || "未命名宠物";
}
updatePetPublisherIndicators();
});
}
function renderDetectedPetCard(card) {
if (!card) return "";
const status = petCardStatus(card);
const publisher = petCardSnapshot.publisher || {};
const activeError = publisher.active_pet_id === card.id
? String(publisher.last_error || "")
: "";
return `<article class="v2-pet-presence-card" data-detected-pet-id="${escapeHtml(card.id)}" data-pet-state="${escapeHtml(status.state)}" aria-label="当前识别宠物 ${escapeHtml(card.pet_name)}">
<header><span><i data-lucide="paw-print"></i></span><div><small>当前识别宠物</small><strong>${escapeHtml(card.pet_name)}</strong></div><b data-pet-publish-state="${escapeHtml(card.id)}" data-state="${escapeHtml(status.state)}" title="${escapeHtml(activeError || status.label)}">${escapeHtml(status.label)}</b></header>
<dl><div><dt>宠物动作</dt><dd>${escapeHtml(card.action)}</dd></div><div><dt>卡片 ID</dt><dd><code>${escapeHtml(card.nfc_id)}</code></dd></div></dl>
<footer><span><i data-lucide="radio-tower"></i>${escapeHtml(publisher.topic || "n7/pet")}</span><strong>3 帧 / 1 秒</strong></footer>
</article>`;
}
function updatePetPublisherIndicators() {
const publisher = petCardSnapshot.publisher || {};
petCards().forEach((card) => {
const status = petCardStatus(card);
$$(`[data-pet-publish-state="${card.id}"]`).forEach((badge) => {
badge.dataset.state = status.state;
badge.textContent = status.label;
badge.title = publisher.active_pet_id === card.id && publisher.last_error
? String(publisher.last_error)
: status.label;
});
$$(`[data-pet-card-id="${card.id}"], [data-detected-pet-id="${card.id}"]`).forEach((article) => {
article.dataset.petState = status.state;
});
});
}
function nfcReaderNote(reader = nfcSnapshot.reader) {
if (reader?.online) {
const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : "";
return `${reader.port || "串口"} 已就绪${firmware}`;
}
if (reader?.state === "connecting" || reader?.state === "starting") {
return "正在查找 PN532 / PCR532 读卡器";
}
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
}
function currentNfcDialogFingerprint() {
return JSON.stringify({
revision: nfcSnapshot.revision,
reader: nfcSnapshot.reader,
card: nfcSnapshot.card,
petRevision: petCardSnapshot.revision,
pets: petCardSnapshot.cards,
publisher: petCardSnapshot.publisher,
devices: state.devices.map((device) => ({
id: device.id,
name: device.name,
type: device.type,
cardUid: device.cardUid || null,
})),
capabilities: state.capabilities.map((capability) => ({
id: capability.id,
deviceId: capability.deviceId,
name: capability.name,
aliases: capability.aliases,
slot: capability.slot,
status: capability.status,
verified: capability.verified,
enabled: capability.enabled !== false,
})),
infraredCommands: state.infraredCommands,
});
}
function renderNfcDialog({ force = false } = {}) {
const dialog = $("#extensionNfcDialog");
if (!dialog) return false;
if (!force && !dialog.open) {
nfcDialogDirty = true;
return false;
}
const renderFingerprint = currentNfcDialogFingerprint();
if (!force && !nfcDialogDirty && renderFingerprint === nfcDialogFingerprint) {
return false;
}
const stage = $("#extensionNfcStage");
const body = $("#extensionNfcBody");
const status = $("#extensionNfcReaderStatus");
const badge = $("#extensionNfcReaderBadge");
if (!stage || !body || !status || !badge) return false;
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
const linkedPet = linkedPetForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
badge.dataset.state = reader.online ? "online" : "offline";
if (!reader.online) {
stage.dataset.state = "offline";
body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到宠物卡片或已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice && !linkedPet) {
stage.dataset.state = "new-card";
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择宠物卡片;也可继续关联已有红外设备 ID。</p></div></div>
${renderNfcPetBinding(null)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>绑定对象(设备 ID)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>关联后,该设备的全部命令默认允许执行,可再逐项取消。</small></div>` : `<div class="v2-nfc-no-devices"><i data-lucide="info"></i><div><strong>暂无可绑定的设备 ID</strong><span>请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。</span></div></div>`}`;
} else if (linkedPet && !linkedDevice) {
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span><i data-lucide="paw-print"></i></span>
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>宠物动作:${escapeHtml(linkedPet.action)} · 本次识别在 1 秒内发送 3 帧 n7/pet</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>继续关联红外设备 ID(可选)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>宠物消息与红外设备授权相互独立,不会覆盖宠物卡片内容。</small></div>` : ""}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const alternativeDeviceGroups = deviceGroups.filter(
(item) => item.deviceId !== linkedDevice.id
&& !normalizeCardUid(
state.devices.find((device) => device.id === item.deviceId)?.cardUid,
),
);
const capabilities = configuredCapabilities(linkedDevice.id);
const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length;
const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length;
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span data-extension-type="other"><i data-lucide="cpu"></i></span>
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedDevice.id)}</strong><p>控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
<div class="v2-nfc-capability-heading"><div><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
${alternativeDeviceGroups.length ? `<div class="v2-nfc-existing v2-nfc-rebind"><span>将当前 NFC 改绑到新设备 ID</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${alternativeDeviceGroups.map((item) => `<option value="${escapeHtml(item.deviceId)}">${escapeHtml(item.deviceId)} — ${escapeHtml(item.objectNames.join(" / ") || "无对象描述")} — ${item.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>改绑设备 ID</button></div><small>改绑成功后,旧设备 ID 的 NFC 授权会解除,新设备的全部有效命令会默认启用。</small></div>` : ""}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
nfcDialogFingerprint = renderFingerprint;
nfcDialogDirty = false;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
}
function buildUnifiedNfcDialog() {
if ($("#extensionNfcDialog")) return;
const dialog = document.createElement("dialog");
dialog.className = "v2-extension-dialog v2-nfc-dialog";
dialog.id = "extensionNfcDialog";
dialog.innerHTML = `<header><div><h2>NFC 设备授权</h2></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-nfc-stage" id="extensionNfcStage" data-state="waiting">
<header><span><i data-lucide="scan-line"></i></span><div><h3>NFC 设备绑定</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
<div class="v2-nfc-body" id="extensionNfcBody"></div>
</section>
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>宠物卡片与 NFC UID 由后端持久化;每次重新识别只在 1 秒内发送 3 帧 n7/pet,随后保持静默。设备 ID 与命令授权逻辑保持不变。</span></footer>`;
document.body.append(dialog);
}
async function syncPeripheralCatalog(device = null, { throwOnError = false } = {}) {
const nextCatalog = peripheralCatalog();
const operation = peripheralCatalogSyncQueue
.catch(() => null)
.then(() => api("display-sync", {
method: "POST",
body: JSON.stringify({
peripherals: nextCatalog,
device,
catalog_revision: peripheralCatalogRevision,
}),
}));
peripheralCatalogSyncQueue = operation.catch(() => null);
try {
const result = await operation;
applyDurablePeripheralCatalog(
nextCatalog,
result.catalog_revision,
);
return result;
} catch (error) {
console.warn("外设能力同步失败", error);
if (throwOnError) throw error;
return null;
}
}
async function bindCardToDevice(device, cardUid) {
if (!device || !cardUid) return;
const previousBindings = state.devices.map((item) => [item.id, item.cardUid || null]);
const normalizedUid = normalizeCardUid(cardUid);
state.devices.forEach((item) => {
if (item.id !== device.id && normalizeCardUid(item.cardUid) === normalizedUid) {
item.cardUid = null;
}
});
device.cardUid = normalizedUid;
device.toyNumber ||= nextToyNumber();
state.selectedDeviceByType[device.type] = device.id;
saveState();
renderAll();
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
previousBindings.forEach(([id, cardUidValue]) => {
const item = state.devices.find((candidate) => candidate.id === id);
if (item) item.cardUid = cardUidValue;
});
saveState();
renderAll();
throw error;
}
}
async function bindCurrentNfcToExisting() {
const deviceId = $("#extensionNfcExistingDevice")?.value;
reconcileDevicesAndCapabilitiesFromSlots();
const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid;
if (!cardUid) {
showToast("当前没有可用于关联的 NFC 卡片。", "error");
return;
}
if (!device) {
showToast("未找到所选设备 ID,请刷新红外槽位后重试。", "error");
return;
}
const capabilities = configuredCapabilities(device.id);
if (!capabilities.length) {
showToast(`设备 ID“${device.id}”没有可授权的有效红外命令。`, "error");
return;
}
const previousDevice = linkedDeviceForCard(cardUid);
const previousEnabled = capabilities.map((capability) => [capability.id, capability.enabled]);
capabilities.forEach((capability) => {
capability.enabled = true;
});
try {
await bindCardToDevice(device, cardUid);
} catch (error) {
previousEnabled.forEach(([id, enabled]) => {
const capability = capabilities.find((item) => item.id === id);
if (capability) capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 设备授权保存失败:${error.message}`, "error");
return;
}
renderNfcDialog();
showToast(
previousDevice && previousDevice.id !== device.id
? `NFC 已从设备 ID“${previousDevice.id}”改绑到“${device.id}”,新设备命令已全部启用。`
: `NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`,
"success",
);
}
async function unbindCurrentNfc() {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
const previousUid = device.cardUid;
device.cardUid = null;
saveState();
renderAll();
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
device.cardUid = previousUid;
saveState();
renderAll();
renderNfcDialog();
showToast(`解除 NFC 绑定失败:${error.message}`, "error");
return;
}
renderNfcDialog();
showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`);
}
async function updateNfcCapabilityAuthorization(capabilityId, enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const capability = configuredCapabilities(device?.id).find(
(item) => item.id === capabilityId,
);
if (!device || !capability) return;
const previousEnabled = capability.enabled;
capability.enabled = enabled;
saveState();
renderAll();
renderNfcDialog();
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
capability.enabled = previousEnabled;
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 命令授权保存失败:${error.message}`, "error");
}
}
async function updateAllNfcCapabilityAuthorizations(enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
const capabilities = configuredCapabilities(device.id);
const previousEnabled = capabilities.map((capability) => [capability.id, capability.enabled]);
capabilities.forEach((capability) => {
capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
previousEnabled.forEach(([id, previous]) => {
const capability = capabilities.find((item) => item.id === id);
if (capability) capability.enabled = previous;
});
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 命令授权保存失败:${error.message}`, "error");
}
}
function applyExtensionToySnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return false;
const revision = Number(value.revision ?? -1);
const sessionId = String(value.session_id || "");
const sessionChanged = sessionId !== peripheralCardSessionId;
if (sessionChanged) restorePeripheralCardUi(sessionId);
const cardRevealed = revealRedetectedPeripheral(value);
if (revision === extensionToySnapshot.revision
&& sessionId === String(extensionToySnapshot.session_id || "")
&& !cardRevealed) return false;
extensionToySnapshot = {
revision,
session_id: sessionId || null,
state: String(value.state || "stopped"),
topic: String(value.topic || "extension/toy"),
burst_published_count: Number(value.burst_published_count || 0),
active_extension_names: Array.isArray(value.active_extension_names)
? value.active_extension_names.map((item) => String(item || "")).filter(Boolean)
: [],
detected_nfc_ids: Array.isArray(value.detected_nfc_ids)
? value.detected_nfc_ids.map(normalizeCardUid).filter(Boolean)
: [],
detection_count: Number(value.detection_count || 0),
active_nfc_id: normalizeCardUid(value.active_nfc_id) || null,
last_delivery_status: value.last_delivery_status || null,
last_error: value.last_error || null,
};
if (sessionChanged || cardRevealed) unifiedExtensionDirty = true;
if (announce && extensionToySnapshot.state === "burst_complete") {
showToast(
`已向 ${extensionToySnapshot.topic} 发送 ${extensionToySnapshot.burst_published_count} 帧设备能力消息。`,
"success",
);
} else if (announce && extensionToySnapshot.state === "burst_failed") {
showToast(
`${extensionToySnapshot.topic} 发送失败:${extensionToySnapshot.last_error || "MQTT 未连接"}`,
"error",
);
}
return true;
}
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
if (value.pet_cards) applyPetCardSnapshot(value.pet_cards);
const extensionToyChanged = value.extension_toy
? applyExtensionToySnapshot(value.extension_toy, { announce })
: false;
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) {
if (extensionToyChanged) renderUnifiedExtensionView?.();
return;
}
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
importLegacyProfiles(value.legacy_profiles);
}
const previousUid = nfcSnapshot.card?.uid || null;
nfcSnapshot = {
revision,
reader: value.reader && typeof value.reader === "object" ? value.reader : {},
card: value.card && typeof value.card === "object" ? value.card : null,
legacyProfiles: Array.isArray(value.legacy_profiles) ? value.legacy_profiles : [],
};
if (Array.isArray(value.legacy_bridge_addresses)) {
void migrateLegacyProfiles(value.legacy_bridge_addresses);
}
const currentUid = nfcSnapshot.card?.uid || null;
if (currentUid && currentUid !== previousUid) {
const device = linkedDeviceForCard(currentUid);
const pet = linkedPetForCard(currentUid);
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) {
showToast(
pet
? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”,正在准备 extension/toy 消息。`
: `已识别设备 ID“${device.id}”,正在准备 extension/toy 消息。`,
"success",
);
}
void syncPeripheralCatalog(device);
} else if (pet) {
if (announce) {
showToast(`已识别宠物“${pet.pet_name}”,将在 1 秒内发送 3 帧 n7/pet。`, "success");
}
} else if (announce) {
showToast("检测到未绑定 NFC,请选择宠物卡片或设备 ID。", "success");
}
}
renderNfcDialog();
renderUnifiedExtensionView?.();
}
async function refreshNfcStatus() {
try {
applyNfcSnapshot(await api("nfc/status"));
} catch (error) {
applyNfcSnapshot({
revision: Date.now(),
reader: { online: false, state: "error", port: "auto", error: error.message },
card: null,
}, { announce: false });
}
}
function startNfcPolling() {
if (nfcPollTimer !== null) return;
void refreshNfcStatus();
nfcPollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") void refreshNfcStatus();
}, NFC_POLL_INTERVAL_MS);
}
function stopNfcPolling() {
if (nfcPollTimer !== null) window.clearInterval(nfcPollTimer);
nfcPollTimer = null;
}
function openNfcDialog() {
openDialog("extensionNfcDialog");
renderNfcDialog({ force: true });
void refreshNfcStatus();
}
function hidePeripheralCard(deviceId, cardUid) {
const normalizedDeviceId = String(deviceId || "").trim();
const normalizedCardUid = normalizeCardUid(cardUid);
if (!normalizedDeviceId || !normalizedCardUid) return;
hiddenPeripheralDevices.set(normalizedDeviceId, {
cardUid: normalizedCardUid,
detectionCount: Number(extensionToySnapshot.detection_count || 0),
});
if (expandedPeripheralDeviceId === normalizedDeviceId) {
expandedPeripheralDeviceId = null;
}
persistPeripheralCardUi();
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
showToast("外设卡片已隐藏;再次感应对应 NFC 后将重新显示。", "success");
}
function togglePeripheralCard(deviceId) {
const normalizedDeviceId = String(deviceId || "").trim();
if (!normalizedDeviceId) return;
expandedPeripheralDeviceId = expandedPeripheralDeviceId === normalizedDeviceId
? null
: normalizedDeviceId;
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
}
async function runPeripheralQuickAction(button) {
const capabilityId = String(button?.dataset.runPeripheralQuickAction || "").trim();
if (!capabilityId || button.disabled) return;
if (runningPeripheralQuickAction) {
showToast("上一条外设快捷指令仍在处理,请稍候。", "error");
return;
}
const capability = durablePeripheralCatalog.find(
(item) => String(item.id || "") === capabilityId,
);
const deviceName = String(
capability?.device_name || capability?.device_id || "外设",
);
const actionName = String(capability?.action || capability?.name || "动作");
if ((button.dataset.risk || capability?.risk) === "high" && !window.confirm(
`“${deviceName} · ${actionName}”可能让外设移动,请确认周围安全。`,
)) return;
const previousText = button.textContent;
runningPeripheralQuickAction = true;
button.disabled = true;
button.dataset.state = "running";
button.textContent = "下发中…";
try {
await api("peripherals/execute", {
method: "POST",
body: JSON.stringify({
capability_id: capabilityId,
conversation_id: sharedConversationId(),
}),
});
button.dataset.state = "success";
button.textContent = "已下发";
showToast(`${deviceName} · ${actionName} 已下发。`, "success");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1200);
} catch (error) {
button.dataset.state = "error";
button.textContent = "下发失败";
showToast(error.message, "error");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1600);
} finally {
runningPeripheralQuickAction = false;
}
}
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
const quickAction = event.target.closest("[data-run-peripheral-quick-action]");
if (quickAction) {
void runPeripheralQuickAction(quickAction);
return;
}
const hideCard = event.target.closest("[data-hide-peripheral-card]");
if (hideCard) {
hidePeripheralCard(
hideCard.dataset.hidePeripheralCard,
hideCard.dataset.cardUid,
);
return;
}
const toggleCard = event.target.closest("[data-toggle-peripheral-card]");
if (toggleCard) {
togglePeripheralCard(toggleCard.dataset.togglePeripheralCard);
return;
}
if (event.target.closest("[data-open-pet-settings]")) openPetSettingsDialog();
else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
bindGscuTcpDialogEvents();
bindPetSettingsDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-open-pet-settings]")) {
closeDialog("extensionNfcDialog");
openPetSettingsDialog();
return;
}
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-bind-existing]")) void bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) void unbindCurrentNfc();
if (event.target.closest("[data-nfc-bind-pet]")) void bindCurrentNfcToPet();
const unbindPet = event.target.closest("[data-nfc-unbind-pet]");
if (unbindPet) void unbindPetCard(unbindPet.dataset.nfcUnbindPet);
});
dialog.addEventListener("change", (event) => {
const petSelect = event.target.closest("[data-nfc-pet-select]");
if (petSelect) selectedPetCardId = petSelect.value;
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
void updateNfcCapabilityAuthorization(
capability.dataset.nfcCapabilityEnabled,
capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) void updateAllNfcCapabilityAuthorizations(selectAll.checked);
});
$("#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", async () => {
if (await confirmLearning()) {
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
}
});
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
}
});
$$('[data-close-extension-dialog="extensionLearnDialog"]').forEach((button) => {
button.addEventListener("click", async () => {
if (learning.phase === "listening") await cancelLearning();
closeDialog("extensionLearnDialog");
});
});
window.addEventListener("pagehide", stopNfcPolling);
window.addEventListener("pageshow", startNfcPolling);
}
function buildUnifiedExtensionSync() {
const mount = $("#vehicleExtensionSync");
if (!mount) return false;
let snapshot = null;
const extensionViewFingerprint = (health) => JSON.stringify({
transport: health.transport,
connected: health.connected,
peerConnected: health.peer_connected,
peer: health.peer,
listening: health.listening,
message: health.message,
extensionCatalog: (snapshot?.extension?.catalog || []).map((item) => ({
id: item.id,
deviceId: item.device_id,
deviceName: item.device_name,
deviceType: item.device_type,
typeLabel: item.type_label,
action: item.action,
name: item.name,
cardUid: item.card_uid,
status: item.status,
enabled: item.enabled,
})),
durableCatalogHydrated: durablePeripheralCatalogHydrated,
durableCatalog: durablePeripheralCatalog.map((item) => ({
id: item.id,
deviceId: item.device_id,
deviceName: item.device_name,
deviceType: item.device_type,
typeLabel: item.type_label,
action: item.action,
cardUid: item.card_uid,
status: item.status,
enabled: item.enabled,
})),
lastAction: snapshot?.extension?.last_action ? {
command: snapshot.extension.last_action.command,
type: snapshot.extension.last_action.type,
} : null,
lastResult: snapshot?.extension?.result ? {
status: snapshot.extension.result.status,
message: snapshot.extension.result.message,
} : null,
nfc: {
revision: nfcSnapshot.revision,
reader: nfcSnapshot.reader,
card: nfcSnapshot.card,
},
detectedExtensionNfcIds: extensionToySnapshot.detected_nfc_ids,
peripheralCardUi: {
sessionId: peripheralCardSessionId,
hiddenDeviceIds: [...hiddenPeripheralDevices.keys()].sort(),
expandedDeviceId: expandedPeripheralDeviceId,
},
pets: petCardSnapshot,
localDevices: state.devices.map((device) => ({
id: device.id,
name: device.name,
type: device.type,
cardUid: device.cardUid || null,
})),
localCapabilities: state.capabilities.map((capability) => ({
id: capability.id,
deviceId: capability.deviceId,
name: capability.name,
slot: capability.slot,
status: capability.status,
verified: capability.verified,
enabled: capability.enabled !== false,
})),
infraredCommands: state.infraredCommands,
});
const render = ({ force = false } = {}) => {
const health = snapshot?.extension_health || {};
healthState = health;
const nextGscuHealthFingerprint = JSON.stringify({
transport: health.transport,
connected: health.connected,
peerConnected: health.peer_connected,
peer: health.peer,
listening: health.listening,
message: health.message,
});
if ($("#extensionGscuTcpDialog")?.open
&& (force || nextGscuHealthFingerprint !== gscuHealthFingerprint)) {
renderGscuTcpDialog(health, false);
}
gscuHealthFingerprint = nextGscuHealthFingerprint;
const embeddedNfc = health.nfc;
if (nfcSnapshot.revision < 0 && embeddedNfc) {
nfcSnapshot = {
revision: Number(embeddedNfc.revision ?? -1),
reader: embeddedNfc.reader || {},
card: embeddedNfc.card || null,
legacyProfiles: Array.isArray(embeddedNfc.legacy_profiles)
? embeddedNfc.legacy_profiles
: [],
};
if (nfcSnapshot.legacyProfiles.length) importLegacyProfiles(nfcSnapshot.legacyProfiles);
if (Array.isArray(embeddedNfc.legacy_bridge_addresses)) {
void migrateLegacyProfiles(embeddedNfc.legacy_bridge_addresses);
}
}
if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
const panel = mount.closest("#v2ExtensionPanel");
if (!force && (panel?.hidden || panel?.classList.contains("is-collapsed"))) {
unifiedExtensionDirty = true;
return false;
}
const renderFingerprint = extensionViewFingerprint(health);
if (!force
&& !unifiedExtensionDirty
&& renderFingerprint === unifiedExtensionFingerprint
&& mount.childElementCount) return false;
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 fallbackCatalog = [...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 persistentCatalog = (
durablePeripheralCatalogHydrated
? durablePeripheralCatalog
: fallbackCatalog
).filter((item) => (
normalizeCardUid(item.card_uid)
&& item.status === "learned"
));
const nfcReader = nfcSnapshot.reader || {};
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
const nfcPet = linkedPetForCard(sensedCardUid);
const grouped = new Map();
persistentCatalog.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 || "扩展硬件",
cardUid: item.card_uid || null,
actions: [],
});
const actionName = item.action || item.name || "已学习能力";
grouped.get(deviceId).actions.push({
id: String(item.id || ""),
name: actionName,
enabled: item.enabled !== false,
risk: item.risk || "low",
slot: item.slot,
});
});
const detectedNfcIds = new Set(extensionToySnapshot.detected_nfc_ids);
const devices = [...grouped.values()].filter(
(device) => detectedNfcIds.has(normalizeCardUid(device.cardUid))
&& !hiddenPeripheralDevices.has(device.id),
);
if (expandedPeripheralDeviceId
&& !devices.some((device) => device.id === expandedPeripheralDeviceId)) {
expandedPeripheralDeviceId = null;
}
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const statusLabel = nfcPet
? petCardSnapshot.publisher?.state === "bursting"
? "宠物消息发送中"
: petCardSnapshot.publisher?.state === "burst_complete"
? "宠物消息已发送"
: petCardSnapshot.publisher?.state === "burst_failed"
? "宠物消息发送失败"
: "已感应宠物 NFC"
: nfcDevice
? "已感应 NFC 外设"
: devices.length
? `${devices.length} 个外设已绑定`
: sensedCardUid
? "当前 NFC 暂无已授权命令"
: nfcReader.online
? "等待 NFC 外设靠近"
: "NFC 读卡器离线";
const gatewayLabel = physicalConnected
? "GSCU1B 已连接"
: health.transport === "simulation"
? "扩展仿真链路"
: listening
? "等待 GSCU1B 接入"
: "红外链路检查中";
const gatewayNote = physicalConnected
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
: health.message || "红外学习器接入后可学习并发射设备动作";
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
: nfcDevice || nfcPet
? "recognized"
: nfcCard?.uid
? "new-card"
: "waiting";
const nfcTitle = nfcPet
? `宠物 · ${nfcPet.pet_name}`
: nfcDevice
? nfcDevice.id
: nfcCard?.uid
? "检测到未绑定 NFC"
: nfcReader.online
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcPet
? `${nfcPet.action} · ${petCardSnapshot.publisher?.topic || "n7/pet"} · 3 帧 / 1 秒`
: nfcDevice
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
mount.dataset.hasPets = String(Boolean(nfcPet));
mount.innerHTML = `
<header class="v2-extension-sync-heading">
<div><h3>外设与扩展能力</h3><div class="v2-extension-sync-actions"><button class="v2-pet-settings-launch" type="button" data-open-pet-settings><i data-lucide="paw-print"></i><span>宠物设置</span></button><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><i data-lucide="scan-line"></i><span>NFC 设备绑定</span></button></div></div>
<span data-state="${nfcPet || devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
</header>
<section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcPet ? "paw-print" : nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<div><strong title="${escapeHtml(nfcNote)}">${escapeHtml(nfcTitle)}</strong>${nfcPet ? "" : `<p>${escapeHtml(nfcNote)}</p>`}${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
<button type="button" data-open-nfc-binding>${nfcPet ? "查看宠物" : nfcDevice ? "查看授权" : nfcCard?.uid ? "关联卡片" : "开始感应"}</button>
</section>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
<div><strong title="${escapeHtml(gatewayNote)}">${gatewayLabel}</strong></div>
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
<button type="button" data-open-gscu-tcp><i data-lucide="settings-2"></i><span>TCP 设置</span></button>
</div>
<div class="v2-extension-device-list">
${nfcPet ? renderDetectedPetCard(nfcPet) : ""}
${devices.length ? devices.map((device) => {
const expanded = expandedPeripheralDeviceId === device.id;
return `
<article class="v2-extension-synced-device" data-expanded="${String(expanded)}" data-extension-synced-device="${escapeHtml(device.id)}">
<header>
<button class="v2-extension-device-toggle" type="button" data-toggle-peripheral-card="${escapeHtml(device.id)}" aria-expanded="${String(expanded)}">
<span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span>
<div><strong title="${escapeHtml(device.type || device.name)}">${escapeHtml(device.name)}</strong></div>
<b>${device.actions.length} 项能力</b>
<i data-lucide="chevron-down"></i>
</button>
<button class="v2-extension-device-hide" type="button" data-hide-peripheral-card="${escapeHtml(device.id)}" data-card-uid="${escapeHtml(device.cardUid)}" title="隐藏${escapeHtml(device.name)}" aria-label="隐藏${escapeHtml(device.name)}"><i data-lucide="eye-off"></i></button>
</header>
${expanded ? `<div class="v2-extension-quick-controls" aria-label="${escapeHtml(device.name)}快捷控制">
${device.actions.map((action) => `<button type="button" data-run-peripheral-quick-action="${escapeHtml(action.id)}" data-risk="${escapeHtml(action.risk)}" ${action.enabled && action.id ? "" : "disabled"}>${escapeHtml(action.name)}${action.enabled ? "" : "(已停用)"}</button>`).join("")}
</div>` : ""}
</article>`;
}).join("") : nfcPet ? "" : `
<div class="v2-extension-empty">
<i data-lucide="scan-line"></i><div><strong>等待 NFC 外设靠近</strong><span>检测到已绑定的 NFC 后,外设将在本次运行期间持续显示</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "暂无外设联动")}</strong></div>
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
</footer>`;
unifiedExtensionFingerprint = renderFingerprint;
unifiedExtensionDirty = false;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
};
renderUnifiedExtensionView = render;
window.addEventListener("cognitive-agent:display-sync", (event) => {
snapshot = event.detail || null;
const extensionSnapshot = snapshot?.extension;
if (
Array.isArray(extensionSnapshot?.catalog)
&& extensionSnapshot?.result?.catalog_revision
) {
applyDurablePeripheralCatalog(
extensionSnapshot.catalog,
extensionSnapshot.result.catalog_revision,
{ render: false },
);
}
if (snapshot?.extension_health?.nfc) {
applyNfcSnapshot(snapshot.extension_health.nfc, { announce: false });
}
render();
});
window.addEventListener("cognitive-agent:side-panel-visibility", (event) => {
if (event.detail?.panel === "extensions" && event.detail?.visible) {
render({ force: true });
}
});
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
render();
renderNfcDialog();
startNfcPolling();
void hydratePetCards().catch((error) => {
console.warn("恢复后端宠物卡片失败。", error);
});
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
return syncPeripheralCatalog();
});
return true;
}
function init() {
if (buildUnifiedExtensionSync()) {
void migrateLegacyProfiles();
return;
}
if (!buildShell()) return;
initializeDesktopPet();
bindEvents();
renderAll();
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
startHealthPolling();
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
void migrateLegacyProfiles();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
document.addEventListener("DOMContentLoaded", init);
}());