feat(n7web): sync N7 scene cards and optimize runtime

This commit is contained in:
Miayui
2026-09-17 11:04:05 +08:00
parent 75ce391312
commit 7d196db02b
31 changed files with 2924 additions and 126 deletions
+324 -28
View File
@@ -60,6 +60,8 @@ const state = {
vehicleStateTurn: -1,
seatTwin: null,
seatActuatorState: {},
seatRenderFingerprint: "",
domainRenderFingerprint: "",
seatFeedbackSource: "simulated",
seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
availableSeatAxes: null,
@@ -76,6 +78,14 @@ const state = {
modeRunTimer: null,
modeHighlightTimer: null,
latestModeRefreshKey: "",
modeProfileSync: {
initialized: false,
inFlight: false,
poller: null,
catalogRevision: "",
importedCount: 0,
rejectedCount: 0,
},
modeEditor: null,
directCanCommandPending: false,
};
@@ -90,6 +100,7 @@ const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(inpu
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 45 * 1000;
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
const MODE_PROFILE_SYNC_POLL_INTERVAL_MS = 1000;
const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);
async function apiResponseError(response) {
@@ -2025,6 +2036,7 @@ function applyTwinSceneTheme(twin) {
canvas.dataset.themeRole = role;
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
}
twin.requestRender?.({ hotspots: false });
}
function updateTwinVehicleVisual(systems, timestamp) {
@@ -2574,6 +2586,10 @@ function configureFixedTwinSeat(THREE, model, position, role) {
return model;
}
function requestSeatTwinRender(options = {}) {
state.seatTwin?.requestRender?.(options);
}
function setTwinSelection(selected) {
const twin = state.seatTwin;
if (!twin) return;
@@ -2609,6 +2625,7 @@ function setTwinSelection(selected) {
twin.view.targetX = 0;
twin.view.yaw = -0.62;
renderTwinReadout();
requestSeatTwinRender();
}
function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
@@ -2734,6 +2751,7 @@ function updateSeatTwin(actuatorState) {
});
});
renderTwinReadout();
requestSeatTwinRender();
}
const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/";
@@ -4080,7 +4098,7 @@ function initSeatTwin() {
return;
}
renderer.autoClear = false;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
else renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
@@ -4218,6 +4236,15 @@ function initSeatTwin() {
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
dragging: false,
pointer: { x: 0, y: 0 },
animationFrame: null,
renderRequested: false,
settleFrames: 0,
inViewport: true,
hotspotsDirty: true,
hotspotLastLayoutAt: Number.NEGATIVE_INFINITY,
hotspotMetrics: new Map(),
hotspotElements: new Map(),
requestRender,
};
applyTwinSceneTheme(state.seatTwin);
window.addEventListener("cognitive-agent:module-change", () => {
@@ -4284,6 +4311,7 @@ function initSeatTwin() {
distance: 10.2,
targetX: 0,
});
requestRender();
}
let stageWasVisible = false;
@@ -4294,6 +4322,8 @@ function initSeatTwin() {
camera.aspect = width / height;
camera.updateProjectionMatrix();
state.seatTwin.narrowViewport = width < 600;
state.seatTwin.hotspotMetrics.clear();
requestRender({ settleFrames: 2 });
}
function synchronizeTwinViewport({ reset = false } = {}) {
@@ -4317,6 +4347,31 @@ function initSeatTwin() {
stageObserver.observe(stage);
state.seatTwin.resizeObserver = stageObserver;
}
if (window.IntersectionObserver) {
const visibilityObserver = new IntersectionObserver(([entry]) => {
const twin = state.seatTwin;
if (!twin) return;
twin.inViewport = Boolean(entry?.isIntersecting);
if (!twin.inViewport && twin.animationFrame !== null) {
window.cancelAnimationFrame(twin.animationFrame);
twin.animationFrame = null;
} else if (twin.inViewport) {
requestRender({ settleFrames: 2 });
}
}, { root: null, rootMargin: "100px" });
visibilityObserver.observe(stage);
state.seatTwin.visibilityObserver = visibilityObserver;
}
document.addEventListener("visibilitychange", () => {
const twin = state.seatTwin;
if (!twin) return;
if (document.visibilityState !== "visible" && twin.animationFrame !== null) {
window.cancelAnimationFrame(twin.animationFrame);
twin.animationFrame = null;
return;
}
requestRender({ settleFrames: 2 });
});
window.addEventListener("resize", scheduleViewportSync);
window.addEventListener("cognitive-agent:module-change", scheduleViewportSync);
canvas.addEventListener("webglcontextlost", (event) => {
@@ -4342,6 +4397,7 @@ function initSeatTwin() {
refreshIcons();
window.setTimeout(() => { fallback.hidden = true; }, 3200);
}
requestRender({ settleFrames: 8 });
});
synchronizeTwinViewport({ reset: true });
stage.addEventListener("pointerdown", (event) => {
@@ -4349,6 +4405,7 @@ function initSeatTwin() {
state.seatTwin.pointer = { x: event.clientX, y: event.clientY };
stage.setPointerCapture(event.pointerId);
stage.classList.add("dragging");
requestRender({ settleFrames: 2 });
});
stage.addEventListener("pointermove", (event) => {
const twin = state.seatTwin;
@@ -4358,11 +4415,13 @@ function initSeatTwin() {
twin.pointer = { x: event.clientX, y: event.clientY };
twin.view.yaw -= dx * 0.006;
twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004));
requestRender({ settleFrames: 2 });
});
const endDrag = () => {
if (!state.seatTwin) return;
state.seatTwin.dragging = false;
stage.classList.remove("dragging");
requestRender({ settleFrames: 2 });
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
@@ -4370,6 +4429,7 @@ function initSeatTwin() {
event.preventDefault();
const twin = state.seatTwin;
twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006));
requestRender({ settleFrames: 2 });
}, { passive: false });
$("#twinReset").addEventListener("click", resetView);
$$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat)));
@@ -4392,7 +4452,11 @@ function initSeatTwin() {
scene.updateMatrixWorld(true);
camera.updateMatrixWorld();
Object.entries(TWIN_DEVICE_HOTSPOT_LAYOUT).forEach(([deviceId, layout]) => {
const button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer);
let button = twin.hotspotElements.get(deviceId);
if (!button?.isConnected) {
button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer);
if (button) twin.hotspotElements.set(deviceId, button);
}
if (!button || !twinDeviceWorldAnchor(THREE, twin, deviceId, hotspotWorldAnchor)) return;
projectedHotspot.copy(hotspotWorldAnchor).project(camera);
const anchorX = (projectedHotspot.x * 0.5 + 0.5) * width;
@@ -4412,8 +4476,18 @@ function initSeatTwin() {
button.dataset.anchorSource = "projected-3d";
const label = $(".v2-twin-hotspot-label", button);
const halfWidth = Math.max(44, (label?.offsetWidth || 94) / 2);
const halfHeight = Math.max(20, (label?.offsetHeight || 42) / 2);
let metrics = twin.hotspotMetrics.get(deviceId);
if (!metrics || metrics.label !== label) {
const measuredWidth = label?.offsetWidth || 94;
const measuredHeight = label?.offsetHeight || 42;
metrics = {
label,
halfWidth: Math.max(44, measuredWidth / 2),
halfHeight: Math.max(20, measuredHeight / 2),
};
twin.hotspotMetrics.set(deviceId, metrics);
}
const { halfWidth, halfHeight } = metrics;
const preferredX = anchorX + layout.labelOffset[0] * offsetScale;
const preferredY = anchorY + layout.labelOffset[1] * offsetScale;
const labelX = clampLabelCoordinate(
@@ -4448,22 +4522,84 @@ function initSeatTwin() {
window.CognitiveAgentConsoleV2?.positionTwinDevicePopover?.();
}
function queueTwinFrame() {
const twin = state.seatTwin;
if (!twin || twin.animationFrame !== null) return;
if (document.visibilityState !== "visible" || twin.inViewport === false) return;
twin.animationFrame = window.requestAnimationFrame(animate);
}
function requestRender({ hotspots = true, settleFrames = 36 } = {}) {
const twin = state.seatTwin;
if (!twin) return;
twin.renderRequested = true;
twin.settleFrames = Math.max(twin.settleFrames || 0, Math.max(0, Number(settleFrames) || 0));
if (hotspots) twin.hotspotsDirty = true;
queueTwinFrame();
}
function twinNeedsAnimation(twin) {
if (twin.dragging || twin.renderRequested || twin.hotspotsDirty || twin.settleFrames > 0) {
return true;
}
const movingSeat = Object.values(twin.models).some((model) => (
Object.keys(model.positions).some(
(axis) => Math.abs(model.targets[axis] - model.positions[axis]) >= 0.002,
)
|| Object.keys(model.angles).some(
(axis) => Math.abs(model.angleTargets[axis] - model.angles[axis]) >= 0.002,
)
|| ["ventilation", "heating"].some((feature) => (
Math.abs(model.thermalTargets[feature] - model.thermalLevels[feature]) >= 0.002
|| model.thermalTargets[feature] > 0.015
|| model.thermalLevels[feature] > 0.015
))
));
if (movingSeat) return true;
const systems = twin.vehicleSystems;
if (!systems) return false;
const desiredAmbient = systems.targets.ambientPower ? systems.targets.ambientBrightness : 0;
const movingSystem = [
[systems.current.ambientBrightness, desiredAmbient],
[systems.current.climateLevel, systems.targets.climateLevel],
[systems.current.roofTransmittance, systems.targets.roofTransmittance],
[systems.current.roofShadePosition, systems.targets.roofShadePosition],
[systems.current.lowBeam, systems.targets.lowBeam],
[systems.current.highBeam, systems.targets.highBeam],
[systems.current.fogLight, systems.targets.fogLight],
[systems.current.turnSignal, systems.targets.turnSignal],
...Object.keys(systems.current.windowPositions).map((key) => [
systems.current.windowPositions[key],
systems.targets.windowPositions[key],
]),
].some(([current, target]) => Math.abs(Number(target) - Number(current)) >= 0.002);
return movingSystem
|| systems.targets.turnSignal > 0.015
|| systems.targets.climateLevel > 0.015
|| systems.airflow.level > 0.015;
}
function animate(timestamp = 0) {
const twin = state.seatTwin;
if (!twin) return;
twin.animationFrame = null;
if (document.visibilityState !== "visible" || twin.inViewport === false) return;
if (!stageHasLayout() || canvas.dataset.contextLost === "true") {
stageWasVisible = false;
window.requestAnimationFrame(animate);
twin.renderRequested = true;
return;
}
if (!stageWasVisible) synchronizeTwinViewport({ reset: true });
let geometryChanged = false;
Object.values(twin.models).forEach((model) => {
Object.keys(model.positions).forEach((axis) => {
const delta = model.targets[axis] - model.positions[axis];
if (Math.abs(delta) >= 0.002) geometryChanged = true;
model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
});
Object.keys(model.angles).forEach((axis) => {
const delta = model.angleTargets[axis] - model.angles[axis];
if (Math.abs(delta) >= 0.002) geometryChanged = true;
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
});
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
@@ -4499,6 +4635,7 @@ function initSeatTwin() {
);
updateTwinThermalVisual(model, timestamp);
});
if (geometryChanged) twin.hotspotsDirty = true;
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
const { view } = twin;
// Fit the whole reference vehicle against the limiting horizontal field
@@ -4519,7 +4656,11 @@ function initSeatTwin() {
);
camera.lookAt(cameraTargetX, 1.14, -0.08);
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
positionTwinDeviceHotspots();
if (twin.hotspotsDirty && timestamp - twin.hotspotLastLayoutAt >= 100) {
positionTwinDeviceHotspots();
twin.hotspotsDirty = false;
twin.hotspotLastLayoutAt = timestamp;
}
canvas.dataset.framing = "whole-vehicle-centered";
renderer.clear();
camera.layers.set(0);
@@ -4532,10 +4673,12 @@ function initSeatTwin() {
scene.background = sceneBackground;
camera.layers.set(0);
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
window.requestAnimationFrame(animate);
twin.renderRequested = false;
twin.settleFrames = Math.max(0, twin.settleFrames - 1);
if (twinNeedsAnimation(twin)) queueTwinFrame();
}
setTwinSelection("passenger");
animate();
requestRender({ settleFrames: 48 });
}
function showToast(message) {
@@ -5114,6 +5257,10 @@ function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true;
}
function isN7SyncedModeProfile(mode) {
return mode?.origin === "n7_mqtt" || mode?.source_topic === "from_n7/scenes";
}
function isDynamicModeProfile(mode) {
const behavior = String(mode?.behavior || "").trim().toLowerCase();
if (["cyclic", "dynamic"].includes(behavior)) return true;
@@ -5179,10 +5326,14 @@ function createModeGalleryCard(mode) {
heading.className = "mode-gallery-card-heading";
const name = document.createElement("strong");
name.textContent = mode.name || mode.id;
const sourceBadge = document.createElement("span");
sourceBadge.className = "mode-gallery-source-badge";
sourceBadge.textContent = "N7 同步";
sourceBadge.hidden = !isN7SyncedModeProfile(mode);
const stateLabel = document.createElement("small");
stateLabel.className = "mode-gallery-state";
stateLabel.textContent = "点击执行";
heading.append(name, stateLabel);
heading.append(name, sourceBadge, stateLabel);
const description = document.createElement("span");
description.className = "mode-gallery-card-description";
description.textContent = mode.description || "整车场景联动配置";
@@ -5382,6 +5533,93 @@ function refreshModeProfileFromResult(result) {
.catch(() => {});
}
function modeProfileSyncCount(value) {
const count = Number(value);
return Number.isFinite(count) && count >= 0 ? Math.trunc(count) : 0;
}
function primeModeProfileSyncFromRuntime(runtime) {
const sync = state.modeProfileSync;
const catalogRevision = String(
runtime?.mode_scene_catalog?.catalog_revision || "",
);
if (catalogRevision) sync.catalogRevision = catalogRevision;
if (sync.initialized) return;
const ingress = runtime?.vehicle_mqtt?.mode_scene_import;
if (!ingress || typeof ingress !== "object") return;
sync.importedCount = modeProfileSyncCount(ingress.imported_count);
sync.rejectedCount = modeProfileSyncCount(ingress.rejected_count);
sync.initialized = true;
}
async function checkModeProfileSync() {
const sync = state.modeProfileSync;
if (document.visibilityState === "hidden" || sync.inFlight) return;
sync.inFlight = true;
try {
const response = await apiRequest("/api/mode-profiles/status", {
cache: "no-store",
});
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw await apiResponseError(response);
const payload = await response.json();
const ingress = payload?.mode_scene_ingress || {};
const catalogRevision = String(payload?.catalog_revision || "");
const importedCount = modeProfileSyncCount(ingress.imported_count);
const rejectedCount = modeProfileSyncCount(ingress.rejected_count);
if (!sync.initialized) {
sync.catalogRevision = catalogRevision;
sync.importedCount = importedCount;
sync.rejectedCount = rejectedCount;
sync.initialized = true;
return;
}
const catalogChanged = Boolean(
catalogRevision && catalogRevision !== sync.catalogRevision,
);
const imported = importedCount > sync.importedCount;
const rejected = rejectedCount > sync.rejectedCount;
sync.catalogRevision = catalogRevision || sync.catalogRevision;
sync.importedCount = importedCount;
sync.rejectedCount = rejectedCount;
const sceneId = String(ingress.last_scene_id || "").trim();
const sceneAlreadyVisible = sceneId
? modeProfiles().some((mode) => String(mode?.id || "") === sceneId)
: false;
if (catalogChanged || (imported && !sceneAlreadyVisible)) {
await loadRuntime();
}
if (imported) {
if (sceneId) revealModeProfile(sceneId);
const sceneName = String(
ingress.last_result?.scene_name || sceneId || "新模式",
);
showToast(`已从 N7 同步模式卡片:${sceneName}`);
} else if (rejected) {
const reason = String(ingress.last_error || "消息格式不符合要求");
showToast(`N7 模式卡片导入失败:${reason}`);
}
} catch (error) {
console.warn("N7 mode profile synchronization check failed", error);
} finally {
sync.inFlight = false;
}
}
function startModeProfileSyncPolling() {
const sync = state.modeProfileSync;
window.clearInterval(sync.poller);
sync.poller = window.setInterval(
() => void checkModeProfileSync(),
MODE_PROFILE_SYNC_POLL_INTERVAL_MS,
);
}
function openModeGallery() {
const backdrop = $("#modeGalleryBackdrop");
if (!backdrop) return;
@@ -6639,8 +6877,16 @@ function setModeChipsDisabled(disabled) {
});
}
function hasExecutableModeCommandFlow(mode) {
const flow = mode?.command_flow;
if (!mode?.id || !flow || typeof flow !== "object") return false;
const plans = flow.plans;
return Boolean(flow.source_request_id)
|| (plans && typeof plans === "object" && Object.keys(plans).length > 0);
}
async function executeSavedModeProfile(mode, { command = "" } = {}) {
if (!mode?.id || !mode?.command_flow?.source_request_id) return false;
if (!hasExecutableModeCommandFlow(mode)) return false;
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
const assistant = window.CognitiveAgentAssistant;
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
@@ -6747,7 +6993,7 @@ function applyModeCommand(command, modeProfileId = null) {
setModeRailSelection(mode);
closeModeGallery({ restoreFocus: false });
assistant?.open?.();
if (mode?.command_flow?.source_request_id) {
if (hasExecutableModeCommandFlow(mode)) {
void executeSavedModeProfile(mode, { command: modeCommand });
return true;
}
@@ -6809,6 +7055,7 @@ async function loadRuntime() {
const response = await apiRequest("/api/runtime", { cache: "no-store" });
if (!response.ok) throw await apiResponseError(response);
state.runtime = await response.json();
primeModeProfileSyncFromRuntime(state.runtime);
renderControlTransport();
applyRuntimeTargetPolicy(state.runtime);
populateVehicleSelector();
@@ -7157,6 +7404,8 @@ function resetSeatState() {
function resetVehicleStatusDisplay() {
state.seatActuatorState = {};
state.seatRenderFingerprint = "";
state.domainRenderFingerprint = "";
resetSeatState();
const setText = (selector, value) => {
const element = $(selector);
@@ -7227,6 +7476,28 @@ function resetVehicleStatusDisplay() {
summarySystem.textContent = "等待状态";
summarySystem.dataset.state = "waiting";
}
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
detail: { scope: "reset", fingerprint: "" },
}));
}
const VEHICLE_RENDER_VOLATILE_KEYS = new Set([
"age_ms",
"capture_sequence",
"captured_at",
"frame_timestamp",
"last_seen_at",
"observed_at",
"received_at",
"request_id",
"timestamp",
"updated_at",
]);
function vehicleRenderFingerprint(value) {
return JSON.stringify(value, (key, item) => (
VEHICLE_RENDER_VOLATILE_KEYS.has(key) ? undefined : item
));
}
function mergeSeatActuatorState(previousState, patchState) {
@@ -7246,20 +7517,6 @@ function mergeSeatActuatorState(previousState, patchState) {
mergedSeat.position = mergedPosition;
return;
}
if (feature === "dynamic_mode") {
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
if (!item) return;
const running = value.state === "running";
item.classList.toggle("running", running);
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
$("b", item).textContent = running
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
: "未运行";
return;
}
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
? { ...(previousSeat[feature] || {}), ...value }
: value;
@@ -7270,13 +7527,15 @@ function mergeSeatActuatorState(previousState, patchState) {
}
function renderSeatState(actuatorState) {
if (!actuatorState) return;
if (!actuatorState) return false;
const mergedActuatorState = mergeSeatActuatorState(
state.seatActuatorState,
actuatorState,
);
const renderFingerprint = vehicleRenderFingerprint(mergedActuatorState);
state.seatActuatorState = mergedActuatorState;
resetSeatState();
if (renderFingerprint === state.seatRenderFingerprint) return false;
state.seatRenderFingerprint = renderFingerprint;
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
const displayFunctions = seat === "driver"
? twinDeviceForSeat("driver", { driver: functions })
@@ -7302,6 +7561,20 @@ function renderSeatState(actuatorState) {
: "CH1 状态已同步";
return;
}
if (feature === "dynamic_mode") {
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
if (!item) return;
const running = value.state === "running";
item.classList.toggle("running", running);
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
$("b", item).textContent = running
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
: "未运行";
return;
}
if (feature === "position") {
Object.entries(value).forEach(([axis, motion]) => {
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
@@ -7335,10 +7608,17 @@ function renderSeatState(actuatorState) {
});
});
updateSeatTwin(mergedActuatorState);
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
detail: { scope: "seat", fingerprint: renderFingerprint },
}));
return true;
}
function renderDomainState(domainState) {
if (!domainState) return;
if (!domainState) return false;
const renderFingerprint = vehicleRenderFingerprint(domainState);
if (renderFingerprint === state.domainRenderFingerprint) return false;
state.domainRenderFingerprint = renderFingerprint;
const outletModeLabel = (value) => {
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
const matrixLabels = {
@@ -7723,6 +8003,11 @@ function renderDomainState(domainState) {
});
}
}
requestSeatTwinRender();
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
detail: { scope: "domain", fingerprint: renderFingerprint },
}));
return true;
}
function renderedConfig(result) {
@@ -9041,6 +9326,15 @@ window.addEventListener("cognitive-agent:request-complete", (event) => {
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void checkModeProfileSync();
});
window.addEventListener("pagehide", () => {
window.clearInterval(state.modeProfileSync.poller);
state.modeProfileSync.poller = null;
});
document.addEventListener("DOMContentLoaded", async () => {
initGlassThemeToggle();
initJointTwinLayout();
@@ -9147,6 +9441,8 @@ document.addEventListener("DOMContentLoaded", async () => {
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
await loadAuthStatus();
await loadRuntime();
startModeProfileSyncPolling();
void checkModeProfileSync();
if (!isDriverOnlyCh1Runtime()) {
await loadHardwareAccessStatus({ silent: true });
state.hardwareAccessPoller = window.setInterval(
+3 -2
View File
@@ -466,6 +466,9 @@ body.console-v2 .v2-twin-device-popover {
background: oklch(0.16 0.045 285 / 0.74) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: 0 10px 28px oklch(0.04 0.035 286 / 0.32), inset 0 1px 0 var(--glass-highlight) !important;
}
body.console-v2 .v2-twin-device-popover {
-webkit-backdrop-filter: blur(14px) saturate(1.18);
backdrop-filter: blur(14px) saturate(1.18);
}
@@ -526,8 +529,6 @@ body.console-v2 .v2-pet-nfc-field output {
background: oklch(0.11 0.035 286 / 0.42) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.08) !important;
-webkit-backdrop-filter: blur(10px) saturate(1.14);
backdrop-filter: blur(10px) saturate(1.14);
}
body.console-v2 :is(input, textarea, select):focus {
+26 -18
View File
@@ -1265,6 +1265,7 @@
pollTimer: null,
deviceFrame: null,
deviceFingerprint: "",
deviceRenderDirty: true,
activeDevice: null,
pinnedDevice: null,
previewClearTimer: null,
@@ -1523,9 +1524,13 @@
</label>`;
}
function renderSyncedDevices() {
function renderSyncedDevices({ force = false } = {}) {
const grid = $("#v2DeviceSyncGrid");
if (!grid) return;
if (!grid) return false;
if (!force && !panelVisible("devices")) {
displaySync.deviceRenderDirty = true;
return false;
}
grid.innerHTML = syncedDevices.map((device) => {
const rows = deviceReadoutRows(device);
const waiting = rows.some(([, value]) => value === "-");
@@ -1553,7 +1558,9 @@
$(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active");
}
displaySync.deviceFingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
displaySync.deviceRenderDirty = false;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
}
function storeDeviceControlDraft(input) {
@@ -1641,11 +1648,17 @@
}
function scheduleSyncedDeviceRender() {
if (!panelVisible("devices")) {
displaySync.deviceRenderDirty = true;
return;
}
if (displaySync.deviceFrame) return;
displaySync.deviceFrame = requestAnimationFrame(() => {
displaySync.deviceFrame = null;
const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
if (fingerprint === displaySync.deviceFingerprint && $("#v2DeviceSyncGrid")?.childElementCount) return;
if (!displaySync.deviceRenderDirty
&& fingerprint === displaySync.deviceFingerprint
&& $("#v2DeviceSyncGrid")?.childElementCount) return;
renderSyncedDevices();
if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, {
pinned: Boolean(displaySync.pinnedDevice),
@@ -1748,7 +1761,11 @@
rightRail.toggleAttribute("inert", collapsed);
rightRail.dataset.openPanels = openPanels.join(" ") || "none";
}
if (key === "devices" && visible) renderSyncedDevices({ force: true });
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.dispatchEvent(new CustomEvent("cognitive-agent:side-panel-visibility", {
detail: { panel: key, visible: Boolean(visible) },
}));
window.dispatchEvent(new Event("resize"));
}
@@ -2550,19 +2567,10 @@
}
buildTwinDeviceInteractions();
cabinBand.classList.add("v2-source-state-only");
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
const stateBand = $(".state-band");
if (stateBand) new MutationObserver(scheduleSyncedDeviceRender).observe(stateBand, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
window.addEventListener(
"cognitive-agent:vehicle-state-rendered",
scheduleSyncedDeviceRender,
);
}
setSidePanelVisibility("devices", false);
setSidePanelVisibility("extensions", false);
@@ -2983,8 +2991,8 @@
});
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
syncRuntimePresentation();
renderSyncedDevices();
if (displaySync.activeDevice) {
scheduleSyncedDeviceRender();
if (panelVisible("devices") && displaySync.activeDevice) {
presentTwinDevice(displaySync.activeDevice, {
pinned: Boolean(displaySync.pinnedDevice),
selectSeat: false,
+4 -1
View File
@@ -869,7 +869,10 @@
createLayer();
seedHistory();
renderMonitor();
monitor.timer = window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS);
monitor.timer = window.setInterval(
() => sampleNow({ render: monitor.open }),
SAMPLE_INTERVAL_MS,
);
document.addEventListener("keydown", captureModalKeys, true);
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
const existingUpdater = window.updateDevicePowerTelemetry;
+4 -4
View File
@@ -38,14 +38,14 @@
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-141" defer></script>
<script src="/app.js?v=dual-seat-145-n7-dispatch" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script>
<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-76" defer></script>
<script src="/self-extension.js?v=pet-settings-52" defer></script>
<script src="/console-v2.js?v=local-first-77-performance" defer></script>
<script src="/self-extension.js?v=pet-settings-53-performance" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
</head>
<body class="console-v2">
<div id="app" aria-busy="true"></div>
+132 -5
View File
@@ -389,6 +389,11 @@
let selectedPetCardId = null;
const petCardDrafts = new Map();
let nfcPollTimer = null;
let nfcDialogFingerprint = "";
let nfcDialogDirty = true;
let unifiedExtensionFingerprint = "";
let unifiedExtensionDirty = true;
let gscuHealthFingerprint = "";
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set();
@@ -3673,12 +3678,50 @@
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
}
function renderNfcDialog() {
function currentNfcDialogFingerprint() {
return JSON.stringify({
revision: nfcSnapshot.revision,
reader: nfcSnapshot.reader,
card: nfcSnapshot.card,
petRevision: petCardSnapshot.revision,
pets: petCardSnapshot.cards,
publisher: petCardSnapshot.publisher,
devices: state.devices.map((device) => ({
id: device.id,
name: device.name,
type: device.type,
cardUid: device.cardUid || null,
})),
capabilities: state.capabilities.map((capability) => ({
id: capability.id,
deviceId: capability.deviceId,
name: capability.name,
aliases: capability.aliases,
slot: capability.slot,
status: capability.status,
verified: capability.verified,
enabled: capability.enabled !== false,
})),
infraredCommands: state.infraredCommands,
});
}
function renderNfcDialog({ force = false } = {}) {
const dialog = $("#extensionNfcDialog");
if (!dialog) return false;
if (!force && !dialog.open) {
nfcDialogDirty = true;
return false;
}
const renderFingerprint = currentNfcDialogFingerprint();
if (!force && !nfcDialogDirty && renderFingerprint === nfcDialogFingerprint) {
return false;
}
const stage = $("#extensionNfcStage");
const body = $("#extensionNfcBody");
const status = $("#extensionNfcReaderStatus");
const badge = $("#extensionNfcReaderBadge");
if (!stage || !body || !status || !badge) return;
if (!stage || !body || !status || !badge) return false;
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
@@ -3725,7 +3768,10 @@
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
nfcDialogFingerprint = renderFingerprint;
nfcDialogDirty = false;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
}
function buildUnifiedNfcDialog() {
@@ -3891,8 +3937,8 @@
}
function openNfcDialog() {
renderNfcDialog();
openDialog("extensionNfcDialog");
renderNfcDialog({ force: true });
void refreshNfcStatus();
}
@@ -3969,10 +4015,73 @@
if (!mount) return false;
let snapshot = null;
const render = () => {
const extensionViewFingerprint = (health) => JSON.stringify({
transport: health.transport,
connected: health.connected,
peerConnected: health.peer_connected,
peer: health.peer,
listening: health.listening,
message: health.message,
extensionCatalog: (snapshot?.extension?.catalog || []).map((item) => ({
id: item.id,
deviceId: item.device_id,
deviceName: item.device_name,
deviceType: item.device_type,
typeLabel: item.type_label,
action: item.action,
name: item.name,
cardUid: item.card_uid,
status: item.status,
enabled: item.enabled,
})),
lastAction: snapshot?.extension?.last_action ? {
command: snapshot.extension.last_action.command,
type: snapshot.extension.last_action.type,
} : null,
lastResult: snapshot?.extension?.result ? {
status: snapshot.extension.result.status,
message: snapshot.extension.result.message,
} : null,
nfc: {
revision: nfcSnapshot.revision,
reader: nfcSnapshot.reader,
card: nfcSnapshot.card,
},
pets: petCardSnapshot,
localDevices: state.devices.map((device) => ({
id: device.id,
name: device.name,
type: device.type,
cardUid: device.cardUid || null,
})),
localCapabilities: state.capabilities.map((capability) => ({
id: capability.id,
deviceId: capability.deviceId,
name: capability.name,
slot: capability.slot,
status: capability.status,
verified: capability.verified,
enabled: capability.enabled !== false,
})),
infraredCommands: state.infraredCommands,
});
const render = ({ force = false } = {}) => {
const health = snapshot?.extension_health || {};
healthState = health;
if ($("#extensionGscuTcpDialog")?.open) renderGscuTcpDialog(health, false);
const nextGscuHealthFingerprint = JSON.stringify({
transport: health.transport,
connected: health.connected,
peerConnected: health.peer_connected,
peer: health.peer,
listening: health.listening,
message: health.message,
});
if ($("#extensionGscuTcpDialog")?.open
&& (force || nextGscuHealthFingerprint !== gscuHealthFingerprint)) {
renderGscuTcpDialog(health, false);
}
gscuHealthFingerprint = nextGscuHealthFingerprint;
const embeddedNfc = health.nfc;
if (nfcSnapshot.revision < 0 && embeddedNfc) {
nfcSnapshot = {
@@ -3989,6 +4098,16 @@
}
}
if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
const panel = mount.closest("#v2ExtensionPanel");
if (!force && (panel?.hidden || panel?.classList.contains("is-collapsed"))) {
unifiedExtensionDirty = true;
return false;
}
const renderFingerprint = extensionViewFingerprint(health);
if (!force
&& !unifiedExtensionDirty
&& renderFingerprint === unifiedExtensionFingerprint
&& mount.childElementCount) return false;
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
@@ -4114,7 +4233,10 @@
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
</footer>`;
unifiedExtensionFingerprint = renderFingerprint;
unifiedExtensionDirty = false;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
return true;
};
renderUnifiedExtensionView = render;
@@ -4125,6 +4247,11 @@
}
render();
});
window.addEventListener("cognitive-agent:side-panel-visibility", (event) => {
if (event.detail?.panel === "extensions" && event.detail?.visible) {
render({ force: true });
}
});
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
+6 -6
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing";
const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,22 +6,22 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-141",
"/app.js?v=dual-seat-145-n7-dispatch",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-76",
"/console-v2.js?v=local-first-77-performance",
"/self-extension.css?v=pet-settings-52",
"/self-extension.js?v=pet-settings-52",
"/self-extension.js?v=pet-settings-53-performance",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-29",
"/assistant-chat.js?v=codex-assistant-34",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-5-performance",
"/ui-consistency.css?v=spatial-ui-8",
"/codepen-glass-theme.css?v=creative-cloud-5",
"/codepen-glass-theme.css?v=creative-cloud-6-performance",
"/vue-ui.js?v=vue-presentation-3",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
+16
View File
@@ -724,6 +724,22 @@ body.mode-editor-open {
font-weight: 700;
}
.mode-gallery-source-badge {
flex: 0 0 auto;
padding: 2px 5px;
color: #315eb8;
background: rgba(91, 132, 222, 0.14);
border: 1px solid rgba(91, 132, 222, 0.24);
border-radius: 999px;
font-size: 7px;
font-weight: 800;
line-height: 1;
}
.mode-gallery-source-badge[hidden] {
display: none;
}
.mode-gallery-card-description,
.mode-gallery-card-meta {
overflow: hidden;