(function () {
"use strict";
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
const MODE_KEY = "cognitive-agent.extension-mode";
const SLOT_MIN = 1;
const SLOT_MAX = 248;
const ACTION_PAGE_SIZE = 6;
const HISTORY_LIMIT = 40;
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
function preset(key, name, aliases, category = "custom", risk = "low", visual = null) {
return { key, name, aliases, category, risk, visual };
}
const TOY_TYPES = {
light: {
label: "灯光类玩具",
shortLabel: "灯光",
icon: "lightbulb",
color: "#eab858",
description: "颜色、亮灭与闪烁",
presets: [
preset("on", "开启", ["打开", "开灯"], "power"),
preset("off", "关闭", ["关掉", "关灯", "熄灭"], "power"),
preset("red", "红色灯光", ["红灯", "变红", "红色"], "color", "low", "#ef6267"),
preset("green", "绿色灯光", ["绿灯", "变绿", "绿色"], "color", "low", "#48b982"),
preset("blue", "蓝色灯光", ["蓝灯", "变蓝", "蓝色"], "color", "low", "#4f7fd2"),
preset("white", "白色灯光", ["白灯", "白色"], "color", "low", "#d9d5c8"),
preset("fast_flash", "快速闪烁", ["快闪", "快速闪灯"], "effect"),
preset("slow_flash", "慢速闪烁", ["慢闪", "慢速闪灯"], "effect"),
preset("warm", "暖色灯光", ["暖光", "暖色"], "color", "low", "#f0a958"),
preset("brightness_up", "亮度增加", ["调亮", "亮一点"], "effect"),
preset("brightness_down", "亮度降低", ["调暗", "暗一点"], "effect"),
preset("breath", "呼吸灯效", ["呼吸灯", "呼吸闪烁"], "effect"),
],
},
sound: {
label: "声音类玩具",
shortLabel: "声音",
icon: "volume-2",
color: "#658bd5",
description: "播放、音量与切换",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止播放"], "power"),
preset("play_pause", "播放 / 暂停", ["播放", "暂停"], "sound"),
preset("volume_up", "音量加", ["大声一点", "调高音量"], "sound"),
preset("volume_down", "音量减", ["小声一点", "调低音量"], "sound"),
preset("next", "下一首", ["下一个声音"], "sound"),
preset("previous", "上一首", ["上一个声音"], "sound"),
preset("mode", "模式切换", ["换个模式"], "effect"),
preset("mute", "静音", ["不要声音", "关闭声音"], "sound"),
preset("loop", "循环播放", ["单曲循环", "循环"], "sound"),
preset("random", "随机播放", ["随机切换"], "sound"),
preset("stop", "停止播放", ["停止", "停止声音"], "sound"),
],
},
action: {
label: "动作类玩具",
shortLabel: "动作",
icon: "car-front",
color: "#db7468",
description: "移动、转向与表演",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "关闭玩具"], "power"),
preset("forward", "前进", ["向前", "往前走"], "motion", "high"),
preset("backward", "后退", ["向后", "倒退"], "motion", "high"),
preset("left", "左转", ["向左", "左拐"], "direction", "high"),
preset("right", "右转", ["向右", "右拐"], "direction", "high"),
preset("stop", "停止", ["停下", "不要动"], "motion"),
preset("perform", "表演动作", ["表演", "做动作"], "effect", "medium"),
preset("speed_up", "速度增加", ["加速", "快一点"], "motion", "high"),
preset("speed_down", "速度降低", ["减速", "慢一点"], "motion"),
preset("spin", "原地旋转", ["转圈", "旋转"], "direction", "medium"),
preset("dance", "舞蹈动作", ["跳舞", "舞蹈"], "effect", "medium"),
],
},
doll: {
label: "玩偶类玩具",
shortLabel: "玩偶",
icon: "bot",
color: "#cb86a4",
description: "语音、舞蹈与互动",
presets: [
preset("on", "开启", ["打开", "唤醒"], "power"),
preset("off", "关闭", ["关掉", "休息"], "power"),
preset("speak", "说话", ["讲句话", "发声"], "sound"),
preset("sing", "唱歌", ["唱一首歌"], "sound"),
preset("dance", "跳舞", ["舞蹈"], "effect", "medium"),
preset("wave", "挥手", ["招手"], "motion"),
preset("interact", "互动", ["陪我玩", "互动一下"], "effect"),
preset("stop", "停止", ["停下", "停止动作"], "motion"),
preset("laugh", "笑声", ["笑一下", "开心"], "sound"),
preset("story", "讲故事", ["故事", "讲个故事"], "sound"),
preset("repeat", "复读", ["重复说", "跟我说"], "sound"),
preset("sleep", "睡眠模式", ["睡觉", "休眠"], "effect"),
],
},
mechanical: {
label: "机械类玩具",
shortLabel: "机械",
icon: "cog",
color: "#758e94",
description: "升降、伸缩与旋转",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止设备"], "power"),
preset("up", "上升", ["升高", "向上"], "motion", "medium"),
preset("down", "下降", ["降低", "向下"], "motion", "medium"),
preset("extend", "伸出", ["向外伸", "展开"], "motion", "medium"),
preset("retract", "收回", ["缩回", "收起"], "motion", "medium"),
preset("clockwise", "正转", ["顺时针", "正向旋转"], "direction", "medium"),
preset("counterclockwise", "反转", ["逆时针", "反向旋转"], "direction", "medium"),
preset("stop", "停止", ["停下", "急停"], "motion"),
preset("speed_up", "速度增加", ["加速", "快一点"], "motion", "medium"),
preset("speed_down", "速度降低", ["减速", "慢一点"], "motion"),
preset("home", "复位", ["回到原位", "归位"], "motion", "medium"),
],
},
other: {
label: "其他玩具",
shortLabel: "其他",
icon: "gamepad-2",
color: "#579a88",
description: "通用自定义功能",
presets: [
preset("on", "开启", ["打开", "启动"], "power"),
preset("off", "关闭", ["关掉", "停止"], "power"),
preset("function_1", "功能一", ["第一个功能"]),
preset("function_2", "功能二", ["第二个功能"]),
preset("function_3", "功能三", ["第三个功能"]),
preset("function_4", "功能四", ["第四个功能"]),
preset("function_5", "功能五", ["第五个功能"]),
preset("function_6", "功能六", ["第六个功能"]),
preset("function_7", "功能七", ["第七个功能"]),
preset("function_8", "功能八", ["第八个功能"]),
preset("mode", "模式切换", ["换个模式"]),
preset("reset", "复位", ["恢复", "回到初始状态"]),
],
},
};
const VEHICLE_DEVICES = [
{ name: "座椅", code: "SEAT · CAN", icon: "armchair", actions: [
{ name: "主驾通风", command: "打开主驾座椅通风" },
{ name: "主驾加热", command: "打开主驾座椅加热" },
{ name: "副驾通风", command: "打开副驾座椅通风" },
{ name: "座椅位置", command: "把副驾座椅向后调一点" },
] },
{ name: "空调", code: "CLIMATE · CAN", icon: "snowflake", actions: [
{ name: "开启空调", command: "打开空调" },
{ name: "24℃", command: "把空调温度调到24度" },
{ name: "风量 3", command: "把空调风量调到3档" },
{ name: "自动模式", command: "把空调设置为自动模式" },
] },
{ name: "电动出风口", code: "OUTLET · 4 ZONES", icon: "wind", actions: [
{ name: "主驾左", command: "把主驾左出风口调到中间位置" },
{ name: "主驾右", command: "把主驾右出风口调到中间位置" },
{ name: "副驾左", command: "把副驾左出风口调到中间位置" },
{ name: "副驾右", command: "把副驾右出风口调到中间位置" },
] },
{ name: "遮阳帘", code: "ROOF · SHADE", icon: "panels-top-left", actions: [
{ name: "打开", command: "打开遮阳帘" },
{ name: "关闭", command: "关闭遮阳帘" },
{ name: "半开", command: "把遮阳帘打开一半" },
{ name: "停止", command: "停止遮阳帘" },
] },
{ name: "车内灯光", code: "INTERIOR · LIGHT", icon: "lamp-ceiling", actions: [
{ name: "室内灯", command: "打开车内室内灯" },
{ name: "阅读灯", command: "打开全部阅读灯" },
{ name: "氛围灯", command: "打开车内氛围灯" },
{ name: "氛围亮度", command: "把氛围灯亮度调到3档" },
] },
{ name: "车窗与门锁", code: "WINDOW · DOOR", icon: "gallery-vertical-end", actions: [
{ name: "全部开窗", command: "打开全部车窗" },
{ name: "全部关窗", command: "关闭全部车窗" },
{ name: "左前窗 50%", command: "把左前车窗打开到50%" },
{ name: "后排门锁", command: "锁上后排门锁" },
] },
];
const DEFAULT_DEVICE = {
id: "toy-light-test",
name: "灯光测试玩具",
type: "light",
aliases: ["灯光测试玩具", "灯光玩具", "玩具灯"],
createdAt: new Date().toISOString(),
};
const state = loadState();
const pages = Object.fromEntries(Object.keys(TOY_TYPES).map((key) => [key, 0]));
let activeMode = sessionStorage.getItem(MODE_KEY) === "control" ? "control" : "study";
let selectedAddType = "light";
let learning = freshLearningSession();
let isPlanning = false;
let isExecuting = false;
let healthState = null;
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() {
const device = clone(DEFAULT_DEVICE);
return {
version: 5,
devices: [device],
capabilities: seedPresets(device),
selectedDeviceByType: { light: device.id },
sessionStartedAt: null,
settings: { actionDelayMs: 520 },
history: [],
lastProtocol: null,
};
}
function loadState() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
if (!saved || saved.version !== 5 || !Array.isArray(saved.devices)) {
return initialState();
}
saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : [];
saved.history = Array.isArray(saved.history) ? saved.history : [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
return saved;
} catch (error) {
console.warn("读取自扩展能力数据失败,已恢复默认状态。", error);
return initialState();
}
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function seedPresets(device) {
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
return type.presets.map((item) => ({
id: uid("cap"),
deviceId: device.id,
key: item.key,
name: item.name,
aliases: [item.name, ...item.aliases],
category: item.category,
risk: item.risk,
visual: item.visual,
slot: null,
status: "pending",
verified: false,
enabled: false,
createdAt: new Date().toISOString(),
}));
}
function freshLearningSession() {
return {
phase: "idle",
draft: null,
countdownTimer: null,
abortController: null,
tested: false,
};
}
function deviceCapabilities(deviceId) {
return state.capabilities.filter((item) => item.deviceId === deviceId);
}
function learnedCapabilities(deviceId = null) {
return state.capabilities.filter((item) => (
(!deviceId || item.deviceId === deviceId)
&& item.status === "learned"
&& item.verified
&& item.enabled !== false
&& validSlot(item.slot)
));
}
function allocateSlot() {
const used = new Set(learnedCapabilities().map((item) => Number(item.slot)));
for (let slot = SLOT_MIN; slot <= SLOT_MAX; slot += 1) {
if (!used.has(slot)) return slot;
}
return null;
}
function selectedDeviceForType(typeKey) {
const devices = state.devices.filter((item) => item.type === typeKey);
const selectedId = state.selectedDeviceByType[typeKey];
const selected = devices.find((item) => item.id === selectedId) || devices[0] || null;
if (selected && selected.id !== selectedId) state.selectedDeviceByType[typeKey] = selected.id;
return selected;
}
function buildShell() {
const sidebar = $(".v2-sidebar");
const mainPanel = $(".main-panel");
const moduleSwitcher = $("#v2ModuleSwitcher");
if (!sidebar || !mainPanel || !moduleSwitcher) return false;
const extensionSidebar = document.createElement("section");
extensionSidebar.className = "v2-sidebar-section v2-extension-sidebar";
extensionSidebar.innerHTML = `
EXTENSION DESK
控制台
工作模式
配置信息
玩具信息
`;
sidebar.insertBefore(extensionSidebar, $(".v2-sidebar-footer"));
const extensionFooter = document.createElement("footer");
extensionFooter.className = "v2-sidebar-footer v2-extension-footer";
extensionFooter.innerHTML = `
自扩展控制链路正在检查 GSCU1B
`;
sidebar.append(extensionFooter);
const root = document.createElement("section");
root.className = "v2-self-extension";
root.id = "v2SelfExtension";
root.setAttribute("aria-label", "自扩展工作台");
root.innerHTML = `
PERIPHERAL LEARNING外设玩具能力一览与学习
为六类外设添加具体硬件,学习并验证遥控动作。
UNIFIED CONTROL说一句,联动外设玩具与车端设备
GPT 只从已学习能力与受控车端能力中生成计划。
LEARNED ACTIONS
玩具动作
0 项可执行
VEHICLE ACTIONS
车端设备动作
同源安全编排
`;
moduleSwitcher.insertAdjacentElement("afterend", root);
buildDialogs();
return true;
}
function buildDialogs() {
const layer = document.createElement("div");
layer.className = "v2-extension-dialog-layer";
layer.innerHTML = `
`;
document.body.append(layer);
}
function openDialog(id) {
const dialog = $(`#${id}`);
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute("open", "");
}
function closeDialog(id) {
const dialog = $(`#${id}`);
if (dialog?.close) dialog.close();
else dialog?.removeAttribute("open");
}
function showToast(message, type = "") {
const toast = document.createElement("div");
toast.className = `v2-extension-toast ${type}`.trim();
toast.textContent = message;
$("#extensionToastStack")?.append(toast);
window.setTimeout(() => toast.remove(), 3800);
}
async function api(path, options = {}) {
let response;
try {
response = await fetch(`/api/extension/${path}`, {
credentials: "same-origin",
...options,
headers: options.body
? { "Content-Type": "application/json", ...(options.headers || {}) }
: options.headers,
});
} catch (_error) {
throw new Error("无法连接自扩展服务。");
}
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.success === false) {
throw new Error(payload.message || `自扩展请求失败(HTTP ${response.status})`);
}
return payload;
}
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();
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
? ``
: `尚未添加具体硬件`;
return `
${type.label}${selector}
${learned}/${capabilities.length}${selected ? `` : ""}
${visible.map((capability) => renderCapability(capability, typeKey, Boolean(selected))).join("")}
`;
}
function renderCapability(capability, typeKey, hasDevice) {
const learned = capability.status === "learned" && validSlot(capability.slot);
if (!hasDevice) {
return ``;
}
return `
${learned ? `` : ``}
`;
}
function renderControl() {
const learned = learnedCapabilities();
$("#extensionLearnedTotal").textContent = `${learned.length} 项可执行`;
if (!learned.length) {
$("#extensionLearnedGrid").innerHTML = `还没有已学习动作切换到学习模式,为外设玩具录入遥控能力。
`;
} else {
$("#extensionLearnedGrid").innerHTML = learned.map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
return ``;
}).join("");
}
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => `
${device.actions.map((action) => ``).join("")}
`).join("");
}
function renderTypeChooser() {
$("#extensionTypeChooser").innerHTML = Object.entries(TOY_TYPES).map(([key, type]) => `
`).join("");
$("#extensionPresetPreview").innerHTML = TOY_TYPES[selectedAddType].presets
.map((item) => `${escapeHtml(item.name)}`)
.join("");
}
function openAdd(typeKey = "light") {
selectedAddType = TOY_TYPES[typeKey] ? typeKey : "other";
$("#extensionHardwareName").value = "";
renderTypeChooser();
openDialog("extensionAddDialog");
window.setTimeout(() => $("#extensionHardwareName")?.focus(), 80);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function addHardware(event) {
event.preventDefault();
const name = $("#extensionHardwareName").value.trim();
if (!name) return;
if (state.devices.some((item) => item.name === name)) {
showToast("已经存在同名外设玩具。", "error");
return;
}
const device = {
id: uid("toy"),
name,
type: selectedAddType,
aliases: [name],
createdAt: new Date().toISOString(),
};
state.devices.push(device);
state.capabilities.push(...seedPresets(device));
state.selectedDeviceByType[device.type] = device.id;
pages[device.type] = 0;
saveState();
closeDialog("extensionAddDialog");
renderAll();
showToast(`已添加“${name}”,可以开始学习遥控动作。`, "success");
}
function deleteHardware(deviceId) {
const device = state.devices.find((item) => item.id === deviceId);
if (!device || !window.confirm(`删除“${device.name}”及其全部前端能力记录?`)) return;
state.devices = state.devices.filter((item) => item.id !== deviceId);
state.capabilities = state.capabilities.filter((item) => item.deviceId !== deviceId);
state.selectedDeviceByType[device.type] = state.devices.find(
(item) => item.type === device.type,
)?.id || null;
saveState();
renderAll();
showToast(`已删除“${device.name}”。`);
}
function showLearnPage(page) {
$$('[data-extension-learn-page]').forEach((element) => {
element.hidden = Number(element.dataset.extensionLearnPage) !== page;
});
$$('[data-extension-learn-step]').forEach((element) => {
element.classList.toggle("active", Number(element.dataset.extensionLearnStep) <= page);
});
}
function resetLearnStatus() {
window.clearInterval(learning.countdownTimer);
learning = freshLearningSession();
$("#extensionLearnAnimation").dataset.state = "ready";
$("#extensionLearnStatusTitle").textContent = "准备接收遥控信号";
$("#extensionLearnStatusMessage").textContent = "请将遥控器对准学习器并按一下对应按键。";
$("#extensionLearnCountdown").textContent = "15";
$("#extensionStartLearn").disabled = false;
$("#extensionStartLearn").textContent = "开始接收";
$("#extensionCancelLearn").hidden = false;
$("#extensionTestEmit").disabled = false;
$("#extensionTestEmit").textContent = "发射试一下";
$("#extensionConfirmLearn").disabled = true;
}
function openLearn(capabilityId) {
const capability = state.capabilities.find((item) => item.id === capabilityId);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
resetLearnStatus();
learning.draft = { capabilityId: capability.id, deviceId: device.id, slot: allocateSlot() };
$("#extensionLearnTitle").textContent = `学习“${capability.name}”`;
$("#extensionLearnPath").textContent = `${type.label} · ${device.name}`;
$("#extensionLearnIcon").innerHTML = ``;
$("#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) => ({
name: device.name,
code: device.code,
actions: device.actions,
}));
}
function hydratePlan(steps) {
return (steps || []).map((step) => {
if (step.kind === "vehicle") {
return {
kind: "vehicle",
command: step.command,
label: step.label || step.command,
risk: step.risk || "medium",
};
}
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,
)) return null;
return {
kind: "peripheral",
device,
capability,
label: `${device.name} · ${capability.name}`,
risk: capability.risk || "low",
};
}).filter(Boolean);
}
function setExecutionState(stateName, title, note, progress = 0) {
const card = $("#extensionExecutionState");
card.dataset.state = stateName;
$("#extensionRunTitle").textContent = title;
$("#extensionRunNote").textContent = note;
$("#extensionRunProgressBar").style.width = `${Math.max(0, Math.min(100, progress))}%`;
}
function renderRunPlan(plan, active = -1) {
const rail = $("#extensionRunPlan");
rail.hidden = false;
rail.innerHTML = plan.map((step, index) => `
${String(index + 1).padStart(2, "0")}${escapeHtml(step.label || "待执行动作")}`).join("");
}
async function executeInstruction(text) {
const input = String(text || "").trim();
if (!input) {
showToast("请先输入要执行的动作。", "error");
return;
}
if (isPlanning || isExecuting) {
showToast("上一条指令还在处理,请稍等。", "error");
return;
}
const button = $("#extensionExecuteButton");
isPlanning = true;
button.disabled = true;
setExecutionState("running", "GPT 正在理解意图", "匹配已学习能力与车端能力", 10);
try {
const result = await api("assistant/plan", {
method: "POST",
body: JSON.stringify({
utterance: input,
peripherals: peripheralCatalog(),
vehicle_devices: vehicleCatalog(),
}),
});
if (result.needs_confirmation) {
setExecutionState("warning", "需要补充目标", result.question || "请说明具体硬件", 0);
showToast(result.question || "请说明具体硬件。", "error");
return;
}
const plan = hydratePlan(result.steps);
if (!plan.length) {
setExecutionState("warning", "没有可执行动作", result.summary || "未匹配到能力", 0);
showToast(result.summary || "没有找到可执行动作。", "error");
return;
}
const risky = plan.filter((item) => item.risk === "high");
if (risky.length && !window.confirm(
`将执行 ${risky.length} 个可能让玩具移动的动作,请确认周围安全。`,
)) {
setExecutionState("idle", "已取消执行", "高风险动作未获确认", 0);
return;
}
isPlanning = false;
await executePlan(plan);
} catch (error) {
setExecutionState("error", "联动计划生成失败", error.message, 0);
showToast(error.message, "error");
} finally {
isPlanning = false;
button.disabled = false;
}
}
async function executeVehicle(command) {
return api("vehicle/execute", {
method: "POST",
body: JSON.stringify({ command, vehicle_id: "denza_n7" }),
});
}
async function executePlan(plan) {
if (isExecuting || !plan.length) return;
isExecuting = true;
renderRunPlan(plan);
let completed = 0;
let vehicleDryRun = false;
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 === "vehicle") {
const result = await executeVehicle(step.command);
vehicleDryRun ||= result.execution_mode === "dry_run";
} 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;
}
if (index < plan.length - 1) await delay(state.settings.actionDelayMs);
}
renderRunPlan(plan, completed);
if (completed === plan.length) {
setExecutionState(
"success",
"联动计划已全部处理",
vehicleDryRun ? "外设已发射,车端处于安全演练模式" : "外设与车端链路处理完成",
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 refreshHealth(showMessage = false) {
$("#extensionStudyHealth").dataset.state = "checking";
$("#extensionStudyHealthState").textContent = "CHECK";
$("#extensionVehicleCard").dataset.state = "checking";
$("#extensionVehicleState").textContent = "CHECK";
try {
healthState = await api("health");
} catch (error) {
healthState = {
connected: false,
transport: "unavailable",
message: error.message,
gpt: { configured: false },
vehicle: { connected: false },
};
}
const connected = Boolean(healthState.connected);
const transport = String(healthState.transport || "--").toUpperCase();
const connectionLabel = connected
? (healthState.transport === "simulation" ? "模拟学习器已就绪" : `${transport} 学习器已连接`)
: "GSCU1B 尚未连接";
$("#extensionStudyHealthText").textContent = connectionLabel;
$("#extensionStudyHealthState").textContent = connected ? "READY" : "OFFLINE";
$("#extensionStudyHealth").dataset.state = connected ? "online" : "offline";
const vehicleConnected = Boolean(healthState.vehicle?.connected);
$("#extensionVehicleState").textContent = vehicleConnected ? "READY" : "OFFLINE";
$("#extensionVehicleCard").dataset.state = vehicleConnected ? "online" : "offline";
$("#extensionConnectionText").textContent = 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 (showMessage) {
const ready = [connected, healthState.gpt?.configured, healthState.vehicle?.connected]
.filter(Boolean).length;
showToast(`自扩展链路:${ready} / 3 项就绪。`, ready === 3 ? "success" : "error");
}
return healthState;
}
async function resetSession() {
const button = $("#extensionConfirmReset");
button.disabled = true;
button.textContent = "正在擦除学习器…";
try {
await sendGatewayCommand("erase", { confirm: "ERASE_ALL" });
state.capabilities = state.devices.flatMap((device) => seedPresets(device));
state.history = [];
state.sessionStartedAt = new Date().toISOString();
saveState();
closeDialog("extensionResetDialog");
renderAll();
showToast("学习器已擦除,所有动作恢复为待学习。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
button.textContent = "擦除并重新开始";
button.disabled = $("#extensionResetConfirm").value.trim() !== "清空";
}
}
function bindEvents() {
$$('[data-extension-mode]').forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
});
$("#extensionHardwareGrid").addEventListener("click", (event) => {
const add = event.target.closest("[data-add-type]");
if (add) return openAdd(add.dataset.addType);
const pageButton = event.target.closest("[data-extension-page]");
if (pageButton) {
const typeKey = pageButton.dataset.extensionPage;
pages[typeKey] = Math.max(
0,
(pages[typeKey] || 0) + Number(pageButton.dataset.pageDirection),
);
renderHardwareGrid();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return;
}
const remove = event.target.closest("[data-delete-extension-device]");
if (remove) return deleteHardware(remove.dataset.deleteExtensionDevice);
const forget = event.target.closest("[data-forget-extension-capability]");
if (forget) return forgetCapability(forget.dataset.forgetExtensionCapability);
const learn = event.target.closest("[data-learn-extension-capability]");
if (learn) return openLearn(learn.dataset.learnExtensionCapability);
const run = event.target.closest("[data-run-extension-capability]");
if (run) runCapability(run.dataset.runExtensionCapability);
});
$("#extensionHardwareGrid").addEventListener("change", (event) => {
const selector = event.target.closest("[data-extension-device-select]");
if (!selector) return;
state.selectedDeviceByType[selector.dataset.extensionDeviceSelect] = selector.value;
pages[selector.dataset.extensionDeviceSelect] = 0;
saveState();
renderHardwareGrid();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
});
$("#extensionTypeChooser").addEventListener("click", (event) => {
const choice = event.target.closest("[data-extension-type-choice]");
if (!choice) return;
selectedAddType = choice.dataset.extensionTypeChoice;
renderTypeChooser();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
});
$("#extensionAddForm").addEventListener("submit", addHardware);
$("#extensionContinueLearn").addEventListener("click", () => {
try {
learning.draft = collectLearningDraft();
showLearnPage(2);
} catch (error) {
showToast(error.message, "error");
}
});
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", confirmLearning);
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
}
});
$("#extensionLearnedGrid").addEventListener("click", (event) => {
const switcher = event.target.closest("[data-switch-extension-mode]");
if (switcher) return setMode(switcher.dataset.switchExtensionMode);
const run = event.target.closest("[data-run-extension-capability]");
if (run) runCapability(run.dataset.runExtensionCapability);
});
$("#extensionVehicleGrid").addEventListener("click", (event) => {
const action = event.target.closest("[data-extension-vehicle-command]");
if (!action) return;
$("#extensionUtterance").value = action.dataset.extensionVehicleCommand;
executeInstruction(action.dataset.extensionVehicleCommand);
});
$("#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();
}
});
}
function init() {
if (!buildShell()) return;
bindEvents();
renderAll();
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
document.addEventListener("DOMContentLoaded", init);
}());