This commit is contained in:
vee
2026-08-14 15:01:29 +08:00
parent 6d768aaef4
commit f77dda70fe
20 changed files with 1125 additions and 122 deletions
+6 -1
View File
@@ -19,7 +19,12 @@ function createConversationId() {
function loadConversationId() { function loadConversationId() {
const stored = sessionStorage.getItem("cognitive-agent.conversation-id"); 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(); const conversationId = createConversationId();
sessionStorage.setItem("cognitive-agent.conversation-id", conversationId); sessionStorage.setItem("cognitive-agent.conversation-id", conversationId);
return conversationId; return conversationId;
+7 -7
View File
@@ -11,22 +11,22 @@
<title>智舱随行 · 整车智能控制台</title> <title>智舱随行 · 整车智能控制台</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" /> <link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-106" /> <link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-106" />
<link rel="stylesheet" href="/self-extension.css?v=extension-18" /> <link rel="stylesheet" href="/self-extension.css?v=extension-19" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-10" /> <link rel="stylesheet" href="/self-calibration.css?v=calibration-12" />
<link rel="stylesheet" href="/self-repair.css?v=repair-21" /> <link rel="stylesheet" href="/self-repair.css?v=repair-21" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script> <script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script> <script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script> <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="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" defer></script> <script src="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=dual-seat-81" defer></script> <script src="/app.js?v=dual-seat-82" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-7"></script> <script type="module" src="/gesture-control.js?v=android-pad-7"></script>
<script src="/pwa.js?v=android-pad-1" defer></script> <script src="/pwa.js?v=android-pad-1" defer></script>
<script src="/console-v2.js?v=desktop-refactor-35" defer></script> <script src="/console-v2.js?v=desktop-refactor-35" defer></script>
<script src="/self-extension.js?v=extension-20" defer></script> <script src="/self-extension.js?v=extension-21" defer></script>
<script src="/self-calibration.js?v=calibration-6" defer></script> <script src="/self-calibration.js?v=calibration-8" defer></script>
<script src="/self-repair.js?v=repair-20" defer></script> <script src="/self-repair.js?v=repair-21" defer></script>
<script src="/self-repair-seat3d.js?v=repair-19" defer></script> <script src="/self-repair-seat3d.js?v=repair-21" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
+80 -6
View File
@@ -463,6 +463,18 @@
cursor: grabbing; 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 { .v2-calibration-seat-scene .seat-model-host canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -551,8 +563,9 @@
.v2-axis-tag { .v2-axis-tag {
position: absolute; position: absolute;
z-index: 3; z-index: 4;
display: flex; display: flex;
align-items: center;
gap: 5px; gap: 5px;
padding: 5px 8px; padding: 5px 8px;
color: #6d7c7e; color: #6d7c7e;
@@ -561,18 +574,79 @@
border-radius: 8px; border-radius: 8px;
box-shadow: 0 7px 18px rgba(46, 78, 80, 0.07); box-shadow: 0 7px 18px rgba(46, 78, 80, 0.07);
font-size: 8px; font-size: 8px;
pointer-events: none; cursor: not-allowed;
pointer-events: auto;
touch-action: none;
user-select: none;
backdrop-filter: blur(8px); 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 { .v2-axis-tag b {
color: #315eaa; color: #315eaa;
} }
.v2-axis-tag.tag-back { top: 17%; right: 13%; } .v2-axis-tag.active {
.v2-axis-tag.tag-base { right: 11%; bottom: 19%; } border-color: rgba(49, 94, 170, 0.42);
.v2-axis-tag.tag-leg { bottom: 24%; left: 14%; } box-shadow: 0 7px 22px rgba(49, 94, 170, 0.18);
.v2-axis-tag.tag-height { top: 52%; left: 11%; } }
.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 { .v2-calibration-operation {
min-width: 0; min-width: 0;
+73 -7
View File
@@ -26,6 +26,7 @@
let toastTimer = 0; let toastTimer = 0;
let settingsHydrated = false; let settingsHydrated = false;
let activeView = "live"; let activeView = "live";
let bootstrapResetComplete = false;
function buildShell() { function buildShell() {
const sidebar = $(".v2-sidebar"); const sidebar = $(".v2-sidebar");
@@ -101,18 +102,24 @@
<div class="v2-calibration-seat-scene"> <div class="v2-calibration-seat-scene">
<div class="v2-calibration-floor-grid"></div> <div class="v2-calibration-floor-grid"></div>
<div id="calibrationSeatViewer" class="seat-model-host calibration-cartoon-viewer"> <div id="calibrationSeatViewer" class="seat-model-host calibration-cartoon-viewer">
<canvas id="calibrationSeatCanvas" aria-label="自标定卡通风格三维座椅,可拖动旋转并滚轮缩放"></canvas> <canvas id="calibrationSeatCanvas" aria-label="自标定三维座椅,拖动空白处旋转视角,拖动轴标签或座椅部件调节四轴"></canvas>
<div id="seatModelLoading" class="model-loading"> <div id="seatModelLoading" class="model-loading">
<span class="model-spinner"></span> <span class="model-spinner"></span>
<strong>正在构建座椅数字孪生</strong> <strong>正在构建座椅数字孪生</strong>
<small>CALIBRATION · CARTOON TWIN</small> <small>CALIBRATION · CARTOON TWIN</small>
</div> </div>
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div> <div class="orbit-hint">拖动空白处旋转 · 拖动轴标签或座椅部件调轴 · 滚轮缩放 · 双击复位</div>
</div> </div>
<div class="v2-axis-tag tag-back">靠背 <b id="calibrationTagBack">0.0°</b></div> <svg class="v2-calibration-label-lines" id="calibrationSeatLabelLines" aria-hidden="true">
<div class="v2-axis-tag tag-base">前后 <b id="calibrationTagLong">0.00 cm</b></div> <line data-seat-label-line="backrest"></line>
<div class="v2-axis-tag tag-leg">腿托 <b id="calibrationTagLeg">0.0°</b></div> <line data-seat-label-line="longitudinal"></line>
<div class="v2-axis-tag tag-height">高度 <b id="calibrationTagHeight">0.00 cm</b></div> <line data-seat-label-line="legrest"></line>
<line data-seat-label-line="height"></line>
</svg>
<div class="v2-axis-tag tag-back" data-seat-axis-label="backrest" role="slider" tabindex="0" aria-label="拖动调节靠背" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>靠背 <b id="calibrationTagBack">0.0°</b></span></div>
<div class="v2-axis-tag tag-base" data-seat-axis-label="longitudinal" role="slider" tabindex="0" aria-label="拖动调节前后位置" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>前后 <b id="calibrationTagLong">0.00 cm</b></span></div>
<div class="v2-axis-tag tag-leg" data-seat-axis-label="legrest" role="slider" tabindex="0" aria-label="拖动调节腿托" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>腿托 <b id="calibrationTagLeg">0.0°</b></span></div>
<div class="v2-axis-tag tag-height" data-seat-axis-label="height" role="slider" tabindex="0" aria-label="拖动调节高度" aria-valuemin="0" aria-valuemax="1000" aria-valuenow="0" aria-disabled="true"><i></i><span>高度 <b id="calibrationTagHeight">0.00 cm</b></span></div>
</div> </div>
<div class="v2-calibration-operation"> <div class="v2-calibration-operation">
<i></i><div><small>当前任务</small><strong id="calibrationOperationLabel">等待设备检测</strong></div><span>JOB <b id="calibrationJobId">—</b></span> <i></i><div><small>当前任务</small><strong id="calibrationOperationLabel">等待设备检测</strong></div><span>JOB <b id="calibrationJobId">—</b></span>
@@ -234,6 +241,7 @@
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`); if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
render(data); render(data);
return data;
} }
async function run(action, success = "") { async function run(action, success = "") {
@@ -300,6 +308,15 @@
renderWorkflow(state.operation.phase); renderWorkflow(state.operation.phase);
renderAxes(state.axes, controller.job_active, transport.state === "online"); renderAxes(state.axes, controller.job_active, transport.state === "online");
renderSeat(state.axes); 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); renderVideo(state.video);
$("#calibrationStartAll").disabled = $("#calibrationStartAll").disabled =
transport.state !== "online" || controller.job_active; transport.state !== "online" || controller.job_active;
@@ -411,6 +428,47 @@
$("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`; $("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`;
$("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`; $("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`;
$("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`; $("#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) { function renderVideo(video) {
@@ -542,7 +600,13 @@
closeEvents(); closeEvents();
return; 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(); connectEvents();
} }
@@ -583,6 +647,8 @@
}); });
window.addEventListener("cognitive-agent:module-change", activate); window.addEventListener("cognitive-agent:module-change", activate);
window.addEventListener("cognitive-agent:demo-role-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); window.addEventListener("beforeunload", closeEvents);
} }
+234
View File
@@ -89,11 +89,245 @@
.console-v2[data-module="self-extension"] .v2-self-extension { .console-v2[data-module="self-extension"] .v2-self-extension {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
position: relative;
display: block; display: block;
grid-row: 2; grid-row: 2;
overflow: hidden; 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 { .v2-extension-sidebar > .v2-field-caption {
display: block; display: block;
margin: 0 0 8px; margin: 0 0 8px;
+109 -2
View File
@@ -278,7 +278,7 @@
const state = loadState(); const state = loadState();
const pages = Object.fromEntries(Object.keys(TOY_TYPES).map((key) => [key, 0])); 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 selectedAddType = "light";
let learning = freshLearningSession(); let learning = freshLearningSession();
let isPlanning = false; let isPlanning = false;
@@ -290,6 +290,8 @@
let latestVehicleStateTurn = -1; let latestVehicleStateTurn = -1;
let sharedTwinBand = null; let sharedTwinBand = null;
let sharedTwinHome = null; let sharedTwinHome = null;
let petTimer = null;
let petPhraseIndex = 0;
function uid(prefix) { function uid(prefix) {
if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`; if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
@@ -347,6 +349,14 @@
: {}; : {};
saved.selectedDeviceByType = saved.selectedDeviceByType || {}; saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 }; 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; return saved;
} catch (error) { } catch (error) {
console.warn("读取自扩展能力数据失败,已恢复默认状态。", error); console.warn("读取自扩展能力数据失败,已恢复默认状态。", error);
@@ -506,7 +516,18 @@
<span><i>CH0 / CH1</i>等待权威车端状态</span> <span><i>CH0 / CH1</i>等待权威车端状态</span>
</div> </div>
<div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div> <div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div>
</section>`; </section>
<aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite">
<span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span>
<button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动">
<span class="v2-extension-pet-antenna"><i></i></span>
<span class="v2-extension-pet-ear left"></span><span class="v2-extension-pet-ear right"></span>
<span class="v2-extension-pet-face"><i class="eye left"></i><i class="eye right"></i><b></b><em></em></span>
<span class="v2-extension-pet-screen"><i></i><b>EXT</b></span>
<span class="v2-extension-pet-foot left"></span><span class="v2-extension-pet-foot right"></span>
<span class="v2-extension-pet-tail"></span>
</button>
</aside>`;
moduleSwitcher.insertAdjacentElement("afterend", root); moduleSwitcher.insertAdjacentElement("afterend", root);
buildDialogs(); 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() { function bindEvents() {
$$('[data-extension-mode]').forEach((button) => { $$('[data-extension-mode]').forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.extensionMode)); button.addEventListener("click", () => setMode(button.dataset.extensionMode));
@@ -1723,6 +1829,7 @@
function init() { function init() {
if (!buildShell()) return; if (!buildShell()) return;
initializeDesktopPet();
bindEvents(); bindEvents();
renderAll(); renderAll();
setMode(activeMode); setMode(activeMode);
+345 -3
View File
@@ -37,6 +37,8 @@
initialAxes = [35, 42, 48, 20], initialAxes = [35, 42, 48, 20],
readyClass = "ready", readyClass = "ready",
axisMode = "repair", axisMode = "repair",
enableAxisDrag = false,
labelBindings = null,
} = options; } = options;
const THREE = window.THREE; const THREE = window.THREE;
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
@@ -135,7 +137,19 @@
const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40); const camera = new THREE.PerspectiveCamera(33, 1, 0.05, 40);
const target = new THREE.Vector3(0, 1.15, 0); const target = new THREE.Vector3(0, 1.15, 0);
const orbit = { yaw: 0.67, pitch: 0.21, distance: 6.8 }; 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)); scene.add(new THREE.HemisphereLight(0xffffff, mixHex(themeStrong, white, 0.46), 1.35));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.8); const keyLight = new THREE.DirectionalLight(0xffffff, 1.8);
@@ -203,6 +217,17 @@
transparent: true, transparent: true,
opacity: 0.72, 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) { function addEdges(mesh, geometry, opacity = 0.3) {
const material = edgeMaterial.clone(); const material = edgeMaterial.clone();
@@ -283,8 +308,10 @@
seatRoot.position.set(0, 0.02, 0); seatRoot.position.set(0, 0.02, 0);
scene.add(seatRoot); scene.add(seatRoot);
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);
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);
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.position.set(0, 0.34, -0.02);
base.castShadow = true; base.castShadow = true;
slideRoot.add(base); slideRoot.add(base);
markAxisTarget(base, 3);
const cushionPivot = new THREE.Group(); const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06); 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]]);
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(); const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5); backrestPivot.position.set(0, 0.62, -0.5);
@@ -339,6 +368,7 @@
headrestInsert.scale.set(1.32, 1, 0.28); headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18); headrestInsert.position.set(0, 1.98, 0.18);
backrestPivot.add(headrestInsert); backrestPivot.add(headrestInsert);
markAxisTarget(backrestPivot, 2);
const legrestPivot = new THREE.Group(); const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7); legrestPivot.position.set(0, 0.64, 0.7);
@@ -348,6 +378,22 @@
legShell.scale.set(0.9, 0.72, 0.54); 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); 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); 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( const statusHalo = new THREE.Mesh(
new THREE.RingGeometry(1.12, 1.18, 72), 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 current = { axes: [...initialAxes], ventilation: false, heating: 0, active: false, state: "IDLE" };
const desired = { 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() { function updateCamera() {
const cosPitch = Math.cos(orbit.pitch); const cosPitch = Math.cos(orbit.pitch);
@@ -440,6 +580,8 @@
const detail = event.detail || {}; const detail = event.detail || {};
(detail.axes || []).forEach((axis) => { (detail.axes || []).forEach((axis) => {
const motor = Number(axis.motor); const motor = Number(axis.motor);
if (pointer.active && pointer.mode === "axis"
&& axisToModelIndex[pointer.axis] === motor) return;
if (motor >= 0 && motor < desired.axes.length) { if (motor >= 0 && motor < desired.axes.length) {
desired.axes[motor] = Math.max(0, Math.min(100, Number(axis.position_pct || 0))); 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.12 + Math.sin(performance.now() * 0.005) * 0.04
: 0.06; : 0.06;
scene.updateMatrixWorld(true);
layoutSeatLabels();
renderer.render(scene, camera); 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) => { viewer.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return; if (event.button !== 0) return;
const axis = axisAtPoint(event.clientX, event.clientY);
if (axis !== null && startAxisDrag(axis, event)) return;
event.preventDefault(); event.preventDefault();
pointer.active = true; pointer.active = true;
pointer.x = event.clientX; pointer.x = event.clientX;
pointer.y = event.clientY; 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"); 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) => { window.addEventListener("pointermove", (event) => {
if (!pointer.active) return; if (!pointer.active) return;
event.preventDefault(); 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.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)); orbit.pitch = Math.max(-0.08, Math.min(0.72, orbit.pitch + (event.clientY - pointer.y) * 0.006));
pointer.x = event.clientX; pointer.x = event.clientX;
@@ -525,8 +843,22 @@
updateCamera(); updateCamera();
}, { passive: false }); }, { passive: false });
const endOrbit = () => { 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.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("dragging");
viewer.classList.remove("axis-dragging");
labelLayout.forEach((item) => item.tag.classList.remove("dragging"));
setActiveAxis(null);
}; };
window.addEventListener("pointerup", endOrbit); window.addEventListener("pointerup", endOrbit);
window.addEventListener("pointercancel", endOrbit); window.addEventListener("pointercancel", endOrbit);
@@ -577,6 +909,16 @@
initialAxes: [50, 50, 20, 0], initialAxes: [50, 50, 20, 0],
readyClass: "is-ready", readyClass: "is-ready",
axisMode: "calibration", 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 },
},
},
}); });
} }
+26 -3
View File
@@ -89,10 +89,10 @@
let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware" let activeView = sessionStorage.getItem(VIEW_KEY) === "hardware"
? "hardware" ? "hardware"
: "software"; : "software";
let executionMode = sessionStorage.getItem(MODE_KEY) === "bench" sessionStorage.removeItem(MODE_KEY);
? "bench" let executionMode = "shadow";
: "shadow";
let polling = false; let polling = false;
let bootstrapRuntimeReset = false;
let toastTimer = 0; let toastTimer = 0;
let confirmationResolve = null; let confirmationResolve = null;
let eventFilter = "all"; let eventFilter = "all";
@@ -642,6 +642,29 @@
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
latest = await response.json(); 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; if (!["IDLE"].includes(latest.healing?.phase)) selectedCaseId = latest.healing.case_id;
renderAll(); renderAll();
} catch (error) { } catch (error) {
+8 -8
View File
@@ -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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -6,18 +6,18 @@ const APP_SHELL = [
"/index.html", "/index.html",
"/styles.css?v=dual-seat-9", "/styles.css?v=dual-seat-9",
"/network.js?v=android-pad-1", "/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", "/gesture-control.js?v=android-pad-7",
"/pwa.js?v=android-pad-1", "/pwa.js?v=android-pad-1",
"/console-v2.css?v=desktop-refactor-106", "/console-v2.css?v=desktop-refactor-106",
"/console-v2.js?v=desktop-refactor-35", "/console-v2.js?v=desktop-refactor-35",
"/self-extension.css?v=extension-18", "/self-extension.css?v=extension-19",
"/self-extension.js?v=extension-20", "/self-extension.js?v=extension-21",
"/self-calibration.css?v=calibration-10", "/self-calibration.css?v=calibration-12",
"/self-calibration.js?v=calibration-6", "/self-calibration.js?v=calibration-8",
"/self-repair.css?v=repair-21", "/self-repair.css?v=repair-21",
"/self-repair.js?v=repair-20", "/self-repair.js?v=repair-21",
"/self-repair-seat3d.js?v=repair-19", "/self-repair-seat3d.js?v=repair-21",
"/assets/models/n7_seat.glb?v=e7f50f03", "/assets/models/n7_seat.glb?v=e7f50f03",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icons/app-icon-192.png", "/icons/app-icon-192.png",
@@ -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: 用户固化的座椅位置模式动作。
@@ -96,6 +96,31 @@ class CalibrationService:
with self._lock: with self._lock:
return json.loads(json.dumps(self.state, ensure_ascii=False)) 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]: def wait_for_version(self, after: int, timeout: float = 15.0) -> dict[str, Any]:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while not self._closing.is_set() and time.monotonic() < deadline: while not self._closing.is_set() and time.monotonic() < deadline:
+45 -20
View File
@@ -363,22 +363,33 @@ class ModeProfile:
class ModeProfileRegistry: 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.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._profiles: list[ModeProfile] = []
self.reload() self.reload()
def reload(self) -> None: def reload(self) -> None:
if not self.profile_root.exists(): profiles: list[ModeProfile] = []
self._profiles = [] if self.profile_root.exists():
return for path in sorted(self.profile_root.rglob("*.yaml")):
self._profiles = [ if not path.is_file():
ModeProfile.from_yaml(path, self.profile_root) continue
for path in sorted(self.profile_root.rglob("*.yaml")) if self.session_user_profiles and _is_user_profile_path(
if path.is_file() 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]: def profiles(self) -> list[ModeProfile]:
return list(self._profiles) return list(self._profiles)
@@ -459,9 +470,9 @@ class ModeProfileRegistry:
], ],
} }
path = ( 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 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) path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp") temporary = path.with_suffix(path.suffix + ".tmp")
@@ -471,7 +482,7 @@ class ModeProfileRegistry:
) )
temporary.replace(path) temporary.replace(path)
self.reload() self.reload()
return ModeProfile.from_yaml(path, self.profile_root) return ModeProfile.from_yaml(path, self.user_root)
def delete_user_mode( def delete_user_mode(
self, self,
@@ -495,11 +506,15 @@ class ModeProfileRegistry:
raise PermissionError("preset mode profiles cannot be deleted") raise PermissionError("preset mode profiles cannot be deleted")
path = Path(profile.source).resolve() path = Path(profile.source).resolve()
profile_root = self.profile_root.resolve() relative_parts = None
try: for root in {self.profile_root.resolve(), self.user_root.resolve()}:
relative_parts = path.relative_to(profile_root).parts try:
except ValueError as exc: relative_parts = path.relative_to(root).parts
raise PermissionError("mode profile is outside the configured registry") from exc 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": if "user" not in relative_parts or path.suffix.lower() != ".yaml":
raise PermissionError("only user-defined mode profiles can be deleted") raise PermissionError("only user-defined mode profiles can be deleted")
if not path.is_file(): if not path.is_file():
@@ -532,7 +547,7 @@ class ModeProfileRegistry:
document = _mode_profile_document(profile, merged_operations) document = _mode_profile_document(profile, merged_operations)
_replace_yaml(path, document) _replace_yaml(path, document)
self.reload() 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: def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
path = Path(profile.source) path = Path(profile.source)
@@ -544,7 +559,17 @@ class ModeProfileRegistry:
temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8") temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
temporary.replace(path) temporary.replace(path)
self.reload() 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]: def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
selected: dict[str, ModeProfile] = {} selected: dict[str, ModeProfile] = {}
@@ -6,6 +6,7 @@ import json
import os import os
import re import re
import sys import sys
import tempfile
import time import time
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -237,7 +238,14 @@ class SeatDemoService:
} }
self.model_plan_adapter = ModelPlanAdapter(self.capability_context) self.model_plan_adapter = ModelPlanAdapter(self.capability_context)
self.vehicle_profiles = VehicleProfileRegistry(repository_root / "config" / "vehicles") 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._artifact_root = repository_root / "artifacts" / "runs"
self.conversations = ConversationStateStore( self.conversations = ConversationStateStore(
preset_root=repository_root / "artifacts" / "user-presets" preset_root=repository_root / "artifacts" / "user-presets"
@@ -343,6 +351,7 @@ class SeatDemoService:
"vehicle": default_vehicle.public_dict(), "vehicle": default_vehicle.public_dict(),
"vehicles": self.vehicle_profiles.public_profiles(), "vehicles": self.vehicle_profiles.public_profiles(),
"modes": self.mode_profiles.public_modes(default_vehicle.id), "modes": self.mode_profiles.public_modes(default_vehicle.id),
"mode_profile_storage": "runtime_session_volatile",
"preset_storage": "conversation_durable", "preset_storage": "conversation_durable",
"inferred_level_range": [1, 3], "inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100], "motion_position_range_pct": [0, 100],
+1
View File
@@ -505,6 +505,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
int(payload["axis"]), bool(payload.get("far")) int(payload["axis"]), bool(payload.get("far"))
), ),
"/api/calibration/stop": self.calibration.stop, "/api/calibration/stop": self.calibration.stop,
"/api/calibration/session/reset": self.calibration.reset_virtual_session,
"/api/calibration/settings": lambda: self.calibration.update_settings( "/api/calibration/settings": lambda: self.calibration.update_settings(
dict(payload) dict(payload)
), ),
+9 -9
View File
@@ -177,7 +177,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None: def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response: with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") 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") app_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3
@@ -192,11 +192,11 @@ operations:
) as response: ) as response:
console_styles = response.read().decode("utf-8") console_styles = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open( with self.opener.open(
@@ -223,14 +223,14 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", 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('href="/styles.css?v=dual-seat-9"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertIn('src="/gesture-control.js?v=android-pad-7"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=desktop-refactor-35"', 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('href="/console-v2.css?v=desktop-refactor-106"', html)
self.assertIn('src="/self-extension.js?v=extension-20"', html) self.assertIn('src="/self-extension.js?v=extension-21"', html)
self.assertIn('href="/self-extension.css?v=extension-18"', html) self.assertIn('href="/self-extension.css?v=extension-19"', html)
self.assertNotIn("滚动查看更多", html) self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('aria-label="执行车舱控制指令"', html)
self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script)
@@ -455,7 +455,7 @@ operations:
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script) self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v117"', service_worker) self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v119"', service_worker)
self.assertIn( self.assertIn(
'"/assets/models/n7_seat.glb?v=e7f50f03"', service_worker '"/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: def test_self_extension_vehicle_success_contract_is_strict(self) -> None:
with self.opener.open( 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: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( 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: ) as response:
app_script = response.read().decode("utf-8") app_script = response.read().decode("utf-8")
+12 -4
View File
@@ -122,16 +122,24 @@ class TestSelfCalibrationApi:
stopped = json.loads(response.read().decode("utf-8"))["state"] stopped = json.loads(response.read().decode("utf-8"))["state"]
assert not stopped["controller"]["armed"] 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: 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: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen( 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, timeout=3,
) as response: ) as response:
script = response.read().decode("utf-8") script = response.read().decode("utf-8")
with urllib.request.urlopen( 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, timeout=3,
) as response: ) as response:
styles = response.read().decode("utf-8") styles = response.read().decode("utf-8")
@@ -160,8 +168,8 @@ class TestSelfCalibrationApi:
timeout=3, timeout=3,
) )
assert 'href="/self-calibration.css?v=calibration-10"' in html assert 'href="/self-calibration.css?v=calibration-12"' in html
assert 'src="/self-calibration.js?v=calibration-6"' in html assert 'src="/self-calibration.js?v=calibration-8"' in html
assert 'data-calibration-page="live"' in script assert 'data-calibration-page="live"' in script
assert 'data-calibration-view="live"' in script assert 'data-calibration-view="live"' in script
assert 'data-calibration-page="hall"' not in script assert 'data-calibration-page="hall"' not in script
+4 -4
View File
@@ -146,7 +146,7 @@ class TestSelfRepairApi:
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response: with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8") html = response.read().decode("utf-8")
with urllib.request.urlopen( 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: ) as response:
script = response.read().decode("utf-8").replace("\r\n", "\n") script = response.read().decode("utf-8").replace("\r\n", "\n")
with urllib.request.urlopen( with urllib.request.urlopen(
@@ -154,7 +154,7 @@ class TestSelfRepairApi:
) as response: ) as response:
styles = response.read().decode("utf-8") styles = response.read().decode("utf-8")
with urllib.request.urlopen( 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: ) as response:
seat3d = response.read().decode("utf-8") seat3d = response.read().decode("utf-8")
with urllib.request.urlopen( with urllib.request.urlopen(
@@ -163,12 +163,12 @@ class TestSelfRepairApi:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
assert 'href="/self-repair.css?v=repair-21"' in html 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 'class="v2-module-heading"' in script
assert 'SELF-REPAIR' in script assert 'SELF-REPAIR' in script
assert 'padding: var(--v2-module-title-block) 0 0' in styles assert 'padding: var(--v2-module-title-block) 0 0' in styles
assert 'margin-inline: var(--v2-module-title-inline)' 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 assert "运行与自修复日志" in script
assert "启动座椅运行" in script assert "启动座椅运行" in script
+46
View File
@@ -356,6 +356,52 @@ operations:
[{"name": "transmittance_pct", "value": 20}], [{"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: def test_delete_user_mode_removes_yaml_and_protects_presets(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -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
+28 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import pytest import pytest
from cognitive_agent.application.calibration import protocol 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: 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"] assert not stopped["controller"]["armed"]
finally: finally:
service.close() 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()