diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 71e1aa0..96469f6 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -44,7 +44,7 @@ - + diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 40e725e..efd328a 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -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, diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 0803621..9e9b7f5 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -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 @@