2394 lines
108 KiB
JavaScript
2394 lines
108 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
|
||
const MODE_KEY = "cognitive-agent.extension-mode";
|
||
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
|
||
const SLOT_MIN = 1;
|
||
const SLOT_MAX = 248;
|
||
const ACTION_PAGE_SIZE = 6;
|
||
const HISTORY_LIMIT = 40;
|
||
const MAX_PLAN_STEPS = 12;
|
||
const MIN_PLAN_DELAY_MS = 1;
|
||
const MAX_PLAN_DELAY_MS = 30000;
|
||
const MAX_TOTAL_PLAN_DELAY_MS = 60000;
|
||
const HEALTH_POLL_INTERVAL_MS = 3000;
|
||
const HEALTH_REQUEST_TIMEOUT_MS = 5000;
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
|
||
function 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 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 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: {},
|
||
};
|
||
}
|
||
|
||
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.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,
|
||
};
|
||
}
|
||
|
||
function deviceCapabilities(deviceId) {
|
||
return state.capabilities.filter((item) => item.deviceId === deviceId);
|
||
}
|
||
|
||
function learnedCapabilities(deviceId = null) {
|
||
return state.capabilities.filter((item) => (
|
||
(!deviceId || item.deviceId === deviceId)
|
||
&& item.status === "learned"
|
||
&& item.verified
|
||
&& item.enabled !== false
|
||
&& validSlot(item.slot)
|
||
));
|
||
}
|
||
|
||
function allocateSlot() {
|
||
const used = new Set(learnedCapabilities().map((item) => Number(item.slot)));
|
||
for (let slot = SLOT_MIN; slot <= SLOT_MAX; slot += 1) {
|
||
if (!used.has(slot)) return slot;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function selectedDeviceForType(typeKey) {
|
||
const devices = state.devices.filter((item) => item.type === typeKey);
|
||
const selectedId = state.selectedDeviceByType[typeKey];
|
||
const selected = devices.find((item) => item.id === selectedId) || devices[0] || null;
|
||
if (selected && selected.id !== selectedId) state.selectedDeviceByType[typeKey] = selected.id;
|
||
return selected;
|
||
}
|
||
|
||
function buildShell() {
|
||
const sidebar = $(".v2-sidebar");
|
||
const mainPanel = $(".main-panel");
|
||
const moduleSwitcher = $("#v2ModuleSwitcher");
|
||
if (!sidebar || !mainPanel || !moduleSwitcher) return false;
|
||
|
||
const extensionSidebar = document.createElement("section");
|
||
extensionSidebar.className = "v2-sidebar-section v2-extension-sidebar";
|
||
extensionSidebar.innerHTML = `
|
||
<div class="v2-section-heading">
|
||
<span>EXTENSION DESK</span>
|
||
<h2>控制台</h2>
|
||
</div>
|
||
<span class="v2-field-caption">工作模式</span>
|
||
<div class="v2-extension-mode-switch" role="group" aria-label="工作模式">
|
||
<button type="button" data-extension-mode="study"><i data-lucide="book-open-check"></i><span>学习模式</span></button>
|
||
<button type="button" data-extension-mode="control"><i data-lucide="send"></i><span>控制模式</span></button>
|
||
</div>
|
||
<div class="v2-divider"></div>
|
||
<span class="v2-field-caption">配置信息</span>
|
||
<div class="v2-extension-config-stack">
|
||
<div class="v2-extension-config-card" id="extensionVehicleCard" data-state="checking"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span id="extensionVehicleState">CHECK</span></div>
|
||
<div class="v2-extension-config-card" id="extensionModelCard"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong id="extensionModelName">模型检测中</strong></div><span id="extensionModelState">CHECK</span></div>
|
||
<div class="v2-extension-config-card" id="extensionStudyHealth" data-state="checking"><i data-lucide="radio-tower"></i><div><small>红外学习器</small><strong id="extensionStudyHealthText">正在检查 GSCU1B</strong></div><span id="extensionStudyHealthState">CHECK</span></div>
|
||
</div>
|
||
<div class="v2-divider"></div>
|
||
<span class="v2-field-caption">玩具信息</span>
|
||
<div class="v2-extension-metrics">
|
||
<div><i data-lucide="radio-tower"></i><span>已接入外设玩具</span><strong id="extensionHardwareCount">0</strong></div>
|
||
<div><i data-lucide="badge-check"></i><span>已学习能力</span><strong id="extensionCapabilityCount">0</strong></div>
|
||
</div>`;
|
||
sidebar.insertBefore(extensionSidebar, $(".v2-sidebar-footer"));
|
||
|
||
const extensionFooter = document.createElement("footer");
|
||
extensionFooter.className = "v2-sidebar-footer v2-extension-footer";
|
||
extensionFooter.innerHTML = `
|
||
<span class="v2-live-dot"></span>
|
||
<div><strong>自扩展控制链路</strong><small id="extensionConnectionText">正在检查 GSCU1B</small></div>
|
||
<button type="button" id="extensionFooterRefresh" title="刷新连接" aria-label="刷新自扩展连接"><i data-lucide="refresh-cw"></i></button>`;
|
||
sidebar.append(extensionFooter);
|
||
|
||
const root = document.createElement("section");
|
||
root.className = "v2-self-extension";
|
||
root.id = "v2SelfExtension";
|
||
root.setAttribute("aria-label", "自扩展工作台");
|
||
root.innerHTML = `
|
||
<section class="v2-extension-view v2-extension-study" data-extension-view="study">
|
||
<header class="v2-extension-heading">
|
||
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具能力学习</h1><p>添加外设硬件,学习并验证可执行的遥控动作。</p></div>
|
||
<div class="v2-extension-heading-actions">
|
||
<button class="v2-extension-reset-action" type="button" id="extensionResetButton"><i data-lucide="rotate-ccw"></i>重置学习</button>
|
||
<button type="button" data-add-type="light"><i data-lucide="plus"></i>添加玩具</button>
|
||
</div>
|
||
</header>
|
||
<div class="v2-extension-hardware-grid" id="extensionHardwareGrid"></div>
|
||
</section>
|
||
<section class="v2-extension-view v2-extension-control" data-extension-view="control" hidden>
|
||
<header class="v2-extension-heading">
|
||
<div class="v2-module-heading"><span class="section-kicker">SELF-EXTENSION</span><h1>外设玩具与车端联动</h1><p>用自然语言统一编排已学习的玩具动作与受控车端设备。</p></div>
|
||
</header>
|
||
<div class="v2-extension-command-row">
|
||
<div class="v2-extension-composer">
|
||
<textarea id="extensionUtterance" rows="1" maxlength="500" placeholder="例如:发出红光,然后打开车内阅读灯" aria-label="输入自扩展联动指令"></textarea>
|
||
<button class="primary-action" type="button" id="extensionExecuteButton" aria-label="执行自扩展联动指令"><i data-lucide="play"></i><span>执行</span></button>
|
||
</div>
|
||
<aside class="v2-extension-execution-state" id="extensionExecutionState" data-state="idle">
|
||
<span>执行情况</span><strong id="extensionRunTitle">等待指令</strong><small id="extensionRunNote">尚未生成联动计划</small>
|
||
<div class="v2-extension-run-progress"><i id="extensionRunProgressBar"></i></div>
|
||
</aside>
|
||
</div>
|
||
<div class="v2-extension-control-grid">
|
||
<section class="v2-extension-action-board">
|
||
<header><div><span class="section-kicker">LEARNED ACTIONS</span><h2>玩具动作</h2></div><strong id="extensionLearnedTotal">0 项可执行</strong></header>
|
||
<div class="v2-extension-learned-grid" id="extensionLearnedGrid"></div>
|
||
</section>
|
||
<section class="v2-extension-action-board">
|
||
<header><div><span class="section-kicker">VEHICLE ACTIONS</span><h2>车端设备动作</h2></div><strong>同源安全编排</strong></header>
|
||
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
|
||
</section>
|
||
</div>
|
||
<section class="v2-extension-twin-board" aria-label="整车孪生界面">
|
||
<div class="v2-extension-twin-host" id="extensionTwinHost">
|
||
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
|
||
</div>
|
||
</section>
|
||
<div class="v2-extension-run-plan" id="extensionVehicleStateSummary" data-state="unknown">
|
||
<span><i>CH0 / CH1</i>等待权威车端状态</span>
|
||
</div>
|
||
<div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div>
|
||
</section>
|
||
<aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite">
|
||
<span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span>
|
||
<button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动">
|
||
<img class="v2-extension-pet-image" src="/assets/pets/self-extension-cat.png?v=3" alt="" draggable="false" />
|
||
<canvas class="v2-extension-pet-entrance-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||
<canvas class="v2-extension-pet-tail-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||
<span class="v2-extension-pet-blink" aria-hidden="true">
|
||
<i class="v2-extension-pet-blink-eye left"></i>
|
||
<i class="v2-extension-pet-blink-eye right"></i>
|
||
</span>
|
||
<span class="v2-extension-pet-mouth-closed" aria-hidden="true"></span>
|
||
</button>
|
||
</aside>`;
|
||
moduleSwitcher.insertAdjacentElement("afterend", root);
|
||
|
||
buildDialogs();
|
||
return true;
|
||
}
|
||
|
||
function buildDialogs() {
|
||
const layer = document.createElement("div");
|
||
layer.className = "v2-extension-dialog-layer";
|
||
layer.innerHTML = `
|
||
<dialog class="v2-extension-dialog" id="extensionAddDialog">
|
||
<form id="extensionAddForm">
|
||
<header><div><span class="section-kicker">NEW PERIPHERAL</span><h2>添加外设玩具</h2><p>选择类别后会自动建立一组待学习动作。</p></div><button type="button" data-close-extension-dialog="extensionAddDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
|
||
<div class="v2-extension-type-chooser" id="extensionTypeChooser"></div>
|
||
<label><span>玩具名称</span><input id="extensionHardwareName" maxlength="48" autocomplete="off" placeholder="例如:床头灯光玩具" required /></label>
|
||
<div class="v2-extension-preset-preview"><span>将建立的预设动作</span><div id="extensionPresetPreview"></div></div>
|
||
<button class="v2-extension-primary" type="submit">添加并开始学习</button>
|
||
</form>
|
||
</dialog>
|
||
<dialog class="v2-extension-dialog v2-extension-learn-dialog" id="extensionLearnDialog">
|
||
<header><div><span class="section-kicker">GSCU1B LEARNING</span><h2 id="extensionLearnTitle">学习遥控动作</h2><p id="extensionLearnPath">选择动作后开始</p></div><button type="button" data-close-extension-dialog="extensionLearnDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
|
||
<div class="v2-extension-learn-steps"><span data-extension-learn-step="1"><b>1</b>配置</span><i></i><span data-extension-learn-step="2"><b>2</b>接收</span><i></i><span data-extension-learn-step="3"><b>3</b>验证</span></div>
|
||
<section class="v2-extension-learn-page" data-extension-learn-page="1">
|
||
<div class="v2-extension-learn-target"><span id="extensionLearnIcon"><i data-lucide="radio"></i></span><div><small>目标玩具</small><strong id="extensionLearnDevice">--</strong></div><i data-lucide="arrow-right"></i><div><small>待学习动作</small><strong id="extensionLearnAction">--</strong></div></div>
|
||
<div class="v2-extension-learn-fields">
|
||
<label><span>说法与别名</span><input id="extensionLearnAliases" /></label>
|
||
<label><span>风险级别</span><select id="extensionLearnRisk"><option value="low">低</option><option value="medium">中</option><option value="high">高</option></select></label>
|
||
<label><span>红外槽位</span><input id="extensionLearnSlot" type="number" min="1" max="248" /></label>
|
||
</div>
|
||
<button class="v2-extension-primary" type="button" id="extensionContinueLearn">继续接收遥控信号</button>
|
||
</section>
|
||
<section class="v2-extension-learn-page v2-extension-learning-page" data-extension-learn-page="2" hidden>
|
||
<div class="v2-extension-learn-animation" id="extensionLearnAnimation"><span><i data-lucide="radio-tower"></i></span><i></i><i></i></div>
|
||
<h3 id="extensionLearnStatusTitle">准备接收遥控信号</h3><p id="extensionLearnStatusMessage">请将遥控器对准学习器并按一下对应按键。</p><strong id="extensionLearnCountdown">15</strong>
|
||
<div class="v2-extension-dialog-actions"><button type="button" id="extensionCancelLearn">取消</button><button class="v2-extension-primary" type="button" id="extensionStartLearn">开始接收</button></div>
|
||
</section>
|
||
<section class="v2-extension-learn-page v2-extension-test-page" data-extension-learn-page="3" hidden>
|
||
<span><i data-lucide="badge-check"></i></span><h3 id="extensionTestTitle">信号已捕获</h3><p id="extensionTestMessage">先发射测试,确认玩具有正确反应后再保存。</p>
|
||
<div class="v2-extension-dialog-actions"><button type="button" id="extensionTestEmit">发射试一下</button><button class="v2-extension-primary" type="button" id="extensionConfirmLearn" disabled>确认并完成学习</button></div>
|
||
</section>
|
||
</dialog>
|
||
<dialog class="v2-extension-dialog v2-extension-reset-dialog" id="extensionResetDialog">
|
||
<header><div><span class="section-kicker">RESET LEARNING</span><h2>重置学习</h2><p>将擦除学习器并把所有动作恢复为待学习。</p></div><button type="button" data-close-extension-dialog="extensionResetDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
|
||
<label><span>输入“清空”确认操作</span><input id="extensionResetConfirm" autocomplete="off" /></label>
|
||
<button class="v2-extension-danger" type="button" id="extensionConfirmReset" disabled>擦除并重新开始</button>
|
||
</dialog>
|
||
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
|
||
document.body.append(layer);
|
||
}
|
||
|
||
function openDialog(id) {
|
||
const dialog = $(`#${id}`);
|
||
if (dialog?.showModal) dialog.showModal();
|
||
else dialog?.setAttribute("open", "");
|
||
}
|
||
|
||
function closeDialog(id) {
|
||
const dialog = $(`#${id}`);
|
||
if (dialog?.close) dialog.close();
|
||
else dialog?.removeAttribute("open");
|
||
}
|
||
|
||
function showToast(message, type = "") {
|
||
const toast = document.createElement("div");
|
||
toast.className = `v2-extension-toast ${type}`.trim();
|
||
toast.textContent = message;
|
||
$("#extensionToastStack")?.append(toast);
|
||
window.setTimeout(() => toast.remove(), 3800);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
let response;
|
||
try {
|
||
response = await fetch(`/api/extension/${path}`, {
|
||
credentials: "same-origin",
|
||
...options,
|
||
headers: options.body
|
||
? { "Content-Type": "application/json", ...(options.headers || {}) }
|
||
: options.headers,
|
||
});
|
||
} catch (_error) {
|
||
throw new Error("无法连接自扩展服务。");
|
||
}
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload.success === false) {
|
||
const error = new Error(payload.message || `自扩展请求失败(HTTP ${response.status})`);
|
||
error.payload = payload;
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function sharedConversationId() {
|
||
const fromConsole = window.CognitiveAgentConsole?.getConversationId?.();
|
||
if (typeof fromConsole === "string" && fromConsole.startsWith("conv-")) {
|
||
sessionStorage.setItem(CONVERSATION_KEY, fromConsole);
|
||
return fromConsole;
|
||
}
|
||
const stored = sessionStorage.getItem(CONVERSATION_KEY);
|
||
if (stored?.startsWith("conv-")) return stored;
|
||
const conversationId = uid("conv");
|
||
sessionStorage.setItem(CONVERSATION_KEY, conversationId);
|
||
return conversationId;
|
||
}
|
||
|
||
function isPhysicalVehicleSource(value) {
|
||
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "wireless_mqtt_ch0", "wireless_mqtt_ch1", "physical_partial"].includes(value);
|
||
}
|
||
|
||
function containsAuthoritativePhysicalCh1State(value) {
|
||
if (!value || typeof value !== "object") return false;
|
||
if (isPhysicalVehicleSource(value.source) || isPhysicalVehicleSource(value.feedback_source)) {
|
||
return true;
|
||
}
|
||
return Object.values(value).some((item) => containsAuthoritativePhysicalCh1State(item));
|
||
}
|
||
|
||
function isAuthoritativeVehicleStatePayload(payload) {
|
||
if (!payload || typeof payload !== "object") return false;
|
||
if (payload.conversation_id !== sharedConversationId()) return false;
|
||
return containsAuthoritativePhysicalCh1State(payload.actuator_state)
|
||
|| containsAuthoritativePhysicalCh1State(payload.domain_state);
|
||
}
|
||
|
||
function renderAuthoritativeVehicleState(payload) {
|
||
if (!isAuthoritativeVehicleStatePayload(payload)) return false;
|
||
const turn = Number(payload.conversation_turn);
|
||
if (Number.isFinite(turn) && turn < latestVehicleStateTurn) return false;
|
||
if (Number.isFinite(turn)) latestVehicleStateTurn = turn;
|
||
const summary = $("#extensionVehicleStateSummary");
|
||
if (!summary) return false;
|
||
|
||
const requestLabel = String(payload.request_id || "-").slice(-8);
|
||
const hasCh0 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.passenger);
|
||
const hasCh1 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.driver)
|
||
|| containsAuthoritativePhysicalCh1State(payload.domain_state);
|
||
const channelLabel = hasCh0 && hasCh1 ? "CH0 + CH1" : hasCh0 ? "CH0" : "CH1";
|
||
const items = [
|
||
`<span class="done"><i>${channelLabel}</i>真实车况 · TURN ${Number.isFinite(turn) ? turn : "-"} · ${escapeHtml(requestLabel)}</span>`,
|
||
];
|
||
const feedback = Array.isArray(payload.physical_feedback)
|
||
? payload.physical_feedback.filter((item) => (
|
||
item?.confirmed === true
|
||
&& [0, 1].includes(Number(item?.channel))
|
||
&& isPhysicalVehicleSource(item?.source)
|
||
))
|
||
: [];
|
||
if (feedback.length) {
|
||
const frames = [...new Set(feedback.map((item) => item.frame?.frame_id).filter(Boolean))];
|
||
items.push(`<span><i>FB</i>${feedback.length}/${payload.physical_feedback.length} MATCH · ${escapeHtml(frames.join(" / ") || channelLabel)}</span>`);
|
||
}
|
||
const driver = payload.actuator_state?.driver || {};
|
||
const passenger = payload.actuator_state?.passenger || {};
|
||
if (isPhysicalVehicleSource(passenger.ventilation?.feedback_source)) {
|
||
items.push(`<span><i>P-VENT</i>${passenger.ventilation.state === "on" ? `ON L${escapeHtml(passenger.ventilation.level)}` : "OFF"}</span>`);
|
||
}
|
||
if (isPhysicalVehicleSource(passenger.heating?.feedback_source)) {
|
||
items.push(`<span><i>P-HEAT</i>${passenger.heating.state === "on" ? `ON L${escapeHtml(passenger.heating.level)}` : "OFF"}</span>`);
|
||
}
|
||
const passengerPosition = passenger.position || {};
|
||
Object.entries(passengerPosition).forEach(([axis, value]) => {
|
||
if (!isPhysicalVehicleSource(value?.feedback_source)) return;
|
||
items.push(`<span><i>P-SEAT</i>${escapeHtml(axis)} ${escapeHtml(value.position_pct ?? "-")}%</span>`);
|
||
});
|
||
const ventilation = driver.ventilation;
|
||
const heating = driver.heating;
|
||
if (ventilation?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>VENT</i>${ventilation.state === "on" ? `ON L${escapeHtml(ventilation.level)}` : "OFF"}</span>`);
|
||
}
|
||
if (heating?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>HEAT</i>${heating.state === "on" ? `ON L${escapeHtml(heating.level)}` : "OFF"}</span>`);
|
||
}
|
||
const massage = driver.massage;
|
||
if (massage?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>MASSAGE</i>${escapeHtml(massage.mode || "off")} · ${escapeHtml(massage.strength || "off")}</span>`);
|
||
}
|
||
const climate = payload.domain_state?.climate;
|
||
if (climate?.feedback_source === "physical_can_ch1") {
|
||
const temperature = climate.zones?.driver?.temperature_c ?? "-";
|
||
const outletMode = climate.zones?.driver?.outlet_mode || "-";
|
||
items.push(`<span><i>AC</i>${escapeHtml(climate.power || climate.ac || "unknown")} · ${escapeHtml(temperature)}℃ · FAN ${escapeHtml(climate.fan_level ?? "-")} · ${escapeHtml(outletMode)}</span>`);
|
||
}
|
||
const roof = payload.domain_state?.roof;
|
||
if (roof?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>ROOF</i>${escapeHtml(roof.shade_position_pct ?? "-")}% · ${escapeHtml(roof.motion_state || "unknown")}</span>`);
|
||
}
|
||
const frontLeftWindow = payload.domain_state?.window?.positions?.front_left;
|
||
if (frontLeftWindow?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>WINDOW</i>左前 ${escapeHtml(frontLeftWindow.position_pct ?? "-")}%</span>`);
|
||
}
|
||
const interiorLight = payload.domain_state?.interior_light;
|
||
if (interiorLight?.feedback_source === "physical_can_ch1") {
|
||
items.push(`<span><i>INTERIOR</i>${escapeHtml(interiorLight.power || "unknown")}</span>`);
|
||
}
|
||
const ambientLight = payload.domain_state?.ambient_light;
|
||
if (ambientLight?.feedback_source === "physical_can_ch1") {
|
||
const brightness = `${ambientLight.front_brightness_level ?? "-"}/${ambientLight.rear_brightness_level ?? "-"}`;
|
||
const color = `${ambientLight.front_color_index ?? "-"}/${ambientLight.rear_color_index ?? "-"}`;
|
||
items.push(`<span><i>AMBIENT</i>${escapeHtml(ambientLight.power || "unknown")} · L${escapeHtml(brightness)} · C${escapeHtml(color)}</span>`);
|
||
}
|
||
summary.dataset.state = hasCh0 && hasCh1 ? "physical_can_dual" : hasCh0 ? "physical_can_ch0" : "physical_can_ch1";
|
||
summary.innerHTML = items.join("");
|
||
return true;
|
||
}
|
||
|
||
function syncSharedTwinMount() {
|
||
const host = $("#extensionTwinHost");
|
||
const shouldMount = document.body.dataset.module === "self-extension"
|
||
&& activeMode === "control";
|
||
if (!sharedTwinBand) {
|
||
sharedTwinBand = $("#seatTwin")?.closest(".twin-band") || null;
|
||
if (sharedTwinBand?.parentNode) {
|
||
sharedTwinHome = document.createComment("shared-vehicle-twin-home");
|
||
sharedTwinBand.parentNode.insertBefore(sharedTwinHome, sharedTwinBand);
|
||
}
|
||
}
|
||
if (!sharedTwinBand || !sharedTwinHome || !host) return;
|
||
if (shouldMount) {
|
||
if (sharedTwinBand.parentNode !== host) host.replaceChildren(sharedTwinBand);
|
||
sharedTwinBand.classList.add("v2-extension-shared-twin");
|
||
} else if (sharedTwinHome.parentNode && sharedTwinBand.parentNode !== sharedTwinHome.parentNode) {
|
||
sharedTwinHome.parentNode.insertBefore(sharedTwinBand, sharedTwinHome.nextSibling);
|
||
sharedTwinBand.classList.remove("v2-extension-shared-twin");
|
||
}
|
||
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
|
||
}
|
||
|
||
function setMode(mode) {
|
||
activeMode = mode === "control" ? "control" : "study";
|
||
sessionStorage.setItem(MODE_KEY, activeMode);
|
||
document.body.dataset.extensionMode = activeMode;
|
||
$$('[data-extension-mode]').forEach((button) => {
|
||
const active = button.dataset.extensionMode === activeMode;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
$$('[data-extension-view]').forEach((view) => {
|
||
view.hidden = view.dataset.extensionView !== activeMode;
|
||
});
|
||
if (activeMode === "control") renderControl();
|
||
syncSharedTwinMount();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function renderAll() {
|
||
renderMetrics();
|
||
renderHardwareGrid();
|
||
renderControl();
|
||
renderTypeChooser();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function renderMetrics() {
|
||
$("#extensionHardwareCount").textContent = String(state.devices.length);
|
||
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
|
||
}
|
||
|
||
function renderHardwareGrid() {
|
||
$("#extensionHardwareGrid").innerHTML = Object.keys(TOY_TYPES)
|
||
.map((typeKey) => renderTypeCard(typeKey))
|
||
.join("");
|
||
}
|
||
|
||
function renderTypeCard(typeKey) {
|
||
const type = TOY_TYPES[typeKey];
|
||
const devices = state.devices.filter((item) => item.type === typeKey);
|
||
const selected = selectedDeviceForType(typeKey);
|
||
const capabilities = selected
|
||
? deviceCapabilities(selected.id)
|
||
: type.presets.map((item) => ({ ...item, id: null, status: "pending", slot: null }));
|
||
const totalPages = Math.max(1, Math.ceil(capabilities.length / ACTION_PAGE_SIZE));
|
||
pages[typeKey] = Math.min(pages[typeKey] || 0, totalPages - 1);
|
||
const page = pages[typeKey];
|
||
const visible = capabilities.slice(page * ACTION_PAGE_SIZE, (page + 1) * ACTION_PAGE_SIZE);
|
||
const learned = selected ? learnedCapabilities(selected.id).length : 0;
|
||
const selector = devices.length
|
||
? `<select data-extension-device-select="${typeKey}" aria-label="选择${type.label}">${devices.map((device) => `<option value="${escapeHtml(device.id)}" ${device.id === selected?.id ? "selected" : ""}>${escapeHtml(device.name)}</option>`).join("")}</select>`
|
||
: `<span class="v2-extension-no-device">尚未添加具体硬件</span>`;
|
||
return `<article class="v2-extension-type-card" data-extension-type="${typeKey}" data-extension-connected="${Boolean(selected)}">
|
||
<header><span class="v2-extension-type-icon"><i data-lucide="${type.icon}"></i></span><div><small>${type.label}</small>${selector}</div><strong>${learned}/${capabilities.length}</strong><button type="button" data-add-type="${typeKey}" title="添加${type.label}" aria-label="添加${type.label}"><i data-lucide="plus"></i></button>${selected ? `<button type="button" data-delete-extension-device="${escapeHtml(selected.id)}" title="删除当前玩具" aria-label="删除当前玩具"><i data-lucide="trash-2"></i></button>` : ""}</header>
|
||
<div class="v2-extension-capability-grid">${visible.map((capability) => renderCapability(capability, typeKey, Boolean(selected))).join("")}</div>
|
||
<footer><span>${devices.length} 台硬件 · ${capabilities.length} 个动作</span><div><button type="button" data-extension-page="${typeKey}" data-page-direction="-1" ${page === 0 ? "disabled" : ""}><i data-lucide="chevron-left"></i></button><b>${page + 1}/${totalPages}</b><button type="button" data-extension-page="${typeKey}" data-page-direction="1" ${page >= totalPages - 1 ? "disabled" : ""}><i data-lucide="chevron-right"></i></button></div></footer>
|
||
</article>`;
|
||
}
|
||
|
||
function renderCapability(capability, typeKey, hasDevice) {
|
||
const learned = capability.status === "learned" && validSlot(capability.slot);
|
||
if (!hasDevice) {
|
||
return `<button class="v2-extension-capability preview" type="button" data-add-type="${typeKey}"><span>${escapeHtml(capability.name)}</span><small>添加硬件后可学习</small><i>添加</i></button>`;
|
||
}
|
||
return `<div class="v2-extension-capability ${learned ? "learned" : "pending"}">
|
||
<button type="button" ${learned ? `data-run-extension-capability="${escapeHtml(capability.id)}"` : `data-learn-extension-capability="${escapeHtml(capability.id)}"`}><span>${escapeHtml(capability.name)}</span><small>${learned ? `IR ${String(capability.slot).padStart(3, "0")} · 已学习` : "待学习"}</small></button>
|
||
<div>${learned ? `<button type="button" data-run-extension-capability="${escapeHtml(capability.id)}">执行</button><button type="button" data-forget-extension-capability="${escapeHtml(capability.id)}" aria-label="忘记动作"><i data-lucide="x"></i></button>` : `<button type="button" data-learn-extension-capability="${escapeHtml(capability.id)}">学习</button>`}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderControl() {
|
||
const learned = learnedCapabilities();
|
||
$("#extensionLearnedTotal").textContent = `${learned.length} 项可执行`;
|
||
if (!learned.length) {
|
||
$("#extensionLearnedGrid").innerHTML = `<div class="v2-extension-empty"><i data-lucide="book-open-check"></i><strong>还没有已学习动作</strong><span>切换到学习模式,为外设玩具录入遥控能力。</span><button type="button" data-switch-extension-mode="study">前往学习</button></div>`;
|
||
} else {
|
||
$("#extensionLearnedGrid").innerHTML = learned.map((capability) => {
|
||
const device = state.devices.find((item) => item.id === capability.deviceId);
|
||
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
|
||
return `<button class="v2-extension-learned-action" type="button" data-run-extension-capability="${escapeHtml(capability.id)}" data-extension-type="${escapeHtml(device?.type || "other")}"><span><i data-lucide="${type.icon}"></i></span><div><small>${escapeHtml(device?.name || type.label)}</small><strong>${escapeHtml(capability.name)}</strong></div><i data-lucide="send"></i></button>`;
|
||
}).join("");
|
||
}
|
||
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => {
|
||
const execution = state.vehicleExecutions?.[device.id];
|
||
const executionState = execution?.state || "idle";
|
||
return `
|
||
<article class="v2-extension-vehicle-card" data-extension-vehicle-device="${escapeHtml(device.id)}" data-execution-state="${escapeHtml(executionState)}">
|
||
<header>
|
||
<span><i data-lucide="${device.icon}"></i></span>
|
||
<div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.code)}</small></div>
|
||
<b>${escapeHtml(device.channel)}</b>
|
||
</header>
|
||
<div class="v2-extension-parameter-list" aria-label="${escapeHtml(device.name)}可控参数">
|
||
${device.parameters.map((parameter) => `<span>${escapeHtml(parameter)}</span>`).join("")}
|
||
</div>
|
||
<footer>
|
||
<small>当前动作</small>
|
||
<strong data-extension-current-action>${escapeHtml(execution?.action || "等待指令")}</strong>
|
||
<span data-extension-action-detail>${escapeHtml(execution?.detail || "输入自然语言后在此显示执行结果")}</span>
|
||
</footer>
|
||
</article>`;
|
||
}).join("");
|
||
}
|
||
|
||
function findVehicleAction(command) {
|
||
for (const device of VEHICLE_DEVICES) {
|
||
const action = device.actions.find((item) => item.command === command);
|
||
if (action) return { device, action };
|
||
}
|
||
const inferredDeviceId = /^副驾/.test(command)
|
||
? "passenger-seat"
|
||
: /^seat driver /.test(command)
|
||
? "driver-seat"
|
||
: /^ac /.test(command)
|
||
? "climate"
|
||
: /^shade /.test(command)
|
||
? "roof"
|
||
: /^light /.test(command)
|
||
? "interior-light"
|
||
: /^window /.test(command)
|
||
? "windows"
|
||
: null;
|
||
const device = VEHICLE_DEVICES.find((item) => item.id === inferredDeviceId);
|
||
if (device) return { device, action: { name: command, command } };
|
||
return null;
|
||
}
|
||
|
||
function updateVehicleCardExecution(command, executionState, detail) {
|
||
const match = findVehicleAction(command);
|
||
if (!match) return;
|
||
const actionText = match.action.name || command;
|
||
state.vehicleExecutions ||= {};
|
||
state.vehicleExecutions[match.device.id] = {
|
||
state: executionState,
|
||
action: actionText,
|
||
detail,
|
||
command,
|
||
at: new Date().toISOString(),
|
||
};
|
||
const card = $(`[data-extension-vehicle-device="${match.device.id}"]`);
|
||
if (!card) return;
|
||
card.dataset.executionState = executionState;
|
||
const actionNode = card.querySelector("[data-extension-current-action]");
|
||
const detailNode = card.querySelector("[data-extension-action-detail]");
|
||
if (actionNode) actionNode.textContent = actionText;
|
||
if (detailNode) detailNode.textContent = detail;
|
||
}
|
||
|
||
function renderTypeChooser() {
|
||
$("#extensionTypeChooser").innerHTML = Object.entries(TOY_TYPES).map(([key, type]) => `
|
||
<button class="${key === selectedAddType ? "active" : ""}" type="button" data-extension-type-choice="${key}" data-extension-type="${key}"><span><i data-lucide="${type.icon}"></i></span><div><strong>${type.shortLabel}</strong><small>${type.description}</small></div></button>`).join("");
|
||
$("#extensionPresetPreview").innerHTML = TOY_TYPES[selectedAddType].presets
|
||
.map((item) => `<i>${escapeHtml(item.name)}</i>`)
|
||
.join("");
|
||
}
|
||
|
||
function openAdd(typeKey = "light") {
|
||
selectedAddType = TOY_TYPES[typeKey] ? typeKey : "other";
|
||
$("#extensionHardwareName").value = "";
|
||
renderTypeChooser();
|
||
openDialog("extensionAddDialog");
|
||
window.setTimeout(() => $("#extensionHardwareName")?.focus(), 80);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function addHardware(event) {
|
||
event.preventDefault();
|
||
const name = $("#extensionHardwareName").value.trim();
|
||
if (!name) return;
|
||
if (state.devices.some((item) => item.name === name)) {
|
||
showToast("已经存在同名外设玩具。", "error");
|
||
return;
|
||
}
|
||
const device = {
|
||
id: uid("toy"),
|
||
name,
|
||
type: selectedAddType,
|
||
aliases: [name],
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
state.devices.push(device);
|
||
state.capabilities.push(...seedPresets(device));
|
||
state.selectedDeviceByType[device.type] = device.id;
|
||
pages[device.type] = 0;
|
||
saveState();
|
||
closeDialog("extensionAddDialog");
|
||
renderAll();
|
||
showToast(`已添加“${name}”,可以开始学习遥控动作。`, "success");
|
||
}
|
||
|
||
function deleteHardware(deviceId) {
|
||
const device = state.devices.find((item) => item.id === deviceId);
|
||
if (!device || !window.confirm(`删除“${device.name}”及其全部前端能力记录?`)) return;
|
||
state.devices = state.devices.filter((item) => item.id !== deviceId);
|
||
state.capabilities = state.capabilities.filter((item) => item.deviceId !== deviceId);
|
||
state.selectedDeviceByType[device.type] = state.devices.find(
|
||
(item) => item.type === device.type,
|
||
)?.id || null;
|
||
saveState();
|
||
renderAll();
|
||
showToast(`已删除“${device.name}”。`);
|
||
}
|
||
|
||
function showLearnPage(page) {
|
||
$$('[data-extension-learn-page]').forEach((element) => {
|
||
element.hidden = Number(element.dataset.extensionLearnPage) !== page;
|
||
});
|
||
$$('[data-extension-learn-step]').forEach((element) => {
|
||
element.classList.toggle("active", Number(element.dataset.extensionLearnStep) <= page);
|
||
});
|
||
}
|
||
|
||
function resetLearnStatus() {
|
||
window.clearInterval(learning.countdownTimer);
|
||
learning = freshLearningSession();
|
||
$("#extensionLearnAnimation").dataset.state = "ready";
|
||
$("#extensionLearnStatusTitle").textContent = "准备接收遥控信号";
|
||
$("#extensionLearnStatusMessage").textContent = "请将遥控器对准学习器并按一下对应按键。";
|
||
$("#extensionLearnCountdown").textContent = "15";
|
||
$("#extensionStartLearn").disabled = false;
|
||
$("#extensionStartLearn").textContent = "开始接收";
|
||
$("#extensionCancelLearn").hidden = false;
|
||
$("#extensionTestEmit").disabled = false;
|
||
$("#extensionTestEmit").textContent = "发射试一下";
|
||
$("#extensionConfirmLearn").disabled = true;
|
||
}
|
||
|
||
function openLearn(capabilityId) {
|
||
const capability = state.capabilities.find((item) => item.id === capabilityId);
|
||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||
if (!capability || !device) return;
|
||
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
|
||
resetLearnStatus();
|
||
learning.draft = { capabilityId: capability.id, deviceId: device.id, slot: allocateSlot() };
|
||
$("#extensionLearnTitle").textContent = `学习“${capability.name}”`;
|
||
$("#extensionLearnPath").textContent = `${type.label} · ${device.name}`;
|
||
$("#extensionLearnIcon").innerHTML = `<i data-lucide="${type.icon}"></i>`;
|
||
$("#extensionLearnDevice").textContent = device.name;
|
||
$("#extensionLearnAction").textContent = capability.name;
|
||
$("#extensionLearnAliases").value = (capability.aliases || []).join("、");
|
||
$("#extensionLearnRisk").value = capability.risk || "low";
|
||
$("#extensionLearnSlot").value = allocateSlot() || "";
|
||
showLearnPage(1);
|
||
openDialog("extensionLearnDialog");
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function collectLearningDraft() {
|
||
const capability = state.capabilities.find(
|
||
(item) => item.id === learning.draft?.capabilityId,
|
||
);
|
||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||
const slot = Number($("#extensionLearnSlot").value || allocateSlot());
|
||
if (!capability || !device) throw new Error("预设动作不存在。");
|
||
if (!validSlot(slot)) throw new Error("没有可用红外槽位。");
|
||
const occupied = learnedCapabilities().find(
|
||
(item) => Number(item.slot) === slot && item.id !== capability.id,
|
||
);
|
||
if (occupied) throw new Error(`槽位 ${slot} 已被“${occupied.name}”使用。`);
|
||
return {
|
||
capabilityId: capability.id,
|
||
deviceId: device.id,
|
||
slot,
|
||
aliases: $("#extensionLearnAliases").value
|
||
.split(/[,,、;;]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
risk: $("#extensionLearnRisk").value,
|
||
};
|
||
}
|
||
|
||
async function sendGatewayCommand(action, payload = {}, signal = undefined) {
|
||
const result = await api(`gscu/${action}`, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
signal,
|
||
});
|
||
state.lastProtocol = {
|
||
at: new Date().toISOString(),
|
||
action,
|
||
payload,
|
||
response: result,
|
||
};
|
||
saveState();
|
||
return result;
|
||
}
|
||
|
||
async function startLearning() {
|
||
if (learning.phase === "listening") return;
|
||
try {
|
||
learning.draft = collectLearningDraft();
|
||
} catch (error) {
|
||
showToast(error.message, "error");
|
||
showLearnPage(1);
|
||
return;
|
||
}
|
||
const capability = state.capabilities.find((item) => item.id === learning.draft.capabilityId);
|
||
const device = state.devices.find((item) => item.id === learning.draft.deviceId);
|
||
learning.phase = "listening";
|
||
learning.abortController = new AbortController();
|
||
$("#extensionLearnAnimation").dataset.state = "listening";
|
||
$("#extensionLearnStatusTitle").textContent = `正在学习“${capability.name}”…`;
|
||
$("#extensionLearnStatusMessage").textContent = `请将“${device.name}”的遥控器对准学习器。`;
|
||
$("#extensionStartLearn").disabled = true;
|
||
$("#extensionStartLearn").textContent = "正在接收";
|
||
let countdown = 15;
|
||
$("#extensionLearnCountdown").textContent = String(countdown);
|
||
learning.countdownTimer = window.setInterval(() => {
|
||
countdown -= 1;
|
||
$("#extensionLearnCountdown").textContent = countdown > 0 ? String(countdown) : "…";
|
||
if (countdown <= 0) window.clearInterval(learning.countdownTimer);
|
||
}, 1000);
|
||
try {
|
||
await sendGatewayCommand(
|
||
"learn",
|
||
{ slot: learning.draft.slot },
|
||
learning.abortController.signal,
|
||
);
|
||
window.clearInterval(learning.countdownTimer);
|
||
learning.phase = "learned";
|
||
$("#extensionLearnAnimation").dataset.state = "success";
|
||
await delay(300);
|
||
showLearnPage(3);
|
||
} catch (error) {
|
||
if (error.name === "AbortError") return;
|
||
window.clearInterval(learning.countdownTimer);
|
||
learning.phase = "idle";
|
||
$("#extensionLearnAnimation").dataset.state = "ready";
|
||
$("#extensionLearnStatusTitle").textContent = "这次没有捕获到按键";
|
||
$("#extensionLearnStatusMessage").textContent = error.message;
|
||
$("#extensionLearnCountdown").textContent = "";
|
||
$("#extensionStartLearn").disabled = false;
|
||
$("#extensionStartLearn").textContent = "重新接收";
|
||
showToast(error.message, "error");
|
||
}
|
||
}
|
||
|
||
async function cancelLearning() {
|
||
window.clearInterval(learning.countdownTimer);
|
||
learning.abortController?.abort();
|
||
try {
|
||
await sendGatewayCommand("cancel");
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
resetLearnStatus();
|
||
showLearnPage(1);
|
||
}
|
||
|
||
async function testLearning() {
|
||
if (!validSlot(learning.draft?.slot)) return;
|
||
const button = $("#extensionTestEmit");
|
||
button.disabled = true;
|
||
button.textContent = "正在发射…";
|
||
try {
|
||
await sendGatewayCommand("emit", { slot: learning.draft.slot });
|
||
learning.tested = true;
|
||
$("#extensionConfirmLearn").disabled = false;
|
||
$("#extensionTestTitle").textContent = "信号已发射,请确认玩具反应";
|
||
$("#extensionTestMessage").textContent = "动作正确即可完成学习;不正确请重新接收。";
|
||
showToast("红外信号已发射,请观察玩具。", "success");
|
||
} catch (error) {
|
||
showToast(error.message, "error");
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "再发射一次";
|
||
}
|
||
}
|
||
|
||
function confirmLearning() {
|
||
if (!learning.tested || !learning.draft) return;
|
||
const capability = state.capabilities.find(
|
||
(item) => item.id === learning.draft.capabilityId,
|
||
);
|
||
if (!capability) return;
|
||
Object.assign(capability, {
|
||
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
|
||
risk: learning.draft.risk,
|
||
slot: Number(learning.draft.slot),
|
||
status: "learned",
|
||
verified: true,
|
||
enabled: true,
|
||
learnedAt: new Date().toISOString(),
|
||
});
|
||
state.sessionStartedAt ||= new Date().toISOString();
|
||
saveState();
|
||
closeDialog("extensionLearnDialog");
|
||
renderAll();
|
||
showToast(`“${capability.name}”已学习完成。`, "success");
|
||
learning = freshLearningSession();
|
||
}
|
||
|
||
function forgetCapability(capabilityId) {
|
||
const capability = state.capabilities.find((item) => item.id === capabilityId);
|
||
if (!capability || !window.confirm(`忘记动作“${capability.name}”?`)) return;
|
||
Object.assign(capability, {
|
||
slot: null,
|
||
status: "pending",
|
||
verified: false,
|
||
enabled: false,
|
||
learnedAt: null,
|
||
});
|
||
saveState();
|
||
renderAll();
|
||
showToast(`“${capability.name}”已恢复为待学习。`);
|
||
}
|
||
|
||
function peripheralCatalog() {
|
||
return learnedCapabilities().map((capability) => {
|
||
const device = state.devices.find((item) => item.id === capability.deviceId);
|
||
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
|
||
return {
|
||
id: capability.id,
|
||
status: "learned",
|
||
device_id: device?.id,
|
||
device_name: device?.name,
|
||
device_type: device?.type,
|
||
type_label: type.label,
|
||
action: capability.name,
|
||
aliases: capability.aliases || [],
|
||
risk: capability.risk || "low",
|
||
};
|
||
}).filter((item) => item.device_id);
|
||
}
|
||
|
||
function vehicleCatalog() {
|
||
return VEHICLE_DEVICES.map((device) => ({
|
||
id: device.id,
|
||
name: device.name,
|
||
code: device.code,
|
||
channel: device.channel,
|
||
parameters: device.parameters,
|
||
actions: device.actions,
|
||
}));
|
||
}
|
||
|
||
function hydratePlan(steps) {
|
||
if (!Array.isArray(steps)) throw new Error("联动计划步骤格式无效。");
|
||
if (steps.length > MAX_PLAN_STEPS) throw new Error("联动计划步骤过多。");
|
||
const plan = steps.map((step) => {
|
||
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
||
throw new Error("联动计划包含无效步骤。");
|
||
}
|
||
if (step.kind === "delay") {
|
||
if (!Number.isInteger(step.delay_ms)
|
||
|| step.delay_ms < MIN_PLAN_DELAY_MS
|
||
|| step.delay_ms > MAX_PLAN_DELAY_MS) {
|
||
throw new Error("延时步骤必须使用安全范围内的整数毫秒。");
|
||
}
|
||
if ([step.capability_id, step.command].some(
|
||
(value) => value != null && String(value).trim() !== "",
|
||
)) {
|
||
throw new Error("延时步骤不能包含设备命令。");
|
||
}
|
||
return {
|
||
kind: "delay",
|
||
delayMs: step.delay_ms,
|
||
label: step.delay_ms % 1000 === 0
|
||
? `等待 ${step.delay_ms / 1000} 秒`
|
||
: `等待 ${step.delay_ms} 毫秒`,
|
||
risk: "low",
|
||
};
|
||
}
|
||
if (step.delay_ms !== undefined && step.delay_ms !== 0) {
|
||
throw new Error("非延时步骤不能携带延时值。");
|
||
}
|
||
if (step.kind === "vehicle") {
|
||
const command = String(step.command || "").trim();
|
||
if (!command) throw new Error("车端计划步骤缺少命令。");
|
||
return {
|
||
kind: "vehicle",
|
||
command,
|
||
label: step.label || command,
|
||
risk: step.risk || "medium",
|
||
};
|
||
}
|
||
if (step.kind !== "peripheral") throw new Error("联动计划包含未知步骤。");
|
||
const capability = state.capabilities.find((item) => item.id === step.capability_id);
|
||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||
if (!capability || !device || !learnedCapabilities(device.id).some(
|
||
(item) => item.id === capability.id,
|
||
)) throw new Error("联动计划引用了尚未学习的外设能力。");
|
||
return {
|
||
kind: "peripheral",
|
||
device,
|
||
capability,
|
||
label: `${device.name} · ${capability.name}`,
|
||
risk: capability.risk || "low",
|
||
};
|
||
});
|
||
let totalDelayMs = 0;
|
||
plan.forEach((step, index) => {
|
||
if (step.kind !== "delay") return;
|
||
if (index === 0 || index === plan.length - 1) {
|
||
throw new Error("延时步骤必须位于两个动作之间。");
|
||
}
|
||
if (plan[index - 1].kind === "delay") {
|
||
throw new Error("联动计划不能包含连续延时。");
|
||
}
|
||
totalDelayMs += step.delayMs;
|
||
});
|
||
if (totalDelayMs > MAX_TOTAL_PLAN_DELAY_MS) {
|
||
throw new Error("联动计划累计延时超过安全上限。");
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
function setExecutionState(stateName, title, note, progress = 0) {
|
||
const card = $("#extensionExecutionState");
|
||
card.dataset.state = stateName;
|
||
$("#extensionRunTitle").textContent = title;
|
||
$("#extensionRunNote").textContent = note;
|
||
$("#extensionRunProgressBar").style.width = `${Math.max(0, Math.min(100, progress))}%`;
|
||
}
|
||
|
||
function renderRunPlan(plan, active = -1) {
|
||
const rail = $("#extensionRunPlan");
|
||
rail.hidden = false;
|
||
rail.innerHTML = plan.map((step, index) => `
|
||
<span class="${index < active ? "done" : index === active ? "active" : ""}"><i>${String(index + 1).padStart(2, "0")}</i>${escapeHtml(step.label || "待执行动作")}</span>`).join("");
|
||
}
|
||
|
||
async function executeInstruction(text) {
|
||
const input = String(text || "").trim();
|
||
if (!input) {
|
||
showToast("请先输入要执行的动作。", "error");
|
||
return;
|
||
}
|
||
if (isPlanning || isExecuting) {
|
||
showToast("上一条指令还在处理,请稍等。", "error");
|
||
return;
|
||
}
|
||
const button = $("#extensionExecuteButton");
|
||
isPlanning = true;
|
||
button.disabled = true;
|
||
setExecutionState("running", "GPT 正在理解意图", "匹配已学习能力与车端能力", 10);
|
||
try {
|
||
const result = await api("assistant/plan", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
utterance: input,
|
||
peripherals: peripheralCatalog(),
|
||
vehicle_devices: vehicleCatalog(),
|
||
}),
|
||
});
|
||
if (result.needs_confirmation) {
|
||
setExecutionState("warning", "需要补充目标", result.question || "请说明具体硬件", 0);
|
||
showToast(result.question || "请说明具体硬件。", "error");
|
||
return;
|
||
}
|
||
const plan = hydratePlan(result.steps);
|
||
if (!plan.length) {
|
||
setExecutionState("warning", "没有可执行动作", result.summary || "未匹配到能力", 0);
|
||
showToast(result.summary || "没有找到可执行动作。", "error");
|
||
return;
|
||
}
|
||
const risky = plan.filter((item) => item.risk === "high");
|
||
if (risky.length && !window.confirm(
|
||
`将执行 ${risky.length} 个可能让玩具移动的动作,请确认周围安全。`,
|
||
)) {
|
||
setExecutionState("idle", "已取消执行", "高风险动作未获确认", 0);
|
||
return;
|
||
}
|
||
isPlanning = false;
|
||
await executePlan(plan);
|
||
} catch (error) {
|
||
setExecutionState("error", "联动计划生成失败", error.message, 0);
|
||
showToast(error.message, "error");
|
||
} finally {
|
||
isPlanning = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function vehicleExecutionSucceeded(result) {
|
||
const operations = Array.isArray(result?.operation_results)
|
||
? result.operation_results
|
||
: [];
|
||
const everyOperationSucceeded = operations.length > 0
|
||
&& operations.every((item) => item?.status === "succeeded");
|
||
const hardwareRequest = result?.execution_mode === "hardware";
|
||
const feedback = Array.isArray(result?.physical_feedback)
|
||
? result.physical_feedback
|
||
: [];
|
||
const everyHardwareOperationConfirmed = !hardwareRequest || (
|
||
result?.physical_feedback_confirmed === true
|
||
&& feedback.length === operations.length
|
||
&& feedback.every((item) => item?.confirmed === true)
|
||
);
|
||
return result?.success === true
|
||
&& result?.status === "succeeded"
|
||
&& everyOperationSucceeded
|
||
&& everyHardwareOperationConfirmed;
|
||
}
|
||
|
||
function synchronizeVehicleExecution(command, result) {
|
||
if (!result || typeof result !== "object") return;
|
||
const completed = vehicleExecutionSucceeded(result);
|
||
state.lastVehicleExecution = {
|
||
at: new Date().toISOString(),
|
||
command,
|
||
success: completed,
|
||
status: result.status || "unknown",
|
||
executionMode: result.execution_mode || null,
|
||
requestId: result.request_id || null,
|
||
operationResults: result.operation_results || [],
|
||
executionReport: result.execution_report || null,
|
||
domainState: result.domain_state || null,
|
||
actuatorState: result.actuator_state || null,
|
||
physicalState: result.physical_state || null,
|
||
physicalFeedback: result.physical_feedback || [],
|
||
physicalFeedbackConfirmed: result.physical_feedback_confirmed === true,
|
||
conversationId: result.conversation_id || null,
|
||
conversationTurn: result.conversation_turn ?? null,
|
||
};
|
||
renderAuthoritativeVehicleState(result);
|
||
const operationCount = Array.isArray(result.operation_results)
|
||
? result.operation_results.length
|
||
: 0;
|
||
const physicalCount = Array.isArray(result.physical_feedback)
|
||
? result.physical_feedback.filter((item) => item?.confirmed === true).length
|
||
: 0;
|
||
const detail = completed
|
||
? result.execution_mode === "hardware"
|
||
? `物理回读已确认 · ${physicalCount || operationCount} 项`
|
||
: `安全演练已完成 · ${operationCount || 1} 项`
|
||
: vehicleExecutionError(result);
|
||
updateVehicleCardExecution(command, completed ? "succeeded" : "failed", detail);
|
||
saveState();
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:extension-vehicle-result", {
|
||
detail: result,
|
||
}));
|
||
}
|
||
|
||
function vehicleExecutionError(result) {
|
||
const failed = (result?.operation_results || []).find(
|
||
(item) => item?.status !== "succeeded",
|
||
);
|
||
return result?.message
|
||
|| failed?.message
|
||
|| `车端动作未成功完成(${result?.status || "unknown"})`;
|
||
}
|
||
|
||
async function executeVehicle(command) {
|
||
let result;
|
||
try {
|
||
result = await api("vehicle/execute", {
|
||
method: "POST",
|
||
// Channel, seat target, provider, vehicle profile and execution mode
|
||
// are intentionally absent: the server owns CH0/CH1 routing and safety policy.
|
||
body: JSON.stringify({ command, conversation_id: sharedConversationId() }),
|
||
});
|
||
} catch (error) {
|
||
if (error.payload) synchronizeVehicleExecution(command, error.payload);
|
||
throw error;
|
||
}
|
||
synchronizeVehicleExecution(command, result);
|
||
if (!vehicleExecutionSucceeded(result)) {
|
||
const error = new Error(vehicleExecutionError(result));
|
||
error.payload = result;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function executePlan(plan) {
|
||
if (isExecuting || !plan.length) return;
|
||
isExecuting = true;
|
||
renderRunPlan(plan);
|
||
let completed = 0;
|
||
let vehicleDryRun = false;
|
||
let vehicleHardwareFeedback = 0;
|
||
for (let index = 0; index < plan.length; index += 1) {
|
||
const step = plan[index];
|
||
renderRunPlan(plan, index);
|
||
setExecutionState(
|
||
"running",
|
||
step.label,
|
||
`正在处理第 ${index + 1} / ${plan.length} 个动作`,
|
||
(index / plan.length) * 100,
|
||
);
|
||
try {
|
||
if (step.kind === "delay") {
|
||
await delay(step.delayMs);
|
||
} else if (step.kind === "vehicle") {
|
||
updateVehicleCardExecution(step.command, "running", `正在执行:${step.label || step.command}`);
|
||
saveState();
|
||
const result = await executeVehicle(step.command);
|
||
vehicleDryRun ||= result.execution_mode === "dry_run";
|
||
if (result.execution_mode === "hardware") {
|
||
vehicleHardwareFeedback += result.physical_feedback.length;
|
||
}
|
||
} else {
|
||
await sendGatewayCommand("emit", { slot: step.capability.slot });
|
||
}
|
||
completed += 1;
|
||
addHistory(step, true);
|
||
} catch (error) {
|
||
addHistory(step, false, error.message);
|
||
setExecutionState("error", "联动执行已中断", error.message, (completed / plan.length) * 100);
|
||
showToast(error.message, "error");
|
||
break;
|
||
}
|
||
const nextStep = plan[index + 1];
|
||
if (nextStep && step.kind !== "delay" && nextStep.kind !== "delay") {
|
||
await delay(state.settings.actionDelayMs);
|
||
}
|
||
}
|
||
renderRunPlan(plan, completed);
|
||
if (completed === plan.length) {
|
||
setExecutionState(
|
||
"success",
|
||
"联动计划已全部处理",
|
||
vehicleDryRun
|
||
? "外设已发射,车端处于安全演练模式"
|
||
: vehicleHardwareFeedback > 0
|
||
? `车端 ${vehicleHardwareFeedback} 项动作均已同步物理反馈`
|
||
: "外设与车端链路处理完成",
|
||
100,
|
||
);
|
||
showToast("联动计划已全部处理。", "success");
|
||
}
|
||
isExecuting = false;
|
||
}
|
||
|
||
function addHistory(step, success, error = null) {
|
||
state.history.unshift({
|
||
id: uid("history"),
|
||
at: new Date().toISOString(),
|
||
kind: step.kind || "peripheral",
|
||
deviceId: step.device?.id || null,
|
||
capabilityName: step.label || step.capability?.name || step.command,
|
||
slot: step.capability?.slot || null,
|
||
success,
|
||
error,
|
||
});
|
||
state.history = state.history.slice(0, HISTORY_LIMIT);
|
||
saveState();
|
||
}
|
||
|
||
async function runCapability(capabilityId) {
|
||
const capability = state.capabilities.find((item) => item.id === capabilityId);
|
||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||
if (!capability || !device) return;
|
||
if (capability.risk === "high" && !window.confirm(
|
||
`“${capability.name}”可能让玩具移动,请确认周围安全。`,
|
||
)) return;
|
||
await executePlan([{
|
||
kind: "peripheral",
|
||
device,
|
||
capability,
|
||
label: `${device.name} · ${capability.name}`,
|
||
risk: capability.risk || "low",
|
||
}]);
|
||
}
|
||
|
||
async function requestHealth() {
|
||
const controller = new AbortController();
|
||
const timeoutId = window.setTimeout(() => controller.abort(), HEALTH_REQUEST_TIMEOUT_MS);
|
||
try {
|
||
return await api("health", { signal: controller.signal });
|
||
} finally {
|
||
window.clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
function refreshHealth(showMessage = false) {
|
||
healthToastRequested ||= showMessage;
|
||
if (healthRefreshPromise) return healthRefreshPromise;
|
||
healthRefreshPromise = (async () => {
|
||
if (!healthState || healthToastRequested) {
|
||
$("#extensionStudyHealth").dataset.state = "checking";
|
||
$("#extensionStudyHealthState").textContent = "CHECK";
|
||
$("#extensionVehicleCard").dataset.state = "checking";
|
||
$("#extensionVehicleState").textContent = "CHECK";
|
||
}
|
||
try {
|
||
healthState = await requestHealth();
|
||
} catch (error) {
|
||
healthState = {
|
||
connected: false,
|
||
listening: false,
|
||
transport: "unavailable",
|
||
message: error.message,
|
||
gpt: { configured: false },
|
||
vehicle: { connected: false },
|
||
};
|
||
}
|
||
const connected = Boolean(healthState.connected);
|
||
const listening = !connected && Boolean(healthState.listening);
|
||
const transport = String(healthState.transport || "--").toUpperCase();
|
||
let connectionLabel;
|
||
if (connected) {
|
||
const peer = String(healthState.peer || "").trim();
|
||
connectionLabel = healthState.transport === "simulation"
|
||
? "模拟学习器已就绪"
|
||
: `${transport} 学习器已连接${peer ? ` · ${peer}` : ""}`;
|
||
} else if (listening) {
|
||
connectionLabel = `${transport} 监听已就绪,等待 GSCU1B 接入`;
|
||
} else {
|
||
connectionLabel = healthState.message || "GSCU1B 尚未连接";
|
||
}
|
||
const studyHealthText = $("#extensionStudyHealthText");
|
||
studyHealthText.textContent = connectionLabel;
|
||
studyHealthText.title = healthState.message || connectionLabel;
|
||
$("#extensionStudyHealthState").textContent = connected
|
||
? "READY"
|
||
: (listening ? "WAIT" : "OFFLINE");
|
||
$("#extensionStudyHealth").dataset.state = connected
|
||
? "online"
|
||
: (listening ? "checking" : "offline");
|
||
const vehicleConnected = Boolean(healthState.vehicle?.connected);
|
||
$("#extensionVehicleState").textContent = vehicleConnected ? "READY" : "OFFLINE";
|
||
$("#extensionVehicleCard").dataset.state = vehicleConnected ? "online" : "offline";
|
||
const connectionText = $("#extensionConnectionText");
|
||
connectionText.textContent = connectionLabel;
|
||
connectionText.title = healthState.message || connectionLabel;
|
||
$(".v2-extension-footer").classList.toggle("offline", !connected);
|
||
$("#extensionModelName").textContent = healthState.gpt?.configured
|
||
? healthState.gpt.model || "GPT"
|
||
: "GPT 未配置";
|
||
$("#extensionModelState").textContent = healthState.gpt?.configured ? "READY" : "LOCAL";
|
||
$("#extensionModelCard").classList.toggle("unavailable", !healthState.gpt?.configured);
|
||
if (healthToastRequested) {
|
||
const ready = [connected, healthState.gpt?.configured, healthState.vehicle?.connected]
|
||
.filter(Boolean).length;
|
||
showToast(`自扩展链路:${ready} / 3 项就绪。`, ready === 3 ? "success" : "error");
|
||
}
|
||
healthToastRequested = false;
|
||
return healthState;
|
||
})().finally(() => {
|
||
healthRefreshPromise = null;
|
||
});
|
||
return healthRefreshPromise;
|
||
}
|
||
|
||
function startHealthPolling() {
|
||
if (healthPollTimer !== null) return;
|
||
healthPollTimer = window.setInterval(() => {
|
||
if (document.visibilityState === "visible"
|
||
&& document.body.dataset.module === "self-extension") refreshHealth();
|
||
}, HEALTH_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
function stopHealthPolling() {
|
||
if (healthPollTimer !== null) window.clearInterval(healthPollTimer);
|
||
healthPollTimer = null;
|
||
}
|
||
|
||
async function resetSession() {
|
||
const button = $("#extensionConfirmReset");
|
||
button.disabled = true;
|
||
button.textContent = "正在擦除学习器…";
|
||
try {
|
||
await sendGatewayCommand("erase", { confirm: "ERASE_ALL" });
|
||
state.capabilities = state.devices.flatMap((device) => seedPresets(device));
|
||
state.history = [];
|
||
state.sessionStartedAt = new Date().toISOString();
|
||
saveState();
|
||
closeDialog("extensionResetDialog");
|
||
renderAll();
|
||
showToast("学习器已擦除,所有动作恢复为待学习。", "success");
|
||
} catch (error) {
|
||
showToast(error.message, "error");
|
||
} finally {
|
||
button.textContent = "擦除并重新开始";
|
||
button.disabled = $("#extensionResetConfirm").value.trim() !== "清空";
|
||
}
|
||
}
|
||
|
||
function initializeDesktopPet() {
|
||
const pet = $("#extensionDesktopPet");
|
||
const body = $("#extensionPetBody");
|
||
const bubble = $("#extensionPetBubble");
|
||
const image = $(".v2-extension-pet-image", body);
|
||
const entranceCanvas = $(".v2-extension-pet-entrance-canvas", body);
|
||
const tailCanvas = $(".v2-extension-pet-tail-canvas", body);
|
||
const mouth = $(".v2-extension-pet-mouth-closed", body);
|
||
const entranceContext = entranceCanvas?.getContext("2d");
|
||
const tailContext = tailCanvas?.getContext("2d");
|
||
if (!pet || !body || !bubble || !image || !entranceCanvas || !entranceContext
|
||
|| !tailCanvas || !tailContext || !mouth) return;
|
||
const sourceImage = new Image();
|
||
const entranceSourceImage = new Image();
|
||
const themedEntranceImage = new Image();
|
||
const ENTRANCE_KEYFRAME_COUNT = 12;
|
||
const ENTRANCE_CELL_SIZE = 512;
|
||
const ENTRANCE_DURATION_MS = 2300;
|
||
const ENTRANCE_WALK_END = 0.6;
|
||
const ENTRANCE_WALK_STEPS = 5;
|
||
const ENTRANCE_SETTLE_START_FRAME = 5;
|
||
const ENTRANCE_WALK_POSES = [
|
||
0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4,
|
||
];
|
||
let tailWaveQueued = false;
|
||
let tailWaveSequenceActive = false;
|
||
let tailWaveReplaying = false;
|
||
let entranceAnimationFrame = null;
|
||
let entranceRequested = document.body.dataset.module === "self-extension";
|
||
let entranceThemeReady = false;
|
||
let entranceThemeRevision = 0;
|
||
let extensionWasActive = document.body.dataset.module === "self-extension";
|
||
const applyPetTheme = () => {
|
||
if (!sourceImage.complete || !sourceImage.naturalWidth) return;
|
||
const accent = getComputedStyle(document.body).getPropertyValue("--v2-accent").trim()
|
||
|| "#0e7773";
|
||
const themedSource = createThemedPetDataUrl(sourceImage, accent);
|
||
if (themedSource) image.src = themedSource;
|
||
image.dataset.themeRole = "user";
|
||
if (!entranceSourceImage.complete || !entranceSourceImage.naturalWidth) return;
|
||
const revision = ++entranceThemeRevision;
|
||
entranceThemeReady = false;
|
||
const themedEntranceSource = createThemedPetDataUrl(entranceSourceImage, accent);
|
||
if (!themedEntranceSource) return;
|
||
themedEntranceImage.addEventListener("load", () => {
|
||
if (revision !== entranceThemeRevision) return;
|
||
entranceThemeReady = true;
|
||
if (entranceRequested) playEntrance();
|
||
}, { once: true });
|
||
themedEntranceImage.src = themedEntranceSource;
|
||
};
|
||
sourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
|
||
sourceImage.src = image.getAttribute("src");
|
||
entranceSourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
|
||
entranceSourceImage.src = "/assets/pets/self-extension-cat-entrance.png?v=3";
|
||
const tailSourceCanvas = document.createElement("canvas");
|
||
const tailSourceContext = tailSourceCanvas.getContext("2d");
|
||
const tailPoints = [
|
||
[350, 165], [400, 166], [416, 190], [414, 400], [399, 450], [342, 464],
|
||
[330, 442], [347, 419], [361, 380], [367, 228], [345, 198],
|
||
];
|
||
const tailMotionFrames = [
|
||
[0, 0, 0, 0, 0, 0],
|
||
[0, -5, -11, -7, 7, 17],
|
||
[0, -9, -20, -13, 13, 30],
|
||
[0, -13, -27, -17, 19, 41],
|
||
[0, -16, -32, -19, 24, 48],
|
||
[0, -9, -19, -11, 13, 31],
|
||
[0, -4, -9, -5, 6, 14],
|
||
[0, 0, 0, 0, 0, 0],
|
||
[0, 2, 5, 3, -2, -6],
|
||
[0, 3, 7, 4, -3, -8],
|
||
[0, 1, 3, 2, -1, -3],
|
||
[0, 0, 0, 0, 0, 0],
|
||
];
|
||
const interpolateCubic = (before, start, end, after, amount, clampToSegment = false) => {
|
||
const amountSquared = amount * amount;
|
||
const amountCubed = amountSquared * amount;
|
||
const interpolated = 0.5 * (
|
||
(2 * start)
|
||
+ (-before + end) * amount
|
||
+ (2 * before - 5 * start + 4 * end - after) * amountSquared
|
||
+ (-before + 3 * start - 3 * end + after) * amountCubed
|
||
);
|
||
if (!clampToSegment) return interpolated;
|
||
return Math.max(Math.min(start, end), Math.min(Math.max(start, end), interpolated));
|
||
};
|
||
const getTailPose = (progress) => {
|
||
const framePosition = Math.max(0, Math.min(1, progress)) * (tailMotionFrames.length - 1);
|
||
const frameIndex = Math.min(tailMotionFrames.length - 2, Math.floor(framePosition));
|
||
const amount = framePosition - frameIndex;
|
||
const beforeFrame = tailMotionFrames[Math.max(0, frameIndex - 1)];
|
||
const startFrame = tailMotionFrames[frameIndex];
|
||
const endFrame = tailMotionFrames[frameIndex + 1];
|
||
const afterFrame = tailMotionFrames[Math.min(tailMotionFrames.length - 1, frameIndex + 2)];
|
||
return startFrame.map((offset, index) => interpolateCubic(
|
||
beforeFrame[index], offset, endFrame[index], afterFrame[index], amount, true,
|
||
));
|
||
};
|
||
const sampleTailOffset = (pose, distanceFromBase) => {
|
||
const position = Math.max(0, Math.min(1, distanceFromBase)) * (pose.length - 1);
|
||
const index = Math.min(pose.length - 2, Math.floor(position));
|
||
const amount = position - index;
|
||
const before = pose[Math.max(0, index - 1)];
|
||
const start = pose[index];
|
||
const end = pose[index + 1];
|
||
const after = pose[Math.min(pose.length - 1, index + 2)];
|
||
return interpolateCubic(before, start, end, after, amount);
|
||
};
|
||
const traceTail = (context, scaleX, scaleY) => {
|
||
context.beginPath();
|
||
tailPoints.forEach(([x, y], index) => {
|
||
const method = index ? "lineTo" : "moveTo";
|
||
context[method](x * scaleX, y * scaleY);
|
||
});
|
||
context.closePath();
|
||
};
|
||
const stopTailWave = () => {
|
||
window.clearTimeout(petTailTimer);
|
||
petTailTimer = null;
|
||
if (petTailFrame !== null) window.cancelAnimationFrame(petTailFrame);
|
||
petTailFrame = null;
|
||
tailWaveQueued = false;
|
||
tailWaveSequenceActive = false;
|
||
tailWaveReplaying = false;
|
||
delete pet.dataset.tailWaving;
|
||
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
|
||
};
|
||
const stopEntrance = () => {
|
||
if (entranceAnimationFrame !== null) {
|
||
window.cancelAnimationFrame(entranceAnimationFrame);
|
||
entranceAnimationFrame = null;
|
||
}
|
||
delete pet.dataset.entering;
|
||
entranceCanvas.style.removeProperty("transform");
|
||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||
};
|
||
const drawEntranceKeyframe = (frameIndex) => {
|
||
entranceContext.drawImage(
|
||
themedEntranceImage,
|
||
frameIndex * ENTRANCE_CELL_SIZE,
|
||
0,
|
||
ENTRANCE_CELL_SIZE,
|
||
ENTRANCE_CELL_SIZE,
|
||
0,
|
||
0,
|
||
entranceCanvas.width,
|
||
entranceCanvas.height,
|
||
);
|
||
};
|
||
const drawEntrancePose = (progress) => {
|
||
let activeFrame;
|
||
let travelProgress = 1;
|
||
let stepBob = 0;
|
||
if (progress < ENTRANCE_WALK_END) {
|
||
const walkProgress = Math.max(0, progress / ENTRANCE_WALK_END);
|
||
const walkStepPosition = walkProgress * ENTRANCE_WALK_STEPS;
|
||
const completedSteps = Math.min(
|
||
ENTRANCE_WALK_STEPS - 1,
|
||
Math.floor(walkStepPosition),
|
||
);
|
||
const stepProgress = walkStepPosition - completedSteps;
|
||
const easedStepProgress = 0.5 - (Math.cos(Math.PI * stepProgress) / 2);
|
||
travelProgress = (completedSteps + easedStepProgress) / ENTRANCE_WALK_STEPS;
|
||
stepBob = -Math.sin(Math.PI * stepProgress) * 2.8;
|
||
const walkPoseIndex = Math.min(
|
||
ENTRANCE_WALK_POSES.length - 1,
|
||
Math.round(walkProgress * (ENTRANCE_WALK_POSES.length - 1)),
|
||
);
|
||
activeFrame = ENTRANCE_WALK_POSES[walkPoseIndex];
|
||
} else {
|
||
const settleProgress = (progress - ENTRANCE_WALK_END) / (1 - ENTRANCE_WALK_END);
|
||
activeFrame = ENTRANCE_SETTLE_START_FRAME + Math.round(
|
||
settleProgress * (ENTRANCE_KEYFRAME_COUNT - ENTRANCE_SETTLE_START_FRAME - 1),
|
||
);
|
||
}
|
||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||
drawEntranceKeyframe(activeFrame);
|
||
return { travelProgress, stepBob };
|
||
};
|
||
function playEntrance() {
|
||
if (!entranceRequested || !entranceThemeReady
|
||
|| document.visibilityState !== "visible"
|
||
|| document.body.dataset.module !== "self-extension") return;
|
||
entranceRequested = false;
|
||
stopEntrance();
|
||
stopTailWave();
|
||
delete pet.dataset.blinking;
|
||
pet.dataset.mood = "idle";
|
||
pet.dataset.entering = "true";
|
||
const startedAt = performance.now();
|
||
const renderEntrance = (now) => {
|
||
if (document.visibilityState !== "visible"
|
||
|| document.body.dataset.module !== "self-extension") {
|
||
stopEntrance();
|
||
return;
|
||
}
|
||
const progress = Math.min(1, (now - startedAt) / ENTRANCE_DURATION_MS);
|
||
const entrancePose = drawEntrancePose(progress);
|
||
const horizontalOffset = (1 - entrancePose.travelProgress) * (pet.offsetWidth + 34);
|
||
entranceCanvas.style.transform = `translate3d(${horizontalOffset}px, ${entrancePose.stepBob}px, 0)`;
|
||
if (progress < 1) {
|
||
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
|
||
return;
|
||
}
|
||
entranceAnimationFrame = null;
|
||
delete pet.dataset.entering;
|
||
entranceCanvas.style.removeProperty("transform");
|
||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||
if (document.body.dataset.module === "self-extension") {
|
||
say("自扩展工作台已就绪", "happy");
|
||
}
|
||
};
|
||
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
|
||
}
|
||
const requestEntrance = () => {
|
||
entranceRequested = true;
|
||
if (entranceThemeReady) playEntrance();
|
||
};
|
||
const drawTailWaveFrame = (progress) => {
|
||
if (!image.complete || !image.naturalWidth || !tailSourceContext) return false;
|
||
const width = image.naturalWidth;
|
||
const height = image.naturalHeight;
|
||
if (tailCanvas.width !== width || tailCanvas.height !== height) {
|
||
tailCanvas.width = width;
|
||
tailCanvas.height = height;
|
||
}
|
||
if (tailSourceCanvas.width !== width || tailSourceCanvas.height !== height) {
|
||
tailSourceCanvas.width = width;
|
||
tailSourceCanvas.height = height;
|
||
}
|
||
const scaleX = width / 512;
|
||
const scaleY = height / 512;
|
||
const tailTop = 171 * scaleY;
|
||
const tailBottom = 459 * scaleY;
|
||
const sliceHeight = Math.max(2, Math.round(2 * scaleY));
|
||
tailContext.clearRect(0, 0, width, height);
|
||
tailContext.drawImage(image, 0, 0, width, height);
|
||
tailContext.save();
|
||
tailContext.globalCompositeOperation = "destination-out";
|
||
traceTail(tailContext, scaleX, scaleY);
|
||
tailContext.fill();
|
||
tailContext.lineWidth = 10 * Math.max(scaleX, scaleY);
|
||
tailContext.lineJoin = "round";
|
||
tailContext.stroke();
|
||
tailContext.restore();
|
||
tailSourceContext.clearRect(0, 0, width, height);
|
||
tailSourceContext.save();
|
||
traceTail(tailSourceContext, scaleX, scaleY);
|
||
tailSourceContext.clip();
|
||
tailSourceContext.drawImage(image, 0, 0, width, height);
|
||
tailSourceContext.restore();
|
||
const tailPose = getTailPose(progress);
|
||
for (let y = tailTop; y < tailBottom; y += sliceHeight) {
|
||
const distanceFromBase = (tailBottom - y) / (tailBottom - tailTop);
|
||
const offset = sampleTailOffset(tailPose, distanceFromBase) * scaleX;
|
||
tailContext.drawImage(
|
||
tailSourceCanvas,
|
||
0, y, width, sliceHeight + 1,
|
||
offset, y, width, sliceHeight + 1,
|
||
);
|
||
}
|
||
return true;
|
||
};
|
||
const runTailWave = (isReplay = false) => {
|
||
petTailTimer = null;
|
||
if (document.visibilityState !== "visible"
|
||
|| document.body.dataset.module !== "self-extension"
|
||
|| !image.complete
|
||
|| !drawTailWaveFrame(0)) {
|
||
stopTailWave();
|
||
return;
|
||
}
|
||
const startedAt = performance.now();
|
||
tailWaveSequenceActive = true;
|
||
tailWaveReplaying = isReplay;
|
||
pet.dataset.tailWaving = "true";
|
||
const renderTailWave = (now) => {
|
||
const progress = Math.min(1, (now - startedAt) / 1900);
|
||
if (!drawTailWaveFrame(progress)) {
|
||
stopTailWave();
|
||
return;
|
||
}
|
||
if (progress < 1) {
|
||
petTailFrame = window.requestAnimationFrame(renderTailWave);
|
||
return;
|
||
}
|
||
petTailFrame = null;
|
||
delete pet.dataset.tailWaving;
|
||
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
|
||
if (!tailWaveReplaying && tailWaveQueued) {
|
||
tailWaveQueued = false;
|
||
tailWaveReplaying = true;
|
||
petTailTimer = window.setTimeout(() => runTailWave(true), 180);
|
||
return;
|
||
}
|
||
tailWaveSequenceActive = false;
|
||
tailWaveReplaying = false;
|
||
tailWaveQueued = false;
|
||
};
|
||
petTailFrame = window.requestAnimationFrame(renderTailWave);
|
||
};
|
||
const requestTailWave = () => {
|
||
if (pet.dataset.entering === "true") return;
|
||
if (tailWaveSequenceActive) {
|
||
if (!tailWaveReplaying) tailWaveQueued = true;
|
||
return;
|
||
}
|
||
runTailWave();
|
||
};
|
||
const scheduleBlink = (delay = 2800 + Math.random() * 4200) => {
|
||
window.clearTimeout(petBlinkTimer);
|
||
petBlinkTimer = window.setTimeout(() => {
|
||
if (document.visibilityState !== "visible"
|
||
|| document.body.dataset.module !== "self-extension"
|
||
|| pet.dataset.entering === "true") {
|
||
scheduleBlink();
|
||
return;
|
||
}
|
||
pet.dataset.blinking = "true";
|
||
window.clearTimeout(petBlinkResetTimer);
|
||
petBlinkResetTimer = window.setTimeout(() => {
|
||
delete pet.dataset.blinking;
|
||
if (Math.random() < 0.22) {
|
||
petBlinkTimer = window.setTimeout(() => {
|
||
pet.dataset.blinking = "true";
|
||
petBlinkResetTimer = window.setTimeout(() => {
|
||
delete pet.dataset.blinking;
|
||
scheduleBlink();
|
||
}, 135);
|
||
}, 145);
|
||
} else {
|
||
scheduleBlink();
|
||
}
|
||
}, 135);
|
||
}, delay);
|
||
};
|
||
scheduleBlink(1200 + Math.random() * 1600);
|
||
const phrases = [
|
||
"嗨,我会陪你学习新能力",
|
||
"拖动我可以换个位置",
|
||
"玩具动作和车端设备都能一起编排",
|
||
"虚拟运行重启后,我也会恢复待命",
|
||
"点击车端动作,我会帮你盯着执行状态",
|
||
];
|
||
const pointer = { active: false, moved: false, x: 0, y: 0, startX: 0, startY: 0 };
|
||
|
||
const animateMouth = () => {
|
||
if (pet.dataset.entering === "true") return;
|
||
mouth.getAnimations?.().forEach((animation) => animation.cancel());
|
||
mouth.animate?.(
|
||
[
|
||
{ opacity: 1, offset: 0 },
|
||
{ opacity: 1, offset: 0.06 },
|
||
{ opacity: 0, offset: 0.09 },
|
||
{ opacity: 0, offset: 0.23 },
|
||
{ opacity: 1, offset: 0.26 },
|
||
{ opacity: 1, offset: 0.38 },
|
||
{ opacity: 0, offset: 0.41 },
|
||
{ opacity: 0, offset: 0.55 },
|
||
{ opacity: 1, offset: 0.58 },
|
||
{ opacity: 1, offset: 0.7 },
|
||
{ opacity: 0, offset: 0.73 },
|
||
{ opacity: 0, offset: 0.87 },
|
||
{ opacity: 1, offset: 0.9 },
|
||
{ opacity: 1, offset: 1 },
|
||
],
|
||
{ duration: 1260, easing: "linear" },
|
||
);
|
||
};
|
||
|
||
const say = (message, mood = "talking") => {
|
||
bubble.textContent = message;
|
||
pet.dataset.mood = mood;
|
||
animateMouth();
|
||
window.clearTimeout(Number(pet.dataset.moodTimer || 0));
|
||
pet.dataset.moodTimer = String(window.setTimeout(() => {
|
||
pet.dataset.mood = "idle";
|
||
}, 2600));
|
||
};
|
||
|
||
pet.addEventListener("pointerenter", () => {
|
||
if (pet.dataset.entering === "true") return;
|
||
requestTailWave();
|
||
if (pet.dataset.mood === "idle") animateMouth();
|
||
});
|
||
|
||
const clampPosition = (x, y) => {
|
||
const edgeMargin = 18;
|
||
const maxLeftShift = Math.max(0, window.innerWidth - pet.offsetWidth - edgeMargin * 2);
|
||
const maxUpShift = Math.max(0, window.innerHeight - pet.offsetHeight - edgeMargin * 2);
|
||
return {
|
||
x: Math.max(-maxLeftShift, Math.min(0, x)),
|
||
y: Math.max(-maxUpShift, Math.min(0, y)),
|
||
};
|
||
};
|
||
|
||
body.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0 || pet.dataset.entering === "true") return;
|
||
pointer.active = true;
|
||
pointer.moved = false;
|
||
pointer.startX = event.clientX;
|
||
pointer.startY = event.clientY;
|
||
pointer.x = Number(pet.dataset.x || 0);
|
||
pointer.y = Number(pet.dataset.y || 0);
|
||
body.setPointerCapture?.(event.pointerId);
|
||
pet.dataset.dragging = "true";
|
||
});
|
||
body.addEventListener("pointermove", (event) => {
|
||
if (!pointer.active) return;
|
||
const deltaX = event.clientX - pointer.startX;
|
||
const deltaY = event.clientY - pointer.startY;
|
||
pointer.moved ||= Math.hypot(deltaX, deltaY) > 4;
|
||
const position = clampPosition(pointer.x + deltaX, pointer.y + deltaY);
|
||
pet.dataset.x = String(position.x);
|
||
pet.dataset.y = String(position.y);
|
||
pet.style.setProperty("--pet-x", `${position.x}px`);
|
||
pet.style.setProperty("--pet-y", `${position.y}px`);
|
||
});
|
||
const endDrag = () => {
|
||
pointer.active = false;
|
||
delete pet.dataset.dragging;
|
||
};
|
||
body.addEventListener("pointerup", endDrag);
|
||
body.addEventListener("pointercancel", endDrag);
|
||
body.addEventListener("click", () => {
|
||
if (pointer.moved || pet.dataset.entering === "true") return;
|
||
requestTailWave();
|
||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||
say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking");
|
||
});
|
||
window.addEventListener("resize", () => {
|
||
const position = clampPosition(Number(pet.dataset.x || 0), Number(pet.dataset.y || 0));
|
||
pet.dataset.x = String(position.x);
|
||
pet.dataset.y = String(position.y);
|
||
pet.style.setProperty("--pet-x", `${position.x}px`);
|
||
pet.style.setProperty("--pet-y", `${position.y}px`);
|
||
});
|
||
window.addEventListener("cognitive-agent:module-change", (event) => {
|
||
const extensionIsActive = event.detail?.module === "self-extension";
|
||
const enteredExtension = extensionIsActive && !extensionWasActive;
|
||
extensionWasActive = extensionIsActive;
|
||
if (enteredExtension) {
|
||
requestEntrance();
|
||
} else if (!extensionIsActive) {
|
||
entranceRequested = false;
|
||
stopEntrance();
|
||
}
|
||
});
|
||
petTimer = window.setInterval(() => {
|
||
if (document.visibilityState !== "visible"
|
||
|| document.body.dataset.module !== "self-extension"
|
||
|| pet.dataset.entering === "true") return;
|
||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||
say(phrases[petPhraseIndex]);
|
||
}, 14000);
|
||
window.addEventListener("pagehide", () => {
|
||
window.clearInterval(petTimer);
|
||
window.clearTimeout(petBlinkTimer);
|
||
window.clearTimeout(petBlinkResetTimer);
|
||
stopEntrance();
|
||
stopTailWave();
|
||
});
|
||
}
|
||
|
||
function bindEvents() {
|
||
$$('[data-extension-mode]').forEach((button) => {
|
||
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
|
||
});
|
||
$("#extensionHardwareGrid").addEventListener("click", (event) => {
|
||
const add = event.target.closest("[data-add-type]");
|
||
if (add) return openAdd(add.dataset.addType);
|
||
const pageButton = event.target.closest("[data-extension-page]");
|
||
if (pageButton) {
|
||
const typeKey = pageButton.dataset.extensionPage;
|
||
pages[typeKey] = Math.max(
|
||
0,
|
||
(pages[typeKey] || 0) + Number(pageButton.dataset.pageDirection),
|
||
);
|
||
renderHardwareGrid();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
return;
|
||
}
|
||
const remove = event.target.closest("[data-delete-extension-device]");
|
||
if (remove) return deleteHardware(remove.dataset.deleteExtensionDevice);
|
||
const forget = event.target.closest("[data-forget-extension-capability]");
|
||
if (forget) return forgetCapability(forget.dataset.forgetExtensionCapability);
|
||
const learn = event.target.closest("[data-learn-extension-capability]");
|
||
if (learn) return openLearn(learn.dataset.learnExtensionCapability);
|
||
const run = event.target.closest("[data-run-extension-capability]");
|
||
if (run) runCapability(run.dataset.runExtensionCapability);
|
||
});
|
||
$("#extensionHardwareGrid").addEventListener("change", (event) => {
|
||
const selector = event.target.closest("[data-extension-device-select]");
|
||
if (!selector) return;
|
||
state.selectedDeviceByType[selector.dataset.extensionDeviceSelect] = selector.value;
|
||
pages[selector.dataset.extensionDeviceSelect] = 0;
|
||
saveState();
|
||
renderHardwareGrid();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
});
|
||
$("#extensionTypeChooser").addEventListener("click", (event) => {
|
||
const choice = event.target.closest("[data-extension-type-choice]");
|
||
if (!choice) return;
|
||
selectedAddType = choice.dataset.extensionTypeChoice;
|
||
renderTypeChooser();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
});
|
||
$("#extensionAddForm").addEventListener("submit", addHardware);
|
||
$("#extensionContinueLearn").addEventListener("click", () => {
|
||
try {
|
||
learning.draft = collectLearningDraft();
|
||
showLearnPage(2);
|
||
} catch (error) {
|
||
showToast(error.message, "error");
|
||
}
|
||
});
|
||
$("#extensionStartLearn").addEventListener("click", startLearning);
|
||
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
|
||
$("#extensionTestEmit").addEventListener("click", testLearning);
|
||
$("#extensionConfirmLearn").addEventListener("click", confirmLearning);
|
||
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
|
||
if (learning.phase === "listening") {
|
||
event.preventDefault();
|
||
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
|
||
}
|
||
});
|
||
$("#extensionLearnedGrid").addEventListener("click", (event) => {
|
||
const switcher = event.target.closest("[data-switch-extension-mode]");
|
||
if (switcher) return setMode(switcher.dataset.switchExtensionMode);
|
||
const run = event.target.closest("[data-run-extension-capability]");
|
||
if (run) runCapability(run.dataset.runExtensionCapability);
|
||
});
|
||
$("#extensionExecuteButton").addEventListener(
|
||
"click",
|
||
() => executeInstruction($("#extensionUtterance").value),
|
||
);
|
||
$("#extensionUtterance").addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
executeInstruction(event.currentTarget.value);
|
||
}
|
||
});
|
||
$("#extensionFooterRefresh").addEventListener("click", () => refreshHealth(true));
|
||
$("#extensionResetButton").addEventListener("click", () => {
|
||
$("#extensionResetConfirm").value = "";
|
||
$("#extensionConfirmReset").disabled = true;
|
||
openDialog("extensionResetDialog");
|
||
});
|
||
$("#extensionResetConfirm").addEventListener("input", (event) => {
|
||
$("#extensionConfirmReset").disabled = event.currentTarget.value.trim() !== "清空";
|
||
});
|
||
$("#extensionConfirmReset").addEventListener("click", resetSession);
|
||
$$('[data-close-extension-dialog]').forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
if (button.dataset.closeExtensionDialog === "extensionLearnDialog"
|
||
&& learning.phase === "listening") await cancelLearning();
|
||
closeDialog(button.dataset.closeExtensionDialog);
|
||
});
|
||
});
|
||
window.addEventListener("cognitive-agent:module-change", (event) => {
|
||
if (event.detail?.module === "self-extension") {
|
||
setMode(activeMode);
|
||
refreshHealth();
|
||
} else {
|
||
syncSharedTwinMount();
|
||
}
|
||
});
|
||
window.addEventListener("cognitive-agent:result", (event) => {
|
||
renderAuthoritativeVehicleState(event.detail);
|
||
});
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "visible"
|
||
&& document.body.dataset.module === "self-extension") refreshHealth();
|
||
});
|
||
window.addEventListener("pagehide", stopHealthPolling);
|
||
window.addEventListener("pageshow", () => {
|
||
startHealthPolling();
|
||
if (document.visibilityState === "visible"
|
||
&& document.body.dataset.module === "self-extension") refreshHealth();
|
||
});
|
||
}
|
||
|
||
function buildUnifiedExtensionSync() {
|
||
const mount = $("#vehicleExtensionSync");
|
||
if (!mount) return false;
|
||
let snapshot = null;
|
||
|
||
const render = () => {
|
||
const health = snapshot?.extension_health || {};
|
||
const physicalConnected = health.transport !== "simulation"
|
||
&& Boolean(health.connected || health.peer_connected);
|
||
const listening = Boolean(health.listening);
|
||
const sharedCatalog = Array.isArray(snapshot?.extension?.catalog)
|
||
? snapshot.extension.catalog
|
||
: [];
|
||
const mergedCatalog = [...sharedCatalog, ...peripheralCatalog()]
|
||
.filter((item, index, catalog) => {
|
||
const identity = item.id || `${item.device_id}:${item.action}`;
|
||
return catalog.findIndex((candidate) => (
|
||
candidate.id || `${candidate.device_id}:${candidate.action}`
|
||
) === identity) === index;
|
||
});
|
||
const grouped = new Map();
|
||
mergedCatalog.forEach((item) => {
|
||
const deviceId = item.device_id || "external-device";
|
||
if (!grouped.has(deviceId)) grouped.set(deviceId, {
|
||
id: deviceId,
|
||
name: item.device_name || item.type_label || "USB 扩展设备",
|
||
type: item.type_label || item.device_type || "扩展硬件",
|
||
actions: [],
|
||
});
|
||
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
|
||
});
|
||
const devices = [...grouped.values()];
|
||
const lastAction = snapshot?.extension?.last_action;
|
||
const lastResult = snapshot?.extension?.result;
|
||
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
|
||
const statusLabel = physicalConnected
|
||
? "USB 外设已接入"
|
||
: devices.length
|
||
? "外设目录已同步"
|
||
: health.transport === "simulation"
|
||
? "扩展仿真链路"
|
||
: listening
|
||
? "等待 USB 外设接入"
|
||
: "扩展链路检查中";
|
||
const statusNote = physicalConnected
|
||
? `${health.peer || "GSCU1B"} · 已同步 ${mergedCatalog.length} 项扩展能力`
|
||
: devices.length
|
||
? `车机已同步 ${devices.length} 个外设、${mergedCatalog.length} 项扩展能力`
|
||
: health.message || "插入外设后,车机将自动识别并同步到此处";
|
||
mount.dataset.hasDevices = String(devices.length > 0);
|
||
mount.innerHTML = `
|
||
<header class="v2-extension-sync-heading">
|
||
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3></div>
|
||
<span data-state="${physicalConnected || devices.length ? "online" : listening ? "listening" : "offline"}"><i></i>${statusLabel}</span>
|
||
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
|
||
</header>
|
||
<div class="v2-extension-gateway">
|
||
<span><i data-lucide="usb"></i></span>
|
||
<div><strong>${statusLabel}</strong><small>${escapeHtml(statusNote)}</small></div>
|
||
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
|
||
</div>
|
||
<div class="v2-extension-device-list">
|
||
${devices.length ? devices.map((device) => `
|
||
<article class="v2-extension-synced-device">
|
||
<header><span><i data-lucide="cpu"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}</small></div><b>${device.actions.length} 项能力</b></header>
|
||
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
|
||
</article>`).join("") : `
|
||
<div class="v2-extension-empty">
|
||
<i data-lucide="cable"></i><div><strong>暂无已识别外设</strong><span>外设名称、类型和已学习能力将由车机同步显示</span></div>
|
||
</div>`}
|
||
</div>
|
||
<footer class="v2-extension-linkage" data-active="${String(Boolean(lastAction))}">
|
||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(lastAction?.command || lastAction?.type || "等待外设与车端设备联动")}</strong></div>
|
||
<span>${escapeHtml(lastResult?.status || lastResult?.message || "STANDBY")}</span>
|
||
</footer>`;
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
};
|
||
|
||
window.addEventListener("cognitive-agent:display-sync", (event) => {
|
||
snapshot = event.detail || null;
|
||
render();
|
||
});
|
||
render();
|
||
return true;
|
||
}
|
||
|
||
function init() {
|
||
if (buildUnifiedExtensionSync()) return;
|
||
if (!buildShell()) return;
|
||
initializeDesktopPet();
|
||
bindEvents();
|
||
renderAll();
|
||
setMode(activeMode);
|
||
if (document.body.dataset.module === "self-extension") refreshHealth();
|
||
startHealthPolling();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
}());
|