(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 petCardSnapshot = { revision: -1, cards: [], publisher: { state: "stopped", topic: "n7/pet", burst_count: 3, burst_window_ms: 1000, burst_attempt_count: 0, burst_published_count: 0, active_pet_id: null, }, }; let selectedPetCardId = null; const petCardDrafts = new Map(); let nfcPollTimer = null; let 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 = `

控制台

工作模式
配置信息
当前车型腾势 N7
CHECK
推理模型模型检测中
CHECK
红外学习器正在检查 GSCU1B
CHECK
玩具信息
已接入外设玩具0
已学习能力0
`; 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 = `

外设玩具能力学习

`; const homepageHeader = moduleSwitcher.closest(".v2-mode-rail") || moduleSwitcher; homepageHeader.insertAdjacentElement("afterend", root); buildDialogs(); return true; } function buildDialogs() { const layer = document.createElement("div"); layer.className = "v2-extension-dialog-layer"; layer.innerHTML = `

添加外设玩具

将建立的预设动作

学习遥控动作

1配置2接收3验证
目标玩具--
待学习动作--

重置学习

红外学习器 TCP 控制

正在读取连接状态--
CHECK
TCP 动作调试
高级动作
已学习红外信号
JSON 调试结果
等待操作

宠物设置

添加或编辑宠物资料;保存后可在 NFC 设备授权中绑定卡片。

`; 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) => `
${group.deviceId ? "DEVICE ID" : "UNASSIGNED"}${escapeHtml(group.deviceId || "未分配设备 ID")}${escapeHtml(group.objectNames.join("、") || "尚无控制对象描述")}
${group.commands.length} 条命令
${group.commands.map((command) => `
红外槽位IR ${String(command.slot).padStart(3, "0")}
`).join("")}
`).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 `
${escapeHtml(device.name)}${escapeHtml(device.code)}
${escapeHtml(device.channel)}
${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}”`; $("#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(); bindPetSettingsDialogEvents(); $$('[data-extension-mode]').forEach((button) => { button.addEventListener("click", () => setMode(button.dataset.extensionMode)); }); $("#extensionHardwareGrid").addEventListener("click", (event) => { const add = event.target.closest("[data-add-type]"); if (add) return openAdd(add.dataset.addType); const pageButton = event.target.closest("[data-extension-page]"); if (pageButton) { const typeKey = pageButton.dataset.extensionPage; pages[typeKey] = Math.max( 0, (pages[typeKey] || 0) + Number(pageButton.dataset.pageDirection), ); renderHardwareGrid(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); return; } const remove = event.target.closest("[data-delete-extension-device]"); if (remove) return deleteHardware(remove.dataset.deleteExtensionDevice); const forget = event.target.closest("[data-forget-extension-capability]"); if (forget) return forgetCapability(forget.dataset.forgetExtensionCapability); const learn = event.target.closest("[data-learn-extension-capability]"); if (learn) return openLearn(learn.dataset.learnExtensionCapability); const run = event.target.closest("[data-run-extension-capability]"); if (run) runCapability(run.dataset.runExtensionCapability); }); $("#extensionHardwareGrid").addEventListener("change", (event) => { const selector = event.target.closest("[data-extension-device-select]"); if (!selector) return; state.selectedDeviceByType[selector.dataset.extensionDeviceSelect] = selector.value; pages[selector.dataset.extensionDeviceSelect] = 0; saveState(); renderHardwareGrid(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); }); $("#extensionTypeChooser").addEventListener("click", (event) => { const choice = event.target.closest("[data-extension-type-choice]"); if (!choice) return; selectedAddType = choice.dataset.extensionTypeChoice; renderTypeChooser(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); }); $("#extensionAddForm").addEventListener("submit", addHardware); $("#extensionContinueLearn").addEventListener("click", () => { try { learning.draft = collectLearningDraft(); showLearnPage(2); } catch (error) { showToast(error.message, "error"); } }); $("#extensionStartLearn").addEventListener("click", startLearning); $("#extensionCancelLearn").addEventListener("click", cancelLearning); $("#extensionTestEmit").addEventListener("click", testLearning); $("#extensionConfirmLearn").addEventListener("click", () => { void confirmLearning(); }); $("#extensionLearnDialog").addEventListener("cancel", (event) => { if (learning.phase === "listening") { event.preventDefault(); cancelLearning().then(() => closeDialog("extensionLearnDialog")); } }); $("#extensionLearnedGrid").addEventListener("click", (event) => { const switcher = event.target.closest("[data-switch-extension-mode]"); if (switcher) return setMode(switcher.dataset.switchExtensionMode); const run = event.target.closest("[data-run-extension-capability]"); if (run) runCapability(run.dataset.runExtensionCapability); }); $("#extensionExecuteButton").addEventListener( "click", () => executeInstruction($("#extensionUtterance").value), ); $("#extensionUtterance").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); executeInstruction(event.currentTarget.value); } }); $("#extensionFooterRefresh").addEventListener("click", () => refreshHealth(true)); $("#extensionResetButton").addEventListener("click", () => { $("#extensionResetConfirm").value = ""; $("#extensionConfirmReset").disabled = true; openDialog("extensionResetDialog"); }); $("#extensionResetConfirm").addEventListener("input", (event) => { $("#extensionConfirmReset").disabled = event.currentTarget.value.trim() !== "清空"; }); $("#extensionConfirmReset").addEventListener("click", resetSession); $$('[data-close-extension-dialog]').forEach((button) => { button.addEventListener("click", async () => { if (button.dataset.closeExtensionDialog === "extensionLearnDialog" && learning.phase === "listening") await cancelLearning(); closeDialog(button.dataset.closeExtensionDialog); }); }); window.addEventListener("cognitive-agent:module-change", (event) => { if (event.detail?.module === "self-extension") { setMode(activeMode); refreshHealth(); } else { syncSharedTwinMount(); } }); window.addEventListener("cognitive-agent:result", (event) => { renderAuthoritativeVehicleState(event.detail); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && document.body.dataset.module === "self-extension") refreshHealth(); }); window.addEventListener("pagehide", stopHealthPolling); window.addEventListener("pageshow", () => { startHealthPolling(); if (document.visibilityState === "visible" && document.body.dataset.module === "self-extension") refreshHealth(); }); } function linkedDeviceForCard(uidValue) { const uidValueNormalized = normalizeCardUid(uidValue); if (!uidValueNormalized) return null; return state.devices.find( (device) => normalizeCardUid(device.cardUid) === uidValueNormalized, ) || null; } function inferLegacyToyType(profile) { const text = [ profile?.name, profile?.model, ...(Array.isArray(profile?.actions) ? profile.actions.flatMap((action) => [action?.name, action?.code]) : []), ].join(" ").toLowerCase(); if (/(氛围|灯|亮度|颜色|rgb|light|colour|color)/i.test(text)) return "light"; if (/(声音|音乐|音量|播放|sound|music|volume)/i.test(text)) return "sound"; if (/(玩偶|娃娃|唱歌|说话|doll)/i.test(text)) return "doll"; if (/(机械|升降|伸缩|正转|反转|mechanical)/i.test(text)) return "mechanical"; if (/(前进|后退|左转|右转|移动|小车|action|motion)/i.test(text)) return "action"; return "other"; } function legacyIdentifier(prefix, value) { const suffix = String(value || "profile") .replace(/[^a-z0-9_-]+/gi, "-") .replace(/^-+|-+$/g, "") .slice(0, 64) || "profile"; let candidate = `${prefix}-${suffix}`; let index = 2; while (state.devices.some((device) => device.id === candidate)) { candidate = `${prefix}-${suffix}-${index}`; index += 1; } return candidate; } function legacyCapabilityRisk(action) { const text = `${action?.name || ""} ${action?.code || ""}`; return /(前进|后退|左转|右转|移动|旋转|升降|伸缩)/.test(text) ? "high" : "low"; } function importLegacyProfiles(value) { if (!Array.isArray(value)) return { devices: 0, actions: 0 }; let importedDevices = 0; let importedActions = 0; let changed = false; value.forEach((profile, profileIndex) => { if (!profile || typeof profile !== "object") return; const legacyActions = Array.isArray(profile.actions) ? profile.actions.filter((action) => action?.learned && validSlot(action.slot)) : []; const cardUid = normalizeCardUid(profile.cardUid); if (!cardUid && !legacyActions.length) return; const legacyProfileId = String(profile.id || `profile-${profileIndex}`); const type = inferLegacyToyType(profile); let device = cardUid ? linkedDeviceForCard(cardUid) : null; device ||= state.devices.find((item) => item.legacyProfileId === legacyProfileId) || null; if (!device) { device = { id: legacyIdentifier("legacy-toy", legacyProfileId), name: String(profile.name || `玩具 ${profile.toyNumber || nextToyNumber()}`), type, aliases: [String(profile.name || "玩具")], toyNumber: String(profile.toyNumber || nextToyNumber()), cardUid: cardUid || null, model: String(profile.model || "ir-toy-learning 导入外设"), legacyProfileId, importedFrom: "ir-toy-learning", createdAt: new Date().toISOString(), }; state.devices.push(device); importedDevices += 1; changed = true; } else { const before = JSON.stringify([ device.name, device.type, device.toyNumber, device.cardUid, device.legacyProfileId, ]); if (!device.cardUid && cardUid) device.cardUid = cardUid; if (!device.toyNumber && profile.toyNumber) device.toyNumber = String(profile.toyNumber); if (!device.legacyProfileId) device.legacyProfileId = legacyProfileId; if (device.type === "other" && type !== "other") device.type = type; if (/^玩具\s*\d+$/i.test(device.name) && profile.name) device.name = String(profile.name); device.importedFrom ||= "ir-toy-learning"; changed ||= before !== JSON.stringify([ device.name, device.type, device.toyNumber, device.cardUid, device.legacyProfileId, ]); } state.selectedDeviceByType[device.type] = device.id; legacyActions.forEach((action, actionIndex) => { const slot = Number(action.slot); const name = String(action.name || action.code || `红外动作 ${slot}`); const legacyActionId = String(action.id || `${legacyProfileId}:${actionIndex}:${slot}`); const aliases = [...new Set([ name, typeof action.code === "string" ? action.code : "", ].filter(Boolean))]; let capability = state.capabilities.find((item) => ( item.deviceId === device.id && (item.legacyActionId === legacyActionId || item.name === name) )); if (!capability) { capability = { id: uid("legacy-cap"), deviceId: device.id, key: `legacy_${slot}`, createdAt: new Date().toISOString(), }; state.capabilities.push(capability); importedActions += 1; changed = true; } // A signal learned again in N7Web is authoritative. Repeated NFC snapshots // may still contain the old ir-toy-learning metadata and must not roll it back. if (capability.signalSource === "n7web") return; const before = JSON.stringify(capability); Object.assign(capability, { name, aliases, category: type === "light" ? "effect" : "custom", risk: legacyCapabilityRisk(action), slot, status: "learned", verified: true, enabled: true, fingerprint: action.fingerprint || capability.fingerprint || null, learnedAt: action.updatedAt || capability.learnedAt || new Date().toISOString(), legacyActionId, importedFrom: "ir-toy-learning", }); changed ||= before !== JSON.stringify(capability); }); }); if (changed) { state.sessionStartedAt ||= new Date().toISOString(); saveState(); renderAll(); void syncPeripheralCatalog(); } return { devices: importedDevices, actions: importedActions }; } function readSameOriginLegacyProfiles() { try { return JSON.parse(localStorage.getItem(LEGACY_PROFILE_KEY) || "null"); } catch (_error) { return null; } } function requestLegacyProfiles(bridgeUrl) { return new Promise((resolve) => { const iframe = document.createElement("iframe"); const bridgeOrigin = new URL(bridgeUrl).origin; const requestId = uid("legacy-import"); let settled = false; const cleanup = (profiles = null) => { if (settled) return; settled = true; window.clearTimeout(timer); window.removeEventListener("message", receive); iframe.remove(); resolve(profiles); }; const request = () => iframe.contentWindow?.postMessage({ type: "N7WEB_REQUEST_IR_TOY_PROFILES", requestId, }, bridgeOrigin); const receive = (event) => { if (event.origin !== bridgeOrigin || event.source !== iframe.contentWindow) return; if (event.data?.type === "IR_TOY_LEGACY_BRIDGE_READY") { request(); return; } if (event.data?.type !== "IR_TOY_LEGACY_PROFILES" || event.data.requestId !== requestId) return; cleanup(Array.isArray(event.data.profiles) ? event.data.profiles : []); }; const timer = window.setTimeout(() => cleanup(), 1800); iframe.src = bridgeUrl; iframe.hidden = true; iframe.setAttribute("aria-hidden", "true"); iframe.addEventListener("load", request, { once: true }); window.addEventListener("message", receive); document.body.append(iframe); }); } function hasImportableLegacyProfiles(profiles) { return Array.isArray(profiles) && profiles.some((profile) => ( normalizeCardUid(profile?.cardUid) || (Array.isArray(profile?.actions) && profile.actions.some((action) => action?.learned && validSlot(action.slot))) )); } function legacyBridgeUrls(addresses = []) { const currentHostUrl = window.location.hostname ? `http://${window.location.hostname}:3001/n7web-profile-bridge.html` : null; const addressUrls = (Array.isArray(addresses) ? addresses : []) .filter((address) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(String(address))) .map((address) => `http://${address}:3001/n7web-profile-bridge.html`); return [...new Set([currentHostUrl, ...LEGACY_PROFILE_BRIDGE_URLS, ...addressUrls].filter(Boolean))]; } async function migrateLegacyProfiles(addresses = []) { if (legacyMigrationCompleted) return; if (!legacySameOriginChecked) { legacySameOriginChecked = true; const sameOriginProfiles = readSameOriginLegacyProfiles(); if (hasImportableLegacyProfiles(sameOriginProfiles)) { const imported = importLegacyProfiles(sameOriginProfiles); legacyMigrationCompleted = true; if (imported.devices || imported.actions) { showToast(`已迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success"); } return; } } for (const bridgeUrl of legacyBridgeUrls(addresses)) { if (legacyBridgeAttempts.has(bridgeUrl)) continue; legacyBridgeAttempts.add(bridgeUrl); const profiles = await requestLegacyProfiles(bridgeUrl); if (!hasImportableLegacyProfiles(profiles)) continue; const imported = importLegacyProfiles(profiles); legacyMigrationCompleted = true; if (imported.devices || imported.actions) { showToast(`已从 ir-toy-learning 迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success"); } return; } } function nextToyNumber() { const highest = state.devices.reduce((current, device) => { const parsed = Number.parseInt(device.toyNumber || "", 10); return Number.isFinite(parsed) ? Math.max(current, parsed) : current; }, 0); return String(highest + 1).padStart(3, "0"); } function petCards() { return Array.isArray(petCardSnapshot.cards) ? petCardSnapshot.cards : []; } function petCardHasUnsavedChanges(card) { const draft = petCardDrafts.get(card?.id); if (!card || !draft) return false; return String(draft.pet_name ?? "") !== String(card.pet_name ?? "") || String(draft.action ?? "") !== String(card.action ?? ""); } function petCardCanBind(card) { return Boolean(card?.saved) && !petCardHasUnsavedChanges(card); } function linkedPetForCard(uidValue) { const normalizedUid = normalizeCardUid(uidValue); if (!normalizedUid) return null; return petCards().find( (card) => normalizeCardUid(card.nfc_id) === normalizedUid, ) || null; } function petCardStatus(card) { const publisher = petCardSnapshot.publisher || {}; const active = publisher.active_pet_id === card.id; if (!card.saved || petCardHasUnsavedChanges(card)) { return { state: "editing", label: "待保存" }; } if (active && publisher.state === "bursting") { return { state: "bursting", label: "3 帧发送中" }; } if (active && publisher.state === "burst_complete") { return { state: "complete", label: "已发送 3 帧" }; } if (active && publisher.state === "burst_failed") { return { state: "error", label: "3 帧发送失败" }; } if (card.nfc_id) return { state: "bound", label: "已绑定 NFC" }; return { state: "unbound", label: "待绑定" }; } function applyPetCardSnapshot(value, { render = true } = {}) { if (!value || typeof value !== "object") return false; const parsedRevision = Number(value.revision ?? -1); const nextRevision = Number.isFinite(parsedRevision) ? parsedRevision : -1; if (nextRevision < petCardSnapshot.revision) return false; const cards = Array.isArray(value.cards) ? value.cards.filter((card) => card && typeof card === "object") : []; const publisher = value.publisher && typeof value.publisher === "object" ? value.publisher : {}; const next = { revision: nextRevision, cards, publisher: { state: String(publisher.state || "stopped"), topic: String(publisher.topic || "n7/pet"), burst_count: Number(publisher.burst_count || 3), burst_window_ms: Number(publisher.burst_window_ms || 1000), burst_attempt_count: Number(publisher.burst_attempt_count || 0), burst_published_count: Number(publisher.burst_published_count || 0), active_pet_id: publisher.active_pet_id || null, active_nfc_id: publisher.active_nfc_id || null, last_published_at: publisher.last_published_at || null, last_delivery_status: publisher.last_delivery_status || null, publish_count: Number(publisher.publish_count || 0), last_error: publisher.last_error || null, }, }; const cardsChanged = JSON.stringify(petCardSnapshot.cards) !== JSON.stringify(cards); const publisherChanged = JSON.stringify(petCardSnapshot.publisher) !== JSON.stringify(next.publisher); if (!cardsChanged && !publisherChanged && next.revision === petCardSnapshot.revision) { return false; } petCardSnapshot = next; if (selectedPetCardId && !cards.some((card) => card.id === selectedPetCardId)) { selectedPetCardId = null; } if (render) { renderNfcDialog(); if (cardsChanged) { renderPetSettingsDialog(); renderUnifiedExtensionView?.(); } else updatePetPublisherIndicators(); } return true; } async function hydratePetCards() { applyPetCardSnapshot(await api("pets")); } function nextPetCardName() { const used = new Set(petCards().map((card) => String(card.pet_name || ""))); let index = petCards().length + 1; while (used.has(`宠物 ${index}`)) index += 1; return `宠物 ${index}`; } async function createPetCard(button = null) { if (button) button.disabled = true; try { const response = await api("pets", { method: "POST", body: JSON.stringify({ pet_name: nextPetCardName(), action: "待设置", }), }); selectedPetCardId = response.card?.id || null; applyPetCardSnapshot(response); showToast("已添加宠物卡片,请编辑并保存后再绑定 NFC。", "success"); window.setTimeout(() => { const card = $(`[data-pet-card-id="${selectedPetCardId}"]`, $("#extensionPetSettingsDialog")); card?.scrollIntoView({ behavior: "smooth", block: "nearest" }); $('[data-pet-field="pet_name"]', card)?.focus(); }, 0); } catch (error) { showToast(error.message, "error"); } finally { if (button?.isConnected) button.disabled = false; } } async function savePetCard(article, button = null) { const cardId = article?.dataset.petCardId; const petName = $('[data-pet-field="pet_name"]', article)?.value || ""; const action = $('[data-pet-field="action"]', article)?.value || ""; if (!cardId) return; if (button) button.disabled = true; try { const response = await api(`pets/${encodeURIComponent(cardId)}`, { method: "PUT", body: JSON.stringify({ pet_name: petName, action }), }); petCardDrafts.delete(cardId); applyPetCardSnapshot(response); showToast("宠物卡片已保存,可通过 NFC 设备授权继续绑定。", "success"); } catch (error) { showToast(error.message, "error"); } finally { if (button?.isConnected) button.disabled = false; } } async function deletePetCard(article, button = null) { const cardId = article?.dataset.petCardId; const petName = $('[data-pet-field="pet_name"]', article)?.value || "该宠物"; if (!cardId || !window.confirm(`确认删除“${petName}”宠物卡片吗?`)) return; if (button) button.disabled = true; try { const response = await api(`pets/${encodeURIComponent(cardId)}`, { method: "DELETE", }); petCardDrafts.delete(cardId); applyPetCardSnapshot(response); showToast("宠物卡片已删除。", "success"); } catch (error) { showToast(error.message, "error"); if (button?.isConnected) button.disabled = false; } } async function bindCurrentNfcToPet() { const petId = $("#extensionNfcExistingPet")?.value || selectedPetCardId; if (!petId) return; const petCard = petCards().find((card) => card.id === petId); if (!petCardCanBind(petCard)) { showToast("请先在“宠物设置”中完成编辑并保存。", "error"); return; } const button = $("[data-nfc-bind-pet]"); if (button) button.disabled = true; try { const response = await api(`pets/${encodeURIComponent(petId)}/bind-current-nfc`, { method: "POST", body: "{}", }); selectedPetCardId = petId; applyPetCardSnapshot(response); renderNfcDialog(); showToast(`NFC 已绑定宠物“${response.card?.pet_name || "宠物"}”。`, "success"); } catch (error) { showToast(error.message, "error"); } finally { if (button?.isConnected) button.disabled = false; } } async function unbindPetCard(petId) { if (!petId) return; const button = $(`[data-nfc-unbind-pet="${petId}"]`); if (button) button.disabled = true; try { const response = await api(`pets/${encodeURIComponent(petId)}/unbind`, { method: "POST", body: "{}", }); applyPetCardSnapshot(response); renderNfcDialog(); showToast("已解除宠物卡片的 NFC 绑定。", "success"); } catch (error) { showToast(error.message, "error"); } finally { if (button?.isConnected) button.disabled = false; } } function renderNfcPetBinding(linkedPet) { if (linkedPet) { const status = petCardStatus(linkedPet); return `
已绑定宠物卡片${escapeHtml(linkedPet.pet_name)}

${escapeHtml(linkedPet.action)} · 本次识别仅发送 3 帧

${escapeHtml(linkedPet.nfc_id)}${escapeHtml(status.label)}
`; } if (!petCards().length) { return `
暂无宠物卡片请先打开“宠物设置”添加并保存宠物资料。
`; } const bindableCards = petCards().filter(petCardCanBind); if (!bindableCards.length) { return `
暂无可绑定的已保存卡片请先在“宠物设置”中完成宠物名与动作编辑并保存。
`; } const selected = bindableCards.some((card) => card.id === selectedPetCardId) ? selectedPetCardId : bindableCards[0].id; return `
绑定宠物卡片
卡片 ID 由当前读卡器中的 NFC 自动写入,不能手动编辑;同一 NFC 只绑定一张宠物卡片。
`; } function renderPetCardEditor() { const publisher = petCardSnapshot.publisher || {}; const cards = petCards(); return `

宠物卡片

${cards.length ? cards.map((card) => { const draft = petCardDrafts.get(card.id) || card; const status = petCardStatus(card); const activeError = publisher.active_pet_id === card.id ? String(publisher.last_error || "") : ""; return `
${escapeHtml(draft.pet_name || card.pet_name)}${escapeHtml(status.label)}
`; }).join("") : `
还没有宠物卡片
`}
`; } function renderPetSettingsDialog() { const body = $("#extensionPetSettingsBody"); if (!body) return; body.innerHTML = renderPetCardEditor(); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } function openPetSettingsDialog() { renderPetSettingsDialog(); openDialog("extensionPetSettingsDialog"); } function bindPetSettingsDialogEvents() { const dialog = $("#extensionPetSettingsDialog"); if (!dialog || dialog.dataset.petSettingsBound === "true") return; dialog.dataset.petSettingsBound = "true"; dialog.addEventListener("click", (event) => { if (event.target.closest('[data-close-extension-dialog="extensionPetSettingsDialog"]')) { closeDialog("extensionPetSettingsDialog"); return; } const addPet = event.target.closest("[data-pet-add]"); const article = event.target.closest("[data-pet-card-id]"); const savePet = event.target.closest("[data-pet-save]"); const deletePet = event.target.closest("[data-pet-delete]"); if (addPet) void createPetCard(addPet); else if (savePet) void savePetCard(article, savePet); else if (deletePet) void deletePetCard(article, deletePet); }); dialog.addEventListener("input", (event) => { const field = event.target.closest("[data-pet-field]"); const article = field?.closest("[data-pet-card-id]"); if (!field || !article) return; const current = petCardDrafts.get(article.dataset.petCardId) || petCards().find((card) => card.id === article.dataset.petCardId) || {}; petCardDrafts.set(article.dataset.petCardId, { ...current, [field.dataset.petField]: field.value, }); const title = $("header strong", article); if (field.dataset.petField === "pet_name" && title) { title.textContent = field.value.trim() || "未命名宠物"; } updatePetPublisherIndicators(); }); } function renderDetectedPetCard(card) { if (!card) return ""; const status = petCardStatus(card); const publisher = petCardSnapshot.publisher || {}; const activeError = publisher.active_pet_id === card.id ? String(publisher.last_error || "") : ""; return `
当前识别宠物${escapeHtml(card.pet_name)}
${escapeHtml(status.label)}
宠物动作
${escapeHtml(card.action)}
卡片 ID
${escapeHtml(card.nfc_id)}
`; } function updatePetPublisherIndicators() { const publisher = petCardSnapshot.publisher || {}; petCards().forEach((card) => { const status = petCardStatus(card); $$(`[data-pet-publish-state="${card.id}"]`).forEach((badge) => { badge.dataset.state = status.state; badge.textContent = status.label; badge.title = publisher.active_pet_id === card.id && publisher.last_error ? String(publisher.last_error) : status.label; }); $$(`[data-pet-card-id="${card.id}"], [data-detected-pet-id="${card.id}"]`).forEach((article) => { article.dataset.petState = status.state; }); }); } function nfcReaderNote(reader = nfcSnapshot.reader) { if (reader?.online) { const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : ""; return `${reader.port || "串口"} 已就绪${firmware}`; } if (reader?.state === "connecting" || reader?.state === "starting") { return "正在查找 PN532 / PCR532 读卡器"; } return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用"; } function 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 linkedPet = linkedPetForCard(card?.uid); const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId); status.textContent = nfcReaderNote(reader); badge.textContent = reader.online ? "READER READY" : "READER OFFLINE"; badge.dataset.state = reader.online ? "online" : "offline"; if (!reader.online) { stage.dataset.state = "offline"; body.innerHTML = `
读卡器尚未就绪

${escapeHtml(nfcReaderNote(reader))}

`; } else if (!card?.uid) { stage.dataset.state = "waiting"; body.innerHTML = `
请将 NFC 卡靠近读卡器

让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到宠物卡片或已配置的设备 ID。

`; } else if (!linkedDevice && !linkedPet) { stage.dataset.state = "new-card"; body.innerHTML = `
检测到未绑定 NFC${escapeHtml(card.uid)}

请选择宠物卡片;也可继续关联已有红外设备 ID。

${renderNfcPetBinding(null)} ${deviceGroups.length ? `
绑定对象(设备 ID)
关联后,该设备的全部命令默认允许执行,可再逐项取消。
` : `
暂无可绑定的设备 ID请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。
`}`; } else if (linkedPet && !linkedDevice) { stage.dataset.state = "recognized"; body.innerHTML = `
NFC UID · ${escapeHtml(card.uid)}${escapeHtml(linkedPet.pet_name)}

宠物动作:${escapeHtml(linkedPet.action)} · 本次识别在 1 秒内发送 3 帧 n7/pet

${renderNfcPetBinding(linkedPet)} ${deviceGroups.length ? `
继续关联红外设备 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} 条命令已授权

${renderNfcPetBinding(linkedPet)}
允许此 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 = `

NFC 设备授权

NFC 设备绑定

正在检查读卡器

READER CHECK
`; 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; if (value.pet_cards) applyPetCardSnapshot(value.pet_cards); 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); const pet = linkedPetForCard(currentUid); if (device) { state.selectedDeviceByType[device.type] = device.id; saveState(); if (announce) { showToast( pet ? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”。` : `已识别设备 ID“${device.id}”。`, "success", ); } void syncPeripheralCatalog(device); } else if (pet) { if (announce) { showToast(`已识别宠物“${pet.pet_name}”,将在 1 秒内发送 3 帧 n7/pet。`, "success"); } } else if (announce) { showToast("检测到未绑定 NFC,请选择宠物卡片或设备 ID。", "success"); } } renderNfcDialog(); renderUnifiedExtensionView?.(); } async function refreshNfcStatus() { try { applyNfcSnapshot(await api("nfc/status")); } catch (error) { applyNfcSnapshot({ revision: Date.now(), reader: { online: false, state: "error", port: "auto", error: error.message }, card: null, }, { announce: false }); } } function startNfcPolling() { if (nfcPollTimer !== null) return; void refreshNfcStatus(); nfcPollTimer = window.setInterval(() => { if (document.visibilityState === "visible") void refreshNfcStatus(); }, NFC_POLL_INTERVAL_MS); } function stopNfcPolling() { if (nfcPollTimer !== null) window.clearInterval(nfcPollTimer); nfcPollTimer = null; } function openNfcDialog() { renderNfcDialog(); openDialog("extensionNfcDialog"); void refreshNfcStatus(); } function bindUnifiedNfcEvents(mount) { mount.addEventListener("click", (event) => { if (event.target.closest("[data-open-pet-settings]")) openPetSettingsDialog(); else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog(); else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog(); }); bindGscuTcpDialogEvents(); bindPetSettingsDialogEvents(); const dialog = $("#extensionNfcDialog"); dialog.addEventListener("click", (event) => { if (event.target.closest("[data-open-pet-settings]")) { closeDialog("extensionNfcDialog"); openPetSettingsDialog(); return; } if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog"); if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting(); if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc(); if (event.target.closest("[data-nfc-bind-pet]")) void bindCurrentNfcToPet(); const unbindPet = event.target.closest("[data-nfc-unbind-pet]"); if (unbindPet) void unbindPetCard(unbindPet.dataset.nfcUnbindPet); }); dialog.addEventListener("change", (event) => { const petSelect = event.target.closest("[data-nfc-pet-select]"); if (petSelect) selectedPetCardId = petSelect.value; const capability = event.target.closest("[data-nfc-capability-enabled]"); if (capability) { 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); } } if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false }); const physicalConnected = health.transport !== "simulation" && Boolean(health.connected || health.peer_connected); const listening = Boolean(health.listening); const sharedCatalog = Array.isArray(snapshot?.extension?.catalog) ? snapshot.extension.catalog : []; const 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 nfcPet = linkedPetForCard(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 statusLabel = nfcPet ? petCardSnapshot.publisher?.state === "bursting" ? "宠物消息发送中" : petCardSnapshot.publisher?.state === "burst_complete" ? "宠物消息已发送" : petCardSnapshot.publisher?.state === "burst_failed" ? "宠物消息发送失败" : "已感应宠物 NFC" : 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 || nfcPet ? "recognized" : nfcCard?.uid ? "new-card" : "waiting"; const nfcTitle = nfcPet ? `宠物 · ${nfcPet.pet_name}` : nfcDevice ? nfcDevice.id : nfcCard?.uid ? "检测到未绑定 NFC" : nfcReader.online ? "等待 NFC 卡靠近" : "NFC 读卡器未连接"; const nfcNote = nfcPet ? `${nfcPet.action} · ${petCardSnapshot.publisher?.topic || "n7/pet"} · 3 帧 / 1 秒` : nfcDevice ? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}` : nfcCard?.uid ? `${nfcCard.uid} · 点击关联设备 ID` : nfcReaderNote(nfcReader); mount.dataset.hasDevices = String(devices.length > 0); mount.dataset.hasPets = String(Boolean(nfcPet)); mount.innerHTML = `

外设与扩展能力

${statusLabel}
${escapeHtml(nfcTitle)}${nfcPet ? "" : `

${escapeHtml(nfcNote)}

`}${nfcCapabilities.length ? `
${nfcCapabilities.slice(0, 6).map((capability) => `${escapeHtml(capability.name)}`).join("")}
` : ""}
${gatewayLabel}
${String(health.transport || "GSCU").toUpperCase()}
${nfcPet ? renderDetectedPetCard(nfcPet) : ""} ${devices.length ? devices.map((device) => `
${escapeHtml(device.name)}
${device.actions.length} 项能力
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : nfcPet ? "" : `
${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 hydratePetCards().catch((error) => { console.warn("恢复后端宠物卡片失败。", error); }); 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); }());