289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
import {
|
||
DEFAULT_DESCRIPTOR,
|
||
buildManualCommand,
|
||
cloneDescriptor,
|
||
parseInstruction,
|
||
splitAliases,
|
||
} from "./parser-engine.js";
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
|
||
let descriptor = cloneDescriptor();
|
||
let latestPayload = null;
|
||
|
||
const categoryLabels = {
|
||
vehicle_electrical: "车端电器",
|
||
seat: "座椅设备",
|
||
peripheral: "扩展外设",
|
||
};
|
||
|
||
const transportLabels = {
|
||
native_can: "原生 CAN",
|
||
domain_runtime: "领域执行器",
|
||
gscu_tcp: "红外网关",
|
||
mqtt: "MQTT",
|
||
};
|
||
|
||
const exampleTemplates = [
|
||
(name) => `开启${name}`,
|
||
(name) => `把${name}温度调到4度`,
|
||
(name) => `将${name}设为速冷模式`,
|
||
(name) => `关闭${name}`,
|
||
(name) => `把${name}调到零下10度`,
|
||
];
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function fillIdentityForm() {
|
||
$("#deviceName").value = descriptor.device.name;
|
||
$("#deviceId").value = descriptor.device.id;
|
||
$("#deviceDomain").value = descriptor.device.domain;
|
||
$("#deviceCategory").value = descriptor.device.category;
|
||
$("#deviceAliases").value = descriptor.device.aliases.join("、");
|
||
$("#deviceScope").value = descriptor.device.defaultScope;
|
||
$("#deviceTransport").value = descriptor.device.transport;
|
||
}
|
||
|
||
function syncIdentityFromForm() {
|
||
descriptor.device.name = $("#deviceName").value.trim() || "未命名设备";
|
||
descriptor.device.id = $("#deviceId").value.trim() || "unnamed_device";
|
||
descriptor.device.domain = $("#deviceDomain").value.trim() || "custom";
|
||
descriptor.device.category = $("#deviceCategory").value;
|
||
descriptor.device.aliases = splitAliases($("#deviceAliases").value);
|
||
descriptor.device.defaultScope = $("#deviceScope").value;
|
||
descriptor.device.targetScopes = [descriptor.device.defaultScope];
|
||
descriptor.device.transport = $("#deviceTransport").value;
|
||
descriptor.device.targetDevice = descriptor.device.id;
|
||
renderAll({ preserveResult: true });
|
||
}
|
||
|
||
function renderCapabilityEditor() {
|
||
$("#capabilityEditor").innerHTML = descriptor.capabilities.map((capability, capabilityIndex) => `
|
||
<section class="capability-definition" data-capability-index="${capabilityIndex}">
|
||
<div class="capability-title">
|
||
<div>
|
||
<strong>${escapeHtml(capability.label)}</strong>
|
||
<code>${escapeHtml(capability.id)}</code>
|
||
</div>
|
||
<label class="toggle-label">
|
||
<input type="checkbox" data-capability-enabled ${capability.enabled !== false ? "checked" : ""} />
|
||
启用
|
||
</label>
|
||
</div>
|
||
<div class="intent-list">
|
||
${capability.intents.map((intent, intentIndex) => `
|
||
<label class="intent-alias-field">
|
||
<span>${escapeHtml(intent.label)}</span>
|
||
<input
|
||
data-intent-index="${intentIndex}"
|
||
value="${escapeHtml(intent.phrases.join("、"))}"
|
||
aria-label="${escapeHtml(capability.label + intent.label)}的自然语言说法"
|
||
/>
|
||
</label>
|
||
`).join("")}
|
||
</div>
|
||
</section>
|
||
`).join("");
|
||
}
|
||
|
||
function renderExamples() {
|
||
$("#exampleCommands").innerHTML = exampleTemplates.map((makeExample) => {
|
||
const utterance = makeExample(descriptor.device.name);
|
||
return `<button type="button" data-example="${escapeHtml(utterance)}">${escapeHtml(utterance)}</button>`;
|
||
}).join("");
|
||
}
|
||
|
||
function controlMarkup(capability) {
|
||
const ui = capability.ui;
|
||
if (ui.type === "select") {
|
||
return `
|
||
<label class="card-control">
|
||
<span>${escapeHtml(capability.label)}</span>
|
||
<select data-manual-capability="${escapeHtml(capability.id)}">
|
||
${ui.options.map(([value, label]) => `<option value="${escapeHtml(value)}"${String(value) === String(ui.value) ? " selected" : ""}>${escapeHtml(label)}</option>`).join("")}
|
||
</select>
|
||
</label>
|
||
`;
|
||
}
|
||
return `
|
||
<label class="card-control">
|
||
<span>${escapeHtml(capability.label)} <output>${escapeHtml(ui.value)}${escapeHtml(ui.unit || "")}</output></span>
|
||
<input
|
||
type="range"
|
||
min="${ui.minimum}"
|
||
max="${ui.maximum}"
|
||
step="${ui.step}"
|
||
value="${ui.value}"
|
||
data-manual-capability="${escapeHtml(capability.id)}"
|
||
data-unit="${escapeHtml(ui.unit || "")}"
|
||
/>
|
||
</label>
|
||
`;
|
||
}
|
||
|
||
function renderDeviceCard() {
|
||
const enabledCapabilities = descriptor.capabilities.filter((item) => item.enabled !== false);
|
||
const category = categoryLabels[descriptor.device.category] || "自定义设备";
|
||
const transport = transportLabels[descriptor.device.transport] || descriptor.device.transport;
|
||
const icon = descriptor.device.category === "seat" ? "◫" : descriptor.device.category === "peripheral" ? "⌁" : "❄";
|
||
$("#deviceCardPreview").innerHTML = `
|
||
<header class="device-card-header">
|
||
<div class="device-identity">
|
||
<span class="device-icon" aria-hidden="true">${icon}</span>
|
||
<div>
|
||
<h3>${escapeHtml(descriptor.device.name)}</h3>
|
||
<p>${escapeHtml(category)} · ${escapeHtml(transport)} · ${escapeHtml(descriptor.device.defaultScope)}</p>
|
||
</div>
|
||
</div>
|
||
<span class="connection-state"><i></i>实验就绪</span>
|
||
</header>
|
||
<div class="readout-grid">
|
||
${descriptor.readouts.map((readout) => `<div><span>${escapeHtml(readout.label)}</span><strong>${escapeHtml(readout.value)}</strong></div>`).join("")}
|
||
</div>
|
||
<div class="card-controls">
|
||
${enabledCapabilities.length ? enabledCapabilities.map(controlMarkup).join("") : "<p>没有已启用功能。</p>"}
|
||
</div>
|
||
<p class="card-footnote">功能控件只生成结构化演示数据;执行映射为虚拟引用,不连接现有服务。</p>
|
||
`;
|
||
}
|
||
|
||
function compactDescriptor() {
|
||
return {
|
||
version: descriptor.version,
|
||
device: descriptor.device,
|
||
capabilities: descriptor.capabilities.map((capability) => ({
|
||
id: capability.id,
|
||
property: capability.property,
|
||
target_scopes: descriptor.device.targetScopes,
|
||
parameters: capability.parameters,
|
||
intents: capability.intents,
|
||
risk_level: capability.risk,
|
||
confirmation_policy: capability.confirmationPolicy,
|
||
executor: capability.executor,
|
||
enabled: capability.enabled,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function renderDescriptor() {
|
||
$("#descriptorPreview").textContent = JSON.stringify(compactDescriptor(), null, 2);
|
||
}
|
||
|
||
function renderAll({ preserveResult = false } = {}) {
|
||
renderCapabilityEditor();
|
||
renderExamples();
|
||
renderDeviceCard();
|
||
renderDescriptor();
|
||
if (!preserveResult) renderResult(null);
|
||
}
|
||
|
||
function resultPresentation(result) {
|
||
if (!result) return { status: "idle", title: "等待解析", message: "输入一句话,查看设备、功能和参数的匹配过程。" };
|
||
if (result.source === "card_control") {
|
||
return {
|
||
status: "success",
|
||
title: "已生成卡片控制载荷",
|
||
message: `已生成 ${result.commands.length} 条结构化演示指令;实验页面不会发送到车辆。`,
|
||
};
|
||
}
|
||
if (result.semantic_status === "recognized_executable") {
|
||
return { status: "success", title: "已匹配可执行功能", message: `${result.clean_text} 当前仅生成演示载荷。` };
|
||
}
|
||
if (result.semantic_status === "ambiguous" || result.semantic_status === "incomplete") {
|
||
return { status: "warning", title: "需要补充信息", message: result.issues.join(";") };
|
||
}
|
||
return { status: "error", title: "未生成控制指令", message: result.issues.join(";") || "没有匹配到设备能力。" };
|
||
}
|
||
|
||
function renderResult(result) {
|
||
const presentation = resultPresentation(result);
|
||
$("#resultBanner").dataset.status = presentation.status;
|
||
$("#resultTitle").textContent = presentation.title;
|
||
$("#resultMessage").textContent = presentation.message;
|
||
$("#traceList").innerHTML = result?.trace?.length
|
||
? result.trace.map((item) => `<li data-state="${escapeHtml(item.state)}"><strong>${escapeHtml(item.stage)}</strong>${escapeHtml(item.detail)}</li>`).join("")
|
||
: "<li><strong>尚无轨迹</strong>解析后将在这里显示每一步决策。</li>";
|
||
latestPayload = result;
|
||
$("#payloadOutput").textContent = JSON.stringify(result || {}, null, 2);
|
||
$("#copyPayload").disabled = !result;
|
||
}
|
||
|
||
function parseCurrentUtterance() {
|
||
renderResult(parseInstruction($("#utterance").value, descriptor));
|
||
}
|
||
|
||
function bindEvents() {
|
||
["deviceName", "deviceId", "deviceDomain", "deviceCategory", "deviceAliases", "deviceScope", "deviceTransport"]
|
||
.forEach((id) => $("#" + id).addEventListener("input", syncIdentityFromForm));
|
||
|
||
$("#capabilityEditor").addEventListener("input", (event) => {
|
||
const section = event.target.closest("[data-capability-index]");
|
||
if (!section) return;
|
||
const capability = descriptor.capabilities[Number(section.dataset.capabilityIndex)];
|
||
if (event.target.matches("[data-capability-enabled]")) {
|
||
capability.enabled = event.target.checked;
|
||
} else if (event.target.matches("[data-intent-index]")) {
|
||
capability.intents[Number(event.target.dataset.intentIndex)].phrases = splitAliases(event.target.value);
|
||
}
|
||
renderDeviceCard();
|
||
renderDescriptor();
|
||
});
|
||
|
||
$("#exampleCommands").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-example]");
|
||
if (!button) return;
|
||
$("#utterance").value = button.dataset.example;
|
||
parseCurrentUtterance();
|
||
});
|
||
|
||
$("#parseCommand").addEventListener("click", parseCurrentUtterance);
|
||
$("#utterance").addEventListener("keydown", (event) => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") parseCurrentUtterance();
|
||
});
|
||
|
||
$("#deviceCardPreview").addEventListener("input", (event) => {
|
||
if (!event.target.matches("[data-manual-capability]")) return;
|
||
const output = event.target.closest(".card-control")?.querySelector("output");
|
||
if (output) output.textContent = `${event.target.value}${event.target.dataset.unit || ""}`;
|
||
const values = Object.fromEntries($$("[data-manual-capability]", $("#deviceCardPreview"))
|
||
.map((input) => [input.dataset.manualCapability, input.value]));
|
||
const result = {
|
||
source: "card_control",
|
||
semantic_status: "recognized_executable",
|
||
execution_eligible: false,
|
||
note: "实验页面不允许真实执行",
|
||
commands: buildManualCommand(descriptor, values),
|
||
};
|
||
renderResult(result);
|
||
});
|
||
|
||
$("#copyPayload").addEventListener("click", async () => {
|
||
if (!latestPayload) return;
|
||
await navigator.clipboard.writeText(JSON.stringify(latestPayload, null, 2));
|
||
const button = $("#copyPayload");
|
||
button.textContent = "已复制";
|
||
window.setTimeout(() => { button.textContent = "复制"; }, 1200);
|
||
});
|
||
|
||
$("#resetDemo").addEventListener("click", () => {
|
||
descriptor = cloneDescriptor(DEFAULT_DESCRIPTOR);
|
||
fillIdentityForm();
|
||
$("#utterance").value = "把冰箱温度调到4度";
|
||
renderAll();
|
||
parseCurrentUtterance();
|
||
});
|
||
}
|
||
|
||
fillIdentityForm();
|
||
renderAll();
|
||
bindEvents();
|
||
parseCurrentUtterance();
|