323 lines
40 KiB
JavaScript
323 lines
40 KiB
JavaScript
"use strict";
|
||
|
||
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
|
||
const SLOT_MIN = 1;
|
||
const SLOT_MAX = 248;
|
||
const ACTION_PAGE_SIZE = 6;
|
||
const HISTORY_LIMIT = 40;
|
||
|
||
function preset(key, name, aliases, category = "custom", risk = "low", visual = null) {
|
||
return { key, name, aliases, category, risk, visual };
|
||
}
|
||
|
||
const TOY_TYPES = {
|
||
light: {
|
||
label: "灯光类玩具", icon: "💡", color: "#f1b84c", 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: "声音类玩具", icon: "🔊", color: "#5c84d7", 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: "动作类玩具", icon: "🏎️", color: "#e46e5d", 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: "玩偶类玩具", icon: "🧸", color: "#d98aa2", 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: "机械类玩具", icon: "⚙️", color: "#728b92", 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: "其他玩具", icon: "🎮", color: "#4e9b87", 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 CATEGORY_META = {
|
||
motion: { icon: "↕" }, direction: { icon: "↗" }, color: { icon: "●" }, effect: { icon: "✦" },
|
||
sound: { icon: "♫" }, power: { icon: "⏻" }, custom: { icon: "◆" },
|
||
};
|
||
|
||
const VEHICLE_DEVICES = [
|
||
{ name: "座椅", code: "SEAT · CAN_DEV", icon: "▱", actions: [
|
||
{ name:"主驾通风", command:"打开主驾座椅通风" }, { name:"主驾加热", command:"打开主驾座椅加热" },
|
||
{ name:"副驾通风", command:"打开副驾座椅通风" }, { name:"座椅位置", command:"把副驾座椅向后调一点" },
|
||
]},
|
||
{ name: "空调", code: "CLIMATE · CAN_DEV", icon: "❉", actions: [
|
||
{ name:"开启空调", command:"打开空调" }, { name:"24℃", command:"把空调温度调到24度" },
|
||
{ name:"风量 3", command:"把空调风量调到3档" }, { name:"自动模式", command:"把空调设置为自动模式" },
|
||
]},
|
||
{ name: "电动出风口", code: "OUTLET · 4 ZONES", icon: "⌁", actions: [
|
||
{ name:"主驾左", command:"把主驾左出风口调到中间位置" }, { name:"主驾右", command:"把主驾右出风口调到中间位置" },
|
||
{ name:"副驾左", command:"把副驾左出风口调到中间位置" }, { name:"副驾右", command:"把副驾右出风口调到中间位置" },
|
||
]},
|
||
{ name: "遮阳帘", code: "ROOF · SHADE", icon: "▣", actions: [
|
||
{ name:"打开", command:"打开遮阳帘" }, { name:"关闭", command:"关闭遮阳帘" },
|
||
{ name:"半开", command:"把遮阳帘打开一半" }, { name:"停止", command:"停止遮阳帘" },
|
||
]},
|
||
{ name: "车内灯光", code: "INTERIOR · AMBIENT", icon: "⌂", actions: [
|
||
{ name:"室内灯", command:"打开车内室内灯" }, { name:"阅读灯", command:"打开全部阅读灯" },
|
||
{ name:"氛围灯", command:"打开车内氛围灯" }, { name:"氛围亮度", command:"把氛围灯亮度调到3档" },
|
||
]},
|
||
{ name: "车窗与门锁", code: "WINDOW · DOOR", icon: "▭", 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 $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
const state = loadState();
|
||
let actionPageByType = Object.fromEntries(Object.keys(TOY_TYPES).map((key) => [key, 0]));
|
||
let selectedHardwareType = "light";
|
||
let learningSession = freshLearningSession();
|
||
let isExecuting = false;
|
||
let isPlanning = false;
|
||
|
||
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 delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||
function validSlot(value) { const slot = Number(value); return Number.isInteger(slot) && slot >= SLOT_MIN && slot <= SLOT_MAX; }
|
||
function freshLearningSession() { return { phase: "idle", draft: null, countdownTimer: null, abortController: null, tested: false }; }
|
||
function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&":"&", "<":"<", ">":">", "'":"'", '"':""" })[char]); }
|
||
|
||
function initialState() {
|
||
const device = clone(DEFAULT_DEVICE);
|
||
return { version:5, devices:[device], capabilities:seedPresets(device), selectedDeviceByType:{light:device.id}, sessionStartedAt:null, settings:{adapterMode:"bridge",bridgeUrl:"",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 = { adapterMode:saved.settings?.adapterMode === "simulation" ? "simulation" : "bridge", bridgeUrl:saved.settings?.bridgeUrl || "", 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 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 showToast(message,type="") { const toast=document.createElement("div");toast.className=`toast ${type}`.trim();toast.textContent=message;$("#toastStack").append(toast);setTimeout(()=>toast.remove(),3800); }
|
||
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 renderAll() { renderHardwareGrid();renderVehicleGrid();renderSession();renderTypeChooser(); }
|
||
|
||
function renderHardwareGrid() {
|
||
$("#hardwareGrid").innerHTML = Object.keys(TOY_TYPES).map((typeKey) => renderTypeRegion(typeKey)).join("");
|
||
}
|
||
|
||
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 renderTypeRegion(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));
|
||
actionPageByType[typeKey] = Math.min(actionPageByType[typeKey] || 0, totalPages - 1);
|
||
const page = actionPageByType[typeKey];
|
||
const visible = capabilities.slice(page * ACTION_PAGE_SIZE, (page + 1) * ACTION_PAGE_SIZE);
|
||
const selector = devices.length
|
||
? `<select class="hardware-selector" data-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="no-hardware">尚未添加具体硬件</span>`;
|
||
const learned = selected ? learnedCapabilities(selected.id).length : 0;
|
||
return `<article class="hardware-card type-region" style="--card-color:${type.color}">
|
||
<div class="hardware-head"><span class="hardware-icon">${type.icon}</span><div class="hardware-head-main"><small>${type.label}</small>${selector}</div><span class="hardware-progress">${learned} / ${capabilities.length}</span><button class="add-same-type" type="button" data-add-type="${typeKey}" title="添加同类硬件">+</button>${selected?`<button class="delete-hardware" type="button" data-delete-hardware="${escapeHtml(selected.id)}" title="删除当前硬件">×</button>`:""}</div>
|
||
<div class="preset-actions">${visible.map((capability) => renderPresetAction(capability,typeKey,Boolean(selected))).join("")}</div>
|
||
<div class="type-card-footer"><span>${devices.length} 台硬件 · ${capabilities.length} 个预设动作</span><div><button type="button" data-action-page="${typeKey}" data-page-direction="-1" ${page===0?"disabled":""}>‹</button><b>${page+1} / ${totalPages}</b><button type="button" data-action-page="${typeKey}" data-page-direction="1" ${page>=totalPages-1?"disabled":""}>›</button></div></div>
|
||
</article>`;
|
||
}
|
||
|
||
function renderPresetAction(capability,typeKey,hasDevice) {
|
||
const meta = CATEGORY_META[capability.category] || CATEGORY_META.custom;
|
||
const learned = capability.status === "learned" && validSlot(capability.slot);
|
||
if (!hasDevice) return `<div class="preset-action preview"><button class="action-main" type="button" data-add-type="${typeKey}"><strong>${meta.icon} ${escapeHtml(capability.name)}</strong><small>添加硬件后可学习</small></button><div class="action-controls"><span class="slot-number">预设动作</span><button class="learn-action" type="button" data-add-type="${typeKey}">添加</button></div></div>`;
|
||
return `<div class="preset-action ${learned ? "learned" : "pending"}">
|
||
<button class="action-main" type="button" ${learned ? `data-run-capability="${escapeHtml(capability.id)}"` : `data-learn-capability="${escapeHtml(capability.id)}"`}><strong>${meta.icon} ${escapeHtml(capability.name)}</strong><small>${learned ? "已学习 · 点击执行" : "待学习"}</small></button>
|
||
<div class="action-controls">${learned ? `<span class="slot-number">IR ${String(capability.slot).padStart(3,"0")}</span><button class="emit-action" type="button" data-run-capability="${escapeHtml(capability.id)}">执行</button><button class="forget-action" type="button" data-forget-capability="${escapeHtml(capability.id)}" title="删除动作">×</button>` : `<span class="slot-number">未占用槽位</span><button class="learn-action" type="button" data-learn-capability="${escapeHtml(capability.id)}">学习</button>`}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderVehicleGrid() {
|
||
$("#vehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => `<article class="vehicle-card"><div class="vehicle-card-head"><span class="vehicle-icon">${device.icon}</span><div><strong>${device.name}</strong><small>${device.code}</small></div><span>可编排</span></div><div class="vehicle-actions">${device.actions.map((action) => `<button class="vehicle-action" type="button" data-vehicle-command="${escapeHtml(action.command)}" title="${escapeHtml(action.command)}">${escapeHtml(action.name)}</button>`).join("")}</div></article>`).join("");
|
||
}
|
||
|
||
function assistantPeripheralCatalog() {
|
||
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 assistantVehicleCatalog() {
|
||
return VEHICLE_DEVICES.map((device)=>({name:device.name,code:device.code,actions:device.actions}));
|
||
}
|
||
|
||
function renderSession() { $("#sessionCapabilityCount").textContent = learnedCapabilities().length; $("#hardwareCount").textContent = state.devices.length; }
|
||
|
||
function renderTypeChooser() {
|
||
$("#typeChooser").innerHTML = Object.entries(TOY_TYPES).map(([key,type]) => `<button class="type-choice ${key===selectedHardwareType?"active":""}" type="button" data-type-choice="${key}" ${key===selectedHardwareType?"":"disabled"}><span>${type.icon}</span><div><strong>${type.label}</strong><small>${type.description}</small></div></button>`).join("");
|
||
$("#presetPreview").innerHTML = TOY_TYPES[selectedHardwareType].presets.map((item) => `<i>${escapeHtml(item.name)}</i>`).join("");
|
||
}
|
||
|
||
function openAddHardware(typeKey = "light") {
|
||
selectedHardwareType = TOY_TYPES[typeKey] ? typeKey : "other";
|
||
$("#newHardwareName").value = "";
|
||
renderTypeChooser();
|
||
openDialog("addHardwareDialog");
|
||
setTimeout(()=>$("#newHardwareName").focus(),80);
|
||
}
|
||
|
||
function addHardware(event) {
|
||
event.preventDefault();
|
||
const name=$("#newHardwareName").value.trim();
|
||
if(!name)return;
|
||
if(state.devices.some((item)=>item.name===name)){showToast("已经存在同名外设硬件。","error");return;}
|
||
const device={id:uid("toy"),name,type:selectedHardwareType,aliases:[name],createdAt:new Date().toISOString()};
|
||
state.devices.push(device);state.capabilities.push(...seedPresets(device));state.selectedDeviceByType[device.type]=device.id;actionPageByType[device.type]=0;saveState();closeDialog("addHardwareDialog");renderAll();showToast(`已在${TOY_TYPES[device.type].label}区域添加“${name}”及 ${TOY_TYPES[device.type].presets.length} 个预设动作`,"success");
|
||
}
|
||
|
||
function deleteHardware(deviceId) {
|
||
const device=state.devices.find((item)=>item.id===deviceId);if(!device)return;
|
||
if(!window.confirm(`删除外设硬件“${device.name}”及其全部前端动作登记?学习器槽位需通过“新一轮测试”统一擦除。`))return;
|
||
state.devices=state.devices.filter((item)=>item.id!==deviceId);state.capabilities=state.capabilities.filter((item)=>item.deviceId!==deviceId);const remaining=state.devices.filter((item)=>item.type===device.type);state.selectedDeviceByType[device.type]=remaining[0]?.id||null;saveState();renderAll();showToast(`已删除“${device.name}”`);
|
||
}
|
||
|
||
function showLearnPage(page) {
|
||
$$('[data-learn-page]').forEach((element)=>{const active=Number(element.dataset.learnPage)===page;element.hidden=!active;});
|
||
$$('[data-learn-step]').forEach((element)=>element.classList.toggle("active",Number(element.dataset.learnStep)<=page));
|
||
}
|
||
|
||
function resetLearnStatus() {
|
||
clearInterval(learningSession.countdownTimer);learningSession=freshLearningSession();
|
||
$("#learnAnimation").dataset.state="ready";$("#learnStatusTitle").textContent="准备接收遥控信号";$("#learnStatusMessage").textContent="把实体遥控器对准学习器,然后点击“开始接收”。";$("#learnCountdown").textContent="15";$("#startLearnButton").disabled=false;$("#startLearnButton").textContent="开始接收";$("#cancelLearnButton").hidden=true;$("#testEmitButton").disabled=false;$("#testEmitButton").textContent="发射试一下";$("#confirmLearnButton").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();learningSession.draft={capabilityId:capability.id,deviceId:device.id,slot:allocateSlot()};
|
||
$("#learnDialogTitle").textContent=`学习“${capability.name}”`;$("#learnTargetPath").textContent=`${type.label} · ${device.name}`;$("#learnHardwareIcon").textContent=type.icon;$("#learnHardwareType").textContent=type.label;$("#learnHardwareName").textContent=device.name;$("#learnActionName").textContent=capability.name;$("#learnAliases").value=(capability.aliases||[]).join("、");$("#learnRisk").value=capability.risk||"low";$("#learnSlot").value=allocateSlot()||"";showLearnPage(1);openDialog("learnDialog");
|
||
}
|
||
|
||
function collectLearningDraft() {
|
||
const capability=state.capabilities.find((item)=>item.id===learningSession.draft?.capabilityId);const device=state.devices.find((item)=>item.id===capability?.deviceId);const slot=Number($("#learnSlot").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:$("#learnAliases").value.split(/[,,、;;]+/).map((item)=>item.trim()).filter(Boolean),risk:$("#learnRisk").value};
|
||
}
|
||
|
||
async function startLearning() {
|
||
if(learningSession.phase==="listening")return;try{learningSession.draft=collectLearningDraft();}catch(error){showToast(error.message,"error");showLearnPage(1);return;}
|
||
const capability=state.capabilities.find((item)=>item.id===learningSession.draft.capabilityId);const device=state.devices.find((item)=>item.id===learningSession.draft.deviceId);learningSession.phase="listening";learningSession.abortController=new AbortController();$("#learnAnimation").dataset.state="listening";$("#learnStatusTitle").textContent=`正在学习“${capability.name}”…`;$("#learnStatusMessage").textContent=`请将“${device.name}”的遥控器对准学习器,并按一下对应按键。`;$("#startLearnButton").disabled=true;$("#startLearnButton").textContent="正在接收";$("#cancelLearnButton").hidden=false;let countdown=15;$("#learnCountdown").textContent=countdown;learningSession.countdownTimer=setInterval(()=>{countdown-=1;$("#learnCountdown").textContent=countdown>0?countdown:"…";if(countdown<=0)clearInterval(learningSession.countdownTimer);},1000);
|
||
try{await sendGatewayCommand("learn",{slot:learningSession.draft.slot},learningSession.abortController.signal);clearInterval(learningSession.countdownTimer);learningSession.phase="learned";$("#learnAnimation").dataset.state="success";await delay(350);showLearnPage(3);}catch(error){if(error.name==="AbortError")return;clearInterval(learningSession.countdownTimer);learningSession.phase="idle";$("#learnAnimation").dataset.state="ready";$("#learnStatusTitle").textContent="这次没有捕获到按键";$("#learnStatusMessage").textContent=error.message;$("#learnCountdown").textContent="";$("#startLearnButton").disabled=false;$("#startLearnButton").textContent="重新接收";$("#cancelLearnButton").hidden=true;showToast(error.message,"error");}
|
||
}
|
||
|
||
async function cancelLearning(){clearInterval(learningSession.countdownTimer);learningSession.abortController?.abort();try{await sendGatewayCommand("cancel",{});}catch(error){console.warn(error)}resetLearnStatus();showLearnPage(1);showToast("已取消本次学习");}
|
||
|
||
async function testLearning(){if(!validSlot(learningSession.draft?.slot))return;$("#testEmitButton").disabled=true;$("#testEmitButton").textContent="正在发射…";try{await sendGatewayCommand("emit",{slot:learningSession.draft.slot});learningSession.tested=true;$("#confirmLearnButton").disabled=false;$("#testTitle").textContent="信号已发射,请确认玩具反应";$("#testMessage").textContent="如果动作正确就完成学习;如果不正确,请取消后重新学习。";showToast("红外信号已发射,请观察玩具","success");}catch(error){showToast(error.message,"error")}finally{$("#testEmitButton").disabled=false;$("#testEmitButton").textContent="再发射一次";}}
|
||
|
||
function confirmLearning(){if(!learningSession.tested||!learningSession.draft)return;const capability=state.capabilities.find((item)=>item.id===learningSession.draft.capabilityId);if(!capability)return;Object.assign(capability,{aliases:[...new Set([capability.name,...learningSession.draft.aliases])],risk:learningSession.draft.risk,slot:Number(learningSession.draft.slot),status:"learned",verified:true,enabled:true,learnedAt:new Date().toISOString()});state.sessionStartedAt||=new Date().toISOString();saveState();closeDialog("learnDialog");renderAll();showToast(`“${capability.name}”已学习完成`,"success");learningSession=freshLearningSession();}
|
||
|
||
function forgetCapability(capabilityId){const capability=state.capabilities.find((item)=>item.id===capabilityId);if(!capability)return;if(!window.confirm(`删除动作“${capability.name}”的前端学习结果?该物理槽位可在下次学习时覆盖。`))return;Object.assign(capability,{slot:null,status:"pending",verified:false,enabled:false,learnedAt:null});saveState();renderAll();showToast(`“${capability.name}”已恢复为待学习`);}
|
||
|
||
async function requestAssistantPlan(text){let response;try{response=await fetch(`${bridgeBaseUrl()}/api/assistant/plan`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({utterance:text,peripherals:assistantPeripheralCatalog(),vehicle_devices:assistantVehicleCatalog()})});}catch(error){throw new Error("无法连接 GPT 智能编排服务。 ");}const result=await response.json().catch(()=>({}));if(!response.ok||result.success===false)throw new Error(result.message||`GPT 编排失败(HTTP ${response.status})`);return result;}
|
||
|
||
function hydrateAssistantPlan(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);}
|
||
|
||
async function executeInstruction(text){const input=String(text||"").trim();if(!input)return showToast("请先说出或输入要执行的动作。","error");if(isPlanning||isExecuting)return showToast("上一条指令还在处理,请稍等。 ");const button=$("#executeButton");isPlanning=true;button.disabled=true;button.textContent="GPT 理解中…";$("#runCard").hidden=false;$("#runTitle").textContent="GPT 正在理解用户意图";$("#runNote").textContent="结合已学外设能力与 CAN_DEV 车端能力生成计划";$("#runProgress").textContent="…";$("#runPlan").innerHTML="";try{const result=await requestAssistantPlan(input);if(result.needs_confirmation){$("#runCard").hidden=true;return showToast(result.question||"存在多个可选硬件,请说出具体硬件名称。","error");}const plan=hydrateAssistantPlan(result.steps);if(!plan.length){$("#runCard").hidden=true;return showToast(result.summary||"没有找到可执行的已学外设或车端动作。","error");}const risky=plan.filter((item)=>item.risk==="high");if(risky.length&&!window.confirm(`将执行 ${risky.length} 个可能让玩具移动的动作,请确认周围安全。`)){$("#runCard").hidden=true;return;}showToast(`${result.model||"GPT"}:${result.summary||`已理解 ${plan.length} 个动作`}`,"success");isPlanning=false;await executePlan(plan);}catch(error){$("#runCard").hidden=true;showToast(error.message,"error");}finally{isPlanning=false;button.disabled=false;button.innerHTML='GPT 执行 <span>→</span>';}}
|
||
function renderRunPlan(plan,active=-1){$("#runPlan").innerHTML=plan.map((step,index)=>`<span class="run-step ${index<active?"done":index===active?"active":""}">${escapeHtml(step.label||"待执行动作")}</span>`).join("");}
|
||
async function sendVehicleCommand(command){let response;try{response=await fetch(`${bridgeBaseUrl()}/api/vehicle/execute`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command,vehicle_id:"denza_n7"})});}catch(error){throw new Error("无法连接 CAN_DEV 车端编排服务。 ");}const result=await response.json().catch(()=>({}));if(!response.ok||result.success===false)throw new Error(result.message||`车端动作失败(HTTP ${response.status})`);return result;}
|
||
async function executePlan(plan){if(isExecuting||!plan.length)return;isExecuting=true;$("#runCard").hidden=false;$("#runTitle").textContent=`准备联动 ${plan.length} 个动作`;$("#runProgress").textContent=`0 / ${plan.length}`;renderRunPlan(plan);let completed=0;let vehicleDryRun=false;for(let index=0;index<plan.length;index+=1){const step=plan[index];$("#runTitle").textContent=step.label;renderRunPlan(plan,index);try{if((step.kind||"peripheral")==="vehicle"){const result=await sendVehicleCommand(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);showToast(error.message,"error");break;}$("#runProgress").textContent=`${completed} / ${plan.length}`;if(index<plan.length-1)await delay(state.settings.actionDelayMs);}$("#runTitle").textContent=completed===plan.length?"联动计划已全部处理":"联动执行已中断";$("#runNote").textContent=vehicleDryRun?"外设红外已发射;车端动作处于 CAN_DEV 安全演练模式":"GPT 计划已通过外设与车端执行链路";renderRunPlan(plan,completed);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();}
|
||
|
||
function bridgeBaseUrl(){return String(state.settings.bridgeUrl||"").trim().replace(/\/$/,"")||window.location.origin;}
|
||
async function sendGatewayCommand(action,payload={},signal=undefined){if(state.settings.adapterMode==="simulation"){await delay(action==="learn"?800:260);const result={success:true,simulation:true,physical_state_confirmed:false};state.lastProtocol={at:new Date().toISOString(),mode:"simulation",action,payload,response:result};saveState();return result;}let response;try{response=await fetch(`${bridgeBaseUrl()}/api/gscu/${action}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload),signal});}catch(error){if(error.name==="AbortError")throw error;throw new Error("无法连接红外学习器桥接服务。 ");}const result=await response.json().catch(()=>({}));state.lastProtocol={at:new Date().toISOString(),mode:"bridge",action,payload,response:result};saveState();if(!response.ok||result.success===false)throw new Error(result.message||`设备指令失败(HTTP ${response.status})`);return result;}
|
||
|
||
async function refreshConnectionStatus(showMessage=false){const button=$("#connectionButton");const aiBadge=$("#intelligenceBadge");const vehicleBadge=$("#vehicleBridgeBadge");button.dataset.state="checking";aiBadge.dataset.state="checking";vehicleBadge.dataset.state="checking";$("#connectionLabel").textContent="正在检查学习器";let status;try{const response=await fetch(`${bridgeBaseUrl()}/api/health`);const result=await response.json();status={connected:Boolean(response.ok&&result.success&&result.connected),transport:result.transport,listen:result.listen,message:result.message,gpt:result.gpt||{},vehicle:result.vehicle||{}};if(status.connected&&status.transport!=="simulation"){state.settings.adapterMode="bridge";saveState();}else if(status.transport==="simulation")state.settings.adapterMode="simulation";}catch(error){status={connected:false,message:"本地桥接服务不可用",gpt:{configured:false},vehicle:{connected:false}};}button.dataset.state=status.connected?"online":"offline";$("#connectionLabel").textContent=status.connected?(status.transport==="simulation"?"模拟学习器":`${String(status.transport).toUpperCase()} 真机已连接`):"学习器未连接";aiBadge.dataset.state=status.gpt.configured?"online":"offline";$("#intelligenceLabel").textContent=status.gpt.configured?`${status.gpt.model||"GPT"} 智能编排`:"GPT 未配置";vehicleBadge.dataset.state=status.vehicle.connected?"online":"offline";vehicleBadge.textContent=status.vehicle.connected?(status.vehicle.execution_mode==="hardware"?"实车执行已接入":"安全演练已接入"):"CAN_DEV 未启动";$("#gptConnectionFact").textContent=status.gpt.configured?`${status.gpt.model||"GPT"} 已配置`:"未配置";$("#vehicleConnectionFact").textContent=status.vehicle.connected?(status.vehicle.execution_mode==="hardware"?"实车模式":"安全演练"):"未启动";$("#connectionOverview").dataset.state=status.connected?"online":"offline";$("#connectionStatusTitle").textContent=status.connected?"GSCU1B 已连接":"GSCU1B 未连接";$("#connectionStatusMessage").textContent=status.connected?`${String(status.transport).toUpperCase()} · ${status.listen||"桥接正常"}`:status.message||"请检查桥接服务";if(showMessage){const ready=[status.connected,status.gpt.configured,status.vehicle.connected].filter(Boolean).length;showToast(`系统连接:${ready} / 3 项就绪`,ready===3?"success":"error");}return status;}
|
||
|
||
async function resetTestSession(){const button=$("#confirmResetButton");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("resetDialog");renderAll();showToast("学习器已擦除,所有预设动作恢复为待学习","success");}catch(error){showToast(error.message,"error")}finally{button.textContent="擦除并开始新测试";button.disabled=$("#resetConfirmText").value.trim()!=="清空";}}
|
||
|
||
function setupVoiceInput(){const Recognition=window.SpeechRecognition||window.webkitSpeechRecognition;if(!Recognition){$("#voiceButton").hidden=true;return;}const recognition=new Recognition();recognition.lang="zh-CN";$("#voiceButton").addEventListener("click",()=>{try{recognition.start()}catch(error){console.warn(error)}});recognition.addEventListener("start",()=>$("#voiceButton").classList.add("listening"));recognition.addEventListener("end",()=>$("#voiceButton").classList.remove("listening"));recognition.addEventListener("result",(event)=>{const text=event.results[0][0].transcript;$("#utterance").value=text;executeInstruction(text);});}
|
||
|
||
function bindEvents(){
|
||
$("#hardwareGrid").addEventListener("click",(event)=>{const addType=event.target.closest("[data-add-type]");if(addType)return openAddHardware(addType.dataset.addType);const pageButton=event.target.closest("[data-action-page]");if(pageButton){const typeKey=pageButton.dataset.actionPage;actionPageByType[typeKey]=Math.max(0,(actionPageByType[typeKey]||0)+Number(pageButton.dataset.pageDirection));renderHardwareGrid();return;}const removeHardware=event.target.closest("[data-delete-hardware]");if(removeHardware)return deleteHardware(removeHardware.dataset.deleteHardware);const forget=event.target.closest("[data-forget-capability]");if(forget)return forgetCapability(forget.dataset.forgetCapability);const learn=event.target.closest("[data-learn-capability]");if(learn)return openLearn(learn.dataset.learnCapability);const run=event.target.closest("[data-run-capability]");if(run){const capability=state.capabilities.find((item)=>item.id===run.dataset.runCapability);const device=state.devices.find((item)=>item.id===capability?.deviceId);if(!capability||!device)return;if(capability.risk==="high"&&!window.confirm(`“${capability.name}”可能让玩具移动,请确认周围安全。`))return;executePlan([{kind:"peripheral",device,capability,label:`${device.name} · ${capability.name}`,risk:capability.risk||"low"}]);}});
|
||
$("#hardwareGrid").addEventListener("change",(event)=>{const selector=event.target.closest("[data-device-select]");if(!selector)return;state.selectedDeviceByType[selector.dataset.deviceSelect]=selector.value;actionPageByType[selector.dataset.deviceSelect]=0;saveState();renderHardwareGrid();});
|
||
$("#typeChooser").addEventListener("click",(event)=>{const choice=event.target.closest("[data-type-choice]");if(choice&&!choice.disabled){selectedHardwareType=choice.dataset.typeChoice;renderTypeChooser();}});$("#addHardwareForm").addEventListener("submit",addHardware);
|
||
$("#vehicleGrid").addEventListener("click",(event)=>{const action=event.target.closest("[data-vehicle-command]");if(!action)return;$("#utterance").value=action.dataset.vehicleCommand;executeInstruction(action.dataset.vehicleCommand);});
|
||
$("#continueToLearn").addEventListener("click",()=>{try{learningSession.draft=collectLearningDraft();showLearnPage(2);}catch(error){showToast(error.message,"error");}});$("#backToAction").addEventListener("click",()=>showLearnPage(1));$("#startLearnButton").addEventListener("click",startLearning);$("#cancelLearnButton").addEventListener("click",cancelLearning);$("#testEmitButton").addEventListener("click",testLearning);$("#confirmLearnButton").addEventListener("click",confirmLearning);$("#learnDialog").addEventListener("cancel",(event)=>{if(learningSession.phase==="listening"){event.preventDefault();cancelLearning().then(()=>closeDialog("learnDialog"));}});
|
||
$("#executeButton").addEventListener("click",()=>executeInstruction($("#utterance").value));$("#utterance").addEventListener("keydown",(event)=>{if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();executeInstruction(event.currentTarget.value);}});
|
||
$("#openResetButton").addEventListener("click",()=>{$("#resetConfirmText").value="";$("#confirmResetButton").disabled=true;openDialog("resetDialog");});$("#resetConfirmText").addEventListener("input",(event)=>{$("#confirmResetButton").disabled=event.currentTarget.value.trim()!=="清空";});$("#confirmResetButton").addEventListener("click",resetTestSession);
|
||
["connectionButton","openConnectionButton"].forEach((id)=>$(`#${id}`).addEventListener("click",()=>{$("#bridgeUrl").value=state.settings.bridgeUrl;openDialog("connectionDialog");refreshConnectionStatus();}));$("#refreshConnection").addEventListener("click",()=>refreshConnectionStatus(true));$("#saveConnection").addEventListener("click",()=>{state.settings.bridgeUrl=$("#bridgeUrl").value.trim().replace(/\/$/,"");state.settings.adapterMode="bridge";saveState();refreshConnectionStatus(true);});
|
||
$$('[data-close-dialog]').forEach((button)=>button.addEventListener("click",async()=>{if(button.dataset.closeDialog==="learnDialog"&&learningSession.phase==="listening")await cancelLearning();closeDialog(button.dataset.closeDialog);}));
|
||
}
|
||
|
||
function init(){bindEvents();setupVoiceInput();renderAll();refreshConnectionStatus();}
|
||
init();
|