3006 lines
133 KiB
JavaScript
3006 lines
133 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: true,
|
||
};
|
||
|
||
let reasoningPickerControl = null;
|
||
let validatedCommandsVisible = false;
|
||
let validatedCommandContext = { requestId: "", utterance: "" };
|
||
let pendingSpeechTiming = null;
|
||
let commandTimingState = {
|
||
inputMode: "text",
|
||
utterance: "",
|
||
requestId: "",
|
||
requestStartedAt: null,
|
||
validatedAt: null,
|
||
displayedAt: null,
|
||
renderDurationMs: null,
|
||
speechStartedAt: null,
|
||
speechResultAt: null,
|
||
speechTimings: null,
|
||
};
|
||
|
||
function resetValidatedCommandContext() {
|
||
validatedCommandsVisible = false;
|
||
validatedCommandContext = { requestId: "", utterance: "" };
|
||
}
|
||
|
||
function commandCorrelationIds(result = {}) {
|
||
return [...new Set([
|
||
result.preview_request_id,
|
||
result.supplement_of,
|
||
result.request_id,
|
||
].map((value) => String(value || "").trim()).filter(Boolean))];
|
||
}
|
||
|
||
function rememberValidatedCommandContext(result = {}) {
|
||
const ids = commandCorrelationIds(result);
|
||
validatedCommandContext = {
|
||
requestId: ids[0] || "",
|
||
utterance: String(result.utterance || "").trim(),
|
||
};
|
||
}
|
||
|
||
function validatedCommandsBelongToTask(task = {}) {
|
||
if (!validatedCommandsVisible) return false;
|
||
const result = task.result && typeof task.result === "object" ? task.result : {};
|
||
const requestIds = commandCorrelationIds(result);
|
||
const utterance = String(task.utterance || result.utterance || "").trim();
|
||
if (validatedCommandContext.requestId && requestIds.length
|
||
&& !requestIds.includes(validatedCommandContext.requestId)) return false;
|
||
if (validatedCommandContext.utterance && utterance
|
||
&& validatedCommandContext.utterance !== utterance
|
||
&& !validatedCommandContext.utterance.includes(utterance)
|
||
&& !utterance.includes(validatedCommandContext.utterance)) return false;
|
||
return true;
|
||
}
|
||
|
||
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("'", "'");
|
||
}
|
||
|
||
const normalizedCanTarget = (target = {}) => String(
|
||
target.zone || target.instance || target.device || "vehicle",
|
||
).replaceAll("_", "-");
|
||
|
||
const normalizedOnOff = (value) => {
|
||
if ([true, 1, "1", "on", "open", "enabled"].includes(value)) return "on";
|
||
if ([false, 0, "0", "off", "close", "disabled"].includes(value)) return "off";
|
||
return null;
|
||
};
|
||
|
||
const boundedInteger = (value, minimum, maximum) => {
|
||
const numeric = Number(value);
|
||
return Number.isInteger(numeric) && numeric >= minimum && numeric <= maximum
|
||
? numeric
|
||
: null;
|
||
};
|
||
|
||
const monotonicNow = () => (
|
||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||
? performance.now()
|
||
: Date.now()
|
||
);
|
||
|
||
const finiteMilliseconds = (value) => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
||
};
|
||
|
||
const semanticValue = (value) => {
|
||
if (value == null) return "null";
|
||
if (["string", "number", "boolean"].includes(typeof value)) return String(value);
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch (_error) {
|
||
return String(value);
|
||
}
|
||
};
|
||
|
||
function semanticControlInstruction(operation = {}) {
|
||
const capability = String(operation.capability || "unknown");
|
||
const rawTarget = operation.target || {};
|
||
const target = rawTarget.zone || rawTarget.instance || rawTarget.device;
|
||
const argumentsList = [];
|
||
if (target != null && String(target).trim()) {
|
||
argumentsList.push(`target=${String(target).replaceAll("_", "-")}`);
|
||
}
|
||
Object.entries(operation.parameters || {})
|
||
.sort(([left], [right]) => left.localeCompare(right))
|
||
.forEach(([key, value]) => {
|
||
argumentsList.push(`${key}=${semanticValue(value)}`);
|
||
});
|
||
return `${capability}(${argumentsList.join(", ")})`;
|
||
}
|
||
|
||
function commandPlanEntries(result = {}) {
|
||
const plans = result.command_plans && typeof result.command_plans === "object"
|
||
? Object.entries(result.command_plans)
|
||
: [];
|
||
if (!plans.length && result.command_plan) plans.push(["default", result.command_plan]);
|
||
return plans;
|
||
}
|
||
|
||
function compiledCommandsForOperation(result, configKey, operation = {}) {
|
||
const operationId = String(operation.id || "");
|
||
if (!operationId) return [];
|
||
const plans = commandPlanEntries(result);
|
||
const preferred = plans.filter(([key]) => key === configKey);
|
||
const operationRefs = (command) => {
|
||
const raw = command?.operation_ref;
|
||
const refs = Array.isArray(raw) ? raw : String(raw || "").split(",");
|
||
return refs.map((ref) => String(ref).trim()).filter(Boolean);
|
||
};
|
||
const matches = (entries) => entries.flatMap(([planKey, plan]) => (
|
||
(plan?.spec?.commands || [])
|
||
.filter((command) => operationRefs(command).includes(operationId))
|
||
.map((command) => ({ command, plan, planKey }))
|
||
));
|
||
if (preferred.length) return matches(preferred);
|
||
const configurations = result.function_configs && typeof result.function_configs === "object"
|
||
? Object.entries(result.function_configs)
|
||
: result.function_config ? [["default", result.function_config]] : [];
|
||
return configurations.length === 1 && plans.length === 1 ? matches(plans) : [];
|
||
}
|
||
|
||
function compiledCommandPresentation(entries, fallback = {}) {
|
||
if (!entries.length) return fallback;
|
||
const actualCommands = [];
|
||
const frames = [];
|
||
let channel = fallback.channel || "CAN1";
|
||
entries.forEach(({ command, plan }) => {
|
||
const signals = command?.signals || {};
|
||
const message = String(command?.message || "");
|
||
const planDomain = String(plan?.spec?.domain || "");
|
||
const bus = String(command?.bus || "").toLowerCase();
|
||
const slot = boundedInteger(signals.slot, 1, 248);
|
||
if (message === "infrared.emit" || planDomain === "self_extension") {
|
||
channel = "IR";
|
||
} else if (bus.includes("seat") || bus.includes("can0")) {
|
||
channel = "CAN0";
|
||
} else if (bus.includes("body") || bus.includes("can1")) {
|
||
channel = "CAN1";
|
||
}
|
||
const actual = String(signals.debug_command || "").trim()
|
||
|| (message === "infrared.emit" && slot !== null ? `infrared emit ${slot}` : "")
|
||
|| String(fallback.command || message).trim();
|
||
if (actual && !actualCommands.includes(actual)) actualCommands.push(actual);
|
||
const frameId = String(signals.frame_id || "").trim();
|
||
const dataHex = String(signals.data_hex || "").trim();
|
||
if (frameId && dataHex) {
|
||
frames.push({ label: `${channel} 帧`, value: `${frameId} · ${dataHex}` });
|
||
} else if (message === "infrared.emit" && slot !== null) {
|
||
frames.push({
|
||
label: "GSCU1B TCP",
|
||
value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
|
||
});
|
||
}
|
||
});
|
||
const uniqueFrames = frames.filter((frame, index) => (
|
||
frames.findIndex((candidate) => (
|
||
candidate.label === frame.label && candidate.value === frame.value
|
||
)) === index
|
||
));
|
||
return {
|
||
...fallback,
|
||
channel,
|
||
command: actualCommands.join(";") || fallback.command || "",
|
||
...(uniqueFrames.length ? {
|
||
frame: uniqueFrames[0].value,
|
||
frameLabel: uniqueFrames[0].label,
|
||
frames: uniqueFrames,
|
||
} : {}),
|
||
};
|
||
}
|
||
|
||
function parsedCanCommandForOperation(operation = {}) {
|
||
const capability = String(operation.capability || "");
|
||
const parameters = operation.parameters || {};
|
||
const target = normalizedCanTarget(operation.target);
|
||
const onOff = normalizedOnOff(parameters.state ?? parameters.power);
|
||
const hyphenated = (value) => String(value || "").replaceAll("_", "-");
|
||
const parseSource = String(operation.parse_source || "");
|
||
const command = (category, text, channel = "CAN1", frame = "") => ({
|
||
channel,
|
||
category,
|
||
command: text,
|
||
...(frame ? { frame } : {}),
|
||
...(parseSource ? { parseSource } : {}),
|
||
});
|
||
const hexByte = (value) => Number(value).toString(16).padStart(2, "0").toUpperCase();
|
||
const littleEndian16 = (value) => {
|
||
const numeric = Number(value) & 0xFFFF;
|
||
return `${hexByte(numeric & 0xFF)} ${hexByte((numeric >>> 8) & 0xFF)}`;
|
||
};
|
||
const nativeCan0Frame = (opcode, motor, direction, paramA, paramB) => {
|
||
const control = (Number(motor) & 0x03) | ((Number(direction) & 0x03) << 2) | 0x10;
|
||
return `0x300 · ${hexByte(opcode)} ${hexByte(control)} <SEQ_LO> <SEQ_HI> ${littleEndian16(paramA)} ${littleEndian16(paramB)}`;
|
||
};
|
||
|
||
if (capability === "infrared.emit") {
|
||
const slot = boundedInteger(parameters.slot, 1, 248);
|
||
const action = String(parameters.action || "红外动作").trim();
|
||
const deviceName = String(operation.target?.name || operation.target?.device || "红外外设").trim();
|
||
if (slot !== null) {
|
||
return {
|
||
channel: "IR",
|
||
category: `${deviceName} · ${action}`,
|
||
command: `infrared emit ${slot}`,
|
||
frame: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
|
||
frameLabel: "GSCU1B TCP",
|
||
...(parseSource ? { parseSource } : {}),
|
||
};
|
||
}
|
||
}
|
||
|
||
const isPassengerSeat = new Set(["passenger", "front-right"]).has(target);
|
||
if (isPassengerSeat) {
|
||
const passengerAxes = {
|
||
"seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
|
||
"seat.set_cushion_tilt": ["副驾座椅坐盆调节", "basin", 1],
|
||
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
|
||
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
|
||
};
|
||
const axis = passengerAxes[capability];
|
||
const position = boundedInteger(parameters.position_pct, 0, 100);
|
||
if (axis && position !== null) {
|
||
const motion = operation.motion || {};
|
||
const delta = boundedInteger(motion.delta_position_pct, 1, 100);
|
||
const relativeDirections = {
|
||
horizontal: { increase: "backward", decrease: "forward" },
|
||
basin: { increase: "down", decrease: "up" },
|
||
backrest: { increase: "backward", decrease: "forward" },
|
||
legrest: { increase: "up", decrease: "down" },
|
||
};
|
||
const relativeDirection = relativeDirections[axis[1]]?.[motion.direction];
|
||
return command(
|
||
axis[0],
|
||
motion.mode === "relative" && relativeDirection && delta !== null
|
||
? `seat passenger ${axis[1]} relative ${relativeDirection} ${delta}`
|
||
: `seat passenger ${axis[1]} position ${position}`,
|
||
"CAN0",
|
||
position === 0
|
||
? nativeCan0Frame(0x0B, axis[2], 0, 0x4B52, 120)
|
||
: nativeCan0Frame(0x01, axis[2], 0, position * 100, 30000),
|
||
);
|
||
}
|
||
if (new Set(["seat.set_ventilation", "seat.set_heating"]).has(capability)) {
|
||
const feature = capability === "seat.set_heating" ? "heat" : "vent";
|
||
const level = boundedInteger(parameters.level, 1, 3);
|
||
const setting = onOff === "off"
|
||
? "off"
|
||
: ({ 1: "low", 2: "medium", 3: "high" })[level];
|
||
if (setting) {
|
||
return command(
|
||
"副驾座椅通风/加热",
|
||
`seat passenger ${feature} ${setting}`,
|
||
"CAN0",
|
||
);
|
||
}
|
||
}
|
||
if (capability === "seat.start_dynamic_mode") {
|
||
const mode = boundedInteger(parameters.mode_id, 1, 2);
|
||
if (mode !== null) {
|
||
const dynamic = mode === 1 ? "rocking-horse" : "cradle";
|
||
return command(
|
||
"副驾座椅动态模式",
|
||
`seat passenger dynamic ${dynamic}`,
|
||
"CAN0",
|
||
nativeCan0Frame(0x0E, 0, 0, mode, 0),
|
||
);
|
||
}
|
||
}
|
||
if (capability === "seat.stop_dynamic_mode") {
|
||
return command(
|
||
"副驾座椅动态模式",
|
||
"seat passenger dynamic stop",
|
||
"CAN0",
|
||
nativeCan0Frame(0x0F, 0, 0, 0, 0),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (capability === "door.set_rear_lock") {
|
||
const lockState = String(parameters.state || "");
|
||
const action = new Set(["lock", "unlock"]).has(lockState)
|
||
? lockState
|
||
: onOff === "on" ? "lock" : onOff === "off" ? "unlock" : null;
|
||
if (action) return command("后排门锁", `door rear ${action}`);
|
||
}
|
||
if (capability === "door.set_vehicle_lock") {
|
||
const lockState = String(parameters.state || "");
|
||
if (new Set(["lock", "unlock"]).has(lockState)) {
|
||
return command("整车门锁", `door vehicle ${lockState}`);
|
||
}
|
||
}
|
||
if (capability === "window.set_motion") {
|
||
const scope = new Set(["all", "front", "rear", "front-left", "front-right", "rear-left", "rear-right"]);
|
||
const motion = String(parameters.motion || parameters.action || "");
|
||
if (scope.has(target) && new Set(["open", "close", "stop"]).has(motion)) {
|
||
return command("车窗动作", `window ${target} ${motion}`);
|
||
}
|
||
}
|
||
if (capability === "window.set_position") {
|
||
const position = boundedInteger(parameters.position_pct, 0, 100);
|
||
if (new Set(["front-left", "front-right", "rear-left", "rear-right"]).has(target)
|
||
&& position !== null) {
|
||
return command("单窗位置", `window ${target} position ${position}`);
|
||
}
|
||
}
|
||
if (capability === "climate.set_power" && onOff) {
|
||
return command("空调电源", `ac ${onOff}`);
|
||
}
|
||
if (capability === "climate.set_fan_level") {
|
||
const level = boundedInteger(parameters.level ?? parameters.fan_level, 1, 7);
|
||
if (level !== null) return command("空调风量", `ac fan ${level}`);
|
||
}
|
||
if (capability === "climate.set_outlet_mode") {
|
||
const mode = hyphenated(parameters.mode ?? parameters.outlet_mode);
|
||
const modes = new Set(["face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"]);
|
||
if (modes.has(mode)) return command("出风模式", `ac mode ${mode}`);
|
||
}
|
||
if (capability === "climate.set_temperature") {
|
||
const temperature = boundedInteger(parameters.temperature_c, 18, 32);
|
||
if (new Set(["driver", "passenger", "rear"]).has(target) && temperature !== null) {
|
||
return command("空调温度", `ac temp ${target} ${temperature}`);
|
||
}
|
||
const vehicleZones = new Set([
|
||
"vehicle",
|
||
"front-row",
|
||
"rear-row",
|
||
"rear-left",
|
||
"rear-right",
|
||
]);
|
||
if (vehicleZones.has(target) && temperature !== null) {
|
||
return command(
|
||
"空调温度",
|
||
`Climate_Control_064_04(${target}=${temperature}°C)`,
|
||
);
|
||
}
|
||
}
|
||
if (capability === "climate.set_control_mode") {
|
||
const mode = String(parameters.mode ?? parameters.control_mode ?? "");
|
||
if (new Set(["manual", "auto"]).has(mode)) return command("空调控制方式", `ac control ${mode}`);
|
||
}
|
||
if (capability === "climate.set_cycle_mode") {
|
||
const mode = String(parameters.mode ?? parameters.cycle_mode ?? "");
|
||
if (new Set(["internal", "external"]).has(mode)) return command("空调循环", `ac cycle ${mode}`);
|
||
}
|
||
if (capability === "outlet.set_switch_state") {
|
||
const state = String(parameters.state ?? "");
|
||
if (new Set(["driver-left", "driver-right", "passenger-left", "passenger-right"]).has(target)
|
||
&& new Set(["open", "close"]).has(state)) {
|
||
return command("单风口开关", `outlet ${target} ${state}`);
|
||
}
|
||
}
|
||
if (capability === "outlet.set_position_percent") {
|
||
const horizontal = boundedInteger(parameters.horizontal_pct, 0, 100);
|
||
const vertical = boundedInteger(parameters.vertical_pct, 0, 100);
|
||
if (new Set(["driver-left", "driver-right", "passenger-left", "passenger-right"]).has(target)
|
||
&& horizontal !== null && vertical !== null) {
|
||
return command("风口百分比", `outlet ${target} percent ${horizontal} ${vertical}`);
|
||
}
|
||
}
|
||
if (capability === "outlet.set_calibrated_position") {
|
||
const horizontal = boundedInteger(parameters.horizontal_value, -128, 126);
|
||
const vertical = boundedInteger(parameters.vertical_value, -128, 126);
|
||
if (new Set(["driver-left", "driver-right", "passenger-left", "passenger-right"]).has(target)
|
||
&& horizontal !== null && vertical !== null) {
|
||
return command("风口标定坐标", `outlet ${target} position ${horizontal} ${vertical}`);
|
||
}
|
||
}
|
||
if (new Set(["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"]).has(capability)
|
||
&& new Set(["driver", "front-left"]).has(target)) {
|
||
const feature = capability.includes("heating") ? "heat" : "vent";
|
||
const level = boundedInteger(parameters.level, 1, 2);
|
||
const setting = onOff === "off" ? "off" : level === 1 ? "high" : level === 2 ? "low" : null;
|
||
if (setting) return command("主驾座椅通风/加热", `seat driver ${feature} ${setting}`);
|
||
}
|
||
if (capability === "seat.set_driver_massage_native"
|
||
&& new Set(["driver", "front-left"]).has(target)) {
|
||
const action = String(parameters.action || "");
|
||
if (action === "off") return command("主驾座椅按摩", "seat driver massage off");
|
||
if (action === "strength") {
|
||
const strength = { 1: "weak", 2: "medium", 3: "strong" }[Number(parameters.strength)]
|
||
|| (new Set(["weak", "medium", "strong"]).has(parameters.strength) ? parameters.strength : null);
|
||
if (strength) return command("主驾座椅按摩", `seat driver massage strength ${strength}`);
|
||
}
|
||
if (action === "mode" && new Set(["wave", "pulse", "stretch", "slack", "relax"]).has(parameters.mode)) {
|
||
return command("主驾座椅按摩", `seat driver massage mode ${parameters.mode}`);
|
||
}
|
||
}
|
||
if (capability === "seat.set_driver_axis_native"
|
||
&& new Set(["driver", "front-left"]).has(target)) {
|
||
const axis = String(parameters.axis || "");
|
||
const direction = String(parameters.direction || "");
|
||
const validDirections = {
|
||
horizontal: new Set(["forward", "backward", "stop"]),
|
||
height: new Set(["up", "down", "stop"]),
|
||
backrest: new Set(["forward", "backward", "stop"]),
|
||
basin: new Set(["up", "down", "stop"]),
|
||
legrest: new Set(["up", "down", "forward", "backward", "stop"]),
|
||
};
|
||
if (validDirections[axis]?.has(direction)) {
|
||
const axisCategory = {
|
||
horizontal: "主驾座椅前后调节",
|
||
height: "主驾座椅坐盆高度调节",
|
||
basin: "主驾座椅坐盆调节",
|
||
backrest: "主驾座椅靠背调节",
|
||
legrest: "主驾座椅腿托调节",
|
||
}[axis] || "主驾座椅调节";
|
||
if (direction === "stop") return command(axisCategory, `seat driver ${axis} stop`);
|
||
const duration = boundedInteger(parameters.duration_ms, 100, 2000);
|
||
if (duration !== null) return command(axisCategory, `seat driver ${axis} ${direction} ${duration}`);
|
||
}
|
||
}
|
||
if (capability === "seat.set_driver_position_native") {
|
||
const position = boundedInteger(parameters.position ?? parameters.slot, 1, 3);
|
||
if (position !== null) return command("主驾座椅记忆", `seat driver position ${position}`);
|
||
}
|
||
if (capability === "seat.recall_driver_memory_native") {
|
||
const position = boundedInteger(parameters.position ?? parameters.slot, 1, 3);
|
||
if (position !== null) return command("主驾座椅记忆", `seat driver memory recall ${position}`);
|
||
}
|
||
if (capability === "interior_light.set_indoor_power" && onOff) {
|
||
return command("室内灯总开关", `light indoor ${onOff}`);
|
||
}
|
||
if (capability === "interior_light.set_reading_power" && target === "all" && onOff) {
|
||
return command("全部阅读灯", `light reading all ${onOff}`);
|
||
}
|
||
if (capability === "ambient_light.set_power" && onOff) {
|
||
return command("氛围灯", `light ambient ${onOff}`);
|
||
}
|
||
if (capability === "ambient_light.set_color_index") {
|
||
const color = boundedInteger(parameters.color_index, 1, 127);
|
||
if (color !== null) return command("氛围灯", `light ambient color ${color}`);
|
||
}
|
||
if (capability === "ambient_light.set_brightness_level") {
|
||
const level = boundedInteger(parameters.brightness_level, 1, 5);
|
||
if (level !== null) return command("氛围灯", `light ambient brightness ${level}`);
|
||
}
|
||
if (capability === "ambient_light.set_illumination") {
|
||
const color = String(parameters.color_hex || "").trim().toUpperCase();
|
||
const brightness = boundedInteger(parameters.brightness_pct, 0, 100);
|
||
if (/^#[0-9A-F]{6}$/.test(color) && brightness !== null) {
|
||
return command(
|
||
"氛围灯颜色与亮度",
|
||
`Ambient_RGB_Control_32B_2B(color=${color}, brightness=${brightness}%)`,
|
||
);
|
||
}
|
||
}
|
||
if (capability === "ambient_light.set_mode") {
|
||
const mode = String(parameters.mode || "").trim();
|
||
if (new Set(["camping", "rest", "cinema", "meditation", "romantic", "off", "on"]).has(mode)) {
|
||
return command("氛围灯模式", `Ambient_Mode_Control_496_04(mode=${mode})`);
|
||
}
|
||
}
|
||
if (capability === "roof.set_shade_motion") {
|
||
const motion = String(parameters.motion || "");
|
||
if (new Set(["open", "close", "half", "stop"]).has(motion)) {
|
||
return command("遮阳帘", `shade ${motion}`);
|
||
}
|
||
}
|
||
if (capability === "roof.set_shade_position") {
|
||
const position = boundedInteger(parameters.position_pct ?? parameters.shade_position_pct, 0, 100);
|
||
if (position !== null) return command("遮阳帘", `shade position ${position}`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parsedCanCommands(result = {}) {
|
||
const configurations = result.function_configs || {};
|
||
const configurationEntries = Object.entries(configurations);
|
||
if (!configurationEntries.length && result.function_config) {
|
||
configurationEntries.push(["default", result.function_config]);
|
||
}
|
||
const executionResults = [
|
||
...(result.execution_report?.spec?.results || []),
|
||
...(result.operation_results || []),
|
||
...Object.values(result.phase_execution_reports || {}).flatMap(
|
||
(report) => report?.spec?.results || [],
|
||
),
|
||
];
|
||
const executionByRef = new Map();
|
||
executionResults.forEach((execution) => {
|
||
const ref = String(execution?.ref || "");
|
||
if (!ref) return;
|
||
const previous = executionByRef.get(ref);
|
||
if (!previous || (!previous.telemetry && execution?.telemetry)) {
|
||
executionByRef.set(ref, execution);
|
||
}
|
||
});
|
||
const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]);
|
||
const abortedStates = new Set(["aborted", "cancelled", "canceled", "skipped"]);
|
||
const deliveryForOperation = (operation) => {
|
||
const capability = String(operation?.capability || "");
|
||
const operationId = String(operation?.id || "");
|
||
const execution = executionByRef.get(operationId) || executionResults.find((candidate) => {
|
||
const ref = String(candidate?.ref || "");
|
||
return ref && operationId && (
|
||
operationId.startsWith(`${ref}-`) || ref.startsWith(`${operationId}-`)
|
||
);
|
||
});
|
||
const telemetry = execution?.telemetry || {};
|
||
const mqttMessages = Array.isArray(telemetry.mqtt_messages) ? telemetry.mqtt_messages : [];
|
||
const deliveryStates = [
|
||
telemetry.delivery_status,
|
||
...mqttMessages.map((message) => message?.delivery_status),
|
||
].map((value) => String(value || "").toLowerCase());
|
||
const submitted = result.execution_mode === "hardware"
|
||
&& telemetry.simulated !== true
|
||
&& (
|
||
deliveryStates.some((value) => submittedStates.has(value))
|
||
|| (Array.isArray(telemetry.sent_frames) && telemetry.sent_frames.length > 0)
|
||
|| telemetry.gateway_acknowledged === true
|
||
|| telemetry.feedback_observed === true
|
||
|| telemetry.physical_effect_confirmed === true
|
||
);
|
||
const executionStatus = String(execution?.status || "").toLowerCase();
|
||
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
|
||
const failureMessage = String(execution?.message || "").trim();
|
||
const aborted = abortedStates.has(executionStatus);
|
||
const executionFailed = executionStatus === "failed"
|
||
|| verificationStatus === "failed_or_blocked";
|
||
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
|
||
/no fresh valid/i.test(failureMessage)
|
||
|| /未收到.*(?:有效|新鲜).*车窗位置/.test(failureMessage)
|
||
);
|
||
const missingShadeFeedback = /0x41a/i.test(failureMessage) && (
|
||
/no fresh valid/i.test(failureMessage)
|
||
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
|
||
);
|
||
const safetyBlocked = executionFailed && !submitted && (
|
||
missingWindowFeedback
|
||
|| missingShadeFeedback
|
||
|| /refusing (?:blind )?motion/i.test(failureMessage)
|
||
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
|
||
|| /thermal protection|热保护/i.test(failureMessage)
|
||
);
|
||
const dispatchFailed = executionFailed && !submitted && !safetyBlocked;
|
||
const verified = !executionFailed && !aborted && (
|
||
verificationStatus === "verified_success"
|
||
|| telemetry.physical_effect_confirmed === true
|
||
|| telemetry.feedback_match === true
|
||
);
|
||
const deliveryState = aborted
|
||
? "aborted"
|
||
: safetyBlocked
|
||
? "blocked"
|
||
: executionFailed
|
||
? "failed"
|
||
: submitted
|
||
? "submitted"
|
||
: "not_submitted";
|
||
const deliveryLabel = aborted
|
||
? "已中止"
|
||
: safetyBlocked
|
||
? "安全阻止"
|
||
: dispatchFailed
|
||
? "下发失败"
|
||
: executionFailed
|
||
? "执行失败"
|
||
: verified
|
||
? "执行已确认"
|
||
: submitted
|
||
? "已下发 · 待状态确认"
|
||
: "未下发";
|
||
const deliveryDetail = missingWindowFeedback
|
||
? "未收到车窗 CAN1 / 0x388 实时位置,系统已阻止车窗盲动"
|
||
: missingShadeFeedback
|
||
? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动"
|
||
: failureMessage;
|
||
const transportLabel = capability === "infrared.emit"
|
||
? "红外 TCP"
|
||
: result.control_transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN";
|
||
return {
|
||
deliveryState,
|
||
deliveryLabel,
|
||
deliveryTransport: transportLabel,
|
||
...(deliveryDetail ? { deliveryDetail } : {}),
|
||
};
|
||
};
|
||
const commands = [];
|
||
const validatedControlCommands = Array.isArray(result.validated_control_commands)
|
||
? result.validated_control_commands
|
||
: [];
|
||
if (validatedControlCommands.length) {
|
||
validatedControlCommands.forEach((presentation) => {
|
||
const operation = presentation?.operation || {};
|
||
const fallback = parsedCanCommandForOperation(operation) || {
|
||
channel: presentation?.domain === "self_extension" ? "IR" : "CAN1",
|
||
category: String(operation.capability || "控制指令"),
|
||
command: "",
|
||
...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}),
|
||
};
|
||
const channel = new Set(["CAN0", "CAN1", "IR"]).has(presentation?.channel)
|
||
? presentation.channel
|
||
: fallback.channel;
|
||
const frames = (Array.isArray(presentation?.frames) ? presentation.frames : [])
|
||
.map((frame) => ({
|
||
label: String(frame?.label || "控制帧"),
|
||
value: String(frame?.value || ""),
|
||
}))
|
||
.filter((frame) => frame.value);
|
||
const command = String(presentation?.actual_command || fallback.command || "").trim();
|
||
if (!command) return;
|
||
const delivery = deliveryForOperation(operation);
|
||
const explicitDeliveryState = String(presentation?.delivery_state || "").trim();
|
||
const explicitDeliveryLabel = String(presentation?.delivery_label || "").trim();
|
||
const explicitDeliveryDetail = String(presentation?.delivery_detail || "").trim();
|
||
const explicitDeliveryTransport = String(presentation?.delivery_transport || "").trim();
|
||
commands.push({
|
||
...fallback,
|
||
channel,
|
||
command,
|
||
category: String(presentation?.category || fallback.category || "控制指令"),
|
||
...(frames.length ? {
|
||
frame: frames[0].value,
|
||
frameLabel: frames[0].label,
|
||
frames,
|
||
} : {}),
|
||
semantic: String(
|
||
presentation?.semantic_instruction || semanticControlInstruction(operation),
|
||
),
|
||
operationRef: String(operation.id || ""),
|
||
...delivery,
|
||
...(explicitDeliveryState ? { deliveryState: explicitDeliveryState } : {}),
|
||
...(explicitDeliveryLabel ? { deliveryLabel: explicitDeliveryLabel } : {}),
|
||
...(explicitDeliveryDetail ? { deliveryDetail: explicitDeliveryDetail } : {}),
|
||
...(explicitDeliveryTransport ? {
|
||
deliveryTransport: explicitDeliveryTransport,
|
||
} : {}),
|
||
});
|
||
});
|
||
} else {
|
||
configurationEntries.forEach(([configKey, configuration]) => {
|
||
(configuration?.spec?.operations || []).forEach((operation) => {
|
||
const fallback = parsedCanCommandForOperation(operation) || {
|
||
channel: configuration?.spec?.domain === "self_extension" ? "IR" : "CAN1",
|
||
category: String(operation.capability || "控制指令"),
|
||
command: "",
|
||
...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}),
|
||
};
|
||
const compiled = compiledCommandsForOperation(result, configKey, operation);
|
||
const item = compiledCommandPresentation(compiled, fallback);
|
||
if (item.command) {
|
||
commands.push({
|
||
...item,
|
||
semantic: semanticControlInstruction(operation),
|
||
operationRef: String(operation.id || ""),
|
||
...deliveryForOperation(operation),
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
const unique = new Map();
|
||
commands.forEach((item) => {
|
||
const key = `${item.operationRef || item.semantic}:${item.channel}:${item.command}`;
|
||
const previous = unique.get(key);
|
||
if (!previous
|
||
|| (previous.deliveryState !== "submitted" && item.deliveryState === "submitted")
|
||
|| (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) {
|
||
unique.set(key, item);
|
||
}
|
||
});
|
||
return [...unique.values()];
|
||
}
|
||
|
||
function parsedCommandSourceLabel(source) {
|
||
return {
|
||
direct_control: "设备控件",
|
||
saved_mode_command_flow: "模式卡片",
|
||
local_rule: "本地规则",
|
||
speech_semantic: "语音语义",
|
||
vehicle_mqtt_extension: "车端 MQTT",
|
||
}[source] || "云端模型";
|
||
}
|
||
|
||
const commandTraceStageLabels = {
|
||
intent_parse: "意图解析",
|
||
context_resolve: "上下文解析",
|
||
domain_route: "控制域路由",
|
||
function_generate: "语义指令生成",
|
||
schema_validate: "Schema 校验",
|
||
domain_validate: "领域语义校验",
|
||
compile: "实际指令编译",
|
||
nfc_peripheral_route: "红外能力路由",
|
||
local_peripheral_plan: "红外本地规则编译",
|
||
nfc_peripheral_sequence: "红外指令序列生成",
|
||
};
|
||
|
||
const speechTimingLabels = {
|
||
audio_read_ms: "音频读取与重采样",
|
||
vad_ms: "语音活动检测",
|
||
asr_ms: "语音识别 ASR",
|
||
assembly_ms: "分段文本组装",
|
||
semantic_ms: "语音语义处理",
|
||
command_resolution_ms: "本地指令消歧",
|
||
recovery_ms: "语音识别恢复",
|
||
total_ms: "语音服务总耗时",
|
||
};
|
||
|
||
function parsedCommandTimingRows(result = {}, timingState = {}) {
|
||
const rows = [];
|
||
const push = (group, key, label, value) => {
|
||
const durationMs = finiteMilliseconds(value);
|
||
if (durationMs !== null) rows.push({ group, key, label, durationMs });
|
||
};
|
||
if (timingState.inputMode === "speech") {
|
||
Object.entries(speechTimingLabels).forEach(([key, label]) => {
|
||
push("speech", key, label, timingState.speechTimings?.[key]);
|
||
});
|
||
if (timingState.speechStartedAt != null && timingState.speechResultAt != null) {
|
||
push(
|
||
"speech",
|
||
"speech_capture_to_text",
|
||
"语音开始 → 文本就绪",
|
||
timingState.speechResultAt - timingState.speechStartedAt,
|
||
);
|
||
}
|
||
}
|
||
const traceEvents = Array.isArray(result.trace_events) ? result.trace_events : [];
|
||
const compileIndex = traceEvents.map((event) => event?.stage).lastIndexOf("compile");
|
||
const validatedTraceEvents = compileIndex >= 0
|
||
? traceEvents.slice(0, compileIndex + 1)
|
||
: traceEvents;
|
||
const localRuleResult = String(result.provider_used || "").startsWith("local")
|
||
|| parsedCanCommands(result).some((item) => item.parseSource === "local_rule");
|
||
const localBudgetMs = finiteMilliseconds(result.local_control_budget_ms);
|
||
const localWithinBudget = result.local_control_within_budget === true;
|
||
push(
|
||
"backend",
|
||
"local_control_elapsed",
|
||
localBudgetMs === null
|
||
? "本地规则解析与下发"
|
||
: `本地规则解析与下发(${localWithinBudget ? "1 秒内" : "超出 1 秒"})`,
|
||
result.local_control_elapsed_ms,
|
||
);
|
||
validatedTraceEvents.forEach((event, index) => {
|
||
const stage = String(event?.stage || "");
|
||
if (!(stage in commandTraceStageLabels)) return;
|
||
const label = stage === "intent_parse" && localRuleResult
|
||
? "本地规则意图解析"
|
||
: commandTraceStageLabels[stage];
|
||
push("backend", `${stage}-${index}`, label, event.duration_ms);
|
||
});
|
||
if (timingState.requestStartedAt != null && timingState.validatedAt != null) {
|
||
push(
|
||
"frontend",
|
||
"request_to_validated",
|
||
"请求提交 → 编译结果到达",
|
||
timingState.validatedAt - timingState.requestStartedAt,
|
||
);
|
||
}
|
||
push("frontend", "dom_render", "前端指令渲染", timingState.renderDurationMs);
|
||
if (timingState.requestStartedAt != null && timingState.displayedAt != null) {
|
||
push(
|
||
"total",
|
||
"request_to_display",
|
||
"文本提交 → 指令显示",
|
||
timingState.displayedAt - timingState.requestStartedAt,
|
||
);
|
||
}
|
||
if (timingState.inputMode === "speech"
|
||
&& timingState.speechStartedAt != null
|
||
&& timingState.displayedAt != null) {
|
||
push(
|
||
"total",
|
||
"speech_to_display",
|
||
"语音开始 → 指令显示",
|
||
timingState.displayedAt - timingState.speechStartedAt,
|
||
);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
const formattedDuration = (value) => {
|
||
const duration = finiteMilliseconds(value);
|
||
if (duration === null) return "--";
|
||
if (duration > 0 && duration < 0.1) return "<0.1 ms";
|
||
return `${duration < 10 ? duration.toFixed(1) : Math.round(duration)} ms`;
|
||
};
|
||
|
||
function renderCommandTimings(result = null, { pending = false } = {}) {
|
||
const container = $("#v2CommandTiming");
|
||
if (!container) return;
|
||
const rows = result ? parsedCommandTimingRows(result, commandTimingState) : [];
|
||
if (!rows.length && !pending) {
|
||
container.hidden = true;
|
||
container.innerHTML = "";
|
||
return;
|
||
}
|
||
container.hidden = false;
|
||
if (!rows.length) {
|
||
container.innerHTML = `<p>阶段耗时计时中 · ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}</p>`;
|
||
return;
|
||
}
|
||
const total = [...rows].reverse().find((row) => row.group === "total");
|
||
container.innerHTML = `
|
||
<header>
|
||
<strong>阶段耗时</strong>
|
||
<span>${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}</span>
|
||
${total ? `<b>${escapeHtml(formattedDuration(total.durationMs))}</b>` : ""}
|
||
</header>
|
||
<ol>${rows.map((row) => `
|
||
<li data-timing-group="${escapeHtml(row.group)}" data-timing-stage="${escapeHtml(row.key)}">
|
||
<span>${escapeHtml(row.label)}</span><b>${escapeHtml(formattedDuration(row.durationMs))}</b>
|
||
</li>`).join("")}</ol>`;
|
||
}
|
||
|
||
function beginCommandTiming(request = {}) {
|
||
const input = $("#utterance");
|
||
const utterance = String(request.utterance ?? input?.value ?? "").trim();
|
||
const speechUtteranceId = String(
|
||
request.speech_utterance_id ?? input?.dataset?.speechUtteranceId ?? "",
|
||
);
|
||
const speechValue = String(
|
||
request.speech_value ?? input?.dataset?.speechValue ?? "",
|
||
);
|
||
const speechMatch = Boolean(
|
||
speechUtteranceId
|
||
&& pendingSpeechTiming?.utteranceId === speechUtteranceId
|
||
&& speechValue === utterance,
|
||
);
|
||
commandTimingState = {
|
||
inputMode: speechMatch ? "speech" : "text",
|
||
utterance,
|
||
requestId: "",
|
||
requestStartedAt: monotonicNow(),
|
||
validatedAt: null,
|
||
displayedAt: null,
|
||
renderDurationMs: null,
|
||
speechStartedAt: speechMatch ? pendingSpeechTiming.startedAt : null,
|
||
speechResultAt: speechMatch ? pendingSpeechTiming.resultAt : null,
|
||
speechTimings: speechMatch ? { ...(pendingSpeechTiming.timings || {}) } : null,
|
||
};
|
||
}
|
||
|
||
function commandResultMatchesTimedRequest(result = {}) {
|
||
if (commandTimingState.requestStartedAt == null) return true;
|
||
const expectedUtterance = String(commandTimingState.utterance || "")
|
||
.replace(/\s+/g, "")
|
||
.trim();
|
||
const receivedUtterance = String(result.utterance || "")
|
||
.replace(/\s+/g, "")
|
||
.trim();
|
||
if (expectedUtterance && receivedUtterance
|
||
&& expectedUtterance !== receivedUtterance
|
||
&& !expectedUtterance.includes(receivedUtterance)
|
||
&& !receivedUtterance.includes(expectedUtterance)) return false;
|
||
const requestIds = commandCorrelationIds(result);
|
||
if (commandTimingState.requestId && requestIds.length
|
||
&& !requestIds.includes(commandTimingState.requestId)) return false;
|
||
if (!commandTimingState.requestId && requestIds.length) {
|
||
[commandTimingState.requestId] = requestIds;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderParsedCanCommands(
|
||
result,
|
||
{ message = "", pending = false, recordTiming = false, reveal = false } = {},
|
||
) {
|
||
const renderStartedAt = monotonicNow();
|
||
const panel = $("#v2CanCommandPanel");
|
||
const list = $("#v2CanCommandList");
|
||
const count = $("#v2CanCommandCount");
|
||
if (!panel || !list || !count) return;
|
||
const commands = result ? parsedCanCommands(result) : [];
|
||
count.textContent = String(commands.length);
|
||
panel.dataset.state = commands.length ? "ready" : message ? "pending" : "empty";
|
||
if (commands.length) {
|
||
list.innerHTML = `<ol>${commands.map((item) => `
|
||
<li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}">
|
||
<span>
|
||
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
|
||
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${parsedCommandSourceLabel(item.parseSource)}</b>` : ""}
|
||
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml([item.deliveryTransport, item.deliveryLabel, item.deliveryDetail].filter(Boolean).join(" · "))}">${escapeHtml(item.deliveryLabel)}</b>
|
||
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
|
||
</span>
|
||
${item.deliveryDetail && ["blocked", "failed", "aborted"].includes(item.deliveryState) ? `
|
||
<small class="v2-can-command-delivery-detail">
|
||
<b>状态说明</b>${escapeHtml(item.deliveryDetail)}
|
||
</small>` : ""}
|
||
<div class="v2-can-command-layer v2-can-command-semantic">
|
||
<small>语义控制指令</small>
|
||
<code>${escapeHtml(item.semantic)}</code>
|
||
</div>
|
||
<div class="v2-can-command-layer v2-can-command-actual">
|
||
<small>实际控制指令</small>
|
||
<code>${escapeHtml(item.command)}</code>
|
||
</div>
|
||
${(item.frames || (item.frame ? [{ label: item.frameLabel || "控制帧", value: item.frame }] : [])).map((frame) => `<small class="v2-can-command-frame"><b>${escapeHtml(frame.label)}</b> · ${escapeHtml(frame.value)}</small>`).join("")}
|
||
</li>`).join("")}</ol>`;
|
||
} else {
|
||
list.innerHTML = `<p>${escapeHtml(message || "本次请求未解析出控制指令")}</p>`;
|
||
}
|
||
if (reveal && commands.length) {
|
||
panel.open = true;
|
||
if (ui.sidebarCollapsed) setSidebarCollapsed(false);
|
||
}
|
||
if (recordTiming && commands.length) {
|
||
if (commandTimingState.validatedAt == null) commandTimingState.validatedAt = renderStartedAt;
|
||
if (commandTimingState.displayedAt == null) {
|
||
commandTimingState.displayedAt = monotonicNow();
|
||
commandTimingState.renderDurationMs = commandTimingState.displayedAt - renderStartedAt;
|
||
}
|
||
}
|
||
renderCommandTimings(result, { pending });
|
||
requestAnimationFrame(syncSidebarLogBaseline);
|
||
}
|
||
|
||
function renderDirectCanCommands(commands, deviceName) {
|
||
const panel = $("#v2CanCommandPanel");
|
||
const list = $("#v2CanCommandList");
|
||
const count = $("#v2CanCommandCount");
|
||
if (!panel || !list || !count) return;
|
||
const items = commands.map((command) => ({
|
||
channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1",
|
||
command,
|
||
deliveryState: "not_submitted",
|
||
deliveryLabel: "未下发",
|
||
}));
|
||
count.textContent = String(items.length);
|
||
panel.dataset.state = "ready";
|
||
list.innerHTML = `<ol>${items.map((item) => `
|
||
<li data-can-channel="${item.channel}" data-delivery-state="${item.deliveryState}">
|
||
<span>
|
||
<b class="v2-can-command-channel">${item.channel}</b>
|
||
<b class="v2-can-command-source" data-parse-source="direct_control">设备控件</b>
|
||
<b class="v2-can-command-delivery" data-delivery-state="${item.deliveryState}" title="尚未进入 CAN 传输链路">${item.deliveryLabel}</b>
|
||
<i class="v2-can-command-category">${escapeHtml(deviceName)}</i>
|
||
</span>
|
||
<code>${escapeHtml(item.command)}</code>
|
||
</li>`).join("")}</ol>`;
|
||
validatedCommandsVisible = true;
|
||
validatedCommandContext = {
|
||
requestId: "",
|
||
utterance: items.map((item) => item.command).join(";"),
|
||
};
|
||
requestAnimationFrame(syncSidebarLogBaseline);
|
||
}
|
||
|
||
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 = () => {
|
||
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 buildSidebar(commandPanel) {
|
||
commandPanel.classList.add("v2-sidebar");
|
||
commandPanel.id = "v2SidebarPanel";
|
||
commandPanel.innerHTML = "";
|
||
|
||
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-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>
|
||
<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(sidebarToggle, footer);
|
||
}
|
||
|
||
function prepareRuntimeControls(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);
|
||
|
||
transportField?.classList.add("v2-transport-field");
|
||
executionField?.classList.add("v2-execution-field");
|
||
simulationFaultField?.classList.add("v2-simulation-fault-field");
|
||
|
||
installReasoningPicker(reasoningField, originalForm);
|
||
|
||
[vehicleField, providerField].forEach((field) => {
|
||
if (!field) return;
|
||
field.classList.add("v2-runtime-data-field");
|
||
field.hidden = true;
|
||
});
|
||
}
|
||
|
||
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>
|
||
<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 title = $("h2", panelHeading);
|
||
if (title) title.textContent = "AI助手";
|
||
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);
|
||
const gesturePanel = $("#v2GesturePanel");
|
||
const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `
|
||
<h2>抬抬手,控制整车</h2>`);
|
||
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, modeRail) {
|
||
const switcher = make("nav", "v2-module-switcher", "");
|
||
switcher.id = "v2ModuleSwitcher";
|
||
switcher.dataset.unifiedSync = "true";
|
||
switcher.setAttribute("aria-label", "页面板块展开与收起");
|
||
switcher.innerHTML = `
|
||
<nav class="v2-panel-control-dock" id="v2PanelControlDock" aria-label="页面板块展开与收起"></nav>`;
|
||
const modeRailBar = $(".mode-rail-bar", modeRail);
|
||
if (modeRailBar) modeRailBar.prepend(switcher);
|
||
else mainPanel.prepend(switcher);
|
||
const panelDock = $("#v2PanelControlDock", switcher);
|
||
const sidebarToggle = $("#v2SidebarToggle");
|
||
if (panelDock && sidebarToggle) panelDock.append(sidebarToggle);
|
||
}
|
||
|
||
|
||
function buildOutput(mainPanel, modeRail) {
|
||
const output = make("section", "v2-output-shell", `
|
||
<header class="v2-output-heading">
|
||
<div>
|
||
<span class="v2-page-index" id="v2PageIndex">00</span>
|
||
<div><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);
|
||
|
||
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);
|
||
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 extensionControlSync = {
|
||
revision: -1,
|
||
requestId: "",
|
||
running: false,
|
||
stopped: true,
|
||
controller: null,
|
||
};
|
||
const nonDispatchTaskSources = new Set(["mode_editor_compile"]);
|
||
|
||
function isDispatchTask(task = {}) {
|
||
return !nonDispatchTaskSources.has(String(task.source || "").trim());
|
||
}
|
||
|
||
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 === "2" ? "low" : "high";
|
||
}
|
||
|
||
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", "关闭"], ["high", "1 档(高)"], ["low", "2 档(低)"],
|
||
];
|
||
const toggleOptions = [["off", "关闭"], ["on", "开启"]];
|
||
const massageModeOptions = [
|
||
["off", "关闭"], ["relax", "放松"], ["stretch", "伸展"], ["slack", "松弛"], ["wave", "波浪"], ["pulse", "脉冲"],
|
||
];
|
||
const massageStrengthOptions = [
|
||
["off", "关闭"], ["weak", "低"], ["medium", "中"], ["strong", "高"],
|
||
];
|
||
const colorIndexNames = ["皓月白", "冰川蓝", "松石绿", "星云紫", "琥珀橙", "玫瑰红"];
|
||
const colorIndexOptions = Array.from({ length: 127 }, (_item, index) => [
|
||
String(index + 1), colorIndexNames[index] ? `${index + 1} · ${colorIndexNames[index]}` : `色号 ${index + 1}`,
|
||
]);
|
||
|
||
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 });
|
||
const hasControlValue = (values, key) => Object.hasOwn(values, key);
|
||
|
||
function normalizeDeviceReadout(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text || /^-+$/.test(text) || /^--\s*/.test(text)) return "-";
|
||
if (/未知|等待反馈|同步中|状态未知/.test(text)) return "-";
|
||
return text;
|
||
}
|
||
|
||
function ambientPowerState() {
|
||
const value = $('[data-domain-panel="ambient_light"]')?.dataset.ambientPower;
|
||
return value === "on" || value === "off" ? value : "unknown";
|
||
}
|
||
|
||
function selectedTransportHasConnection() {
|
||
const selected = $('.vehicle-transport-options [data-control-transport][aria-pressed="true"]')
|
||
|| $('.vehicle-transport-options [data-control-transport].active');
|
||
return selected?.dataset.connectionState === "online";
|
||
}
|
||
|
||
function deviceFeedbackAvailable(deviceId) {
|
||
if (!selectedTransportHasConnection()) return false;
|
||
return deviceId === "passenger"
|
||
? document.body.dataset.passengerFeedbackState === "online"
|
||
: document.body.dataset.vehicleFeedbackState === "online";
|
||
}
|
||
|
||
function deviceReadoutRows(device) {
|
||
const rows = device.rows();
|
||
if (!deviceFeedbackAvailable(device.id)) {
|
||
return rows.map(([label]) => [label, "-"]);
|
||
}
|
||
return rows.map(([label, value]) => [label, normalizeDeviceReadout(value)]);
|
||
}
|
||
|
||
function deviceControlValue(deviceId, control) {
|
||
const draft = displaySync.deviceDrafts[deviceId];
|
||
return draft && Object.hasOwn(draft, control.key) ? draft[control.key] : control.value;
|
||
}
|
||
|
||
const syncedDevices = [
|
||
{
|
||
id: "passenger", name: "副驾座椅", icon: "armchair",
|
||
rows: () => [["前后", seatAxisValue("passenger", "longitudinal")], ["高度", seatAxisValue("passenger", "cushion_tilt")], ["靠背", seatAxisValue("passenger", "backrest_recline")], ["腿托", seatAxisValue("passenger", "legrest_height")]],
|
||
controls: () => [
|
||
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")),
|
||
],
|
||
commands: (values) => [
|
||
["longitudinal", "horizontal"],
|
||
["cushion_tilt", "basin"],
|
||
["backrest_recline", "backrest"],
|
||
["legrest_height", "legrest"],
|
||
].filter(([key]) => hasControlValue(values, key))
|
||
.map(([key, axis]) => `seat passenger ${axis} position ${Math.round(Number(values[key]))}`),
|
||
},
|
||
{
|
||
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),
|
||
];
|
||
},
|
||
commands: (values) => {
|
||
const commands = [];
|
||
if (hasControlValue(values, "ventilation")) commands.push(`seat driver vent ${values.ventilation}`);
|
||
if (hasControlValue(values, "heating")) commands.push(`seat driver heat ${values.heating}`);
|
||
if (hasControlValue(values, "massage_mode")) {
|
||
commands.push(values.massage_mode === "off"
|
||
? "seat driver massage off"
|
||
: `seat driver massage mode ${values.massage_mode}`);
|
||
}
|
||
if (hasControlValue(values, "massage_strength")) {
|
||
commands.push(values.massage_strength === "off"
|
||
? "seat driver massage off"
|
||
: `seat driver massage strength ${values.massage_strength}`);
|
||
}
|
||
return [...new Set(commands)];
|
||
},
|
||
},
|
||
{
|
||
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")),
|
||
],
|
||
commands: (values) => Object.entries({
|
||
front_left: "front-left", front_right: "front-right", rear_left: "rear-left", rear_right: "rear-right",
|
||
}).filter(([key]) => hasControlValue(values, key))
|
||
.map(([key, target]) => `window ${target} position ${Math.round(Number(values[key]))}`),
|
||
},
|
||
{
|
||
id: "shade", name: "遮阳帘", icon: "panels-top-left",
|
||
rows: () => [["当前位置", readText("#shadePositionValue")]],
|
||
controls: () => [rangeControl("position", "当前位置", readText("#shadePositionValue"), 0, 100, 1, "%", "0% 关闭 · 100% 打开")],
|
||
commands: (values) => hasControlValue(values, "position")
|
||
? [`shade position ${Math.round(Number(values.position))}`]
|
||
: [],
|
||
},
|
||
{
|
||
id: "reading", name: "阅读灯", icon: "book-open",
|
||
rows: () => [["开关状态", readText("#interiorLightPower", "等待反馈")]],
|
||
controls: () => [selectControl("power", "开关状态", /开启|ON/.test(readText("#interiorLightPower")) ? "on" : "off", toggleOptions)],
|
||
commands: (values) => hasControlValue(values, "power") ? [`light reading all ${values.power}`] : [],
|
||
},
|
||
{
|
||
id: "ambient", name: "氛围灯", icon: "sparkles",
|
||
rows: () => {
|
||
const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness");
|
||
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
||
const powerState = ambientPowerState();
|
||
const power = powerState === "on" ? "开启" : powerState === "off" ? "关闭" : "-";
|
||
return [["开关状态", power], ["前排亮度", frontBrightness], ["前排颜色", frontColor], ["后排亮度", rearBrightness], ["后排颜色", rearColor]];
|
||
},
|
||
controls: () => {
|
||
const [frontBrightness, rearBrightness] = splitReadout("#ambientBrightness");
|
||
const [frontColor, rearColor] = splitReadout("#ambientColorIndex");
|
||
return [
|
||
selectControl("power", "开关状态", ambientPowerState() === "off" ? "off" : "on", toggleOptions),
|
||
rangeControl("brightness", "全车亮度", frontBrightness === "-" ? rearBrightness : frontBrightness, 1, 5, 1, " 级"),
|
||
selectControl("color", "全车颜色", String(clampControlNumber(frontColor === "-" ? rearColor : frontColor, 1, 127, 1)), colorIndexOptions),
|
||
];
|
||
},
|
||
commands: (values) => [
|
||
...(hasControlValue(values, "power") ? [`light ambient ${values.power}`] : []),
|
||
...(hasControlValue(values, "brightness") ? [`light ambient brightness ${Math.round(Number(values.brightness))}`] : []),
|
||
...(hasControlValue(values, "color") ? [`light ambient color ${Math.round(Number(values.color))}`] : []),
|
||
],
|
||
},
|
||
{
|
||
id: "headlight", name: "前大灯", icon: "car-front",
|
||
controllable: false,
|
||
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)),
|
||
},
|
||
{
|
||
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", "主驾温度", Math.round(clampControlNumber(readText("#driverTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||
rangeControl("passenger_temperature", "副驾温度", Math.round(clampControlNumber(readText("#passengerTemperature"), 18, 32, 24)), 18, 32, 1, "℃"),
|
||
rangeControl("fan_level", "风量", readText("#fanLevel"), 1, 7, 1, " 档"),
|
||
selectControl("cycle_mode", "循环模式", readText("#cycleMode").includes("内") ? "internal" : "external", [["internal", "内循环"], ["external", "外循环"]]),
|
||
],
|
||
commands: (values) => [
|
||
...(hasControlValue(values, "power") ? [`ac ${values.power}`] : []),
|
||
...(hasControlValue(values, "driver_temperature") ? [`ac temp driver ${Math.round(Number(values.driver_temperature))}`] : []),
|
||
...(hasControlValue(values, "passenger_temperature") ? [`ac temp passenger ${Math.round(Number(values.passenger_temperature))}`] : []),
|
||
...(hasControlValue(values, "fan_level") ? [`ac fan ${Math.round(Number(values.fan_level))}`] : []),
|
||
...(hasControlValue(values, "cycle_mode") ? [`ac cycle ${values.cycle_mode}`] : []),
|
||
],
|
||
},
|
||
];
|
||
|
||
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 = deviceReadoutRows(device);
|
||
const waiting = rows.some(([, value]) => value === "-");
|
||
const controllable = device.controllable !== false;
|
||
const controlOpen = controllable && 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)}"${controllable ? "" : " disabled"}><i data-lucide="${controllable ? "sliders-horizontal" : "eye"}"></i><span>${controllable ? (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 || !hasDraft ? " disabled" : ""}><i data-lucide="send"></i><span>${pending ? "等待车机同步" : hasDraft ? "发送 CAN 控制" : "请先修改参数"}</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, deviceReadoutRows(device)]));
|
||
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" },
|
||
}));
|
||
}
|
||
|
||
async function sendDeviceControl(deviceId) {
|
||
const device = syncedDevices.find((item) => item.id === deviceId);
|
||
if (!device) return;
|
||
if (device.controllable === false || typeof device.commands !== "function") {
|
||
window.CognitiveAgentConsole?.notify?.(`${device.name}当前只有状态反馈,尚无已验证的 CAN 控制白名单`);
|
||
return;
|
||
}
|
||
const draft = displaySync.deviceDrafts[device.id];
|
||
if (!draft || !Object.keys(draft).length) {
|
||
window.CognitiveAgentConsole?.notify?.("请先修改需要发送的参数");
|
||
return;
|
||
}
|
||
if (displaySync.pendingControls[device.id]) {
|
||
window.CognitiveAgentConsole?.notify?.("该设备的上一组 CAN 指令仍在执行");
|
||
return;
|
||
}
|
||
const values = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, String(value)]));
|
||
const commands = device.commands(values).filter(Boolean);
|
||
if (!commands.length) {
|
||
window.CognitiveAgentConsole?.notify?.("修改项没有对应的 CAN 白名单控制指令");
|
||
return;
|
||
}
|
||
const commandSummary = commands.join(";");
|
||
const detail = {
|
||
device_id: device.id,
|
||
device_name: device.name,
|
||
controls: values,
|
||
can_commands: commands,
|
||
source: "web_console",
|
||
requested_at: new Date().toISOString(),
|
||
};
|
||
const execute = window.CognitiveAgentConsole?.executeCanCommands;
|
||
if (typeof execute !== "function") {
|
||
window.CognitiveAgentConsole?.notify?.("当前页面尚未加载 CAN 直控接口");
|
||
return;
|
||
}
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-can-control-request", { detail }));
|
||
displaySync.pendingControls[device.id] = commandSummary;
|
||
appendSyncLog({ category: "device", title: `网页端直控${device.name}`, detail: commandSummary, status: "info" });
|
||
renderDirectCanCommands(commands, device.name);
|
||
renderSyncedDevices();
|
||
try {
|
||
await execute(commands, { deviceName: device.name });
|
||
delete displaySync.deviceDrafts[device.id];
|
||
appendSyncLog({
|
||
category: "device",
|
||
title: `${device.name} CAN 控制已执行`,
|
||
detail: commandSummary,
|
||
status: "success",
|
||
});
|
||
} catch (error) {
|
||
appendSyncLog({
|
||
category: "device",
|
||
title: `${device.name} CAN 控制发送失败`,
|
||
detail: error?.message || "CAN 控制请求失败",
|
||
status: "error",
|
||
});
|
||
window.CognitiveAgentConsole?.notify?.(error?.message || "CAN 控制请求失败");
|
||
} finally {
|
||
delete displaySync.pendingControls[device.id];
|
||
renderSyncedDevices();
|
||
}
|
||
}
|
||
|
||
function scheduleSyncedDeviceRender() {
|
||
if (displaySync.deviceFrame) return;
|
||
displaySync.deviceFrame = requestAnimationFrame(() => {
|
||
displaySync.deviceFrame = null;
|
||
const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
|
||
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;
|
||
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-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);
|
||
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");
|
||
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");
|
||
if (!device || !popover) return;
|
||
if (displaySync.previewClearTimer) {
|
||
window.clearTimeout(displaySync.previewClearTimer);
|
||
displaySync.previewClearTimer = null;
|
||
}
|
||
displaySync.activeDevice = deviceId;
|
||
if (pinned) displaySync.pinnedDevice = deviceId;
|
||
const rows = deviceReadoutRows(device);
|
||
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><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" } });
|
||
positionTwinDevicePopover(deviceId);
|
||
}
|
||
|
||
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
|
||
&& !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 || "等待车机端或网页端选择预设模式、输入一句话指令";
|
||
const normalizedStatus = String(task.status || "idle").toLowerCase();
|
||
const verificationStatus = String(
|
||
result?.execution_outcome
|
||
|| result?.execution_report?.spec?.verification_status
|
||
|| "",
|
||
);
|
||
const submittedUnverified = verificationStatus === "submitted_unverified";
|
||
const statusMap = {
|
||
idle: ["等待任务", "idle"],
|
||
running: ["构建 / 执行中", "running"],
|
||
succeeded: ["执行完成", "succeeded"],
|
||
partially_succeeded: ["部分完成", "partial"],
|
||
needs_confirmation: ["等待确认", "partial"],
|
||
failed: ["执行失败", "failed"],
|
||
};
|
||
const [label, state] = submittedUnverified && normalizedStatus === "succeeded"
|
||
? ["指令已提交 · 等待状态确认", "partial"]
|
||
: 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);
|
||
if (normalizedStatus === "running") {
|
||
// The streaming endpoint can publish fully validated commands before the
|
||
// slower display-sync poll observes the terminal execution state. A stale
|
||
// `running` snapshot for the same task must not erase those commands.
|
||
if (!validatedCommandsBelongToTask(task)) {
|
||
resetValidatedCommandContext();
|
||
renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
|
||
}
|
||
} else if (result) {
|
||
const commands = parsedCanCommands(result);
|
||
if (commands.length) {
|
||
validatedCommandsVisible = true;
|
||
rememberValidatedCommandContext(result);
|
||
renderParsedCanCommands(result);
|
||
} else if (!validatedCommandsBelongToTask(task)) {
|
||
resetValidatedCommandContext();
|
||
renderParsedCanCommands(result);
|
||
}
|
||
}
|
||
}
|
||
|
||
function extensionControlDelivery(action = {}) {
|
||
const state = String(action.state || "pending");
|
||
const defaults = {
|
||
pending: ["not_submitted", "等待下发"],
|
||
executing: ["executing", "正在下发"],
|
||
submitted_unverified: ["submitted", "已下发 · 物理效果未确认"],
|
||
verified_success: ["submitted", "执行已确认"],
|
||
failed: ["failed", "下发失败"],
|
||
aborted: ["aborted", "已中止"],
|
||
};
|
||
const [deliveryState, deliveryLabel] = defaults[state] || defaults.pending;
|
||
return {
|
||
deliveryState,
|
||
deliveryLabel: String(action.state_label || deliveryLabel),
|
||
deliveryDetail: String(action.state_detail || ""),
|
||
};
|
||
}
|
||
|
||
function extensionControlResult(snapshot = {}) {
|
||
const requestId = String(snapshot.last_request_id || "");
|
||
const actions = Array.isArray(snapshot.action_statuses)
|
||
? snapshot.action_statuses
|
||
: [];
|
||
return {
|
||
request_id: requestId,
|
||
utterance: "车端 MQTT 外设控制",
|
||
status: snapshot.state === "failed" ? "failed" : "succeeded",
|
||
execution_mode: "hardware",
|
||
control_transport: "extension_mqtt",
|
||
orchestration_route: "vehicle_mqtt_extension_control",
|
||
validated_control_commands: actions.map((action, position) => {
|
||
const index = Number.isInteger(Number(action.index))
|
||
? Number(action.index)
|
||
: position;
|
||
const atMs = Math.max(0, Number(action.at_ms) || 0);
|
||
const slot = Number(action.slot);
|
||
const extensionName = String(action.extension_name || "红外外设");
|
||
const actionName = String(action.action || "红外动作");
|
||
const targetScope = String(action.target_scope || "external-device");
|
||
const delivery = extensionControlDelivery(action);
|
||
return {
|
||
domain: "self_extension",
|
||
channel: "IR",
|
||
category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`,
|
||
semantic_instruction: `${extensionName} · ${actionName}`,
|
||
actual_command: `infrared emit ${slot}`,
|
||
delivery_state: delivery.deliveryState,
|
||
delivery_label: delivery.deliveryLabel,
|
||
delivery_detail: [delivery.deliveryDetail, `at_ms=${atMs}`]
|
||
.filter(Boolean)
|
||
.join(" · "),
|
||
delivery_transport: "车端 MQTT → 红外 TCP",
|
||
frames: [{
|
||
label: "GSCU1B TCP",
|
||
value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
|
||
}],
|
||
operation: {
|
||
id: `${requestId || "extension-ctl"}:${index}`,
|
||
domain: "self_extension",
|
||
capability: "infrared.emit",
|
||
target: { device: targetScope, name: extensionName },
|
||
parameters: { action: actionName, slot },
|
||
parse_source: "vehicle_mqtt_extension",
|
||
},
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
function renderExtensionControlRejection(snapshot = {}) {
|
||
const panel = $("#v2CanCommandPanel");
|
||
const list = $("#v2CanCommandList");
|
||
const count = $("#v2CanCommandCount");
|
||
if (!panel || !list || !count) return;
|
||
count.textContent = "0";
|
||
panel.dataset.state = "error";
|
||
panel.open = true;
|
||
list.innerHTML = `<p>车端 MQTT 外设控制已拒绝:${escapeHtml(snapshot.last_error || "消息未通过校验")}</p>`;
|
||
if (ui.sidebarCollapsed) setSidebarCollapsed(false);
|
||
requestAnimationFrame(syncSidebarLogBaseline);
|
||
}
|
||
|
||
function applyExtensionControlSnapshot(snapshot = {}) {
|
||
const revision = Number(snapshot.revision);
|
||
if (!Number.isInteger(revision)) return;
|
||
if (revision < extensionControlSync.revision) {
|
||
extensionControlSync.revision = -1;
|
||
extensionControlSync.requestId = "";
|
||
}
|
||
if (revision <= extensionControlSync.revision) return;
|
||
extensionControlSync.revision = revision;
|
||
const requestId = String(snapshot.last_request_id || "");
|
||
const state = String(snapshot.state || "");
|
||
const actions = Array.isArray(snapshot.action_statuses)
|
||
? snapshot.action_statuses
|
||
: [];
|
||
if (requestId) extensionControlSync.requestId = requestId;
|
||
|
||
if (actions.length) {
|
||
renderParsedCanCommands(extensionControlResult(snapshot), { reveal: true });
|
||
appendSyncLog({
|
||
key: `extension-control:${requestId}:${state}:${snapshot.executed_action_count || 0}`,
|
||
category: "extension",
|
||
title: state === "completed"
|
||
? "车端 MQTT 外设控制已下发"
|
||
: state === "failed"
|
||
? "车端 MQTT 外设控制下发失败"
|
||
: "正在执行车端 MQTT 外设控制",
|
||
detail: `${actions.length} 条红外指令 · ${snapshot.last_error || state}`,
|
||
status: state === "failed" ? "error" : state === "completed" ? "success" : "info",
|
||
});
|
||
return;
|
||
}
|
||
if (requestId && ["rejected", "queue_full"].includes(state)) {
|
||
renderExtensionControlRejection(snapshot);
|
||
appendSyncLog({
|
||
key: `extension-control:${requestId}:${state}`,
|
||
category: "extension",
|
||
title: "车端 MQTT 外设控制已拒绝",
|
||
detail: String(snapshot.last_error || "消息未通过校验"),
|
||
status: "error",
|
||
});
|
||
}
|
||
}
|
||
|
||
const extensionControlRetryDelay = () => new Promise((resolve) => {
|
||
window.setTimeout(resolve, 1000);
|
||
});
|
||
|
||
async function monitorExtensionControl() {
|
||
if (extensionControlSync.running) return;
|
||
extensionControlSync.running = true;
|
||
extensionControlSync.stopped = false;
|
||
try {
|
||
while (!extensionControlSync.stopped) {
|
||
const controller = new AbortController();
|
||
extensionControlSync.controller = controller;
|
||
const query = new URLSearchParams({
|
||
after_revision: String(extensionControlSync.revision),
|
||
timeout_ms: "25000",
|
||
});
|
||
try {
|
||
const response = await fetch(`/api/extension/control/status?${query}`, {
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
signal: controller.signal,
|
||
});
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
applyExtensionControlSnapshot(await response.json());
|
||
} catch (error) {
|
||
if (extensionControlSync.stopped || error?.name === "AbortError") break;
|
||
console.warn("[extension-control-sync]", error);
|
||
await extensionControlRetryDelay();
|
||
} finally {
|
||
if (extensionControlSync.controller === controller) {
|
||
extensionControlSync.controller = null;
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
extensionControlSync.running = false;
|
||
}
|
||
}
|
||
|
||
function startExtensionControlMonitor() {
|
||
if (extensionControlSync.running) return;
|
||
extensionControlSync.stopped = false;
|
||
void monitorExtensionControl();
|
||
}
|
||
|
||
function stopExtensionControlMonitor() {
|
||
extensionControlSync.stopped = true;
|
||
extensionControlSync.controller?.abort();
|
||
extensionControlSync.controller = null;
|
||
}
|
||
|
||
function applyDisplaySnapshot(snapshot) {
|
||
const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog)
|
||
? snapshot.extension.catalog
|
||
: [];
|
||
const revision = Number(snapshot?.revision ?? -1);
|
||
if (revision !== displaySync.revision) {
|
||
displaySync.revision = revision;
|
||
const task = snapshot?.task || {};
|
||
if (isDispatchTask(task)) {
|
||
setTaskPresentation(task);
|
||
const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
|
||
if (task.utterance) {
|
||
const submittedUnverified = task.result?.execution_outcome === "submitted_unverified"
|
||
|| task.result?.execution_report?.spec?.verification_status === "submitted_unverified";
|
||
appendSyncLog({
|
||
key: `task:${revision}:${task.status}`,
|
||
category: "task",
|
||
title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
|
||
detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`,
|
||
status: submittedUnverified ? "info" : ["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());
|
||
appendSyncLog({ key: "sync:online", category: "sync", title: "网页同步链路已建立", detail: "车机任务与设备状态每 2 秒同步", status: "success" });
|
||
} catch (_error) {
|
||
console.warn("[vehicle-display-sync]", _error);
|
||
}
|
||
}
|
||
|
||
function enableRemoteOrchestration() {
|
||
const form = $("#requestForm");
|
||
const input = $("#utterance");
|
||
if (input) {
|
||
input.readOnly = false;
|
||
input.removeAttribute("aria-readonly");
|
||
input.setAttribute("aria-label", "输入问题或座舱控制需求");
|
||
input.placeholder = "输入问题或座舱控制需求";
|
||
}
|
||
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 || ui.sidebarCollapsed) return;
|
||
const sidebarRect = sidebar.getBoundingClientRect();
|
||
const overview = $(".v2-sync-sidebar-overview", sidebar);
|
||
const overviewRect = overview?.getBoundingClientRect();
|
||
const overviewBottom = overview && overviewRect
|
||
? Array.from(overview.children).reduce(
|
||
(bottom, child) => Math.max(bottom, child.getBoundingClientRect().bottom),
|
||
overviewRect.top,
|
||
)
|
||
: 0;
|
||
const overviewPaddingBottom = overview
|
||
? Number.parseFloat(getComputedStyle(overview).paddingBottom) || 0
|
||
: 0;
|
||
const requiredOverviewHeight = overviewRect
|
||
? Math.ceil(overviewBottom - overviewRect.top + overviewPaddingBottom + 10)
|
||
: 0;
|
||
const offset = Math.max(
|
||
0,
|
||
lowerPanel.getBoundingClientRect().top - sidebarRect.top,
|
||
requiredOverviewHeight,
|
||
);
|
||
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>
|
||
<section class="v2-sync-log" aria-labelledby="v2SyncLogTitle">
|
||
<header><div><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);
|
||
const syncOverview = $(".v2-sync-sidebar-overview", syncSidebar);
|
||
const transportField = $("#vehicleTransportField");
|
||
const executionField = $(".field-group:has(.execution-mode-options)");
|
||
const simulationFaultField = $("#simulationFaultField");
|
||
if (syncOverview) {
|
||
const insertVisibleControl = (field, className) => {
|
||
if (!field) return;
|
||
field.classList.add(className);
|
||
syncOverview.append(field);
|
||
};
|
||
insertVisibleControl(transportField, "v2-sync-transport-field");
|
||
if (executionField) executionField.hidden = false;
|
||
insertVisibleControl(executionField, "v2-sync-execution-field");
|
||
insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field");
|
||
const canCommandPanel = make("details", "v2-can-command-panel", `
|
||
<summary>
|
||
<span><i data-lucide="binary"></i><b>下发控制指令</b></span>
|
||
<strong id="v2CanCommandCount">0</strong>
|
||
<i data-lucide="chevron-down"></i>
|
||
</summary>
|
||
<section class="v2-command-timing" id="v2CommandTiming" aria-live="polite" hidden></section>
|
||
<div class="v2-can-command-list" id="v2CanCommandList">
|
||
<p>等待准备下发的控制指令</p>
|
||
</div>`);
|
||
canCommandPanel.id = "v2CanCommandPanel";
|
||
syncOverview.append(canCommandPanel);
|
||
canCommandPanel.addEventListener("toggle", () => {
|
||
requestAnimationFrame(syncSidebarLogBaseline);
|
||
});
|
||
}
|
||
syncOverview?.append(sidebarFooter);
|
||
}
|
||
|
||
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", `
|
||
<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 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, {
|
||
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", false);
|
||
setSidePanelVisibility("extensions", false);
|
||
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);
|
||
const beginPendingControlRequest = (request = {}) => {
|
||
beginCommandTiming(request);
|
||
resetValidatedCommandContext();
|
||
renderParsedCanCommands(null, {
|
||
message: "正在解析自然语言并生成控制指令…",
|
||
pending: true,
|
||
});
|
||
};
|
||
$("#requestForm")?.addEventListener("submit", () => {
|
||
beginPendingControlRequest();
|
||
}, true);
|
||
window.addEventListener("cognitive-agent:control-request-start", (event) => {
|
||
beginPendingControlRequest(event.detail || {});
|
||
});
|
||
window.addEventListener("cognitive-agent:speech-state", (event) => {
|
||
const phase = event.detail?.phase;
|
||
if (phase === "capturing" && !pendingSpeechTiming?.captureActive) {
|
||
pendingSpeechTiming = {
|
||
utteranceId: "",
|
||
startedAt: monotonicNow(),
|
||
resultAt: null,
|
||
timings: null,
|
||
captureActive: true,
|
||
};
|
||
} else if (["finalizing", "success", "error"].includes(phase)
|
||
&& pendingSpeechTiming) {
|
||
pendingSpeechTiming.captureActive = false;
|
||
}
|
||
});
|
||
window.addEventListener("cognitive-agent:speech-result", (event) => {
|
||
const result = event.detail || {};
|
||
pendingSpeechTiming = {
|
||
...(pendingSpeechTiming || { startedAt: null }),
|
||
utteranceId: String(result.utterance_id || ""),
|
||
resultAt: monotonicNow(),
|
||
timings: result.timings && typeof result.timings === "object"
|
||
? { ...result.timings }
|
||
: null,
|
||
captureActive: false,
|
||
};
|
||
});
|
||
window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
|
||
const result = event.detail || {};
|
||
if (!commandResultMatchesTimedRequest(result)) return;
|
||
validatedCommandsVisible = parsedCanCommands(result).length > 0;
|
||
if (validatedCommandsVisible) rememberValidatedCommandContext(result);
|
||
else resetValidatedCommandContext();
|
||
renderParsedCanCommands(result, {
|
||
message: "控制指令已解析,正在等待传输结果…",
|
||
recordTiming: true,
|
||
reveal: true,
|
||
});
|
||
});
|
||
window.addEventListener("cognitive-agent:result", (event) => {
|
||
const result = event.detail || {};
|
||
const submittedUnverified = result.execution_outcome === "submitted_unverified"
|
||
|| result.execution_report?.spec?.verification_status === "submitted_unverified";
|
||
const commands = parsedCanCommands(result);
|
||
const matchesTimedRequest = commandResultMatchesTimedRequest(result);
|
||
if (matchesTimedRequest && commands.length) {
|
||
validatedCommandsVisible = true;
|
||
rememberValidatedCommandContext(result);
|
||
renderParsedCanCommands(result, {
|
||
recordTiming: commandTimingState.displayedAt == null,
|
||
reveal: commandTimingState.requestStartedAt != null,
|
||
});
|
||
} else if (matchesTimedRequest && !validatedCommandsBelongToTask({
|
||
utterance: result.utterance,
|
||
result,
|
||
})) {
|
||
resetValidatedCommandContext();
|
||
renderParsedCanCommands(result);
|
||
}
|
||
appendSyncLog({
|
||
key: result.request_id ? `result:${result.request_id}` : "",
|
||
category: "task",
|
||
title: submittedUnverified
|
||
? "CAN 指令已提交,等待车辆状态确认"
|
||
: ["succeeded", "partially_succeeded"].includes(result.status)
|
||
? "编排任务执行完成"
|
||
: "编排任务返回结果",
|
||
detail: `${result.summary || result.message || "执行反馈已同步"} · ${submittedUnverified ? "submitted_unverified" : result.status || "unknown"}`,
|
||
status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info",
|
||
});
|
||
});
|
||
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||
const completedUtterance = String(event.detail?.utterance || "")
|
||
.replace(/\s+/g, "")
|
||
.trim();
|
||
const expectedUtterance = String(commandTimingState.utterance || "")
|
||
.replace(/\s+/g, "")
|
||
.trim();
|
||
const matchesTimedRequest = !expectedUtterance || !completedUtterance
|
||
|| expectedUtterance === completedUtterance;
|
||
if (matchesTimedRequest && !validatedCommandsVisible) {
|
||
renderParsedCanCommands(null, { message: "本次请求未生成控制指令" });
|
||
}
|
||
if (event.detail?.error) {
|
||
const error = event.detail.error;
|
||
const outcomeLabels = {
|
||
failed_or_blocked: "失败或已阻止",
|
||
submitted_unverified: "已提交但未验证",
|
||
verified_success: "反馈验证成功",
|
||
};
|
||
const outcome = outcomeLabels[error.outcome];
|
||
appendSyncLog({
|
||
category: "task",
|
||
title: "编排任务执行失败",
|
||
detail: `${error.detail || error.message || "请求失败"}${outcome ? ` · ${outcome}` : ""}`,
|
||
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);
|
||
startExtensionControlMonitor();
|
||
window.addEventListener("pagehide", stopExtensionControlMonitor);
|
||
window.addEventListener("pageshow", startExtensionControlMonitor);
|
||
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);
|
||
}
|
||
const drawer = $("#gestureDrawer");
|
||
if (!triggerGesture) {
|
||
const gestureVisible = ui.controlMode === "gesture";
|
||
drawer?.setAttribute("aria-hidden", String(!gestureVisible));
|
||
drawer?.classList.toggle("open", gestureVisible);
|
||
return;
|
||
}
|
||
if (ui.controlMode === "gesture") {
|
||
window.CognitiveAgentGesture?.open?.();
|
||
} else if (drawer?.classList.contains("open")) {
|
||
window.CognitiveAgentGesture?.close?.();
|
||
}
|
||
}
|
||
|
||
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 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));
|
||
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);
|
||
}
|
||
|
||
function keepRunButtonAvailable() {
|
||
const runButton = $("#runButton");
|
||
if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false;
|
||
}
|
||
|
||
function syncRuntimePresentation(connectionState = window.CognitiveAgentNetwork?.getState?.()) {
|
||
const connectionText = $("#v2ConnectionText");
|
||
if (connectionText) {
|
||
const status = connectionState?.status || "connecting";
|
||
const labels = {
|
||
connecting: "正在连接服务",
|
||
online: "服务已连接",
|
||
degraded: "连接不稳定",
|
||
offline: "服务已断开",
|
||
};
|
||
connectionText.textContent = labels[status] || labels.connecting;
|
||
connectionText.title = connectionState?.lastError || connectionText.textContent;
|
||
$(".v2-sidebar-footer")?.classList.toggle(
|
||
"offline",
|
||
status === "offline" || status === "degraded",
|
||
);
|
||
}
|
||
keepRunButtonAvailable();
|
||
}
|
||
|
||
function bindEvents() {
|
||
$$('.v2-module-tab').forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
if (!button.disabled) setModule(button.dataset.module);
|
||
});
|
||
});
|
||
$$(".v2-page-button").forEach((button) => {
|
||
button.addEventListener("click", () => setPage(button.dataset.v2PageTarget));
|
||
});
|
||
$("#v2LogoutButton")?.addEventListener(
|
||
"click",
|
||
() => window.CognitiveAgentConsole?.logout?.(),
|
||
);
|
||
$("#v2SidebarToggle")?.addEventListener(
|
||
"click",
|
||
() => setSidebarCollapsed(!ui.sidebarCollapsed),
|
||
);
|
||
document.addEventListener("click", () => {
|
||
closeReasoningPicker();
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
closeReasoningPicker();
|
||
clearTwinDevicePreview({ force: true });
|
||
}
|
||
});
|
||
window.addEventListener("resize", updateScrollCues);
|
||
window.addEventListener("cognitive-agent:result", () => requestAnimationFrame(updateScrollCues));
|
||
window.addEventListener("cognitive-agent:connection", (event) => {
|
||
syncRuntimePresentation(event.detail);
|
||
});
|
||
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
|
||
renderSyncedDevices();
|
||
if (displaySync.activeDevice) {
|
||
presentTwinDevice(displaySync.activeDevice, {
|
||
pinned: Boolean(displaySync.pinnedDevice),
|
||
selectSeat: false,
|
||
});
|
||
}
|
||
});
|
||
const observer = new MutationObserver(() => keepRunButtonAvailable());
|
||
[$("#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 requestHeader = $(".request-header", mainPanel);
|
||
const gestureDrawer = $("#gestureDrawer");
|
||
|
||
const detached = {
|
||
panelHeading,
|
||
modeRail,
|
||
requestHeader,
|
||
gestureDrawer,
|
||
originalForm,
|
||
};
|
||
Object.values(detached).filter(Boolean).forEach((node) => node.remove());
|
||
|
||
buildSidebar(commandPanel);
|
||
prepareRuntimeControls(originalForm);
|
||
buildOperation(
|
||
mainPanel,
|
||
originalForm,
|
||
panelHeading,
|
||
requestHeader,
|
||
gestureDrawer,
|
||
);
|
||
buildModuleSwitcher(mainPanel, modeRail);
|
||
buildOutput(mainPanel, modeRail);
|
||
buildSyncControlLayer();
|
||
bindEvents();
|
||
setControlMode(ui.controlMode, { triggerGesture: false });
|
||
setModule(ui.activeModule, { persist: false });
|
||
setSidebarCollapsed(ui.sidebarCollapsed);
|
||
setPage(0);
|
||
syncRuntimePresentation();
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
requestAnimationFrame(() => {
|
||
document.body.classList.add("v2-ready");
|
||
window.setTimeout(() => $("#v2BootScreen")?.remove(), 220);
|
||
});
|
||
}
|
||
|
||
window.CognitiveAgentConsoleV2 = {
|
||
getModule: () => ui.activeModule,
|
||
getRole: () => "user",
|
||
getParsedCanCommands: (result) => parsedCanCommands(result).map((item) => ({ ...item })),
|
||
positionTwinDevicePopover,
|
||
setModule,
|
||
};
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
}());
|