feat: 完善 N7 智能座舱交互与控制链路

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
+301 -40
View File
@@ -17,7 +17,7 @@
: "natural",
activeModule: "self-orchestration",
activePage: 0,
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
sidebarCollapsed: true,
};
let reasoningPickerControl = null;
@@ -580,13 +580,17 @@
function buildSidebar(commandPanel) {
commandPanel.classList.add("v2-sidebar");
commandPanel.id = "v2SidebarPanel";
commandPanel.innerHTML = "";
const sidebarToggle = make("button", "v2-sidebar-toggle", '<i data-lucide="panel-left-close"></i>');
const sidebarToggle = make("button", "v2-sidebar-toggle v2-panel-control-button", '<i data-lucide="panel-left"></i><span class="visually-hidden">左侧控制台</span>');
sidebarToggle.id = "v2SidebarToggle";
sidebarToggle.type = "button";
sidebarToggle.setAttribute("aria-label", "折叠左侧控制台");
sidebarToggle.setAttribute("aria-expanded", "true");
sidebarToggle.setAttribute("aria-controls", commandPanel.id);
sidebarToggle.setAttribute("aria-label", "展开左侧控制台");
sidebarToggle.setAttribute("aria-pressed", "false");
sidebarToggle.setAttribute("aria-expanded", "false");
sidebarToggle.title = "展开左侧控制台";
const footer = make("footer", "v2-sidebar-footer", `
<span class="v2-live-dot"></span>
@@ -617,7 +621,7 @@
});
}
function buildOperation(mainPanel, originalForm, panelHeading, modeRail, requestHeader, gestureDrawer) {
function buildOperation(mainPanel, originalForm, panelHeading, requestHeader, gestureDrawer) {
const operation = make("section", "v2-operation-shell", `
<div class="v2-operation-glow" aria-hidden="true"></div>
<div class="v2-natural-panel" id="v2NaturalPanel"></div>
@@ -628,7 +632,7 @@
if (panelHeading) {
panelHeading.classList.add("v2-operation-heading", "v2-module-heading");
const title = $("h2", panelHeading);
if (title) title.textContent = "一句话远程编排";
if (title) title.textContent = "AI助手";
naturalPanel.append(panelHeading);
}
@@ -641,11 +645,6 @@
naturalGrid.append(requestHeader);
}
naturalPanel.append(naturalGrid);
if (modeRail) {
modeRail.classList.add("v2-mode-rail");
naturalPanel.append(modeRail);
}
const gesturePanel = $("#v2GesturePanel");
const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `
<h2>抬抬手,控制整车</h2>`);
@@ -699,12 +698,16 @@
<div><h1>511工程-智能座舱</h1></div>
</div>
<div class="v2-remote-control-state"><i data-lucide="radio-tower"></i><span>网页控制链路</span><strong>已启用</strong></div>
<div class="v2-unified-live"><i></i><span>LIVE</span><b id="v2SyncClock">--:--:--</b></div>`;
<div class="v2-unified-live"><i></i><span>LIVE</span><b id="v2SyncClock">--:--:--</b></div>
<nav class="v2-panel-control-dock" id="v2PanelControlDock" aria-label="页面板块展开与收起"></nav>`;
mainPanel.prepend(switcher);
const panelDock = $("#v2PanelControlDock", switcher);
const sidebarToggle = $("#v2SidebarToggle");
if (panelDock && sidebarToggle) panelDock.append(sidebarToggle);
}
function buildOutput(mainPanel) {
function buildOutput(mainPanel, modeRail) {
const output = make("section", "v2-output-shell", `
<header class="v2-output-heading">
<div>
@@ -717,6 +720,11 @@
<nav class="v2-page-rail" id="v2PageRail" aria-label="信息分页" hidden></nav>`);
mainPanel.append(output);
if (modeRail) {
modeRail.classList.add("v2-mode-rail");
mainPanel.insertBefore(modeRail, output);
}
const content = $("#v2OutputContent");
const twin = $(".joint-twin-shell", mainPanel);
const resultWorkspace = $(".result-workspace", mainPanel);
@@ -1165,14 +1173,17 @@
function setSidePanelVisibility(key, visible) {
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
if (!panel) return;
panel.hidden = false;
const panelName = key === "devices" ? "车端设备状态" : "外设与扩展能力";
panel.hidden = !visible;
panel.classList.toggle("is-collapsed", !visible);
panel.dataset.collapsed = String(!visible);
sessionStorage.setItem(`cognitive-agent.sync-panel.${key}`, visible ? "visible" : "hidden");
$$(`[data-panel-toggle="${key}"], [data-panel-close="${key}"]`).forEach((button) => {
button.classList.toggle("active", visible);
button.setAttribute("aria-pressed", String(visible));
button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${key === "devices" ? "车端设备状态" : "外设与扩展能力"}`);
button.setAttribute("aria-expanded", String(visible));
button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${panelName}`);
button.title = `${visible ? "收起" : "展开"}${panelName}`;
const actionLabel = $("[data-panel-action-label]", button);
if (actionLabel) actionLabel.textContent = visible ? "收起" : "展开";
const stateLabel = $("[data-panel-state-label]", button);
@@ -1183,9 +1194,234 @@
return candidate && !candidate.classList.contains("is-collapsed");
});
$(".joint-twin-shell")?.setAttribute("data-open-panels", openPanels.join(" ") || "none");
const rightRail = $(".v2-unified-right-rail");
if (rightRail) {
const collapsed = openPanels.length === 0;
rightRail.hidden = collapsed;
rightRail.classList.toggle("is-collapsed", collapsed);
rightRail.toggleAttribute("inert", collapsed);
rightRail.dataset.openPanels = openPanels.join(" ") || "none";
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.dispatchEvent(new Event("resize"));
}
const twinPopoverClamp = (value, minimum, maximum) => (
maximum < minimum ? minimum : Math.max(minimum, Math.min(maximum, value))
);
function twinPopoverIntersectionArea(first, second) {
const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
return width * height;
}
function positionTwinDevicePopover(deviceId = displaySync.activeDevice) {
const stage = $("#seatTwin");
const popover = $("#v2TwinDevicePopover");
const hotspot = deviceId
? $(`[data-twin-device="${deviceId}"]`, stage)
: null;
const hotspotLabel = hotspot ? $(".v2-twin-hotspot-label", hotspot) : null;
if (!stage || !popover || popover.hidden || !hotspot || !hotspotLabel) return null;
if (hotspot.hidden) {
popover.dataset.anchorVisible = "false";
if (popover.dataset.pinned === "false") popover.hidden = true;
return null;
}
popover.dataset.anchorVisible = "true";
const stageRect = stage.getBoundingClientRect();
const labelClientRect = hotspotLabel.getBoundingClientRect();
const popoverWidth = popover.offsetWidth;
const popoverHeight = popover.offsetHeight;
if (!stageRect.width || !stageRect.height || !popoverWidth || !popoverHeight) return null;
const margin = stageRect.width < 520 ? 8 : 12;
const gap = stageRect.width < 520 ? 8 : 12;
const bounds = {
left: margin,
top: margin,
right: Math.max(margin, stageRect.width - margin),
bottom: Math.max(margin, stageRect.height - margin),
};
const labelRect = {
left: labelClientRect.left - stageRect.left,
top: labelClientRect.top - stageRect.top,
right: labelClientRect.right - stageRect.left,
bottom: labelClientRect.bottom - stageRect.top,
};
labelRect.width = labelRect.right - labelRect.left;
labelRect.height = labelRect.bottom - labelRect.top;
labelRect.centerX = (labelRect.left + labelRect.right) / 2;
labelRect.centerY = (labelRect.top + labelRect.bottom) / 2;
const toStageRect = (element, padding = 0) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left - stageRect.left - padding,
top: rect.top - stageRect.top - padding,
right: rect.right - stageRect.left + padding,
bottom: rect.bottom - stageRect.top + padding,
};
};
const visibleElement = (element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const labelObstacles = $$(".v2-twin-hotspot-label", stage)
.filter((element) => element !== hotspotLabel && visibleElement(element))
.map((element) => ({
deviceId: element.closest("[data-twin-device]")?.dataset.twinDevice || "",
rect: toStageRect(element, 3),
}));
const chromeObstacles = $$([".twin-seat-label", ".twin-orientation", ".twin-readout:not([hidden])"].join(","), stage)
.filter(visibleElement)
.map((element) => toStageRect(element, 5));
const activeLabelObstacle = {
left: labelRect.left - 10,
top: labelRect.top - 10,
right: labelRect.right + 10,
bottom: labelRect.bottom + 10,
};
const geometrySignature = [
deviceId,
popover.dataset.pinned,
Math.round(stageRect.width),
Math.round(stageRect.height),
Math.round(popoverWidth),
Math.round(popoverHeight),
Math.round(labelRect.left),
Math.round(labelRect.top),
...labelObstacles.flatMap(({ deviceId: obstacleDeviceId, rect }) => [
obstacleDeviceId,
Math.round(rect.left),
Math.round(rect.top),
]),
...chromeObstacles.flatMap((rect) => [Math.round(rect.left), Math.round(rect.top)]),
].join(":");
if (popover.dataset.layoutSignature === geometrySignature) return null;
popover.dataset.layoutSignature = geometrySignature;
const availableSpace = {
right: bounds.right - labelRect.right,
left: labelRect.left - bounds.left,
bottom: bounds.bottom - labelRect.bottom,
top: labelRect.top - bounds.top,
};
const sideOrder = Object.keys(availableSpace).sort((first, second) => (
availableSpace[second] - availableSpace[first]
));
const rawCandidates = [];
const pushSideCandidates = (side) => {
const horizontal = side === "left" || side === "right";
const alignments = horizontal
? [
[labelRect.centerY - popoverHeight / 2, "center"],
[labelRect.top, "start"],
[labelRect.bottom - popoverHeight, "end"],
]
: [
[labelRect.centerX - popoverWidth / 2, "center"],
[labelRect.left, "start"],
[labelRect.right - popoverWidth, "end"],
];
alignments.forEach(([crossAxis, alignment], alignmentIndex) => {
let left = crossAxis;
let top = crossAxis;
if (side === "right") {
left = labelRect.right + gap;
top = crossAxis;
} else if (side === "left") {
left = labelRect.left - gap - popoverWidth;
top = crossAxis;
} else if (side === "bottom") {
left = crossAxis;
top = labelRect.bottom + gap;
} else {
left = crossAxis;
top = labelRect.top - gap - popoverHeight;
}
rawCandidates.push({ left, top, side, alignment, alignmentIndex, anchored: true });
});
};
sideOrder.forEach(pushSideCandidates);
const maximumLeft = bounds.right - popoverWidth;
const maximumTop = bounds.bottom - popoverHeight;
[bounds.left, labelRect.left - gap - popoverWidth, labelRect.centerX - popoverWidth / 2,
labelRect.right + gap, maximumLeft].forEach((left) => {
[bounds.top, labelRect.top - gap - popoverHeight, labelRect.centerY - popoverHeight / 2,
labelRect.bottom + gap, maximumTop].forEach((top) => {
rawCandidates.push({ left, top, side: "adaptive", alignment: "free", alignmentIndex: 0, anchored: false });
});
});
const seen = new Set();
const previousPlacement = popover.dataset.anchorDevice === deviceId
? `${popover.dataset.placement || ""}:${popover.dataset.alignment || ""}`
: "";
const candidates = rawCandidates.map((candidate) => {
const left = twinPopoverClamp(candidate.left, bounds.left, maximumLeft);
const top = twinPopoverClamp(candidate.top, bounds.top, maximumTop);
const key = `${Math.round(left)}:${Math.round(top)}`;
if (seen.has(key)) return null;
seen.add(key);
const rect = {
left,
top,
right: left + popoverWidth,
bottom: top + popoverHeight,
};
const clampDistance = Math.abs(left - candidate.left) + Math.abs(top - candidate.top);
const activeOverlap = twinPopoverIntersectionArea(rect, activeLabelObstacle);
const shadeOverlap = labelObstacles.reduce((total, obstacle) => (
total + (obstacle.deviceId === "shade" ? twinPopoverIntersectionArea(rect, obstacle.rect) : 0)
), 0);
const labelOverlap = labelObstacles.reduce((total, obstacle) => (
total + (obstacle.deviceId === "shade" ? 0 : twinPopoverIntersectionArea(rect, obstacle.rect))
), 0);
const chromeOverlap = chromeObstacles.reduce((total, obstacle) => (
total + twinPopoverIntersectionArea(rect, obstacle)
), 0);
const distance = Math.hypot(
left + popoverWidth / 2 - labelRect.centerX,
top + popoverHeight / 2 - labelRect.centerY,
);
const placement = candidate.side === "adaptive"
? (Math.abs(left + popoverWidth / 2 - labelRect.centerX) > Math.abs(top + popoverHeight / 2 - labelRect.centerY)
? (left + popoverWidth / 2 >= labelRect.centerX ? "right" : "left")
: (top + popoverHeight / 2 >= labelRect.centerY ? "bottom" : "top"))
: candidate.side;
const placementKey = `${placement}:${candidate.alignment}`;
const sideRank = sideOrder.indexOf(placement);
const score = activeOverlap * 1000
+ shadeOverlap * 640
+ labelOverlap * 36
+ chromeOverlap * 12
+ clampDistance * 90
+ distance * 0.35
+ Math.max(0, sideRank) * 70
+ candidate.alignmentIndex * 8
+ (candidate.anchored ? 0 : 90)
- (placementKey === previousPlacement ? 28 : 0);
return { ...candidate, left, top, placement, score, activeOverlap, shadeOverlap, labelOverlap };
}).filter(Boolean);
const best = candidates.sort((first, second) => first.score - second.score)[0];
if (!best) return null;
popover.style.left = `${Math.round(best.left)}px`;
popover.style.top = `${Math.round(best.top)}px`;
popover.style.removeProperty("right");
popover.style.removeProperty("bottom");
popover.dataset.anchorDevice = deviceId;
popover.dataset.placement = best.placement;
popover.dataset.alignment = best.alignment;
popover.dataset.shadeOverlap = String(Math.round(best.shadeOverlap));
popover.dataset.labelOverlap = String(Math.round(best.labelOverlap));
return best;
}
function presentTwinDevice(deviceId, { pinned = false, selectSeat = pinned } = {}) {
const device = syncedDevices.find((item) => item.id === deviceId);
const popover = $("#v2TwinDevicePopover");
@@ -1209,6 +1445,7 @@
$(`[data-twin-seat="${deviceId}"]`)?.click();
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
positionTwinDevicePopover(deviceId);
}
function clearTwinDevicePreview({ force = false } = {}) {
@@ -1323,7 +1560,8 @@
const utterance = String(task.utterance || "");
const result = task.result && typeof task.result === "object" ? task.result : null;
const input = $("#utterance");
if (input && utterance && document.activeElement !== input) input.value = utterance;
if (input && utterance && document.activeElement !== input
&& !document.body.classList.contains("assistant-chat-enabled")) input.value = utterance;
const kind = taskKind(utterance, result, String(task.source || ""));
if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind;
if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令";
@@ -1447,14 +1685,14 @@
if (input) {
input.readOnly = false;
input.removeAttribute("aria-readonly");
input.setAttribute("aria-label", "输入一句话远程编排车端功能");
input.placeholder = "例如:副驾调到舒适姿态,空调 24℃,氛围灯改成暖橙色";
input.setAttribute("aria-label", "输入问题或座舱控制需求");
input.placeholder = "输入问题或座舱控制需求";
}
form?.addEventListener("submit", () => {
const utterance = input?.value.trim();
if (utterance) appendSyncLog({
category: "task",
title: "网页端发送编排指令",
title: "网页端发送助手请求",
detail: utterance,
status: "info",
});
@@ -1468,7 +1706,7 @@
function syncSidebarLogBaseline() {
const sidebar = $(".v2-sidebar");
const lowerPanel = $("#v2DevicePanel") || $(".joint-twin-shell > .twin-band");
if (!sidebar || !lowerPanel) return;
if (!sidebar || !lowerPanel || ui.sidebarCollapsed) return;
const sidebarRect = sidebar.getBoundingClientRect();
const overview = $(".v2-sync-sidebar-overview", sidebar);
const overviewRect = overview?.getBoundingClientRect();
@@ -1539,8 +1777,6 @@
});
}
syncOverview?.append(sidebarFooter);
const sidebarToggle = $("#v2SidebarToggle", sidebar);
if (sidebarToggle) $(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);
}
const naturalPanel = $("#v2NaturalPanel");
@@ -1554,16 +1790,34 @@
const cabinBand = $(".cabin-band", jointShell);
if (jointShell && cabinBand) {
const rightRail = make("aside", "v2-unified-right-rail", `
<section class="v2-device-sync-board v2-side-status-panel" id="v2DevicePanel">
<header><div><h3>车端设备状态</h3></div><span><i></i>实时反馈</span><button type="button" data-panel-toggle="devices" aria-label="收起车端设备状态"><i data-lucide="panel-left-close"></i><span data-panel-action-label>收起</span></button></header>
<div class="v2-device-sync-grid" id="v2DeviceSyncGrid"></div>
</section>
<section class="v2-extension-panel v2-side-status-panel" id="v2ExtensionPanel"><div class="v2-extension-sync-mount" id="vehicleExtensionSync" aria-live="polite"></div></section>`);
<div class="v2-side-status-stack">
<section class="v2-device-sync-board v2-side-status-panel" id="v2DevicePanel">
<header><div><h3>车端设备状态</h3></div><span><i></i>实时反馈</span></header>
<div class="v2-device-sync-grid" id="v2DeviceSyncGrid"></div>
</section>
<section class="v2-extension-panel v2-side-status-panel" id="v2ExtensionPanel"><div class="v2-extension-sync-mount" id="vehicleExtensionSync" aria-live="polite"></div></section>
</div>`);
jointShell.insertBefore(rightRail, cabinBand);
const panelControls = make("div", "v2-panel-visibility-controls", `
<button type="button" data-panel-toggle="devices" aria-pressed="true"><i data-lucide="sliders-horizontal"></i><span>设备状态</span><b data-panel-state-label>已展开</b></button>
<button type="button" data-panel-toggle="extensions" aria-pressed="true"><i data-lucide="usb"></i><span>外设能力</span><b data-panel-state-label>已展开</b></button>`);
$(".twin-toolbar", jointShell)?.prepend(panelControls);
const panelDock = $("#v2PanelControlDock");
if (panelDock) {
const deviceToggle = make("button", "v2-panel-control-button", '<i data-lucide="car-front"></i><span class="visually-hidden">车端设备状态</span>');
deviceToggle.type = "button";
deviceToggle.dataset.panelToggle = "devices";
deviceToggle.setAttribute("aria-controls", "v2DevicePanel");
deviceToggle.setAttribute("aria-pressed", "false");
deviceToggle.setAttribute("aria-expanded", "false");
deviceToggle.setAttribute("aria-label", "展开车端设备状态");
deviceToggle.title = "展开车端设备状态";
const extensionToggle = make("button", "v2-panel-control-button", '<i data-lucide="usb"></i><span class="visually-hidden">外设与扩展能力</span>');
extensionToggle.type = "button";
extensionToggle.dataset.panelToggle = "extensions";
extensionToggle.setAttribute("aria-controls", "v2ExtensionPanel");
extensionToggle.setAttribute("aria-pressed", "false");
extensionToggle.setAttribute("aria-expanded", "false");
extensionToggle.setAttribute("aria-label", "展开外设与扩展能力");
extensionToggle.title = "展开外设与扩展能力";
panelDock.append(deviceToggle, extensionToggle);
}
buildTwinDeviceInteractions();
cabinBand.classList.add("v2-source-state-only");
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
@@ -1580,8 +1834,8 @@
subtree: true,
});
}
setSidePanelVisibility("devices", panelVisible("devices"));
setSidePanelVisibility("extensions", panelVisible("extensions"));
setSidePanelVisibility("devices", false);
setSidePanelVisibility("extensions", false);
window.addEventListener("resize", syncSidebarLogBaseline);
requestAnimationFrame(syncSidebarLogBaseline);
document.addEventListener("click", (event) => {
@@ -1842,14 +2096,21 @@
String(ui.sidebarCollapsed),
);
document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed);
const sidebar = $("#v2SidebarPanel");
if (sidebar) {
sidebar.setAttribute("aria-hidden", String(ui.sidebarCollapsed));
sidebar.toggleAttribute("inert", ui.sidebarCollapsed);
}
const button = $("#v2SidebarToggle");
if (button) {
const expanded = !ui.sidebarCollapsed;
button.classList.toggle("active", expanded);
button.setAttribute("aria-pressed", String(expanded));
button.setAttribute("aria-expanded", String(!ui.sidebarCollapsed));
button.setAttribute(
"aria-label",
ui.sidebarCollapsed ? "展开左侧控制台" : "折叠左侧控制台",
);
button.innerHTML = `<i data-lucide="${ui.sidebarCollapsed ? "panel-left-open" : "panel-left-close"}"></i><span>${ui.sidebarCollapsed ? "展开" : "收起"}</span>`;
const label = ui.sidebarCollapsed ? "展开左侧控制台" : "收起左侧控制台";
button.setAttribute("aria-label", label);
button.title = label;
button.innerHTML = '<i data-lucide="panel-left"></i><span class="visually-hidden">左侧控制台</span>';
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180);
@@ -1959,12 +2220,11 @@
mainPanel,
originalForm,
panelHeading,
modeRail,
requestHeader,
gestureDrawer,
);
buildModuleSwitcher(mainPanel);
buildOutput(mainPanel);
buildOutput(mainPanel, modeRail);
buildSyncControlLayer();
bindEvents();
setControlMode(ui.controlMode, { triggerGesture: false });
@@ -1983,6 +2243,7 @@
getModule: () => ui.activeModule,
getRole: () => "user",
getParsedCanCommands: (result) => parsedCanCommands(result).map((item) => ({ ...item })),
positionTwinDevicePopover,
setModule,
};