feat(n7web): refine spatial UI and NFC-gated extension routing
This commit is contained in:
@@ -722,6 +722,19 @@
|
|||||||
return "binary";
|
return "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusLatestDispatchedControls(container) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const transcript = $("#assistantMessages");
|
||||||
|
const activeCard = container?.matches?.(".assistant-dispatched-controls")
|
||||||
|
? container
|
||||||
|
: $(".assistant-dispatched-controls", container);
|
||||||
|
if (!transcript || !activeCard) return;
|
||||||
|
transcript.querySelectorAll(".assistant-dispatched-controls").forEach((card) => {
|
||||||
|
card.open = card === activeCard;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function appendDispatchedControls(message, result = {}) {
|
function appendDispatchedControls(message, result = {}) {
|
||||||
if (!message?.body || !result || typeof result !== "object") return;
|
if (!message?.body || !result || typeof result !== "object") return;
|
||||||
const previous = message.dispatchedControlResult || {};
|
const previous = message.dispatchedControlResult || {};
|
||||||
@@ -766,6 +779,7 @@
|
|||||||
if (vueRenderer(host, viewCommands)) {
|
if (vueRenderer(host, viewCommands)) {
|
||||||
host.dataset.commandSignature = signature;
|
host.dataset.commandSignature = signature;
|
||||||
message.article?.classList.add("assistant-message-with-dispatched-controls");
|
message.article?.classList.add("assistant-message-with-dispatched-controls");
|
||||||
|
focusLatestDispatchedControls(host);
|
||||||
followLatest(true);
|
followLatest(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -777,6 +791,13 @@
|
|||||||
card = document.createElement("details");
|
card = document.createElement("details");
|
||||||
card.className = "assistant-dispatched-controls";
|
card.className = "assistant-dispatched-controls";
|
||||||
card.open = true;
|
card.open = true;
|
||||||
|
card.addEventListener("toggle", () => {
|
||||||
|
if (!card.open) return;
|
||||||
|
const transcript = $("#assistantMessages");
|
||||||
|
transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((otherCard) => {
|
||||||
|
if (otherCard !== card) otherCard.open = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
const summary = document.createElement("summary");
|
const summary = document.createElement("summary");
|
||||||
const headingIcon = document.createElement("span");
|
const headingIcon = document.createElement("span");
|
||||||
headingIcon.className = "assistant-dispatched-controls-heading-icon";
|
headingIcon.className = "assistant-dispatched-controls-heading-icon";
|
||||||
@@ -828,6 +849,7 @@
|
|||||||
list.append(row);
|
list.append(row);
|
||||||
});
|
});
|
||||||
message.article?.classList.add("assistant-message-with-dispatched-controls");
|
message.article?.classList.add("assistant-message-with-dispatched-controls");
|
||||||
|
focusLatestDispatchedControls(card);
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
followLatest(true);
|
followLatest(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,7 +568,11 @@
|
|||||||
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
|
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
|
||||||
const failureMessage = String(execution?.message || "").trim();
|
const failureMessage = String(execution?.message || "").trim();
|
||||||
const aborted = abortedStates.has(executionStatus);
|
const aborted = abortedStates.has(executionStatus);
|
||||||
|
const explicitlyBlocked = executionStatus === "blocked"
|
||||||
|
|| verificationStatus === "blocked"
|
||||||
|
|| Boolean(telemetry.authorization_reason);
|
||||||
const executionFailed = executionStatus === "failed"
|
const executionFailed = executionStatus === "failed"
|
||||||
|
|| explicitlyBlocked
|
||||||
|| verificationStatus === "failed_or_blocked";
|
|| verificationStatus === "failed_or_blocked";
|
||||||
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
|
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
|
||||||
/no fresh valid/i.test(failureMessage)
|
/no fresh valid/i.test(failureMessage)
|
||||||
@@ -579,7 +583,8 @@
|
|||||||
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
|
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
|
||||||
);
|
);
|
||||||
const safetyBlocked = executionFailed && !submitted && (
|
const safetyBlocked = executionFailed && !submitted && (
|
||||||
missingWindowFeedback
|
explicitlyBlocked
|
||||||
|
|| missingWindowFeedback
|
||||||
|| missingShadeFeedback
|
|| missingShadeFeedback
|
||||||
|| /refusing (?:blind )?motion/i.test(failureMessage)
|
|| /refusing (?:blind )?motion/i.test(failureMessage)
|
||||||
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
|
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
|
||||||
@@ -1100,9 +1105,9 @@
|
|||||||
sidebarToggle.setAttribute("aria-expanded", "false");
|
sidebarToggle.setAttribute("aria-expanded", "false");
|
||||||
sidebarToggle.title = "展开左侧控制台";
|
sidebarToggle.title = "展开左侧控制台";
|
||||||
|
|
||||||
const footer = make("footer", "v2-sidebar-footer", `
|
const footer = make("footer", "v2-sidebar-footer v2-control-link-card", `
|
||||||
<span class="v2-live-dot"></span>
|
<span class="v2-live-dot"></span>
|
||||||
<div><strong>智舱控制链路</strong><small id="v2ConnectionText">正在连接服务</small></div>
|
<div><strong>智舱控制链路</strong><small id="v2ConnectionText" aria-live="polite">正在连接服务</small></div>
|
||||||
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
||||||
|
|
||||||
commandPanel.append(sidebarToggle, footer);
|
commandPanel.append(sidebarToggle, footer);
|
||||||
@@ -1675,6 +1680,11 @@
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
const filter = $("#v2LogFilter")?.value || "all";
|
const filter = $("#v2LogFilter")?.value || "all";
|
||||||
const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter);
|
const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter);
|
||||||
|
const count = $("#v2SyncLogCount");
|
||||||
|
if (count) {
|
||||||
|
count.textContent = String(items.length);
|
||||||
|
count.title = `${items.length} 条记录`;
|
||||||
|
}
|
||||||
const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" };
|
const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" };
|
||||||
list.innerHTML = items.length ? items.map((item) => `
|
list.innerHTML = items.length ? items.map((item) => `
|
||||||
<li data-log-status="${escapeHtml(item.status)}">
|
<li data-log-status="${escapeHtml(item.status)}">
|
||||||
@@ -1685,6 +1695,21 @@
|
|||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSyncLogCollapsed(collapsed) {
|
||||||
|
const panel = $(".v2-sync-log");
|
||||||
|
const list = $("#v2SyncLogList");
|
||||||
|
const toggle = $("#v2SyncLogToggle");
|
||||||
|
if (!panel || !list || !toggle) return;
|
||||||
|
const nextCollapsed = Boolean(collapsed);
|
||||||
|
const label = nextCollapsed ? "展开运行日志与同步记录" : "折叠运行日志与同步记录";
|
||||||
|
panel.dataset.collapsed = String(nextCollapsed);
|
||||||
|
list.hidden = nextCollapsed;
|
||||||
|
toggle.setAttribute("aria-expanded", String(!nextCollapsed));
|
||||||
|
toggle.setAttribute("aria-label", label);
|
||||||
|
toggle.title = label;
|
||||||
|
requestAnimationFrame(syncSidebarLogBaseline);
|
||||||
|
}
|
||||||
|
|
||||||
function panelVisible(key) {
|
function panelVisible(key) {
|
||||||
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
||||||
if (panel) return !panel.classList.contains("is-collapsed");
|
if (panel) return !panel.classList.contains("is-collapsed");
|
||||||
@@ -2439,9 +2464,15 @@
|
|||||||
const sidebarFooter = $(".v2-sidebar-footer", sidebar);
|
const sidebarFooter = $(".v2-sidebar-footer", sidebar);
|
||||||
const syncSidebar = make("section", "v2-sync-sidebar", `
|
const syncSidebar = make("section", "v2-sync-sidebar", `
|
||||||
<div class="v2-sync-sidebar-overview"></div>
|
<div class="v2-sync-sidebar-overview"></div>
|
||||||
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle">
|
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle" data-collapsed="true">
|
||||||
<header><div><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div><select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select></header>
|
<header>
|
||||||
<ol id="v2SyncLogList" aria-live="polite"></ol>
|
<div><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div>
|
||||||
|
<div class="v2-sync-log-actions">
|
||||||
|
<select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select>
|
||||||
|
<button type="button" class="v2-sync-log-toggle" id="v2SyncLogToggle" aria-controls="v2SyncLogList" aria-expanded="false" aria-label="展开运行日志与同步记录" title="展开运行日志与同步记录"><span id="v2SyncLogCount">0</span><i data-lucide="chevron-down"></i></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ol id="v2SyncLogList" aria-live="polite" hidden></ol>
|
||||||
</section>`);
|
</section>`);
|
||||||
if (sidebar && sidebarFooter) {
|
if (sidebar && sidebarFooter) {
|
||||||
sidebar.insertBefore(syncSidebar, sidebarFooter);
|
sidebar.insertBefore(syncSidebar, sidebarFooter);
|
||||||
@@ -2574,6 +2605,10 @@
|
|||||||
if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target);
|
if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target);
|
||||||
});
|
});
|
||||||
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
|
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
|
||||||
|
$("#v2SyncLogToggle")?.addEventListener("click", () => {
|
||||||
|
setSyncLogCollapsed($(".v2-sync-log")?.dataset.collapsed !== "true");
|
||||||
|
});
|
||||||
|
setSyncLogCollapsed(true);
|
||||||
const beginPendingControlRequest = (request = {}) => {
|
const beginPendingControlRequest = (request = {}) => {
|
||||||
beginCommandTiming(request);
|
beginCommandTiming(request);
|
||||||
resetValidatedCommandContext();
|
resetValidatedCommandContext();
|
||||||
@@ -2879,18 +2914,38 @@
|
|||||||
const connectionText = $("#v2ConnectionText");
|
const connectionText = $("#v2ConnectionText");
|
||||||
if (connectionText) {
|
if (connectionText) {
|
||||||
const status = connectionState?.status || "connecting";
|
const status = connectionState?.status || "connecting";
|
||||||
const labels = {
|
const selectedTransport = $('.vehicle-transport-options [data-control-transport][aria-pressed="true"]')
|
||||||
connecting: "正在连接服务",
|
|| $('.vehicle-transport-options [data-control-transport].active');
|
||||||
online: "服务已连接",
|
const transport = selectedTransport?.dataset.controlTransport || "";
|
||||||
degraded: "连接不稳定",
|
const transportOnline = selectedTransport?.dataset.connectionState === "online";
|
||||||
offline: "服务已断开",
|
const transportLabel = transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN";
|
||||||
};
|
const footer = connectionText.closest(".v2-control-link-card");
|
||||||
connectionText.textContent = labels[status] || labels.connecting;
|
let presentationState = status;
|
||||||
connectionText.title = connectionState?.lastError || connectionText.textContent;
|
let label = "正在连接服务";
|
||||||
$(".v2-sidebar-footer")?.classList.toggle(
|
|
||||||
"offline",
|
if (status === "online" && transport) {
|
||||||
status === "offline" || status === "degraded",
|
presentationState = transportOnline ? "online" : "offline";
|
||||||
);
|
label = transport === "wireless_mqtt"
|
||||||
|
? `${transportLabel} · MQTT ${transportOnline ? "已连接" : "未连接"}`
|
||||||
|
: `${transportLabel} · 信号链路${transportOnline ? "正常" : "断开"}`;
|
||||||
|
} else if (status === "online") {
|
||||||
|
presentationState = "online";
|
||||||
|
label = "服务已连接";
|
||||||
|
} else if (status === "degraded") {
|
||||||
|
label = transport ? `${transportLabel} · 主服务连接不稳定` : "主服务连接不稳定";
|
||||||
|
} else if (status === "offline") {
|
||||||
|
label = transport ? `${transportLabel} · 主服务已断开` : "主服务已断开";
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionText.textContent = label;
|
||||||
|
connectionText.title = connectionState?.lastError
|
||||||
|
|| (!transportOnline ? selectedTransport?.title : "")
|
||||||
|
|| label;
|
||||||
|
if (footer) {
|
||||||
|
footer.dataset.transport = transport || "service";
|
||||||
|
footer.dataset.state = presentationState;
|
||||||
|
footer.classList.toggle("offline", presentationState !== "online");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
keepRunButtonAvailable();
|
keepRunButtonAvailable();
|
||||||
}
|
}
|
||||||
@@ -2927,6 +2982,7 @@
|
|||||||
syncRuntimePresentation(event.detail);
|
syncRuntimePresentation(event.detail);
|
||||||
});
|
});
|
||||||
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
|
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
|
||||||
|
syncRuntimePresentation();
|
||||||
renderSyncedDevices();
|
renderSyncedDevices();
|
||||||
if (displaySync.activeDevice) {
|
if (displaySync.activeDevice) {
|
||||||
presentTwinDevice(displaySync.activeDevice, {
|
presentTwinDevice(displaySync.activeDevice, {
|
||||||
@@ -2935,6 +2991,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const transportOptions = $(".vehicle-transport-options");
|
||||||
|
if (transportOptions) {
|
||||||
|
new MutationObserver(() => syncRuntimePresentation()).observe(transportOptions, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"],
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const observer = new MutationObserver(() => keepRunButtonAvailable());
|
const observer = new MutationObserver(() => keepRunButtonAvailable());
|
||||||
[$("#hardwareMode"), $("#simulationMode")]
|
[$("#hardwareMode"), $("#simulationMode")]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -1224,7 +1224,9 @@
|
|||||||
}
|
}
|
||||||
showGscuResult(result);
|
showGscuResult(result);
|
||||||
showToast(
|
showToast(
|
||||||
action === "learn" ? `IR ${slot} 学习完成,槽位回读已确认。` : `${action} 指令处理完成。`,
|
action === "learn"
|
||||||
|
? `IR ${slot} 学习完成并已加入自然语言解析;实际下发仍需 NFC 授权。`
|
||||||
|
: `${action} 指令处理完成。`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1275,7 +1277,10 @@
|
|||||||
$("[data-gscu-action-names]", card).value,
|
$("[data-gscu-action-names]", card).value,
|
||||||
"tcp",
|
"tcp",
|
||||||
).then((value) => {
|
).then((value) => {
|
||||||
showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success");
|
showToast(
|
||||||
|
`IR ${value.slot} 已保存并立即加入自然语言解析;实际下发仍需 NFC 授权。`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
showToast(error.message, "error");
|
showToast(error.message, "error");
|
||||||
}).finally(() => { save.disabled = false; });
|
}).finally(() => { save.disabled = false; });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite";
|
const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ body.console-v2 :is(
|
|||||||
.v2-sync-transport-field,
|
.v2-sync-transport-field,
|
||||||
.v2-sync-execution-field,
|
.v2-sync-execution-field,
|
||||||
.v2-can-command-panel,
|
.v2-can-command-panel,
|
||||||
|
.v2-control-link-card,
|
||||||
.v2-nfc-inline,
|
.v2-nfc-inline,
|
||||||
.v2-extension-gateway,
|
.v2-extension-gateway,
|
||||||
.v2-extension-synced-device,
|
.v2-extension-synced-device,
|
||||||
@@ -132,6 +133,119 @@ body.console-v2 .v2-sync-log h3 {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar disclosure, connection status and control-command readability use
|
||||||
|
* the same semantic tokens in both day and night themes. */
|
||||||
|
body.console-v2 .v2-sync-log[data-collapsed="true"] {
|
||||||
|
min-height: 0;
|
||||||
|
align-self: start;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log[data-collapsed="true"] > header {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log[data-collapsed="true"] #v2LogFilter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log #v2SyncLogList[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log > header > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log .v2-sync-log-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log .v2-sync-log-toggle {
|
||||||
|
min-width: 46px;
|
||||||
|
min-height: var(--n7-control-height-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 7px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--secondary);
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: var(--n7-radius-control);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log .v2-sync-log-toggle:is(:hover, :focus-visible) {
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log .v2-sync-log-toggle svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-sync-log .v2-sync-log-toggle[aria-expanded="true"] svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2.v2-passive-display.v2-interactive-sync .v2-control-link-card {
|
||||||
|
min-height: 56px;
|
||||||
|
grid-template-columns: 10px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 10px 11px !important;
|
||||||
|
color: var(--card-foreground);
|
||||||
|
background: var(--glass-card) !important;
|
||||||
|
border: 1px solid var(--glass-line) !important;
|
||||||
|
border-radius: var(--n7-radius-card) !important;
|
||||||
|
box-shadow: var(--n7-shadow-card) !important;
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card strong {
|
||||||
|
color: var(--card-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card small {
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card[data-state="online"] .v2-live-dot {
|
||||||
|
background: var(--status-online);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-online) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card[data-state="connecting"] .v2-live-dot {
|
||||||
|
background: var(--status-info);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-info) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card[data-state="degraded"] .v2-live-dot {
|
||||||
|
background: var(--status-warning);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-warning) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-control-link-card[data-state="offline"] .v2-live-dot {
|
||||||
|
background: var(--status-offline);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-offline) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2.v2-passive-display.v2-interactive-sync
|
||||||
|
.v2-can-command-list li[data-can-channel]
|
||||||
|
.v2-can-command-actual code {
|
||||||
|
color: var(--card-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
body.console-v2 :is(
|
body.console-v2 :is(
|
||||||
.mode-gallery-header h2,
|
.mode-gallery-header h2,
|
||||||
.mode-editor-header h2,
|
.mode-editor-header h2,
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue";
|
|||||||
open: state.open,
|
open: state.open,
|
||||||
onToggle(event) {
|
onToggle(event) {
|
||||||
state.open = event.currentTarget.open;
|
state.open = event.currentTarget.open;
|
||||||
|
if (!state.open) return;
|
||||||
|
const transcript = document.querySelector("#assistantMessages");
|
||||||
|
transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((card) => {
|
||||||
|
if (card !== event.currentTarget) card.open = false;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}, [
|
}, [
|
||||||
h("summary", null, [
|
h("summary", null, [
|
||||||
|
|||||||
@@ -380,6 +380,86 @@ class InfraredSlotCatalog:
|
|||||||
capabilities.append(sanitized)
|
capabilities.append(sanitized)
|
||||||
return capabilities
|
return capabilities
|
||||||
|
|
||||||
|
def list_routing_capabilities(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return every named IR slot, annotated with execution authorization.
|
||||||
|
|
||||||
|
The slot catalog is the source of truth for natural-language discovery,
|
||||||
|
while ``infrared_capabilities`` remains the source of truth for physical
|
||||||
|
execution authorization. Keeping those concerns separate lets a newly
|
||||||
|
learned device become parseable immediately without allowing an
|
||||||
|
unbound NFC device to emit infrared commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slots = self.list_slots()
|
||||||
|
authorized = self.list_capabilities()
|
||||||
|
authorized_by_identity: dict[tuple[str, int], dict[str, Any]] = {}
|
||||||
|
for item in authorized:
|
||||||
|
try:
|
||||||
|
identity = (
|
||||||
|
str(item.get("device_id") or "").strip(),
|
||||||
|
validate_slot(item.get("slot")),
|
||||||
|
)
|
||||||
|
except GatewayError:
|
||||||
|
continue
|
||||||
|
if identity[0]:
|
||||||
|
authorized_by_identity.setdefault(identity, item)
|
||||||
|
|
||||||
|
routing: list[dict[str, Any]] = []
|
||||||
|
for slot_record in slots:
|
||||||
|
device_id = str(slot_record.get("device_id") or "").strip()
|
||||||
|
object_names = _safe_peripheral_identity_names(
|
||||||
|
slot_record.get("object_names")
|
||||||
|
)
|
||||||
|
action_names = [
|
||||||
|
str(value or "").strip()
|
||||||
|
for value in slot_record.get("action_names", [])
|
||||||
|
if str(value or "").strip()
|
||||||
|
][:24]
|
||||||
|
if (
|
||||||
|
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}", device_id)
|
||||||
|
or not object_names
|
||||||
|
or not action_names
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
slot = validate_slot(slot_record.get("slot"))
|
||||||
|
persisted = authorized_by_identity.get((device_id, slot))
|
||||||
|
item = dict(persisted or {})
|
||||||
|
card_uid = _normalized_card_uid(item.get("card_uid"))
|
||||||
|
enabled = item.get("enabled", True) is not False
|
||||||
|
execution_authorized = bool(card_uid and enabled)
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"id": str(item.get("id") or f"ir-slot:{device_id}:{slot}"),
|
||||||
|
"status": "learned",
|
||||||
|
"enabled": enabled if card_uid else False,
|
||||||
|
"parseable": True,
|
||||||
|
"execution_authorized": execution_authorized,
|
||||||
|
"authorization_state": (
|
||||||
|
"authorized"
|
||||||
|
if execution_authorized
|
||||||
|
else "disabled"
|
||||||
|
if card_uid
|
||||||
|
else "nfc_binding_required"
|
||||||
|
),
|
||||||
|
"device_id": device_id,
|
||||||
|
"device_name": object_names[0],
|
||||||
|
"device_aliases": object_names,
|
||||||
|
"object_names": object_names,
|
||||||
|
"device_type": str(item.get("device_type") or "other")[:48],
|
||||||
|
"card_uid": card_uid or None,
|
||||||
|
"action": action_names[0],
|
||||||
|
"aliases": action_names,
|
||||||
|
"action_names": action_names,
|
||||||
|
"risk": str(item.get("risk") or "low"),
|
||||||
|
"slot": slot,
|
||||||
|
"transport": str(item.get("transport") or "gscu_tcp")[:32],
|
||||||
|
"routing_source": "infrared_slot_catalog",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
routing.append(item)
|
||||||
|
return routing
|
||||||
|
|
||||||
def sync_capabilities(
|
def sync_capabilities(
|
||||||
self,
|
self,
|
||||||
catalog: object,
|
catalog: object,
|
||||||
@@ -1244,6 +1324,28 @@ def resolve_active_peripheral_command(
|
|||||||
catalog,
|
catalog,
|
||||||
nfc_snapshot,
|
nfc_snapshot,
|
||||||
require_enabled=True,
|
require_enabled=True,
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_parseable_peripheral_command(
|
||||||
|
utterance: str,
|
||||||
|
catalog: list[dict[str, Any]],
|
||||||
|
nfc_snapshot: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Resolve a named learned IR action without granting execution rights.
|
||||||
|
|
||||||
|
This is intentionally a parsing-only operation. Callers must inspect the
|
||||||
|
returned capability's persisted ``card_uid`` and ``enabled`` state before
|
||||||
|
allowing any physical infrared emit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _resolve_bound_peripheral_command(
|
||||||
|
utterance,
|
||||||
|
catalog,
|
||||||
|
nfc_snapshot,
|
||||||
|
require_enabled=False,
|
||||||
|
require_binding=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1253,6 +1355,7 @@ def _resolve_bound_peripheral_command(
|
|||||||
nfc_snapshot: dict[str, Any],
|
nfc_snapshot: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
require_enabled: bool,
|
require_enabled: bool,
|
||||||
|
require_binding: bool = True,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Resolve a persistently card-bound command, including disabled entries.
|
"""Resolve a persistently card-bound command, including disabled entries.
|
||||||
|
|
||||||
@@ -1279,7 +1382,7 @@ def _resolve_bound_peripheral_command(
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
|
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
|
||||||
if not bound_uid:
|
if require_binding and not bound_uid:
|
||||||
continue
|
continue
|
||||||
device_score = _device_match_score(compact_utterance, raw_item)
|
device_score = _device_match_score(compact_utterance, raw_item)
|
||||||
if not device_score:
|
if not device_score:
|
||||||
@@ -1310,6 +1413,17 @@ def _resolve_bound_peripheral_command(
|
|||||||
resolved = dict(matches[0][2])
|
resolved = dict(matches[0][2])
|
||||||
resolved["slot"] = validate_slot(resolved.get("slot"))
|
resolved["slot"] = validate_slot(resolved.get("slot"))
|
||||||
resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid"))
|
resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid"))
|
||||||
|
resolved["parseable"] = True
|
||||||
|
resolved["execution_authorized"] = bool(
|
||||||
|
resolved["card_uid"] and resolved.get("enabled", True) is not False
|
||||||
|
)
|
||||||
|
resolved["authorization_state"] = (
|
||||||
|
"authorized"
|
||||||
|
if resolved["execution_authorized"]
|
||||||
|
else "disabled"
|
||||||
|
if resolved["card_uid"]
|
||||||
|
else "nfc_binding_required"
|
||||||
|
)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
@@ -1326,10 +1440,12 @@ def partition_active_peripheral_intent(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Claim only explicit learned-peripheral clauses and preserve CAN text.
|
"""Claim only explicit learned-peripheral clauses and preserve CAN text.
|
||||||
|
|
||||||
The function is deliberately conservative: a clause must contain both a
|
The function is deliberately conservative: a clause must contain both an
|
||||||
persistently NFC-bound device identity and one learned action. When no peripheral
|
explicit learned device identity and one learned action. A matching but
|
||||||
clause is claimed, ``residual_utterance`` is byte-for-byte the original
|
unbound device is returned as an authorization denial, never as residual
|
||||||
trimmed input so the existing CAN parser observes exactly its old input.
|
vehicle text. When no peripheral clause is claimed, ``residual_utterance``
|
||||||
|
is byte-for-byte the original trimmed input so the existing CAN parser
|
||||||
|
observes exactly its old input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
original = str(utterance or "").strip()
|
original = str(utterance or "").strip()
|
||||||
@@ -1342,12 +1458,16 @@ def partition_active_peripheral_intent(
|
|||||||
"denied_peripheral_actions": [],
|
"denied_peripheral_actions": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
bound_catalog = [
|
routing_catalog = [
|
||||||
item
|
item
|
||||||
for item in catalog[:MAX_CATALOG_ITEMS]
|
for item in catalog[:MAX_CATALOG_ITEMS]
|
||||||
if isinstance(item, dict)
|
if isinstance(item, dict)
|
||||||
and item.get("status") == "learned"
|
and item.get("status") == "learned"
|
||||||
and bool(_normalized_card_uid(item.get("card_uid")))
|
]
|
||||||
|
bound_catalog = [
|
||||||
|
item
|
||||||
|
for item in routing_catalog
|
||||||
|
if bool(_normalized_card_uid(item.get("card_uid")))
|
||||||
]
|
]
|
||||||
active_catalog = [
|
active_catalog = [
|
||||||
item
|
item
|
||||||
@@ -1355,8 +1475,8 @@ def partition_active_peripheral_intent(
|
|||||||
if item.get("enabled", True) is not False
|
if item.get("enabled", True) is not False
|
||||||
]
|
]
|
||||||
compact_original = _compact_match_text(original)
|
compact_original = _compact_match_text(original)
|
||||||
if not bound_catalog or not any(
|
if not routing_catalog or not any(
|
||||||
_device_match_score(compact_original, item) for item in bound_catalog
|
_device_match_score(compact_original, item) for item in routing_catalog
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
"peripheral_actions": [],
|
"peripheral_actions": [],
|
||||||
@@ -1380,36 +1500,30 @@ def partition_active_peripheral_intent(
|
|||||||
contextual_device_name = ""
|
contextual_device_name = ""
|
||||||
for index, clause in enumerate(clauses):
|
for index, clause in enumerate(clauses):
|
||||||
compact_clause = _compact_match_text(clause)
|
compact_clause = _compact_match_text(clause)
|
||||||
explicit_bound_candidates = [
|
explicit_routing_candidates = [
|
||||||
item
|
item
|
||||||
for item in bound_catalog
|
for item in routing_catalog
|
||||||
if _device_match_score(compact_clause, item)
|
if _device_match_score(compact_clause, item)
|
||||||
]
|
]
|
||||||
explicit_candidates = [
|
if explicit_routing_candidates and _has_unshadowed_vehicle_device_phrase(
|
||||||
item
|
|
||||||
for item in explicit_bound_candidates
|
|
||||||
if item.get("enabled", True) is not False
|
|
||||||
]
|
|
||||||
if explicit_bound_candidates and _has_unshadowed_vehicle_device_phrase(
|
|
||||||
compact_clause,
|
compact_clause,
|
||||||
explicit_bound_candidates,
|
explicit_routing_candidates,
|
||||||
):
|
):
|
||||||
residual.append(clause)
|
residual.append(clause)
|
||||||
contextual_device_id = None
|
contextual_device_id = None
|
||||||
contextual_device_name = ""
|
contextual_device_name = ""
|
||||||
continue
|
continue
|
||||||
clause_candidates = explicit_candidates
|
clause_candidates = explicit_routing_candidates
|
||||||
bound_clause_candidates = explicit_bound_candidates
|
|
||||||
resolution_text = clause
|
resolution_text = clause
|
||||||
if explicit_bound_candidates:
|
if explicit_routing_candidates:
|
||||||
candidate_device_ids = {
|
candidate_device_ids = {
|
||||||
str(item.get("device_id") or "")
|
str(item.get("device_id") or "")
|
||||||
for item in explicit_bound_candidates
|
for item in explicit_routing_candidates
|
||||||
}
|
}
|
||||||
if len(candidate_device_ids) == 1:
|
if len(candidate_device_ids) == 1:
|
||||||
contextual_device_id = next(iter(candidate_device_ids))
|
contextual_device_id = next(iter(candidate_device_ids))
|
||||||
identity_names: list[str] = []
|
identity_names: list[str] = []
|
||||||
for item in explicit_bound_candidates:
|
for item in explicit_routing_candidates:
|
||||||
for field in ("object_names", "device_aliases"):
|
for field in ("object_names", "device_aliases"):
|
||||||
values = item.get(field, [])
|
values = item.get(field, [])
|
||||||
if isinstance(values, str):
|
if isinstance(values, str):
|
||||||
@@ -1454,56 +1568,51 @@ def partition_active_peripheral_intent(
|
|||||||
):
|
):
|
||||||
clause_candidates = [
|
clause_candidates = [
|
||||||
item
|
item
|
||||||
for item in active_catalog
|
for item in routing_catalog
|
||||||
if str(item.get("device_id") or "") == contextual_device_id
|
|
||||||
]
|
|
||||||
bound_clause_candidates = [
|
|
||||||
item
|
|
||||||
for item in bound_catalog
|
|
||||||
if str(item.get("device_id") or "") == contextual_device_id
|
if str(item.get("device_id") or "") == contextual_device_id
|
||||||
]
|
]
|
||||||
resolution_text = f"{contextual_device_name},{clause}"
|
resolution_text = f"{contextual_device_name},{clause}"
|
||||||
if not clause_candidates and not bound_clause_candidates:
|
if not clause_candidates:
|
||||||
residual.append(clause)
|
residual.append(clause)
|
||||||
continue
|
continue
|
||||||
active_resolution_catalog = (
|
routing_resolution_catalog = (
|
||||||
clause_candidates if not explicit_candidates else active_catalog
|
clause_candidates if not explicit_routing_candidates else routing_catalog
|
||||||
)
|
)
|
||||||
resolved = resolve_active_peripheral_command(
|
resolved = resolve_parseable_peripheral_command(
|
||||||
resolution_text, active_resolution_catalog, nfc_snapshot
|
resolution_text, routing_resolution_catalog, nfc_snapshot
|
||||||
)
|
)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
bound_resolution_catalog = (
|
|
||||||
bound_clause_candidates
|
|
||||||
if not explicit_bound_candidates
|
|
||||||
else bound_catalog
|
|
||||||
)
|
|
||||||
bound_match = _resolve_bound_peripheral_command(
|
|
||||||
resolution_text,
|
|
||||||
bound_resolution_catalog,
|
|
||||||
nfc_snapshot,
|
|
||||||
require_enabled=False,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
bound_match is not None
|
|
||||||
and bound_match.get("enabled", True) is False
|
|
||||||
):
|
|
||||||
denied.append(
|
|
||||||
{
|
|
||||||
"index": index,
|
|
||||||
"clause": clause,
|
|
||||||
"capability": bound_match,
|
|
||||||
"reason": "nfc_command_disabled",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
# Explicit third-party device text must never fall through into
|
# Explicit third-party device text must never fall through into
|
||||||
# the vehicle CAN model as if it were a cabin actuator request.
|
# the vehicle CAN model as if it were a cabin actuator request.
|
||||||
unresolved.append(clause)
|
unresolved.append(clause)
|
||||||
|
authorized_device_ids = {
|
||||||
|
str(item.get("device_id") or "") for item in clause_candidates
|
||||||
|
}
|
||||||
unresolved_candidates.update(
|
unresolved_candidates.update(
|
||||||
(str(item.get("id")), item)
|
(str(item.get("id")), item)
|
||||||
for item in clause_candidates
|
for item in active_catalog
|
||||||
if item.get("id")
|
if item.get("id")
|
||||||
|
and str(item.get("device_id") or "") in authorized_device_ids
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not _normalized_card_uid(resolved.get("card_uid")):
|
||||||
|
denied.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"clause": clause,
|
||||||
|
"capability": resolved,
|
||||||
|
"reason": "nfc_binding_required",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if resolved.get("enabled", True) is False:
|
||||||
|
denied.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"clause": clause,
|
||||||
|
"capability": resolved,
|
||||||
|
"reason": "nfc_command_disabled",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
actions.append(
|
actions.append(
|
||||||
@@ -2275,6 +2384,11 @@ class SelfExtensionService:
|
|||||||
|
|
||||||
return self.infrared_catalog.list_capabilities()
|
return self.infrared_catalog.list_capabilities()
|
||||||
|
|
||||||
|
def list_peripheral_routing_capabilities(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return parseable IR actions with explicit authorization metadata."""
|
||||||
|
|
||||||
|
return self.infrared_catalog.list_routing_capabilities()
|
||||||
|
|
||||||
def sync_peripheral_catalog(self, catalog: object) -> dict[str, Any]:
|
def sync_peripheral_catalog(self, catalog: object) -> dict[str, Any]:
|
||||||
"""Persist the complete learned capability catalog from the NFC UI."""
|
"""Persist the complete learned capability catalog from the NFC UI."""
|
||||||
|
|
||||||
@@ -2441,6 +2555,13 @@ class SelfExtensionService:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute a persistently NFC-bound match through the main request API."""
|
"""Execute a persistently NFC-bound match through the main request API."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
capability.get("enabled", True) is False
|
||||||
|
or not _normalized_card_uid(capability.get("card_uid"))
|
||||||
|
):
|
||||||
|
raise ExtensionIntegrationError(
|
||||||
|
"红外能力尚未完成持久 NFC 授权,已拒绝物理下发。"
|
||||||
|
)
|
||||||
if not _peripheral_execution_identity_authorized(utterance, capability):
|
if not _peripheral_execution_identity_authorized(utterance, capability):
|
||||||
raise ExtensionIntegrationError(
|
raise ExtensionIntegrationError(
|
||||||
"外设名称未在原始指令中被明确提及,已拒绝红外执行。"
|
"外设名称未在原始指令中被明确提及,已拒绝红外执行。"
|
||||||
|
|||||||
+211
-21
@@ -733,7 +733,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
).strip()
|
).strip()
|
||||||
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
||||||
peripheral_transcript = bool(
|
peripheral_transcript = bool(
|
||||||
peripheral_routing.get("execution_eligible") and raw_text
|
peripheral_routing.get("routing_eligible") and raw_text
|
||||||
)
|
)
|
||||||
expected_text = raw_text if peripheral_transcript else clean_text
|
expected_text = raw_text if peripheral_transcript else clean_text
|
||||||
if utterance != expected_text:
|
if utterance != expected_text:
|
||||||
@@ -933,6 +933,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _peripheral_routing_catalog(self) -> list[dict[str, object]]:
|
||||||
|
"""Read the parse catalog while remaining compatible with test doubles."""
|
||||||
|
|
||||||
|
provider = getattr(
|
||||||
|
self.extension,
|
||||||
|
"list_peripheral_routing_capabilities",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if callable(provider):
|
||||||
|
return list(provider())
|
||||||
|
return list(self.extension.list_peripheral_capabilities())
|
||||||
|
|
||||||
def _execute_active_peripheral_request(
|
def _execute_active_peripheral_request(
|
||||||
self,
|
self,
|
||||||
utterance: str,
|
utterance: str,
|
||||||
@@ -1044,14 +1056,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
Vehicle speech keeps using its cached structured semantic payload. A
|
Vehicle speech keeps using its cached structured semantic payload. A
|
||||||
learned peripheral cannot be represented by the static vehicle
|
learned peripheral cannot be represented by the static vehicle
|
||||||
capability catalog, so it may instead reuse the exact server-owned ASR
|
capability catalog, so it may instead reuse the exact server-owned ASR
|
||||||
transcript only after the durable NFC binding and learned action resolve.
|
transcript once the learned device and action resolve. Execution
|
||||||
|
authorization is checked separately so an unbound device can be shown
|
||||||
|
as parsed-and-blocked without ever reaching the infrared transport.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text = str(utterance or "").strip()
|
text = str(utterance or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return {"execution_eligible": False, "action_count": 0}
|
return {"execution_eligible": False, "action_count": 0}
|
||||||
try:
|
try:
|
||||||
catalog = self.extension.list_peripheral_capabilities()
|
catalog = self._peripheral_routing_catalog()
|
||||||
nfc_snapshot = self.nfc.snapshot()
|
nfc_snapshot = self.nfc.snapshot()
|
||||||
partition = partition_active_peripheral_intent(
|
partition = partition_active_peripheral_intent(
|
||||||
text,
|
text,
|
||||||
@@ -1080,9 +1094,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
action_count = len(entries) or (1 if direct is not None else 0)
|
action_count = len(entries) or (1 if direct is not None else 0)
|
||||||
if plan_catalog:
|
if plan_catalog:
|
||||||
action_count = max(action_count, 1)
|
action_count = max(action_count, 1)
|
||||||
|
routing_eligible = bool(action_count or unresolved or denied)
|
||||||
return {
|
return {
|
||||||
"execution_eligible": bool(action_count and not unresolved and not denied),
|
"execution_eligible": bool(action_count and not unresolved and not denied),
|
||||||
|
"routing_eligible": routing_eligible,
|
||||||
"action_count": action_count,
|
"action_count": action_count,
|
||||||
|
"denied_action_count": len(denied),
|
||||||
"has_ordered_plan": bool(plan_catalog),
|
"has_ordered_plan": bool(plan_catalog),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,7 +1403,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
result: dict[str, object],
|
result: dict[str, object],
|
||||||
entries: list[dict[str, object]],
|
entries: list[dict[str, object]],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Expose disabled bound commands without discarding allowed work."""
|
"""Expose parsed-but-unauthorized IR commands without dispatching them."""
|
||||||
|
|
||||||
if not entries:
|
if not entries:
|
||||||
return result
|
return result
|
||||||
@@ -1410,25 +1427,169 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
result["denied_peripheral_actions"] = denied_actions
|
result["denied_peripheral_actions"] = denied_actions
|
||||||
result["authorization_filtered"] = True
|
result["authorization_filtered"] = True
|
||||||
|
blocked_operations: list[dict[str, object]] = []
|
||||||
|
blocked_commands: list[dict[str, object]] = []
|
||||||
|
blocked_results: list[dict[str, object]] = []
|
||||||
|
for position, item in enumerate(denied_actions, start=1):
|
||||||
|
reason = str(item.get("reason") or "nfc_command_disabled")
|
||||||
|
device_name = str(
|
||||||
|
item.get("device_name") or item.get("device_id") or "未命名外设"
|
||||||
|
)
|
||||||
|
action_name = str(item.get("action") or "未命名动作")
|
||||||
|
operation_ref = (
|
||||||
|
f"{item.get('capability_id') or 'infrared'}-authorization-{position}"
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
f"已识别“{device_name} · {action_name}”,但设备尚未完成 NFC "
|
||||||
|
"绑定授权,未下发。"
|
||||||
|
if reason == "nfc_binding_required"
|
||||||
|
else f"已识别“{device_name} · {action_name}”,但该 NFC 能力已被禁用,未下发。"
|
||||||
|
)
|
||||||
|
blocked_operations.append(
|
||||||
|
{
|
||||||
|
"id": operation_ref,
|
||||||
|
"capability": "infrared.emit",
|
||||||
|
"target": {
|
||||||
|
"device": item.get("device_id"),
|
||||||
|
"name": item.get("device_name"),
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"action": item.get("action"),
|
||||||
|
"slot": item.get("slot"),
|
||||||
|
},
|
||||||
|
"parse_source": "local_rule",
|
||||||
|
"authorization": {
|
||||||
|
"authorized": False,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
blocked_commands.append(
|
||||||
|
{
|
||||||
|
"operation_ref": operation_ref,
|
||||||
|
"bus": "GSCU_TCP",
|
||||||
|
"message": "infrared.emit",
|
||||||
|
"signals": {
|
||||||
|
"slot": item.get("slot"),
|
||||||
|
"authorization_status": "blocked",
|
||||||
|
"authorization_reason": reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
blocked_results.append(
|
||||||
|
{
|
||||||
|
"ref": operation_ref,
|
||||||
|
"domain": "self_extension",
|
||||||
|
"status": "blocked",
|
||||||
|
"message": message,
|
||||||
|
"telemetry": {
|
||||||
|
"transport": "gscu_tcp",
|
||||||
|
"slot": item.get("slot"),
|
||||||
|
"delivery_status": "not_submitted",
|
||||||
|
"transport_submitted": False,
|
||||||
|
"gateway_acknowledged": False,
|
||||||
|
"physical_effect_confirmed": False,
|
||||||
|
"verification_status": "failed_or_blocked",
|
||||||
|
"authorization_reason": reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function_configs = deepcopy(result.get("function_configs") or {})
|
||||||
|
command_plans = deepcopy(result.get("command_plans") or {})
|
||||||
|
config_key = "self_extension_blocked"
|
||||||
|
suffix = 2
|
||||||
|
while config_key in function_configs or config_key in command_plans:
|
||||||
|
config_key = f"self_extension_blocked_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
blocked_config = {
|
||||||
|
"apiVersion": "cognitive-agent.io/v1alpha1",
|
||||||
|
"kind": "FunctionConfig",
|
||||||
|
"metadata": {
|
||||||
|
"name": config_key,
|
||||||
|
"labels": {
|
||||||
|
"execution_scope": "nfc_authorization_required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"domain": "self_extension",
|
||||||
|
"operations": blocked_operations,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
blocked_plan = {
|
||||||
|
"apiVersion": "cognitive-agent.io/v1alpha1",
|
||||||
|
"kind": "CommandPlan",
|
||||||
|
"metadata": {
|
||||||
|
"name": f"{config_key}-gscu",
|
||||||
|
"labels": {"authorization_status": "blocked"},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"domain": "self_extension",
|
||||||
|
"platform": "gscu1b",
|
||||||
|
"commands": blocked_commands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
function_configs[config_key] = blocked_config
|
||||||
|
command_plans[config_key] = blocked_plan
|
||||||
|
result["function_configs"] = function_configs
|
||||||
|
result["command_plans"] = command_plans
|
||||||
|
if not isinstance(result.get("function_config"), dict):
|
||||||
|
result["function_config"] = blocked_config
|
||||||
|
if not isinstance(result.get("command_plan"), dict):
|
||||||
|
result["command_plan"] = blocked_plan
|
||||||
|
result["operation_results"] = [
|
||||||
|
*deepcopy(list(result.get("operation_results") or [])),
|
||||||
|
*blocked_results,
|
||||||
|
]
|
||||||
|
result["trace_events"] = [
|
||||||
|
*deepcopy(list(result.get("trace_events") or [])),
|
||||||
|
{
|
||||||
|
"stage": "nfc_peripheral_authorization",
|
||||||
|
"status": "blocked",
|
||||||
|
"summary": "红外指令已由本地规则解析,但未通过 NFC 执行授权。",
|
||||||
|
"duration_ms": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
summaries = list(result.get("decision_summary") or [])
|
summaries = list(result.get("decision_summary") or [])
|
||||||
labels = "、".join(
|
binding_required = [
|
||||||
str(item.get("clause") or item.get("action") or "未命名动作")
|
item
|
||||||
for item in denied_actions
|
for item in denied_actions
|
||||||
)
|
if item.get("reason") == "nfc_binding_required"
|
||||||
summaries.append(
|
]
|
||||||
f"持久绑定中有 {len(denied_actions)} 条红外能力未启用,已过滤:{labels}。"
|
disabled = [
|
||||||
)
|
item
|
||||||
|
for item in denied_actions
|
||||||
|
if item.get("reason") != "nfc_binding_required"
|
||||||
|
]
|
||||||
|
if binding_required:
|
||||||
|
labels = "、".join(
|
||||||
|
str(item.get("clause") or item.get("action") or "未命名动作")
|
||||||
|
for item in binding_required
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
f"已解析 {len(binding_required)} 条红外控制指令,但设备尚未完成 NFC "
|
||||||
|
f"绑定授权,未下发:{labels}。"
|
||||||
|
)
|
||||||
|
if disabled:
|
||||||
|
labels = "、".join(
|
||||||
|
str(item.get("clause") or item.get("action") or "未命名动作")
|
||||||
|
for item in disabled
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
f"持久绑定中有 {len(disabled)} 条红外能力未启用,已过滤:{labels}。"
|
||||||
|
)
|
||||||
result["decision_summary"] = summaries
|
result["decision_summary"] = summaries
|
||||||
|
|
||||||
prior_status = str(result.get("status") or "failed")
|
prior_status = str(result.get("status") or "failed")
|
||||||
if prior_status in {"succeeded", "partially_succeeded", "no_action"}:
|
if prior_status in {"succeeded", "partially_succeeded", "no_action"}:
|
||||||
result["status"] = "partially_succeeded"
|
result["status"] = "partially_succeeded"
|
||||||
result["success"] = False
|
result["success"] = False
|
||||||
|
result["outcome"] = "failed_or_blocked"
|
||||||
execution_report = result.get("execution_report")
|
execution_report = result.get("execution_report")
|
||||||
if isinstance(execution_report, dict):
|
if isinstance(execution_report, dict):
|
||||||
spec = execution_report.get("spec")
|
spec = execution_report.get("spec")
|
||||||
if isinstance(spec, dict):
|
if isinstance(spec, dict):
|
||||||
spec["status"] = "partially_succeeded"
|
spec["status"] = str(result.get("status") or "blocked")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _execute_request_with_peripherals(
|
def _execute_request_with_peripherals(
|
||||||
@@ -1465,7 +1626,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
**mode_arguments,
|
**mode_arguments,
|
||||||
_on_commands_validated=callback,
|
_on_commands_validated=callback,
|
||||||
)
|
)
|
||||||
catalog = self.extension.list_peripheral_capabilities()
|
catalog = self._peripheral_routing_catalog()
|
||||||
nfc_snapshot = self.nfc.snapshot()
|
nfc_snapshot = self.nfc.snapshot()
|
||||||
partition = partition_active_peripheral_intent(
|
partition = partition_active_peripheral_intent(
|
||||||
utterance,
|
utterance,
|
||||||
@@ -1479,7 +1640,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
parsed_peripheral_clauses = list(
|
parsed_peripheral_clauses = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(entry.get("clause") or "").strip()
|
str(entry.get("clause") or "").strip()
|
||||||
for entry in entries
|
for entry in [*entries, *denied]
|
||||||
if isinstance(entry, dict) and str(entry.get("clause") or "").strip()
|
if isinstance(entry, dict) and str(entry.get("clause") or "").strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1829,14 +1990,43 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
"supplement_required": True,
|
"supplement_required": True,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
labels = "、".join(
|
local_elapsed_ms = round(
|
||||||
str(item.get("clause") or "未命名动作")
|
(time.perf_counter() - local_stage_started) * 1000,
|
||||||
for item in denied
|
3,
|
||||||
if isinstance(item, dict)
|
|
||||||
)
|
|
||||||
raise ExtensionIntegrationError(
|
|
||||||
f"以下持久绑定红外能力未启用,无法执行:{labels}。"
|
|
||||||
)
|
)
|
||||||
|
request_id = f"req-local-blocked-{os.urandom(8).hex()}"
|
||||||
|
result = {
|
||||||
|
"request_id": request_id,
|
||||||
|
"preview_request_id": request_id,
|
||||||
|
"utterance": utterance,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"status": "blocked",
|
||||||
|
"success": False,
|
||||||
|
"outcome": "failed_or_blocked",
|
||||||
|
"phase": "authorization_blocked",
|
||||||
|
"orchestration_route": "local_peripheral_authorization",
|
||||||
|
"provider_requested": provider,
|
||||||
|
"provider_used": "infrared_local_rules",
|
||||||
|
"execution_mode": execution_mode,
|
||||||
|
"control_transport": "gscu_tcp",
|
||||||
|
"decision_summary": [],
|
||||||
|
"function_config": None,
|
||||||
|
"function_configs": {},
|
||||||
|
"command_plan": None,
|
||||||
|
"command_plans": {},
|
||||||
|
"trace_events": [],
|
||||||
|
"validation": [],
|
||||||
|
"execution_report": None,
|
||||||
|
"operation_results": [],
|
||||||
|
"parsed_control_signatures": [],
|
||||||
|
"preview_elapsed_ms": local_elapsed_ms,
|
||||||
|
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||||||
|
"local_control_elapsed_ms": local_elapsed_ms,
|
||||||
|
"local_control_within_budget": (
|
||||||
|
local_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
|
||||||
|
),
|
||||||
|
"supplement_required": False,
|
||||||
|
}
|
||||||
|
|
||||||
result["natural_language_routing"] = deepcopy(natural_language_routing)
|
result["natural_language_routing"] = deepcopy(natural_language_routing)
|
||||||
for key in ("parsed_clauses", "resolved_clauses", "unresolved_clauses"):
|
for key in ("parsed_clauses", "resolved_clauses", "unresolved_clauses"):
|
||||||
@@ -3528,7 +3718,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
result = {**result, "input_mode": input_mode}
|
result = {**result, "input_mode": input_mode}
|
||||||
raw_text = str(result.get("raw_text") or "").strip()
|
raw_text = str(result.get("raw_text") or "").strip()
|
||||||
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
||||||
if peripheral_routing.get("execution_eligible"):
|
if peripheral_routing.get("routing_eligible"):
|
||||||
result = {
|
result = {
|
||||||
**result,
|
**result,
|
||||||
"routing_text": raw_text,
|
"routing_text": raw_text,
|
||||||
|
|||||||
@@ -1331,7 +1331,7 @@ operations:
|
|||||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"',
|
'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"',
|
||||||
service_worker,
|
service_worker,
|
||||||
)
|
)
|
||||||
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
||||||
@@ -2084,6 +2084,96 @@ operations:
|
|||||||
finally:
|
finally:
|
||||||
SeatDemoHandler.extension = original_extension
|
SeatDemoHandler.extension = original_extension
|
||||||
|
|
||||||
|
def test_new_tcp_slot_is_immediately_parsed_but_blocked_until_nfc_binding(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
original_extension = SeatDemoHandler.extension
|
||||||
|
original_nfc = SeatDemoHandler.nfc
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
extension = SelfExtensionService(
|
||||||
|
SimulationGateway(),
|
||||||
|
catalog_db_path=Path(directory, "unbound-cactus.sqlite3"),
|
||||||
|
)
|
||||||
|
SeatDemoHandler.extension = extension
|
||||||
|
SeatDemoHandler.nfc = SimpleNamespace(
|
||||||
|
snapshot=lambda: {
|
||||||
|
"revision": 1,
|
||||||
|
"reader": {"online": True},
|
||||||
|
"card": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
save_request = urllib.request.Request(
|
||||||
|
f"{self.base_url}/api/extension/gscu/slots/11",
|
||||||
|
method="PUT",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"device_id": "toy-cacti-002",
|
||||||
|
"object_names": ["仙人掌"],
|
||||||
|
"action_names": ["打开", "开启"],
|
||||||
|
"source": "tcp",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8"),
|
||||||
|
)
|
||||||
|
control_request = urllib.request.Request(
|
||||||
|
f"{self.base_url}/api/requests",
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/x-ndjson",
|
||||||
|
},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"utterance": "打开仙人掌",
|
||||||
|
"provider": "local",
|
||||||
|
"conversation_id": "conv-unbound-cactus-e2e",
|
||||||
|
"execution_mode": "hardware",
|
||||||
|
"client_type": "web_console",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self.opener.open(save_request, timeout=3) as response:
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
with patch.object(
|
||||||
|
extension,
|
||||||
|
"execute_gateway",
|
||||||
|
side_effect=AssertionError("unbound IR command must not be emitted"),
|
||||||
|
):
|
||||||
|
with self.opener.open(control_request, timeout=3) as response:
|
||||||
|
events = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in response.read().decode("utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
SeatDemoHandler.extension = original_extension
|
||||||
|
SeatDemoHandler.nfc = original_nfc
|
||||||
|
|
||||||
|
self.assertEqual(events[-1]["type"], "result")
|
||||||
|
result = events[-1]["payload"]
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertLess(result["local_control_elapsed_ms"], 1000)
|
||||||
|
self.assertEqual(result["parsed_clauses"], ["打开仙人掌"])
|
||||||
|
self.assertEqual(result["unresolved_clauses"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
result["denied_peripheral_actions"][0]["reason"],
|
||||||
|
"nfc_binding_required",
|
||||||
|
)
|
||||||
|
operation = next(
|
||||||
|
operation
|
||||||
|
for config in result["function_configs"].values()
|
||||||
|
for operation in config["spec"]["operations"]
|
||||||
|
)
|
||||||
|
self.assertEqual(operation["target"]["device"], "toy-cacti-002")
|
||||||
|
self.assertEqual(operation["parameters"], {"action": "打开", "slot": 11})
|
||||||
|
self.assertEqual(result["operation_results"][0]["status"], "blocked")
|
||||||
|
self.assertFalse(
|
||||||
|
result["operation_results"][0]["telemetry"]["transport_submitted"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None:
|
def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None:
|
||||||
original_extension = SeatDemoHandler.extension
|
original_extension = SeatDemoHandler.extension
|
||||||
original_nfc = SeatDemoHandler.nfc
|
original_nfc = SeatDemoHandler.nfc
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
|||||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||||
@@ -120,6 +120,32 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
|
|||||||
assert ".assistant-dispatched-controls[open]" in css
|
assert ".assistant-dispatched-controls[open]" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_nfc_authorization_block_is_rendered_as_safety_blocked():
|
||||||
|
console = (ROOT / "apps/web-console/console-v2.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'executionStatus === "blocked"' in console
|
||||||
|
assert "Boolean(telemetry.authorization_reason)" in console
|
||||||
|
assert 'deliveryState = aborted' in console
|
||||||
|
assert '? "blocked"' in console
|
||||||
|
assert '? "安全阻止"' in console
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_dispatched_control_group_is_the_only_group_expanded():
|
||||||
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
vue_ui = (ROOT / "apps/web-console/vue-ui.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function focusLatestDispatchedControls(container)" in assistant
|
||||||
|
assert 'transcript.querySelectorAll(".assistant-dispatched-controls")' in assistant
|
||||||
|
assert "card.open = card === activeCard" in assistant
|
||||||
|
assert "focusLatestDispatchedControls(host)" in assistant
|
||||||
|
assert "focusLatestDispatchedControls(card)" in assistant
|
||||||
|
assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in assistant
|
||||||
|
assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in vue_ui
|
||||||
|
assert "if (card !== event.currentTarget) card.open = false" in vue_ui
|
||||||
|
|
||||||
|
|
||||||
def test_saved_mode_execution_opens_with_pending_flow_before_http_completion():
|
def test_saved_mode_execution_opens_with_pending_flow_before_http_completion():
|
||||||
app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
|
app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
|
||||||
flow = app.split(
|
flow = app.split(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
|||||||
"/energy-monitor.js?v=energy-monitor-4"
|
"/energy-monitor.js?v=energy-monitor-4"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
|||||||
assert source_asset in main
|
assert source_asset in main
|
||||||
assert f'"{cached_asset}"' in service_worker
|
assert f'"{cached_asset}"' in service_worker
|
||||||
assert 'import "./styles/design-system.css"' in main
|
assert 'import "./styles/design-system.css"' in main
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||||
@@ -247,6 +247,22 @@ def test_command_fields_use_distinct_semantic_colors() -> None:
|
|||||||
assert mapping in console
|
assert mapping in console
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_command_and_transport_status_use_theme_semantics() -> None:
|
||||||
|
console = source("console-v2.js")
|
||||||
|
design_system = source("src/styles/design-system.css")
|
||||||
|
|
||||||
|
assert '"v2-sidebar-footer v2-control-link-card"' in console
|
||||||
|
assert 'transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"' in console
|
||||||
|
assert 'MQTT ${transportOnline ? "已连接" : "未连接"}' in console
|
||||||
|
assert 'footer.dataset.state = presentationState' in console
|
||||||
|
assert 'attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"]' in console
|
||||||
|
assert ".v2-control-link-card" in design_system
|
||||||
|
assert 'data-state="online"' in design_system
|
||||||
|
assert 'data-state="offline"' in design_system
|
||||||
|
assert ".v2-can-command-actual code" in design_system
|
||||||
|
assert "color: var(--card-foreground) !important" in design_system
|
||||||
|
|
||||||
|
|
||||||
def test_function_modes_use_an_inline_accessible_collapsible() -> None:
|
def test_function_modes_use_an_inline_accessible_collapsible() -> None:
|
||||||
html = source("index.html")
|
html = source("index.html")
|
||||||
app = source("app.js")
|
app = source("app.js")
|
||||||
|
|||||||
@@ -696,6 +696,84 @@ class SelfExtensionTests(unittest.TestCase):
|
|||||||
self.assertEqual(changed["device_id"], "toy-light-v2")
|
self.assertEqual(changed["device_id"], "toy-light-v2")
|
||||||
self.assertEqual(service.list_peripheral_capabilities(), [])
|
self.assertEqual(service.list_peripheral_capabilities(), [])
|
||||||
|
|
||||||
|
def test_new_structured_slot_is_parseable_before_nfc_authorization(self) -> None:
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
service = SelfExtensionService(
|
||||||
|
SimulationGateway(),
|
||||||
|
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
|
||||||
|
)
|
||||||
|
service.save_infrared_slot(
|
||||||
|
11,
|
||||||
|
{
|
||||||
|
"device_id": "toy-cacti-002",
|
||||||
|
"object_names": ["仙人掌"],
|
||||||
|
"action_names": ["打开", "开启"],
|
||||||
|
"source": "tcp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(service.list_peripheral_capabilities(), [])
|
||||||
|
routing = service.list_peripheral_routing_capabilities()
|
||||||
|
self.assertEqual(len(routing), 1)
|
||||||
|
self.assertEqual(routing[0]["id"], "ir-slot:toy-cacti-002:11")
|
||||||
|
self.assertEqual(routing[0]["authorization_state"], "nfc_binding_required")
|
||||||
|
self.assertTrue(routing[0]["parseable"])
|
||||||
|
self.assertFalse(routing[0]["execution_authorized"])
|
||||||
|
|
||||||
|
partition = partition_active_peripheral_intent(
|
||||||
|
"开启仙人掌",
|
||||||
|
routing,
|
||||||
|
{"card": None},
|
||||||
|
)
|
||||||
|
self.assertEqual(partition["peripheral_actions"], [])
|
||||||
|
self.assertEqual(partition["residual_utterance"], "")
|
||||||
|
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
item["capability"]["id"],
|
||||||
|
item["capability"]["slot"],
|
||||||
|
item["reason"],
|
||||||
|
)
|
||||||
|
for item in partition["denied_peripheral_actions"]
|
||||||
|
],
|
||||||
|
[("ir-slot:toy-cacti-002:11", 11, "nfc_binding_required")],
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_active_peripheral_command(
|
||||||
|
"开启仙人掌",
|
||||||
|
routing,
|
||||||
|
{"card": None},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.sync_peripheral_catalog(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
**routing[0],
|
||||||
|
"enabled": True,
|
||||||
|
"card_uid": "DBA5B203",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
authorized_routing = service.list_peripheral_routing_capabilities()
|
||||||
|
self.assertEqual(
|
||||||
|
authorized_routing[0]["authorization_state"], "authorized"
|
||||||
|
)
|
||||||
|
authorized = partition_active_peripheral_intent(
|
||||||
|
"开启仙人掌",
|
||||||
|
authorized_routing,
|
||||||
|
{"card": None},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["capability"]["slot"] for item in authorized["peripheral_actions"]],
|
||||||
|
[11],
|
||||||
|
)
|
||||||
|
self.assertEqual(authorized["denied_peripheral_actions"], [])
|
||||||
|
|
||||||
|
service.delete_infrared_slot(11)
|
||||||
|
self.assertEqual(service.list_peripheral_routing_capabilities(), [])
|
||||||
|
|
||||||
def test_structured_slot_rejects_invalid_or_missing_device_id(self) -> None:
|
def test_structured_slot_rejects_invalid_or_missing_device_id(self) -> None:
|
||||||
with TemporaryDirectory() as directory:
|
with TemporaryDirectory() as directory:
|
||||||
service = SelfExtensionService(
|
service = SelfExtensionService(
|
||||||
|
|||||||
@@ -133,6 +133,22 @@ def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None:
|
|||||||
assert "white-space: nowrap" in design_system
|
assert "white-space: nowrap" in design_system
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_log_starts_collapsed_with_an_accessible_disclosure() -> None:
|
||||||
|
console = source("console-v2.js")
|
||||||
|
design_system = source("src/styles/design-system.css")
|
||||||
|
|
||||||
|
assert 'class="v2-sync-log" aria-labelledby="v2SyncLogTitle" data-collapsed="true"' in console
|
||||||
|
assert 'id="v2SyncLogList" aria-live="polite" hidden' in console
|
||||||
|
assert 'id="v2SyncLogToggle"' in console
|
||||||
|
assert 'aria-controls="v2SyncLogList"' in console
|
||||||
|
assert 'aria-expanded="false"' in console
|
||||||
|
assert "function setSyncLogCollapsed(collapsed)" in console
|
||||||
|
assert "setSyncLogCollapsed(true)" in console
|
||||||
|
assert '.v2-sync-log[data-collapsed="true"]' in design_system
|
||||||
|
assert '.v2-sync-log[data-collapsed="true"] #v2LogFilter' in design_system
|
||||||
|
assert '#v2SyncLogList[hidden]' in design_system
|
||||||
|
|
||||||
|
|
||||||
def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
|
def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
|
||||||
styles = source("ui-consistency.css")
|
styles = source("ui-consistency.css")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from cognitive_agent.application.seat_service import SeatDemoService
|
from cognitive_agent.application.seat_service import SeatDemoService
|
||||||
|
from cognitive_agent.application.self_extension import (
|
||||||
|
SelfExtensionService,
|
||||||
|
SimulationGateway,
|
||||||
|
)
|
||||||
from cognitive_agent.server import SeatDemoHandler
|
from cognitive_agent.server import SeatDemoHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -591,6 +595,74 @@ def test_pure_unresolved_peripheral_returns_cloud_ready_local_preview(
|
|||||||
).is_file()
|
).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_unbound_infrared_device_is_parsed_but_never_dispatched(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
||||||
|
handler.service = _service(tmp_path)
|
||||||
|
handler.extension = SelfExtensionService(
|
||||||
|
SimulationGateway(),
|
||||||
|
catalog_db_path=tmp_path / "infrared-slots.sqlite3",
|
||||||
|
)
|
||||||
|
handler.extension.save_infrared_slot(
|
||||||
|
11,
|
||||||
|
{
|
||||||
|
"device_id": "toy-cacti-002",
|
||||||
|
"object_names": ["仙人掌"],
|
||||||
|
"action_names": ["打开", "开启"],
|
||||||
|
"source": "tcp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
handler.nfc = SimpleNamespace(snapshot=lambda: {"card": None})
|
||||||
|
validated_events: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
with patch.object(handler.extension, "execute_gateway") as execute_gateway:
|
||||||
|
result = handler._execute_request_with_peripherals(
|
||||||
|
"/api/requests",
|
||||||
|
{
|
||||||
|
"utterance": "打开仙人掌",
|
||||||
|
"provider": "local",
|
||||||
|
"conversation_id": "conv-unbound-cactus",
|
||||||
|
"execution_mode": "hardware",
|
||||||
|
},
|
||||||
|
on_commands_validated=validated_events.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_gateway.assert_not_called()
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "blocked"
|
||||||
|
assert result["phase"] == "authorization_blocked"
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["local_control_elapsed_ms"] < 1000
|
||||||
|
assert result["natural_language_routing"] == {
|
||||||
|
"original_utterance": "打开仙人掌",
|
||||||
|
"parsed_clauses": ["打开仙人掌"],
|
||||||
|
"resolved_clauses": ["打开仙人掌"],
|
||||||
|
"unresolved_clauses": [],
|
||||||
|
}
|
||||||
|
assert result["denied_peripheral_actions"][0]["reason"] == (
|
||||||
|
"nfc_binding_required"
|
||||||
|
)
|
||||||
|
operation = next(
|
||||||
|
operation
|
||||||
|
for config in result["function_configs"].values()
|
||||||
|
for operation in config["spec"]["operations"]
|
||||||
|
)
|
||||||
|
assert operation["capability"] == "infrared.emit"
|
||||||
|
assert operation["target"]["device"] == "toy-cacti-002"
|
||||||
|
assert operation["parameters"] == {"action": "打开", "slot": 11}
|
||||||
|
assert operation["authorization"] == {
|
||||||
|
"authorized": False,
|
||||||
|
"reason": "nfc_binding_required",
|
||||||
|
}
|
||||||
|
blocked = result["operation_results"][0]
|
||||||
|
assert blocked["status"] == "blocked"
|
||||||
|
assert blocked["telemetry"]["delivery_status"] == "not_submitted"
|
||||||
|
assert blocked["telemetry"]["transport_submitted"] is False
|
||||||
|
assert "尚未完成 NFC 绑定授权" in blocked["message"]
|
||||||
|
assert validated_events == [result]
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_request_finishes_all_local_phases_before_cloud_ir() -> None:
|
def test_mixed_request_finishes_all_local_phases_before_cloud_ir() -> None:
|
||||||
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
||||||
order: list[str] = []
|
order: list[str] = []
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
|||||||
assert package["scripts"]["build"] == "vite build"
|
assert package["scripts"]["build"] == "vite build"
|
||||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||||
assert 'outDir: outputRoot' in vite
|
assert 'outDir: outputRoot' in vite
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"' in service_worker
|
||||||
assert "unpkg.com" not in html
|
assert "unpkg.com" not in html
|
||||||
assert "cdn.jsdelivr.net" not in html
|
assert "cdn.jsdelivr.net" not in html
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user