diff --git a/.gitignore b/.gitignore
index f9b9909..1e0b874 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,10 +7,12 @@ artifacts/runs/
artifacts/server/
artifacts/tmp/
artifacts/self-repair/
+artifacts/*.log
tmp/*.log
.codex_tmp/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/
+tools/zcan/seat_function_runtime/
artifacts/hil/*_server_*.log
artifacts/hil/frontend_service_*.log
tmp/can-matrix-artifact/
diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index d0e2191..7e1e7c0 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -47,11 +47,18 @@ const state = {
seatTwin: null,
seatActuatorState: {},
seatFeedbackSource: "simulated",
+ seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
availableSeatAxes: null,
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
hardwareClientId: loadHardwareClientId(),
hardwareAccess: null,
hardwareAccessPoller: null,
+ physicalStatePoller: null,
+ physicalStateSyncPromise: null,
+ pendingModeProfileId: null,
+ modeRunTimer: null,
+ modeDraftCommand: "",
+ lastSuccessfulUtterance: "",
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -77,6 +84,7 @@ function refreshIcons() {
const TWIN_DEFAULT_POSITIONS = {
longitudinal: 50,
cushion_tilt: 50,
+ height: 50,
backrest_recline: 20,
legrest_height: 0,
legrest_extension: 0,
@@ -91,9 +99,10 @@ function initJointTwinLayout() {
const twinBand = $(".twin-band");
const stateBand = $(".state-band");
const passengerCard = $('.seat-card[data-seat="passenger"]');
+ const driverCard = $('.seat-card[data-seat="driver"]');
const domainGrid = $(".domain-grid");
const coverage = $(".coverage-section");
- if (!cabinBand || !twinBand || !stateBand || !passengerCard || !domainGrid || !coverage) return;
+ if (!cabinBand || !twinBand || !stateBand || !passengerCard || !driverCard || !domainGrid || !coverage) return;
const shell = document.createElement("section");
shell.className = "joint-twin-shell";
@@ -104,11 +113,13 @@ function initJointTwinLayout() {
VEHICLE DIGITAL TWIN · JOINT CONTROL
整车联合孪生总览
-
完整五座跨界车孪生:主驾与后排固定,副驾支持姿态、通风和加热动态反馈。
+
双通道前排座椅孪生:CH0 对应副驾座椅,CH1 对应主驾座椅;两侧状态相互独立。
副驾座椅
+ 主驾座椅
+
空调
氛围灯
@@ -118,9 +129,12 @@ function initJointTwinLayout() {
cabinBand.before(shell);
shell.append(heading, twinBand, cabinBand);
- passengerCard.classList.add("joint-seat-panel", "domain-panel");
+ [passengerCard, driverCard].forEach((card) => card.classList.add("joint-seat-panel", "domain-panel"));
+ passengerCard.dataset.deviceChannel = "ch0";
+ driverCard.dataset.deviceChannel = "ch1";
const seatFeedback = $("#seatFeedbackModeLabel");
- if (seatFeedback) passengerCard.prepend(seatFeedback);
+ if (seatFeedback) coverage.prepend(seatFeedback);
+ domainGrid.prepend(driverCard);
domainGrid.prepend(passengerCard);
const updateDomainGridScrollState = () => {
const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2;
@@ -1577,6 +1591,7 @@ function createTwinSeat(THREE, x, accentColor) {
};
addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]);
addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]);
+ addAnchor("height", slide, [outerSide * 0.84, 0.25, -0.12]);
addAnchor("backrest_recline", backrestPivot, [outerSide * 0.7, 1.28, 0.2]);
addAnchor("legrest_height", legrestExtension, [outerSide * 0.7, 0.08, 0.58]);
addAnchor("legrest_extension", legrestExtension, [outerSide * 0.7, 0.1, 0.82]);
@@ -1668,11 +1683,14 @@ function configureFixedTwinSeat(THREE, model, position, role) {
function setTwinSelection(selected) {
const twin = state.seatTwin;
if (!twin) return;
- const focusedSeat = "passenger";
+ const focusedSeat = selected === "driver" ? "driver" : "passenger";
twin.selected = focusedSeat;
$("#seatTwin").dataset.selectedSeat = focusedSeat;
+ $("#seatTwinCanvas").dataset.activeSeat = focusedSeat === "driver" ? "front-left" : "front-right";
$$(".twin-seat-button").forEach((button) => {
- button.classList.toggle("active", button.dataset.twinSeat === focusedSeat);
+ const active = button.dataset.twinSeat === focusedSeat;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-selected", String(active));
});
Object.entries(twin.models).forEach(([seat, model]) => {
const isSelected = seat === focusedSeat;
@@ -1691,8 +1709,8 @@ function setTwinSelection(selected) {
material.needsUpdate = true;
});
});
- twin.view.targetX = 0;
- twin.view.yaw = -0.62;
+ twin.view.targetX = focusedSeat === "driver" ? 0.34 : -0.34;
+ twin.view.yaw = focusedSeat === "driver" ? -0.48 : -0.72;
renderTwinReadout();
}
@@ -1704,6 +1722,7 @@ function renderTwinCalloutValues(device) {
);
const longitudinal = value("longitudinal", 50);
const cushion = value("cushion_tilt", 50);
+ const height = value("height", 50);
const backrest = value("backrest_recline", 20);
const legrest = value("legrest_height", 0);
const extension = value("legrest_extension", 0);
@@ -1719,6 +1738,7 @@ function renderTwinCalloutValues(device) {
const values = {
longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "前移区" : longitudinal > 55 ? "后移区" : "中位区"],
cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`],
+ height: [`${height}%`, "CH1 高度电机"],
backrest_recline: [
`${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`,
`${backrestEstimated ? "估算 " : ""}${backrest}% · 相对归零位`,
@@ -1744,14 +1764,39 @@ function renderTwinCalloutValues(device) {
});
}
+function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
+ const device = actuatorState?.[seat] || {};
+ if (seat !== "driver") return device;
+ const position = device.position || {};
+ return {
+ ...device,
+ position: {
+ ...position,
+ longitudinal: position.longitudinal || position.horizontal,
+ cushion_tilt: position.cushion_tilt || position.basin,
+ backrest_recline: position.backrest_recline || position.backrest,
+ },
+ };
+}
+
+function twinAxesForSeat(seat) {
+ return seat === "driver"
+ ? new Set(["longitudinal", "height", "cushion_tilt", "backrest_recline"])
+ : new Set(["longitudinal", "cushion_tilt", "backrest_recline", "legrest_height", "legrest_extension"]);
+}
+
function renderTwinReadout() {
const twin = state.seatTwin;
if (!twin) return;
- const driverOnly = state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only";
- const seat = driverOnly ? "driver" : "passenger";
- const device = twin.actuatorState[seat] || twin.actuatorState.passenger;
- $("#twinSeatName").textContent = driverOnly ? "主驾" : "副驾";
- $("#twinSeatZone").textContent = driverOnly ? "front_left · CH1" : "front_right";
+ const seat = twin.selected === "driver" ? "driver" : "passenger";
+ const device = twinDeviceForSeat(seat, twin.actuatorState);
+ const supportedAxes = twinAxesForSeat(seat);
+ const axisLabels = {
+ longitudinal: "水平位置", height: "座椅高度", cushion_tilt: "坐垫俯仰",
+ backrest_recline: "靠背夹角", legrest_height: "腿托夹角", legrest_extension: "腿托伸缩",
+ };
+ $("#twinSeatName").textContent = seat === "driver" ? "主驾座椅" : "副驾座椅";
+ $("#twinSeatZone").textContent = seat === "driver" ? "CH1 · 左前排" : "CH0 · 右前排";
const postureAngles = twinPostureAngles(device);
Object.entries(TWIN_DEFAULT_POSITIONS).forEach(([axis, fallback]) => {
const motion = device?.position?.[axis];
@@ -1759,12 +1804,20 @@ function renderTwinReadout() {
const postureAngle = postureAngles[axis];
const estimatedPrefix = isEstimatedMotion(device, axis) ? "≈" : "";
const row = $(`[data-twin-axis="${axis}"]`);
+ if (!row) return;
+ row.hidden = !supportedAxes.has(axis);
+ if (axisLabels[axis]) $("span", row).textContent = axisLabels[axis];
$("b", row).textContent = postureAngle == null
? `${estimatedPrefix}${position}%`
: `${estimatedPrefix}${position}% · ${Number(postureAngle).toFixed(Number.isInteger(Number(postureAngle)) ? 0 : 1)}°`;
row.style.setProperty("--value", `${Math.max(0, Math.min(100, position))}%`);
row.classList.toggle("fault", Boolean(device?.position?.[axis]?.fault));
});
+ $$('[data-callout]').forEach((callout) => {
+ const axis = callout.dataset.callout;
+ if (axis?.endsWith("included_angle")) return;
+ callout.hidden = !supportedAxes.has(axis);
+ });
["ventilation", "heating"].forEach((feature) => {
const value = device?.[feature] || { state: "off", level: 0 };
const item = $(`[data-twin-function="${feature}"]`);
@@ -1780,7 +1833,7 @@ function renderTwinReadout() {
function updateTwinExplicitAngles(result) {
const twin = state.seatTwin;
if (!twin) return;
- const targets = { passenger: {} };
+ const targets = { passenger: {}, driver: {} };
const requestedSeats = new Set();
const capabilityAxes = {
"seat.set_backrest_recline": "backrest_recline",
@@ -1799,7 +1852,7 @@ function updateTwinExplicitAngles(result) {
});
}
twin.explicitAngleTargets = targets;
- setTwinSelection("passenger");
+ setTwinSelection(requestedSeats.has("driver") ? "driver" : twin.selected);
}
function updateSeatTwin(actuatorState) {
@@ -1807,8 +1860,9 @@ function updateSeatTwin(actuatorState) {
if (!twin || !actuatorState) return;
twin.actuatorState = actuatorState;
Object.entries(twin.models).forEach(([seat, model]) => {
+ const device = twinDeviceForSeat(seat, actuatorState);
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
- const motion = actuatorState[seat]?.position?.[axis];
+ const motion = device?.position?.[axis];
const value = motion?.position_pct;
if (Number.isFinite(Number(value))) model.targets[axis] = Number(value);
if (TWIN_ANGLE_RANGES[axis]) {
@@ -1817,7 +1871,7 @@ function updateSeatTwin(actuatorState) {
}
});
["ventilation", "heating"].forEach((feature) => {
- const value = actuatorState[seat]?.[feature];
+ const value = device?.[feature];
const enabled = value?.state === "on";
const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0;
model.thermalTargets[feature] = level / 3;
@@ -2255,7 +2309,7 @@ function normalizeReferenceVehicle(THREE, vehicle) {
return { bounds, size: bounds.getSize(new THREE.Vector3()), scale };
}
-function measureReferenceDriverCushion(THREE, vehicle) {
+function measureReferenceSeatCushion(THREE, vehicle, minX, maxX) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return null;
interior.updateWorldMatrix(true, false);
@@ -2274,15 +2328,15 @@ function measureReferenceDriverCushion(THREE, vehicle) {
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
- const insideDriverCushion = (
- centroid.x >= 0.18
- && centroid.x <= 1.18
+ const insideSeatCushion = (
+ centroid.x >= minX
+ && centroid.x <= maxX
&& centroid.y >= 0.25
&& centroid.y <= 1.15
&& centroid.z >= -0.12
&& centroid.z <= 0.98
);
- if (insideDriverCushion) samples.push(centroid.y);
+ if (insideSeatCushion) samples.push(centroid.y);
}
if (geometry !== interior.geometry) geometry.dispose();
if (samples.length < 20) return null;
@@ -2299,7 +2353,15 @@ function measureReferenceDriverCushion(THREE, vehicle) {
};
}
-function carveReferencePassengerSeat(THREE, vehicle) {
+function measureReferenceDriverCushion(THREE, vehicle) {
+ return measureReferenceSeatCushion(THREE, vehicle, 0.18, 1.18);
+}
+
+function measureReferencePassengerCushion(THREE, vehicle) {
+ return measureReferenceSeatCushion(THREE, vehicle, -1.18, -0.18);
+}
+
+function carveReferenceFrontSeats(THREE, vehicle) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return 0;
interior.updateWorldMatrix(true, false);
@@ -2319,15 +2381,15 @@ function carveReferencePassengerSeat(THREE, vehicle) {
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
- const insidePassengerSeat = (
- centroid.x >= -1.15
- && centroid.x <= -0.12
+ const insideFrontSeat = (
+ ((centroid.x >= -1.15 && centroid.x <= -0.12)
+ || (centroid.x >= 0.12 && centroid.x <= 1.15))
&& centroid.y >= 0.05
&& centroid.y <= 2.05
&& centroid.z >= -0.92
&& centroid.z <= 1.08
);
- if (insidePassengerSeat) {
+ if (insideFrontSeat) {
removedTriangles += 1;
continue;
}
@@ -2927,7 +2989,8 @@ function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
(vehicle) => {
const normalized = normalizeReferenceVehicle(THREE, vehicle);
const driverCushion = measureReferenceDriverCushion(THREE, vehicle);
- const removedPassengerTriangles = carveReferencePassengerSeat(THREE, vehicle);
+ const passengerCushion = measureReferencePassengerCushion(THREE, vehicle);
+ const removedFrontSeatTriangles = carveReferenceFrontSeats(THREE, vehicle);
const referenceWindowPanes = extractReferenceWindowPanes(THREE, vehicle);
const report = configureReferenceVehicleModel(THREE, vehicle);
styleReferencePassiveGlass(THREE, vehicle);
@@ -2965,7 +3028,7 @@ function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({
meshes: report.meshes,
size: normalized.size.toArray().map((value) => Number(value.toFixed(3))),
- removedPassengerTriangles,
+ removedFrontSeatTriangles,
windowPanes: Object.fromEntries(
Object.entries(referenceWindowPanes).map(([key, value]) => [key, value.length]),
),
@@ -2976,8 +3039,9 @@ function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
rearLampMeshes: passiveRearExterior.rearLampMeshes.length,
rearBumperMeshes: passiveRearExterior.rearBumperMeshes.length,
driverCushion,
+ passengerCushion,
}));
- resolve({ vehicle, report, normalized, driverCushion });
+ resolve({ vehicle, report, normalized, driverCushion, passengerCushion });
},
(event) => {
if (!fallback || !event.total) return;
@@ -3087,33 +3151,22 @@ function initSeatTwin() {
const models = {
passenger: createTwinSeat(THREE, -1.05, 0x4a78af),
+ driver: createTwinSeat(THREE, 1.05, 0x08766e),
};
- configureActivePassengerSeatStyle(models.passenger);
- models.passenger.root.scale.setScalar(0.5);
- models.passenger.root.position.set(-0.62, 0.38, 0.25);
- models.passenger.root.userData.twinRole = "active_front_passenger";
- configureTwinSolidEntity(THREE, models.passenger.root, {
- renderOrder: 51,
+ Object.entries(models).forEach(([seat, model]) => {
+ configureActivePassengerSeatStyle(model);
+ model.root.scale.setScalar(0.5);
+ model.root.position.set(seat === "driver" ? 0.62 : -0.62, 0.38, 0.25);
+ model.root.userData.baseRootY = model.root.position.y;
+ model.root.userData.twinRole = `active_front_${seat}`;
+ configureTwinSolidEntity(THREE, model.root, { renderOrder: 51 });
+ configureActiveSeatDepthLayers(THREE, model);
+ scene.add(model.root);
});
- configureActiveSeatDepthLayers(THREE, models.passenger);
- scene.add(models.passenger.root);
const fixedModels = {
- driver: configureFixedTwinSeat(
- THREE,
- createTwinSeat(THREE, 0.82, 0x667f84),
- [0.82, 0.18, -0.25],
- "fixed_front_driver",
- ),
rear: createTwinRearBench(THREE),
};
- applyTwinOutlineStyle(THREE, fixedModels.driver.root, {
- color: 0x5e7479,
- lineOpacity: 0.46,
- fillOpacity: 0.01,
- renderOrder: 41,
- thresholdAngle: 26,
- });
applyTwinOutlineStyle(THREE, fixedModels.rear, {
color: 0x5e7479,
lineOpacity: 0.42,
@@ -3121,12 +3174,11 @@ function initSeatTwin() {
renderOrder: 41,
thresholdAngle: 26,
});
- scene.add(fixedModels.driver.root);
vehicleSystems.root.add(fixedModels.rear);
[
vehicleSystems.root,
models.passenger.root,
- fixedModels.driver.root,
+ models.driver.root,
fixedModels.rear,
].forEach((root) => {
root.traverse((object) => object.layers.set(1));
@@ -3145,8 +3197,8 @@ function initSeatTwin() {
fixedModels,
vehicleSystems,
selected: "passenger",
- actuatorState: { passenger: initialDevice() },
- explicitAngleTargets: { passenger: {} },
+ actuatorState: { passenger: initialDevice(), driver: initialDevice() },
+ explicitAngleTargets: { passenger: {}, driver: {} },
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
dragging: false,
pointer: { x: 0, y: 0 },
@@ -3157,32 +3209,29 @@ function initSeatTwin() {
});
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
- .then(({ vehicle, report, normalized, driverCushion }) => {
+ .then(({ vehicle, report, normalized, driverCushion, passengerCushion }) => {
if (!state.seatTwin) return;
state.seatTwin.referenceVehicle = vehicle;
state.seatTwin.referenceReport = report;
state.seatTwin.referenceBounds = normalized.bounds;
- fixedModels.driver.root.visible = false;
fixedModels.rear.visible = false;
- if (driverCushion) {
- const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot);
+ [["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => {
+ if (!referenceCushion) return;
+ const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
const verticalCorrection = THREE.MathUtils.clamp(
- driverCushion.surfaceY - cushionBoundsBefore.max.y,
+ referenceCushion.surfaceY - cushionBoundsBefore.max.y,
-0.08,
0.42,
);
- models.passenger.root.position.y += verticalCorrection;
- models.passenger.root.updateMatrixWorld(true);
- const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot);
- models.passenger.root.userData.cushionSurfaceY = cushionBoundsAfter.max.y;
- canvas.dataset.activeSeatCushionSurface = cushionBoundsAfter.max.y.toFixed(3);
- canvas.dataset.activeSeatRootY = models.passenger.root.position.y.toFixed(3);
- canvas.dataset.referenceDriverCushionSurface = driverCushion.surfaceY.toFixed(3);
- canvas.dataset.referenceDriverCushionSamples = String(driverCushion.triangleCount);
- canvas.dataset.seatCushionAligned = String(
- Math.abs(cushionBoundsAfter.max.y - driverCushion.surfaceY) < 0.025
+ models[seat].root.position.y += verticalCorrection;
+ models[seat].root.userData.baseRootY = models[seat].root.position.y;
+ models[seat].root.updateMatrixWorld(true);
+ const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
+ models[seat].root.userData.cushionSurfaceY = cushionBoundsAfter.max.y;
+ canvas.dataset[`${seat}SeatCushionAligned`] = String(
+ Math.abs(cushionBoundsAfter.max.y - referenceCushion.surfaceY) < 0.025
);
- }
+ });
models.passenger.root.updateMatrixWorld(true);
const activeSeatBounds = new THREE.Box3().setFromObject(models.passenger.root);
const activeSeatSize = activeSeatBounds.getSize(new THREE.Vector3());
@@ -3273,9 +3322,12 @@ function initSeatTwin() {
backrest_included_angle: "backrest_recline",
legrest_included_angle: "legrest_height",
}[axis];
+ const supportedAxes = twinAxesForSeat(twin.selected);
+ const supported = supportedAxes.has(includedAxis || axis);
const requested = (
- !includedAxis
- || twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null
+ supported
+ && (!includedAxis
+ || twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null)
);
const visible = (
requested
@@ -3334,6 +3386,8 @@ function initSeatTwin() {
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
});
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
+ model.root.position.y = Number(model.root.userData.baseRootY ?? 0.38)
+ + (model.positions.height - 50) * 0.003;
const cushionAngle = model.positions.cushion_tilt * 0.09;
model.cushionPivot.rotation.x = degrees(-cushionAngle);
model.backrestPivot.rotation.x = (
@@ -3366,7 +3420,15 @@ function initSeatTwin() {
});
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
const { view } = twin;
- const cameraDistance = view.distance * (twin.narrowViewport ? 1.08 : 1);
+ // Fit the whole reference vehicle against the limiting horizontal field
+ // of view. The desktop twin is often almost portrait-shaped, where a
+ // fixed narrow-viewport multiplier still clips the front bumper.
+ const viewportFit = THREE.MathUtils.clamp(
+ 1.24 / Math.max(0.72, camera.aspect),
+ 1,
+ 1.4,
+ );
+ const cameraDistance = view.distance * viewportFit;
const horizontal = Math.cos(view.pitch) * cameraDistance;
const cameraTargetX = view.targetX;
camera.position.set(
@@ -3438,30 +3500,32 @@ function selectProvider(provider) {
function updateSeatFeedbackModeLabel() {
const label = $("#seatFeedbackModeLabel");
- if (!label) return;
- if (state.seatFeedbackSource === "physical_can_ch1") {
- label.textContent = "CH1 PHYSICAL CAN · DRIVER SYNCHRONIZED";
- return;
- }
- if (state.seatFeedbackSource === "physical_can") {
- label.textContent = "PHYSICAL CAN · BENCH SYNCHRONIZED";
- return;
- }
- if (state.seatFeedbackSource === "physical_partial") {
- label.textContent = "PHYSICAL CAN · PARTIAL FEEDBACK";
- return;
- }
- if (state.seatFeedbackSource === "unavailable") {
- label.textContent = "PHYSICAL CAN · SYNC UNAVAILABLE";
- return;
- }
- if (state.seatFeedbackSource === "simulated_hardware") {
- label.textContent = "SIMULATED HIL · NO PHYSICAL CAN";
- return;
- }
- label.textContent = state.executionMode === "hardware"
- ? "PHYSICAL CAN FEEDBACK"
- : "SIMULATED FEEDBACK";
+ const descriptions = {
+ physical_can: "实车已同步",
+ physical_partial: "物理反馈不完整",
+ physical_can_ch1: "实车已同步",
+ simulated_hardware: "模拟 HIL",
+ simulated: "虚拟反馈",
+ unavailable: "不可用",
+ };
+ const compactDescriptions = {
+ physical_can: "实车",
+ physical_partial: "部分",
+ physical_can_ch1: "实车",
+ simulated_hardware: "模拟",
+ simulated: "虚拟",
+ unavailable: "未连接",
+ };
+ const channels = { passenger: "CH0", driver: "CH1" };
+ Object.entries(channels).forEach(([seat, channel]) => {
+ const source = state.seatFeedbackSources[seat] || "unavailable";
+ const badge = $(`[data-seat-feedback="${seat}"]`);
+ if (!badge) return;
+ badge.dataset.source = source;
+ badge.textContent = `${channel} · ${compactDescriptions[source] || source}`;
+ badge.title = `${channel} · ${descriptions[source] || source}`;
+ });
+ if (label) label.textContent = `CH0 副驾 · ${descriptions[state.seatFeedbackSources.passenger]} / CH1 主驾 · ${descriptions[state.seatFeedbackSources.driver]}`;
}
function selectExecutionMode(mode) {
@@ -3474,7 +3538,7 @@ function selectExecutionMode(mode) {
const hardware = mode === "hardware";
const simulatedHardware = mode === "simulated_hardware";
const vehicleCanReady = Boolean(state.runtime?.vehicle_can?.configured);
- const ch1DriverOnly = state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only";
+ const ch1DriverOnly = isDriverOnlyCh1Runtime();
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
@@ -3489,7 +3553,7 @@ function selectExecutionMode(mode) {
? ch1DriverOnly && vehicleCanReady
? "仅使用 ZLG CH1 控制主驾座椅与已验证车身设备;每次动作前必须确认 P 挡和 0 km/h,并以 CH1 状态帧回读为准。CH0、副驾 T2G 与 IAR 路径均不参与。"
: vehicleCanReady
- ? "同一 CAN 盒由单进程持有:通道 0 控制副驾座椅并校验板端反馈,通道 1 以 ISO CAN FD 500 kbit/s / 2 Mbit/s 控制空调等车身设备并校验状态帧。"
+ ? "同一 CAN 盒由单进程持有:CH0 控制副驾四电机并校验板端反馈;CH1 控制主驾四电机与车身设备,每次动作前校验 P 挡、零车速和对应状态帧。"
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
: simulatedHardware
? "故障只注入执行报告;物理 CAN、ZLG 适配器和 IAR 固件路径保持关闭。"
@@ -3541,44 +3605,50 @@ function applySeatMotorAvailability(vehicle) {
const availableAxes = new Set(Object.keys(motors));
state.availableSeatAxes = availableAxes;
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
- const available = availableAxes.has(axis);
+ const available = availableAxes.has(axis) || axis === "height";
$$(`[data-axis="${axis}"], [data-twin-axis="${axis}"], [data-callout="${axis}"]`)
.forEach((item) => {
item.classList.toggle("seat-axis-unavailable", !available);
item.setAttribute("aria-hidden", String(!available));
});
- const motorName = motors[axis]?.name || axis;
- const twinLabel = axis === "longitudinal"
- ? `${motorName}位置`
- : ["cushion_tilt", "backrest_recline", "legrest_height"].includes(axis)
- ? `${motorName}夹角`
- : motorName;
+ const twinLabel = {
+ longitudinal: "水平位置",
+ cushion_tilt: "坐垫俯仰",
+ height: "座椅高度",
+ backrest_recline: "靠背夹角",
+ legrest_height: "腿托夹角",
+ legrest_extension: "腿托伸缩",
+ }[axis];
$$(`[data-twin-axis="${axis}"] > span, [data-callout="${axis}"] > span`)
.forEach((label) => { label.textContent = twinLabel; });
});
- const cushionName = motors.cushion_tilt?.name;
- const backrestName = motors.backrest_recline?.name;
- const legrestName = motors.legrest_height?.name;
const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span');
const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span');
- if (backrestIncludedLabel && backrestName && cushionName) {
- backrestIncludedLabel.textContent = `${backrestName}-${cushionName}夹角`;
- }
- if (legrestIncludedLabel && cushionName && legrestName) {
- legrestIncludedLabel.textContent = `${cushionName}-${legrestName}夹角`;
- }
+ if (backrestIncludedLabel) backrestIncludedLabel.textContent = "靠背-坐垫夹角";
+ if (legrestIncludedLabel) legrestIncludedLabel.textContent = "坐垫-腿托夹角";
}
function renderSeatMotorRows(vehicle) {
- const motors = Object.entries(vehicle?.seat?.motors || {});
$$(".seat-card .motion-state-grid").forEach((grid) => {
+ const seat = grid.closest(".seat-card")?.dataset.seat;
+ const motors = seat === "driver"
+ ? [
+ ["longitudinal", { name: "水平" }],
+ ["height", { name: "高度" }],
+ ["backrest_recline", { name: "靠背" }],
+ ["cushion_tilt", { name: "盆倾" }],
+ ]
+ : Object.entries(vehicle?.seat?.motors || {});
const fragment = document.createDocumentFragment();
motors.forEach(([axis, motor]) => {
const item = document.createElement("div");
item.className = "motion-axis";
item.dataset.axis = axis;
const label = document.createElement("span");
- label.textContent = motor?.name || axis;
+ label.textContent = {
+ longitudinal: "水平", cushion_tilt: "坐垫", height: "高度",
+ backrest_recline: "靠背", legrest_height: "腿托", legrest_extension: "伸缩",
+ }[axis] || motor?.name || axis;
const value = document.createElement("b");
value.textContent = "--";
item.append(label, value, document.createElement("i"));
@@ -3645,19 +3715,242 @@ function modeSecondsLabel(durationMs) {
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
}
+const MODE_GALLERY_GROUPS = [
+ {
+ id: "personal",
+ title: "我的模式",
+ description: "根据自然语言指令创建,可执行或删除",
+ icon: "bookmark",
+ modeIds: [],
+ userOnly: true,
+ },
+ {
+ id: "frequent",
+ title: "常用模式",
+ description: "高频座舱场景,可从主界面快捷执行",
+ icon: "sparkles",
+ modeIds: ["zero_gravity", "office", "nap", "queen_passenger_driving", "leisure_lounger"],
+ },
+ {
+ id: "comfort",
+ title: "舒享模式",
+ description: "休憩、冥想与乘坐舒适场景",
+ icon: "armchair",
+ modeIds: [
+ "cruise_sofa",
+ "easy_entry_exit",
+ "electric_beach_chair",
+ "calf_decompression_float",
+ "baby_nap",
+ "meditation_space",
+ ],
+ },
+ {
+ id: "dynamic",
+ title: "动态模式",
+ description: "按受控节拍持续调整座椅姿态",
+ icon: "waves",
+ modeIds: ["rocking_horse", "dynamic_cradle"],
+ },
+];
+
+const MODE_DOMAIN_LABELS = {
+ seat: "座椅",
+ climate: "空调",
+ roof: "天幕",
+ ambient_light: "氛围灯",
+ window: "车窗",
+ front_light: "车灯",
+ vehicle_status: "车辆状态",
+};
+
+function isUserModeProfile(mode) {
+ return mode?.origin === "user" || mode?.deletable === true;
+}
+
+function modeGalleryBuckets() {
+ const modes = modeProfiles();
+ const assignedIds = new Set(MODE_GALLERY_GROUPS.flatMap((group) => group.modeIds));
+ return MODE_GALLERY_GROUPS.map((definition) => {
+ let groupModes;
+ if (definition.userOnly) {
+ groupModes = modes.filter(isUserModeProfile);
+ } else if (definition.id === "comfort") {
+ groupModes = definition.modeIds
+ .map((modeId) => modes.find((mode) => mode.id === modeId))
+ .filter(Boolean);
+ modes.filter((mode) => !isUserModeProfile(mode) && !assignedIds.has(mode.id))
+ .forEach((mode) => groupModes.push(mode));
+ } else {
+ groupModes = definition.modeIds
+ .map((modeId) => modes.find((mode) => mode.id === modeId))
+ .filter(Boolean);
+ }
+ return { ...definition, modes: groupModes };
+ }).filter((group) => group.modes.length > 0 || group.userOnly);
+}
+
+function createModeGalleryCard(mode) {
+ const shell = document.createElement("article");
+ shell.className = "mode-gallery-card-shell";
+ shell.dataset.modeOrigin = isUserModeProfile(mode) ? "user" : "preset";
+ const button = document.createElement("button");
+ button.className = "mode-gallery-card";
+ button.type = "button";
+ button.dataset.modeProfileId = mode.id;
+ button.dataset.modeCommand = `进入${mode.name}`;
+ button.dataset.modeOrigin = isUserModeProfile(mode) ? "user" : "preset";
+ button.dataset.modeState = "idle";
+ button.setAttribute("aria-pressed", "false");
+
+ const iconBox = document.createElement("span");
+ iconBox.className = "mode-gallery-card-icon";
+ const icon = document.createElement("i");
+ icon.dataset.lucide = mode.icon || "bookmark";
+ iconBox.append(icon);
+
+ const copy = document.createElement("span");
+ copy.className = "mode-gallery-card-copy";
+ const heading = document.createElement("span");
+ heading.className = "mode-gallery-card-heading";
+ const name = document.createElement("strong");
+ name.textContent = mode.name || mode.id;
+ const stateLabel = document.createElement("small");
+ stateLabel.className = "mode-gallery-state";
+ stateLabel.textContent = "点击执行";
+ heading.append(name, stateLabel);
+ const description = document.createElement("span");
+ description.className = "mode-gallery-card-description";
+ description.textContent = mode.description || "整车场景联动配置";
+ const meta = document.createElement("span");
+ meta.className = "mode-gallery-card-meta";
+ const domains = Array.isArray(mode.domains) ? mode.domains : [];
+ const domainCopy = domains.slice(0, 4)
+ .map((domain) => MODE_DOMAIN_LABELS[domain] || domain)
+ .join(" · ");
+ meta.textContent = domainCopy || `${Number(mode.operation_count || 0)} 项联动动作`;
+ copy.append(heading, description, meta);
+ button.append(iconBox, copy);
+ shell.append(button);
+ if (isUserModeProfile(mode)) {
+ const deleteButton = document.createElement("button");
+ deleteButton.className = "mode-gallery-delete";
+ deleteButton.type = "button";
+ deleteButton.dataset.modeDelete = mode.id;
+ deleteButton.title = `删除${mode.name}`;
+ deleteButton.setAttribute("aria-label", `删除自定义功能模式${mode.name}`);
+ const deleteIcon = document.createElement("i");
+ deleteIcon.dataset.lucide = "trash-2";
+ deleteButton.append(deleteIcon);
+ shell.append(deleteButton);
+ }
+ return shell;
+}
+
+function populateModeGallery() {
+ const container = $("#modeGalleryGroups");
+ if (!container) return;
+ container.replaceChildren();
+ modeGalleryBuckets().forEach((group) => {
+ const section = document.createElement("section");
+ section.className = "mode-gallery-group";
+ section.dataset.modeGroup = group.id;
+ const header = document.createElement("header");
+ const title = document.createElement("span");
+ const titleIcon = document.createElement("i");
+ titleIcon.dataset.lucide = group.icon;
+ const titleCopy = document.createElement("span");
+ const strong = document.createElement("strong");
+ strong.textContent = group.title;
+ const small = document.createElement("small");
+ small.textContent = group.description;
+ titleCopy.append(strong, small);
+ title.append(titleIcon, titleCopy);
+ const count = document.createElement("b");
+ count.textContent = String(group.modes.length);
+ header.append(title, count);
+ section.append(header);
+
+ const grid = document.createElement("div");
+ grid.className = "mode-gallery-grid";
+ if (group.modes.length) {
+ group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
+ } else {
+ const empty = document.createElement("div");
+ empty.className = "mode-gallery-empty";
+ empty.innerHTML = '还没有自定义模式可在主界面根据当前指令创建';
+ grid.append(empty);
+ }
+ section.append(grid);
+ container.append(section);
+ });
+ const modeCount = modeProfiles().length;
+ $("#modeGalleryCount").textContent = String(modeCount);
+ $("#modeGallerySummaryCount").textContent = String(modeCount);
+ refreshIcons();
+}
+
+function updateModeRailNavigation() {
+ const chips = $(".mode-chips");
+ const previous = $("#modeRailPrev");
+ const next = $("#modeRailNext");
+ if (!chips || !previous || !next) return;
+ const maxScroll = Math.max(0, chips.scrollWidth - chips.clientWidth);
+ previous.disabled = maxScroll <= 2 || chips.scrollLeft <= 2;
+ next.disabled = maxScroll <= 2 || chips.scrollLeft >= maxScroll - 2;
+}
+
+function scrollModeRail(direction) {
+ const chips = $(".mode-chips");
+ if (!chips) return;
+ const buttons = $$("button[data-mode-profile-id]", chips);
+ const viewport = chips.getBoundingClientRect();
+ const target = direction > 0
+ ? buttons.find((button) => button.getBoundingClientRect().right > viewport.right + 2)
+ : [...buttons].reverse().find((button) => button.getBoundingClientRect().left < viewport.left - 2);
+ if (target) {
+ const targetLeft = direction > 0
+ ? target.offsetLeft - 6
+ : target.offsetLeft + target.offsetWidth - chips.clientWidth + 6;
+ chips.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
+ } else {
+ chips.scrollBy({ left: direction * Math.max(220, chips.clientWidth * 0.72), behavior: "smooth" });
+ }
+}
+
+function openModeGallery() {
+ const backdrop = $("#modeGalleryBackdrop");
+ if (!backdrop) return;
+ closeSaveModeComposer();
+ backdrop.hidden = false;
+ document.body.classList.add("mode-gallery-open");
+ $("#modeGalleryToggle").setAttribute("aria-expanded", "true");
+ window.requestAnimationFrame(() => {
+ backdrop.classList.add("open");
+ $("#modeGalleryClose").focus();
+ });
+}
+
+function closeModeGallery({ restoreFocus = true } = {}) {
+ const backdrop = $("#modeGalleryBackdrop");
+ if (!backdrop || backdrop.hidden) return;
+ backdrop.classList.remove("open");
+ backdrop.hidden = true;
+ document.body.classList.remove("mode-gallery-open");
+ $("#modeGalleryToggle").setAttribute("aria-expanded", "false");
+ if (restoreFocus) $("#modeGalleryToggle").focus();
+}
+
function populateModeChips() {
const group = $(".mode-chips");
if (!group) return;
- if (state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only") {
- group.innerHTML = `
-
-
- `;
- refreshIcons();
- return;
- }
const buttons = () => $$("button", group);
- modeProfiles().forEach((mode) => {
+ const modes = modeProfiles();
+ const runtimeModeIds = new Set(modes.map((mode) => mode.id));
+ $$('button[data-mode-origin="user"]', group).forEach((button) => {
+ if (!runtimeModeIds.has(button.dataset.modeProfileId)) button.remove();
+ });
+ modes.forEach((mode) => {
const label = modeChipLabel(mode);
let button = buttons().find((item) => item.dataset.modeProfileId === mode.id)
|| buttons().find((item) => item.textContent.trim() === label);
@@ -3666,11 +3959,25 @@ function populateModeChips() {
button.type = "button";
const icon = document.createElement("i");
icon.dataset.lucide = mode.icon || "bookmark";
- button.append(icon, document.createTextNode(label));
+ const labelNode = document.createElement("span");
+ labelNode.className = "mode-chip-label";
+ labelNode.textContent = label;
+ button.append(icon, labelNode);
group.append(button);
}
+ let labelNode = $(".mode-chip-label", button) || $("span", button);
+ if (!labelNode) {
+ labelNode = document.createElement("span");
+ button.append(labelNode);
+ }
+ labelNode.classList.add("mode-chip-label");
+ labelNode.textContent = label;
button.dataset.modeProfileId = mode.id;
button.dataset.modeCommand = `进入${mode.name}`;
+ button.dataset.modeOrigin = isUserModeProfile(mode)
+ ? "user"
+ : "preset";
+ button.setAttribute("aria-pressed", "false");
const timeline = mode.timeline;
let cycleBadge = $(".mode-cycle-badge", button);
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
@@ -3708,96 +4015,250 @@ function populateModeChips() {
button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n");
});
+ const preferredStaticOrder = [
+ "zero_gravity",
+ "office",
+ "nap",
+ "queen_passenger_driving",
+ "leisure_lounger",
+ ];
const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
const allButtons = buttons();
+ const userButtons = allButtons.filter((button) => button.dataset.modeOrigin === "user");
const dynamicButtons = dynamicModeOrder
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
- const staticButtons = allButtons.filter((button) => !dynamicButtons.includes(button));
- group.querySelector(".mode-group-divider")?.remove();
- dynamicButtons.forEach((button) => group.append(button));
+ const staticButtons = allButtons.filter(
+ (button) => !userButtons.includes(button) && !dynamicButtons.includes(button),
+ );
+ const preferredStaticButtons = preferredStaticOrder
+ .map((modeId) => staticButtons.find((button) => button.dataset.modeProfileId === modeId))
+ .filter(Boolean);
+ const otherStaticButtons = staticButtons.filter(
+ (button) => !preferredStaticButtons.includes(button),
+ );
+ $$(".mode-group-divider", group).forEach((divider) => divider.remove());
+ userButtons.forEach((button) => group.append(button));
+ if (userButtons.length && staticButtons.length) {
+ const userDivider = document.createElement("span");
+ userDivider.className = "mode-group-divider";
+ userDivider.dataset.modeDivider = "user";
+ userDivider.setAttribute("aria-hidden", "true");
+ group.append(userDivider);
+ }
+ preferredStaticButtons.forEach((button) => group.append(button));
+ otherStaticButtons.forEach((button) => group.append(button));
if (dynamicButtons.length && staticButtons.length) {
const divider = document.createElement("span");
divider.className = "mode-group-divider";
divider.setAttribute("aria-hidden", "true");
group.append(divider);
}
- staticButtons.forEach((button) => group.append(button));
+ dynamicButtons.forEach((button) => group.append(button));
+ if (userButtons.length) group.scrollLeft = 0;
+ populateModeGallery();
refreshIcons();
+ window.requestAnimationFrame(updateModeRailNavigation);
}
-function applyModeCommand(command) {
+async function deleteUserModeProfile(modeProfileId) {
+ const mode = modeProfiles().find((item) => item.id === modeProfileId);
+ if (!mode || !isUserModeProfile(mode)) return;
+ if (!window.confirm(`删除自定义功能模式“${mode.name}”?删除后无法恢复。`)) return;
+
+ const deleteButtons = $$(`[data-mode-delete="${CSS.escape(modeProfileId)}"]`);
+ deleteButtons.forEach((button) => {
+ button.disabled = true;
+ button.setAttribute("aria-busy", "true");
+ });
+ try {
+ const query = new URLSearchParams({
+ vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id || "",
+ });
+ const response = await apiRequest(
+ `/api/mode-profiles/${encodeURIComponent(modeProfileId)}?${query}`,
+ { method: "DELETE" },
+ );
+ const payload = await response.json();
+ if (response.status === 401) {
+ window.location.replace("/login");
+ return;
+ }
+ if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
+ await loadRuntime();
+ showToast(`${mode.name}已删除`);
+ } catch (error) {
+ showToast(`删除失败:${error.message || "服务不可用"}`);
+ deleteButtons.forEach((button) => {
+ button.disabled = false;
+ button.removeAttribute("aria-busy");
+ });
+ }
+}
+
+function setModeExecutionState(modeProfileId, modeState = "idle") {
+ window.clearTimeout(state.modeRunTimer);
+ state.modeRunTimer = null;
+ $$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
+ const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
+ button.classList.toggle("running", active && modeState === "running");
+ button.classList.toggle("succeeded", active && modeState === "succeeded");
+ button.classList.toggle("failed", active && modeState === "failed");
+ button.dataset.modeState = active ? modeState : "idle";
+ button.setAttribute("aria-pressed", active ? "true" : "false");
+ button.setAttribute("aria-busy", active && modeState === "running" ? "true" : "false");
+ const stateLabel = $(".mode-gallery-state", button);
+ if (stateLabel) {
+ stateLabel.textContent = active && modeState === "running"
+ ? "执行中"
+ : active && modeState === "succeeded"
+ ? "已完成"
+ : active && modeState === "failed"
+ ? "执行失败"
+ : "点击执行";
+ }
+ });
+ if (modeProfileId && ["succeeded", "failed"].includes(modeState)) {
+ state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
+ }
+}
+
+function setModeChipsDisabled(disabled) {
+ $$(".mode-chips button[data-mode-command], #modeGallery button[data-mode-command]").forEach((button) => {
+ button.disabled = disabled;
+ });
+}
+
+function applyModeCommand(command, modeProfileId = null) {
if (!command) return;
+ if ($("#runButton").disabled) {
+ showToast("上一条指令仍在执行,请稍候");
+ return;
+ }
+ closeModeGallery({ restoreFocus: false });
+ state.pendingModeProfileId = modeProfileId;
+ if (modeProfileId) setModeExecutionState(modeProfileId, "running");
$("#utterance").value = command;
$("#requestForm").requestSubmit();
}
+function updateModeDraftPreview() {
+ const command = $("#utterance").value.trim();
+ state.modeDraftCommand = command;
+ const source = $("#saveModeSource");
+ source.textContent = command || "请先在指令框输入需要固化的功能组合";
+ source.classList.toggle("empty", !command);
+}
+
function toggleSaveModeComposer() {
const composer = $("#saveModeComposer");
composer.hidden = !composer.hidden;
- if (!composer.hidden) $("#saveModeName").focus();
+ if (!composer.hidden) {
+ updateModeDraftPreview();
+ $("#saveModeName").focus();
+ }
}
-function saveCurrentModeProfile() {
+function closeSaveModeComposer() {
+ $("#saveModeComposer").hidden = true;
+ state.modeDraftCommand = "";
+}
+
+function requestCommandAndWait(utterance) {
+ return new Promise((resolve) => {
+ const button = $("#runButton");
+ if (!button || button.disabled) {
+ resolve({ result: null, error: { message: "上一条指令仍在执行" } });
+ return;
+ }
+ const timeoutMs = requestTimeoutForExecutionMode(state.executionMode) + 5000;
+ const onComplete = (event) => {
+ if (event.detail?.utterance !== utterance) return;
+ window.clearTimeout(timer);
+ window.removeEventListener("cognitive-agent:request-complete", onComplete);
+ resolve(event.detail);
+ };
+ const timer = window.setTimeout(() => {
+ window.removeEventListener("cognitive-agent:request-complete", onComplete);
+ resolve({ result: null, error: { message: "指令执行等待超时" } });
+ }, timeoutMs);
+ window.addEventListener("cognitive-agent:request-complete", onComplete);
+ $("#utterance").value = utterance;
+ $("#requestForm").requestSubmit();
+ });
+}
+
+async function saveCurrentModeProfile() {
const input = $("#saveModeName");
const rawName = input.value.trim();
if (!rawName) {
showToast("请输入模式名称");
return;
}
+ const sourceCommand = state.modeDraftCommand || $("#utterance").value.trim();
+ if (!sourceCommand) {
+ showToast("请先输入需要创建为模式的用户指令");
+ $("#utterance").focus();
+ return;
+ }
const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`;
- $("#utterance").value = `这个模式不错,固化为${modeName}`;
- $("#saveModeComposer").hidden = true;
- input.value = "";
- $("#requestForm").requestSubmit();
+ const confirm = $("#saveModeConfirm");
+ confirm.disabled = true;
+ confirm.classList.add("loading");
+ confirm.textContent = "创建中…";
+ try {
+ if (state.lastSuccessfulUtterance !== sourceCommand) {
+ showToast("正在执行当前指令,成功后将创建功能模式");
+ const execution = await requestCommandAndWait(sourceCommand);
+ if (!execution.result || !["succeeded", "partially_succeeded"].includes(execution.result.status)) {
+ throw new Error(execution.error?.message || "当前指令未成功执行,未创建功能模式");
+ }
+ }
+ const saveCommand = `这个模式不错,固化为${modeName}`;
+ const saved = await requestCommandAndWait(saveCommand);
+ if (!saved.result?.mode_profile || saved.result.status !== "succeeded") {
+ throw new Error(saved.error?.message || "功能模式创建失败");
+ }
+ await loadRuntime();
+ const createdModeId = saved.result.mode_profile.id;
+ setModeExecutionState(createdModeId, "succeeded");
+ closeSaveModeComposer();
+ input.value = "";
+ $("#utterance").value = sourceCommand;
+ showToast(`${saved.result.mode_profile.name}已创建并添加到功能模式栏`);
+ } catch (error) {
+ showToast(error.message || "功能模式创建失败");
+ } finally {
+ confirm.disabled = false;
+ confirm.classList.remove("loading");
+ confirm.textContent = "创建并添加";
+ }
}
function applyRuntimeTargetPolicy(runtime) {
- const driverOnly = runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only";
- if (!driverOnly) return;
+ const passengerReady = runtime?.hardware?.configured === true;
+ const driverReady = runtime?.vehicle_can?.configured === true;
+ const driverOnly = driverReady && !passengerReady;
const stage = $("#seatTwin");
- const canvas = $("#seatTwinCanvas");
- const label = $(".twin-seat-label");
- const card = $(".seat-card[data-seat=\"passenger\"], .seat-card[data-seat=\"driver\"]");
- if (stage) stage.dataset.physicalSeat = "driver";
- if (canvas) {
- canvas.dataset.activeSeat = "front-left";
- canvas.setAttribute("aria-label", "通过模型动画展示 CH1 主驾座椅与车身设备物理反馈");
- }
- if (label) {
- label.classList.remove("passenger");
- label.classList.add("driver");
- $("span", label).textContent = "主驾车身座椅";
- $("small", label).textContent = "ACTIVE · FRONT LEFT · CH1";
- }
- $("#twinSeatName").textContent = "主驾";
- $("#twinSeatZone").textContent = "front_left";
- if (card) {
- card.dataset.seat = "driver";
- $(".seat-identity strong", card).textContent = "主驾";
- $(".seat-identity span", card).textContent = "front_left · CH1";
- }
- const sectionHeading = $(".seat-state-section .section-title-row h3");
- if (sectionHeading) sectionHeading.textContent = "主驾座椅状态";
- const coverageTarget = $(".coverage-section dl div:nth-child(4) dt");
- const coverageValue = $(".coverage-section dl div:nth-child(4) dd");
- if (coverageTarget) coverageTarget.textContent = "主驾座椅车身 CAN 执行器";
- if (coverageValue) {
- coverageValue.textContent = "driver · front_left · CH1";
- coverageValue.className = "available";
+ if (stage) {
+ stage.dataset.passengerChannel = "CH0";
+ stage.dataset.driverChannel = "CH1";
+ stage.dataset.passengerConfigured = String(passengerReady);
+ stage.dataset.driverConfigured = String(driverReady);
}
+ ['passenger', 'driver'].forEach((seat) => {
+ const ready = seat === 'passenger' ? passengerReady : driverReady;
+ const card = $(`.seat-card[data-seat="${seat}"]`);
+ if (card) card.classList.toggle('hardware-unavailable', !ready);
+ });
const jointCopy = $(".joint-twin-heading p");
- if (jointCopy) jointCopy.textContent = "CH1 主驾座椅与车身设备物理反馈联合展示;CH0、副驾 T2G 与 IAR 路径已禁用。";
- const unsupportedFrontLightPanel = $('[data-domain-panel="front_light"]');
- if (unsupportedFrontLightPanel) unsupportedFrontLightPanel.hidden = true;
- const jointSeatScope = $(".joint-scope span");
- if (jointSeatScope) jointSeatScope.innerHTML = '主驾座椅 · CH1';
+ if (jointCopy) jointCopy.textContent = passengerReady && driverReady
+ ? "CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈,并与车身设备联合展示。"
+ : "前排双座椅保持独立展示;未配置的物理通道会明确标记为不可用。";
+ if (driverOnly) setTwinSelection("driver");
const input = $("#utterance");
- if (input) {
+ if (input && driverOnly) {
input.placeholder = "例如:打开主驾通风,并把空调温度调到24度";
- if (input.value.includes("观影状态") || input.value.includes("副驾")) {
- input.value = "打开主驾通风,并把空调温度调到24度";
- }
}
const safeExamples = [
["seat driver vent low", "主驾通风低档"],
@@ -3807,7 +4268,7 @@ function applyRuntimeTargetPolicy(runtime) {
["shade position 0", "关闭遮阳帘"],
["light reading all off", "关闭全部阅读灯"],
];
- $$(".example-command").forEach((button, index) => {
+ if (driverOnly) $$(".example-command").forEach((button, index) => {
const example = safeExamples[index];
button.hidden = !example;
if (!example) return;
@@ -3816,7 +4277,7 @@ function applyRuntimeTargetPolicy(runtime) {
if (label) label.textContent = example[1];
});
const gestureButton = $("#gestureToggleButton");
- if (gestureButton) {
+ if (gestureButton && driverOnly) {
gestureButton.disabled = true;
gestureButton.title = "CH1 主驾实车测试不启用无闭环的座椅位置手势";
}
@@ -3836,7 +4297,7 @@ async function loadRuntime() {
const linkedHardwareReady = Boolean(hardware.configured && vehicleCan.configured);
const seatHardwareReady = Boolean(hardware.configured);
const vehicleHardwareReady = Boolean(vehicleCan.configured);
- const ch1DriverOnly = vehicleCan.target_policy === "ch1_body_can_driver_only";
+ const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
const hardwareExecutionReady = Boolean(state.runtime.hardware_execution_allowed);
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
@@ -3916,10 +4377,7 @@ async function loadRuntime() {
}
}
-async function synchronizePhysicalSeatState() {
- if (state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only") {
- return false;
- }
+async function synchronizePassengerSeatState() {
if (!state.runtime?.hardware?.configured) return false;
const query = new URLSearchParams({
conversation_id: state.conversationId,
@@ -3936,21 +4394,66 @@ async function synchronizePhysicalSeatState() {
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
- renderSeatState(payload.actuator_state);
- state.seatFeedbackSource = payload.complete
+ renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
+ state.seatFeedbackSources.passenger = payload.complete
? "physical_can"
: "physical_partial";
+ state.seatFeedbackSource = state.seatFeedbackSources.passenger;
updateSeatFeedbackModeLabel();
return true;
} catch (_error) {
- if (!state.seatFeedbackSource.startsWith("physical")) {
- state.seatFeedbackSource = "unavailable";
+ if (!state.seatFeedbackSources.passenger.startsWith("physical")) {
+ state.seatFeedbackSources.passenger = "unavailable";
updateSeatFeedbackModeLabel();
}
return false;
}
}
+async function synchronizeDriverSeatState() {
+ if (!state.runtime?.vehicle_can?.configured) return false;
+ const query = new URLSearchParams({
+ conversation_id: state.conversationId,
+ vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
+ hardware_client_id: state.hardwareClientId,
+ });
+ try {
+ const response = await apiRequest(`/api/hardware/vehicle-state?${query}`, { cache: "no-store" });
+ if (response.status === 401) {
+ window.location.replace("/login");
+ return false;
+ }
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const payload = await response.json();
+ applyAuthoritativeVehicleState(payload);
+ state.seatFeedbackSources.driver = payload.complete ? "physical_can_ch1" : "physical_partial";
+ state.seatFeedbackSource = state.seatFeedbackSources.driver;
+ updateSeatFeedbackModeLabel();
+ return true;
+ } catch (_error) {
+ if (!state.seatFeedbackSources.driver.startsWith("physical")) {
+ state.seatFeedbackSources.driver = "unavailable";
+ updateSeatFeedbackModeLabel();
+ }
+ return false;
+ }
+}
+
+async function synchronizePhysicalSeatState() {
+ if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
+ state.physicalStateSyncPromise = (async () => {
+ // Both channels share one adapter owner; keep passive reads serialized.
+ const passenger = await synchronizePassengerSeatState();
+ const driver = await synchronizeDriverSeatState();
+ return passenger || driver;
+ })();
+ try {
+ return await state.physicalStateSyncPromise;
+ } finally {
+ state.physicalStateSyncPromise = null;
+ }
+}
+
async function loadAuthStatus() {
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
@@ -4160,7 +4663,18 @@ function renderSeatState(actuatorState) {
state.seatActuatorState = mergedActuatorState;
resetSeatState();
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
- Object.entries(functions).forEach(([feature, value]) => {
+ const displayFunctions = seat === "driver"
+ ? twinDeviceForSeat("driver", { driver: functions })
+ : functions;
+ Object.entries(displayFunctions).forEach(([feature, value]) => {
+ if (feature === "position_feedback" && seat === "driver") {
+ const status = $('.seat-card[data-seat="driver"] [data-dynamic-mode] b');
+ const initialized = Object.values(value.initialized || {}).filter(Boolean).length;
+ if (status) status.textContent = value.absolute_position_available === false
+ ? `0x494 初始化 ${initialized}/4 · 无绝对位置`
+ : "CH1 状态已同步";
+ return;
+ }
if (feature === "position") {
Object.entries(value).forEach(([axis, motion]) => {
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
@@ -4193,11 +4707,7 @@ function renderSeatState(actuatorState) {
$("b", item).textContent = value.state === "on" ? `ON · L${value.level}` : "OFF";
});
});
- const twinActuatorState = state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
- && mergedActuatorState.driver
- ? { ...mergedActuatorState, passenger: mergedActuatorState.driver }
- : mergedActuatorState;
- updateSeatTwin(twinActuatorState);
+ updateSeatTwin(mergedActuatorState);
}
function renderDomainState(domainState) {
@@ -4259,10 +4769,25 @@ function renderDomainState(domainState) {
"active",
transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on",
);
- $("#roofGlass").style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`;
- $("#roofShade").style.width = `${100 - shadePosition}%`;
+ const roofGlass = $("#roofGlass");
+ const roofShade = $("#roofShade");
+ if (roofGlass) roofGlass.style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`;
+ if (roofShade) roofShade.style.width = `${100 - shadePosition}%`;
$("#shadePositionValue").textContent = `${shadePosition}%`;
- $("#automaticDimming").textContent = roof.automatic_dimming === "on" ? "开启" : "关闭";
+ const shadePositionMeter = $("#shadePositionMeter");
+ if (shadePositionMeter) shadePositionMeter.style.width = `${shadePosition}%`;
+ const shadeMotionLabels = {
+ stopped: "停止",
+ opening: "打开中",
+ closing: "关闭中",
+ anti_pinch: "防夹",
+ unknown: "未知",
+ };
+ $("#shadeMotionState").textContent = shadeMotionLabels[roof.motion_state] || "未知";
+ $("#shadeInitializedState").textContent = roof.initialized === true
+ ? "已初始化"
+ : roof.initialized === false ? "未初始化" : "未知";
+ $("#shadeThermalState").textContent = roof.thermal_protection === true ? "保护中" : "正常";
$("#roofState").textContent = roof.fault ? "FAULT" : "READY";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
@@ -4271,6 +4796,46 @@ function renderDomainState(domainState) {
}
}
+ const vehicleStatus = domainState.vehicle_status || domainState.vehicle_safety;
+ const door = domainState.door;
+ if (vehicleStatus || door) {
+ const panel = $('[data-domain-panel="vehicle_status"]');
+ const gear = String(vehicleStatus?.gear || "unknown").toUpperCase();
+ const speedValue = vehicleStatus?.speed_kmh;
+ const speed = Number(speedValue);
+ const hasGear = ["P", "R", "N", "D"].includes(gear);
+ const hasSpeed = speedValue !== null
+ && speedValue !== undefined
+ && speedValue !== ""
+ && Number.isFinite(speed);
+ $("#vehicleGear").textContent = hasGear ? gear : "--";
+ $("#vehicleSpeed").textContent = hasSpeed
+ ? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h`
+ : "-- km/h";
+
+ const doorLabels = { lock: "已锁", unlock: "未锁", unknown: "未知" };
+ const doorIds = {
+ front_left: "frontLeftDoorState",
+ front_right: "frontRightDoorState",
+ rear_left: "rearLeftDoorState",
+ rear_right: "rearRightDoorState",
+ };
+ let knownDoorCount = 0;
+ Object.entries(doorIds).forEach(([name, id]) => {
+ const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase();
+ if (lockState !== "unknown") knownDoorCount += 1;
+ $(`#${id}`).textContent = doorLabels[lockState] || "未知";
+ const row = panel?.querySelector(`[data-door-state="${name}"]`);
+ if (row) row.dataset.state = lockState;
+ });
+ const hasPhysicalState = vehicleStatus?.feedback_source === "physical_can_ch1"
+ || door?.feedback_source === "physical_can_ch1";
+ panel?.classList.toggle("active", hasPhysicalState);
+ $("#vehicleTelemetryState").textContent = hasPhysicalState
+ ? (hasGear && hasSpeed && knownDoorCount === 4 ? "实时" : "部分")
+ : "只读";
+ }
+
const ambient = domainState.ambient_light;
if (ambient) {
const active = ambient.power === "on";
@@ -4310,6 +4875,19 @@ function renderDomainState(domainState) {
: modeLabels[ambient.mode] || ambient.mode || "自定义";
$("#ambientBrightness").textContent = `${ambient.front_brightness_level ?? "-"} / ${ambient.rear_brightness_level ?? "-"}`;
$("#ambientColorIndex").textContent = `${ambient.front_color_index ?? "-"} / ${ambient.rear_color_index ?? "-"}`;
+ [
+ ["ambientFrontMeter", ambient.front_brightness_level],
+ ["ambientRearMeter", ambient.rear_brightness_level],
+ ].forEach(([id, level]) => {
+ const meter = $(`#${id}`);
+ if (!meter) return;
+ const normalizedLevel = Math.max(0, Math.min(5, Number(level) || 0));
+ meter.style.background = color;
+ meter.style.opacity = String(active ? 0.28 + normalizedLevel * 0.144 : 0.18);
+ meter.style.boxShadow = active
+ ? `0 0 ${3 + normalizedLevel * 1.5}px ${color}`
+ : "none";
+ });
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.ambientPower = active ? 1 : 0;
@@ -4350,6 +4928,14 @@ function renderDomainState(domainState) {
};
Object.entries(windowPositions).forEach(([id, value]) => {
$(`#${id}`).textContent = `${value}%`;
+ const windowKey = {
+ frontLeftWindow: "front_left",
+ frontRightWindow: "front_right",
+ rearLeftWindow: "rear_left",
+ rearRightWindow: "rear_right",
+ }[id];
+ const meter = $(`[data-window-meter="${windowKey}"]`);
+ if (meter) meter.style.setProperty("--window-open", `${value}%`);
});
const anyWindowOpen = Object.values(windowPositions).some((value) => value > 0);
$('[data-domain-panel="window"]').classList.toggle("active", anyWindowOpen);
@@ -4386,6 +4972,8 @@ function renderDomainState(domainState) {
};
Object.entries(lightStates).forEach(([id, value]) => {
$(`#${id}`).textContent = onOffLabel(value);
+ const meter = $(`[data-light-meter="${id}"]`);
+ if (meter) meter.dataset.state = onOffLabel(value) === "开启" ? "on" : "off";
});
const anyLightOn = Object.values(lightStates).some((value) => onOffLabel(value) === "开启");
$('[data-domain-panel="front_light"]').classList.toggle("active", anyLightOn);
@@ -4871,6 +5459,20 @@ function physicalCh1StatePatch(value) {
return Object.keys(patch).length ? patch : null;
}
+function physicalSeatStatePatch(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const source = value.source || value.feedback_source;
+ if (["physical_can", "physical_can_ch0", "physical_partial"].includes(source)) {
+ return value;
+ }
+ const patch = {};
+ Object.entries(value).forEach(([key, item]) => {
+ const child = physicalSeatStatePatch(item);
+ if (child) patch[key] = child;
+ });
+ return Object.keys(patch).length ? patch : null;
+}
+
function isAuthoritativeVehicleStatePayload(payload) {
if (!payload || typeof payload !== "object") return false;
if (payload.conversation_id !== state.conversationId) return false;
@@ -4899,6 +5501,7 @@ function applyAuthoritativeVehicleState(payload) {
};
if (actuatorPatch?.driver) {
state.seatFeedbackSource = "physical_can_ch1";
+ state.seatFeedbackSources.driver = "physical_can_ch1";
}
renderSeatState(actuatorPatch);
renderDomainState(domainPatch);
@@ -4907,7 +5510,24 @@ function applyAuthoritativeVehicleState(payload) {
}
window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
- applyAuthoritativeVehicleState(event.detail);
+ const payload = event.detail;
+ if (!payload || payload.conversation_id !== state.conversationId) return;
+ const appliedCh1 = applyAuthoritativeVehicleState(payload);
+ const passengerPatch = physicalSeatStatePatch(payload.actuator_state?.passenger);
+ if (passengerPatch) {
+ renderSeatState({ passenger: passengerPatch });
+ state.seatFeedbackSource = "physical_can";
+ state.seatFeedbackSources.passenger = "physical_can";
+ updateSeatFeedbackModeLabel();
+ }
+ if (!appliedCh1 && !passengerPatch && payload.execution_mode !== "hardware") {
+ renderSeatState(payload.actuator_state);
+ renderDomainState(payload.domain_state);
+ if (payload.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
+ if (payload.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
+ state.seatFeedbackSource = "simulated_hardware";
+ updateSeatFeedbackModeLabel();
+ }
});
function renderResult(result) {
@@ -4916,15 +5536,21 @@ function renderResult(result) {
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
if (feedbackSource === "simulated_hardware") {
state.seatFeedbackSource = "simulated_hardware";
+ if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
+ if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
} else if (feedbackSource === "physical_can_ch1"
&& isAuthoritativeVehicleStatePayload(result)) {
state.seatFeedbackSource = "physical_can_ch1";
+ state.seatFeedbackSources.driver = "physical_can_ch1";
} else if (reportMode === "hil") {
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
? "physical_can"
: "physical_partial";
+ state.seatFeedbackSources.passenger = state.seatFeedbackSource;
} else if (result.function_configs?.seat) {
state.seatFeedbackSource = "simulated";
+ if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated";
+ if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated";
}
const partiallySucceeded = result.status === "partially_succeeded";
const executionSucceeded = result.status === "succeeded" || partiallySucceeded;
@@ -4977,7 +5603,10 @@ function renderResult(result) {
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
+ const submittedModeProfileId = state.pendingModeProfileId;
+ state.pendingModeProfileId = null;
if (!utterance) {
+ if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
showToast("请输入车舱控制请求");
return;
}
@@ -4986,13 +5615,21 @@ async function submitRequest(event) {
&& !driverOnlyCh1
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
- showToast(state.hardwareAccess?.state === "pending"
+ const accessMessage = state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
- : "请先申请并获得本地真实台架使用权");
+ : "请先申请并获得本地真实台架使用权";
+ if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
+ showToast(accessMessage);
+ window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
+ detail: { utterance, result: null, error: new Error(accessMessage) },
+ }));
return;
}
const button = $("#runButton");
+ let completedResult = null;
+ let completedError = null;
button.disabled = true;
+ setModeChipsDisabled(true);
button.classList.add("loading");
button.innerHTML = '处理中';
refreshIcons();
@@ -5032,13 +5669,20 @@ async function submitRequest(event) {
throw requestError;
}
renderResult(payload);
+ completedResult = payload;
if (state.executionMode === "hardware") {
await synchronizePhysicalSeatState();
}
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
+ if (completed) state.lastSuccessfulUtterance = utterance;
+ if (submittedModeProfileId) {
+ setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
+ }
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
showToast(completed
- ? (payload.status === "partially_succeeded"
+ ? (payload.mode_profile
+ ? `${payload.mode_profile.name}已创建并添加到功能模式`
+ : payload.status === "partially_succeeded"
? "已执行可用设备;部分设备未完成,详见执行结果"
: simulated
? "模拟台架执行完成"
@@ -5047,6 +5691,8 @@ async function submitRequest(event) {
: "可执行动作的 dry-run 已完成")
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
} catch (error) {
+ completedError = error;
+ if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
window.clearInterval(state.progressTimer);
resetPipeline();
if (error.code === "hardware_access_required") {
@@ -5066,12 +5712,16 @@ async function submitRequest(event) {
}
} finally {
button.disabled = false;
+ setModeChipsDisabled(false);
button.classList.remove("loading");
const buttonLabel = state.executionMode === "hardware"
? "真实执行"
: state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行";
button.innerHTML = `${buttonLabel}`;
refreshIcons();
+ window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
+ detail: { utterance, result: completedResult, error: completedError },
+ }));
}
}
@@ -5190,9 +5840,50 @@ document.addEventListener("DOMContentLoaded", async () => {
$(".mode-chips").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button[data-mode-command]");
- if (button) applyModeCommand(button.dataset.modeCommand);
+ if (button) applyModeCommand(
+ button.dataset.modeCommand,
+ button.dataset.modeProfileId || null,
+ );
+ });
+ $(".mode-chips").addEventListener(
+ "scroll",
+ () => window.requestAnimationFrame(updateModeRailNavigation),
+ { passive: true },
+ );
+ $("#modeRailPrev").addEventListener("click", () => scrollModeRail(-1));
+ $("#modeRailNext").addEventListener("click", () => scrollModeRail(1));
+ $("#modeGalleryToggle").addEventListener("click", openModeGallery);
+ $("#modeGalleryClose").addEventListener("click", () => closeModeGallery());
+ $("#modeGalleryBackdrop").addEventListener("click", (event) => {
+ if (event.target === event.currentTarget) closeModeGallery();
+ });
+ $("#modeGalleryGroups").addEventListener("click", (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const deleteButton = target?.closest("button[data-mode-delete]");
+ if (deleteButton) {
+ event.preventDefault();
+ event.stopPropagation();
+ deleteUserModeProfile(deleteButton.dataset.modeDelete);
+ return;
+ }
+ const button = target?.closest("button[data-mode-command]");
+ if (button) applyModeCommand(
+ button.dataset.modeCommand,
+ button.dataset.modeProfileId || null,
+ );
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && !$("#modeGalleryBackdrop").hidden) {
+ event.preventDefault();
+ closeModeGallery();
+ }
+ });
+ window.addEventListener("resize", () => window.requestAnimationFrame(updateModeRailNavigation));
+ $("#utterance").addEventListener("input", () => {
+ if (!$("#saveModeComposer").hidden) updateModeDraftPreview();
});
$("#saveModeToggle").addEventListener("click", toggleSaveModeComposer);
+ $("#saveModeCancel").addEventListener("click", closeSaveModeComposer);
$("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile);
$("#saveModeName").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) {
@@ -5218,6 +5909,12 @@ document.addEventListener("DOMContentLoaded", async () => {
);
}
await synchronizePhysicalSeatState();
+ state.physicalStatePoller = window.setInterval(() => {
+ const activeModule = document.body.dataset.module;
+ if (document.visibilityState !== "visible") return;
+ if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
+ synchronizePhysicalSeatState();
+ }, 5000);
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
}
diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css
index d03993e..bbc9bcb 100644
--- a/apps/web-console/console-v2.css
+++ b/apps/web-console/console-v2.css
@@ -981,10 +981,11 @@
.console-v2 .v2-mode-rail {
min-width: 0;
+ position: relative;
display: grid;
- grid-template-columns: minmax(0, 1fr);
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
- gap: 0;
+ gap: 9px;
margin: 0;
padding: 0;
background: transparent;
@@ -992,22 +993,83 @@
}
.console-v2 .v2-mode-rail .mode-rail-heading {
- display: none;
+ display: contents;
}
-.console-v2 .v2-mode-rail .mode-rail-heading > span {
+.console-v2 .v2-mode-rail .mode-rail-heading > div {
+ display: block;
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.console-v2 .v2-mode-rail .mode-rail-heading > div > span {
color: #8a9797;
font-size: 9px;
font-weight: 800;
white-space: nowrap;
}
-.console-v2 .v2-mode-rail #saveModeToggle {
+.console-v2 .v2-mode-rail .mode-rail-heading small {
display: none;
}
+.console-v2 .v2-mode-rail #saveModeToggle {
+ grid-column: 4;
+ grid-row: 1;
+ min-height: 30px;
+ padding: 5px 9px;
+ color: var(--v2-accent-strong);
+ background: color-mix(in srgb, var(--v2-accent-soft) 70%, white);
+ border: 1px solid color-mix(in srgb, var(--v2-accent) 12%, white);
+ border-radius: 999px;
+ font-size: 9px;
+ font-weight: 850;
+ white-space: nowrap;
+}
+
+.console-v2 .v2-mode-rail .mode-rail-controls {
+ grid-column: 3;
+ grid-row: 1;
+ gap: 5px;
+ margin: 0;
+}
+
+.console-v2 .v2-mode-rail .mode-rail-pager {
+ grid-template-columns: repeat(2, 24px);
+ gap: 1px;
+ padding: 2px;
+ border-color: rgba(70, 104, 105, 0.12);
+ border-radius: 999px;
+}
+
+.console-v2 .v2-mode-rail .mode-rail-page {
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+}
+
+.console-v2 .v2-mode-rail .mode-gallery-toggle {
+ min-height: 30px;
+ padding: 4px 8px;
+ color: var(--v2-accent-strong);
+ background: color-mix(in srgb, var(--v2-accent-soft) 56%, white);
+ border-color: color-mix(in srgb, var(--v2-accent) 12%, white);
+ border-radius: 999px;
+ font-size: 9px;
+}
+
+.console-v2 .v2-mode-rail .mode-gallery-toggle strong {
+ min-width: 17px;
+ height: 17px;
+ padding: 0 4px;
+ background: var(--v2-accent);
+ font-size: 8px;
+}
+
.console-v2 .v2-mode-rail .mode-chips {
min-width: 0;
+ grid-column: 2;
+ grid-row: 1;
display: flex;
gap: 8px;
padding: 2px;
@@ -1079,15 +1141,52 @@
color: var(--v2-accent);
}
+.console-v2 .v2-mode-rail .mode-chips button.running {
+ color: #294e9d;
+ background: #e5ecff;
+ border-color: rgba(66, 104, 194, 0.38);
+ box-shadow: 0 0 0 3px rgba(66, 104, 194, 0.1);
+}
+
+.console-v2 .v2-mode-rail .mode-chips button.succeeded {
+ color: #0d755f;
+ background: #ddf7ef;
+ border-color: rgba(18, 148, 115, 0.36);
+ box-shadow: 0 0 0 3px rgba(18, 148, 115, 0.09);
+}
+
+.console-v2 .v2-mode-rail .mode-chips button.failed {
+ color: #a84039;
+ background: #fde9e7;
+ border-color: rgba(201, 72, 62, 0.36);
+}
+
+.console-v2 .v2-mode-rail .mode-chips button:disabled {
+ cursor: wait;
+}
+
.console-v2 .v2-mode-rail .mode-cycle-badge {
display: none;
}
.console-v2 .v2-mode-rail .save-mode-composer {
+ width: min(420px, 52vw);
position: absolute;
- right: 24px;
- bottom: 12px;
- z-index: 5;
+ right: 0;
+ bottom: 40px;
+ z-index: 8;
+ grid-template-columns: minmax(0, 1.15fr) minmax(150px, 0.85fr);
+ padding: 11px;
+ border-color: rgba(70, 104, 105, 0.14);
+ box-shadow: 0 18px 42px rgba(31, 67, 69, 0.18);
+}
+
+.console-v2 .v2-mode-rail .save-mode-source {
+ grid-column: 1 / -1;
+}
+
+.console-v2 .v2-mode-rail .save-mode-actions {
+ grid-template-columns: auto minmax(92px, 1fr);
}
.console-v2[data-control-mode="gesture"] .v2-natural-panel,
@@ -1804,7 +1903,7 @@
}
.console-v2 .joint-twin-shell > .cabin-band {
- grid-template-rows: 38px minmax(0, 1fr);
+ grid-template-rows: 66px minmax(0, 1fr);
padding: 0;
position: relative;
}
@@ -1873,20 +1972,75 @@
display: flex;
align-items: center;
margin: 0;
- padding: 4px 10px;
- background: rgba(255, 255, 255, 0.62);
+ padding: 8px 10px;
+ background:
+ linear-gradient(105deg, rgba(223, 245, 241, 0.82), rgba(255, 255, 255, 0.74) 58%),
+ rgba(255, 255, 255, 0.62);
border-bottom: 1px solid rgba(113, 141, 140, 0.12);
}
.console-v2 .joint-twin-shell .cabin-heading .section-kicker {
+ display: block;
+ color: var(--v2-accent-strong);
font-size: 6px;
+ letter-spacing: 0.12em;
}
.console-v2 .joint-twin-shell .cabin-heading h3 {
- margin: 0;
+ margin: 1px 0 0;
font-size: 13px;
}
+.console-v2 .joint-twin-shell .cabin-title-copy {
+ min-width: 0;
+}
+
+.console-v2 .joint-twin-shell .cabin-title-copy p {
+ max-width: 420px;
+ margin: 2px 0 0;
+ overflow: hidden;
+ color: #6b7e7e;
+ font-size: 7px;
+ line-height: 1.35;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .cabin-channel-legend {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ margin-left: auto;
+}
+
+.console-v2 .joint-twin-shell .cabin-channel-legend > span:not(.simulation-label) {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 6px;
+ color: #52696a;
+ background: rgba(255, 255, 255, 0.76);
+ border: 1px solid rgba(73, 122, 119, 0.13);
+ border-radius: 999px;
+ font-size: 7px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .cabin-channel-legend i {
+ color: #fff;
+ background: var(--v2-accent);
+ border-radius: 999px;
+ padding: 2px 4px;
+ font: 900 6px "SFMono-Regular", Consolas, monospace;
+ font-style: normal;
+}
+
+.console-v2 .joint-twin-shell .cabin-channel-legend [data-channel="ch0"] i {
+ background: #3677b5;
+}
+
.console-v2 .joint-twin-shell .cabin-heading .simulation-label {
display: none;
}
@@ -1898,19 +2052,44 @@
grid-template-rows: none;
grid-auto-rows: max-content;
align-content: start;
- gap: 4px;
- padding: 4px;
+ gap: 6px;
+ padding: 6px;
overflow-x: hidden;
- overflow-y: hidden;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(28, 111, 104, 0.28) transparent;
}
.console-v2 .joint-twin-shell .domain-panel {
min-width: 0;
min-height: 0;
+ position: relative;
padding: 6px;
overflow: hidden;
- border-radius: 8px;
- box-shadow: none;
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 249, 0.92));
+ border-color: rgba(78, 123, 120, 0.15);
+ border-radius: 10px;
+ box-shadow: 0 5px 16px rgba(32, 76, 73, 0.045);
+}
+
+.console-v2 .joint-twin-shell .domain-panel::before {
+ content: "";
+ width: 3px;
+ position: absolute;
+ inset: 8px auto 8px 0;
+ background: var(--v2-accent);
+ border-radius: 0 3px 3px 0;
+ opacity: 0.62;
+}
+
+.console-v2 .joint-twin-shell .domain-panel[data-device-channel="ch0"]::before {
+ background: #3677b5;
+}
+
+.console-v2 .joint-twin-shell .domain-panel.active {
+ border-color: color-mix(in srgb, var(--v2-accent) 32%, #dce7e4);
+ box-shadow: 0 7px 18px rgba(19, 112, 104, 0.08);
}
.console-v2 .joint-twin-shell .domain-panel header {
@@ -1946,6 +2125,27 @@
font-size: 9px;
}
+.console-v2 .joint-twin-shell .domain-capability-line {
+ margin: 5px 0 0;
+ overflow: hidden;
+ color: #718585;
+ font-size: 7px;
+ font-weight: 700;
+ line-height: 1.35;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .domain-capability-line span {
+ margin-right: 5px;
+ padding: 2px 4px;
+ color: var(--v2-accent-strong);
+ background: var(--v2-accent-soft);
+ border-radius: 4px;
+ font-size: 6px;
+ font-weight: 900;
+}
+
.console-v2 .joint-twin-shell .climate-display,
.console-v2 .joint-twin-shell .roof-visual,
.console-v2 .joint-twin-shell .ambient-visual {
@@ -2072,7 +2272,7 @@
.console-v2 .joint-twin-shell .window-panel,
.console-v2 .joint-twin-shell .lighting-panel {
- min-height: 108px;
+ min-height: 116px;
}
.console-v2 .joint-seat-panel > .simulation-label {
@@ -2529,3 +2729,557 @@
transition-duration: 0.01ms !important;
}
}
+
+/* Whole-vehicle state board: eight balanced cards in a complete 4 x 2 matrix. */
+.console-v2 .joint-twin-shell.v2-output-page {
+ grid-template-columns: minmax(360px, 0.72fr) minmax(0, 1.28fr);
+}
+
+.console-v2 .joint-twin-shell .twin-heading h3,
+.console-v2 .joint-twin-shell .twin-seat-button {
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell > .cabin-band {
+ grid-template-rows: 58px minmax(0, 1fr);
+}
+
+.console-v2 .joint-twin-shell .domain-grid {
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ grid-template-areas: none;
+ grid-auto-rows: minmax(0, 1fr);
+ align-content: stretch;
+ overflow: hidden;
+}
+
+.console-v2 .joint-twin-shell .domain-grid > .domain-panel {
+ grid-area: auto;
+ grid-column: span 2;
+ height: 100%;
+ box-sizing: border-box;
+}
+
+.console-v2 .joint-twin-shell .window-panel,
+.console-v2 .joint-twin-shell .lighting-panel {
+ min-height: 0;
+}
+
+.console-v2 .joint-seat-panel .thermal-states {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.console-v2 .joint-seat-panel .motion-state-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.console-v2 .joint-seat-panel .thermal-states > span,
+.console-v2 .joint-seat-panel .motion-axis {
+ min-width: 0;
+}
+
+.console-v2 .joint-seat-panel .motion-axis span {
+ line-height: 1.2;
+}
+
+.console-v2 .joint-seat-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto auto auto minmax(0, 1fr);
+ align-content: start;
+ position: relative;
+}
+
+.console-v2 .joint-seat-panel .seat-identity {
+ min-width: 0;
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.console-v2 .joint-seat-panel .seat-identity strong {
+ white-space: nowrap;
+}
+
+.console-v2 .joint-seat-panel .seat-channel-badge {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-seat-panel .dynamic-mode-state,
+.console-v2 .joint-seat-panel .thermal-states,
+.console-v2 .joint-seat-panel .motion-state-grid {
+ grid-column: 1 / -1;
+}
+
+.console-v2 .joint-seat-panel .seat-channel-badge {
+ width: fit-content;
+ max-width: 100%;
+ position: static;
+ margin: 0;
+ padding: 2px 5px;
+ overflow: hidden;
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-seat-panel .seat-identity {
+ max-width: 100%;
+}
+
+.console-v2 .joint-twin-shell .roof-panel .domain-readouts,
+.console-v2 .joint-twin-shell .ambient-panel .domain-readouts,
+.console-v2 .joint-twin-shell .window-panel .domain-readouts,
+.console-v2 .joint-twin-shell .lighting-panel .domain-readouts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+}
+
+.console-v2 .joint-twin-shell .roof-panel .domain-readouts > div,
+.console-v2 .joint-twin-shell .ambient-panel .domain-readouts > div,
+.console-v2 .joint-twin-shell .window-panel .domain-readouts > div,
+.console-v2 .joint-twin-shell .lighting-panel .domain-readouts > div {
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+}
+
+.console-v2 .joint-twin-shell .roof-panel .domain-readouts dd,
+.console-v2 .joint-twin-shell .ambient-panel .domain-readouts dd,
+.console-v2 .joint-twin-shell .window-panel .domain-readouts dd,
+.console-v2 .joint-twin-shell .lighting-panel .domain-readouts dd {
+ max-width: 100%;
+ margin-left: 0;
+ text-align: left;
+}
+
+.console-v2 .joint-twin-shell .domain-readouts dt,
+.console-v2 .joint-twin-shell .domain-readouts dd {
+ line-height: 1.2;
+}
+
+.console-v2 .joint-twin-shell .zone-temperature span,
+.console-v2 .joint-twin-shell .zone-temperature strong {
+ line-height: 1.2;
+}
+
+.console-v2 .joint-twin-shell .climate-panel .climate-display {
+ min-height: 50px;
+}
+
+.console-v2 .joint-twin-shell .climate-panel .zone-temperature {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-content: center;
+ gap: 2px 4px;
+}
+
+.console-v2 .joint-twin-shell .climate-panel .zone-temperature small {
+ min-width: 0;
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+ color: #718384;
+ font-size: 8px;
+ font-weight: 750;
+ line-height: 1;
+}
+
+.console-v2 .joint-twin-shell .climate-panel .zone-temperature small b {
+ overflow: hidden;
+ color: #354f51;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .domain-summary-visual {
+ min-width: 0;
+ height: 40px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: 5px;
+ padding: 4px 6px;
+ overflow: hidden;
+ color: #748485;
+ background: linear-gradient(145deg, #f6f9f8, #edf3f1);
+ border: 1px solid rgba(93, 132, 129, 0.16);
+ border-radius: 7px;
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.console-v2 .joint-twin-shell .shade-summary-visual > i {
+ min-width: 0;
+ height: 8px;
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ background: #dce7e4;
+ border-radius: 999px;
+}
+
+.console-v2 .joint-twin-shell .shade-summary-visual > i > b {
+ width: 0;
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: linear-gradient(90deg, #74b6ae, var(--v2-accent));
+ border-radius: inherit;
+ transition: width 220ms ease;
+}
+
+.console-v2 .joint-twin-shell .ambient-summary-visual {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.console-v2 .joint-twin-shell .ambient-summary-visual > span {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 13px minmax(0, 1fr);
+ align-items: center;
+ gap: 5px;
+}
+
+.console-v2 .joint-twin-shell .ambient-summary-visual i {
+ width: 13px;
+ height: 13px;
+ display: block;
+ background: #b9c5c3;
+ border-radius: 50%;
+ box-shadow: 0 0 0 3px rgba(116, 217, 255, 0.08);
+}
+
+.console-v2 .joint-twin-shell .ambient-summary-visual b,
+.console-v2 .joint-twin-shell .window-summary-visual b,
+.console-v2 .joint-twin-shell .lighting-summary-visual b {
+ overflow: hidden;
+ color: #52696a;
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .window-summary-visual,
+.console-v2 .joint-twin-shell .lighting-summary-visual {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 4px;
+ padding-inline: 5px;
+}
+
+.console-v2 .joint-twin-shell .window-summary-visual > span,
+.console-v2 .joint-twin-shell .lighting-summary-visual > span {
+ min-width: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+}
+
+.console-v2 .joint-twin-shell .window-summary-visual i {
+ width: 15px;
+ height: 10px;
+ position: relative;
+ overflow: hidden;
+ background: #dbe6e4;
+ border: 1px solid #9db2af;
+ border-radius: 2px 2px 4px 4px;
+}
+
+.console-v2 .joint-twin-shell .window-summary-visual i::after {
+ content: "";
+ height: var(--window-open, 0%);
+ position: absolute;
+ inset: auto 0 0;
+ background: linear-gradient(#8dcac4, var(--v2-accent));
+ transition: height 220ms ease;
+}
+
+.console-v2 .joint-twin-shell .lighting-summary-visual i {
+ width: 8px;
+ height: 8px;
+ background: #b9c5c3;
+ border-radius: 50%;
+ transition: 180ms ease;
+}
+
+.console-v2 .joint-twin-shell .lighting-summary-visual [data-state="on"] i {
+ background: #f0b84a;
+ box-shadow: 0 0 7px rgba(240, 184, 74, 0.8);
+}
+
+.console-v2 .joint-twin-shell .vehicle-status-panel {
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr);
+ align-content: start;
+}
+
+.console-v2 .joint-twin-shell .vehicle-status-panel .readonly-state,
+.console-v2 .joint-twin-shell .vehicle-status-panel.active .readonly-state {
+ color: #315f5b;
+ background: #e2efec;
+}
+
+.console-v2 .joint-twin-shell .readonly-capability span {
+ color: #526f71;
+ background: #edf2f1;
+}
+
+.console-v2 .joint-twin-shell .vehicle-primary-readout {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 0.72fr 1.28fr;
+ gap: 4px;
+ margin-top: 5px;
+}
+
+.console-v2 .joint-twin-shell .vehicle-primary-readout > div {
+ min-width: 0;
+ min-height: 46px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 4px 6px;
+ background: linear-gradient(145deg, #eef5f3, #f8faf9);
+ border: 1px solid rgba(74, 119, 116, 0.16);
+ border-radius: 7px;
+}
+
+.console-v2 .joint-twin-shell .vehicle-primary-readout span {
+ color: #708282;
+ font-size: 8px;
+ font-weight: 750;
+ line-height: 1;
+}
+
+.console-v2 .joint-twin-shell .vehicle-primary-readout strong {
+ margin-top: 3px;
+ overflow: hidden;
+ color: #193d3b;
+ font: 900 13px/1.15 "SFMono-Regular", Consolas, "Microsoft YaHei", monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .vehicle-primary-readout > div:first-child strong {
+ color: var(--v2-accent-strong);
+ font-size: 18px;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ gap: 3px;
+ margin-top: 4px;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid > span {
+ min-width: 0;
+ min-height: 29px;
+ display: grid;
+ grid-template-columns: 6px minmax(0, 1fr);
+ grid-template-rows: repeat(2, auto);
+ align-items: center;
+ gap: 4px;
+ padding: 2px 5px;
+ color: #52696a;
+ background: #f3f5f4;
+ border: 1px solid rgba(113, 141, 140, 0.16);
+ border-radius: 5px;
+ font-size: 9px;
+ font-weight: 750;
+ line-height: 1;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid i {
+ width: 6px;
+ height: 6px;
+ background: #aab6b5;
+ border-radius: 50%;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid b {
+ grid-column: 2;
+ color: #607474;
+ font-size: 8px;
+ white-space: nowrap;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid [data-state="lock"] i {
+ background: var(--v2-accent);
+}
+
+.console-v2 .joint-twin-shell .door-state-grid [data-state="lock"] b {
+ color: var(--v2-accent-strong);
+}
+
+.console-v2 .joint-twin-shell .door-state-grid [data-state="unlock"] i {
+ background: #d99a36;
+}
+
+.console-v2 .joint-twin-shell .door-state-grid [data-state="unlock"] b {
+ color: #9a6818;
+}
+
+@media (max-height: 820px) {
+ .console-v2 .joint-twin-shell > .cabin-band {
+ grid-template-rows: 50px minmax(0, 1fr);
+ }
+
+ .console-v2 .joint-twin-shell .cabin-heading {
+ padding: 5px 8px;
+ }
+
+ .console-v2 .joint-twin-shell .cabin-title-copy p {
+ display: none;
+ }
+
+ .console-v2 .joint-twin-shell .domain-grid {
+ gap: 6px;
+ padding: 6px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-panel {
+ padding: 6px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-panel header {
+ grid-template-columns: 24px minmax(0, 1fr) auto;
+ }
+
+ .console-v2 .joint-twin-shell .domain-icon,
+ .console-v2 .joint-seat-panel .seat-identity > svg {
+ width: 24px;
+ height: 24px;
+ padding: 5px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-panel header strong,
+ .console-v2 .joint-seat-panel .seat-identity strong {
+ font-size: 11px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-panel header small,
+ .console-v2 .joint-seat-panel .seat-identity span {
+ display: none;
+ }
+
+ .console-v2 .joint-twin-shell .domain-capability-line {
+ font-size: 8px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-capability-line {
+ margin-top: 5px;
+ }
+
+ .console-v2 .joint-twin-shell .climate-display,
+ .console-v2 .joint-twin-shell .roof-visual,
+ .console-v2 .joint-twin-shell .ambient-visual {
+ min-height: 22px;
+ margin-top: 3px;
+ }
+
+ .console-v2 .joint-twin-shell .climate-direction-display {
+ margin-top: 3px;
+ }
+
+ .console-v2 .joint-twin-shell .zone-temperature,
+ .console-v2 .joint-twin-shell .domain-readouts > div {
+ min-height: 22px;
+ padding: 3px 5px;
+ }
+
+ .console-v2 .joint-twin-shell .zone-temperature span,
+ .console-v2 .joint-twin-shell .zone-temperature strong,
+ .console-v2 .joint-twin-shell .domain-readouts > div,
+ .console-v2 .joint-twin-shell .domain-readouts dd {
+ font-size: 10px;
+ }
+
+ .console-v2 .joint-twin-shell .domain-readouts {
+ gap: 4px;
+ margin-top: 4px;
+ }
+
+ .console-v2 .joint-twin-shell .roof-panel .domain-readouts > div,
+ .console-v2 .joint-twin-shell .ambient-panel .domain-readouts > div,
+ .console-v2 .joint-twin-shell .window-panel .domain-readouts > div,
+ .console-v2 .joint-twin-shell .lighting-panel .domain-readouts > div {
+ min-height: 30px;
+ }
+
+ .console-v2 .joint-twin-shell .climate-panel .zone-temperature,
+ .console-v2 .joint-twin-shell .climate-panel .domain-readouts > div {
+ min-height: 32px;
+ }
+
+ .console-v2 .joint-twin-shell .climate-panel .zone-temperature {
+ padding: 4px 5px;
+ }
+
+ .console-v2 .joint-seat-panel .dynamic-mode-state {
+ margin: 4px 0;
+ padding: 3px 4px;
+ font-size: 9px;
+ }
+
+ .console-v2 .joint-seat-panel .dynamic-mode-state b,
+ .console-v2 .joint-seat-panel .thermal-states b,
+ .console-v2 .joint-seat-panel .motion-axis b {
+ font-size: 10px;
+ }
+
+ .console-v2 .joint-seat-panel .thermal-states,
+ .console-v2 .joint-seat-panel .motion-state-grid {
+ gap: 4px;
+ margin-top: 4px;
+ }
+
+ .console-v2 .joint-seat-panel .thermal-states > span,
+ .console-v2 .joint-seat-panel .motion-axis {
+ min-height: 24px;
+ padding: 3px 4px;
+ font-size: 10px;
+ }
+
+ .console-v2 .joint-seat-panel .motion-state-grid {
+ padding-top: 4px;
+ }
+
+ .console-v2 .joint-seat-panel .thermal-states > span {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: repeat(2, auto);
+ justify-items: center;
+ gap: 0;
+ text-align: center;
+ }
+
+ .console-v2 .joint-seat-panel .thermal-states svg {
+ display: none;
+ }
+
+ .console-v2 .joint-seat-panel .motion-axis {
+ flex-direction: column;
+ justify-content: center;
+ gap: 1px;
+ text-align: center;
+ }
+
+ .console-v2 .joint-seat-panel .thermal-states b,
+ .console-v2 .joint-seat-panel .motion-axis b {
+ font-size: 10px;
+ }
+}
diff --git a/apps/web-console/gesture-control.js b/apps/web-console/gesture-control.js
index aad981b..c092135 100755
--- a/apps/web-console/gesture-control.js
+++ b/apps/web-console/gesture-control.js
@@ -805,24 +805,6 @@ function closeDrawer() {
document.body.classList.remove("gesture-open");
}
-function initModeRail() {
- document.querySelectorAll("[data-mode-command]").forEach((button) => button.addEventListener("click", () => {
- window.CognitiveAgentConsole?.executeUtterance?.(button.dataset.modeCommand, { autoSubmit: true });
- }));
- $("#saveModeToggle")?.addEventListener("click", () => {
- const composer = $("#saveModeComposer");
- composer.hidden = !composer.hidden;
- if (!composer.hidden) $("#saveModeName").focus();
- });
- $("#saveModeConfirm")?.addEventListener("click", () => {
- const name = $("#saveModeName").value.trim();
- if (!name) return notify("请先输入模式名称");
- window.CognitiveAgentConsole?.executeUtterance?.(`将当前副驾驶座椅姿态保存为${name}模式`, { autoSubmit: true });
- $("#saveModeComposer").hidden = true;
- $("#saveModeName").value = "";
- });
-}
-
document.addEventListener("DOMContentLoaded", async () => {
$("#gestureStartButton").disabled = true;
$("#gestureToggleButton")?.addEventListener("click", openDrawer);
@@ -850,7 +832,6 @@ document.addEventListener("DOMContentLoaded", async () => {
window.addEventListener("beforeunload", stopCamera);
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
syncGestureDebugRole();
- initModeRail();
let runtimeError = null;
try {
await loadGestureRuntime();
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index 66a7f1f..6d007a7 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -9,22 +9,22 @@
智舱随行 · 整车智能控制台
-
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
@@ -61,15 +61,29 @@
-
快捷模式
-
-
-
-
+
+
功能模式点击模式即可执行,运行状态会实时反馈
+
+
+
+
+
+
+
+
+
+
-
-
+
将根据当前指令创建请先在指令框输入需要固化的功能组合
+
+
+
+
+
@@ -220,35 +234,36 @@
-
-
联合设备状态
+
+
LIVE VEHICLE EXECUTORS
+
整车设备状态
+
CH0 独立控制副驾四电机,CH1 映射主驾与车身执行器;所有状态按当前反馈源更新。
+
+
+ CH0副驾座椅
+ CH1主驾 / 车身
+ MATRIX-BACKED · SIMULATED FEEDBACK
-
MATRIX-BACKED · SIMULATED FEEDBACK
-
+
+ 可控参数电源 · 温度 · 风量/风向
-
-
- 主驾风向-
-
-
- 副驾风向-
+ 风向-
@@ -259,42 +274,53 @@
-
+
+
+ 只读信号档位 · 车速 · 四门锁状态
+
+
+ 左前门未知
+ 右前门未知
+ 左后门未知
+ 右后门未知
+
+
+
+
-
-
-
-
-
+
可控参数开关 / 停止 · 位置
+
+ 闭合打开
- 遮阳帘
- 0%
- - 调光
- 关闭
+ - 动作
- 停止
+ - 初始化
- 未知
+ - 热保护
- 正常
-
+
-
-
-
-
-
-
-
-
-
-
-
-
全车 · CH1 氛围灯
+
可控参数阅读灯 · 氛围亮度 · 色号
+
+ 前排
+ 后排
- 氛围灯
- 关闭
@@ -304,12 +330,19 @@
-
+
+ 可控参数四窗独立 · 开/关/停 · 位置
+
+ 左前
+ 右前
+ 左后
+ 右后
+
- 左前窗
- 0%
- 右前窗
- 0%
@@ -318,12 +351,19 @@
-
+
前大灯FRONT LIGHT · SWITCH
OFF
+ 可控参数近/远光 · 雾灯 · 转向灯
+
+ 近光
+ 远光
+ 雾灯
+ 转向
+
- 近光灯
- 关闭
- 远光灯
- 关闭
@@ -331,6 +371,7 @@
- 转向灯
- 关闭
+
@@ -343,17 +384,23 @@
-
-
副驾动态座椅ACTIVE · FRONT RIGHT
+
+
副驾座椅CH0 · 右前排
+
主驾座椅CH1 · 左前排
水平位置50%导轨
坐垫俯仰4.5°50%
+
座椅高度50%CH1 高度电机
靠背夹角13.6°20% · 相对归零位
腿托夹角0°0% · 相对垂直零位
腿托伸缩0%行程
@@ -363,12 +410,13 @@
车头方向
+
+