From f77dda70fe518001d52dcd0a4d321897c6ee5be3 Mon Sep 17 00:00:00 2001 From: vee Date: Fri, 14 Aug 2026 15:01:29 +0800 Subject: [PATCH] update --- apps/web-console/app.js | 7 +- apps/web-console/index.html | 14 +- apps/web-console/self-calibration.css | 86 ++++- apps/web-console/self-calibration.js | 80 +++- apps/web-console/self-extension.css | 234 ++++++++++++ apps/web-console/self-extension.js | 111 +++++- apps/web-console/self-repair-seat3d.js | 348 +++++++++++++++++- apps/web-console/self-repair.js | 29 +- apps/web-console/service-worker.js | 16 +- .../denza_n7/user/mode-ceff921663.yaml | 46 --- .../application/calibration/service.py | 25 ++ .../application/mode_profile.py | 65 +++- .../application/seat_service.py | 11 +- src/cognitive_agent/server.py | 1 + tests/e2e/test_seat_web_api.py | 18 +- tests/e2e/test_self_calibration_api.py | 16 +- tests/e2e/test_self_repair_api.py | 8 +- tests/unit/test_mode_profile.py | 46 +++ .../test_requested_frontend_enhancements.py | 57 +++ tests/unit/test_self_calibration.py | 29 +- 20 files changed, 1125 insertions(+), 122 deletions(-) delete mode 100644 config/modes/vehicles/denza_n7/user/mode-ceff921663.yaml create mode 100644 tests/unit/test_requested_frontend_enhancements.py diff --git a/apps/web-console/app.js b/apps/web-console/app.js index af56591..b0bde15 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -19,7 +19,12 @@ function createConversationId() { function loadConversationId() { const stored = sessionStorage.getItem("cognitive-agent.conversation-id"); - if (stored?.startsWith("conv-")) return stored; + // A browser runtime restart is a new virtual run. Do not reuse the old + // conversation actuator state; hardware mode will repopulate this fresh + // context from authoritative CAN feedback when it is selected. + if (stored?.startsWith("conv-")) { + sessionStorage.removeItem("cognitive-agent.conversation-id"); + } const conversationId = createConversationId(); sessionStorage.setItem("cognitive-agent.conversation-id", conversationId); return conversationId; diff --git a/apps/web-console/index.html b/apps/web-console/index.html index e94fdd4..1524d72 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -11,22 +11,22 @@ 智舱随行 · 整车智能控制台 - - + + - + - - - - + + + +
diff --git a/apps/web-console/self-calibration.css b/apps/web-console/self-calibration.css index 6216ba9..d86e0b9 100644 --- a/apps/web-console/self-calibration.css +++ b/apps/web-console/self-calibration.css @@ -463,6 +463,18 @@ cursor: grabbing; } +.v2-calibration-seat-scene .calibration-cartoon-viewer.axis-hover { + cursor: ns-resize; +} + +.v2-calibration-seat-scene .calibration-cartoon-viewer.axis-hover[data-hover-axis="0"] { + cursor: ew-resize; +} + +.v2-calibration-seat-scene .calibration-cartoon-viewer.axis-dragging { + cursor: grabbing; +} + .v2-calibration-seat-scene .seat-model-host canvas { width: 100%; height: 100%; @@ -551,8 +563,9 @@ .v2-axis-tag { position: absolute; - z-index: 3; + z-index: 4; display: flex; + align-items: center; gap: 5px; padding: 5px 8px; color: #6d7c7e; @@ -561,18 +574,79 @@ border-radius: 8px; box-shadow: 0 7px 18px rgba(46, 78, 80, 0.07); font-size: 8px; - pointer-events: none; + cursor: not-allowed; + pointer-events: auto; + touch-action: none; + user-select: none; backdrop-filter: blur(8px); + transform: translate3d(-1000px, -1000px, 0); + transition: opacity 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + will-change: transform; +} + +.v2-axis-tag[data-control-enabled="true"] { + cursor: grab; +} + +.v2-axis-tag[data-control-enabled="true"].dragging { + cursor: grabbing; +} + +.v2-axis-tag:focus-visible { + outline: 2px solid rgba(49, 94, 170, 0.42); + outline-offset: 2px; +} + +.v2-axis-tag > i { + width: 5px; + height: 5px; + flex: 0 0 auto; + background: #5d82c2; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(79, 115, 178, 0.13); +} + +.v2-axis-tag > span { + display: flex; + gap: 5px; + white-space: nowrap; } .v2-axis-tag b { color: #315eaa; } -.v2-axis-tag.tag-back { top: 17%; right: 13%; } -.v2-axis-tag.tag-base { right: 11%; bottom: 19%; } -.v2-axis-tag.tag-leg { bottom: 24%; left: 14%; } -.v2-axis-tag.tag-height { top: 52%; left: 11%; } +.v2-axis-tag.active { + border-color: rgba(49, 94, 170, 0.42); + box-shadow: 0 7px 22px rgba(49, 94, 170, 0.18); +} + +.v2-axis-tag[data-hidden="true"] { + opacity: 0; +} + +.v2-calibration-label-lines { + position: absolute; + inset: 0; + z-index: 3; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.v2-calibration-label-lines line { + stroke: rgba(49, 94, 170, 0.42); + stroke-width: 1.15; + stroke-linecap: round; + stroke-dasharray: 2 3; + vector-effect: non-scaling-stroke; +} + +.v2-calibration-label-lines line[data-hidden="true"] { + opacity: 0; +} .v2-calibration-operation { min-width: 0; diff --git a/apps/web-console/self-calibration.js b/apps/web-console/self-calibration.js index 92aff34..52b7950 100644 --- a/apps/web-console/self-calibration.js +++ b/apps/web-console/self-calibration.js @@ -26,6 +26,7 @@ let toastTimer = 0; let settingsHydrated = false; let activeView = "live"; + let bootstrapResetComplete = false; function buildShell() { const sidebar = $(".v2-sidebar"); @@ -101,18 +102,24 @@
- +
正在构建座椅数字孪生 CALIBRATION · CARTOON TWIN
-
拖动旋转 · 滚轮缩放 · 双击复位
+
拖动空白处旋转 · 拖动轴标签或座椅部件调轴 · 滚轮缩放 · 双击复位
-
靠背 0.0°
-
前后 0.00 cm
-
腿托 0.0°
-
高度 0.00 cm
+ +
靠背 0.0°
+
前后 0.00 cm
+
腿托 0.0°
+
高度 0.00 cm
当前任务等待设备检测
JOB — @@ -234,6 +241,7 @@ const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`); render(data); + return data; } async function run(action, success = "") { @@ -300,6 +308,15 @@ renderWorkflow(state.operation.phase); renderAxes(state.axes, controller.job_active, transport.state === "online"); renderSeat(state.axes); + const seatViewer = $("#calibrationSeatViewer"); + const axisControlEnabled = transport.state === "online" && !controller.job_active; + if (seatViewer) { + seatViewer.dataset.axisControlEnabled = String(axisControlEnabled); + } + $$('[data-seat-axis-label]').forEach((label) => { + label.dataset.controlEnabled = String(axisControlEnabled); + label.setAttribute("aria-disabled", String(!axisControlEnabled)); + }); renderVideo(state.video); $("#calibrationStartAll").disabled = transport.state !== "online" || controller.job_active; @@ -411,6 +428,47 @@ $("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`; $("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`; $("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`; + [ + ["longitudinal", longitudinal], + ["legrest", legrest], + ["backrest", backrest], + ["height", height], + ].forEach(([name, axis]) => { + const label = $(`[data-seat-axis-label="${name}"]`); + if (!label) return; + label.setAttribute("aria-valuenow", String(Math.round(axis.permille))); + label.setAttribute("aria-valuetext", formatPhysical(axis, axis.permille)); + }); + } + + function previewSeatAxis(event) { + if (!currentState?.axes?.length) return; + const viewer = $("#calibrationSeatViewer"); + if (viewer?.dataset.axisControlEnabled !== "true") return; + const axisId = Number(event.detail?.axis); + const permille = Math.max(0, Math.min(1000, Number(event.detail?.permille))); + if (!Number.isInteger(axisId) || !Number.isFinite(permille)) return; + const axes = currentState.axes.map((axis) => ({ ...axis })); + const axis = axes.find((item) => item.id === axisId); + if (!axis) return; + axis.permille = permille; + axis.physical_value = axis.physical_range * permille / 1000; + const card = $(`.v2-calibration-axis-card[data-axis="${axisId}"]`); + if (card) { + const slider = $('input[type="range"]', card); + slider.value = String(permille); + $(".v2-calibration-axis-reading strong", card).textContent = formatPhysical(axis, permille); + } + renderSeat(axes); + } + + function commitSeatAxis(event) { + const viewer = $("#calibrationSeatViewer"); + if (viewer?.dataset.axisControlEnabled !== "true") return; + const axis = Number(event.detail?.axis); + const permille = Math.max(0, Math.min(1000, Math.round(Number(event.detail?.permille)))); + if (!Number.isInteger(axis) || !Number.isFinite(permille)) return; + run(() => api("/move", { axis, permille })); } function renderVideo(video) { @@ -542,7 +600,13 @@ closeEvents(); return; } - fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true)); + fetchState().then(async (state) => { + if (bootstrapResetComplete) return; + bootstrapResetComplete = true; + if (state.transport?.mode === "simulation") { + await api("/session/reset"); + } + }).catch((error) => toast(`自标定服务连接失败:${error.message}`, true)); connectEvents(); } @@ -583,6 +647,8 @@ }); window.addEventListener("cognitive-agent:module-change", activate); window.addEventListener("cognitive-agent:demo-role-change", activate); + window.addEventListener("calibration-seat-axis-preview", previewSeatAxis); + window.addEventListener("calibration-seat-axis-commit", commitSeatAxis); window.addEventListener("beforeunload", closeEvents); } diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 3620b08..3b2e746 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -89,11 +89,245 @@ .console-v2[data-module="self-extension"] .v2-self-extension { min-width: 0; min-height: 0; + position: relative; display: block; grid-row: 2; overflow: hidden; } +.v2-extension-pet { + --pet-x: 0px; + --pet-y: 0px; + width: 126px; + height: 126px; + position: absolute; + right: 18px; + bottom: 16px; + z-index: 24; + display: grid; + place-items: end center; + transform: translate3d(var(--pet-x), var(--pet-y), 0); + filter: drop-shadow(0 14px 16px rgba(9, 66, 63, 0.2)); + touch-action: none; + user-select: none; +} + +.v2-extension-pet-bubble { + max-width: 158px; + position: absolute; + right: 54px; + bottom: 89px; + padding: 7px 9px; + color: #285153; + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(14, 119, 115, 0.18); + border-radius: 11px 11px 2px 11px; + box-shadow: 0 9px 24px rgba(22, 72, 70, 0.12); + font-size: 8px; + font-weight: 800; + line-height: 1.45; + opacity: 0; + transform: translateY(5px) scale(0.96); + transform-origin: right bottom; + transition: 170ms ease; + pointer-events: none; +} + +.v2-extension-pet:hover .v2-extension-pet-bubble, +.v2-extension-pet[data-mood="talking"] .v2-extension-pet-bubble, +.v2-extension-pet[data-mood="happy"] .v2-extension-pet-bubble { + opacity: 1; + transform: translateY(0) scale(1); +} + +.v2-extension-pet-body { + width: 76px; + height: 77px; + position: relative; + display: block; + padding: 0; + color: inherit; + background: linear-gradient(145deg, #f4fffd 0%, #bfe9e5 54%, #7bc3bd 100%); + border: 2px solid rgba(7, 86, 82, 0.48); + border-radius: 25px 25px 22px 22px; + box-shadow: + inset 0 3px 0 rgba(255, 255, 255, 0.82), + inset 0 -6px 12px rgba(9, 93, 89, 0.13), + 0 6px 0 #326f6c; + cursor: grab; + animation: extension-pet-float 3.2s ease-in-out infinite; +} + +.v2-extension-pet[data-dragging="true"] .v2-extension-pet-body { + cursor: grabbing; + animation-play-state: paused; +} + +.v2-extension-pet-ear { + width: 22px; + height: 24px; + position: absolute; + top: -12px; + z-index: -1; + background: linear-gradient(145deg, #dff8f5, #75bbb6); + border: 2px solid rgba(7, 86, 82, 0.48); + border-radius: 7px 15px 5px 13px; +} + +.v2-extension-pet-ear.left { left: 3px; transform: rotate(-20deg); } +.v2-extension-pet-ear.right { right: 3px; transform: scaleX(-1) rotate(-20deg); } + +.v2-extension-pet-antenna { + width: 2px; + height: 17px; + position: absolute; + top: -21px; + left: 50%; + background: #397f7b; + transform: translateX(-50%); +} + +.v2-extension-pet-antenna i { + width: 8px; + height: 8px; + position: absolute; + top: -5px; + left: -3px; + background: #ffbd62; + border: 1px solid #9f6630; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(255, 189, 98, 0.17); + animation: extension-pet-signal 1.8s ease-in-out infinite; +} + +.v2-extension-pet-face { + width: 58px; + height: 34px; + position: absolute; + top: 9px; + left: 7px; + background: linear-gradient(180deg, #164e50, #0d383a); + border: 2px solid rgba(255, 255, 255, 0.65); + border-radius: 16px; + box-shadow: inset 0 4px 10px rgba(0, 0, 0, 0.2); +} + +.v2-extension-pet-face .eye { + width: 8px; + height: 10px; + position: absolute; + top: 9px; + background: #bdfaf2; + border-radius: 50%; + box-shadow: 0 0 7px rgba(141, 255, 241, 0.72); + animation: extension-pet-blink 4.6s infinite; +} + +.v2-extension-pet-face .eye.left { left: 12px; } +.v2-extension-pet-face .eye.right { right: 12px; } + +.v2-extension-pet-face b { + width: 6px; + height: 4px; + position: absolute; + top: 19px; + left: 50%; + background: #ffc17a; + border-radius: 50% 50% 65% 65%; + transform: translateX(-50%); +} + +.v2-extension-pet-face em { + width: 13px; + height: 6px; + position: absolute; + top: 22px; + left: 50%; + border-bottom: 2px solid #bdfaf2; + border-radius: 50%; + transform: translateX(-50%); +} + +.v2-extension-pet[data-mood="happy"] .v2-extension-pet-face .eye { + height: 5px; + top: 13px; + background: transparent; + border-top: 2px solid #bdfaf2; + border-radius: 50%; +} + +.v2-extension-pet-screen { + width: 36px; + height: 17px; + position: absolute; + bottom: 7px; + left: 18px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: #eafffb; + background: #277c78; + border: 1px solid rgba(6, 79, 75, 0.5); + border-radius: 7px; + font: 900 6px Consolas, monospace; +} + +.v2-extension-pet-screen i { + width: 5px; + height: 5px; + background: #80f0bd; + border-radius: 50%; + box-shadow: 0 0 5px rgba(128, 240, 189, 0.8); +} + +.v2-extension-pet-foot { + width: 21px; + height: 10px; + position: absolute; + bottom: -12px; + background: #5ba5a0; + border: 2px solid #326f6c; + border-radius: 4px 4px 9px 9px; +} + +.v2-extension-pet-foot.left { left: 8px; } +.v2-extension-pet-foot.right { right: 8px; } + +.v2-extension-pet-tail { + width: 27px; + height: 11px; + position: absolute; + right: -21px; + bottom: 12px; + z-index: -1; + border: 5px solid #4d9994; + border-left: 0; + border-radius: 0 13px 13px 0; + transform-origin: left center; + animation: extension-pet-tail 1.1s ease-in-out infinite alternate; +} + +@keyframes extension-pet-float { + 0%, 100% { transform: translateY(0) rotate(-1deg); } + 50% { transform: translateY(-5px) rotate(1deg); } +} + +@keyframes extension-pet-blink { + 0%, 45%, 49%, 100% { transform: scaleY(1); } + 47% { transform: scaleY(0.12); } +} + +@keyframes extension-pet-signal { + 0%, 100% { opacity: 0.72; transform: scale(0.86); } + 50% { opacity: 1; transform: scale(1.15); } +} + +@keyframes extension-pet-tail { + from { transform: rotate(-14deg); } + to { transform: rotate(16deg); } +} + .v2-extension-sidebar > .v2-field-caption { display: block; margin: 0 0 8px; diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 4793edd..1007a0b 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -278,7 +278,7 @@ const state = loadState(); const pages = Object.fromEntries(Object.keys(TOY_TYPES).map((key) => [key, 0])); - let activeMode = sessionStorage.getItem(MODE_KEY) === "control" ? "control" : "study"; + let activeMode = "study"; let selectedAddType = "light"; let learning = freshLearningSession(); let isPlanning = false; @@ -290,6 +290,8 @@ let latestVehicleStateTurn = -1; let sharedTwinBand = null; let sharedTwinHome = null; + let petTimer = null; + let petPhraseIndex = 0; function uid(prefix) { if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`; @@ -347,6 +349,14 @@ : {}; saved.selectedDeviceByType = saved.selectedDeviceByType || {}; saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 }; + // Learned capabilities are durable configuration; execution lamps, history, + // and protocol feedback belong to one virtual run and must never survive a restart. + saved.history = []; + saved.lastProtocol = null; + saved.vehicleExecutions = {}; + saved.sessionStartedAt = null; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + sessionStorage.removeItem(MODE_KEY); return saved; } catch (error) { console.warn("读取自扩展能力数据失败,已恢复默认状态。", error); @@ -506,7 +516,18 @@ CH0 / CH1等待权威车端状态
- `; + + `; moduleSwitcher.insertAdjacentElement("afterend", root); buildDialogs(); @@ -1603,6 +1624,91 @@ } } + function initializeDesktopPet() { + const pet = $("#extensionDesktopPet"); + const body = $("#extensionPetBody"); + const bubble = $("#extensionPetBubble"); + const host = $("#v2SelfExtension"); + if (!pet || !body || !bubble || !host) return; + const phrases = [ + "嗨,我会陪你学习新能力", + "拖动我可以换个位置", + "玩具动作和车端设备都能一起编排", + "虚拟运行重启后,我也会恢复待命", + "点击车端动作,我会帮你盯着执行状态", + ]; + const pointer = { active: false, moved: false, x: 0, y: 0, startX: 0, startY: 0 }; + + const say = (message, mood = "talking") => { + bubble.textContent = message; + pet.dataset.mood = mood; + window.clearTimeout(Number(pet.dataset.moodTimer || 0)); + pet.dataset.moodTimer = String(window.setTimeout(() => { + pet.dataset.mood = "idle"; + }, 2600)); + }; + + const clampPosition = (x, y) => { + const hostRect = host.getBoundingClientRect(); + const petRect = pet.getBoundingClientRect(); + return { + x: Math.max(-hostRect.width + petRect.width + 10, Math.min(0, x)), + y: Math.max(-hostRect.height + petRect.height + 10, Math.min(0, y)), + }; + }; + + body.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + pointer.active = true; + pointer.moved = false; + pointer.startX = event.clientX; + pointer.startY = event.clientY; + pointer.x = Number(pet.dataset.x || 0); + pointer.y = Number(pet.dataset.y || 0); + body.setPointerCapture?.(event.pointerId); + pet.dataset.dragging = "true"; + }); + body.addEventListener("pointermove", (event) => { + if (!pointer.active) return; + const deltaX = event.clientX - pointer.startX; + const deltaY = event.clientY - pointer.startY; + pointer.moved ||= Math.hypot(deltaX, deltaY) > 4; + const position = clampPosition(pointer.x + deltaX, pointer.y + deltaY); + pet.dataset.x = String(position.x); + pet.dataset.y = String(position.y); + pet.style.setProperty("--pet-x", `${position.x}px`); + pet.style.setProperty("--pet-y", `${position.y}px`); + }); + const endDrag = () => { + pointer.active = false; + delete pet.dataset.dragging; + }; + body.addEventListener("pointerup", endDrag); + body.addEventListener("pointercancel", endDrag); + body.addEventListener("click", () => { + if (pointer.moved) return; + petPhraseIndex = (petPhraseIndex + 1) % phrases.length; + say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking"); + }); + window.addEventListener("resize", () => { + const position = clampPosition(Number(pet.dataset.x || 0), Number(pet.dataset.y || 0)); + pet.dataset.x = String(position.x); + pet.dataset.y = String(position.y); + pet.style.setProperty("--pet-x", `${position.x}px`); + pet.style.setProperty("--pet-y", `${position.y}px`); + }); + window.addEventListener("cognitive-agent:module-change", (event) => { + if (event.detail?.module === "self-extension") say("自扩展工作台已就绪", "happy"); + }); + petTimer = window.setInterval(() => { + if (document.visibilityState !== "visible" + || document.body.dataset.module !== "self-extension") return; + petPhraseIndex = (petPhraseIndex + 1) % phrases.length; + say(phrases[petPhraseIndex]); + }, 14000); + window.addEventListener("pagehide", () => window.clearInterval(petTimer)); + } + function bindEvents() { $$('[data-extension-mode]').forEach((button) => { button.addEventListener("click", () => setMode(button.dataset.extensionMode)); @@ -1723,6 +1829,7 @@ function init() { if (!buildShell()) return; + initializeDesktopPet(); bindEvents(); renderAll(); setMode(activeMode); diff --git a/apps/web-console/self-repair-seat3d.js b/apps/web-console/self-repair-seat3d.js index 4aa7950..a2897c8 100644 --- a/apps/web-console/self-repair-seat3d.js +++ b/apps/web-console/self-repair-seat3d.js @@ -37,6 +37,8 @@ initialAxes = [35, 42, 48, 20], readyClass = "ready", axisMode = "repair", + enableAxisDrag = false, + labelBindings = null, } = options; const THREE = window.THREE; const canvas = document.getElementById(canvasId); @@ -135,7 +137,19 @@ const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40); const target = new THREE.Vector3(0, 1.15, 0); const orbit = { yaw: 0.67, pitch: 0.21, distance: 6.8 }; - const pointer = { active: false, x: 0, y: 0 }; + const pointer = { + active: false, + x: 0, + y: 0, + startX: 0, + startY: 0, + startValue: 0, + axis: null, + mode: "orbit", + moved: false, + dragVector: { x: 1, y: 0 }, + dragSource: "model", + }; scene.add(new THREE.HemisphereLight(0xffffff, mixHex(themeStrong, white, 0.46), 1.35)); const keyLight = new THREE.DirectionalLight(0xffffff, 1.8); @@ -203,6 +217,17 @@ transparent: true, opacity: 0.72, }); + const axisHitTargets = []; + + function markAxisTarget(object, axis) { + if (!object || !Number.isInteger(axis)) return object; + object.traverse((child) => { + if (!child.isMesh) return; + child.userData.calibrationAxis = axis; + axisHitTargets.push(child); + }); + return object; + } function addEdges(mesh, geometry, opacity = 0.3) { const material = edgeMaterial.clone(); @@ -283,8 +308,10 @@ seatRoot.position.set(0, 0.02, 0); scene.add(seatRoot); - makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial); - makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial); + const leftRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], railMaterial); + const rightRail = makeBox(seatRoot, [0.11, 0.08, 2.65], [0.48, 0.12, 0], railMaterial); + markAxisTarget(leftRail, 0); + markAxisTarget(rightRail, 0); makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structureMaterial); makeBox(seatRoot, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structureMaterial); @@ -296,6 +323,7 @@ base.position.set(0, 0.34, -0.02); base.castShadow = true; slideRoot.add(base); + markAxisTarget(base, 3); const cushionPivot = new THREE.Group(); cushionPivot.position.set(0, 0.57, 0.06); @@ -311,6 +339,7 @@ }); makeSeam(cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]]); makeSeam(cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]]); + markAxisTarget(cushionPivot, 3); const backrestPivot = new THREE.Group(); backrestPivot.position.set(0, 0.62, -0.5); @@ -339,6 +368,7 @@ headrestInsert.scale.set(1.32, 1, 0.28); headrestInsert.position.set(0, 1.98, 0.18); backrestPivot.add(headrestInsert); + markAxisTarget(backrestPivot, 2); const legrestPivot = new THREE.Group(); legrestPivot.position.set(0, 0.64, 0.7); @@ -348,6 +378,22 @@ legShell.scale.set(0.9, 0.72, 0.54); const legInsert = makePanel(legrestPivot, cushionShape(0.2), 0.045, [0, 0.12, 0.4], [-Math.PI / 2, 0, 0], seatInsert); legInsert.scale.set(0.92, 0.74, 0.56); + markAxisTarget(legrestPivot, 1); + + const labelAnchors = { + longitudinal: new THREE.Object3D(), + height: new THREE.Object3D(), + backrest: new THREE.Object3D(), + legrest: new THREE.Object3D(), + }; + labelAnchors.longitudinal.position.set(0.62, 0.16, 0.78); + slideRoot.add(labelAnchors.longitudinal); + labelAnchors.height.position.set(-0.72, 0.34, -0.08); + slideRoot.add(labelAnchors.height); + labelAnchors.backrest.position.set(0.62, 1.16, 0.2); + backrestPivot.add(labelAnchors.backrest); + labelAnchors.legrest.position.set(-0.48, 0.14, 0.42); + legrestPivot.add(labelAnchors.legrest); const statusHalo = new THREE.Mesh( new THREE.RingGeometry(1.12, 1.18, 72), @@ -387,6 +433,100 @@ const current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; const desired = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" }; + const labelLines = labelBindings?.lineRoot + ? document.querySelector(labelBindings.lineRoot) + : null; + const labelAxisByName = { longitudinal: 0, legrest: 1, backrest: 2, height: 3 }; + const labelLayout = labelBindings + ? Object.entries(labelBindings.items || {}).map(([name, binding]) => ({ + name, + axis: labelAxisByName[name], + side: binding.side === "left" ? "left" : "right", + yOffset: Number(binding.yOffset || 0), + anchor: labelAnchors[name], + tag: document.querySelector(binding.selector), + line: labelLines?.querySelector(`[data-seat-label-line="${name}"]`) || null, + })).filter((item) => item.anchor && item.tag) + : []; + const projected = new THREE.Vector3(); + const worldPoint = new THREE.Vector3(); + + function setLabelHidden(item, hidden) { + item.tag.dataset.hidden = String(hidden); + if (item.line) item.line.dataset.hidden = String(hidden); + } + + function layoutSeatLabels() { + if (!labelLayout.length || !viewerHasLayout(viewer)) return; + const width = viewer.clientWidth; + const height = viewer.clientHeight; + const margin = 10; + const gap = 8; + const lanes = { left: [], right: [] }; + + labelLayout.forEach((item) => { + item.anchor.getWorldPosition(worldPoint); + projected.copy(worldPoint).project(camera); + const visible = projected.z >= -1 && projected.z <= 1; + if (!visible) { + setLabelHidden(item, true); + return; + } + const anchorX = (projected.x * 0.5 + 0.5) * width; + const anchorY = (-projected.y * 0.5 + 0.5) * height; + const tagWidth = Math.max(54, item.tag.offsetWidth); + const tagHeight = Math.max(20, item.tag.offsetHeight); + const desiredLeft = item.side === "left" + ? anchorX - tagWidth - 44 + : anchorX + 44; + lanes[item.side].push({ + ...item, + anchorX, + anchorY, + tagWidth, + tagHeight, + left: Math.max(margin, Math.min(width - tagWidth - margin, desiredLeft)), + desiredTop: Math.max( + margin, + Math.min(height - tagHeight - margin, anchorY - tagHeight / 2 + item.yOffset), + ), + }); + setLabelHidden(item, false); + }); + + Object.values(lanes).forEach((lane) => { + lane.sort((a, b) => a.desiredTop - b.desiredTop); + let cursor = margin; + lane.forEach((item) => { + item.top = Math.max(item.desiredTop, cursor); + cursor = item.top + item.tagHeight + gap; + }); + const overflow = cursor - gap + margin - height; + if (overflow > 0) lane.forEach((item) => { item.top -= overflow; }); + cursor = margin; + lane.forEach((item) => { + item.top = Math.max(cursor, Math.min(height - item.tagHeight - margin, item.top)); + cursor = item.top + item.tagHeight + gap; + }); + + lane.forEach((item) => { + item.tag.style.transform = `translate3d(${item.left.toFixed(1)}px, ${item.top.toFixed(1)}px, 0)`; + if (!item.line) return; + const endX = item.side === "left" ? item.left + item.tagWidth : item.left; + const endY = item.top + item.tagHeight / 2; + item.line.setAttribute("x1", item.anchorX.toFixed(1)); + item.line.setAttribute("y1", item.anchorY.toFixed(1)); + item.line.setAttribute("x2", endX.toFixed(1)); + item.line.setAttribute("y2", endY.toFixed(1)); + }); + }); + } + + function setActiveAxis(axis) { + labelLayout.forEach((item) => { + item.tag.classList.toggle("active", item.axis === axis); + }); + } function updateCamera() { const cosPitch = Math.cos(orbit.pitch); @@ -440,6 +580,8 @@ const detail = event.detail || {}; (detail.axes || []).forEach((axis) => { const motor = Number(axis.motor); + if (pointer.active && pointer.mode === "axis" + && axisToModelIndex[pointer.axis] === motor) return; if (motor >= 0 && motor < desired.axes.length) { desired.axes[motor] = Math.max(0, Math.min(100, Number(axis.position_pct || 0))); } @@ -504,20 +646,196 @@ ? 0.12 + Math.sin(performance.now() * 0.005) * 0.04 : 0.06; + scene.updateMatrixWorld(true); + layoutSeatLabels(); renderer.render(scene, camera); } + const raycaster = new THREE.Raycaster(); + const rayPointer = new THREE.Vector2(); + const axisToModelIndex = { 0: 0, 1: 3, 2: 2, 3: 1 }; + const labelNameByAxis = { 0: "longitudinal", 1: "legrest", 2: "backrest", 3: "height" }; + const fallbackDragVectors = { + 0: { x: 1, y: 0 }, + 1: { x: -0.7, y: -0.7 }, + 2: { x: 0.8, y: 0.6 }, + 3: { x: 0, y: -1 }, + }; + + function applyAxisModelPose(axis, value) { + if (axis === 0) { + slideRoot.position.z = THREE.MathUtils.mapLinear(value, 0, 100, -0.46, 0.46); + } else if (axis === 1) { + legrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.08, -0.68); + } else if (axis === 2) { + backrestPivot.rotation.x = THREE.MathUtils.mapLinear(value, 0, 100, 0.16, -0.44); + } else if (axis === 3 && axisMode === "calibration") { + slideRoot.position.y = THREE.MathUtils.mapLinear(value, 0, 100, -0.12, 0.16); + } + } + + function projectAxisAnchor(anchor) { + anchor.getWorldPosition(worldPoint); + projected.copy(worldPoint).project(camera); + return { + x: (projected.x * 0.5 + 0.5) * viewer.clientWidth, + y: (-projected.y * 0.5 + 0.5) * viewer.clientHeight, + }; + } + + function calculateAxisDragVector(axis, value) { + const anchor = labelAnchors[labelNameByAxis[axis]]; + if (!anchor) return fallbackDragVectors[axis]; + const low = Math.max(0, value - 8); + const high = Math.min(100, value + 8); + if (high - low < 1) return fallbackDragVectors[axis]; + const saved = { + slideY: slideRoot.position.y, + slideZ: slideRoot.position.z, + backrestX: backrestPivot.rotation.x, + legrestX: legrestPivot.rotation.x, + }; + applyAxisModelPose(axis, low); + scene.updateMatrixWorld(true); + const start = projectAxisAnchor(anchor); + applyAxisModelPose(axis, high); + scene.updateMatrixWorld(true); + const end = projectAxisAnchor(anchor); + slideRoot.position.y = saved.slideY; + slideRoot.position.z = saved.slideZ; + backrestPivot.rotation.x = saved.backrestX; + legrestPivot.rotation.x = saved.legrestX; + scene.updateMatrixWorld(true); + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.hypot(dx, dy); + return length > 0.5 + ? { x: dx / length, y: dy / length } + : fallbackDragVectors[axis]; + } + + function startAxisDrag(axis, event, source = "model") { + if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true" + || !Number.isInteger(axis)) return false; + event.preventDefault(); + event.stopPropagation(); + pointer.active = true; + pointer.x = event.clientX; + pointer.y = event.clientY; + pointer.startX = event.clientX; + pointer.startY = event.clientY; + pointer.moved = false; + pointer.axis = axis; + pointer.mode = "axis"; + pointer.dragSource = source; + const modelIndex = axisToModelIndex[axis]; + pointer.startValue = desired.axes[modelIndex]; + pointer.dragVector = calculateAxisDragVector(axis, pointer.startValue); + viewer.dataset.dragAxis = String(axis); + viewer.dataset.axisDragSource = source; + viewer.classList.add("axis-dragging"); + viewer.classList.remove("axis-hover"); + labelLayout.forEach((item) => { + item.tag.classList.toggle("dragging", source === "label" && item.axis === axis); + }); + setActiveAxis(axis); + return true; + } + + function axisAtPoint(clientX, clientY) { + if (!enableAxisDrag || viewer.dataset.axisControlEnabled !== "true") return null; + const bounds = viewer.getBoundingClientRect(); + if (clientX < bounds.left || clientX > bounds.right + || clientY < bounds.top || clientY > bounds.bottom) return null; + rayPointer.set( + ((clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1, + -((clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1, + ); + raycaster.setFromCamera(rayPointer, camera); + const hit = raycaster.intersectObjects(axisHitTargets, false)[0]; + return Number.isInteger(hit?.object?.userData?.calibrationAxis) + ? hit.object.userData.calibrationAxis + : null; + } + + function updateAxisHover(event) { + if (pointer.active) return; + const axis = axisAtPoint(event.clientX, event.clientY); + viewer.classList.toggle("axis-hover", axis !== null); + if (axis === null) delete viewer.dataset.hoverAxis; + else viewer.dataset.hoverAxis = String(axis); + setActiveAxis(axis); + } + + viewer.addEventListener("pointermove", updateAxisHover); + viewer.addEventListener("pointerleave", () => { + if (pointer.active) return; + viewer.classList.remove("axis-hover"); + delete viewer.dataset.hoverAxis; + setActiveAxis(null); + }); viewer.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; + const axis = axisAtPoint(event.clientX, event.clientY); + if (axis !== null && startAxisDrag(axis, event)) return; event.preventDefault(); pointer.active = true; pointer.x = event.clientX; pointer.y = event.clientY; + pointer.startX = event.clientX; + pointer.startY = event.clientY; + pointer.moved = false; + pointer.axis = null; + pointer.mode = "orbit"; + pointer.dragSource = "model"; viewer.classList.add("dragging"); }); + labelLayout.forEach((item) => { + item.tag.dataset.axis = String(item.axis); + item.tag.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + startAxisDrag(item.axis, event, "label"); + }); + item.tag.addEventListener("pointerenter", () => { + if (!pointer.active && viewer.dataset.axisControlEnabled === "true") { + setActiveAxis(item.axis); + } + }); + item.tag.addEventListener("pointerleave", () => { + if (!pointer.active) setActiveAxis(null); + }); + item.tag.addEventListener("keydown", (event) => { + if (viewer.dataset.axisControlEnabled !== "true") return; + const direction = ["ArrowRight", "ArrowUp"].includes(event.key) + ? 1 + : ["ArrowLeft", "ArrowDown"].includes(event.key) ? -1 : 0; + if (!direction) return; + event.preventDefault(); + const modelIndex = axisToModelIndex[item.axis]; + const value = Math.max(0, Math.min(100, desired.axes[modelIndex] + direction * 2.5)); + desired.axes[modelIndex] = value; + const detail = { axis: item.axis, permille: Math.round(value * 10) }; + window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", { detail })); + window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", { detail })); + }); + }); window.addEventListener("pointermove", (event) => { if (!pointer.active) return; event.preventDefault(); + const totalX = event.clientX - pointer.startX; + const totalY = event.clientY - pointer.startY; + pointer.moved ||= Math.hypot(totalX, totalY) > 3; + if (pointer.mode === "axis") { + const delta = ( + totalX * pointer.dragVector.x + totalY * pointer.dragVector.y + ) * 0.55; + const value = Math.max(0, Math.min(100, pointer.startValue + delta)); + desired.axes[axisToModelIndex[pointer.axis]] = value; + window.dispatchEvent(new CustomEvent("calibration-seat-axis-preview", { + detail: { axis: pointer.axis, permille: Math.round(value * 10) }, + })); + return; + } orbit.yaw -= (event.clientX - pointer.x) * 0.008; orbit.pitch = Math.max(-0.08, Math.min(0.72, orbit.pitch + (event.clientY - pointer.y) * 0.006)); pointer.x = event.clientX; @@ -525,8 +843,22 @@ updateCamera(); }, { passive: false }); const endOrbit = () => { + if (pointer.active && pointer.mode === "axis" && pointer.moved) { + const value = desired.axes[axisToModelIndex[pointer.axis]]; + window.dispatchEvent(new CustomEvent("calibration-seat-axis-commit", { + detail: { axis: pointer.axis, permille: Math.round(value * 10) }, + })); + } pointer.active = false; + pointer.axis = null; + pointer.mode = "orbit"; + pointer.dragSource = "model"; + delete viewer.dataset.dragAxis; + delete viewer.dataset.axisDragSource; viewer.classList.remove("dragging"); + viewer.classList.remove("axis-dragging"); + labelLayout.forEach((item) => item.tag.classList.remove("dragging")); + setActiveAxis(null); }; window.addEventListener("pointerup", endOrbit); window.addEventListener("pointercancel", endOrbit); @@ -577,6 +909,16 @@ initialAxes: [50, 50, 20, 0], readyClass: "is-ready", axisMode: "calibration", + enableAxisDrag: true, + labelBindings: { + lineRoot: "#calibrationSeatLabelLines", + items: { + backrest: { selector: '[data-seat-axis-label="backrest"]', side: "right", yOffset: -28 }, + longitudinal: { selector: '[data-seat-axis-label="longitudinal"]', side: "right", yOffset: 24 }, + legrest: { selector: '[data-seat-axis-label="legrest"]', side: "left", yOffset: 24 }, + height: { selector: '[data-seat-axis-label="height"]', side: "left", yOffset: -18 }, + }, + }, }); } diff --git a/apps/web-console/self-repair.js b/apps/web-console/self-repair.js index 37bb104..44d9f20 100644 --- a/apps/web-console/self-repair.js +++ b/apps/web-console/self-repair.js @@ -89,10 +89,10 @@ let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware" ? "hardware" : "software"; - let executionMode = sessionStorage.getItem(MODE_KEY) === "bench" - ? "bench" - : "shadow"; + sessionStorage.removeItem(MODE_KEY); + let executionMode = "shadow"; let polling = false; + let bootstrapRuntimeReset = false; let toastTimer = 0; let confirmationResolve = null; let eventFilter = "all"; @@ -642,6 +642,29 @@ }); if (!response.ok) throw new Error(`HTTP ${response.status}`); latest = await response.json(); + if (!bootstrapRuntimeReset) { + bootstrapRuntimeReset = true; + const backendMode = latest.runtime?.execution_mode === "bench" ? "bench" : "shadow"; + if (backendMode === "bench") { + // A physical session is authoritative: retain it and render bench feedback. + executionMode = "bench"; + sessionStorage.setItem(MODE_KEY, executionMode); + if ($("#repairExecutionMode")) $("#repairExecutionMode").value = executionMode; + } else { + // Shadow state is one-run-only. A full web runtime restart always returns + // counters, injected faults, and the twin to the initial safe state. + if (latest.runtime?.active) { + await apiPost("/runtime", { active: false, execution_mode: "shadow" }); + } + await apiPost("/runtime/reset-statistics"); + const resetResponse = await fetch(`${API_ROOT}/state`, { + cache: "no-store", + credentials: "same-origin", + headers: { Accept: "application/json", "X-Cognitive-Agent-Role": "admin" }, + }); + if (resetResponse.ok) latest = await resetResponse.json(); + } + } if (!["IDLE"].includes(latest.healing?.phase)) selectedCaseId = latest.healing.case_id; renderAll(); } catch (error) { diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index cb09d95..dc8b944 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v117"; +const CACHE_VERSION = "cognitive-agent-pad-v119"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,18 +6,18 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-9", "/network.js?v=android-pad-1", - "/app.js?v=dual-seat-81", + "/app.js?v=dual-seat-82", "/gesture-control.js?v=android-pad-7", "/pwa.js?v=android-pad-1", "/console-v2.css?v=desktop-refactor-106", "/console-v2.js?v=desktop-refactor-35", - "/self-extension.css?v=extension-18", - "/self-extension.js?v=extension-20", - "/self-calibration.css?v=calibration-10", - "/self-calibration.js?v=calibration-6", + "/self-extension.css?v=extension-19", + "/self-extension.js?v=extension-21", + "/self-calibration.css?v=calibration-12", + "/self-calibration.js?v=calibration-8", "/self-repair.css?v=repair-21", - "/self-repair.js?v=repair-20", - "/self-repair-seat3d.js?v=repair-19", + "/self-repair.js?v=repair-21", + "/self-repair-seat3d.js?v=repair-21", "/assets/models/n7_seat.glb?v=e7f50f03", "/manifest.webmanifest", "/icons/app-icon-192.png", diff --git a/config/modes/vehicles/denza_n7/user/mode-ceff921663.yaml b/config/modes/vehicles/denza_n7/user/mode-ceff921663.yaml deleted file mode 100644 index 4a8da8e..0000000 --- a/config/modes/vehicles/denza_n7/user/mode-ceff921663.yaml +++ /dev/null @@ -1,46 +0,0 @@ -id: mode-ceff921663 -name: 躺平模式 -aliases: -- 躺平模式 -- 躺平 -description: 用户固化的长期模式配置。 -icon: bookmark -scope: - vehicle_ids: - - denza_n7 -operations: -- domain: seat - capability: seat.set_longitudinal_position - target_scope: passenger - operation: set - parameters: - position_pct: 90 - reason: 用户固化的座椅位置模式动作。 -- domain: seat - capability: seat.set_cushion_tilt - target_scope: passenger - operation: set - parameters: - position_pct: 40 - reason: 用户固化的座椅位置模式动作。 -- domain: seat - capability: seat.set_backrest_recline - target_scope: passenger - operation: set - parameters: - position_pct: 80 - reason: 用户固化的座椅位置模式动作。 -- domain: seat - capability: seat.set_legrest_height - target_scope: passenger - operation: set - parameters: - position_pct: 75 - reason: 用户固化的座椅位置模式动作。 -- domain: seat - capability: seat.set_legrest_extension - target_scope: passenger - operation: set - parameters: - position_pct: 80 - reason: 用户固化的座椅位置模式动作。 diff --git a/src/cognitive_agent/application/calibration/service.py b/src/cognitive_agent/application/calibration/service.py index 590d5d4..2106400 100644 --- a/src/cognitive_agent/application/calibration/service.py +++ b/src/cognitive_agent/application/calibration/service.py @@ -96,6 +96,31 @@ class CalibrationService: with self._lock: return json.loads(json.dumps(self.state, ensure_ascii=False)) + def reset_virtual_session(self) -> dict[str, Any]: + """Return a simulation run to its safe initial state without touching hardware.""" + + with self._lock: + mode = str(self.state["transport"].get("mode", "hardware")) + if mode != "simulation": + raise ApiError("真实台架会话不能通过虚拟重启接口复位", 409) + self.video.stop() + if self._mqtt: + self._mqtt.close() + self._mqtt = None + if self._transport: + self._transport.close() + self._transport = None + with self._lock: + self._pending.clear() + self._saved_job = -1 + self.state = self._initial_state() + self.state["transport"].update( + {"mode": "simulation", "state": "disconnected", "detail": "虚拟运行已恢复初始状态"} + ) + self.state["operation"]["label"] = "等待连接标定仿真台架" + self._touch() + return self.snapshot() + def wait_for_version(self, after: int, timeout: float = 15.0) -> dict[str, Any]: deadline = time.monotonic() + timeout while not self._closing.is_set() and time.monotonic() < deadline: diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index 6a1b006..53427f3 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -363,22 +363,33 @@ class ModeProfile: class ModeProfileRegistry: - """Load, match, and persist long-term cabin mode profiles.""" + """Load preset profiles and optionally keep user profiles in a session overlay.""" - def __init__(self, profile_root: Path): + def __init__(self, profile_root: Path, user_root: Path | None = None): self.profile_root = Path(profile_root) + self.user_root = Path(user_root) if user_root is not None else self.profile_root + self.session_user_profiles = user_root is not None self._profiles: list[ModeProfile] = [] self.reload() def reload(self) -> None: - if not self.profile_root.exists(): - self._profiles = [] - return - self._profiles = [ - ModeProfile.from_yaml(path, self.profile_root) - for path in sorted(self.profile_root.rglob("*.yaml")) - if path.is_file() - ] + profiles: list[ModeProfile] = [] + if self.profile_root.exists(): + for path in sorted(self.profile_root.rglob("*.yaml")): + if not path.is_file(): + continue + if self.session_user_profiles and _is_user_profile_path( + path, self.profile_root + ): + continue + profiles.append(ModeProfile.from_yaml(path, self.profile_root)) + if self.session_user_profiles and self.user_root.exists(): + profiles.extend( + ModeProfile.from_yaml(path, self.user_root) + for path in sorted(self.user_root.rglob("*.yaml")) + if path.is_file() + ) + self._profiles = profiles def profiles(self) -> list[ModeProfile]: return list(self._profiles) @@ -459,9 +470,9 @@ class ModeProfileRegistry: ], } path = ( - self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" + self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" if vehicle_id - else self.profile_root / "common" / "user" / f"{profile_id}.yaml" + else self.user_root / "common" / "user" / f"{profile_id}.yaml" ) path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") @@ -471,7 +482,7 @@ class ModeProfileRegistry: ) temporary.replace(path) self.reload() - return ModeProfile.from_yaml(path, self.profile_root) + return ModeProfile.from_yaml(path, self.user_root) def delete_user_mode( self, @@ -495,11 +506,15 @@ class ModeProfileRegistry: raise PermissionError("preset mode profiles cannot be deleted") path = Path(profile.source).resolve() - profile_root = self.profile_root.resolve() - try: - relative_parts = path.relative_to(profile_root).parts - except ValueError as exc: - raise PermissionError("mode profile is outside the configured registry") from exc + relative_parts = None + for root in {self.profile_root.resolve(), self.user_root.resolve()}: + try: + relative_parts = path.relative_to(root).parts + break + except ValueError: + continue + if relative_parts is None: + raise PermissionError("mode profile is outside the configured registry") if "user" not in relative_parts or path.suffix.lower() != ".yaml": raise PermissionError("only user-defined mode profiles can be deleted") if not path.is_file(): @@ -532,7 +547,7 @@ class ModeProfileRegistry: document = _mode_profile_document(profile, merged_operations) _replace_yaml(path, document) self.reload() - return ModeProfile.from_yaml(path, self.profile_root) + return ModeProfile.from_yaml(path, self._root_for_path(path)) def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile: path = Path(profile.source) @@ -544,7 +559,17 @@ class ModeProfileRegistry: temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8") temporary.replace(path) self.reload() - return ModeProfile.from_yaml(path, self.profile_root) + return ModeProfile.from_yaml(path, self._root_for_path(path)) + + def _root_for_path(self, path: Path) -> Path: + resolved = path.resolve() + for root in (self.user_root.resolve(), self.profile_root.resolve()): + try: + resolved.relative_to(root) + return root + except ValueError: + continue + return self.profile_root def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]: selected: dict[str, ModeProfile] = {} diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 899beeb..a115020 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -6,6 +6,7 @@ import json import os import re import sys +import tempfile import time import uuid from concurrent.futures import ThreadPoolExecutor @@ -237,7 +238,14 @@ class SeatDemoService: } self.model_plan_adapter = ModelPlanAdapter(self.capability_context) self.vehicle_profiles = VehicleProfileRegistry(repository_root / "config" / "vehicles") - self.mode_profiles = ModeProfileRegistry(repository_root / "config" / "modes") + self._mode_session_directory = tempfile.TemporaryDirectory( + prefix="cognitive-agent-mode-session-", + ignore_cleanup_errors=True, + ) + self.mode_profiles = ModeProfileRegistry( + repository_root / "config" / "modes", + user_root=Path(self._mode_session_directory.name), + ) self._artifact_root = repository_root / "artifacts" / "runs" self.conversations = ConversationStateStore( preset_root=repository_root / "artifacts" / "user-presets" @@ -343,6 +351,7 @@ class SeatDemoService: "vehicle": default_vehicle.public_dict(), "vehicles": self.vehicle_profiles.public_profiles(), "modes": self.mode_profiles.public_modes(default_vehicle.id), + "mode_profile_storage": "runtime_session_volatile", "preset_storage": "conversation_durable", "inferred_level_range": [1, 3], "motion_position_range_pct": [0, 100], diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 45388b6..39869cb 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -505,6 +505,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): int(payload["axis"]), bool(payload.get("far")) ), "/api/calibration/stop": self.calibration.stop, + "/api/calibration/session/reset": self.calibration.reset_virtual_session, "/api/calibration/settings": lambda: self.calibration.update_settings( dict(payload) ), diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index adcfea5..5b9ea44 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -177,7 +177,7 @@ operations: def test_static_console_and_runtime_endpoint(self) -> None: with self.opener.open(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") - with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-81", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-82", timeout=3) as response: app_script = response.read().decode("utf-8") with self.opener.open( f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 @@ -192,11 +192,11 @@ operations: ) as response: console_styles = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/self-extension.js?v=extension-20", timeout=3 + f"{self.base_url}/self-extension.js?v=extension-21", timeout=3 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/self-extension.css?v=extension-18", timeout=3 + f"{self.base_url}/self-extension.css?v=extension-19", timeout=3 ) as response: extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") with self.opener.open( @@ -223,14 +223,14 @@ operations: self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertNotIn("透明车体 · 实体设备", html) - self.assertIn('src="/app.js?v=dual-seat-81"', html) + self.assertIn('src="/app.js?v=dual-seat-82"', html) self.assertIn('href="/styles.css?v=dual-seat-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertNotIn('class="vehicle-system-hud"', html) self.assertIn('src="/console-v2.js?v=desktop-refactor-35"', html) self.assertIn('href="/console-v2.css?v=desktop-refactor-106"', html) - self.assertIn('src="/self-extension.js?v=extension-20"', html) - self.assertIn('href="/self-extension.css?v=extension-18"', html) + self.assertIn('src="/self-extension.js?v=extension-21"', html) + self.assertIn('href="/self-extension.css?v=extension-19"', html) self.assertNotIn("滚动查看更多", html) self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) @@ -455,7 +455,7 @@ operations: self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) - self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v117"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v119"', service_worker) self.assertIn( '"/assets/models/n7_seat.glb?v=e7f50f03"', service_worker ) @@ -664,11 +664,11 @@ operations: def test_self_extension_vehicle_success_contract_is_strict(self) -> None: with self.opener.open( - f"{self.base_url}/self-extension.js?v=extension-20", timeout=3 + f"{self.base_url}/self-extension.js?v=extension-21", timeout=3 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-81", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-82", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/e2e/test_self_calibration_api.py b/tests/e2e/test_self_calibration_api.py index f7b35fa..abe13b6 100644 --- a/tests/e2e/test_self_calibration_api.py +++ b/tests/e2e/test_self_calibration_api.py @@ -122,16 +122,24 @@ class TestSelfCalibrationApi: stopped = json.loads(response.read().decode("utf-8"))["state"] assert not stopped["controller"]["armed"] + with urllib.request.urlopen( + self.request("/api/calibration/session/reset", method="POST", payload={}), + timeout=3, + ) as response: + reset = json.loads(response.read().decode("utf-8"))["state"] + assert reset["transport"]["state"] == "disconnected" + assert [axis["permille"] for axis in reset["axes"]] == [0, 0, 0, 0] + def test_frontend_and_original_model_are_served_by_existing_service(self) -> None: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-calibration.js?v=calibration-6", + f"{self.base_url}/self-calibration.js?v=calibration-8", timeout=3, ) as response: script = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-calibration.css?v=calibration-10", + f"{self.base_url}/self-calibration.css?v=calibration-12", timeout=3, ) as response: styles = response.read().decode("utf-8") @@ -160,8 +168,8 @@ class TestSelfCalibrationApi: timeout=3, ) - assert 'href="/self-calibration.css?v=calibration-10"' in html - assert 'src="/self-calibration.js?v=calibration-6"' in html + assert 'href="/self-calibration.css?v=calibration-12"' in html + assert 'src="/self-calibration.js?v=calibration-8"' in html assert 'data-calibration-page="live"' in script assert 'data-calibration-view="live"' in script assert 'data-calibration-page="hall"' not in script diff --git a/tests/e2e/test_self_repair_api.py b/tests/e2e/test_self_repair_api.py index f599142..dee4be3 100644 --- a/tests/e2e/test_self_repair_api.py +++ b/tests/e2e/test_self_repair_api.py @@ -146,7 +146,7 @@ class TestSelfRepairApi: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-repair.js?v=repair-20", timeout=3 + f"{self.base_url}/self-repair.js?v=repair-21", timeout=3 ) as response: script = response.read().decode("utf-8").replace("\r\n", "\n") with urllib.request.urlopen( @@ -154,7 +154,7 @@ class TestSelfRepairApi: ) as response: styles = response.read().decode("utf-8") with urllib.request.urlopen( - f"{self.base_url}/self-repair-seat3d.js?v=repair-19", timeout=3 + f"{self.base_url}/self-repair-seat3d.js?v=repair-21", timeout=3 ) as response: seat3d = response.read().decode("utf-8") with urllib.request.urlopen( @@ -163,12 +163,12 @@ class TestSelfRepairApi: console_script = response.read().decode("utf-8") assert 'href="/self-repair.css?v=repair-21"' in html - assert 'src="/self-repair.js?v=repair-20"' in html + assert 'src="/self-repair.js?v=repair-21"' in html assert 'class="v2-module-heading"' in script assert 'SELF-REPAIR' in script assert 'padding: var(--v2-module-title-block) 0 0' in styles assert 'margin-inline: var(--v2-module-title-inline)' in styles - assert 'src="/self-repair-seat3d.js?v=repair-19"' in html + assert 'src="/self-repair-seat3d.js?v=repair-21"' in html assert "量产故障注入" in script assert "运行与自修复日志" in script assert "启动座椅运行" in script diff --git a/tests/unit/test_mode_profile.py b/tests/unit/test_mode_profile.py index 2b6144e..112b22a 100644 --- a/tests/unit/test_mode_profile.py +++ b/tests/unit/test_mode_profile.py @@ -356,6 +356,52 @@ operations: [{"name": "transmittance_pct", "value": 20}], ) + def test_session_overlay_ignores_stale_repository_user_modes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "profiles" + session_root = Path(directory) / "session" + stale = root / "vehicles" / "denza_n7" / "user" / "stale.yaml" + stale.parent.mkdir(parents=True) + stale.write_text( + """ +id: stale +name: 上次运行模式 +scope: + vehicle_ids: [denza_n7] +operations: +- domain: roof + capability: roof.set_transmittance + target_scope: vehicle + parameters: + transmittance_pct: 20 +""".strip() + "\n", + encoding="utf-8", + ) + registry = ModeProfileRegistry(root, user_root=session_root) + + self.assertEqual(registry.public_modes("denza_n7"), []) + created = registry.save_mode( + "本次运行模式", + [ + { + "domain": "roof", + "capability": "roof.set_transmittance", + "target_scope": "vehicle", + "parameters": {"transmittance_pct": 35}, + } + ], + vehicle_id="denza_n7", + ) + self.assertTrue(Path(created.source).is_relative_to(session_root)) + self.assertEqual( + {mode["id"] for mode in registry.public_modes("denza_n7")}, + {created.id}, + ) + + registry.delete_user_mode(created.id, "denza_n7") + self.assertFalse(Path(created.source).exists()) + self.assertEqual(registry.public_modes("denza_n7"), []) + def test_delete_user_mode_removes_yaml_and_protects_presets(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py new file mode 100644 index 0000000..f76bd05 --- /dev/null +++ b/tests/unit/test_requested_frontend_enhancements.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def source(name: str) -> str: + return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8") + + +def test_calibration_twin_tracks_labels_and_commits_direct_axis_drag() -> None: + calibration = source("self-calibration.js") + seat3d = source("self-repair-seat3d.js") + styles = source("self-calibration.css") + + assert 'data-seat-axis-label="backrest"' in calibration + assert 'data-seat-label-line="legrest"' in calibration + assert 'window.addEventListener("calibration-seat-axis-preview"' in calibration + assert 'window.addEventListener("calibration-seat-axis-commit"' in calibration + assert "function layoutSeatLabels()" in seat3d + assert "makePanel(legrestPivot, cushionShape(0.08)" in seat3d + assert "function legrestShape(" not in seat3d + assert 'enableAxisDrag: true' in seat3d + assert 'function startAxisDrag(axis, event, source = "model")' in seat3d + assert 'startAxisDrag(item.axis, event, "label")' in seat3d + assert "calculateAxisDragVector(axis, value)" in seat3d + assert "Math.max(0, Math.min(100," in seat3d + assert 'new CustomEvent("calibration-seat-axis-commit"' in seat3d + assert ".v2-calibration-label-lines line" in styles + assert '.v2-axis-tag[data-control-enabled="true"]' in styles + assert "pointer-events: auto;" in styles + + +def test_extension_pet_and_transient_vehicle_state_restart_contract() -> None: + extension = source("self-extension.js") + styles = source("self-extension.css") + + assert 'id="extensionDesktopPet"' in extension + assert "function initializeDesktopPet()" in extension + assert "saved.vehicleExecutions = {};" in extension + assert "saved.lastProtocol = null;" in extension + assert ".v2-extension-pet-body" in styles + assert "@keyframes extension-pet-blink" in styles + + +def test_virtual_modules_reset_but_physical_sessions_remain_authoritative() -> None: + orchestration = source("app.js") + calibration = source("self-calibration.js") + repair = source("self-repair.js") + + assert 'sessionStorage.removeItem("cognitive-agent.conversation-id")' in orchestration + assert 'api("/session/reset")' in calibration + assert 'if (state.transport?.mode === "simulation")' in calibration + assert 'if (backendMode === "bench")' in repair + assert 'await apiPost("/runtime/reset-statistics")' in repair diff --git a/tests/unit/test_self_calibration.py b/tests/unit/test_self_calibration.py index f55fa98..6755754 100644 --- a/tests/unit/test_self_calibration.py +++ b/tests/unit/test_self_calibration.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from cognitive_agent.application.calibration import protocol -from cognitive_agent.application.calibration.service import CalibrationService +from cognitive_agent.application.calibration.service import ApiError, CalibrationService def test_protocol_matches_android_golden_vectors() -> None: @@ -89,3 +89,30 @@ def test_simulation_uses_real_wire_boundary_and_generates_pose() -> None: assert not stopped["controller"]["armed"] finally: service.close() + + +def test_virtual_session_restart_resets_axes_without_touching_hardware() -> None: + with tempfile.TemporaryDirectory() as directory: + service = CalibrationService(Path(directory)) + service.start() + try: + service.connect_can({"mode": "simulation"}) + service.move(1, 720) + time.sleep(0.15) + + reset = service.reset_virtual_session() + + assert reset["transport"]["mode"] == "simulation" + assert reset["transport"]["state"] == "disconnected" + assert reset["controller"]["armed"] is False + assert [axis["permille"] for axis in reset["axes"]] == [0, 0, 0, 0] + assert reset["operation"]["phase"] == protocol.PHASE_IDLE + finally: + service.close() + + +def test_virtual_session_restart_rejects_real_bench_state() -> None: + with tempfile.TemporaryDirectory() as directory: + service = CalibrationService(Path(directory)) + with pytest.raises(ApiError, match="真实台架会话"): + service.reset_virtual_session()