优化语音输入/普通文本输入

This commit is contained in:
minghanchen
2026-09-03 13:36:40 +08:00
parent 009d8ca73e
commit 32d179143f
26 changed files with 2318 additions and 53 deletions
@@ -0,0 +1,30 @@
# 设备能力卡片实验 Demo
这是一个完全独立的静态实验,不导入 Cognitive Agent 的现有前端或后端模块,
也不会调用 API、执行器或 CAN 通道。
## 运行
```bash
cd experiments/device-capability-card-demo
python3 -m http.server 8877 --bind 127.0.0.1
```
浏览器访问:<http://127.0.0.1:8877/>
## 测试
```bash
cd experiments/device-capability-card-demo
npm test
```
## 演示内容
- 实时修改设备名称、ID、领域、别名、目标范围和执行通道;
- 启用或停用能力,修改各项功能的自然语言说法;
- 根据能力定义自动生成设备功能卡片;
- 将自然语言转换为结构化控制命令;
- 展示设备匹配、功能匹配、范围解析、参数校验和执行器绑定轨迹;
- 对数值越界、未命中设备、功能停用等情况执行失败关闭;
- 所有执行载荷均带有 `demo_only: true`。
@@ -0,0 +1,288 @@
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();
@@ -0,0 +1,149 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>设备能力卡片实验室</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="demo-shell">
<header class="page-header">
<div>
<p class="eyebrow">独立实验 · 不连接车辆</p>
<h1>设备能力卡片实验室</h1>
<p class="page-intro">修改设备名称、别名和功能说法,观察自然语言如何匹配为结构化控制指令。</p>
</div>
<button class="secondary-button" id="resetDemo" type="button">恢复示例</button>
</header>
<section class="safety-note" aria-label="实验边界">
<span aria-hidden="true">◇</span>
<p><strong>安全边界</strong> 本页面只在浏览器内生成演示数据,不导入当前项目模块,也不会调用控制接口或发送 CAN 指令。</p>
</section>
<div class="workspace-grid">
<aside class="configuration-panel" aria-labelledby="configurationTitle">
<div class="section-heading">
<span>01</span>
<h2 id="configurationTitle">设备定义</h2>
</div>
<div class="field-grid">
<label class="field">
<span>设备名称</span>
<input id="deviceName" autocomplete="off" />
</label>
<label class="field">
<span>设备 ID</span>
<input id="deviceId" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>所属领域</span>
<input id="deviceDomain" autocomplete="off" spellcheck="false" />
</label>
<label class="field">
<span>设备类型</span>
<select id="deviceCategory">
<option value="vehicle_electrical">车端电器</option>
<option value="seat">座椅设备</option>
<option value="peripheral">扩展外设</option>
</select>
</label>
<label class="field field-wide">
<span>设备别名</span>
<input id="deviceAliases" autocomplete="off" placeholder="使用顿号或逗号分隔" />
<small>解析时优先匹配最长别名;避免使用“灯、开关、设备”等过宽名称。</small>
</label>
<label class="field">
<span>默认目标</span>
<select id="deviceScope">
<option value="vehicle">全车</option>
<option value="driver">主驾</option>
<option value="passenger">副驾</option>
<option value="rear_row">后排</option>
</select>
</label>
<label class="field">
<span>执行通道</span>
<select id="deviceTransport">
<option value="native_can">原生 CAN</option>
<option value="domain_runtime">领域执行器</option>
<option value="gscu_tcp">红外网关</option>
<option value="mqtt">MQTT</option>
</select>
</label>
</div>
<div class="section-heading compact-heading">
<span>02</span>
<h2>功能定义</h2>
</div>
<div id="capabilityEditor" class="capability-editor"></div>
<details class="schema-details">
<summary>查看卡片声明</summary>
<pre id="descriptorPreview" aria-label="设备卡片声明"></pre>
</details>
</aside>
<section class="experiment-panel">
<div class="pipeline" aria-label="匹配流程">
<span>设备别名</span><i aria-hidden="true">→</i>
<span>功能说法</span><i aria-hidden="true">→</i>
<span>参数校验</span><i aria-hidden="true">→</i>
<span>执行载荷</span>
</div>
<section class="preview-section" aria-labelledby="previewTitle">
<div class="section-heading">
<span>03</span>
<h2 id="previewTitle">功能卡片</h2>
</div>
<article class="device-card" id="deviceCardPreview"></article>
</section>
<section class="parser-section" aria-labelledby="parserTitle">
<div class="section-heading">
<span>04</span>
<h2 id="parserTitle">指令解析</h2>
</div>
<div class="example-row" id="exampleCommands" aria-label="示例指令"></div>
<label class="command-composer">
<span class="sr-only">自然语言控制指令</span>
<textarea id="utterance" rows="2" maxlength="180">把冰箱温度调到4度</textarea>
<button class="primary-button" id="parseCommand" type="button">解析指令</button>
</label>
<div class="result-banner" id="resultBanner" data-status="idle" aria-live="polite">
<span class="result-dot" aria-hidden="true"></span>
<div>
<strong id="resultTitle">等待解析</strong>
<p id="resultMessage">输入一句话,查看设备、功能和参数的匹配过程。</p>
</div>
</div>
<div class="result-grid">
<section class="trace-panel" aria-labelledby="traceTitle">
<h3 id="traceTitle">匹配轨迹</h3>
<ol id="traceList"></ol>
</section>
<section class="payload-panel" aria-labelledby="payloadTitle">
<div class="payload-heading">
<h3 id="payloadTitle">结构化结果</h3>
<button id="copyPayload" class="text-button" type="button" disabled>复制</button>
</div>
<pre id="payloadOutput">{}</pre>
</section>
</div>
</section>
</section>
</div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
@@ -0,0 +1,8 @@
{
"name": "device-capability-card-demo",
"private": true,
"type": "module",
"scripts": {
"test": "node parser-engine.test.mjs"
}
}
@@ -0,0 +1,353 @@
export const DEFAULT_DESCRIPTOR = {
version: 1,
device: {
id: "cabin_refrigerator",
name: "车载冰箱",
aliases: ["冰箱", "车载冷藏箱", "冷藏箱"],
domain: "refrigerator",
category: "vehicle_electrical",
targetDevice: "refrigerator",
targetScopes: ["vehicle"],
defaultScope: "vehicle",
transport: "native_can",
channel: "实验通道",
},
readouts: [
{ key: "power", label: "工作状态", value: "制冷中" },
{ key: "temperature", label: "当前温度", value: "6℃" },
{ key: "mode", label: "当前模式", value: "节能" },
],
capabilities: [
{
id: "refrigerator.set_power",
label: "工作状态",
property: "refrigerator.power",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "state", type: "string", required: true, allowedValues: ["on", "off"] },
],
intents: [
{ label: "开启", phrases: ["打开", "开启", "启动", "开启制冷"], values: { state: "on" } },
{ label: "关闭", phrases: ["关闭", "关掉", "停止", "停止制冷"], values: { state: "off" } },
],
ui: {
type: "select",
value: "on",
options: [["off", "关闭"], ["on", "开启"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.power" },
},
{
id: "refrigerator.set_temperature",
label: "目标温度",
property: "refrigerator.temperature",
operation: "set",
enabled: true,
risk: "medium",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "temperature_c", type: "number", unit: "celsius", required: true, minimum: 0, maximum: 15 },
],
intents: [
{
label: "设置温度",
phrases: ["温度", "调到", "设置为", "制冷到", "冷藏到"],
extract: { parameter: "temperature_c", kind: "number" },
},
],
ui: { type: "range", value: 4, minimum: 0, maximum: 15, step: 1, unit: "℃" },
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.temperature" },
},
{
id: "refrigerator.set_mode",
label: "工作模式",
property: "refrigerator.mode",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "mode", type: "string", required: true, allowedValues: ["eco", "fast_cool"] },
],
intents: [
{ label: "节能", phrases: ["节能模式", "省电模式", "节能"], values: { mode: "eco" } },
{ label: "速冷", phrases: ["速冷模式", "快速制冷", "速冷"], values: { mode: "fast_cool" } },
],
ui: {
type: "select",
value: "eco",
options: [["eco", "节能"], ["fast_cool", "速冷"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.mode" },
},
],
};
const SCOPE_ALIASES = {
driver: ["主驾", "驾驶位", "司机位"],
passenger: ["副驾", "副驾驶", "乘客位"],
rear_row: ["后排", "第二排"],
vehicle: ["全车", "整车", "车内"],
};
const POLITE_WORDS = /^(?:请|麻烦|拜托|帮我|请帮我|给我)+/;
const END_POLITE_WORDS = /(?:谢谢|可以吗|好吗|行吗)[。!!??]*$/;
export function cloneDescriptor(value = DEFAULT_DESCRIPTOR) {
return JSON.parse(JSON.stringify(value));
}
export function splitAliases(value) {
return [...new Set(String(value || "")
.split(/[,,、;;|]/)
.map((item) => item.trim())
.filter(Boolean))];
}
export function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.trim()
.replace(POLITE_WORDS, "")
.replace(END_POLITE_WORDS, "")
.toLowerCase()
.replace(/[\s,,。;;!!??、]/g, "");
}
function longestContained(normalizedText, phrases) {
return [...new Set(phrases.map(normalizeText).filter(Boolean))]
.filter((phrase) => normalizedText.includes(phrase))
.sort((left, right) => right.length - left.length)[0] || "";
}
function chineseInteger(token) {
if (!token) return null;
if (/^\d+$/.test(token)) return Number(token);
const digits = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
if (token.includes("十")) {
const [left, right] = token.split("十", 2);
const tens = left ? digits[left.at(-1)] : 1;
const ones = right ? digits[right.at(-1)] : 0;
return Number.isFinite(tens) && Number.isFinite(ones) ? tens * 10 + ones : null;
}
if ([...token].every((char) => Object.hasOwn(digits, char))) {
return Number([...token].map((char) => digits[char]).join(""));
}
return null;
}
function extractNumber(text) {
const belowZero = String(text).match(/零下(\d+(?:\.\d+)?|[零〇一二两三四五六七八九十]+)/);
if (belowZero) {
const value = /^\d/.test(belowZero[1]) ? Number(belowZero[1]) : chineseInteger(belowZero[1]);
return value === null ? null : -value;
}
const numeric = String(text).match(/-?\d+(?:\.\d+)?/);
if (numeric) return Number(numeric[0]);
const chinese = String(text).match(/[零〇一二两三四五六七八九十]+/);
return chinese ? chineseInteger(chinese[0]) : null;
}
function resolveScope(normalizedText, device) {
const explicit = Object.entries(SCOPE_ALIASES)
.flatMap(([scope, aliases]) => aliases.map((alias) => ({ scope, alias: normalizeText(alias) })))
.find(({ alias }) => normalizedText.includes(alias));
if (explicit) {
if (!device.targetScopes.includes(explicit.scope)) {
return { status: "unsupported", scope: explicit.scope, explicit: true, reason: `设备不支持“${explicit.alias}”范围` };
}
return { status: "resolved", scope: explicit.scope, explicit: true, reason: `显式目标:${explicit.alias}` };
}
if (device.defaultScope && device.targetScopes.includes(device.defaultScope)) {
return { status: "resolved", scope: device.defaultScope, explicit: false, reason: `采用卡片默认目标:${device.defaultScope}` };
}
if (device.targetScopes.length === 1) {
return { status: "resolved", scope: device.targetScopes[0], explicit: false, reason: `能力仅支持唯一目标:${device.targetScopes[0]}` };
}
return { status: "ambiguous", scope: null, explicit: false, reason: "存在多个目标范围,需要用户确认" };
}
function validateParameters(capability, parameters) {
const issues = [];
for (const schema of capability.parameters || []) {
const value = parameters[schema.name];
if ((value === undefined || value === null || value === "") && schema.required) {
issues.push(`缺少必填参数 ${schema.name}`);
continue;
}
if (value === undefined || value === null || value === "") continue;
if (schema.type === "number" && typeof value !== "number") {
issues.push(`${schema.name} 必须是数字`);
}
if (schema.minimum !== undefined && value < schema.minimum) {
issues.push(`${schema.name}=${value} 低于下限 ${schema.minimum}`);
}
if (schema.maximum !== undefined && value > schema.maximum) {
issues.push(`${schema.name}=${value} 超过上限 ${schema.maximum}`);
}
if (schema.allowedValues && !schema.allowedValues.includes(value)) {
issues.push(`${schema.name}=${value} 不在允许值中`);
}
}
return issues;
}
function renderCleanText(descriptor, capability, parameters) {
const deviceName = descriptor.device.name;
if (capability.property.endsWith(".power")) {
return `${parameters.state === "on" ? "开启" : "关闭"}${deviceName}。`;
}
if (capability.property.endsWith(".temperature")) {
return `将${deviceName}设置为${parameters.temperature_c}度。`;
}
if (capability.property.endsWith(".mode")) {
const modeLabels = { eco: "节能模式", fast_cool: "速冷模式" };
return `将${deviceName}设置为${modeLabels[parameters.mode] || parameters.mode}。`;
}
return `执行${deviceName}的${capability.label}。`;
}
function baseResult(rawText, normalizedText, trace) {
return {
raw_text: rawText,
normalized_text: normalizedText,
semantic_status: "no_command",
execution_eligible: false,
clean_text: "",
command: null,
issues: [],
trace,
};
}
export function parseInstruction(rawText, descriptorInput = DEFAULT_DESCRIPTOR) {
const descriptor = cloneDescriptor(descriptorInput);
const normalizedText = normalizeText(rawText);
const trace = [{ stage: "文本整理", state: "success", detail: normalizedText || "输入为空" }];
const result = baseResult(String(rawText || ""), normalizedText, trace);
if (!normalizedText) {
result.semantic_status = "incomplete";
result.issues.push("请输入自然语言指令");
trace.push({ stage: "设备识别", state: "error", detail: "没有可分析的文字" });
return result;
}
const devicePhrases = [descriptor.device.name, ...(descriptor.device.aliases || [])];
const matchedDevice = longestContained(normalizedText, devicePhrases);
if (!matchedDevice) {
result.issues.push("指令没有命中设备名称或别名");
trace.push({ stage: "设备识别", state: "error", detail: `候选:${devicePhrases.join("、")}` });
return result;
}
trace.push({ stage: "设备识别", state: "success", detail: `“${matchedDevice}” → ${descriptor.device.id}` });
const candidates = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
for (const intent of capability.intents || []) {
const phrase = longestContained(normalizedText, intent.phrases || []);
if (!phrase) continue;
const parameters = { ...(intent.values || {}) };
if (intent.extract?.kind === "number") {
const number = extractNumber(rawText);
if (number !== null) parameters[intent.extract.parameter] = number;
}
candidates.push({
capability,
intent,
phrase,
parameters,
score: matchedDevice.length * 100 + phrase.length,
});
}
}
if (!candidates.length) {
result.semantic_status = "incomplete";
result.issues.push("设备已识别,但没有命中已启用的功能说法");
trace.push({ stage: "功能识别", state: "error", detail: "请使用卡片中定义的功能说法" });
return result;
}
candidates.sort((left, right) => right.score - left.score);
const best = candidates[0];
const tied = candidates.filter((item) => item.score === best.score);
if (tied.length > 1) {
result.semantic_status = "ambiguous";
result.issues.push(`同时命中多个功能:${tied.map((item) => item.intent.label).join("、")}`);
trace.push({ stage: "功能识别", state: "error", detail: "最高匹配分相同,需要补充更明确的动作" });
return result;
}
trace.push({ stage: "功能识别", state: "success", detail: `“${best.phrase}” → ${best.capability.id}` });
const scope = resolveScope(normalizedText, descriptor.device);
trace.push({ stage: "目标解析", state: scope.status === "resolved" ? "success" : "error", detail: scope.reason });
if (scope.status !== "resolved") {
result.semantic_status = scope.status === "ambiguous" ? "ambiguous" : "recognized_unsupported";
result.issues.push(scope.reason);
return result;
}
const issues = validateParameters(best.capability, best.parameters);
trace.push({
stage: "参数校验",
state: issues.length ? "error" : "success",
detail: issues.length ? issues.join(";") : JSON.stringify(best.parameters),
});
if (issues.length) {
result.semantic_status = Object.keys(best.parameters).length ? "recognized_unsupported" : "incomplete";
result.issues.push(...issues);
return result;
}
result.semantic_status = "recognized_executable";
result.execution_eligible = true;
result.clean_text = renderCleanText(descriptor, best.capability, best.parameters);
result.command = {
domain: descriptor.device.domain,
capability: best.capability.id,
property: best.capability.property,
target_device: descriptor.device.targetDevice,
target_scope: scope.scope,
operation: best.capability.operation,
parameters: best.parameters,
risk_level: best.capability.risk,
confirmation_policy: best.capability.confirmationPolicy,
executor_binding: best.capability.executor.binding,
executor_payload: {
transport: descriptor.device.transport,
mapping_ref: best.capability.executor.mappingRef,
capability: best.capability.id,
target: scope.scope,
parameters: best.parameters,
demo_only: true,
},
};
trace.push({ stage: "执行绑定", state: "success", detail: `${best.capability.executor.binding} · 演示载荷,不发送` });
return result;
}
export function buildManualCommand(descriptorInput, values) {
const descriptor = cloneDescriptor(descriptorInput);
const commands = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
const value = values[capability.id];
if (value === undefined) continue;
const schema = capability.parameters?.[0];
if (!schema) continue;
const parsedValue = schema.type === "number" ? Number(value) : value;
const parameters = { [schema.name]: parsedValue };
const issues = validateParameters(capability, parameters);
if (issues.length) continue;
commands.push({
capability: capability.id,
target_scope: descriptor.device.defaultScope,
parameters,
demo_only: true,
});
}
return commands;
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { DEFAULT_DESCRIPTOR, cloneDescriptor, parseInstruction } from "./parser-engine.js";
const successCases = [
["开启车载冰箱", "refrigerator.set_power", { state: "on" }],
["把冰箱温度调到4度", "refrigerator.set_temperature", { temperature_c: 4 }],
["将冷藏箱设为速冷模式", "refrigerator.set_mode", { mode: "fast_cool" }],
["关闭冰箱", "refrigerator.set_power", { state: "off" }],
];
for (const [utterance, capability, parameters] of successCases) {
const result = parseInstruction(utterance, DEFAULT_DESCRIPTOR);
assert.equal(result.semantic_status, "recognized_executable", utterance);
assert.equal(result.execution_eligible, true, utterance);
assert.equal(result.command.capability, capability, utterance);
assert.deepEqual(result.command.parameters, parameters, utterance);
assert.equal(result.command.executor_payload.demo_only, true, utterance);
}
const outOfRange = parseInstruction("把冰箱调到零下10度", DEFAULT_DESCRIPTOR);
assert.equal(outOfRange.semantic_status, "recognized_unsupported");
assert.equal(outOfRange.execution_eligible, false);
assert.match(outOfRange.issues.join(" "), /低于下限/);
const noDevice = parseInstruction("把空调调到4度", DEFAULT_DESCRIPTOR);
assert.equal(noDevice.semantic_status, "no_command");
assert.equal(noDevice.execution_eligible, false);
const disabled = cloneDescriptor();
disabled.capabilities.find((item) => item.id === "refrigerator.set_temperature").enabled = false;
const disabledResult = parseInstruction("把冰箱温度调到4度", disabled);
assert.equal(disabledResult.semantic_status, "incomplete");
console.log(`device capability card demo: ${successCases.length + 3} parser checks passed`);
@@ -0,0 +1,746 @@
:root {
color-scheme: light dark;
--page: #f2f5f2;
--surface: #ffffff;
--surface-soft: #f7f9f7;
--surface-strong: #e8eeea;
--ink: #18201c;
--muted: #68736c;
--line: #d8dfda;
--accent: #176b4a;
--accent-soft: #dceee5;
--accent-ink: #0f5137;
--warning: #9a640b;
--warning-soft: #fff3d7;
--danger: #a33a3a;
--danger-soft: #fde9e7;
--shadow: 0 18px 55px rgba(32, 50, 40, 0.08);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 8% 3%, rgba(23, 107, 74, 0.1), transparent 25rem),
var(--page);
color: var(--ink);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.demo-shell {
width: min(1440px, calc(100% - 40px));
margin: 0 auto;
padding: 38px 0 60px;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 10px;
font-size: clamp(30px, 4vw, 48px);
line-height: 1.05;
letter-spacing: -0.045em;
}
.page-intro {
max-width: 720px;
margin-bottom: 0;
color: var(--muted);
line-height: 1.7;
}
.secondary-button,
.primary-button {
min-height: 42px;
border-radius: 11px;
padding: 0 17px;
font-weight: 700;
}
.secondary-button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
}
.primary-button {
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
}
.safety-note {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--line));
border-radius: 14px;
padding: 12px 16px;
background: color-mix(in srgb, var(--accent-soft) 60%, var(--surface));
color: var(--accent-ink);
}
.safety-note > span {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border-radius: 50%;
background: var(--accent);
color: #fff;
}
.safety-note p {
margin: 0;
line-height: 1.55;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(320px, 0.75fr) minmax(560px, 1.55fr);
gap: 20px;
align-items: start;
}
.configuration-panel,
.preview-section,
.parser-section {
border: 1px solid var(--line);
border-radius: 20px;
background: var(--surface);
box-shadow: var(--shadow);
}
.configuration-panel {
padding: 22px;
}
.experiment-panel {
display: grid;
gap: 20px;
}
.preview-section,
.parser-section {
padding: 22px;
}
.section-heading {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.section-heading > span {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 9px;
background: var(--accent-soft);
color: var(--accent-ink);
font-size: 12px;
font-weight: 800;
}
.section-heading h2 {
margin: 0;
font-size: 19px;
letter-spacing: -0.02em;
}
.compact-heading {
margin-top: 28px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field-wide {
grid-column: 1 / -1;
}
.field > span,
.intent-alias-field > span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.field small {
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-soft);
color: var(--ink);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input,
select {
min-height: 40px;
padding: 0 11px;
}
textarea {
resize: vertical;
padding: 12px;
line-height: 1.6;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
.capability-editor {
display: grid;
gap: 12px;
}
.capability-definition {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.capability-definition:first-child {
border-top: 0;
padding-top: 0;
}
.capability-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.capability-title strong {
font-size: 14px;
}
.capability-title code {
color: var(--muted);
font-size: 11px;
}
.toggle-label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.toggle-label input {
width: 16px;
min-height: 16px;
accent-color: var(--accent);
}
.intent-list {
display: grid;
gap: 9px;
}
.intent-alias-field {
display: grid;
grid-template-columns: 72px 1fr;
align-items: center;
gap: 8px;
}
.intent-alias-field input {
min-height: 36px;
font-size: 12px;
}
.schema-details {
margin-top: 20px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.schema-details summary {
color: var(--accent);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
pre {
overflow: auto;
margin: 12px 0 0;
border-radius: 12px;
padding: 15px;
background: #17231d;
color: #d8e6dd;
font: 12px/1.65 "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.pipeline {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 54px;
padding: 10px 16px;
color: var(--muted);
font-size: 12px;
}
.pipeline span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 11px;
background: var(--surface);
color: var(--ink);
font-weight: 700;
}
.pipeline i {
color: var(--accent);
font-style: normal;
}
.device-card {
position: relative;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--line));
border-radius: 18px;
padding: 20px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--accent-soft) 70%, var(--surface)), var(--surface) 52%);
}
.device-card::after {
position: absolute;
right: -50px;
top: -70px;
width: 180px;
height: 180px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 9%, transparent);
content: "";
}
.device-card-header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.device-identity {
display: flex;
align-items: center;
gap: 12px;
}
.device-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 15px;
background: var(--accent);
color: #fff;
font-size: 22px;
}
.device-identity h3 {
margin: 0 0 4px;
font-size: 21px;
}
.device-identity p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.connection-state {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 700;
color: var(--accent-ink);
}
.connection-state i,
.result-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
.readout-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 20px 0;
}
.readout-grid div {
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
border-radius: 12px;
padding: 12px;
background: color-mix(in srgb, var(--surface) 84%, transparent);
}
.readout-grid span,
.card-control span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.readout-grid strong {
font-size: 16px;
}
.card-controls {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card-control input,
.card-control select {
background: color-mix(in srgb, var(--surface) 88%, transparent);
}
.card-control output {
float: right;
color: var(--accent);
font-size: 12px;
font-weight: 800;
}
.card-control input[type="range"] {
min-height: auto;
padding: 0;
accent-color: var(--accent);
}
.card-footnote {
position: relative;
z-index: 1;
margin: 15px 0 0;
color: var(--muted);
font-size: 11px;
}
.example-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.example-row button,
.text-button {
border: 0;
background: transparent;
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
.example-row button {
border-radius: 999px;
padding: 7px 10px;
background: var(--accent-soft);
}
.command-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: stretch;
}
.command-composer textarea {
min-height: 64px;
}
.result-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 14px 0;
border-radius: 13px;
padding: 13px 15px;
background: var(--surface-soft);
}
.result-banner[data-status="success"] {
background: var(--accent-soft);
color: var(--accent-ink);
}
.result-banner[data-status="warning"] {
background: var(--warning-soft);
color: var(--warning);
}
.result-banner[data-status="error"] {
background: var(--danger-soft);
color: var(--danger);
}
.result-banner[data-status="warning"] .result-dot {
background: var(--warning);
}
.result-banner[data-status="error"] .result-dot {
background: var(--danger);
}
.result-banner strong {
display: block;
margin-bottom: 3px;
font-size: 14px;
}
.result-banner p {
margin: 0;
font-size: 12px;
line-height: 1.55;
}
.result-grid {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(280px, 1.15fr);
gap: 14px;
}
.trace-panel,
.payload-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 14px;
padding: 15px;
}
.trace-panel h3,
.payload-heading h3 {
margin: 0;
font-size: 14px;
}
.trace-panel ol {
display: grid;
gap: 12px;
margin: 15px 0 0;
padding: 0;
list-style: none;
counter-reset: trace;
}
.trace-panel li {
position: relative;
min-height: 30px;
padding-left: 34px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
counter-increment: trace;
}
.trace-panel li::before {
position: absolute;
left: 0;
top: 0;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 8px;
background: var(--surface-strong);
color: var(--ink);
content: counter(trace);
font-size: 11px;
font-weight: 800;
}
.trace-panel li[data-state="success"]::before {
background: var(--accent-soft);
color: var(--accent-ink);
}
.trace-panel li[data-state="error"]::before {
background: var(--danger-soft);
color: var(--danger);
}
.trace-panel li strong {
display: block;
color: var(--ink);
}
.payload-heading {
display: flex;
align-items: center;
justify-content: space-between;
}
.payload-panel pre {
min-height: 216px;
max-height: 360px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
:root {
--page: #111713;
--surface: #19211c;
--surface-soft: #202923;
--surface-strong: #29342d;
--ink: #edf4ef;
--muted: #a4b1a8;
--line: #354139;
--accent: #58b98a;
--accent-soft: #243f32;
--accent-ink: #bdebd3;
--warning: #f2c873;
--warning-soft: #463b23;
--danger: #f0a09a;
--danger-soft: #4b2928;
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
}
.primary-button,
.device-icon,
.safety-note > span {
color: #102019;
}
}
@media (max-width: 980px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.demo-shell {
width: min(100% - 24px, 1440px);
padding-top: 24px;
}
.page-header {
align-items: flex-start;
flex-direction: column;
}
.pipeline {
justify-content: flex-start;
overflow-x: auto;
}
.pipeline span {
white-space: nowrap;
}
.field-grid,
.card-controls,
.result-grid {
grid-template-columns: 1fr;
}
.field-wide {
grid-column: auto;
}
.readout-grid {
grid-template-columns: 1fr 1fr;
}
.command-composer {
grid-template-columns: 1fr;
}
}