(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 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 nfcPollTimer = null;
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set();
let renderUnifiedExtensionView = null;
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) => ({
"&": "&",
"<": "<",
">": ">",
"'": "'",
'"': """,
})[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 = `
EXTENSION DESK
控制台
工作模式
配置信息
玩具信息
`;
sidebar.insertBefore(extensionSidebar, $(".v2-sidebar-footer"));
const extensionFooter = document.createElement("footer");
extensionFooter.className = "v2-sidebar-footer v2-extension-footer";
extensionFooter.innerHTML = `
自扩展控制链路正在检查 GSCU1B
`;
sidebar.append(extensionFooter);
const root = document.createElement("section");
root.className = "v2-self-extension";
root.id = "v2SelfExtension";
root.setAttribute("aria-label", "自扩展工作台");
root.innerHTML = `
SELF-EXTENSION外设玩具能力学习
添加外设硬件,学习并验证可执行的遥控动作。
LEARNED ACTIONS
玩具动作
0 项可执行
VEHICLE ACTIONS
车端设备动作
同源安全编排
CH0 / CH1等待权威车端状态
`;
moduleSwitcher.insertAdjacentElement("afterend", root);
buildDialogs();
return true;
}
function buildDialogs() {
const layer = document.createElement("div");
layer.className = "v2-extension-dialog-layer";
layer.innerHTML = `
`;
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) => ``).join("")
: "学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。
";
}
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} 学习完成,槽位回读已确认。` : `${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} 的控制对象与执行动作已保存。`, "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 = [
`${channelLabel}真实车况 · TURN ${Number.isFinite(turn) ? turn : "-"} · ${escapeHtml(requestLabel)}`,
];
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(`FB${feedback.length}/${payload.physical_feedback.length} MATCH · ${escapeHtml(frames.join(" / ") || channelLabel)}`);
}
const driver = payload.actuator_state?.driver || {};
const passenger = payload.actuator_state?.passenger || {};
if (isPhysicalVehicleSource(passenger.ventilation?.feedback_source)) {
items.push(`P-VENT${passenger.ventilation.state === "on" ? `ON L${escapeHtml(passenger.ventilation.level)}` : "OFF"}`);
}
if (isPhysicalVehicleSource(passenger.heating?.feedback_source)) {
items.push(`P-HEAT${passenger.heating.state === "on" ? `ON L${escapeHtml(passenger.heating.level)}` : "OFF"}`);
}
const passengerPosition = passenger.position || {};
Object.entries(passengerPosition).forEach(([axis, value]) => {
if (!isPhysicalVehicleSource(value?.feedback_source)) return;
items.push(`P-SEAT${escapeHtml(axis)} ${escapeHtml(value.position_pct ?? "-")}%`);
});
const ventilation = driver.ventilation;
const heating = driver.heating;
if (ventilation?.feedback_source === "physical_can_ch1") {
items.push(`VENT${ventilation.state === "on" ? `ON L${escapeHtml(ventilation.level)}` : "OFF"}`);
}
if (heating?.feedback_source === "physical_can_ch1") {
items.push(`HEAT${heating.state === "on" ? `ON L${escapeHtml(heating.level)}` : "OFF"}`);
}
const massage = driver.massage;
if (massage?.feedback_source === "physical_can_ch1") {
items.push(`MASSAGE${escapeHtml(massage.mode || "off")} · ${escapeHtml(massage.strength || "off")}`);
}
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(`AC${escapeHtml(climate.power || climate.ac || "unknown")} · ${escapeHtml(temperature)}℃ · FAN ${escapeHtml(climate.fan_level ?? "-")} · ${escapeHtml(outletMode)}`);
}
const roof = payload.domain_state?.roof;
if (roof?.feedback_source === "physical_can_ch1") {
items.push(`ROOF${escapeHtml(roof.shade_position_pct ?? "-")}% · ${escapeHtml(roof.motion_state || "unknown")}`);
}
const frontLeftWindow = payload.domain_state?.window?.positions?.front_left;
if (frontLeftWindow?.feedback_source === "physical_can_ch1") {
items.push(`WINDOW左前 ${escapeHtml(frontLeftWindow.position_pct ?? "-")}%`);
}
const interiorLight = payload.domain_state?.interior_light;
if (interiorLight?.feedback_source === "physical_can_ch1") {
items.push(`INTERIOR${escapeHtml(interiorLight.power || "unknown")}`);
}
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(`AMBIENT${escapeHtml(ambientLight.power || "unknown")} · L${escapeHtml(brightness)} · C${escapeHtml(color)}`);
}
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 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
? ``
: `尚未添加具体硬件`;
return `
${type.label}${selector}
${learned}/${capabilities.length}${selected ? `` : ""}
${visible.map((capability) => renderCapability(capability, typeKey, Boolean(selected))).join("")}
`;
}
function renderCapability(capability, typeKey, hasDevice) {
const learned = capability.status === "learned" && validSlot(capability.slot);
if (!hasDevice) {
return ``;
}
return `
${learned ? `` : ``}
`;
}
function renderControl() {
const learned = learnedCapabilities();
$("#extensionLearnedTotal").textContent = `${learned.length} 项可执行`;
if (!learned.length) {
$("#extensionLearnedGrid").innerHTML = `还没有已学习动作切换到学习模式,为外设玩具录入遥控能力。
`;
} 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 ``;
}).join("");
}
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => {
const execution = state.vehicleExecutions?.[device.id];
const executionState = execution?.state || "idle";
return `
${device.parameters.map((parameter) => `${escapeHtml(parameter)}`).join("")}
`;
}).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]) => `
`).join("");
$("#extensionPresetPreview").innerHTML = TOY_TYPES[selectedAddType].presets
.map((item) => `${escapeHtml(item.name)}`)
.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}”`;
$("#extensionLearnPath").textContent = relearning
? `${type.label} · ${device.name} · 将覆盖 IR ${String(targetSlot).padStart(3, "0")} 原信号`
: `${type.label} · ${device.name}`;
$("#extensionLearnIcon").innerHTML = ``;
$("#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));
}
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
if (!catalog.length) 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) => `
${String(index + 1).padStart(2, "0")}${escapeHtml(step.label || "待执行动作")}`).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();
$$('[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 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 renderNfcDialog() {
const stage = $("#extensionNfcStage");
const body = $("#extensionNfcBody");
const status = $("#extensionNfcReaderStatus");
const badge = $("#extensionNfcReaderBadge");
if (!stage || !body || !status || !badge) return;
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(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 = `NFC READER读卡器尚未就绪${escapeHtml(nfcReaderNote(reader))}
`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
body.innerHTML = `WAITING FOR NFC请将 NFC 卡靠近读卡器让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。
`;
} else if (!linkedDevice) {
stage.dataset.state = "new-card";
body.innerHTML = `检测到未绑定 NFC${escapeHtml(card.uid)}请选择设备 ID;控制对象名称仅作为调试辅助说明。
${deviceGroups.length ? `绑定对象(设备 ID)关联后,该设备的全部命令默认允许执行,可再逐项取消。 ` : `暂无可绑定的设备 ID请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。
`}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
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 = `
NFC UID · ${escapeHtml(card.uid)}${escapeHtml(linkedDevice.id)}控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权
COMMAND AUTHORIZATION允许此 NFC 执行的命令
${enabledCount}/${capabilities.length} 已选择
${capabilities.length ? `${capabilities.map((capability) => ``).join("")}
` : `该设备 ID 暂无有效命令槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。
`}
复选框状态会持久化为自然语言执行白名单。
`;
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function buildUnifiedNfcDialog() {
if ($("#extensionNfcDialog")) return;
const dialog = document.createElement("dialog");
dialog.className = "v2-extension-dialog v2-nfc-dialog";
dialog.id = "extensionNfcDialog";
dialog.innerHTML = `
`;
document.body.append(dialog);
}
async function syncPeripheralCatalog(device = null) {
try {
await api("display-sync", {
method: "POST",
body: JSON.stringify({ peripherals: peripheralCatalog(), device }),
});
} catch (error) {
console.warn("外设能力同步失败", error);
}
}
function bindCardToDevice(device, cardUid) {
if (!device || !cardUid) return;
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();
void syncPeripheralCatalog(device);
}
function bindCurrentNfcToExisting() {
const deviceId = $("#extensionNfcExistingDevice")?.value;
reconcileDevicesAndCapabilitiesFromSlots();
const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid;
if (!device || !cardUid) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = true;
});
bindCardToDevice(device, cardUid);
renderNfcDialog();
showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success");
}
function unbindCurrentNfc() {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
device.cardUid = null;
saveState();
renderAll();
void syncPeripheralCatalog(device);
renderNfcDialog();
showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`);
}
function updateNfcCapabilityAuthorization(capabilityId, enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const capability = configuredCapabilities(device?.id).find(
(item) => item.id === capabilityId,
);
if (!device || !capability) return;
capability.enabled = enabled;
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
function updateAllNfcCapabilityAuthorizations(enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) 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);
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
void syncPeripheralCatalog(device);
} 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() {
renderNfcDialog();
openDialog("extensionNfcDialog");
void refreshNfcStatus();
}
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
});
dialog.addEventListener("change", (event) => {
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
capability.dataset.nfcCapabilityEnabled,
capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) 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 render = () => {
const health = snapshot?.extension_health || {};
healthState = health;
if ($("#extensionGscuTcpDialog")?.open) renderGscuTcpDialog(health, false);
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);
}
}
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
const sharedCatalog = Array.isArray(snapshot?.extension?.catalog)
? snapshot.extension.catalog
: [];
const mergedCatalog = [...sharedCatalog, ...peripheralCatalog()]
.filter((item, index, catalog) => {
const identity = item.id || `${item.device_id}:${item.action}`;
return catalog.findIndex((candidate) => (
candidate.id || `${candidate.device_id}:${candidate.action}`
) === identity) === index;
});
const nfcReader = nfcSnapshot.reader || {};
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
const activeCatalog = sensedCardUid
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
&& item.status === "learned"
&& item.enabled !== false
))
: [];
const grouped = new Map();
activeCatalog.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: [],
});
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
});
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = devices.length
? "已感应 NFC 外设"
: 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
? "recognized"
: nfcCard?.uid
? "new-card"
: "waiting";
const nfcTitle = nfcDevice
? nfcDevice.id
: nfcCard?.uid
? "检测到未绑定 NFC"
: nfcReader.online
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcDevice
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
mount.innerHTML = `
靠近读卡器后${escapeHtml(nfcTitle)}${escapeHtml(nfcNote)}
${nfcCapabilities.length ? `
${nfcCapabilities.slice(0, 6).map((capability) => `${escapeHtml(capability.name)}`).join("")}
` : ""}
${gatewayLabel}${escapeHtml(gatewayNote)}
${String(health.transport || "GSCU").toUpperCase()}
${devices.length ? devices.map((device) => `
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : `
${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}
`}
`;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
};
renderUnifiedExtensionView = render;
window.addEventListener("cognitive-agent:display-sync", (event) => {
snapshot = event.detail || null;
if (snapshot?.extension_health?.nfc) {
applyNfcSnapshot(snapshot.extension_health.nfc, { announce: false });
}
render();
});
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
render();
renderNfcDialog();
startNfcPolling();
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
})
.finally(() => 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);
}());