优化语音输入/普通文本输入
This commit is contained in:
@@ -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("&", "&")
|
||||
.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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user