1667 lines
78 KiB
JavaScript
1667 lines
78 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const pageDefinitions = [
|
|
{ index: 0, label: "整车联合数字孪生", shortLabel: "数字孪生", target: ".joint-twin-shell" },
|
|
{ index: 1, label: "决策轨迹与校验", shortLabel: "决策校验", target: "#panel-trace" },
|
|
{ index: 2, label: "指令配置与硬件映射", shortLabel: "配置映射", target: "#panel-config" },
|
|
{ index: 3, label: "CAN 编译计划", shortLabel: "CAN 计划", target: "#panel-plan" },
|
|
{ index: 4, label: "执行结果与硬件回读", shortLabel: "执行回读", target: "#panel-result" },
|
|
];
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
const ui = {
|
|
controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture"
|
|
? "gesture"
|
|
: "natural",
|
|
activeModule: "self-orchestration",
|
|
activePage: 0,
|
|
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
|
|
};
|
|
|
|
const dropdownControls = new Map();
|
|
let reasoningPickerControl = null;
|
|
|
|
function make(tag, className, html) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (html != null) node.innerHTML = html;
|
|
return node;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function closeCustomDropdowns(except = null) {
|
|
dropdownControls.forEach((control) => {
|
|
if (control !== except) control.close();
|
|
});
|
|
}
|
|
|
|
function closeReasoningPicker() {
|
|
reasoningPickerControl?.close();
|
|
}
|
|
|
|
function installReasoningPicker(reasoningField, originalForm) {
|
|
const composer = $(".command-composer", originalForm);
|
|
if (!composer || !reasoningField) return;
|
|
|
|
reasoningField.classList.add("v2-reasoning-field");
|
|
reasoningField.hidden = true;
|
|
const trigger = make("button", "v2-reasoning-trigger", `
|
|
<i data-lucide="sparkles"></i>
|
|
<span>模型智能</span>
|
|
<strong id="v2ReasoningValue">均衡</strong>
|
|
<i class="v2-reasoning-caret" data-lucide="chevron-up"></i>`);
|
|
trigger.id = "v2ReasoningTrigger";
|
|
trigger.type = "button";
|
|
trigger.setAttribute("aria-haspopup", "listbox");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
trigger.setAttribute("aria-controls", "reasoningField");
|
|
trigger.setAttribute("aria-label", "选择模型智能程度,当前均衡");
|
|
$(".reasoning-segmented", reasoningField)?.setAttribute("role", "listbox");
|
|
|
|
const options = () => $$('[data-reasoning-effort]', reasoningField);
|
|
const place = () => {
|
|
if (reasoningField.hidden) return;
|
|
const triggerBox = trigger.getBoundingClientRect();
|
|
const width = Math.min(320, Math.max(250, window.innerWidth - 24));
|
|
const left = Math.min(
|
|
Math.max(12, triggerBox.left),
|
|
Math.max(12, window.innerWidth - width - 12),
|
|
);
|
|
reasoningField.style.width = `${width}px`;
|
|
reasoningField.style.left = `${left}px`;
|
|
const height = reasoningField.offsetHeight;
|
|
const above = triggerBox.top - height - 8;
|
|
const top = above >= 12
|
|
? above
|
|
: Math.min(triggerBox.bottom + 8, window.innerHeight - height - 12);
|
|
reasoningField.style.top = `${Math.max(12, top)}px`;
|
|
};
|
|
const sync = () => {
|
|
const selected = options().find((button) => button.classList.contains("active"));
|
|
const label = selected?.querySelector("strong")?.textContent?.trim() || "均衡";
|
|
const unavailable = options().length > 0 && options().every((button) => button.disabled);
|
|
$("#v2ReasoningValue", trigger).textContent = label;
|
|
trigger.classList.toggle("unavailable", unavailable);
|
|
trigger.setAttribute("aria-disabled", String(unavailable));
|
|
trigger.setAttribute("aria-label", `选择模型智能程度,当前${label}`);
|
|
options().forEach((button) => {
|
|
button.setAttribute("role", "option");
|
|
button.setAttribute("aria-selected", String(button === selected));
|
|
});
|
|
};
|
|
const close = () => {
|
|
reasoningField.hidden = true;
|
|
trigger.classList.remove("open");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
};
|
|
const open = () => {
|
|
closeCustomDropdowns();
|
|
reasoningField.hidden = false;
|
|
trigger.classList.add("open");
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
place();
|
|
};
|
|
reasoningPickerControl = { close, open, place, sync, trigger };
|
|
const resetButton = $("#newConversation", originalForm);
|
|
const runButton = $("#runButton", originalForm);
|
|
if (resetButton) composer.append(resetButton);
|
|
composer.append(trigger);
|
|
if (runButton) composer.append(runButton);
|
|
document.body.append(reasoningField);
|
|
|
|
trigger.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (reasoningField.hidden) open();
|
|
else close();
|
|
});
|
|
reasoningField.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (!event.target.closest("[data-reasoning-effort]")) return;
|
|
requestAnimationFrame(() => {
|
|
sync();
|
|
close();
|
|
trigger.focus({ preventScroll: true });
|
|
});
|
|
});
|
|
new MutationObserver(sync).observe(reasoningField, {
|
|
attributes: true,
|
|
attributeFilter: ["class", "disabled"],
|
|
subtree: true,
|
|
});
|
|
window.addEventListener("resize", place);
|
|
sync();
|
|
}
|
|
|
|
function enhanceSelect(select) {
|
|
if (!select || dropdownControls.has(select)) return;
|
|
const shell = select.parentElement;
|
|
if (!shell) return;
|
|
|
|
shell.classList.add("v2-custom-select");
|
|
select.classList.add("v2-native-select");
|
|
|
|
const trigger = make("button", "v2-dropdown-trigger", `
|
|
<span class="v2-dropdown-value"></span>
|
|
<span class="v2-dropdown-caret" aria-hidden="true"></span>`);
|
|
trigger.type = "button";
|
|
trigger.setAttribute("aria-haspopup", "listbox");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
trigger.setAttribute("aria-label", select.getAttribute("aria-label") || "打开下拉菜单");
|
|
|
|
const menu = make("div", "v2-dropdown-menu", "");
|
|
menu.id = `${select.id}Menu`;
|
|
menu.hidden = true;
|
|
menu.setAttribute("role", "listbox");
|
|
menu.setAttribute("aria-label", select.getAttribute("aria-label") || "选项");
|
|
trigger.setAttribute("aria-controls", menu.id);
|
|
shell.append(trigger, menu);
|
|
|
|
const valueNode = $(".v2-dropdown-value", trigger);
|
|
const render = () => {
|
|
const selected = select.selectedOptions[0] || select.options[0];
|
|
valueNode.textContent = selected?.textContent?.trim() || "请选择";
|
|
trigger.disabled = select.disabled;
|
|
menu.innerHTML = "";
|
|
[...select.options].forEach((option) => {
|
|
const item = make("button", "v2-dropdown-option", "");
|
|
item.type = "button";
|
|
item.dataset.value = option.value;
|
|
item.textContent = option.textContent.trim();
|
|
item.disabled = option.disabled;
|
|
item.setAttribute("role", "option");
|
|
item.setAttribute("aria-selected", String(option.value === select.value));
|
|
menu.append(item);
|
|
});
|
|
};
|
|
|
|
const close = () => {
|
|
menu.hidden = true;
|
|
shell.classList.remove("v2-dropdown-open");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
};
|
|
|
|
const open = () => {
|
|
if (trigger.disabled) return;
|
|
closeCustomDropdowns();
|
|
render();
|
|
menu.hidden = false;
|
|
shell.classList.add("v2-dropdown-open");
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
};
|
|
|
|
const control = { close, menu, open, select, shell, sync: render, trigger };
|
|
dropdownControls.set(select, control);
|
|
|
|
trigger.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (menu.hidden) open();
|
|
else close();
|
|
});
|
|
trigger.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
open();
|
|
requestAnimationFrame(() => {
|
|
$(".v2-dropdown-option[aria-selected=\"true\"]:not(:disabled)", menu)?.focus();
|
|
});
|
|
} else if (event.key === "Escape") {
|
|
close();
|
|
}
|
|
});
|
|
menu.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
const item = event.target.closest(".v2-dropdown-option");
|
|
if (!item || item.disabled) return;
|
|
select.value = item.dataset.value;
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
render();
|
|
close();
|
|
trigger.focus();
|
|
});
|
|
menu.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Escape") return;
|
|
event.preventDefault();
|
|
close();
|
|
trigger.focus();
|
|
});
|
|
select.addEventListener("change", render);
|
|
new MutationObserver(render).observe(select, {
|
|
attributes: true,
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
render();
|
|
}
|
|
|
|
function enhanceConfiguredDropdowns() {
|
|
[$("#vehicleSelect")]
|
|
.filter(Boolean)
|
|
.forEach(enhanceSelect);
|
|
}
|
|
|
|
function buildSidebar(commandPanel) {
|
|
commandPanel.classList.add("v2-sidebar");
|
|
commandPanel.innerHTML = "";
|
|
|
|
const profile = make("section", "v2-profile", `
|
|
<div class="v2-avatar" aria-hidden="true"><span></span></div>
|
|
<div class="v2-profile-copy">
|
|
<small>WELCOME TO THE CABIN</small>
|
|
<div class="v2-role-select">
|
|
<span>Hi,</span><strong>user</strong>
|
|
</div>
|
|
</div>
|
|
<button class="v2-sidebar-toggle" id="v2SidebarToggle" type="button" aria-label="折叠左侧控制台" aria-expanded="true">
|
|
<i data-lucide="panel-left-close"></i>
|
|
</button>`);
|
|
|
|
const control = make("section", "v2-sidebar-section v2-control-section", `
|
|
<div class="v2-section-heading">
|
|
<span>CONTROL DESK</span>
|
|
<h2>控制台</h2>
|
|
</div>
|
|
<span class="v2-field-caption">控制方式</span>
|
|
<div class="v2-control-switch" role="group" aria-label="控制方式">
|
|
<button type="button" data-v2-control="natural"><i data-lucide="message-square-text"></i><span>自然语言控制</span></button>
|
|
<button type="button" data-v2-control="gesture"><i data-lucide="scan-face"></i><span>手势控制</span></button>
|
|
</div>`);
|
|
|
|
const config = make("section", "v2-sidebar-section v2-config-section", `
|
|
<div class="v2-divider"></div>
|
|
<span class="v2-field-caption">配置信息</span>
|
|
<div class="v2-config-stack" id="v2ConfigStack"></div>`);
|
|
|
|
const footer = make("footer", "v2-sidebar-footer", `
|
|
<span class="v2-live-dot"></span>
|
|
<div><strong>智舱控制链路</strong><small id="v2ConnectionText">正在连接服务</small></div>
|
|
<button type="button" id="v2LogoutButton" title="退出登录" aria-label="退出登录"><i data-lucide="log-out"></i></button>`);
|
|
|
|
commandPanel.append(profile, control, config, footer);
|
|
return $("#v2ConfigStack");
|
|
}
|
|
|
|
function buildConfigFields(configStack, originalForm) {
|
|
const vehicleField = $(".vehicle-field", originalForm);
|
|
const transportField = $("#vehicleTransportField", originalForm);
|
|
const providerField = $(".field-group:has(.segmented [data-provider])", originalForm);
|
|
const reasoningField = $("#reasoningField", originalForm);
|
|
const executionField = $(".field-group:has(.execution-mode-options)", originalForm);
|
|
const simulationFaultField = $("#simulationFaultField", originalForm);
|
|
|
|
if (vehicleField) {
|
|
vehicleField.classList.add("v2-config-field", "v2-vehicle-field");
|
|
const vehicleControl = $(".vehicle-select-control", vehicleField);
|
|
const vehicleSelect = $("#vehicleSelect", vehicleField);
|
|
const vehicleIcon = $("svg, [data-lucide]", vehicleControl);
|
|
if (vehicleControl && vehicleSelect && vehicleIcon) {
|
|
const copy = make("div", "v2-config-card-copy", "<small>车型配置</small>");
|
|
const selectSlot = make("div", "v2-config-card-select", "");
|
|
const state = make("span", "v2-field-state", "CHECK");
|
|
state.id = "v2VehicleState";
|
|
selectSlot.append(vehicleSelect);
|
|
copy.append(selectSlot);
|
|
vehicleControl.classList.add("v2-config-card", "v2-config-select-card");
|
|
vehicleControl.id = "v2VehicleCard";
|
|
vehicleControl.dataset.state = "checking";
|
|
vehicleControl.replaceChildren(vehicleIcon, copy, state);
|
|
$(".field-label", vehicleField)?.remove();
|
|
}
|
|
configStack.append(vehicleField);
|
|
}
|
|
|
|
if (transportField) {
|
|
transportField.classList.add("v2-config-field", "v2-transport-field");
|
|
configStack.append(transportField);
|
|
}
|
|
|
|
const modelField = make("div", "v2-config-field v2-config-card v2-model-field", `
|
|
<i data-lucide="sparkles"></i>
|
|
<div><small>推理模型</small><strong id="v2ModelValue">模型检测中</strong></div>
|
|
<span class="v2-field-state" id="v2ModelState">AUTO</span>
|
|
<p class="provider-note" id="v2ModelNote">模型由服务端安全配置</p>`);
|
|
modelField.id = "v2ModelField";
|
|
configStack.append(modelField);
|
|
installReasoningPicker(reasoningField, originalForm);
|
|
|
|
const gestureModelField = make("div", "v2-config-field v2-config-card v2-gesture-model-field", `
|
|
<i data-lucide="scan-line"></i>
|
|
<div><small>手势模型</small><strong id="v2GestureModelValue">MediaPipe GR</strong></div>
|
|
<span class="v2-field-state">VISION</span>
|
|
<small class="v2-gesture-model-note" id="v2GestureModelNote">Tasks Vision · 0.10.14</small>`);
|
|
gestureModelField.id = "v2GestureModelField";
|
|
configStack.append(gestureModelField);
|
|
|
|
const userHardwareState = make("div", "v2-user-target-status", `
|
|
<i></i><span id="v2UserTargetText">正在准备虚拟 CAN</span>`);
|
|
configStack.append(userHardwareState);
|
|
|
|
if (providerField) {
|
|
providerField.classList.add("v2-provider-field");
|
|
providerField.hidden = true;
|
|
configStack.append(providerField);
|
|
}
|
|
|
|
if (executionField) {
|
|
executionField.classList.add("v2-native-execution-field");
|
|
executionField.hidden = true;
|
|
configStack.append(executionField);
|
|
}
|
|
|
|
const advanced = make("div", "v2-advanced-stack", "");
|
|
[simulationFaultField].forEach((field) => {
|
|
if (!field) return;
|
|
field.classList.add("v2-config-field", "v2-advanced-field");
|
|
advanced.append(field);
|
|
});
|
|
configStack.append(advanced);
|
|
}
|
|
|
|
function buildOperation(mainPanel, originalForm, panelHeading, modeRail, 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>
|
|
<div class="v2-gesture-panel" id="v2GesturePanel"></div>`);
|
|
mainPanel.prepend(operation);
|
|
|
|
const naturalPanel = $("#v2NaturalPanel");
|
|
if (panelHeading) {
|
|
panelHeading.classList.add("v2-operation-heading", "v2-module-heading");
|
|
const kicker = $(".section-kicker", panelHeading);
|
|
const title = $("h2", panelHeading);
|
|
const intro = $(".panel-intro", panelHeading);
|
|
if (kicker) kicker.textContent = "REMOTE ORCHESTRATION · LIVE SYNC";
|
|
if (title) title.textContent = "一句话远程编排";
|
|
if (intro) intro.textContent = "网页指令将通过同步链路发送至车机;车机任务与执行反馈在右侧实时更新";
|
|
naturalPanel.append(panelHeading);
|
|
}
|
|
|
|
const naturalGrid = make("div", "v2-natural-grid", "");
|
|
naturalGrid.id = "v2NaturalGrid";
|
|
originalForm.classList.add("v2-request-form");
|
|
naturalGrid.append(originalForm);
|
|
if (requestHeader) {
|
|
requestHeader.classList.add("v2-request-status");
|
|
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", `
|
|
<span class="section-kicker">SELF-ORCHESTRATION</span>
|
|
<h2>抬抬手,控制整车</h2>
|
|
<p class="panel-intro">通过摄像头识别手势,直观控制车舱功能</p>`);
|
|
gesturePanel.append(gestureHeading);
|
|
if (gestureDrawer) {
|
|
gestureDrawer.classList.add("v2-inline-gesture");
|
|
const cameraStage = $(".gesture-camera-stage", gestureDrawer);
|
|
const cameraToggle = $("#gestureStartButton", gestureDrawer);
|
|
const actionRail = $(".gesture-actions", gestureDrawer);
|
|
const statusStrip = $(".gesture-status-strip", gestureDrawer);
|
|
const gestureNow = $(".gesture-now", gestureDrawer);
|
|
const guideCard = $(".gesture-guide-card", gestureDrawer);
|
|
if (cameraToggle) {
|
|
cameraToggle.classList.add("v2-camera-toggle");
|
|
cameraToggle.setAttribute("aria-label", "开启摄像头");
|
|
cameraToggle.setAttribute("title", "开启摄像头");
|
|
}
|
|
if (actionRail && cameraToggle) {
|
|
actionRail.classList.add("v2-gesture-action-rail");
|
|
actionRail.append(cameraToggle);
|
|
}
|
|
|
|
const resultStack = make("div", "v2-gesture-result-stack", "");
|
|
resultStack.id = "v2GestureResultStack";
|
|
const recognitionCard = make("section", "v2-gesture-recognition-card", `
|
|
<span class="v2-gesture-result-label">手势识别结果</span>`);
|
|
const executionSlot = make("section", "v2-gesture-execution-slot", `
|
|
<span class="v2-gesture-result-label">执行结果</span>`);
|
|
executionSlot.id = "v2GestureExecutionSlot";
|
|
if (statusStrip) recognitionCard.append(statusStrip);
|
|
if (gestureNow) recognitionCard.append(gestureNow);
|
|
resultStack.append(recognitionCard, executionSlot);
|
|
|
|
if (cameraStage) gestureDrawer.append(cameraStage);
|
|
if (actionRail) gestureDrawer.append(actionRail);
|
|
gestureDrawer.append(resultStack);
|
|
if (guideCard) gestureDrawer.append(guideCard);
|
|
gesturePanel.append(gestureDrawer);
|
|
}
|
|
return operation;
|
|
}
|
|
|
|
function buildModuleSwitcher(mainPanel) {
|
|
const switcher = make("nav", "v2-module-switcher", "");
|
|
switcher.id = "v2ModuleSwitcher";
|
|
switcher.dataset.unifiedSync = "true";
|
|
switcher.setAttribute("aria-label", "智能座舱远程控制与同步状态");
|
|
switcher.innerHTML = `
|
|
<div class="v2-unified-brand">
|
|
<span class="v2-unified-brand-mark"><i data-lucide="waypoints"></i></span>
|
|
<div><small>COCKPIT REMOTE · LIVE MIRROR</small><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>`;
|
|
mainPanel.prepend(switcher);
|
|
}
|
|
|
|
|
|
function buildOutput(mainPanel) {
|
|
const output = make("section", "v2-output-shell", `
|
|
<header class="v2-output-heading">
|
|
<div>
|
|
<span class="v2-page-index" id="v2PageIndex">00</span>
|
|
<div><small>INFORMATION OUTPUT</small><h2 id="v2PageTitle">整车联合数字孪生</h2></div>
|
|
</div>
|
|
<span class="v2-output-state"><i></i><span id="v2OutputState">LIVE STATE</span></span>
|
|
</header>
|
|
<div class="v2-output-content" id="v2OutputContent"></div>
|
|
<nav class="v2-page-rail" id="v2PageRail" aria-label="信息分页" hidden></nav>`);
|
|
mainPanel.append(output);
|
|
|
|
const content = $("#v2OutputContent");
|
|
const twin = $(".joint-twin-shell", mainPanel);
|
|
const resultWorkspace = $(".result-workspace", mainPanel);
|
|
if (twin) content.append(twin);
|
|
if (resultWorkspace) content.append(resultWorkspace);
|
|
|
|
pageDefinitions.forEach((page) => {
|
|
const target = $(page.target, content);
|
|
if (!target) return;
|
|
target.dataset.v2Page = String(page.index);
|
|
target.classList.add("v2-output-page");
|
|
});
|
|
|
|
pageDefinitions.forEach((page) => {
|
|
const button = make("button", "v2-page-button", `
|
|
<span>${String(page.index).padStart(2, "0")}</span>
|
|
<i></i>
|
|
<strong>${page.shortLabel}</strong>`);
|
|
button.type = "button";
|
|
button.dataset.v2PageTarget = String(page.index);
|
|
button.setAttribute("aria-label", `${String(page.index).padStart(2, "0")} ${page.label}`);
|
|
$("#v2PageRail").append(button);
|
|
});
|
|
}
|
|
|
|
const displaySync = {
|
|
revision: -1,
|
|
requestId: null,
|
|
pollTimer: null,
|
|
deviceFrame: null,
|
|
deviceFingerprint: "",
|
|
activeDevice: null,
|
|
pinnedDevice: null,
|
|
previewClearTimer: null,
|
|
hoverSuppressedUntil: 0,
|
|
controlDevice: null,
|
|
deviceDrafts: {},
|
|
pendingControls: {},
|
|
logs: [],
|
|
logKeys: new Set(),
|
|
extensionFingerprint: "",
|
|
};
|
|
|
|
const readText = (selector, fallback = "--") => $(selector)?.textContent?.trim() || fallback;
|
|
|
|
const clampControlNumber = (value, min, max, fallback = min) => {
|
|
const numeric = Number.parseFloat(String(value ?? "").replace(/[^\d.-]/g, ""));
|
|
return Number.isFinite(numeric) ? Math.max(min, Math.min(max, numeric)) : fallback;
|
|
};
|
|
|
|
const splitReadout = (selector) => {
|
|
const [front = "-", rear = "-"] = readText(selector, "- / -").split("/");
|
|
return [front.trim() || "-", rear.trim() || "-"];
|
|
};
|
|
|
|
function seatFunctionValue(seat, feature) {
|
|
return $(`.seat-card[data-seat="${seat}"] [data-function="${feature}"] b`)?.textContent?.trim() || "--";
|
|
}
|
|
|
|
function seatAxisValue(seat, axis) {
|
|
return $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"] b`)?.textContent?.trim() || "--";
|
|
}
|
|
|
|
function seatLevelControlValue(seat, feature) {
|
|
const text = seatFunctionValue(seat, feature);
|
|
const level = text.match(/L(\d+)/i)?.[1];
|
|
return /OFF|关闭|--/.test(text) ? "off" : level || "1";
|
|
}
|
|
|
|
function massageReadout() {
|
|
const text = seatFunctionValue("driver", "massage");
|
|
if (text === "--") return ["--", "--"];
|
|
if (/OFF|关闭/.test(text)) return ["关闭", "关闭"];
|
|
const [mode = "开启", strength = "默认"] = text.split("·").map((item) => item.trim());
|
|
return [mode, strength];
|
|
}
|
|
|
|
const levelOptions = [
|
|
["off", "关闭"], ["1", "1 档"], ["2", "2 档"], ["3", "3 档"],
|
|
];
|
|
const toggleOptions = [["off", "关闭"], ["on", "开启"]];
|
|
const massageModeOptions = [
|
|
["off", "关闭"], ["relax", "舒缓"], ["stretch", "伸展"], ["waist", "腰部"], ["wave", "波浪"],
|
|
];
|
|
const massageStrengthOptions = [
|
|
["off", "关闭"], ["low", "低"], ["medium", "中"], ["high", "高"],
|
|
];
|
|
const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"];
|
|
const colorIndexOptions = Array.from({ length: 16 }, (_item, index) => [
|
|
String(index), colorIndexNames[index] ? `${index} · ${colorIndexNames[index]}` : `色号 ${index}`,
|
|
]);
|
|
|
|
const rangeControl = (key, label, value, min = 0, max = 100, step = 1, unit = "%", hint = "") => ({
|
|
key, label, type: "range", value: clampControlNumber(value, min, max), min, max, step, unit, hint,
|
|
});
|
|
const selectControl = (key, label, value, options) => ({ key, label, type: "select", value, options });
|
|
|
|
function deviceControlValue(deviceId, control) {
|
|
const draft = displaySync.deviceDrafts[deviceId];
|
|
return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value;
|
|
}
|
|
|
|
function optionLabel(options, value) {
|
|
return options.find(([optionValue]) => String(optionValue) === String(value))?.[1] || String(value);
|
|
}
|
|
|
|
const syncedDevices = [
|
|
{
|
|
id: "passenger", name: "副驾座椅", icon: "armchair",
|
|
rows: () => [["通风", seatFunctionValue("passenger", "ventilation")], ["加热", seatFunctionValue("passenger", "heating")], ["水平", seatAxisValue("passenger", "longitudinal")], ["坐垫", seatAxisValue("passenger", "cushion_tilt")], ["靠背", seatAxisValue("passenger", "backrest_recline")], ["腿托", seatAxisValue("passenger", "legrest_height")]],
|
|
controls: () => [
|
|
selectControl("ventilation", "通风", seatLevelControlValue("passenger", "ventilation"), levelOptions),
|
|
selectControl("heating", "加热", seatLevelControlValue("passenger", "heating"), levelOptions),
|
|
rangeControl("longitudinal", "水平", seatAxisValue("passenger", "longitudinal")),
|
|
rangeControl("cushion_tilt", "坐垫", seatAxisValue("passenger", "cushion_tilt")),
|
|
rangeControl("backrest_recline", "靠背", seatAxisValue("passenger", "backrest_recline")),
|
|
rangeControl("legrest_height", "腿托", seatAxisValue("passenger", "legrest_height")),
|
|
],
|
|
command: (values) => `调节副驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},水平${values.longitudinal}%,坐垫${values.cushion_tilt}%,靠背${values.backrest_recline}%,腿托${values.legrest_height}%`,
|
|
},
|
|
{
|
|
id: "driver", name: "主驾座椅", icon: "armchair",
|
|
rows: () => {
|
|
const [massageMode, massageStrength] = massageReadout();
|
|
return [["通风", seatFunctionValue("driver", "ventilation")], ["加热", seatFunctionValue("driver", "heating")], ["按摩模式", massageMode], ["按摩强度", massageStrength]];
|
|
},
|
|
controls: () => {
|
|
const [massageMode, massageStrength] = massageReadout();
|
|
return [
|
|
selectControl("ventilation", "通风", seatLevelControlValue("driver", "ventilation"), levelOptions),
|
|
selectControl("heating", "加热", seatLevelControlValue("driver", "heating"), levelOptions),
|
|
selectControl("massage_mode", "按摩模式", /关闭|--/.test(massageMode) ? "off" : massageMode.toLowerCase(), massageModeOptions),
|
|
selectControl("massage_strength", "按摩强度", /关闭|--/.test(massageStrength) ? "off" : massageStrength.toLowerCase(), massageStrengthOptions),
|
|
];
|
|
},
|
|
command: (values) => `调节主驾座椅:通风${optionLabel(levelOptions, values.ventilation)},加热${optionLabel(levelOptions, values.heating)},按摩模式${optionLabel(massageModeOptions, values.massage_mode)},按摩强度${optionLabel(massageStrengthOptions, values.massage_strength)}`,
|
|
},
|
|
{
|
|
id: "windows", name: "车窗", icon: "gallery-vertical-end",
|
|
rows: () => [["左前窗", readText("#frontLeftWindow")], ["右前窗", readText("#frontRightWindow")], ["左后窗", readText("#rearLeftWindow")], ["右后窗", readText("#rearRightWindow")]],
|
|
controls: () => [
|
|
rangeControl("front_left", "左前窗", readText("#frontLeftWindow"), 0, 100, 1, "%", "0% 关闭 · 100% 打开"),
|
|
rangeControl("front_right", "右前窗", readText("#frontRightWindow")),
|
|
rangeControl("rear_left", "左后窗", readText("#rearLeftWindow")),
|
|
rangeControl("rear_right", "右后窗", readText("#rearRightWindow")),
|
|
],
|
|
command: (values) => `调节车窗开合比例:左前窗${values.front_left}%,右前窗${values.front_right}%,左后窗${values.rear_left}%,右后窗${values.rear_right}%`,
|
|
},
|
|
{
|
|
id: "shade", name: "遮阳帘", icon: "panels-top-left",
|
|
rows: () => [["当前位置", readText("#shadePositionValue")]],
|
|
controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")],
|
|
command: (values) => `将遮阳帘开合位置调整为${values.position}%`,
|
|
},
|
|
{
|
|
id: "reading", name: "阅读灯", icon: "book-open",
|
|
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]],
|
|
controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)],
|
|
command: (values) => `${values.power === "on" ? "打开" : "关闭"}阅读灯`,
|
|
},
|
|
{
|
|
id: "ambient", name: "氛围灯", icon: "sparkles",
|
|
rows: () => {
|
|
const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness");
|
|
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
|
return [["开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "关闭" : "开启"], ["前排亮度", frontBrightness], ["前排颜色", frontColor], ["后排亮度", rearBrightness], ["后排颜色", rearColor]];
|
|
},
|
|
controls: () => {
|
|
const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness");
|
|
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
|
return [
|
|
selectControl("power", "开关状态", /OFF|关闭/.test(readText("#ambientPower")) ? "off" : "on", toggleOptions),
|
|
rangeControl("front_brightness", "前排亮度", frontBrightness, 0, 5, 1, " 级"),
|
|
selectControl("front_color", "前排颜色", String(clampControlNumber(frontColor, 0, 15, 0)), colorIndexOptions),
|
|
rangeControl("rear_brightness", "后排亮度", rearBrightness, 0, 5, 1, " 级"),
|
|
selectControl("rear_color", "后排颜色", String(clampControlNumber(rearColor, 0, 15, 0)), colorIndexOptions),
|
|
];
|
|
},
|
|
command: (values) => `调节氛围灯:${values.power === "on" ? "开启" : "关闭"},前排亮度${values.front_brightness}级、颜色${optionLabel(colorIndexOptions, values.front_color)},后排亮度${values.rear_brightness}级、颜色${optionLabel(colorIndexOptions, values.rear_color)}`,
|
|
},
|
|
{
|
|
id: "headlight", name: "前大灯", icon: "car-front",
|
|
rows: () => [["近光灯", readText("#lowBeamState")], ["远光灯", readText("#highBeamState")], ["雾灯", readText("#fogLightState")], ["转向灯", readText("#turnSignalState")]],
|
|
controls: () => [["low_beam", "近光灯", "#lowBeamState"], ["high_beam", "远光灯", "#highBeamState"], ["fog_light", "雾灯", "#fogLightState"], ["turn_signal", "转向灯", "#turnSignalState"]].map(([key, label, selector]) => selectControl(key, label, /开启|ON/.test(readText(selector)) ? "on" : "off", toggleOptions)),
|
|
command: (values) => `调节前大灯:近光灯${optionLabel(toggleOptions, values.low_beam)},远光灯${optionLabel(toggleOptions, values.high_beam)},雾灯${optionLabel(toggleOptions, values.fog_light)},转向灯${optionLabel(toggleOptions, values.turn_signal)}`,
|
|
},
|
|
{
|
|
id: "climate", name: "空调", icon: "fan",
|
|
rows: () => [["开关状态", readText("#climatePower", "等待反馈")], ["主驾温度", readText("#driverTemperature")], ["副驾温度", readText("#passengerTemperature")], ["风量", readText("#fanLevel")], ["循环模式", readText("#cycleMode")]],
|
|
controls: () => [
|
|
selectControl("power", "开关状态", /ON|ACTIVE|开启/.test(readText("#climatePower")) ? "on" : "off", toggleOptions),
|
|
rangeControl("driver_temperature", "主驾温度", readText("#driverTemperature"), 16, 32, 0.5, "℃"),
|
|
rangeControl("passenger_temperature", "副驾温度", readText("#passengerTemperature"), 16, 32, 0.5, "℃"),
|
|
rangeControl("fan_level", "风量", readText("#fanLevel"), 0, 7, 1, " 档"),
|
|
selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]),
|
|
],
|
|
command: (values) => `调节空调:${values.power === "on" ? "开启" : "关闭"},主驾${values.driver_temperature}℃,副驾${values.passenger_temperature}℃,风量${values.fan_level}档,${values.cycle_mode === "internal" ? "内循环" : "外循环"}`,
|
|
},
|
|
];
|
|
|
|
function renderDeviceControl(device, control) {
|
|
const value = deviceControlValue(device.id, control);
|
|
if (control.type === "select") {
|
|
const options = control.options.map(([optionValue, label]) => `<option value="${escapeHtml(optionValue)}"${String(optionValue) === String(value) ? " selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
|
return `<label class="v2-device-control-row v2-device-control-select"><span>${escapeHtml(control.label)}</span><select data-device-control="${device.id}" data-control-key="${control.key}" aria-label="${escapeHtml(device.name + control.label)}">${options}</select></label>`;
|
|
}
|
|
return `<label class="v2-device-control-row v2-device-control-range">
|
|
<span>${escapeHtml(control.label)}</span><output data-control-output="${control.key}">${escapeHtml(value)}${escapeHtml(control.unit)}</output>
|
|
<input type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${escapeHtml(value)}" data-device-control="${device.id}" data-control-key="${control.key}" data-control-unit="${escapeHtml(control.unit)}" aria-label="${escapeHtml(device.name + control.label)}" />
|
|
${control.hint ? `<small>${escapeHtml(control.hint)}</small>` : ""}
|
|
</label>`;
|
|
}
|
|
|
|
function renderSyncedDevices() {
|
|
const grid = $("#v2DeviceSyncGrid");
|
|
if (!grid) return;
|
|
grid.innerHTML = syncedDevices.map((device) => {
|
|
const rows = device.rows();
|
|
const waiting = rows.some(([, value]) => /--|等待|同步中|未知/.test(value));
|
|
const controlOpen = displaySync.controlDevice === device.id;
|
|
const hasDraft = Boolean(displaySync.deviceDrafts[device.id]);
|
|
const pending = Boolean(displaySync.pendingControls[device.id]);
|
|
const stateLabel = pending ? "已发送 · 等待同步" : hasDraft ? "参数待发送" : `${rows.length} 项关键状态`;
|
|
return `<article class="v2-synced-device" data-device="${device.id}" data-ready="${String(!waiting)}" data-control-open="${String(controlOpen)}" data-has-draft="${String(hasDraft)}">
|
|
<header class="v2-device-card-header">
|
|
<button class="v2-device-card-focus" type="button" data-device-focus="${device.id}" aria-label="在孪生界面查看${device.name}">
|
|
<span><i data-lucide="${device.icon}"></i></span><span><strong>${device.name}</strong><small data-device-card-state>${stateLabel}</small></span><i class="v2-device-signal"></i>
|
|
</button>
|
|
<button class="v2-device-adjust-toggle" type="button" data-device-control-toggle="${device.id}" aria-expanded="${String(controlOpen)}"><i data-lucide="sliders-horizontal"></i><span>${controlOpen ? "收起" : "调节"}</span></button>
|
|
</header>
|
|
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>
|
|
<div class="v2-device-controls" data-device-controls="${device.id}"${controlOpen ? "" : " hidden"}>
|
|
<div class="v2-device-controls-intro"><span>手动调节</span><small>参数确认后通过编排链路发送至车机</small></div>
|
|
${device.controls().map((control) => renderDeviceControl(device, control)).join("")}
|
|
<button class="v2-device-control-apply" type="button" data-device-control-apply="${device.id}"${pending ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : "发送控制"}</span></button>
|
|
</div>
|
|
</article>`;
|
|
}).join("");
|
|
if (displaySync.activeDevice) {
|
|
$(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active");
|
|
}
|
|
displaySync.deviceFingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, device.rows()]));
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
}
|
|
|
|
function storeDeviceControlDraft(input) {
|
|
const deviceId = input.dataset.deviceControl;
|
|
const key = input.dataset.controlKey;
|
|
if (!deviceId || !key) return;
|
|
displaySync.deviceDrafts[deviceId] ||= {};
|
|
displaySync.deviceDrafts[deviceId][key] = input.value;
|
|
const controls = input.closest("[data-device-controls]");
|
|
const output = controls?.querySelector(`[data-control-output="${key}"]`);
|
|
if (output) output.textContent = `${input.value}${input.dataset.controlUnit || ""}`;
|
|
const card = input.closest("[data-device]");
|
|
if (card) {
|
|
card.dataset.hasDraft = "true";
|
|
const state = $("[data-device-card-state]", card);
|
|
if (state) state.textContent = "参数待发送";
|
|
}
|
|
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-draft", {
|
|
detail: { device_id: deviceId, control: key, value: input.value, source: "web_console" },
|
|
}));
|
|
}
|
|
|
|
function sendDeviceControl(deviceId) {
|
|
const device = syncedDevices.find((item) => item.id === deviceId);
|
|
if (!device) return;
|
|
const values = Object.fromEntries(device.controls().map((control) => [control.key, String(deviceControlValue(device.id, control))]));
|
|
const utterance = device.command(values);
|
|
const detail = {
|
|
device_id: device.id,
|
|
device_name: device.name,
|
|
controls: values,
|
|
utterance,
|
|
source: "web_console",
|
|
requested_at: new Date().toISOString(),
|
|
};
|
|
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-request", { detail }));
|
|
const accepted = window.CognitiveAgentConsole?.executeUtterance?.(utterance, { autoSubmit: true }) || false;
|
|
if (!accepted) {
|
|
window.CognitiveAgentConsole?.notify?.("上一条指令仍在执行,请稍候");
|
|
return;
|
|
}
|
|
displaySync.pendingControls[device.id] = utterance;
|
|
appendSyncLog({ category: "device", title: `网页端调节${device.name}`, detail: utterance, status: "info" });
|
|
renderSyncedDevices();
|
|
}
|
|
|
|
function scheduleSyncedDeviceRender() {
|
|
if (displaySync.deviceFrame) return;
|
|
displaySync.deviceFrame = requestAnimationFrame(() => {
|
|
displaySync.deviceFrame = null;
|
|
const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, device.rows()]));
|
|
if (fingerprint === displaySync.deviceFingerprint && $("#v2DeviceSyncGrid")?.childElementCount) return;
|
|
renderSyncedDevices();
|
|
if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, {
|
|
pinned: Boolean(displaySync.pinnedDevice),
|
|
selectSeat: false,
|
|
});
|
|
});
|
|
}
|
|
|
|
function appendSyncLog({ key = "", category = "sync", title, detail = "", status = "info" }) {
|
|
if (!title || (key && displaySync.logKeys.has(key))) return;
|
|
const item = {
|
|
key,
|
|
category,
|
|
title,
|
|
detail,
|
|
status,
|
|
time: new Intl.DateTimeFormat("zh-CN", {
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
|
}).format(new Date()),
|
|
};
|
|
if (key) displaySync.logKeys.add(key);
|
|
displaySync.logs.unshift(item);
|
|
if (displaySync.logs.length > 40) {
|
|
const removed = displaySync.logs.pop();
|
|
if (removed?.key) displaySync.logKeys.delete(removed.key);
|
|
}
|
|
renderSyncLogs();
|
|
}
|
|
|
|
function renderSyncLogs() {
|
|
const list = $("#v2SyncLogList");
|
|
if (!list) return;
|
|
const filter = $("#v2LogFilter")?.value || "all";
|
|
const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter);
|
|
const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" };
|
|
list.innerHTML = items.length ? items.map((item) => `
|
|
<li data-log-status="${escapeHtml(item.status)}">
|
|
<time>${escapeHtml(item.time)}</time>
|
|
<span><i data-lucide="${icons[item.category] || "circle-dot"}"></i></span>
|
|
<div><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.detail)}</small></div>
|
|
</li>`).join("") : '<li class="v2-sync-log-empty"><div><strong>暂无记录</strong><small>车机任务、设备反馈与外设事件会显示在这里</small></div></li>';
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
}
|
|
|
|
function panelVisible(key) {
|
|
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
|
if (panel) return !panel.classList.contains("is-collapsed");
|
|
return sessionStorage.getItem(`cognitive-agent.sync-panel.${key}`) !== "hidden";
|
|
}
|
|
|
|
function setSidePanelVisibility(key, visible) {
|
|
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
|
if (!panel) return;
|
|
panel.hidden = false;
|
|
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" ? "车端设备状态" : "外设与扩展能力"}`);
|
|
const actionLabel = $("[data-panel-action-label]", button);
|
|
if (actionLabel) actionLabel.textContent = visible ? "收起" : "展开";
|
|
const stateLabel = $("[data-panel-state-label]", button);
|
|
if (stateLabel) stateLabel.textContent = visible ? "已展开" : "已收起";
|
|
});
|
|
const openPanels = ["devices", "extensions"].filter((panelKey) => {
|
|
const candidate = panelKey === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
|
|
return candidate && !candidate.classList.contains("is-collapsed");
|
|
});
|
|
$(".joint-twin-shell")?.setAttribute("data-open-panels", openPanels.join(" ") || "none");
|
|
window.dispatchEvent(new Event("resize"));
|
|
}
|
|
|
|
function presentTwinDevice(deviceId, { pinned = false, selectSeat = pinned } = {}) {
|
|
const device = syncedDevices.find((item) => item.id === deviceId);
|
|
const popover = $("#v2TwinDevicePopover");
|
|
if (!device || !popover) return;
|
|
if (displaySync.previewClearTimer) {
|
|
window.clearTimeout(displaySync.previewClearTimer);
|
|
displaySync.previewClearTimer = null;
|
|
}
|
|
displaySync.activeDevice = deviceId;
|
|
if (pinned) displaySync.pinnedDevice = deviceId;
|
|
const rows = device.rows();
|
|
popover.hidden = false;
|
|
popover.dataset.device = deviceId;
|
|
popover.dataset.pinned = String(Boolean(displaySync.pinnedDevice));
|
|
popover.innerHTML = `
|
|
<header><span><i data-lucide="${device.icon}"></i></span><div><small>LIVE DEVICE FEEDBACK</small><strong>${escapeHtml(device.name)}</strong></div><b>${pinned ? "已固定" : "靠近查看"}</b><button class="v2-twin-popover-close" type="button" data-twin-popover-close aria-label="关闭${escapeHtml(device.name)}状态详情" title="关闭"><i data-lucide="x"></i></button></header>
|
|
<dl>${rows.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>`;
|
|
$$("[data-twin-device]").forEach((button) => button.classList.toggle("active", button.dataset.twinDevice === deviceId));
|
|
$$(".v2-synced-device").forEach((button) => button.classList.toggle("active", button.dataset.device === deviceId));
|
|
if (selectSeat && ["driver", "passenger"].includes(deviceId)) {
|
|
$(`[data-twin-seat="${deviceId}"]`)?.click();
|
|
}
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
}
|
|
|
|
function clearTwinDevicePreview({ force = false } = {}) {
|
|
if (displaySync.previewClearTimer) {
|
|
window.clearTimeout(displaySync.previewClearTimer);
|
|
displaySync.previewClearTimer = null;
|
|
}
|
|
if (displaySync.pinnedDevice && !force) return;
|
|
displaySync.activeDevice = null;
|
|
if (force) displaySync.pinnedDevice = null;
|
|
const popover = $("#v2TwinDevicePopover");
|
|
if (popover) popover.hidden = true;
|
|
$$("[data-twin-device], .v2-synced-device").forEach((button) => button.classList.remove("active"));
|
|
}
|
|
|
|
function scheduleTwinDevicePreviewClear() {
|
|
if (displaySync.pinnedDevice) return;
|
|
if (displaySync.previewClearTimer) window.clearTimeout(displaySync.previewClearTimer);
|
|
displaySync.previewClearTimer = window.setTimeout(() => {
|
|
displaySync.previewClearTimer = null;
|
|
clearTwinDevicePreview();
|
|
}, 45);
|
|
}
|
|
|
|
function buildTwinDeviceInteractions() {
|
|
const stage = $("#seatTwin");
|
|
if (!stage || $("#v2TwinHotspots")) return;
|
|
const hotspots = make("div", "v2-twin-hotspots", syncedDevices.map((device) => `
|
|
<button type="button" data-twin-device="${device.id}" aria-label="查看${device.name}状态"><span class="v2-twin-hotspot-label"><i data-lucide="${device.icon}"></i><b>${device.name}</b><small>实时状态</small></span></button>`).join(""));
|
|
hotspots.id = "v2TwinHotspots";
|
|
const popover = make("aside", "v2-twin-device-popover", "");
|
|
popover.id = "v2TwinDevicePopover";
|
|
popover.hidden = true;
|
|
popover.setAttribute("role", "dialog");
|
|
popover.setAttribute("aria-label", "车端设备状态详情");
|
|
popover.setAttribute("aria-live", "polite");
|
|
stage.append(hotspots, popover);
|
|
|
|
hotspots.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
$$("[data-twin-device]", hotspots).forEach((button) => {
|
|
button.addEventListener("pointerenter", () => {
|
|
if (!displaySync.pinnedDevice && performance.now() >= displaySync.hoverSuppressedUntil) {
|
|
presentTwinDevice(button.dataset.twinDevice);
|
|
}
|
|
});
|
|
button.addEventListener("pointerleave", (event) => {
|
|
if (event.relatedTarget?.closest?.("#v2TwinDevicePopover")) return;
|
|
scheduleTwinDevicePreviewClear();
|
|
});
|
|
button.addEventListener("focus", () => presentTwinDevice(button.dataset.twinDevice));
|
|
button.addEventListener("blur", scheduleTwinDevicePreviewClear);
|
|
});
|
|
hotspots.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-twin-device]");
|
|
if (!button) return;
|
|
event.stopPropagation();
|
|
const samePinned = displaySync.pinnedDevice === button.dataset.twinDevice;
|
|
if (samePinned) clearTwinDevicePreview({ force: true });
|
|
else presentTwinDevice(button.dataset.twinDevice, { pinned: true });
|
|
});
|
|
popover.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
popover.addEventListener("pointerenter", () => {
|
|
if (displaySync.previewClearTimer) {
|
|
window.clearTimeout(displaySync.previewClearTimer);
|
|
displaySync.previewClearTimer = null;
|
|
}
|
|
});
|
|
popover.addEventListener("pointerleave", scheduleTwinDevicePreviewClear);
|
|
popover.addEventListener("click", (event) => {
|
|
if (!event.target.closest("[data-twin-popover-close]")) return;
|
|
event.stopPropagation();
|
|
displaySync.hoverSuppressedUntil = performance.now() + 160;
|
|
clearTwinDevicePreview({ force: true });
|
|
});
|
|
stage.addEventListener("click", (event) => {
|
|
if (event.target.closest("[data-twin-device], #v2TwinDevicePopover")) return;
|
|
clearTwinDevicePreview({ force: true });
|
|
});
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
}
|
|
|
|
function taskKind(utterance, result, source = "") {
|
|
if (source === "web_console") return "网页远程编排";
|
|
if (result?.mode_profile) return "自定义功能模式";
|
|
const preset = $$(".mode-chips [data-mode-command]").find((button) => (
|
|
String(utterance).includes(button.textContent.trim())
|
|
|| button.dataset.modeCommand === utterance
|
|
));
|
|
return preset ? "预设功能模式" : utterance ? "一句话自定义编排" : "等待编排任务";
|
|
}
|
|
|
|
function renderTaskTimeline(task) {
|
|
const timeline = $("#v2TaskTimeline");
|
|
if (!timeline) return;
|
|
const result = task?.result;
|
|
const trace = Array.isArray(result?.trace_events) ? result.trace_events : [];
|
|
const domains = Object.keys(result?.function_configs || {});
|
|
const stages = task?.status === "running"
|
|
? ["接收编排指令", "解析意图", "构建功能模式", "等待设备反馈"]
|
|
: trace.length
|
|
? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤")
|
|
: domains.length
|
|
? ["接收编排指令", `编排 ${domains.length} 个设备域`, "下发执行计划", "同步执行结果"]
|
|
: ["等待车机或网页端下发编排任务"];
|
|
timeline.innerHTML = stages.map((stage, index) => `
|
|
<span class="${task?.status === "running" && index === stages.length - 1 ? "active" : "done"}">
|
|
<i>${String(index + 1).padStart(2, "0")}</i><b>${escapeHtml(stage)}</b>
|
|
</span>`).join("");
|
|
}
|
|
|
|
function setTaskPresentation(task = {}) {
|
|
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;
|
|
const kind = taskKind(utterance, result, String(task.source || ""));
|
|
if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind;
|
|
if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令";
|
|
const normalizedStatus = String(task.status || "idle").toLowerCase();
|
|
const statusMap = {
|
|
idle: ["等待任务", "idle"],
|
|
running: ["构建 / 执行中", "running"],
|
|
succeeded: ["执行完成", "succeeded"],
|
|
partially_succeeded: ["部分完成", "partial"],
|
|
needs_confirmation: ["等待确认", "partial"],
|
|
failed: ["执行失败", "failed"],
|
|
};
|
|
const [label, state] = statusMap[normalizedStatus] || statusMap.idle;
|
|
const badge = $("#v2TaskState");
|
|
if (badge) {
|
|
badge.textContent = label;
|
|
badge.dataset.state = state;
|
|
}
|
|
$$(".mode-chips [data-mode-command]").forEach((button) => {
|
|
const active = Boolean(utterance) && (
|
|
utterance === button.dataset.modeCommand
|
|
|| utterance.includes(button.textContent.trim())
|
|
);
|
|
button.classList.toggle("v2-sync-active", active);
|
|
});
|
|
renderTaskTimeline(task);
|
|
}
|
|
|
|
function applyDisplaySnapshot(snapshot) {
|
|
const health = snapshot?.extension_health || {};
|
|
const physicallyConnected = health.transport !== "simulation"
|
|
&& Boolean(health.connected || health.peer_connected);
|
|
const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog)
|
|
? snapshot.extension.catalog
|
|
: [];
|
|
const connected = physicallyConnected || synchronizedCatalog.length > 0;
|
|
const listening = Boolean(health.listening);
|
|
const usbState = $("#v2UsbState");
|
|
if (usbState) {
|
|
const usbLabel = $("span", usbState);
|
|
if (usbLabel) usbLabel.textContent = physicallyConnected
|
|
? "USB 外设已接入"
|
|
: synchronizedCatalog.length
|
|
? `${new Set(synchronizedCatalog.map((item) => item.device_id)).size} 个外设已同步`
|
|
: listening ? "等待 USB 外设" : "扩展链路检查中";
|
|
usbState.dataset.state = connected ? "online" : listening ? "listening" : "offline";
|
|
}
|
|
if ($("#v2SyncTransport")) {
|
|
$("#v2SyncTransport").textContent = `${String(health.transport || "GSCU").toUpperCase()} · ${health.peer || health.listen || "等待连接"}`;
|
|
}
|
|
const vehicleOnline = health.vehicle?.connected !== false;
|
|
const vehicleBadge = $("#v2HeadUnitState");
|
|
if (vehicleBadge) {
|
|
vehicleBadge.dataset.state = vehicleOnline ? "online" : "offline";
|
|
$("span", vehicleBadge).textContent = vehicleOnline ? "车机链路在线" : "车机链路离线";
|
|
}
|
|
const revision = Number(snapshot?.revision ?? -1);
|
|
if (revision !== displaySync.revision) {
|
|
displaySync.revision = revision;
|
|
const task = snapshot?.task || {};
|
|
setTaskPresentation(task);
|
|
const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
|
|
if (task.utterance) {
|
|
appendSyncLog({
|
|
key: `task:${revision}:${task.status}`,
|
|
category: "task",
|
|
title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
|
|
detail: `${task.utterance} · ${task.status || "idle"}`,
|
|
status: ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
|
|
});
|
|
}
|
|
const result = task.result;
|
|
const requestId = result?.request_id || null;
|
|
if (result && requestId !== displaySync.requestId) {
|
|
displaySync.requestId = requestId;
|
|
window.CognitiveAgentConsole?.applySyncedResult?.(result, {
|
|
utterance: task.utterance || "",
|
|
});
|
|
}
|
|
}
|
|
const extensionFingerprint = synchronizedCatalog
|
|
.map((item) => `${item.device_id || "external"}:${item.action || item.name || "capability"}`)
|
|
.sort()
|
|
.join("|");
|
|
if (extensionFingerprint && extensionFingerprint !== displaySync.extensionFingerprint) {
|
|
displaySync.extensionFingerprint = extensionFingerprint;
|
|
appendSyncLog({
|
|
key: `extension:${extensionFingerprint}`,
|
|
category: "extension",
|
|
title: "外设能力目录已同步",
|
|
detail: `${new Set(synchronizedCatalog.map((item) => item.device_id || "external")).size} 个外设 · ${synchronizedCatalog.length} 项能力`,
|
|
status: "success",
|
|
});
|
|
}
|
|
window.dispatchEvent(new CustomEvent("cognitive-agent:display-sync", { detail: snapshot }));
|
|
}
|
|
|
|
async function pollVehicleDisplaySync() {
|
|
try {
|
|
const response = await fetch("/api/vehicle-display-sync", {
|
|
credentials: "same-origin",
|
|
cache: "no-store",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
applyDisplaySnapshot(await response.json());
|
|
$("#v2MirrorState")?.setAttribute("data-state", "online");
|
|
if ($("#v2MirrorState span")) $("#v2MirrorState span").textContent = "同步正常";
|
|
appendSyncLog({ key: "sync:online", category: "sync", title: "网页同步链路已建立", detail: "车机任务与设备状态每 2 秒同步", status: "success" });
|
|
} catch (_error) {
|
|
console.warn("[vehicle-display-sync]", _error);
|
|
$("#v2MirrorState")?.setAttribute("data-state", "offline");
|
|
if ($("#v2MirrorState span")) $("#v2MirrorState span").textContent = "等待同步服务";
|
|
}
|
|
}
|
|
|
|
function enableRemoteOrchestration() {
|
|
const form = $("#requestForm");
|
|
const input = $("#utterance");
|
|
if (input) {
|
|
input.readOnly = false;
|
|
input.removeAttribute("aria-readonly");
|
|
input.setAttribute("aria-label", "输入一句话远程编排车端功能");
|
|
input.placeholder = "例如:副驾调到舒适姿态,空调 24℃,氛围灯改成暖橙色";
|
|
}
|
|
form?.addEventListener("submit", () => {
|
|
const utterance = input?.value.trim();
|
|
if (utterance) appendSyncLog({
|
|
category: "task",
|
|
title: "网页端发送编排指令",
|
|
detail: utterance,
|
|
status: "info",
|
|
});
|
|
});
|
|
$$("[data-mode-command]").forEach((button) => {
|
|
button.removeAttribute("aria-disabled");
|
|
button.title = "点击后由网页端发送至车机执行";
|
|
});
|
|
}
|
|
|
|
function syncSidebarLogBaseline() {
|
|
const sidebar = $(".v2-sidebar");
|
|
const lowerPanel = $("#v2DevicePanel") || $(".joint-twin-shell > .twin-band");
|
|
if (!sidebar || !lowerPanel) return;
|
|
const offset = Math.max(0, lowerPanel.getBoundingClientRect().top - sidebar.getBoundingClientRect().top);
|
|
sidebar.style.setProperty("--v2-sync-log-top", `${offset}px`);
|
|
}
|
|
|
|
function buildSyncControlLayer() {
|
|
document.body.classList.add("v2-passive-display", "v2-interactive-sync");
|
|
document.body.dataset.module = "self-orchestration";
|
|
enableRemoteOrchestration();
|
|
|
|
const sidebar = $(".v2-sidebar");
|
|
const sidebarFooter = $(".v2-sidebar-footer", sidebar);
|
|
const syncSidebar = make("section", "v2-sync-sidebar", `
|
|
<div class="v2-sync-sidebar-overview">
|
|
<div class="v2-sync-sidebar-heading"><small>REMOTE CLIENT</small><h2>511工程-智能座舱</h2><p>网页可远程编排,车机任务、设备反馈与 USB 外设能力保持同步。</p></div>
|
|
<div class="v2-sync-link-stack">
|
|
<div class="v2-sync-link" id="v2HeadUnitState" data-state="checking"><i data-lucide="monitor-smartphone"></i><div><strong>车机链路</strong><span>连接检查中</span></div><b></b></div>
|
|
<div class="v2-sync-link" id="v2MirrorState" data-state="checking"><i data-lucide="refresh-cw"></i><div><strong>网页同步</strong><span>建立同步中</span></div><b></b></div>
|
|
<div class="v2-sync-link" id="v2UsbState" data-state="checking"><i data-lucide="usb"></i><div><strong>USB 扩展</strong><span>检查外设中</span></div><b></b></div>
|
|
</div>
|
|
<div class="v2-sync-protocol"><i data-lucide="radio-tower"></i><div><small>扩展链路</small><strong id="v2SyncTransport">GSCU · 等待连接</strong></div></div>
|
|
</div>
|
|
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle">
|
|
<header><div><small>RUNTIME / SYNC</small><h3 id="v2SyncLogTitle">运行日志 / 同步记录</h3></div><select id="v2LogFilter" aria-label="筛选同步日志"><option value="all">全部</option><option value="task">任务</option><option value="sync">同步</option><option value="extension">外设</option><option value="device">设备</option></select></header>
|
|
<ol id="v2SyncLogList" aria-live="polite"></ol>
|
|
</section>`);
|
|
if (sidebar && sidebarFooter) {
|
|
sidebar.insertBefore(syncSidebar, sidebarFooter);
|
|
$(".v2-sync-sidebar-overview", syncSidebar)?.append(sidebarFooter);
|
|
const sidebarToggle = $("#v2SidebarToggle", sidebar);
|
|
if (sidebarToggle) $(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);
|
|
}
|
|
|
|
const naturalPanel = $("#v2NaturalPanel");
|
|
const taskOverview = make("section", "v2-task-overview", `
|
|
<div class="v2-task-copy"><span id="v2TaskKind">当前车机任务</span><strong id="v2TaskCommand">等待车机端或网页端选择预设模式、输入一句话指令</strong></div>
|
|
<span class="v2-task-state" id="v2TaskState" data-state="idle">等待任务</span>
|
|
<div class="v2-task-timeline" id="v2TaskTimeline"><span><i>01</i><b>等待车机或网页端下发编排任务</b></span></div>`);
|
|
naturalPanel?.append(taskOverview);
|
|
|
|
const jointShell = $(".joint-twin-shell");
|
|
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><small>LIVE VEHICLE DEVICES</small><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>`);
|
|
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);
|
|
buildTwinDeviceInteractions();
|
|
cabinBand.classList.add("v2-source-state-only");
|
|
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
|
|
attributes: true,
|
|
childList: true,
|
|
characterData: true,
|
|
subtree: true,
|
|
});
|
|
const stateBand = $(".state-band");
|
|
if (stateBand) new MutationObserver(scheduleSyncedDeviceRender).observe(stateBand, {
|
|
attributes: true,
|
|
childList: true,
|
|
characterData: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
setSidePanelVisibility("devices", panelVisible("devices"));
|
|
setSidePanelVisibility("extensions", panelVisible("extensions"));
|
|
window.addEventListener("resize", syncSidebarLogBaseline);
|
|
requestAnimationFrame(syncSidebarLogBaseline);
|
|
document.addEventListener("click", (event) => {
|
|
const toggle = event.target.closest("[data-panel-toggle], [data-panel-close]");
|
|
if (toggle) {
|
|
const key = toggle.dataset.panelToggle || toggle.dataset.panelClose;
|
|
setSidePanelVisibility(key, !panelVisible(key));
|
|
}
|
|
});
|
|
const deviceGrid = $("#v2DeviceSyncGrid");
|
|
deviceGrid?.addEventListener("click", (event) => {
|
|
const controlToggle = event.target.closest("[data-device-control-toggle]");
|
|
if (controlToggle) {
|
|
const deviceId = controlToggle.dataset.deviceControlToggle;
|
|
displaySync.controlDevice = displaySync.controlDevice === deviceId ? null : deviceId;
|
|
if (displaySync.controlDevice) presentTwinDevice(deviceId, { pinned: true });
|
|
else if (displaySync.pinnedDevice === deviceId) clearTwinDevicePreview({ force: true });
|
|
renderSyncedDevices();
|
|
return;
|
|
}
|
|
const apply = event.target.closest("[data-device-control-apply]");
|
|
if (apply) {
|
|
sendDeviceControl(apply.dataset.deviceControlApply);
|
|
return;
|
|
}
|
|
const focus = event.target.closest("[data-device-focus]");
|
|
if (focus) {
|
|
const samePinned = displaySync.pinnedDevice === focus.dataset.deviceFocus;
|
|
if (samePinned) clearTwinDevicePreview({ force: true });
|
|
else presentTwinDevice(focus.dataset.deviceFocus, { pinned: true });
|
|
}
|
|
});
|
|
deviceGrid?.addEventListener("input", (event) => {
|
|
if (event.target.matches("[data-device-control]")) storeDeviceControlDraft(event.target);
|
|
});
|
|
deviceGrid?.addEventListener("change", (event) => {
|
|
if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target);
|
|
});
|
|
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
|
|
window.addEventListener("cognitive-agent:result", (event) => {
|
|
const result = event.detail || {};
|
|
appendSyncLog({
|
|
key: result.request_id ? `result:${result.request_id}` : "",
|
|
category: "task",
|
|
title: ["succeeded", "partially_succeeded"].includes(result.status) ? "编排任务执行完成" : "编排任务返回结果",
|
|
detail: `${result.summary || result.message || "执行反馈已同步"} · ${result.status || "unknown"}`,
|
|
status: ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info",
|
|
});
|
|
});
|
|
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
|
const pendingDevice = Object.entries(displaySync.pendingControls).find(([, utterance]) => utterance === event.detail?.utterance)?.[0];
|
|
if (pendingDevice) {
|
|
const device = syncedDevices.find((item) => item.id === pendingDevice);
|
|
delete displaySync.pendingControls[pendingDevice];
|
|
if (!event.detail?.error) delete displaySync.deviceDrafts[pendingDevice];
|
|
appendSyncLog({
|
|
category: "device",
|
|
title: event.detail?.error ? `${device?.name || "设备"}控制发送失败` : `${device?.name || "设备"}控制已执行`,
|
|
detail: event.detail?.error?.message || "控制结果已进入车机状态同步链路",
|
|
status: event.detail?.error ? "error" : "success",
|
|
});
|
|
renderSyncedDevices();
|
|
}
|
|
if (event.detail?.error) {
|
|
appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" });
|
|
}
|
|
});
|
|
window.addEventListener("cognitive-agent:connection", (event) => {
|
|
const online = event.detail?.status === "online";
|
|
appendSyncLog({
|
|
key: `connection:${event.detail?.status}`,
|
|
category: "sync",
|
|
title: online ? "车机服务连接正常" : "车机服务连接异常",
|
|
detail: online ? "远程控制与状态同步通道可用" : String(event.detail?.status || "offline"),
|
|
status: online ? "success" : "error",
|
|
});
|
|
});
|
|
renderSyncedDevices();
|
|
setTaskPresentation({ status: "idle" });
|
|
appendSyncLog({ key: "client:ready", category: "sync", title: "同步控制台已就绪", detail: "正在连接车机任务与设备状态服务", status: "info" });
|
|
pollVehicleDisplaySync();
|
|
displaySync.pollTimer = window.setInterval(pollVehicleDisplaySync, 2000);
|
|
const updateClock = () => {
|
|
if ($("#v2SyncClock")) $("#v2SyncClock").textContent = new Intl.DateTimeFormat("zh-CN", {
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
|
}).format(new Date());
|
|
};
|
|
updateClock();
|
|
window.setInterval(updateClock, 1000);
|
|
}
|
|
|
|
function setPage(pageIndex) {
|
|
ui.activePage = 0;
|
|
const definition = pageDefinitions[ui.activePage];
|
|
$(".v2-output-shell")?.classList.toggle("v2-twin-active", definition.index === 0);
|
|
$$(".v2-output-page").forEach((page) => {
|
|
const active = Number(page.dataset.v2Page) === ui.activePage;
|
|
page.classList.toggle("v2-active-output", active);
|
|
page.setAttribute("aria-hidden", active ? "false" : "true");
|
|
});
|
|
$$(".v2-page-button").forEach((button) => {
|
|
const active = Number(button.dataset.v2PageTarget) === ui.activePage;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-current", active ? "page" : "false");
|
|
});
|
|
$("#v2PageIndex").textContent = String(definition.index).padStart(2, "0");
|
|
$("#v2PageTitle").textContent = definition.label;
|
|
$("#v2OutputState").textContent = definition.index === 0 ? "LIVE STATE" : "AUDIT VIEW";
|
|
requestAnimationFrame(updateScrollCues);
|
|
}
|
|
|
|
function updateScrollCues() {
|
|
$$(".result-workspace .tab-panel").forEach((panel) => {
|
|
const active = panel.classList.contains("v2-active-output");
|
|
panel.classList.toggle(
|
|
"v2-scrollable",
|
|
active && panel.scrollHeight > panel.clientHeight + 6,
|
|
);
|
|
});
|
|
}
|
|
|
|
function updateModeEdgeHints() {
|
|
const chips = $(".v2-mode-rail .mode-chips");
|
|
if (!chips) return;
|
|
const buttons = $$('button', chips);
|
|
buttons.forEach((button) => {
|
|
button.classList.remove("v2-edge-fade-left", "v2-edge-fade-right");
|
|
button.style.removeProperty("--v2-edge-fade-start");
|
|
button.style.removeProperty("--v2-edge-fade-end");
|
|
});
|
|
if (buttons.length < 2 || chips.clientWidth <= 0) return;
|
|
|
|
const viewport = chips.getBoundingClientRect();
|
|
const visible = buttons.map((button) => ({
|
|
button,
|
|
rect: button.getBoundingClientRect(),
|
|
})).filter(({ rect }) => {
|
|
return rect.right > viewport.left + 1 && rect.left < viewport.right - 1;
|
|
});
|
|
if (!visible.length) return;
|
|
|
|
const fadeSpan = 30;
|
|
const firstVisible = visible[0];
|
|
const lastVisible = visible[visible.length - 1];
|
|
if (firstVisible.rect.left < viewport.left - 1) {
|
|
const clippedAt = Math.min(100, Math.max(
|
|
0,
|
|
((viewport.left - firstVisible.rect.left) / firstVisible.rect.width) * 100,
|
|
));
|
|
firstVisible.button.style.setProperty("--v2-edge-fade-start", `${clippedAt}%`);
|
|
firstVisible.button.style.setProperty(
|
|
"--v2-edge-fade-end",
|
|
`${Math.min(100, clippedAt + fadeSpan)}%`,
|
|
);
|
|
firstVisible.button.classList.add("v2-edge-fade-left");
|
|
}
|
|
if (lastVisible.rect.right > viewport.right + 1) {
|
|
const clippedAt = Math.min(100, Math.max(
|
|
0,
|
|
((viewport.right - lastVisible.rect.left) / lastVisible.rect.width) * 100,
|
|
));
|
|
lastVisible.button.style.setProperty(
|
|
"--v2-edge-fade-start",
|
|
`${Math.max(0, clippedAt - fadeSpan)}%`,
|
|
);
|
|
lastVisible.button.style.setProperty("--v2-edge-fade-end", `${clippedAt}%`);
|
|
lastVisible.button.classList.add("v2-edge-fade-right");
|
|
}
|
|
}
|
|
|
|
function setControlMode(mode, { triggerGesture = true } = {}) {
|
|
ui.controlMode = mode === "gesture" ? "gesture" : "natural";
|
|
sessionStorage.setItem("cognitive-agent.control-mode", ui.controlMode);
|
|
document.body.dataset.controlMode = ui.controlMode;
|
|
const requestStatus = $(".v2-request-status");
|
|
const statusTarget = ui.controlMode === "gesture"
|
|
? $("#v2GestureExecutionSlot")
|
|
: $("#v2NaturalGrid");
|
|
if (requestStatus && statusTarget && requestStatus.parentElement !== statusTarget) {
|
|
statusTarget.append(requestStatus);
|
|
}
|
|
$$('[data-v2-control]').forEach((button) => {
|
|
const active = button.dataset.v2Control === ui.controlMode;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
});
|
|
requestAnimationFrame(updateModeEdgeHints);
|
|
const drawer = $("#gestureDrawer");
|
|
if (!triggerGesture) {
|
|
const gestureVisible = ui.controlMode === "gesture";
|
|
drawer?.setAttribute("aria-hidden", String(!gestureVisible));
|
|
$("#gestureToggleButton")?.setAttribute("aria-expanded", String(gestureVisible));
|
|
return;
|
|
}
|
|
if (ui.controlMode === "gesture") {
|
|
if (!drawer?.classList.contains("open")) $("#gestureToggleButton")?.click();
|
|
} else if (drawer?.classList.contains("open")) {
|
|
$("#gestureCloseButton")?.click();
|
|
}
|
|
}
|
|
|
|
function setModule(module, { persist = true } = {}) {
|
|
const allowed = new Set(["self-orchestration", "self-extension"]);
|
|
ui.activeModule = allowed.has(module) ? module : "self-orchestration";
|
|
if (persist) {
|
|
sessionStorage.setItem("cognitive-agent.active-module", ui.activeModule);
|
|
}
|
|
document.body.dataset.module = ui.activeModule;
|
|
$$('.v2-module-tab').forEach((button) => {
|
|
const active = button.dataset.module === ui.activeModule;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-selected", String(active));
|
|
button.tabIndex = active ? 0 : -1;
|
|
});
|
|
if (ui.activeModule === "self-extension") {
|
|
const drawer = $("#gestureDrawer");
|
|
if (drawer?.classList.contains("open")) $("#gestureCloseButton")?.click();
|
|
} else {
|
|
setControlMode(ui.controlMode, { triggerGesture: false });
|
|
}
|
|
window.dispatchEvent(new CustomEvent("cognitive-agent:module-change", {
|
|
detail: { module: ui.activeModule },
|
|
}));
|
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 120);
|
|
}
|
|
|
|
function setSidebarCollapsed(collapsed) {
|
|
ui.sidebarCollapsed = Boolean(collapsed);
|
|
sessionStorage.setItem(
|
|
"cognitive-agent.sidebar-collapsed",
|
|
String(ui.sidebarCollapsed),
|
|
);
|
|
document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed);
|
|
const button = $("#v2SidebarToggle");
|
|
if (button) {
|
|
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>`;
|
|
}
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180);
|
|
}
|
|
|
|
function requestUserSafeExecutionMode() {
|
|
const dryRunButton = $('[data-execution-mode="dry_run"]');
|
|
const note = $("#v2ExecutionNote");
|
|
const userTarget = $(".v2-user-target-status");
|
|
const userTargetText = $("#v2UserTargetText");
|
|
const runButton = $("#runButton");
|
|
if (!dryRunButton) return;
|
|
if (!dryRunButton.classList.contains("active")) dryRunButton.click();
|
|
if (note) note.textContent = "user 固定使用虚拟 CAN 安全执行环境";
|
|
if (userTarget) userTarget.dataset.state = "ready";
|
|
if (userTargetText) userTargetText.textContent = "执行目标:虚拟 CAN";
|
|
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
|
syncExecutionSelector();
|
|
}
|
|
|
|
function syncExecutionSelector() {
|
|
const select = $("#v2ExecutionSelect");
|
|
if (!select) return;
|
|
const active = $('[data-execution-mode].active');
|
|
if (active) select.value = active.dataset.executionMode;
|
|
["dry_run", "simulated_hardware", "hardware"].forEach((value) => {
|
|
const option = $(`option[value="${value}"]`, select);
|
|
const button = $(`[data-execution-mode="${value}"]`);
|
|
if (option && button) option.disabled = button.disabled;
|
|
});
|
|
dropdownControls.get(select)?.sync();
|
|
}
|
|
|
|
function syncRuntimePresentation() {
|
|
const modelBadge = $("#modelBadge");
|
|
const connectionBadge = $("#connectionBadge");
|
|
const modelValue = $("#v2ModelValue");
|
|
const modelNote = $("#v2ModelNote");
|
|
const connectionText = $("#v2ConnectionText");
|
|
if (modelBadge && modelValue) {
|
|
const text = modelBadge.textContent.trim() || "模型检测中";
|
|
modelValue.textContent = text;
|
|
const unavailable = modelBadge.classList.contains("danger");
|
|
$("#v2ModelField")?.classList.toggle("unavailable", unavailable);
|
|
$("#v2ModelState").textContent = unavailable ? "LOCAL" : "READY";
|
|
if (modelNote) modelNote.textContent = unavailable
|
|
? "GPT 不可用,自动使用本地规则"
|
|
: "服务端安全托管 · 失败自动降级";
|
|
}
|
|
if (connectionBadge && connectionText) {
|
|
connectionText.textContent = connectionBadge.textContent.trim() || "正在连接服务";
|
|
const online = !connectionBadge.classList.contains("danger")
|
|
&& !connectionBadge.classList.contains("warning");
|
|
$(".v2-sidebar-footer")?.classList.toggle("offline", !online);
|
|
}
|
|
syncExecutionSelector();
|
|
requestUserSafeExecutionMode();
|
|
}
|
|
|
|
function setVehicleLinkState(connected) {
|
|
const card = $("#v2VehicleCard");
|
|
const state = $("#v2VehicleState");
|
|
if (!card || !state) return;
|
|
card.dataset.state = connected ? "online" : "offline";
|
|
state.textContent = connected ? "READY" : "OFFLINE";
|
|
}
|
|
|
|
async function refreshVehicleLinkState() {
|
|
const card = $("#v2VehicleCard");
|
|
const state = $("#v2VehicleState");
|
|
if (card) card.dataset.state = "checking";
|
|
if (state) state.textContent = "CHECK";
|
|
try {
|
|
const response = await fetch("/api/extension/health", {
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const health = await response.json();
|
|
setVehicleLinkState(Boolean(health.vehicle?.connected));
|
|
} catch (_error) {
|
|
setVehicleLinkState(false);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
$$('[data-v2-control]').forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
setControlMode(button.dataset.v2Control);
|
|
});
|
|
});
|
|
$$('.v2-module-tab').forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (!button.disabled) setModule(button.dataset.module);
|
|
});
|
|
});
|
|
$("#v2ExecutionSelect")?.addEventListener("change", (event) => {
|
|
$(`[data-execution-mode="${event.target.value}"]`)?.click();
|
|
syncExecutionSelector();
|
|
});
|
|
$$(".v2-page-button").forEach((button) => {
|
|
button.addEventListener("click", () => setPage(button.dataset.v2PageTarget));
|
|
});
|
|
$("#v2LogoutButton")?.addEventListener("click", () => $("#logoutButton")?.click());
|
|
window.addEventListener("cognitive-agent:connection", (event) => {
|
|
if (event.detail?.status === "online") refreshVehicleLinkState();
|
|
else if (["offline", "degraded"].includes(event.detail?.status)) setVehicleLinkState(false);
|
|
});
|
|
$("#v2SidebarToggle")?.addEventListener(
|
|
"click",
|
|
() => setSidebarCollapsed(!ui.sidebarCollapsed),
|
|
);
|
|
document.addEventListener("click", () => {
|
|
closeCustomDropdowns();
|
|
closeReasoningPicker();
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
closeCustomDropdowns();
|
|
closeReasoningPicker();
|
|
clearTwinDevicePreview({ force: true });
|
|
}
|
|
});
|
|
window.addEventListener("resize", updateScrollCues);
|
|
window.addEventListener("resize", updateModeEdgeHints);
|
|
window.addEventListener("cognitive-agent:result", () => requestAnimationFrame(updateScrollCues));
|
|
const modeChips = $(".v2-mode-rail .mode-chips");
|
|
modeChips?.addEventListener(
|
|
"scroll",
|
|
() => requestAnimationFrame(updateModeEdgeHints),
|
|
{ passive: true },
|
|
);
|
|
modeChips?.addEventListener("wheel", (event) => {
|
|
if (modeChips.scrollWidth <= modeChips.clientWidth + 2) return;
|
|
const distance = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
|
? event.deltaX
|
|
: event.deltaY;
|
|
if (!distance) return;
|
|
event.preventDefault();
|
|
modeChips.scrollBy({ left: distance, behavior: "smooth" });
|
|
window.requestAnimationFrame(updateModeEdgeHints);
|
|
}, { passive: false });
|
|
if (modeChips) {
|
|
new MutationObserver(() => requestAnimationFrame(updateModeEdgeHints))
|
|
.observe(modeChips, { childList: true });
|
|
}
|
|
|
|
const observer = new MutationObserver(syncRuntimePresentation);
|
|
[$("#modelBadge"), $("#connectionBadge"), $("#hardwareMode"), $("#simulationMode")]
|
|
.filter(Boolean)
|
|
.forEach((node) => observer.observe(node, {
|
|
attributes: true,
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
}));
|
|
}
|
|
|
|
function init() {
|
|
const workspace = $(".workspace");
|
|
const commandPanel = $(".command-panel");
|
|
const mainPanel = $(".main-panel");
|
|
const originalForm = $("#requestForm");
|
|
if (!workspace || !commandPanel || !mainPanel || !originalForm) return;
|
|
|
|
document.body.classList.add("console-v2");
|
|
const panelHeading = $(".panel-heading", commandPanel);
|
|
const modeRail = $(".mode-rail", commandPanel);
|
|
const exampleGroup = $(".example-group", commandPanel);
|
|
const safetyStrip = $("#safetyStrip", commandPanel);
|
|
const requestHeader = $(".request-header", mainPanel);
|
|
const gestureDrawer = $("#gestureDrawer");
|
|
|
|
const detached = {
|
|
panelHeading,
|
|
modeRail,
|
|
requestHeader,
|
|
gestureDrawer,
|
|
originalForm,
|
|
exampleGroup,
|
|
safetyStrip,
|
|
};
|
|
Object.values(detached).filter(Boolean).forEach((node) => node.remove());
|
|
|
|
const legacyParking = make("div", "v2-legacy-parking v2-legacy-hidden", "");
|
|
[exampleGroup, safetyStrip].filter(Boolean).forEach((node) => legacyParking.append(node));
|
|
document.body.append(legacyParking);
|
|
|
|
const configStack = buildSidebar(commandPanel);
|
|
buildConfigFields(configStack, originalForm);
|
|
enhanceConfiguredDropdowns();
|
|
buildOperation(
|
|
mainPanel,
|
|
originalForm,
|
|
panelHeading,
|
|
modeRail,
|
|
requestHeader,
|
|
gestureDrawer,
|
|
);
|
|
buildModuleSwitcher(mainPanel);
|
|
$(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden");
|
|
$(".topbar")?.classList.add("v2-legacy-hidden");
|
|
$(".gesture-backdrop")?.classList.add("v2-legacy-hidden");
|
|
buildOutput(mainPanel);
|
|
buildSyncControlLayer();
|
|
bindEvents();
|
|
setControlMode(ui.controlMode, { triggerGesture: false });
|
|
setModule(ui.activeModule, { persist: false });
|
|
setSidebarCollapsed(ui.sidebarCollapsed);
|
|
setPage(0);
|
|
syncRuntimePresentation();
|
|
refreshVehicleLinkState();
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
requestAnimationFrame(() => {
|
|
updateModeEdgeHints();
|
|
document.body.classList.add("v2-ready");
|
|
});
|
|
}
|
|
|
|
window.CognitiveAgentConsoleV2 = {
|
|
enhanceSelect,
|
|
getModule: () => ui.activeModule,
|
|
getRole: () => "user",
|
|
setModule,
|
|
};
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
}());
|