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() {
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;
+7 -7
View File
@@ -11,22 +11,22 @@
<title>智舱随行 · 整车智能控制台</title>
<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="/self-extension.css?v=extension-18" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-10" />
<link rel="stylesheet" href="/self-extension.css?v=extension-19" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-12" />
<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/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/FBXLoader.js" 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 src="/pwa.js?v=android-pad-1" 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-calibration.js?v=calibration-6" defer></script>
<script src="/self-repair.js?v=repair-20" defer></script>
<script src="/self-repair-seat3d.js?v=repair-19" defer></script>
<script src="/self-extension.js?v=extension-21" defer></script>
<script src="/self-calibration.js?v=calibration-8" defer></script>
<script src="/self-repair.js?v=repair-21" defer></script>
<script src="/self-repair-seat3d.js?v=repair-21" defer></script>
</head>
<body>
<header class="topbar">
+80 -6
View File
@@ -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;
+73 -7
View File
@@ -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 @@
<div class="v2-calibration-seat-scene">
<div class="v2-calibration-floor-grid"></div>
<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">
<span class="model-spinner"></span>
<strong>正在构建座椅数字孪生</strong>
<small>CALIBRATION · CARTOON TWIN</small>
</div>
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
<div class="orbit-hint">拖动空白处旋转 · 拖动轴标签或座椅部件调轴 · 滚轮缩放 · 双击复位</div>
</div>
<div class="v2-axis-tag tag-back">靠背 <b id="calibrationTagBack">0.0°</b></div>
<div class="v2-axis-tag tag-base">前后 <b id="calibrationTagLong">0.00 cm</b></div>
<div class="v2-axis-tag tag-leg">腿托 <b id="calibrationTagLeg">0.0°</b></div>
<div class="v2-axis-tag tag-height">高度 <b id="calibrationTagHeight">0.00 cm</b></div>
<svg class="v2-calibration-label-lines" id="calibrationSeatLabelLines" aria-hidden="true">
<line data-seat-label-line="backrest"></line>
<line data-seat-label-line="longitudinal"></line>
<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 class="v2-calibration-operation">
<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(() => ({}));
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);
}
+234
View File
@@ -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;
+109 -2
View File
@@ -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 @@
<span><i>CH0 / CH1</i>等待权威车端状态</span>
</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);
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);
+345 -3
View File
@@ -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 },
},
},
});
}
+26 -3
View File
@@ -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) {
+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 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",
@@ -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:
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:
+43 -18
View File
@@ -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"))
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()
relative_parts = None
for root in {self.profile_root.resolve(), self.user_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 = 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] = {}
@@ -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],
+1
View File
@@ -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)
),
+9 -9
View File
@@ -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")
+12 -4
View File
@@ -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
+4 -4
View File
@@ -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
+46
View File
@@ -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)
@@ -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
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()