feat(web): add peripheral card quick controls

This commit is contained in:
Miayui
2026-09-21 16:01:42 +08:00
parent ad0c1f4ed1
commit ba5dfcd6da
14 changed files with 530 additions and 19 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=local-first-79-cinema-status" defer></script>
<script src="/self-extension.js?v=nfc-session-58" defer></script>
<script src="/self-extension.js?v=peripheral-cards-59" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-39-video-media" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
<script src="/firmware-workbench-entry.js?v=3" defer></script>
+171
View File
@@ -2698,6 +2698,146 @@ form > .v2-extension-primary {
font-size: 7px;
}
.v2-extension-synced-device {
overflow: hidden;
padding: 0;
border: 1px solid rgba(107, 214, 203, 0.12);
transition: border-color 160ms ease, background 160ms ease;
}
.v2-extension-synced-device[data-expanded="true"] {
border-color: rgba(107, 214, 203, 0.3);
background: rgba(87, 191, 180, 0.075);
}
.v2-extension-synced-device > header {
display: flex;
align-items: center;
gap: 4px;
padding: 7px 8px;
}
.v2-extension-device-toggle {
min-width: 0;
flex: 1;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto 14px;
align-items: center;
gap: 8px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.v2-extension-device-toggle > span {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: #76cfc6;
background: rgba(87, 191, 180, 0.1);
border-radius: 8px;
}
.v2-extension-device-toggle > span svg,
.v2-extension-device-toggle > svg {
width: 14px;
height: 14px;
}
.v2-extension-device-toggle > div {
min-width: 0;
display: grid;
}
.v2-extension-device-toggle > div strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-extension-device-toggle > b {
white-space: nowrap;
}
.v2-extension-device-toggle > svg {
color: #8da7a5;
transition: transform 160ms ease;
}
.v2-extension-synced-device[data-expanded="true"] .v2-extension-device-toggle > svg {
transform: rotate(180deg);
}
.v2-extension-device-hide {
width: 27px;
height: 27px;
flex: none;
display: grid;
place-items: center;
padding: 0;
color: #8da7a5;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.v2-extension-device-hide:is(:hover, :focus-visible) {
color: #eaa89c;
background: rgba(216, 99, 82, 0.12);
}
.v2-extension-device-hide svg {
width: 13px;
height: 13px;
}
.v2-extension-synced-device > .v2-extension-quick-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 0;
padding: 0 8px 8px 44px;
border-top: 1px solid rgba(107, 214, 203, 0.09);
}
.v2-extension-quick-controls > button {
min-height: 25px;
padding: 4px 8px;
color: #b9d8d4;
background: rgba(95, 190, 180, 0.1);
border: 1px solid rgba(107, 214, 203, 0.13);
border-radius: 999px;
font-size: 7px;
font-weight: 800;
cursor: pointer;
}
.v2-extension-quick-controls > button:is(:hover, :focus-visible) {
color: #effaf8;
background: rgba(95, 190, 180, 0.2);
border-color: rgba(107, 214, 203, 0.3);
}
.v2-extension-quick-controls > button:disabled {
opacity: 0.55;
cursor: wait;
}
.v2-extension-quick-controls > button[data-state="success"] {
color: #bdf2d5;
background: rgba(61, 173, 112, 0.16);
}
.v2-extension-quick-controls > button[data-state="error"] {
color: #f0b4aa;
background: rgba(216, 99, 82, 0.14);
}
.v2-extension-sync-mount .v2-extension-empty {
display: flex;
align-items: center;
@@ -3547,6 +3687,37 @@ form > .v2-extension-primary {
}
}
.console-v2 #v2ExtensionPanel .v2-extension-device-toggle > span {
color: var(--primary);
background: var(--accent);
}
.console-v2 #v2ExtensionPanel .v2-extension-device-toggle > svg,
.console-v2 #v2ExtensionPanel .v2-extension-device-hide {
color: var(--muted-foreground);
}
.console-v2 #v2ExtensionPanel .v2-extension-synced-device[data-expanded="true"] {
background: color-mix(in oklch, var(--accent) 64%, var(--card));
border-color: color-mix(in oklch, var(--primary) 30%, var(--border));
}
.console-v2 #v2ExtensionPanel .v2-extension-synced-device > .v2-extension-quick-controls {
border-top-color: var(--border);
}
.console-v2 #v2ExtensionPanel .v2-extension-quick-controls > button {
color: var(--primary);
background: var(--accent);
border-color: color-mix(in oklch, var(--primary) 18%, var(--border));
}
.console-v2 #v2ExtensionPanel .v2-extension-quick-controls > button:is(:hover, :focus-visible) {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
}
/* Final semantic theme layer for extension surfaces and nested dialogs --- */
.console-v2 #v2ExtensionPanel .v2-extension-sync-mount,
.console-v2 #v2ExtensionPanel .v2-extension-gateway,
+207 -10
View File
@@ -5,6 +5,7 @@
const SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1";
const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const PERIPHERAL_CARD_UI_KEY = "cognitive-agent.extension-peripheral-cards.v1";
const SLOT_MIN = 1;
const SLOT_MAX = 248;
const ACTION_PAGE_SIZE = 6;
@@ -375,10 +376,13 @@
};
let extensionToySnapshot = {
revision: -1,
session_id: null,
state: "stopped",
topic: "extension/toy",
burst_published_count: 0,
detected_nfc_ids: [],
detection_count: 0,
active_nfc_id: null,
last_error: null,
};
let peripheralCatalogRevision = null;
@@ -410,6 +414,10 @@
let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set();
let renderUnifiedExtensionView = null;
const hiddenPeripheralDevices = new Map();
let expandedPeripheralDeviceId = null;
let peripheralCardSessionId = "";
let runningPeripheralQuickAction = false;
let latestVehicleStateTurn = -1;
let sharedTwinBand = null;
let sharedTwinHome = null;
@@ -1494,6 +1502,59 @@
return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase();
}
function persistPeripheralCardUi() {
try {
sessionStorage.setItem(PERIPHERAL_CARD_UI_KEY, JSON.stringify({
sessionId: peripheralCardSessionId,
hidden: [...hiddenPeripheralDevices].map(([deviceId, value]) => ({
deviceId,
cardUid: value.cardUid,
detectionCount: value.detectionCount,
})),
}));
} catch (_error) {
// The in-memory state remains authoritative when storage is unavailable.
}
}
function restorePeripheralCardUi(sessionId) {
hiddenPeripheralDevices.clear();
expandedPeripheralDeviceId = null;
peripheralCardSessionId = String(sessionId || "");
try {
const saved = JSON.parse(sessionStorage.getItem(PERIPHERAL_CARD_UI_KEY) || "null");
if (!saved || saved.sessionId !== peripheralCardSessionId) {
persistPeripheralCardUi();
return;
}
(Array.isArray(saved.hidden) ? saved.hidden : []).forEach((item) => {
const deviceId = String(item?.deviceId || "").trim();
const cardUid = normalizeCardUid(item?.cardUid);
const detectionCount = Number(item?.detectionCount ?? -1);
if (deviceId && cardUid && Number.isInteger(detectionCount)) {
hiddenPeripheralDevices.set(deviceId, { cardUid, detectionCount });
}
});
} catch (_error) {
persistPeripheralCardUi();
}
}
function revealRedetectedPeripheral(value) {
const cardUid = normalizeCardUid(value?.active_nfc_id);
const detectionCount = Number(value?.detection_count ?? -1);
if (!cardUid || !Number.isInteger(detectionCount)) return false;
let changed = false;
hiddenPeripheralDevices.forEach((hidden, deviceId) => {
if (hidden.cardUid === cardUid && hidden.detectionCount !== detectionCount) {
hiddenPeripheralDevices.delete(deviceId);
changed = true;
}
});
if (changed) persistPeripheralCardUi();
return changed;
}
function renderMetrics() {
$("#extensionHardwareCount").textContent = String(state.devices.length);
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
@@ -4007,9 +4068,16 @@
function applyExtensionToySnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return false;
const revision = Number(value.revision ?? -1);
if (revision === extensionToySnapshot.revision) return false;
const sessionId = String(value.session_id || "");
const sessionChanged = sessionId !== peripheralCardSessionId;
if (sessionChanged) restorePeripheralCardUi(sessionId);
const cardRevealed = revealRedetectedPeripheral(value);
if (revision === extensionToySnapshot.revision
&& sessionId === String(extensionToySnapshot.session_id || "")
&& !cardRevealed) return false;
extensionToySnapshot = {
revision,
session_id: sessionId || null,
state: String(value.state || "stopped"),
topic: String(value.topic || "extension/toy"),
burst_published_count: Number(value.burst_published_count || 0),
@@ -4019,9 +4087,12 @@
detected_nfc_ids: Array.isArray(value.detected_nfc_ids)
? value.detected_nfc_ids.map(normalizeCardUid).filter(Boolean)
: [],
detection_count: Number(value.detection_count || 0),
active_nfc_id: normalizeCardUid(value.active_nfc_id) || null,
last_delivery_status: value.last_delivery_status || null,
last_error: value.last_error || null,
};
if (sessionChanged || cardRevealed) unifiedExtensionDirty = true;
if (announce && extensionToySnapshot.state === "burst_complete") {
showToast(
`已向 ${extensionToySnapshot.topic} 发送 ${extensionToySnapshot.burst_published_count} 帧设备能力消息。`,
@@ -4119,8 +4190,107 @@
void refreshNfcStatus();
}
function hidePeripheralCard(deviceId, cardUid) {
const normalizedDeviceId = String(deviceId || "").trim();
const normalizedCardUid = normalizeCardUid(cardUid);
if (!normalizedDeviceId || !normalizedCardUid) return;
hiddenPeripheralDevices.set(normalizedDeviceId, {
cardUid: normalizedCardUid,
detectionCount: Number(extensionToySnapshot.detection_count || 0),
});
if (expandedPeripheralDeviceId === normalizedDeviceId) {
expandedPeripheralDeviceId = null;
}
persistPeripheralCardUi();
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
showToast("外设卡片已隐藏;再次感应对应 NFC 后将重新显示。", "success");
}
function togglePeripheralCard(deviceId) {
const normalizedDeviceId = String(deviceId || "").trim();
if (!normalizedDeviceId) return;
expandedPeripheralDeviceId = expandedPeripheralDeviceId === normalizedDeviceId
? null
: normalizedDeviceId;
unifiedExtensionDirty = true;
renderUnifiedExtensionView?.({ force: true });
}
async function runPeripheralQuickAction(button) {
const capabilityId = String(button?.dataset.runPeripheralQuickAction || "").trim();
if (!capabilityId || button.disabled) return;
if (runningPeripheralQuickAction) {
showToast("上一条外设快捷指令仍在处理,请稍候。", "error");
return;
}
const capability = durablePeripheralCatalog.find(
(item) => String(item.id || "") === capabilityId,
);
const deviceName = String(
capability?.device_name || capability?.device_id || "外设",
);
const actionName = String(capability?.action || capability?.name || "动作");
if ((button.dataset.risk || capability?.risk) === "high" && !window.confirm(
`“${deviceName} · ${actionName}”可能让外设移动,请确认周围安全。`,
)) return;
const previousText = button.textContent;
runningPeripheralQuickAction = true;
button.disabled = true;
button.dataset.state = "running";
button.textContent = "下发中…";
try {
await api("peripherals/execute", {
method: "POST",
body: JSON.stringify({
capability_id: capabilityId,
conversation_id: sharedConversationId(),
}),
});
button.dataset.state = "success";
button.textContent = "已下发";
showToast(`${deviceName} · ${actionName} 已下发。`, "success");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1200);
} catch (error) {
button.dataset.state = "error";
button.textContent = "下发失败";
showToast(error.message, "error");
window.setTimeout(() => {
if (!button.isConnected) return;
button.dataset.state = "";
button.textContent = previousText;
button.disabled = false;
}, 1600);
} finally {
runningPeripheralQuickAction = false;
}
}
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
const quickAction = event.target.closest("[data-run-peripheral-quick-action]");
if (quickAction) {
void runPeripheralQuickAction(quickAction);
return;
}
const hideCard = event.target.closest("[data-hide-peripheral-card]");
if (hideCard) {
hidePeripheralCard(
hideCard.dataset.hidePeripheralCard,
hideCard.dataset.cardUid,
);
return;
}
const toggleCard = event.target.closest("[data-toggle-peripheral-card]");
if (toggleCard) {
togglePeripheralCard(toggleCard.dataset.togglePeripheralCard);
return;
}
if (event.target.closest("[data-open-pet-settings]")) openPetSettingsDialog();
else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
@@ -4237,6 +4407,11 @@
card: nfcSnapshot.card,
},
detectedExtensionNfcIds: extensionToySnapshot.detected_nfc_ids,
peripheralCardUi: {
sessionId: peripheralCardSessionId,
hiddenDeviceIds: [...hiddenPeripheralDevices.keys()].sort(),
expandedDeviceId: expandedPeripheralDeviceId,
},
pets: petCardSnapshot,
localDevices: state.devices.map((device) => ({
id: device.id,
@@ -4335,14 +4510,23 @@
actions: [],
});
const actionName = item.action || item.name || "已学习能力";
grouped.get(deviceId).actions.push(
item.enabled === false ? `${actionName}(已停用)` : actionName,
);
grouped.get(deviceId).actions.push({
id: String(item.id || ""),
name: actionName,
enabled: item.enabled !== false,
risk: item.risk || "low",
slot: item.slot,
});
});
const detectedNfcIds = new Set(extensionToySnapshot.detected_nfc_ids);
const devices = [...grouped.values()].filter(
(device) => detectedNfcIds.has(normalizeCardUid(device.cardUid)),
(device) => detectedNfcIds.has(normalizeCardUid(device.cardUid))
&& !hiddenPeripheralDevices.has(device.id),
);
if (expandedPeripheralDeviceId
&& !devices.some((device) => device.id === expandedPeripheralDeviceId)) {
expandedPeripheralDeviceId = null;
}
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice
@@ -4419,11 +4603,24 @@
</div>
<div class="v2-extension-device-list">
${nfcPet ? renderDetectedPetCard(nfcPet) : ""}
${devices.length ? devices.map((device) => `
<article class="v2-extension-synced-device">
<header><span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span><div><strong title="${escapeHtml(device.type || device.name)}">${escapeHtml(device.name)}</strong></div><b>${device.actions.length} 项能力</b></header>
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : nfcPet ? "" : `
${devices.length ? devices.map((device) => {
const expanded = expandedPeripheralDeviceId === device.id;
return `
<article class="v2-extension-synced-device" data-expanded="${String(expanded)}" data-extension-synced-device="${escapeHtml(device.id)}">
<header>
<button class="v2-extension-device-toggle" type="button" data-toggle-peripheral-card="${escapeHtml(device.id)}" aria-expanded="${String(expanded)}">
<span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span>
<div><strong title="${escapeHtml(device.type || device.name)}">${escapeHtml(device.name)}</strong></div>
<b>${device.actions.length} 项能力</b>
<i data-lucide="chevron-down"></i>
</button>
<button class="v2-extension-device-hide" type="button" data-hide-peripheral-card="${escapeHtml(device.id)}" data-card-uid="${escapeHtml(device.cardUid)}" title="隐藏${escapeHtml(device.name)}" aria-label="隐藏${escapeHtml(device.name)}"><i data-lucide="eye-off"></i></button>
</header>
${expanded ? `<div class="v2-extension-quick-controls" aria-label="${escapeHtml(device.name)}快捷控制">
${device.actions.map((action) => `<button type="button" data-run-peripheral-quick-action="${escapeHtml(action.id)}" data-risk="${escapeHtml(action.risk)}" ${action.enabled && action.id ? "" : "disabled"}>${escapeHtml(action.name)}${action.enabled ? "" : "(已停用)"}</button>`).join("")}
</div>` : ""}
</article>`;
}).join("") : nfcPet ? "" : `
<div class="v2-extension-empty">
<i data-lucide="scan-line"></i><div><strong>等待 NFC 外设靠近</strong><span>检测到已绑定的 NFC 后,外设将在本次运行期间持续显示</span></div>
</div>`}
+2 -2
View File
@@ -11,8 +11,8 @@ const APP_SHELL = [
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.css?v=pet-settings-54-dialog-size",
"/self-extension.js?v=nfc-session-58",
"/self-extension.css?v=peripheral-cards-55",
"/self-extension.js?v=peripheral-cards-59",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",