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

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
+27 -2
View File
@@ -5,8 +5,10 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留
当前页面包括:
- 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。
- AI助手默认展开;左侧控制台、车端设备状态、外设与扩展能力默认收起。四个板块统一通过页面右上角的图标按钮展开或收回,主工作区随面板状态自适应。
- AI助手支持普通问答、工程能力核对和车控工作。能力问答读取运行时权威目录,高置信度冷热请求走确定性快速控制链,其他问题和复杂车控由 Codex App Server 流式处理;车控结果仍以自然语言和可审计的动作进度呈现。
- 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
- 一句话输入框支持麦克风听写,优先采集 `TAKSTAR V2`;AudioWorklet 采集、浏览器端静音停录、服务端 Silero VAD、SenseVoiceSmall、幂等 TranscriptAssembler 和结构化理解层组成完整链路。只有生成 commands 的结果才回填;无关内容不覆盖输入框,uncertain 结果需人工修改后才能执行。
- AI助手语音入口支持双模式:手动点击麦克风按钮会立即以 `manual` 模式录音,不要求唤醒词;浏览器已授权麦克风、助手展开且页面可见时自动进入 `wake` 待机,只有听到“你好,双十一”(兼容无标点与“双11”)后才展示并继续转录。AudioWorklet 采集后,服务端 SenseVoiceSmall 以节流预览返回未语义清洗的实时文字;静音端点立即停止采音,再由 Silero VAD、幂等 TranscriptAssembler 和理解层生成最终 `assistant_text`。普通问答、未知能力、否定和撤销表达均可回填,`commands` 仅作为受信任车控旁路,不能反向过滤助手输入。
- 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
- 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
- USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
@@ -14,6 +16,16 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留
UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。
## AI助手接口
浏览器只使用同源接口,不直接连接 App Server、MCP 或模型服务:
- `GET /api/assistant/status`:返回 App Server 状态、可用性和当前会话数。
- `GET /api/assistant/capabilities`:返回由运行时注册表、模式目录、CAN 绑定和已学习外设共同生成的权威能力快照。`service_surfaces` 只提供语音、手势、NFC、自标定和自修复的状态信息,不代表助手可调用;执行资格以 `assistant_callable` 和服务端校验为准。
- `POST /api/assistant/stream`:提交 `message`、`conversation_id`、执行模式和当前车型等上下文,返回 `application/x-ndjson`。前端消费 `message.accepted`、`work.*`、`action.*`、`answer.delta`、`answer.completed` 与 `error` 事件,以增量方式展示回复和控制进度。
`POST /api/internal/codex/vehicle-control` 是 App Server MCP 子进程专用的回环接口,要求一次性内部令牌且与原始用户文本绑定,不是浏览器或第三方集成接口。浏览器也不能通过响应内容扩大执行目标、切换执行模式或提交原始 CAN。
当前座椅阶段一控制台为无构建静态页面,由 `cognitive_agent.server` 同源提供 API 和资源。运行:
```bash
@@ -27,7 +39,7 @@ python -m pip install -e ".[speech]"
python tools/install_sensevoice_model.py
```
录音只用于当前请求,不落盘;`raw_text`、`clean_text` 与结构化 Intent 分字段返回。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。
录音只用于当前请求,不落盘;`captured_text` 保留完整声学结果,`raw_text` 在 `manual` 模式保留完整原文、在 `wake` 模式剥离唤醒词,`assistant_text` 是面向问答的轻清洗会话,`clean_text/control_text` 与结构化 Intent 则保持严格车控语义。前后端通过 `X-Speech-Input-Mode: manual|wake` 显式区分模式;可用 `SPEECH_WAKE_WORD_ENABLED=false` 关闭唤醒门,或用竖线分隔的 `SPEECH_WAKE_WORDS` 配置主唤醒词与别名。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。
Android PAD/模拟器联调使用:
@@ -114,6 +126,19 @@ TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的
- `POST /api/extension/display-sync`:车机刚识别 USB 外设时上报 `device` 与 `peripherals` 能力目录,无需等待首次执行。
- `POST /api/extension/assistant/plan` 和 `/api/extension/vehicle/execute`:联动规划与车端执行结果同样写入同步快照。
右上角“能耗监测”入口以独立固定页面展示 7 个当前可控车端设备的实时功率、
本次监测累计能耗、总功率趋势和分设备趋势。能耗链路优先接收
`cognitive-agent:power-telemetry` 事件,也兼容调用
`window.updateDevicePowerTelemetry(payload)` 的现有集成方式;载荷可使用
`devices.<device>.power_w` 或 `total_power_w`,并可附带 `energy_wh`、
`trip_energy_kwh` 或 `today_energy_kwh`。累计能耗独立保留,瞬时功率超过 15 秒
未更新时只会回退到状态估算,不会让已上报的车端累计值消失。每条遥测应显式提供
`source: "vehicle_power_sensor"`(实测)或 `source: "estimated"`(估算)。
当前 CAN 状态协议没有独立电流/
功率电表信号,因此未注入新鲜功率数据的设备会显示带 `≈` 和“状态估算”标签的
确定性实时估算,绝不标记为车端实测。真实功率通道接入后页面会逐设备自动切换为
“实测”,无需更换页面或曲线实现。
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
`/login`,主控制台提供退出按钮。固定入口是
`https://511.kendeji.fun`,应用容器只监听服务器回环地址,再由 Caddy 提供
+593 -265
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+879
View File
@@ -0,0 +1,879 @@
(function initializeUnifiedAssistant() {
const STORAGE_KEY = "cognitive-agent.assistant-messages";
const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id";
const MAX_STORED_MESSAGES = 24;
const COMPOSER_MIN_HEIGHT = 64;
const COMPOSER_MAX_HEIGHT = 116;
const WELCOME_TEXT = "你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。";
let pendingController = null;
let pendingMessage = null;
let latestControlResult = null;
let initialized = false;
const $ = (selector, root = document) => root.querySelector(selector);
function setDrawerOpen(open, { restoreFocus = false } = {}) {
const drawer = $("#assistantDrawer");
const trigger = $("#assistantDrawerToggle");
if (!drawer || !trigger) return;
document.body.classList.toggle("assistant-drawer-open", open);
trigger.classList.toggle("active", open);
trigger.setAttribute("aria-pressed", String(open));
trigger.setAttribute("aria-expanded", String(open));
const triggerLabel = open ? "收起AI助手" : "展开AI助手";
trigger.setAttribute("aria-label", triggerLabel);
trigger.title = triggerLabel;
drawer.setAttribute("aria-hidden", String(!open));
drawer.toggleAttribute("inert", !open);
const speech = window.CognitiveAgentSpeech;
if (typeof speech?.setAssistantOpen === "function") {
void speech.setAssistantOpen(open);
}
if (open) {
requestAnimationFrame(() => $("#utterance")?.focus({ preventScroll: true }));
} else {
// A collapsed assistant must never leave an invisible microphone or
// preview request running in the background.
if (typeof speech?.setAssistantOpen !== "function") {
void speech?.stop?.("AI助手已收起,语音输入已停止");
}
if (restoreFocus) trigger.focus({ preventScroll: true });
}
}
function installAssistantDrawer() {
const drawer = $(".v2-operation-shell");
const heading = $(".v2-operation-heading", drawer);
if (!drawer || !heading || $("#assistantDrawerToggle")) return;
drawer.id = "assistantDrawer";
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", "AI助手");
const actions = document.createElement("div");
actions.className = "assistant-drawer-actions";
const reset = $("#newConversation");
if (reset) {
reset.classList.add("assistant-drawer-new");
actions.append(reset);
}
heading.append(actions);
const trigger = document.createElement("button");
trigger.id = "assistantDrawerToggle";
trigger.className = "assistant-drawer-toggle v2-panel-control-button";
trigger.type = "button";
trigger.setAttribute("aria-controls", drawer.id);
trigger.setAttribute("aria-pressed", "false");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", "展开AI助手");
trigger.title = "展开AI助手";
trigger.innerHTML = '<i data-lucide="bot-message-square"></i><span class="visually-hidden">AI助手</span><b aria-hidden="true"></b>';
($("#v2PanelControlDock") || document.body).append(trigger);
trigger.addEventListener("click", () => {
setDrawerOpen(!document.body.classList.contains("assistant-drawer-open"));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && document.body.classList.contains("assistant-drawer-open")) {
event.preventDefault();
setDrawerOpen(false, { restoreFocus: true });
}
});
window.addEventListener("cognitive-agent:module-change", (event) => {
if (event.detail?.module === "self-extension") setDrawerOpen(false);
});
setDrawerOpen(true);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function createMessageId() {
if (globalThis.crypto?.randomUUID) return `ui-${globalThis.crypto.randomUUID()}`;
return `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createConversationId() {
if (globalThis.crypto?.randomUUID) return `conv-${globalThis.crypto.randomUUID()}`;
return `conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function conversationId() {
const existing = sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
|| window.CognitiveAgentConsole?.getConversationId?.();
if (existing) return existing;
const created = createConversationId();
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, created);
return created;
}
function loadStoredMessages() {
try {
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(stored)
? stored.filter((item) => item && ["user", "assistant"].includes(item.role))
: [];
} catch (_error) {
return [];
}
}
function persistMessages() {
const messages = [...document.querySelectorAll("#assistantMessages [data-message-role]")]
.map((node) => ({
role: node.dataset.messageRole,
content: node.dataset.messageContent || "",
}))
.filter((item) => item.content && item.content !== WELCOME_TEXT)
.slice(-MAX_STORED_MESSAGES);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
}
function appendInlineText(container, text) {
const parts = String(text).split(/(`[^`\n]+`)/g);
parts.forEach((part) => {
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
const code = document.createElement("code");
code.textContent = part.slice(1, -1);
container.append(code);
} else {
container.append(document.createTextNode(part));
}
});
}
function renderRichText(container, value) {
container.replaceChildren();
const sections = String(value).split(/```(?:[\w+-]+)?\n?([\s\S]*?)```/g);
sections.forEach((section, index) => {
if (index % 2 === 1) {
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = section.replace(/\n$/, "");
pre.append(code);
container.append(pre);
return;
}
const lines = section.split("\n");
let list = null;
lines.forEach((line) => {
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
if (bullet) {
if (!list) {
list = document.createElement("ul");
container.append(list);
}
const item = document.createElement("li");
appendInlineText(item, bullet[1]);
list.append(item);
return;
}
list = null;
if (!line.trim()) return;
const heading = line.match(/^#{1,3}\s+(.+)$/);
const block = document.createElement(heading ? "strong" : "p");
if (heading) block.className = "assistant-inline-heading";
appendInlineText(block, heading ? heading[1] : line);
container.append(block);
});
});
}
function shouldFollowLatest(transcript) {
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 90;
}
function followLatest(force = false) {
const transcript = $("#assistantTranscript");
if (!transcript || (!force && !shouldFollowLatest(transcript))) return;
requestAnimationFrame(() => {
transcript.scrollTop = transcript.scrollHeight;
});
}
function resizeComposerInput(input = $("#utterance")) {
if (!input) return;
input.style.height = "auto";
const contentHeight = input.scrollHeight;
const nextHeight = Math.max(
COMPOSER_MIN_HEIGHT,
Math.min(contentHeight, COMPOSER_MAX_HEIGHT),
);
input.style.height = `${nextHeight}px`;
const scrollable = contentHeight > COMPOSER_MAX_HEIGHT + 1;
input.classList.toggle("is-scrollable", scrollable);
if (!scrollable) input.scrollTop = 0;
}
function createMessage(role, content = "", { pending = false } = {}) {
const article = document.createElement("article");
article.className = `assistant-message assistant-message-${role}`;
article.dataset.messageId = createMessageId();
article.dataset.messageRole = role;
article.dataset.messageContent = content;
if (role === "assistant") {
const avatar = document.createElement("span");
avatar.className = "assistant-avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.innerHTML = '<i data-lucide="waypoints"></i>';
article.append(avatar);
}
const body = document.createElement("div");
body.className = "assistant-message-body";
const contentNode = document.createElement("div");
contentNode.className = "assistant-message-content";
if (pending && !content) {
contentNode.classList.add("assistant-thinking");
contentNode.innerHTML = '<span>正在理解你的需求</span><i></i><i></i><i></i>';
} else if (role === "assistant") {
renderRichText(contentNode, content);
} else {
contentNode.textContent = content;
}
body.append(contentNode);
const action = document.createElement("div");
action.className = "assistant-action-status";
action.hidden = true;
action.setAttribute("role", "status");
body.append(action);
article.append(body);
$("#assistantMessages").append(article);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest(true);
return { article, body, contentNode, action, content };
}
function setMessageContent(message, content, { final = false } = {}) {
message.content = content;
message.article.dataset.messageContent = content;
message.contentNode.classList.remove("assistant-thinking");
if (final) renderRichText(message.contentNode, content);
else message.contentNode.textContent = content;
followLatest();
}
function setActionStatus(message, text, state = "working") {
message.action.hidden = !text;
message.action.className = `assistant-action-status ${state}`;
message.action.replaceChildren();
if (!text) return;
const icon = document.createElement("i");
icon.setAttribute(
"data-lucide",
state === "success" ? "circle-check" : state === "failed" ? "circle-x" : "loader-circle",
);
const label = document.createElement("span");
label.textContent = text;
message.action.append(icon, label);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest();
}
function appendModeCreatedState(message, profile) {
if (!message?.body || !profile?.id) return;
const selector = `[data-assistant-mode-created="${CSS.escape(String(profile.id))}"]`;
if ($(selector, message.body)) return;
const card = document.createElement("div");
card.className = "assistant-mode-created";
card.dataset.assistantModeCreated = String(profile.id);
const icon = document.createElement("span");
icon.className = "assistant-mode-created-icon";
icon.innerHTML = '<i data-lucide="circle-check"></i>';
const copy = document.createElement("span");
copy.className = "assistant-mode-created-copy";
const label = document.createElement("small");
label.textContent = "已添加到「我的模式」";
const name = document.createElement("strong");
name.textContent = String(profile.name || "新功能模式");
copy.append(label, name);
const reveal = document.createElement("button");
reveal.type = "button";
reveal.textContent = "查看";
reveal.addEventListener("click", () => {
window.CognitiveAgentConsole?.revealModeProfile?.(String(profile.id), { focus: true });
});
card.append(icon, copy, reveal);
message.body.append(card);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest(true);
}
function modeDraftRows(draft, rawActions) {
const instruction = String(draft?.instruction || "");
const actions = Array.isArray(rawActions)
? rawActions.filter((item) => item && typeof item === "object")
: [];
const consumed = new Set();
const rows = [];
const targetZone = (action) => String(
action?.target?.instance || action?.target?.zone || action?.target || "",
);
const parameters = (action) => (
action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
);
const instructionOrder = (...terms) => {
const indexes = terms.map((term) => instruction.indexOf(term)).filter((index) => index >= 0);
return indexes.length ? Math.min(...indexes) : 10_000 + rows.length;
};
const addRow = (label, value, icon, order, indexes = []) => {
indexes.forEach((index) => consumed.add(index));
rows.push({ label, value, icon, order });
};
const zeroGravityIndexes = actions
.map((action, index) => ({ action, index }))
.filter(({ action }) => String(action.capability || "").startsWith("seat.set_")
&& ["passenger", "front_right"].includes(targetZone(action)))
.map(({ index }) => index);
if (/零重力/.test(instruction) && zeroGravityIndexes.length >= 2) {
addRow(
"副驾座椅",
"零重力模式",
"armchair",
instructionOrder("副驾", "零重力"),
zeroGravityIndexes,
);
}
const lightIndexes = actions
.map((action, index) => ({ action, index }))
.filter(({ action }) => ["interior_light", "ambient_light"].includes(String(action.domain || "")))
.map(({ index }) => index);
if (
lightIndexes.length >= 2
&& /(?:灯光.*(?:全部|所有|全车).*(?:关闭|关掉|熄灭)|(?:全部|所有|全车).*灯光.*(?:关闭|关掉|熄灭))/.test(instruction)
) {
addRow(
"座舱灯光",
"全部关闭",
"lightbulb-off",
instructionOrder("灯光"),
lightIndexes,
);
}
const motionLabel = (value) => ({
open: "打开",
close: "关闭",
stop: "停止",
half: "半开",
on: "开启",
off: "关闭",
}[String(value)] || String(value || "已配置"));
actions.forEach((action, index) => {
if (consumed.has(index)) return;
const capability = String(action.capability || "");
const domain = String(action.domain || "");
const zone = targetZone(action);
const params = parameters(action);
let label = capability || domain || "座舱设备";
let value = "已配置";
let icon = "sliders-horizontal";
let order = 10_000 + index;
if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") {
label = "遮阳帘";
value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion);
icon = "blinds";
order = instructionOrder("遮阳帘", "天幕帘");
} else if (domain === "window") {
const windowLabels = {
all: "全部车窗",
front: "前排车窗",
rear: "后排车窗",
front_left: "主驾车窗",
front_right: "副驾车窗",
rear_left: "左后车窗",
rear_right: "右后车窗",
};
label = windowLabels[zone] || "车窗";
value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion);
icon = "panels-top-left";
order = instructionOrder("车窗");
} else if (domain === "seat") {
label = ["passenger", "front_right"].includes(zone) ? "副驾座椅" : "主驾座椅";
value = params.position_pct != null
? `${params.position_pct}%`
: motionLabel(params.state || params.mode_id);
icon = "armchair";
order = instructionOrder("座椅", "副驾", "主驾");
} else if (capability === "climate.set_temperature") {
label = "空调温度";
value = `${params.temperature_c ?? "--"}℃`;
icon = "thermometer";
order = instructionOrder("空调", "温度");
} else if (domain === "climate") {
label = "空调";
value = params.level != null ? `${params.level}档` : motionLabel(params.state);
icon = "fan";
order = instructionOrder("空调", "风量");
} else if (capability === "interior_light.set_reading_power") {
label = "阅读灯";
value = motionLabel(params.state);
icon = "book-open";
order = instructionOrder("阅读灯", "灯光");
} else if (["interior_light", "ambient_light"].includes(domain)) {
label = domain === "ambient_light" ? "氛围灯" : "室内灯";
value = motionLabel(params.state);
icon = "lightbulb";
order = instructionOrder(label, "灯光");
}
addRow(label, value, icon, order, [index]);
});
return rows.sort((left, right) => left.order - right.order);
}
async function saveModeProfileDraft(message, card, draft, buttons, status) {
buttons.forEach((button) => { button.disabled = true; });
card.classList.add("is-busy");
status.hidden = false;
status.className = "assistant-mode-draft-status working";
status.textContent = "正在保存到「我的模式」…";
try {
const response = await window.CognitiveAgentNetwork.request("/api/mode-profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: draft.name,
instruction: draft.instruction,
source_request_id: draft.source_request_id,
vehicle_id: draft.vehicle_id || $("#vehicleSelect")?.value || null,
}),
timeoutMs: 45 * 1000,
retries: 0,
});
const payload = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw new Error(payload.message || payload.error || `HTTP ${response.status}`);
card.classList.remove("is-busy");
card.classList.add("is-saved");
status.className = "assistant-mode-draft-status success";
status.textContent = "模式已保存";
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id);
appendModeCreatedState(message, payload.mode_profile);
persistMessages();
} catch (error) {
card.classList.remove("is-busy");
status.className = "assistant-mode-draft-status failed";
status.textContent = error.message || "保存失败,请重试。";
buttons.forEach((button) => { button.disabled = false; });
}
}
async function tryModeProfileDraft(card, draft, buttons, status) {
buttons.forEach((button) => { button.disabled = true; });
card.classList.add("is-busy");
status.hidden = false;
status.className = "assistant-mode-draft-status working";
status.textContent = "正在进行模拟试运行…";
try {
const result = await window.CognitiveAgentConsole?.tryModeDraft?.(draft);
if (!result || result.status !== "succeeded") throw new Error("试运行未通过,请检查动作设置。");
status.className = "assistant-mode-draft-status success";
status.textContent = "试运行通过 · 尚未保存";
} catch (error) {
status.className = "assistant-mode-draft-status failed";
status.textContent = error.message || "试运行失败,请重试。";
} finally {
card.classList.remove("is-busy");
buttons.forEach((button) => { button.disabled = false; });
}
}
function appendModeDraftState(message, draft, actions) {
if (!message?.body || !draft?.name || !draft?.source_request_id) return;
const selector = `[data-assistant-mode-draft="${CSS.escape(String(draft.source_request_id))}"]`;
if ($(selector, message.body)) return;
const card = document.createElement("section");
card.className = "assistant-mode-draft";
card.dataset.assistantModeDraft = String(draft.source_request_id);
message.article?.classList.add("assistant-message-mode-draft");
const heading = document.createElement("header");
const headingCopy = document.createElement("span");
const eyebrow = document.createElement("small");
eyebrow.textContent = "已为你生成功能模式";
const title = document.createElement("strong");
title.textContent = `「${draft.name}」`;
headingCopy.append(eyebrow, title);
const badge = document.createElement("span");
badge.className = "assistant-mode-draft-badge";
badge.textContent = `${Number(draft.operation_count) || 0} 项动作`;
heading.append(headingCopy, badge);
const list = document.createElement("div");
list.className = "assistant-mode-draft-list";
modeDraftRows(draft, actions).forEach((item) => {
const row = document.createElement("div");
row.className = "assistant-mode-draft-row";
const icon = document.createElement("i");
icon.dataset.lucide = item.icon;
const label = document.createElement("span");
label.textContent = item.label;
const value = document.createElement("strong");
value.textContent = item.value;
row.append(icon, label, value);
list.append(row);
});
const controls = document.createElement("div");
controls.className = "assistant-mode-draft-actions";
const save = document.createElement("button");
save.type = "button";
save.className = "assistant-mode-draft-save";
save.innerHTML = '<i data-lucide="bookmark-plus"></i><span>保存为功能模式</span>';
const trial = document.createElement("button");
trial.type = "button";
trial.className = "assistant-mode-draft-trial";
trial.textContent = "先试运行";
controls.append(save, trial);
const status = document.createElement("p");
status.className = "assistant-mode-draft-status";
status.hidden = true;
status.setAttribute("role", "status");
const buttons = [save, trial];
save.addEventListener("click", () => {
void saveModeProfileDraft(message, card, draft, buttons, status);
});
trial.addEventListener("click", () => {
void tryModeProfileDraft(card, draft, buttons, status);
});
card.append(heading, list, controls, status);
message.body.append(card);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest(true);
}
function startModeCreation() {
setDrawerOpen(true);
const input = $("#utterance");
if (!input) return;
const helper = createMessage(
"assistant",
"告诉我模式名称和希望联动的座舱状态。比如:创建宝宝睡眠模式,副驾半躺、空调 24℃、关闭阅读灯。我会先生成动作预览,确认后再加入「我的模式」。",
);
helper.article.classList.add("assistant-mode-guide");
input.value = "请帮我创建一个";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.focus({ preventScroll: true });
input.setSelectionRange(input.value.length, input.value.length);
}
function setComposerPending(pending) {
const button = $("#runButton");
const input = $("#utterance");
if (!button || !input) return;
document.body.classList.toggle("assistant-drawer-busy", pending);
button.classList.toggle("assistant-stop", pending);
button.setAttribute("aria-label", pending ? "停止生成" : "发送消息");
button.title = pending ? "停止生成" : "发送消息";
// General questions must stay available even when no physical CAN adapter
// is connected; hardware eligibility is evaluated only for control routes.
button.disabled = false;
button.innerHTML = pending
? '<i data-lucide="square"></i><span>停止</span>'
: '<i data-lucide="send-horizontal"></i><span>发送</span>';
input.setAttribute("aria-busy", String(pending));
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function requestBody(message) {
const executionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run";
const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can";
const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
const vehicleId = $("#vehicleSelect")?.value || null;
return {
message,
conversation_id: conversationId(),
client_type: "web_console_codex_assistant",
execution_mode: executionMode,
control_transport: controlTransport,
...(vehicleId ? { vehicle_id: vehicleId } : {}),
...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId
? { hardware_client_id: hardwareClientId }
: {}),
...(executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault")?.value || "normal" }
: {}),
};
}
async function readErrorResponse(response) {
try {
const payload = await response.json();
return payload.message || payload.error || `HTTP ${response.status}`;
} catch (_error) {
return `HTTP ${response.status}`;
}
}
function applyAssistantEvent(event, message) {
if (event.type === "answer.delta") {
setMessageContent(message, `${message.content}${event.delta || ""}`);
return;
}
if (["action.started", "action.progress", "work.started", "work.progress"].includes(event.type)) {
const defaultText = event.type.startsWith("work.")
? "AI助手正在处理工作……"
: "正在执行座舱控制……";
setActionStatus(message, event.display_text || defaultText, "working");
if (event.type === "action.progress" && event.payload) {
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-activity", {
detail: event.payload,
}));
}
return;
}
if (event.type === "action.completed" || event.type === "work.completed") {
latestControlResult = event.payload || latestControlResult;
const completed = event.success !== false;
const defaultText = event.type === "work.completed"
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
: (completed ? "座舱控制已完成" : "座舱控制未完成");
setActionStatus(
message,
event.display_text || defaultText,
completed ? "success" : "failed",
);
if (event.payload) {
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
if (
event.payload.mode_profile_draft
&& event.payload.orchestration_route === "mode_profile_create_preview"
) {
appendModeDraftState(
message,
event.payload.mode_profile_draft,
event.payload.actions,
);
} else if (
event.payload.mode_profile
&& event.payload.orchestration_route === "mode_profile_create"
) {
appendModeCreatedState(message, event.payload.mode_profile);
}
}
return;
}
if (event.type === "answer.completed") {
const authoritativeAnswer = Object.prototype.hasOwnProperty.call(event, "answer")
? String(event.answer || "")
: message.content;
setMessageContent(message, authoritativeAnswer, { final: true });
return;
}
if (event.type === "error") {
const error = new Error(event.message || "请求处理失败");
error.code = event.error;
throw error;
}
}
async function streamAssistant(messageText, assistantMessage, signal) {
const response = await window.CognitiveAgentNetwork.request("/api/assistant/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson",
},
body: JSON.stringify(requestBody(messageText)),
timeoutMs: 15 * 60 * 1000,
retries: 0,
signal,
});
if (!response.ok) throw new Error(await readErrorResponse(response));
if (!response.body?.getReader) throw new Error("当前浏览器不支持流式问答");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
let answerCompleted = false;
const abortError = () => {
const error = new Error("助手响应已取消");
error.name = "AbortError";
return error;
};
const cancelReader = () => {
// CognitiveAgentNetwork resolves once response headers arrive. Cancelling
// the body reader keeps the Stop button effective for the remaining
// long-lived NDJSON stream as well.
reader.cancel(signal.reason).catch(() => {});
};
signal.addEventListener("abort", cancelReader, { once: true });
try {
if (signal.aborted) {
await reader.cancel(signal.reason).catch(() => {});
throw abortError();
}
while (true) {
const { done, value } = await reader.read();
if (signal.aborted) throw abortError();
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffered.split("\n");
buffered = done ? "" : lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
applyAssistantEvent(event, assistantMessage);
if (event.type === "answer.completed") answerCompleted = true;
}
if (done) break;
}
if (buffered.trim()) {
const event = JSON.parse(buffered);
applyAssistantEvent(event, assistantMessage);
if (event.type === "answer.completed") answerCompleted = true;
}
if (!answerCompleted) throw new Error("助手响应意外中断,请重试。");
} finally {
signal.removeEventListener("abort", cancelReader);
reader.releaseLock();
}
}
async function submitAssistant() {
if (pendingController) {
pendingController.abort("user_stop");
return;
}
const input = $("#utterance");
const speechPhase = window.CognitiveAgentSpeech?.phase;
const speechInputMode = window.CognitiveAgentSpeech?.inputMode;
if (["capturing", "finalizing"].includes(speechPhase)
|| (speechPhase === "arming" && speechInputMode === "manual")) {
window.CognitiveAgentConsole?.notify?.("请等待语音整理完成后再发送");
return;
}
const messageText = input?.value.trim() || "";
if (!messageText) {
window.CognitiveAgentConsole?.notify?.("请输入问题或工作需求");
return;
}
createMessage("user", messageText);
input.value = "";
delete input.dataset.speechStage;
delete input.dataset.speechUtteranceId;
delete input.dataset.speechValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
const assistantMessage = createMessage("assistant", "", { pending: true });
pendingMessage = assistantMessage;
latestControlResult = null;
pendingController = new AbortController();
setComposerPending(true);
let completedError = null;
try {
await streamAssistant(messageText, assistantMessage, pendingController.signal);
setMessageContent(assistantMessage, assistantMessage.content, { final: true });
persistMessages();
} catch (error) {
completedError = error;
if (pendingController.signal.aborted) {
const stopped = assistantMessage.content
? `${assistantMessage.content}\n\n已停止继续生成。已下发的车辆操作仍会由安全执行链路完成。`
: "已停止生成。已下发的车辆操作仍会由安全执行链路完成。";
setMessageContent(assistantMessage, stopped, { final: true });
setActionStatus(assistantMessage, "已停止显示", "failed");
} else {
setMessageContent(assistantMessage, error.message || "请求处理失败,请稍后再试。", {
final: true,
});
setActionStatus(assistantMessage, "本次请求未完成", "failed");
}
persistMessages();
} finally {
pendingController = null;
pendingMessage = null;
setComposerPending(false);
input.focus({ preventScroll: true });
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
detail: {
utterance: messageText,
result: latestControlResult,
error: completedError,
},
}));
}
}
function renderInitialMessages() {
const container = $("#assistantMessages");
if (!container) return;
const stored = loadStoredMessages();
container.replaceChildren();
createMessage("assistant", WELCOME_TEXT);
stored.forEach((message) => createMessage(message.role, String(message.content || "")));
}
function initialize() {
if (initialized || !$("#requestForm") || !$("#assistantMessages")) return;
initialized = true;
document.body.classList.add("assistant-chat-enabled");
const title = $(".v2-operation-heading h2");
if (title) title.textContent = "AI助手";
const input = $("#utterance");
input.value = "";
input.placeholder = "向 AI助手提问或交代工作";
input.setAttribute("aria-label", "向 AI助手提问或交代工作");
installAssistantDrawer();
input.addEventListener("input", () => resizeComposerInput(input));
resizeComposerInput(input);
// Some browsers restore the last submitted textarea value after
// DOMContentLoaded. Clear that restoration once the initial page settles so
// a reload cannot accidentally resend an already handled vehicle command.
window.setTimeout(() => {
if (pendingController) return;
input.value = "";
resizeComposerInput(input);
}, 300);
renderInitialMessages();
setComposerPending(false);
}
document.addEventListener("submit", (event) => {
if (!(event.target instanceof HTMLFormElement) || event.target.id !== "requestForm") return;
event.preventDefault();
event.stopImmediatePropagation();
submitAssistant();
}, true);
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest("#newConversation")) return;
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
}, true);
document.addEventListener("DOMContentLoaded", initialize);
window.CognitiveAgentAssistant = {
submit: submitAssistant,
open: () => setDrawerOpen(true),
close: () => setDrawerOpen(false),
startModeCreation,
announceModeCreated(profile) {
if (!profile?.id) return;
setDrawerOpen(true);
const message = createMessage("assistant", `已完成「${profile.name || "新功能模式"}」的配置。`);
appendModeCreatedState(message, profile);
persistMessages();
},
stop() {
pendingController?.abort("user_stop");
},
isPending: () => Boolean(pendingMessage),
};
if (window.__N7WEB_ASSISTANT_TEST__ === true) {
window.CognitiveAgentAssistant.__test = { setDrawerOpen };
}
}());
File diff suppressed because it is too large Load Diff
+301 -40
View File
@@ -17,7 +17,7 @@
: "natural",
activeModule: "self-orchestration",
activePage: 0,
sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true",
sidebarCollapsed: true,
};
let reasoningPickerControl = null;
@@ -580,13 +580,17 @@
function buildSidebar(commandPanel) {
commandPanel.classList.add("v2-sidebar");
commandPanel.id = "v2SidebarPanel";
commandPanel.innerHTML = "";
const sidebarToggle = make("button", "v2-sidebar-toggle", '<i data-lucide="panel-left-close"></i>');
const sidebarToggle = make("button", "v2-sidebar-toggle v2-panel-control-button", '<i data-lucide="panel-left"></i><span class="visually-hidden">左侧控制台</span>');
sidebarToggle.id = "v2SidebarToggle";
sidebarToggle.type = "button";
sidebarToggle.setAttribute("aria-label", "折叠左侧控制台");
sidebarToggle.setAttribute("aria-expanded", "true");
sidebarToggle.setAttribute("aria-controls", commandPanel.id);
sidebarToggle.setAttribute("aria-label", "展开左侧控制台");
sidebarToggle.setAttribute("aria-pressed", "false");
sidebarToggle.setAttribute("aria-expanded", "false");
sidebarToggle.title = "展开左侧控制台";
const footer = make("footer", "v2-sidebar-footer", `
<span class="v2-live-dot"></span>
@@ -617,7 +621,7 @@
});
}
function buildOperation(mainPanel, originalForm, panelHeading, modeRail, requestHeader, gestureDrawer) {
function buildOperation(mainPanel, originalForm, panelHeading, requestHeader, gestureDrawer) {
const operation = make("section", "v2-operation-shell", `
<div class="v2-operation-glow" aria-hidden="true"></div>
<div class="v2-natural-panel" id="v2NaturalPanel"></div>
@@ -628,7 +632,7 @@
if (panelHeading) {
panelHeading.classList.add("v2-operation-heading", "v2-module-heading");
const title = $("h2", panelHeading);
if (title) title.textContent = "一句话远程编排";
if (title) title.textContent = "AI助手";
naturalPanel.append(panelHeading);
}
@@ -641,11 +645,6 @@
naturalGrid.append(requestHeader);
}
naturalPanel.append(naturalGrid);
if (modeRail) {
modeRail.classList.add("v2-mode-rail");
naturalPanel.append(modeRail);
}
const gesturePanel = $("#v2GesturePanel");
const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `
<h2>抬抬手,控制整车</h2>`);
@@ -699,12 +698,16 @@
<div><h1>511工程-智能座舱</h1></div>
</div>
<div class="v2-remote-control-state"><i data-lucide="radio-tower"></i><span>网页控制链路</span><strong>已启用</strong></div>
<div class="v2-unified-live"><i></i><span>LIVE</span><b id="v2SyncClock">--:--:--</b></div>`;
<div class="v2-unified-live"><i></i><span>LIVE</span><b id="v2SyncClock">--:--:--</b></div>
<nav class="v2-panel-control-dock" id="v2PanelControlDock" aria-label="页面板块展开与收起"></nav>`;
mainPanel.prepend(switcher);
const panelDock = $("#v2PanelControlDock", switcher);
const sidebarToggle = $("#v2SidebarToggle");
if (panelDock && sidebarToggle) panelDock.append(sidebarToggle);
}
function buildOutput(mainPanel) {
function buildOutput(mainPanel, modeRail) {
const output = make("section", "v2-output-shell", `
<header class="v2-output-heading">
<div>
@@ -717,6 +720,11 @@
<nav class="v2-page-rail" id="v2PageRail" aria-label="信息分页" hidden></nav>`);
mainPanel.append(output);
if (modeRail) {
modeRail.classList.add("v2-mode-rail");
mainPanel.insertBefore(modeRail, output);
}
const content = $("#v2OutputContent");
const twin = $(".joint-twin-shell", mainPanel);
const resultWorkspace = $(".result-workspace", mainPanel);
@@ -1165,14 +1173,17 @@
function setSidePanelVisibility(key, visible) {
const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel");
if (!panel) return;
panel.hidden = false;
const panelName = key === "devices" ? "车端设备状态" : "外设与扩展能力";
panel.hidden = !visible;
panel.classList.toggle("is-collapsed", !visible);
panel.dataset.collapsed = String(!visible);
sessionStorage.setItem(`cognitive-agent.sync-panel.${key}`, visible ? "visible" : "hidden");
$$(`[data-panel-toggle="${key}"], [data-panel-close="${key}"]`).forEach((button) => {
button.classList.toggle("active", visible);
button.setAttribute("aria-pressed", String(visible));
button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${key === "devices" ? "车端设备状态" : "外设与扩展能力"}`);
button.setAttribute("aria-expanded", String(visible));
button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${panelName}`);
button.title = `${visible ? "收起" : "展开"}${panelName}`;
const actionLabel = $("[data-panel-action-label]", button);
if (actionLabel) actionLabel.textContent = visible ? "收起" : "展开";
const stateLabel = $("[data-panel-state-label]", button);
@@ -1183,9 +1194,234 @@
return candidate && !candidate.classList.contains("is-collapsed");
});
$(".joint-twin-shell")?.setAttribute("data-open-panels", openPanels.join(" ") || "none");
const rightRail = $(".v2-unified-right-rail");
if (rightRail) {
const collapsed = openPanels.length === 0;
rightRail.hidden = collapsed;
rightRail.classList.toggle("is-collapsed", collapsed);
rightRail.toggleAttribute("inert", collapsed);
rightRail.dataset.openPanels = openPanels.join(" ") || "none";
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.dispatchEvent(new Event("resize"));
}
const twinPopoverClamp = (value, minimum, maximum) => (
maximum < minimum ? minimum : Math.max(minimum, Math.min(maximum, value))
);
function twinPopoverIntersectionArea(first, second) {
const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
return width * height;
}
function positionTwinDevicePopover(deviceId = displaySync.activeDevice) {
const stage = $("#seatTwin");
const popover = $("#v2TwinDevicePopover");
const hotspot = deviceId
? $(`[data-twin-device="${deviceId}"]`, stage)
: null;
const hotspotLabel = hotspot ? $(".v2-twin-hotspot-label", hotspot) : null;
if (!stage || !popover || popover.hidden || !hotspot || !hotspotLabel) return null;
if (hotspot.hidden) {
popover.dataset.anchorVisible = "false";
if (popover.dataset.pinned === "false") popover.hidden = true;
return null;
}
popover.dataset.anchorVisible = "true";
const stageRect = stage.getBoundingClientRect();
const labelClientRect = hotspotLabel.getBoundingClientRect();
const popoverWidth = popover.offsetWidth;
const popoverHeight = popover.offsetHeight;
if (!stageRect.width || !stageRect.height || !popoverWidth || !popoverHeight) return null;
const margin = stageRect.width < 520 ? 8 : 12;
const gap = stageRect.width < 520 ? 8 : 12;
const bounds = {
left: margin,
top: margin,
right: Math.max(margin, stageRect.width - margin),
bottom: Math.max(margin, stageRect.height - margin),
};
const labelRect = {
left: labelClientRect.left - stageRect.left,
top: labelClientRect.top - stageRect.top,
right: labelClientRect.right - stageRect.left,
bottom: labelClientRect.bottom - stageRect.top,
};
labelRect.width = labelRect.right - labelRect.left;
labelRect.height = labelRect.bottom - labelRect.top;
labelRect.centerX = (labelRect.left + labelRect.right) / 2;
labelRect.centerY = (labelRect.top + labelRect.bottom) / 2;
const toStageRect = (element, padding = 0) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left - stageRect.left - padding,
top: rect.top - stageRect.top - padding,
right: rect.right - stageRect.left + padding,
bottom: rect.bottom - stageRect.top + padding,
};
};
const visibleElement = (element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const labelObstacles = $$(".v2-twin-hotspot-label", stage)
.filter((element) => element !== hotspotLabel && visibleElement(element))
.map((element) => ({
deviceId: element.closest("[data-twin-device]")?.dataset.twinDevice || "",
rect: toStageRect(element, 3),
}));
const chromeObstacles = $$([".twin-seat-label", ".twin-orientation", ".twin-readout:not([hidden])"].join(","), stage)
.filter(visibleElement)
.map((element) => toStageRect(element, 5));
const activeLabelObstacle = {
left: labelRect.left - 10,
top: labelRect.top - 10,
right: labelRect.right + 10,
bottom: labelRect.bottom + 10,
};
const geometrySignature = [
deviceId,
popover.dataset.pinned,
Math.round(stageRect.width),
Math.round(stageRect.height),
Math.round(popoverWidth),
Math.round(popoverHeight),
Math.round(labelRect.left),
Math.round(labelRect.top),
...labelObstacles.flatMap(({ deviceId: obstacleDeviceId, rect }) => [
obstacleDeviceId,
Math.round(rect.left),
Math.round(rect.top),
]),
...chromeObstacles.flatMap((rect) => [Math.round(rect.left), Math.round(rect.top)]),
].join(":");
if (popover.dataset.layoutSignature === geometrySignature) return null;
popover.dataset.layoutSignature = geometrySignature;
const availableSpace = {
right: bounds.right - labelRect.right,
left: labelRect.left - bounds.left,
bottom: bounds.bottom - labelRect.bottom,
top: labelRect.top - bounds.top,
};
const sideOrder = Object.keys(availableSpace).sort((first, second) => (
availableSpace[second] - availableSpace[first]
));
const rawCandidates = [];
const pushSideCandidates = (side) => {
const horizontal = side === "left" || side === "right";
const alignments = horizontal
? [
[labelRect.centerY - popoverHeight / 2, "center"],
[labelRect.top, "start"],
[labelRect.bottom - popoverHeight, "end"],
]
: [
[labelRect.centerX - popoverWidth / 2, "center"],
[labelRect.left, "start"],
[labelRect.right - popoverWidth, "end"],
];
alignments.forEach(([crossAxis, alignment], alignmentIndex) => {
let left = crossAxis;
let top = crossAxis;
if (side === "right") {
left = labelRect.right + gap;
top = crossAxis;
} else if (side === "left") {
left = labelRect.left - gap - popoverWidth;
top = crossAxis;
} else if (side === "bottom") {
left = crossAxis;
top = labelRect.bottom + gap;
} else {
left = crossAxis;
top = labelRect.top - gap - popoverHeight;
}
rawCandidates.push({ left, top, side, alignment, alignmentIndex, anchored: true });
});
};
sideOrder.forEach(pushSideCandidates);
const maximumLeft = bounds.right - popoverWidth;
const maximumTop = bounds.bottom - popoverHeight;
[bounds.left, labelRect.left - gap - popoverWidth, labelRect.centerX - popoverWidth / 2,
labelRect.right + gap, maximumLeft].forEach((left) => {
[bounds.top, labelRect.top - gap - popoverHeight, labelRect.centerY - popoverHeight / 2,
labelRect.bottom + gap, maximumTop].forEach((top) => {
rawCandidates.push({ left, top, side: "adaptive", alignment: "free", alignmentIndex: 0, anchored: false });
});
});
const seen = new Set();
const previousPlacement = popover.dataset.anchorDevice === deviceId
? `${popover.dataset.placement || ""}:${popover.dataset.alignment || ""}`
: "";
const candidates = rawCandidates.map((candidate) => {
const left = twinPopoverClamp(candidate.left, bounds.left, maximumLeft);
const top = twinPopoverClamp(candidate.top, bounds.top, maximumTop);
const key = `${Math.round(left)}:${Math.round(top)}`;
if (seen.has(key)) return null;
seen.add(key);
const rect = {
left,
top,
right: left + popoverWidth,
bottom: top + popoverHeight,
};
const clampDistance = Math.abs(left - candidate.left) + Math.abs(top - candidate.top);
const activeOverlap = twinPopoverIntersectionArea(rect, activeLabelObstacle);
const shadeOverlap = labelObstacles.reduce((total, obstacle) => (
total + (obstacle.deviceId === "shade" ? twinPopoverIntersectionArea(rect, obstacle.rect) : 0)
), 0);
const labelOverlap = labelObstacles.reduce((total, obstacle) => (
total + (obstacle.deviceId === "shade" ? 0 : twinPopoverIntersectionArea(rect, obstacle.rect))
), 0);
const chromeOverlap = chromeObstacles.reduce((total, obstacle) => (
total + twinPopoverIntersectionArea(rect, obstacle)
), 0);
const distance = Math.hypot(
left + popoverWidth / 2 - labelRect.centerX,
top + popoverHeight / 2 - labelRect.centerY,
);
const placement = candidate.side === "adaptive"
? (Math.abs(left + popoverWidth / 2 - labelRect.centerX) > Math.abs(top + popoverHeight / 2 - labelRect.centerY)
? (left + popoverWidth / 2 >= labelRect.centerX ? "right" : "left")
: (top + popoverHeight / 2 >= labelRect.centerY ? "bottom" : "top"))
: candidate.side;
const placementKey = `${placement}:${candidate.alignment}`;
const sideRank = sideOrder.indexOf(placement);
const score = activeOverlap * 1000
+ shadeOverlap * 640
+ labelOverlap * 36
+ chromeOverlap * 12
+ clampDistance * 90
+ distance * 0.35
+ Math.max(0, sideRank) * 70
+ candidate.alignmentIndex * 8
+ (candidate.anchored ? 0 : 90)
- (placementKey === previousPlacement ? 28 : 0);
return { ...candidate, left, top, placement, score, activeOverlap, shadeOverlap, labelOverlap };
}).filter(Boolean);
const best = candidates.sort((first, second) => first.score - second.score)[0];
if (!best) return null;
popover.style.left = `${Math.round(best.left)}px`;
popover.style.top = `${Math.round(best.top)}px`;
popover.style.removeProperty("right");
popover.style.removeProperty("bottom");
popover.dataset.anchorDevice = deviceId;
popover.dataset.placement = best.placement;
popover.dataset.alignment = best.alignment;
popover.dataset.shadeOverlap = String(Math.round(best.shadeOverlap));
popover.dataset.labelOverlap = String(Math.round(best.labelOverlap));
return best;
}
function presentTwinDevice(deviceId, { pinned = false, selectSeat = pinned } = {}) {
const device = syncedDevices.find((item) => item.id === deviceId);
const popover = $("#v2TwinDevicePopover");
@@ -1209,6 +1445,7 @@
$(`[data-twin-seat="${deviceId}"]`)?.click();
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
positionTwinDevicePopover(deviceId);
}
function clearTwinDevicePreview({ force = false } = {}) {
@@ -1323,7 +1560,8 @@
const utterance = String(task.utterance || "");
const result = task.result && typeof task.result === "object" ? task.result : null;
const input = $("#utterance");
if (input && utterance && document.activeElement !== input) input.value = utterance;
if (input && utterance && document.activeElement !== input
&& !document.body.classList.contains("assistant-chat-enabled")) input.value = utterance;
const kind = taskKind(utterance, result, String(task.source || ""));
if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind;
if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令";
@@ -1447,14 +1685,14 @@
if (input) {
input.readOnly = false;
input.removeAttribute("aria-readonly");
input.setAttribute("aria-label", "输入一句话远程编排车端功能");
input.placeholder = "例如:副驾调到舒适姿态,空调 24℃,氛围灯改成暖橙色";
input.setAttribute("aria-label", "输入问题或座舱控制需求");
input.placeholder = "输入问题或座舱控制需求";
}
form?.addEventListener("submit", () => {
const utterance = input?.value.trim();
if (utterance) appendSyncLog({
category: "task",
title: "网页端发送编排指令",
title: "网页端发送助手请求",
detail: utterance,
status: "info",
});
@@ -1468,7 +1706,7 @@
function syncSidebarLogBaseline() {
const sidebar = $(".v2-sidebar");
const lowerPanel = $("#v2DevicePanel") || $(".joint-twin-shell > .twin-band");
if (!sidebar || !lowerPanel) return;
if (!sidebar || !lowerPanel || ui.sidebarCollapsed) return;
const sidebarRect = sidebar.getBoundingClientRect();
const overview = $(".v2-sync-sidebar-overview", sidebar);
const overviewRect = overview?.getBoundingClientRect();
@@ -1539,8 +1777,6 @@
});
}
syncOverview?.append(sidebarFooter);
const sidebarToggle = $("#v2SidebarToggle", sidebar);
if (sidebarToggle) $(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle);
}
const naturalPanel = $("#v2NaturalPanel");
@@ -1554,16 +1790,34 @@
const cabinBand = $(".cabin-band", jointShell);
if (jointShell && cabinBand) {
const rightRail = make("aside", "v2-unified-right-rail", `
<section class="v2-device-sync-board v2-side-status-panel" id="v2DevicePanel">
<header><div><h3>车端设备状态</h3></div><span><i></i>实时反馈</span><button type="button" data-panel-toggle="devices" aria-label="收起车端设备状态"><i data-lucide="panel-left-close"></i><span data-panel-action-label>收起</span></button></header>
<div class="v2-device-sync-grid" id="v2DeviceSyncGrid"></div>
</section>
<section class="v2-extension-panel v2-side-status-panel" id="v2ExtensionPanel"><div class="v2-extension-sync-mount" id="vehicleExtensionSync" aria-live="polite"></div></section>`);
<div class="v2-side-status-stack">
<section class="v2-device-sync-board v2-side-status-panel" id="v2DevicePanel">
<header><div><h3>车端设备状态</h3></div><span><i></i>实时反馈</span></header>
<div class="v2-device-sync-grid" id="v2DeviceSyncGrid"></div>
</section>
<section class="v2-extension-panel v2-side-status-panel" id="v2ExtensionPanel"><div class="v2-extension-sync-mount" id="vehicleExtensionSync" aria-live="polite"></div></section>
</div>`);
jointShell.insertBefore(rightRail, cabinBand);
const panelControls = make("div", "v2-panel-visibility-controls", `
<button type="button" data-panel-toggle="devices" aria-pressed="true"><i data-lucide="sliders-horizontal"></i><span>设备状态</span><b data-panel-state-label>已展开</b></button>
<button type="button" data-panel-toggle="extensions" aria-pressed="true"><i data-lucide="usb"></i><span>外设能力</span><b data-panel-state-label>已展开</b></button>`);
$(".twin-toolbar", jointShell)?.prepend(panelControls);
const panelDock = $("#v2PanelControlDock");
if (panelDock) {
const deviceToggle = make("button", "v2-panel-control-button", '<i data-lucide="car-front"></i><span class="visually-hidden">车端设备状态</span>');
deviceToggle.type = "button";
deviceToggle.dataset.panelToggle = "devices";
deviceToggle.setAttribute("aria-controls", "v2DevicePanel");
deviceToggle.setAttribute("aria-pressed", "false");
deviceToggle.setAttribute("aria-expanded", "false");
deviceToggle.setAttribute("aria-label", "展开车端设备状态");
deviceToggle.title = "展开车端设备状态";
const extensionToggle = make("button", "v2-panel-control-button", '<i data-lucide="usb"></i><span class="visually-hidden">外设与扩展能力</span>');
extensionToggle.type = "button";
extensionToggle.dataset.panelToggle = "extensions";
extensionToggle.setAttribute("aria-controls", "v2ExtensionPanel");
extensionToggle.setAttribute("aria-pressed", "false");
extensionToggle.setAttribute("aria-expanded", "false");
extensionToggle.setAttribute("aria-label", "展开外设与扩展能力");
extensionToggle.title = "展开外设与扩展能力";
panelDock.append(deviceToggle, extensionToggle);
}
buildTwinDeviceInteractions();
cabinBand.classList.add("v2-source-state-only");
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
@@ -1580,8 +1834,8 @@
subtree: true,
});
}
setSidePanelVisibility("devices", panelVisible("devices"));
setSidePanelVisibility("extensions", panelVisible("extensions"));
setSidePanelVisibility("devices", false);
setSidePanelVisibility("extensions", false);
window.addEventListener("resize", syncSidebarLogBaseline);
requestAnimationFrame(syncSidebarLogBaseline);
document.addEventListener("click", (event) => {
@@ -1842,14 +2096,21 @@
String(ui.sidebarCollapsed),
);
document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed);
const sidebar = $("#v2SidebarPanel");
if (sidebar) {
sidebar.setAttribute("aria-hidden", String(ui.sidebarCollapsed));
sidebar.toggleAttribute("inert", ui.sidebarCollapsed);
}
const button = $("#v2SidebarToggle");
if (button) {
const expanded = !ui.sidebarCollapsed;
button.classList.toggle("active", expanded);
button.setAttribute("aria-pressed", String(expanded));
button.setAttribute("aria-expanded", String(!ui.sidebarCollapsed));
button.setAttribute(
"aria-label",
ui.sidebarCollapsed ? "展开左侧控制台" : "折叠左侧控制台",
);
button.innerHTML = `<i data-lucide="${ui.sidebarCollapsed ? "panel-left-open" : "panel-left-close"}"></i><span>${ui.sidebarCollapsed ? "展开" : "收起"}</span>`;
const label = ui.sidebarCollapsed ? "展开左侧控制台" : "收起左侧控制台";
button.setAttribute("aria-label", label);
button.title = label;
button.innerHTML = '<i data-lucide="panel-left"></i><span class="visually-hidden">左侧控制台</span>';
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180);
@@ -1959,12 +2220,11 @@
mainPanel,
originalForm,
panelHeading,
modeRail,
requestHeader,
gestureDrawer,
);
buildModuleSwitcher(mainPanel);
buildOutput(mainPanel);
buildOutput(mainPanel, modeRail);
buildSyncControlLayer();
bindEvents();
setControlMode(ui.controlMode, { triggerGesture: false });
@@ -1983,6 +2243,7 @@
getModule: () => ui.activeModule,
getRole: () => "user",
getParsedCanCommands: (result) => parsedCanCommands(result).map((item) => ({ ...item })),
positionTwinDevicePopover,
setModule,
};
+897
View File
@@ -0,0 +1,897 @@
/* Independent whole-vehicle energy monitor -------------------------------- */
.console-v2 .energy-monitor-layer {
position: fixed;
z-index: 5000;
inset: 0;
display: grid;
place-items: center;
padding: 68px 20px 20px;
background: color-mix(in oklch, var(--background) 58%, transparent);
backdrop-filter: blur(6px) saturate(.9);
-webkit-backdrop-filter: blur(6px) saturate(.9);
overscroll-behavior: contain;
}
.console-v2 .energy-monitor-layer[hidden] {
display: none !important;
}
.console-v2 .energy-monitor-page {
width: min(1180px, 100%);
height: min(664px, 100%);
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
color: var(--foreground);
background:
radial-gradient(circle at 7% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 30%),
color-mix(in oklch, var(--card) 97%, var(--background));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 22px;
box-shadow:
0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent),
0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent),
inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent);
font-variant-numeric: tabular-nums;
animation: energy-monitor-enter 180ms ease-out both;
}
.console-v2 .energy-monitor-header {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 18px 13px 20px;
border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border));
background: color-mix(in oklch, var(--card) 85%, transparent);
}
.console-v2 .energy-monitor-heading,
.console-v2 .energy-monitor-live-state,
.console-v2 .energy-monitor-heading > div,
.console-v2 .energy-monitor-heading > span,
.console-v2 .energy-monitor-live-label,
.console-v2 .energy-trend-card > header,
.console-v2 .energy-devices-card > header,
.console-v2 .energy-trend-card > footer,
.console-v2 .energy-device-identity small {
display: flex;
align-items: center;
}
.console-v2 .energy-monitor-heading {
min-width: 0;
gap: 12px;
}
.console-v2 .energy-monitor-heading > span {
width: 46px;
height: 46px;
flex: 0 0 46px;
justify-content: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
border-radius: 15px;
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 70%, transparent);
}
.console-v2 .energy-monitor-heading > span svg {
width: 24px;
height: 24px;
stroke-width: 1.8;
}
.console-v2 .energy-monitor-heading > div {
min-width: 0;
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.console-v2 .energy-monitor-heading h2,
.console-v2 .energy-trend-card h3,
.console-v2 .energy-devices-card h3 {
margin: 0;
color: var(--heading-foreground, var(--foreground));
letter-spacing: -.025em;
}
.console-v2 .energy-monitor-heading h2 {
font-size: 20px;
line-height: 1.2;
}
.console-v2 .energy-monitor-heading p,
.console-v2 .energy-trend-card header p,
.console-v2 .energy-devices-card header p {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.45;
}
.console-v2 .energy-monitor-live-state {
flex: 0 0 auto;
gap: 10px;
color: var(--muted-foreground);
font-size: 12px;
}
.console-v2 .energy-monitor-live-label {
gap: 6px;
color: var(--primary);
font-weight: 700;
}
.console-v2 .energy-monitor-live-label > i,
.console-v2 .energy-trend-card > footer span > i {
width: 7px;
height: 7px;
display: block;
background: var(--primary);
border-radius: 50%;
box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 12%, transparent);
}
.console-v2 .energy-monitor-source {
padding: 5px 9px;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 999px;
font-weight: 700;
}
.console-v2 .energy-monitor-source[data-source="simulated"],
.console-v2 .energy-monitor-source[data-source="simulated_estimated"],
.console-v2 .energy-monitor-source[data-source="measured_simulated"],
.console-v2 .energy-monitor-source[data-source="measured_simulated_estimated"] {
color: color-mix(in oklch, var(--primary) 72%, #7c3aed);
}
.console-v2 #energyMonitorClose {
width: 38px;
height: 38px;
display: grid;
flex: 0 0 38px;
place-items: center;
padding: 0;
color: var(--foreground);
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border));
border-radius: 12px;
box-shadow: 0 4px 12px color-mix(in oklch, var(--foreground) 7%, transparent);
cursor: pointer;
transition: color 150ms ease, border-color 150ms ease, background 150ms ease, transform 150ms ease;
}
.console-v2 #energyMonitorClose:hover,
.console-v2 #energyMonitorClose:focus-visible {
color: var(--primary);
background: var(--accent);
border-color: color-mix(in oklch, var(--primary) 28%, var(--border));
outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent);
outline-offset: 1px;
transform: translateY(-1px);
}
.console-v2 #energyMonitorClose svg {
width: 18px;
height: 18px;
}
.console-v2 .energy-monitor-content {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 14px 16px 16px;
}
.console-v2 .energy-monitor-kpis {
display: grid;
grid-template-columns: 1.18fr 1fr 1fr 1fr;
gap: 10px;
}
.console-v2 .energy-kpi {
min-width: 0;
min-height: 94px;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
overflow: hidden;
padding: 13px 15px;
background: color-mix(in oklch, var(--card) 94%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border));
border-radius: 15px;
box-shadow: 0 6px 16px color-mix(in oklch, var(--foreground) 5%, transparent);
}
.console-v2 .energy-kpi-primary {
background:
linear-gradient(135deg, color-mix(in oklch, var(--primary) 10%, var(--card)), color-mix(in oklch, var(--primary) 3%, var(--card)));
border-color: color-mix(in oklch, var(--primary) 42%, var(--border));
box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 10%, transparent);
}
.console-v2 .energy-kpi > small {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 650;
}
.console-v2 .energy-kpi > strong {
min-width: 0;
color: var(--heading-foreground, var(--foreground));
font-size: clamp(19px, 1.9vw, 28px);
line-height: 1.1;
white-space: nowrap;
}
.console-v2 .energy-kpi-primary > strong {
display: flex;
align-items: baseline;
gap: 6px;
color: var(--primary);
font-size: clamp(27px, 2.6vw, 38px);
}
.console-v2 .energy-kpi-primary > strong span {
color: var(--foreground);
font-size: 14px;
font-weight: 700;
}
.console-v2 .energy-kpi > em {
max-width: 100%;
overflow: hidden;
color: var(--muted-foreground);
font-size: 10px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-kpi-primary > svg {
width: 42px;
height: 42px;
position: absolute;
right: 10px;
top: 10px;
color: color-mix(in oklch, var(--primary) 20%, transparent);
stroke-width: 1.4;
}
.console-v2 .energy-monitor-dashboard {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.58fr) minmax(330px, .92fr);
gap: 12px;
}
.console-v2 .energy-trend-card,
.console-v2 .energy-devices-card {
min-width: 0;
min-height: 0;
overflow: hidden;
background: color-mix(in oklch, var(--card) 96%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border));
border-radius: 16px;
box-shadow: 0 7px 18px color-mix(in oklch, var(--foreground) 5%, transparent);
}
.console-v2 .energy-trend-card {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 15px 16px 12px;
}
.console-v2 .energy-trend-card > header,
.console-v2 .energy-devices-card > header {
justify-content: space-between;
gap: 12px;
}
.console-v2 .energy-trend-card h3,
.console-v2 .energy-devices-card h3 {
font-size: 15px;
}
.console-v2 .energy-trend-card > header > strong {
color: var(--primary);
font-size: 16px;
white-space: nowrap;
}
.console-v2 .energy-trend-tabs {
display: flex;
gap: 7px;
padding: 11px 0 4px;
}
.console-v2 .energy-trend-tabs button {
min-height: 30px;
padding: 5px 13px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid var(--border);
border-radius: 9px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.console-v2 .energy-trend-tabs button:hover,
.console-v2 .energy-trend-tabs button:focus-visible {
color: var(--primary);
border-color: color-mix(in oklch, var(--primary) 28%, var(--border));
outline: 2px solid color-mix(in oklch, var(--ring) 14%, transparent);
outline-offset: 1px;
}
.console-v2 .energy-trend-tabs button.active {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 5px 13px color-mix(in oklch, var(--primary) 20%, transparent);
}
.console-v2 .energy-chart-wrap {
min-height: 210px;
position: relative;
overflow: hidden;
}
.console-v2 .energy-monitor-chart {
width: 100%;
height: 100%;
min-height: 210px;
display: block;
color: var(--primary);
overflow: visible;
}
.console-v2 .energy-chart-grid line {
stroke: color-mix(in oklch, var(--primary) 10%, var(--border));
stroke-width: 1;
stroke-dasharray: 3 5;
}
.console-v2 .energy-chart-area {
fill: url(#energyMonitorAreaGradient);
stroke: none;
}
.console-v2 .energy-chart-line {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 3px 5px color-mix(in oklch, var(--primary) 18%, transparent));
transition: points 260ms linear;
}
.console-v2 .energy-chart-y {
position: absolute;
z-index: 2;
left: 0;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--card) 82%, transparent);
font-size: 9px;
line-height: 1;
pointer-events: none;
}
.console-v2 .energy-chart-y-top { top: 8px; }
.console-v2 .energy-chart-y-mid { top: 49%; }
.console-v2 .energy-chart-y-bottom { bottom: 26px; }
.console-v2 .energy-chart-x {
position: absolute;
right: 0;
bottom: 3px;
left: 42px;
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 9px;
}
.console-v2 .energy-trend-card > footer {
justify-content: space-between;
gap: 10px;
padding-top: 5px;
color: var(--muted-foreground);
border-top: 1px solid color-mix(in oklch, var(--primary) 8%, var(--border));
font-size: 10px;
}
.console-v2 .energy-trend-card > footer span {
display: flex;
align-items: center;
gap: 8px;
color: var(--foreground);
font-weight: 650;
}
.console-v2 .energy-trend-card > footer p {
margin: 0;
}
.console-v2 .energy-devices-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 15px 12px 10px;
}
.console-v2 .energy-devices-card > header {
padding: 0 3px 10px;
}
.console-v2 .energy-devices-card > header > span {
padding: 5px 8px;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 8%, var(--card));
border-radius: 999px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.console-v2 .energy-device-list {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow: auto;
padding: 1px 3px 4px;
scrollbar-color: color-mix(in oklch, var(--primary) 24%, var(--border)) transparent;
scrollbar-width: thin;
}
.console-v2 .energy-device-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 34px minmax(82px, 1fr) auto 72px 34px;
align-items: center;
gap: 8px;
padding: 8px 7px;
color: var(--foreground);
text-align: left;
background: color-mix(in oklch, var(--card) 88%, var(--secondary));
border: 1px solid transparent;
border-radius: 11px;
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease, transform 150ms ease;
}
.console-v2 .energy-device-row:hover,
.console-v2 .energy-device-row:focus-visible,
.console-v2 .energy-device-row.active {
color: var(--foreground);
background: color-mix(in oklch, var(--primary) 7%, var(--card));
border-color: color-mix(in oklch, var(--primary) 25%, var(--border));
outline: none;
}
.console-v2 .energy-device-row:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 14%, transparent);
}
.console-v2 .energy-device-row.active {
box-shadow: inset 3px 0 0 var(--primary);
}
.console-v2 .energy-device-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--card));
border: 1px solid color-mix(in oklch, var(--primary) 14%, var(--border));
border-radius: 10px;
}
.console-v2 .energy-device-icon svg {
width: 16px;
height: 16px;
}
.console-v2 .energy-device-identity {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.console-v2 .energy-device-identity strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-device-identity small {
min-width: 0;
gap: 4px;
color: var(--muted-foreground);
font-size: 8px;
}
.console-v2 .energy-device-identity small b {
color: var(--primary);
font-weight: 750;
}
.console-v2 .energy-device-identity small b[data-source="estimated"] {
color: var(--muted-foreground);
}
.console-v2 .energy-device-identity small i {
width: 2px;
height: 2px;
flex: 0 0 2px;
background: currentColor;
border-radius: 50%;
}
.console-v2 .energy-device-identity small span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-device-metrics {
min-width: 64px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
white-space: nowrap;
}
.console-v2 .energy-device-metrics strong {
color: var(--heading-foreground, var(--foreground));
font-size: 11px;
}
.console-v2 .energy-device-metrics small {
color: var(--muted-foreground);
font-size: 8px;
}
.console-v2 .energy-device-sparkline {
width: 72px;
height: 24px;
display: block;
overflow: visible;
}
.console-v2 .energy-device-sparkline polyline {
fill: none;
stroke: var(--primary);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.console-v2 .energy-device-share {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.console-v2 .energy-device-share > b {
color: var(--primary);
font-size: 8px;
}
.console-v2 .energy-device-share > i {
width: 30px;
height: 3px;
display: block;
overflow: hidden;
background: color-mix(in oklch, var(--primary) 10%, var(--border));
border-radius: 999px;
}
.console-v2 .energy-device-share em {
width: var(--energy-share, 0%);
height: 100%;
display: block;
background: var(--primary);
border-radius: inherit;
transition: width 260ms linear;
}
.console-v2 .energy-devices-card > footer {
display: flex;
align-items: flex-start;
gap: 7px;
margin-top: 6px;
padding: 8px 9px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--primary) 5%, var(--secondary));
border-radius: 9px;
font-size: 9px;
line-height: 1.45;
}
.console-v2 .energy-devices-card > footer svg {
width: 13px;
height: 13px;
flex: 0 0 13px;
color: var(--primary);
}
.console-v2 #v2PanelControlDock > .energy-monitor-toggle {
overflow: visible !important;
}
.console-v2 .energy-monitor-toggle-live {
width: 6px;
height: 6px;
position: absolute;
right: 6px;
top: 6px;
display: block;
background: var(--primary);
border: 1.5px solid var(--card);
border-radius: 50%;
box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 10%, transparent);
}
@keyframes energy-monitor-enter {
from { opacity: 0; transform: translateY(8px) scale(.992); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 980px) {
.console-v2 .energy-monitor-content {
overflow: auto;
}
.console-v2 .energy-monitor-dashboard {
grid-template-columns: minmax(0, 1fr);
}
.console-v2 .energy-trend-card {
min-height: 390px;
}
.console-v2 .energy-devices-card {
min-height: 450px;
}
.console-v2 .energy-device-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
}
.console-v2 .energy-device-row {
grid-template-columns: 34px minmax(72px, 1fr) auto 52px;
}
.console-v2 .energy-device-share {
display: none;
}
.console-v2 .energy-device-sparkline {
width: 52px;
}
}
@media (max-width: 760px) {
.console-v2 .energy-monitor-layer {
inset: 0;
padding: 56px 8px 8px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.console-v2 .energy-monitor-page {
width: 100%;
height: 100%;
border-radius: 17px;
}
.console-v2 .energy-monitor-header {
min-height: 66px;
gap: 8px;
padding: 10px 10px 9px 12px;
}
.console-v2 .energy-monitor-heading {
gap: 8px;
}
.console-v2 .energy-monitor-heading > span {
width: 38px;
height: 38px;
flex-basis: 38px;
border-radius: 12px;
}
.console-v2 .energy-monitor-heading > span svg {
width: 20px;
height: 20px;
}
.console-v2 .energy-monitor-heading h2 {
font-size: 16px;
}
.console-v2 .energy-monitor-heading p,
.console-v2 #energyMonitorFreshness {
display: none;
}
.console-v2 .energy-monitor-live-state {
gap: 6px;
}
.console-v2 .energy-monitor-live-label {
font-size: 10px;
}
.console-v2 .energy-monitor-source {
padding: 4px 7px;
font-size: 9px;
}
.console-v2 #energyMonitorClose {
width: 34px;
height: 34px;
flex-basis: 34px;
}
.console-v2 .energy-monitor-content {
gap: 9px;
padding: 10px;
}
.console-v2 .energy-monitor-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.console-v2 .energy-kpi {
min-height: 82px;
padding: 10px 11px;
border-radius: 12px;
}
.console-v2 .energy-kpi > strong {
font-size: 18px;
}
.console-v2 .energy-kpi-primary > strong {
font-size: 26px;
}
.console-v2 .energy-trend-card {
min-height: 340px;
padding: 12px 11px 9px;
}
.console-v2 .energy-chart-wrap,
.console-v2 .energy-monitor-chart {
min-height: 190px;
}
.console-v2 .energy-trend-tabs {
gap: 5px;
overflow-x: auto;
padding-bottom: 7px;
}
.console-v2 .energy-trend-tabs button {
flex: 0 0 auto;
padding-inline: 11px;
}
.console-v2 .energy-devices-card {
min-height: 560px;
}
.console-v2 .energy-device-list {
grid-template-columns: minmax(0, 1fr);
}
.console-v2 .energy-device-row {
grid-template-columns: 34px minmax(70px, 1fr) auto 62px 34px;
}
.console-v2 .energy-device-share {
display: flex;
}
.console-v2 .energy-device-sparkline {
width: 62px;
}
}
@media (max-width: 460px) {
.console-v2 .energy-monitor-heading p,
.console-v2 .energy-monitor-live-label {
display: none;
}
.console-v2 .energy-monitor-source {
max-width: 74px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .energy-monitor-kpis {
gap: 6px;
}
.console-v2 .energy-kpi {
min-height: 78px;
}
.console-v2 .energy-kpi > small {
font-size: 9px;
}
.console-v2 .energy-kpi > em {
font-size: 8px;
}
.console-v2 .energy-device-row {
grid-template-columns: 32px minmax(64px, 1fr) auto 50px;
gap: 6px;
padding-inline: 5px;
}
.console-v2 .energy-device-share {
display: none;
}
.console-v2 .energy-device-sparkline {
width: 50px;
}
.console-v2 .energy-trend-card > footer p {
display: none;
}
}
@media (max-width: 340px) {
.console-v2 .energy-monitor-heading > span {
display: none;
}
.console-v2 .energy-monitor-source {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.console-v2 .energy-monitor-page {
animation: none;
}
.console-v2 .energy-chart-line,
.console-v2 .energy-device-share em,
.console-v2 .energy-device-row,
.console-v2 #energyMonitorClose {
transition: none;
}
}
+905
View File
@@ -0,0 +1,905 @@
(function () {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const SAMPLE_INTERVAL_MS = 1000;
const MEASURED_STALE_MS = 15000;
const HISTORY_LIMIT = 60;
const MAX_DEVICE_POWER_W = 100000;
const MAX_REPORTED_ENERGY_WH = 1000000000;
const ENERGY_MONITORED_DEVICES = Object.freeze([
{ id: "passenger", telemetryKeys: ["passenger", "passenger_seat"], name: "副驾座椅", icon: "armchair", phase: 1 },
{ id: "driver", telemetryKeys: ["driver", "driver_seat"], name: "主驾座椅", icon: "armchair", phase: 2 },
{ id: "climate", telemetryKeys: ["climate", "hvac"], name: "空调", icon: "snowflake", phase: 3 },
{ id: "shade", telemetryKeys: ["shade", "roof", "sunshade"], name: "遮阳帘", icon: "panels-top-left", phase: 4 },
{ id: "windows", telemetryKeys: ["windows", "window"], name: "车窗", icon: "gallery-vertical-end", phase: 5 },
{ id: "reading", telemetryKeys: ["reading", "reading_light", "interior_light"], name: "阅读灯", icon: "book-open", phase: 6 },
{ id: "ambient", telemetryKeys: ["ambient", "ambient_light"], name: "氛围灯", icon: "sparkles", phase: 7 },
]);
const SERIES = Object.freeze([
{ id: "total", name: "总功率", devices: ENERGY_MONITORED_DEVICES.map(({ id }) => id) },
{ id: "climate", name: "空调", devices: ["climate"] },
{ id: "seats", name: "座椅", devices: ["passenger", "driver"] },
{ id: "lights", name: "灯光", devices: ["reading", "ambient"] },
]);
const aliasToDevice = new Map();
ENERGY_MONITORED_DEVICES.forEach((device) => {
device.telemetryKeys.forEach((key) => aliasToDevice.set(key, device.id));
});
const monitor = {
mounted: false,
open: false,
activeSeries: "total",
timer: null,
startedAt: Date.now(),
lastSampleAt: null,
frames: [],
latest: null,
measured: new Map(),
reportedEnergy: new Map(),
energyWh: Object.fromEntries(ENERGY_MONITORED_DEVICES.map(({ id }) => [id, 0])),
motionUntil: new Map(),
controlRequests: new Map(),
returnFocus: null,
backgroundInert: [],
};
function finiteNonNegative(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
}
function finitePower(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_DEVICE_POWER_W ? number : null;
}
function finiteReportedEnergy(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_REPORTED_ENERGY_WH ? number : null;
}
function normalizeSource(value) {
return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
}
function telemetrySource(telemetry, payload) {
if (telemetry?.simulated === true || payload?.simulated === true) return "simulated";
if (telemetry?.estimated === true || payload?.estimated === true) return "estimated";
const declared = normalizeSource(
telemetry?.source
|| telemetry?.data_source
|| payload?.source
|| payload?.data_source,
);
if (/(^|_)(simulated|simulation|demo|mock|synthetic|virtual)(_|$)/.test(declared)) {
return "simulated";
}
if (/(^|_)(estimated|estimate|derived|inferred|calculated|projected|unknown)(_|$)/.test(declared)) {
return "estimated";
}
if (telemetry?.measured === true || payload?.measured === true) return "measured";
if (/(^|_)(measured|meter|sensor|hardware|physical|vehicle|can|mqtt)(_|$)/.test(declared)) {
return "measured";
}
// An unlabelled caller is not enough evidence to claim hardware measurement.
return "estimated";
}
function text(selector, fallback = "") {
return $(selector)?.textContent?.trim() || fallback;
}
function stateFlag(value) {
const normalized = String(value || "").trim();
if (/OFF|关闭|停止|未运行/i.test(normalized)) return false;
if (/ON|ACTIVE|AMBIENT|INTERIOR|开启|运行|L[1-9]|高|中|低/i.test(normalized)) return true;
return null;
}
function numericText(selector, fallback) {
const match = text(selector).match(/-?\d+(?:\.\d+)?/);
const value = match ? Number(match[0]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function deterministicWave(now, phase, amplitude = 0.04) {
const seconds = now / 1000;
return 1
+ Math.sin(seconds / 2.4 + phase * 1.37) * amplitude
+ Math.sin(seconds / 5.7 + phase * 0.71) * amplitude * 0.45;
}
function controlIsActive(deviceId, now) {
if ((monitor.motionUntil.get(deviceId) || 0) > now) return true;
const card = $(`.v2-synced-device[data-device="${deviceId}"]`);
return /等待车机同步/.test(card?.querySelector("[data-device-control-apply]")?.textContent || "");
}
function estimateDevicePower(device, now) {
let watts = 0;
let activity = "待机";
const moving = controlIsActive(device.id, now);
if (device.id === "climate") {
const power = stateFlag(text("#climatePower"));
const fan = Math.max(0, Math.min(7, numericText("#fanLevel", 3)));
const target = (numericText("#driverTemperature", 24) + numericText("#passengerTemperature", 24)) / 2;
if (power === false) {
watts = 3.2;
activity = "待机";
} else {
watts = 390 + fan * 48 + Math.abs(24 - target) * 32;
activity = power === true ? `${fan} 档运行` : "状态估算";
}
if (moving) {
watts += 95;
activity = "调节执行中";
}
} else if (device.id === "passenger") {
const card = $('.seat-card[data-seat="passenger"]');
const ventilation = stateFlag(card?.dataset.ventilationState);
const heating = stateFlag(card?.dataset.heatingState);
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 5.6 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (moving ? 130 : 0);
activity = moving ? "执行器动作" : ventilation || heating ? "舒适功能运行" : "待机";
} else if (device.id === "driver") {
const card = $('.seat-card[data-seat="driver"]');
const ventilation = stateFlag(card?.dataset.ventilationState || text('.seat-card[data-seat="driver"] [data-function="ventilation"] b'));
const heating = stateFlag(card?.dataset.heatingState || text('.seat-card[data-seat="driver"] [data-function="heating"] b'));
const massage = stateFlag(card?.dataset.massageState || text('.seat-card[data-seat="driver"] [data-function="massage"] b'));
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 6.4 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (massage ? 31 : 0) + (moving ? 92 : 0);
activity = moving ? "执行器动作" : ventilation || heating || massage ? "舒适功能运行" : "待机";
} else if (device.id === "windows") {
watts = moving ? 118 : 2.8;
activity = moving ? "车窗动作" : "待机";
} else if (device.id === "shade") {
watts = moving ? 86 : 1.9;
activity = moving ? "遮阳帘动作" : "待机";
} else if (device.id === "reading") {
const power = stateFlag(text("#interiorLightPower"));
watts = power === false ? 0.8 : power === true ? 18 : 7.5;
activity = power === true ? "已开启" : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 6;
activity = "切换执行中";
}
} else if (device.id === "ambient") {
const power = stateFlag(text("#ambientPower"));
const brightness = Math.max(1, Math.min(5, numericText("#ambientBrightness", 3)));
watts = power === false ? 1.1 : power === true ? 8 + brightness * 6.2 : 18;
activity = power === true ? `${brightness} 级亮度` : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 5;
activity = "调光执行中";
}
}
const amplitude = watts < 10 ? 0.025 : device.id === "climate" ? 0.055 : 0.04;
return {
watts: Math.max(0, watts * deterministicWave(now, device.phase, amplitude)),
source: "estimated",
activity,
updatedAt: now,
};
}
function readingForDevice(device, now) {
const measured = monitor.measured.get(device.id);
const reportedEnergy = monitor.reportedEnergy.get(device.id);
if (measured && now - measured.receivedAt <= MEASURED_STALE_MS) {
const activity = measured.source === "measured"
? "车端功率通道"
: measured.source === "simulated" ? "模拟功率通道" : "外部估算通道";
return {
watts: measured.watts,
source: measured.source,
activity,
updatedAt: measured.updatedAt || measured.receivedAt,
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
return {
...estimateDevicePower(device, now),
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
function frameSeriesValue(frame, seriesId) {
const exactDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === seriesId);
const deviceIds = exactDevice
? [exactDevice.id]
: SERIES.find(({ id }) => id === seriesId)?.devices || SERIES[0].devices;
return deviceIds.reduce((sum, id) => sum + (frame.values[id] || 0), 0);
}
function publicSnapshot() {
if (!monitor.latest) return null;
return {
timestamp: monitor.latest.timestamp,
total_power_w: monitor.latest.total,
total_energy_wh: Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
source: monitor.latest.source,
measured_count: monitor.latest.measuredCount,
simulated_count: monitor.latest.simulatedCount,
estimated_count: monitor.latest.estimatedCount,
total_energy_scope: "monitor_power_integral_estimate",
devices: Object.fromEntries(ENERGY_MONITORED_DEVICES.map((device) => {
const reading = monitor.latest.readings[device.id];
return [device.id, {
name: device.name,
power_w: reading.watts,
energy_wh: monitor.energyWh[device.id],
reported_energy_wh: reading.reportedEnergyWh,
reported_energy_label: reading.reportedEnergyLabel,
reported_energy_source: reading.reportedEnergySource,
source: reading.source,
activity: reading.activity,
}];
})),
sample_count: monitor.frames.length,
};
}
function pruneExpiredControlRequests(now) {
monitor.controlRequests.forEach((request, requestId) => {
if (request.until <= now) monitor.controlRequests.delete(requestId);
});
}
function sampleNow({ now = Date.now(), integrate = true, render = true } = {}) {
const timestamp = Number(now);
if (!Number.isFinite(timestamp)) return publicSnapshot();
pruneExpiredControlRequests(timestamp);
const readings = {};
const values = {};
let total = 0;
let measuredCount = 0;
let simulatedCount = 0;
ENERGY_MONITORED_DEVICES.forEach((device) => {
const reading = readingForDevice(device, timestamp);
readings[device.id] = reading;
values[device.id] = reading.watts;
total += finitePower(reading.watts) ?? 0;
if (reading.source === "measured") measuredCount += 1;
if (reading.source === "simulated") simulatedCount += 1;
});
const elapsedMs = monitor.lastSampleAt === null
? 0
: Math.max(0, timestamp - monitor.lastSampleAt);
if (integrate && elapsedMs > 0) {
ENERGY_MONITORED_DEVICES.forEach(({ id }) => {
const previous = monitor.frames.at(-1)?.values?.[id] ?? values[id];
monitor.energyWh[id] += ((previous + values[id]) / 2) * elapsedMs / 3600000;
});
}
monitor.lastSampleAt = timestamp;
monitor.frames.push({ timestamp, values, total });
if (monitor.frames.length > HISTORY_LIMIT) monitor.frames.splice(0, monitor.frames.length - HISTORY_LIMIT);
const estimatedCount = ENERGY_MONITORED_DEVICES.length - measuredCount - simulatedCount;
const source = measuredCount === ENERGY_MONITORED_DEVICES.length
? "measured"
: simulatedCount === ENERGY_MONITORED_DEVICES.length
? "simulated"
: estimatedCount === ENERGY_MONITORED_DEVICES.length
? "estimated"
: measuredCount && simulatedCount && estimatedCount
? "measured_simulated_estimated"
: measuredCount && simulatedCount
? "measured_simulated"
: measuredCount ? "mixed" : "simulated_estimated";
monitor.latest = {
timestamp,
readings,
total,
measuredCount,
simulatedCount,
estimatedCount,
source,
};
if (render && monitor.mounted) renderMonitor();
window.dispatchEvent(new CustomEvent("cognitive-agent:energy-snapshot", { detail: publicSnapshot() }));
return publicSnapshot();
}
function ingestPowerTelemetry(payload, { sample = true } = {}) {
const receivedAt = Date.now();
const updates = payload?.devices && typeof payload.devices === "object"
? Object.entries(payload.devices)
: payload?.device ? [[payload.device, payload]] : [];
updates.forEach(([rawKey, rawTelemetry]) => {
const deviceId = aliasToDevice.get(String(rawKey || "").trim().toLowerCase());
if (!deviceId) return;
const telemetry = rawTelemetry && typeof rawTelemetry === "object"
? rawTelemetry
: { power_w: rawTelemetry };
const directEnergyWh = finiteReportedEnergy(telemetry.energy_wh ?? telemetry.total_energy_wh);
const tripEnergyKwh = finiteNonNegative(telemetry.trip_energy_kwh);
const todayEnergyKwh = finiteNonNegative(telemetry.today_energy_kwh);
const tripEnergyWh = tripEnergyKwh === null ? null : finiteReportedEnergy(tripEnergyKwh * 1000);
const todayEnergyWh = todayEnergyKwh === null ? null : finiteReportedEnergy(todayEnergyKwh * 1000);
const reportedEnergyWh = directEnergyWh
?? tripEnergyWh
?? todayEnergyWh
?? null;
const reportedEnergyLabel = directEnergyWh !== null
? String(telemetry.energy_label || "车端累计")
: tripEnergyWh !== null ? "本次行程"
: todayEnergyWh !== null ? "今日累计"
: "";
const source = telemetrySource(telemetry, payload);
if (reportedEnergyWh !== null) {
monitor.reportedEnergy.set(deviceId, {
wattsHours: reportedEnergyWh,
label: reportedEnergyLabel,
source,
receivedAt,
});
}
const watts = finitePower(
telemetry.power_w ?? telemetry.total_power_w ?? telemetry.current_power_w,
);
if (watts === null) return;
const parsedUpdatedAt = telemetry.updated_at ? new Date(telemetry.updated_at).getTime() : NaN;
monitor.measured.set(deviceId, {
watts,
receivedAt,
updatedAt: Number.isFinite(parsedUpdatedAt) ? parsedUpdatedAt : receivedAt,
source,
});
});
return sample ? sampleNow() : publicSnapshot();
}
function formatPowerParts(watts) {
const value = finiteNonNegative(watts) || 0;
if (value >= 1000) return { value: (value / 1000).toFixed(value >= 10000 ? 1 : 2), unit: "kW" };
return { value: String(Math.round(value)), unit: "W" };
}
function formatPower(watts, estimated = false) {
const parts = formatPowerParts(watts);
return `${estimated ? "≈ " : ""}${parts.value} ${parts.unit}`;
}
function formatEnergy(wh, estimated = false) {
const value = finiteNonNegative(wh) || 0;
const prefix = estimated ? "≈ " : "";
if (value >= 1000) return `${prefix}${(value / 1000).toFixed(3)} kWh`;
return `${prefix}${value < 10 ? value.toFixed(2) : value.toFixed(1)} Wh`;
}
function sourceLabel(source) {
return {
measured: "车端实测",
simulated: "模拟数据",
simulated_estimated: "模拟 + 估算",
measured_simulated: "实测 + 模拟",
measured_simulated_estimated: "实测 + 模拟 + 估算",
mixed: "实测 + 估算",
estimated: "状态估算",
}[source] || "状态估算";
}
function sourceShortLabel(source) {
return source === "measured" ? "实测" : source === "simulated" ? "模拟" : "估算";
}
function totalPowerQualifier(source) {
if (source === "measured") return "实测总功率";
if (source === "simulated") return "模拟总功率";
if (source === "estimated") return "估算总功率";
return "混合来源总功率";
}
function niceMaximum(value) {
const safe = Math.max(1, value);
const magnitude = 10 ** Math.floor(Math.log10(safe));
const normalized = safe / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function chartPoints(values, maxValue, width = 700, height = 238, padding = { left: 42, right: 14, top: 16, bottom: 28 }) {
if (values.length < 2) return [];
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
return values.map((value, index) => ({
x: padding.left + innerWidth * index / (values.length - 1),
y: padding.top + innerHeight * (1 - Math.max(0, value) / maxValue),
}));
}
function polylinePoints(values, maxValue, width, height, padding) {
return chartPoints(values, maxValue, width, height, padding)
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
.join(" ");
}
function renderMainChart() {
const values = monitor.frames.map((frame) => frameSeriesValue(frame, monitor.activeSeries));
const maximum = niceMaximum(Math.max(...values, 1));
const points = chartPoints(values, maximum);
const line = $("#energyMonitorChartLine");
const area = $("#energyMonitorChartArea");
const svg = $("#energyMonitorChart");
const baseline = 210;
line?.setAttribute("points", points.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "));
area?.setAttribute("d", points.length
? `M ${points[0].x.toFixed(1)} ${baseline} L ${points.map(({ x, y }) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1).x.toFixed(1)} ${baseline} Z`
: "");
const selectedDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === monitor.activeSeries);
const selectedSeries = SERIES.find(({ id }) => id === monitor.activeSeries);
const label = selectedDevice?.name || selectedSeries?.name || "总功率";
const current = values.at(-1) || 0;
const currentReading = selectedDevice ? monitor.latest?.readings?.[selectedDevice.id] : null;
const estimated = currentReading ? currentReading.source !== "measured" : monitor.latest?.source !== "measured";
$("#energyMonitorTrendName").textContent = label;
$("#energyMonitorTrendCurrent").textContent = formatPower(current, estimated);
$("#energyMonitorChartMax").textContent = formatPower(maximum);
$("#energyMonitorChartMid").textContent = formatPower(maximum / 2);
const spanSeconds = Math.max(1, Math.round(((monitor.frames.at(-1)?.timestamp || 0) - (monitor.frames[0]?.timestamp || 0)) / 1000));
const windowSeconds = monitor.frames.length >= HISTORY_LIMIT ? 60 : Math.min(60, spanSeconds);
$("#energyMonitorTrendWindow").textContent = `最近 ${windowSeconds} 秒`;
$("#energyMonitorChartStart").textContent = `${windowSeconds} 秒前`;
if (svg) svg.setAttribute("aria-label", `${label}最近 ${windowSeconds} 秒功率趋势,当前${formatPower(current, estimated)}`);
$$('[data-energy-series]').forEach((button) => {
const selected = button.dataset.energySeries === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
$$('[data-energy-device]').forEach((button) => {
const selected = button.dataset.energyDevice === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function renderDeviceRows() {
if (!monitor.latest) return;
const total = Math.max(monitor.latest.total, 0.0001);
ENERGY_MONITORED_DEVICES.forEach((device) => {
const row = $(`[data-energy-device="${device.id}"]`);
const reading = monitor.latest.readings[device.id];
if (!row || !reading) return;
const estimated = reading.source !== "measured";
$("[data-energy-device-power]", row).textContent = formatPower(reading.watts, estimated);
const hasReportedEnergy = reading.reportedEnergyWh !== null && reading.reportedEnergyWh !== undefined;
const displayedEnergyWh = hasReportedEnergy ? reading.reportedEnergyWh : monitor.energyWh[device.id];
const reportedEnergyEstimated = !hasReportedEnergy || reading.reportedEnergySource !== "measured";
$("[data-energy-device-energy]", row).textContent = formatEnergy(displayedEnergyWh, reportedEnergyEstimated);
$("[data-energy-device-energy]", row).title = hasReportedEnergy
? `${reading.reportedEnergyLabel || "车端累计"}能耗`
: "本次监测功率积分估算";
$("[data-energy-device-source]", row).textContent = sourceShortLabel(reading.source);
$("[data-energy-device-source]", row).dataset.source = reading.source;
$("[data-energy-device-activity]", row).textContent = reading.activity;
const share = Math.max(0, Math.min(100, reading.watts / total * 100));
$("[data-energy-device-share]", row).textContent = `${share.toFixed(1)}%`;
row.style.setProperty("--energy-share", `${share}%`);
const values = monitor.frames.map((frame) => frame.values[device.id] || 0);
const maximum = Math.max(...values, 1);
$("[data-energy-device-sparkline]", row)?.setAttribute(
"points",
polylinePoints(values, maximum, 92, 28, { left: 1, right: 1, top: 3, bottom: 3 }),
);
const energyScope = hasReportedEnergy ? reading.reportedEnergyLabel || "车端累计" : "本次监测估算";
row.setAttribute("aria-label", `${device.name},当前${formatPower(reading.watts, estimated)},${energyScope}${formatEnergy(displayedEnergyWh, reportedEnergyEstimated)},点击查看趋势`);
});
}
function renderMonitor() {
if (!monitor.latest) return;
const totalParts = formatPowerParts(monitor.latest.total);
const estimated = monitor.latest.source !== "measured";
$("#energyMonitorTotalPower").textContent = totalParts.value;
$("#energyMonitorTotalUnit").textContent = totalParts.unit;
$("#energyMonitorTotalQualifier").textContent = totalPowerQualifier(monitor.latest.source);
$("#energyMonitorAccumulated").textContent = formatEnergy(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
true,
);
const elapsedHours = Math.max(1 / 3600, (monitor.latest.timestamp - monitor.startedAt) / 3600000);
$("#energyMonitorAverage").textContent = formatPower(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0) / elapsedHours,
true,
);
$("#energyMonitorCoverage").textContent = `${ENERGY_MONITORED_DEVICES.length} / ${ENERGY_MONITORED_DEVICES.length}`;
$("#energyMonitorSource").textContent = sourceLabel(monitor.latest.source);
$("#energyMonitorSource").dataset.source = monitor.latest.source;
const sourceBreakdown = [
monitor.latest.measuredCount ? `${monitor.latest.measuredCount} 路实测` : "",
monitor.latest.simulatedCount ? `${monitor.latest.simulatedCount} 路模拟` : "",
monitor.latest.estimatedCount ? `${monitor.latest.estimatedCount} 路估算` : "",
].filter(Boolean).join(" · ");
$("#energyMonitorCoverageNote").textContent = sourceBreakdown || "等待设备采样";
$("#energyMonitorLiveLabelText").textContent = monitor.latest.measuredCount
? "实时监测"
: monitor.latest.simulatedCount ? "动态模拟" : "状态估算";
$("#energyMonitorFreshness").textContent = `采样更新 ${new Date(monitor.latest.timestamp).toLocaleTimeString("zh-CN", { hour12: false })}`;
$("#energyMonitorPage").dataset.revision = String(Number($("#energyMonitorPage").dataset.revision || 0) + 1);
$("#energyMonitorPage").dataset.sampleCount = String(monitor.frames.length);
renderMainChart();
renderDeviceRows();
}
function deviceRowsMarkup() {
return ENERGY_MONITORED_DEVICES.map((device) => `
<button class="energy-device-row" type="button" data-energy-device="${device.id}" aria-pressed="false">
<span class="energy-device-icon"><i data-lucide="${device.icon}"></i></span>
<span class="energy-device-identity"><strong>${device.name}</strong><small><b data-energy-device-source data-source="estimated">估算</b><i></i><span data-energy-device-activity>等待采样</span></small></span>
<span class="energy-device-metrics"><strong data-energy-device-power>-- W</strong><small data-energy-device-energy>-- Wh</small></span>
<svg class="energy-device-sparkline" viewBox="0 0 92 28" preserveAspectRatio="none" aria-hidden="true"><polyline data-energy-device-sparkline points=""></polyline></svg>
<span class="energy-device-share"><b data-energy-device-share>0%</b><i><em></em></i></span>
</button>`).join("");
}
function createLayer() {
const trigger = document.createElement("button");
trigger.id = "energyMonitorToggle";
trigger.className = "energy-monitor-toggle v2-panel-control-button";
trigger.type = "button";
trigger.setAttribute("aria-controls", "energyMonitorLayer");
trigger.setAttribute("aria-pressed", "false");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", "打开整车能耗监测");
trigger.title = "打开整车能耗监测";
trigger.innerHTML = '<i data-lucide="activity"></i><span class="visually-hidden">整车能耗监测</span><b class="energy-monitor-toggle-live" aria-hidden="true"></b>';
const layer = document.createElement("div");
layer.id = "energyMonitorLayer";
layer.className = "energy-monitor-layer";
layer.hidden = true;
layer.inert = true;
layer.setAttribute("role", "dialog");
layer.setAttribute("aria-modal", "true");
layer.setAttribute("aria-labelledby", "energyMonitorTitle");
layer.innerHTML = `
<section class="energy-monitor-page" id="energyMonitorPage" data-revision="0" data-sample-count="0">
<header class="energy-monitor-header">
<div class="energy-monitor-heading">
<span><i data-lucide="gauge"></i></span>
<div><h2 id="energyMonitorTitle">整车能耗监测</h2><p>实时追踪各可控车端设备功率与累计能耗</p></div>
</div>
<div class="energy-monitor-live-state">
<span class="energy-monitor-live-label"><i></i><b id="energyMonitorLiveLabelText">状态估算</b></span>
<span id="energyMonitorFreshness">最后更新 --:--:--</span>
<span class="energy-monitor-source" id="energyMonitorSource" data-source="estimated">状态估算</span>
<button id="energyMonitorClose" type="button" aria-label="关闭整车能耗监测" title="关闭"><i data-lucide="x"></i></button>
</div>
</header>
<div class="energy-monitor-content">
<section class="energy-monitor-kpis" aria-label="整车能耗摘要">
<article class="energy-kpi energy-kpi-primary"><small>实时总功率</small><strong><b id="energyMonitorTotalPower">--</b><span id="energyMonitorTotalUnit">W</span></strong><em id="energyMonitorTotalQualifier">等待采样</em><i data-lucide="zap"></i></article>
<article class="energy-kpi"><small>本次监测累计能耗</small><strong id="energyMonitorAccumulated">-- Wh</strong><em>按实时功率积分</em></article>
<article class="energy-kpi"><small>本次监测平均功率</small><strong id="energyMonitorAverage">-- W</strong><em>自页面加载开始</em></article>
<article class="energy-kpi"><small>监测设备</small><strong id="energyMonitorCoverage">7 / 7</strong><em id="energyMonitorCoverageNote">等待设备采样</em></article>
</section>
<div class="energy-monitor-dashboard">
<section class="energy-trend-card" aria-labelledby="energyTrendTitle">
<header>
<div><h3 id="energyTrendTitle">功率趋势</h3><p><span id="energyMonitorTrendName">总功率</span> · <span id="energyMonitorTrendWindow">最近 24 秒</span></p></div>
<strong id="energyMonitorTrendCurrent">-- W</strong>
</header>
<div class="energy-trend-tabs" role="group" aria-label="选择功率趋势">
${SERIES.map((series) => `<button type="button" data-energy-series="${series.id}" aria-pressed="${String(series.id === "total")}" class="${series.id === "total" ? "active" : ""}">${series.name}</button>`).join("")}
</div>
<div class="energy-chart-wrap">
<span class="energy-chart-y energy-chart-y-top" id="energyMonitorChartMax">-- W</span>
<span class="energy-chart-y energy-chart-y-mid" id="energyMonitorChartMid">-- W</span>
<span class="energy-chart-y energy-chart-y-bottom">0</span>
<svg id="energyMonitorChart" class="energy-monitor-chart" viewBox="0 0 700 238" preserveAspectRatio="none" role="img" aria-label="功率趋势">
<defs><linearGradient id="energyMonitorAreaGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".22"></stop><stop offset="1" stop-color="currentColor" stop-opacity=".015"></stop></linearGradient></defs>
<g class="energy-chart-grid"><line x1="42" y1="16" x2="686" y2="16"></line><line x1="42" y1="113" x2="686" y2="113"></line><line x1="42" y1="210" x2="686" y2="210"></line></g>
<path id="energyMonitorChartArea" class="energy-chart-area" d=""></path>
<polyline id="energyMonitorChartLine" class="energy-chart-line" points=""></polyline>
</svg>
<div class="energy-chart-x"><span id="energyMonitorChartStart">60 秒前</span><span>现在</span></div>
</div>
<footer><span><i></i>当前选中曲线</span><p>点击右侧设备可查看其独立趋势</p></footer>
</section>
<section class="energy-devices-card" aria-labelledby="energyDevicesTitle">
<header><div><h3 id="energyDevicesTitle">设备实时功率</h3><p>功率 · 累计能耗 · 占比</p></div><span>${ENERGY_MONITORED_DEVICES.length} 个可控设备</span></header>
<div class="energy-device-list" id="energyMonitorDeviceList">${deviceRowsMarkup()}</div>
<footer><i data-lucide="info"></i><span>优先读取车端功率通道;未接入独立电表的设备显示明确标注的状态估算值。</span></footer>
</section>
</div>
</div>
</section>`;
$("#v2PanelControlDock").append(trigger);
document.body.append(layer);
trigger.addEventListener("click", () => setOpen(!monitor.open));
$("#energyMonitorClose", layer).addEventListener("click", () => setOpen(false));
layer.addEventListener("click", (event) => {
if (event.target === layer) setOpen(false);
});
layer.addEventListener("click", (event) => {
const seriesButton = event.target.closest("[data-energy-series]");
const deviceButton = event.target.closest("[data-energy-device]");
if (seriesButton) selectSeries(seriesButton.dataset.energySeries);
if (deviceButton) selectSeries(deviceButton.dataset.energyDevice);
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function focusableElements() {
return $$('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', $("#energyMonitorLayer"))
.filter((element) => !element.hidden && element.offsetParent !== null);
}
function setBackgroundInert(inert) {
const layer = $("#energyMonitorLayer");
if (!layer || !document.body) return;
if (inert) {
monitor.backgroundInert = [...document.body.children]
.filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName))
.map((element) => ({ element, wasInert: element.hasAttribute("inert") }));
monitor.backgroundInert.forEach(({ element }) => { element.toggleAttribute("inert", true); });
return;
}
monitor.backgroundInert.forEach(({ element, wasInert }) => { element.toggleAttribute("inert", wasInert); });
monitor.backgroundInert = [];
}
function captureModalKeys(event) {
if (!monitor.open) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopImmediatePropagation();
setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
const layer = $("#energyMonitorLayer");
if (!layer?.contains(document.activeElement)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function setOpen(open) {
const next = Boolean(open);
const layer = $("#energyMonitorLayer");
const trigger = $("#energyMonitorToggle");
if (!layer || !trigger || monitor.open === next) return;
monitor.open = next;
trigger.classList.toggle("active", next);
trigger.setAttribute("aria-pressed", String(next));
trigger.setAttribute("aria-expanded", String(next));
trigger.setAttribute("aria-label", next ? "关闭整车能耗监测" : "打开整车能耗监测");
trigger.title = next ? "关闭整车能耗监测" : "打开整车能耗监测";
if (next) {
monitor.returnFocus = document.activeElement;
layer.hidden = false;
layer.inert = false;
layer.dataset.open = "true";
setBackgroundInert(true);
renderMonitor();
requestAnimationFrame(() => $("#energyMonitorClose", layer)?.focus());
} else {
layer.dataset.open = "false";
layer.inert = true;
layer.hidden = true;
setBackgroundInert(false);
const target = monitor.returnFocus instanceof HTMLElement && monitor.returnFocus.isConnected
? monitor.returnFocus
: trigger;
monitor.returnFocus = null;
target.focus();
}
}
function selectSeries(seriesId) {
const valid = SERIES.some(({ id }) => id === seriesId)
|| ENERGY_MONITORED_DEVICES.some(({ id }) => id === seriesId);
if (!valid) return;
monitor.activeSeries = seriesId;
renderMainChart();
}
function deviceIdsForControlDomain(domain, context = "") {
const normalizedDomain = normalizeSource(domain).replace(/^native_/, "");
const normalizedContext = normalizeSource(context).replace(/[^a-z0-9\u4e00-\u9fff]+/g, "_");
if (normalizedDomain === "seat" || /(^|_)seat(_|$)/.test(normalizedContext)) {
if (/(^|_)(driver|front_left|left_front)(_|$)|主驾/.test(normalizedContext)) return ["driver"];
if (/(^|_)(passenger|front_right|right_front)(_|$)|副驾/.test(normalizedContext)) return ["passenger"];
return ["passenger"];
}
if (["window", "windows"].includes(normalizedDomain) || /(^|_)window(s)?(_|$)/.test(normalizedContext)) return ["windows"];
if (["roof", "shade", "sunshade"].includes(normalizedDomain) || /(^|_)(roof|shade|sunshade)(_|$)/.test(normalizedContext)) return ["shade"];
if (["climate", "hvac", "outlet"].includes(normalizedDomain) || /(^|_)(climate|hvac|outlet|ac)(_|$)/.test(normalizedContext)) return ["climate"];
if (["interior_light", "reading", "reading_light"].includes(normalizedDomain) || /(^|_)(interior|reading)_?light(_|$)/.test(normalizedContext)) return ["reading"];
if (["ambient", "ambient_light"].includes(normalizedDomain) || /(^|_)ambient_?light(_|$)/.test(normalizedContext)) return ["ambient"];
return [];
}
function controlActivityDevices(payload = {}) {
const wrappedResult = payload?.result && typeof payload.result === "object";
const eventPayload = wrappedResult ? payload.result : payload;
const status = String(eventPayload?.status || "");
if (payload?.error) return [];
if (wrappedResult && !["succeeded", "partially_succeeded"].includes(status)) return [];
if (!wrappedResult && status && !["processing", "succeeded", "partially_succeeded", "submitted"].includes(status)) return [];
if (eventPayload?.preview_only === true || /_preview$/.test(String(eventPayload?.orchestration_route || ""))) {
return [];
}
const deviceIds = new Set();
const directId = aliasToDevice.get(normalizeSource(eventPayload?.device_id))
|| normalizeSource(eventPayload?.device_id);
if (ENERGY_MONITORED_DEVICES.some(({ id }) => id === directId)) deviceIds.add(directId);
const actions = [
...(Array.isArray(eventPayload?.actions) ? eventPayload.actions : []),
...(Array.isArray(eventPayload?.validated_actions) ? eventPayload.validated_actions : []),
...(Array.isArray(eventPayload?.intent_ir?.spec?.actions) ? eventPayload.intent_ir.spec.actions : []),
];
actions.forEach((action) => {
const capabilityDomain = String(action?.capability || "").split(".")[0];
const context = JSON.stringify({
capability: action?.capability,
target: action?.target,
});
deviceIdsForControlDomain(action?.domain || capabilityDomain, context).forEach((id) => deviceIds.add(id));
});
const functionConfigs = Object.entries(eventPayload?.function_configs || {});
if (eventPayload?.function_config && typeof eventPayload.function_config === "object") {
functionConfigs.push([eventPayload.function_config?.spec?.domain || "function", eventPayload.function_config]);
}
functionConfigs.forEach(([configKey, config]) => {
const domain = config?.spec?.domain || config?.domain || configKey;
const operations = config?.spec?.operations || config?.operations || [];
if (!operations.length) {
deviceIdsForControlDomain(domain, configKey).forEach((id) => deviceIds.add(id));
return;
}
operations.forEach((operation) => {
const context = JSON.stringify({
capability: operation?.capability,
target: operation?.target,
});
deviceIdsForControlDomain(operation?.domain || domain, context).forEach((id) => deviceIds.add(id));
});
});
Object.entries(eventPayload?.command_plans || {}).forEach(([planKey, plan]) => {
const domain = plan?.spec?.domain || plan?.domain || planKey;
const commands = plan?.spec?.commands || plan?.commands || [];
const context = commands.length ? JSON.stringify(commands) : planKey;
deviceIdsForControlDomain(domain, context).forEach((id) => deviceIds.add(id));
});
return [...deviceIds];
}
function markControlActivity(payload) {
const until = Date.now() + 4000;
const requestId = controlRequestId(payload);
const currentDeviceIds = controlActivityDevices(payload);
const existing = requestId ? monitor.controlRequests.get(requestId) : null;
const deviceIds = [...new Set([...(existing?.deviceIds || []), ...currentDeviceIds])];
deviceIds.forEach((deviceId) => {
monitor.motionUntil.set(deviceId, Math.max(monitor.motionUntil.get(deviceId) || 0, until));
});
if (requestId && deviceIds.length) {
monitor.controlRequests.set(requestId, { deviceIds, until });
}
return currentDeviceIds;
}
function controlRequestId(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
return String(eventPayload?.request_id || payload?.request_id || "").trim();
}
function completeControlActivity(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
const requestId = controlRequestId(payload);
const tracked = requestId ? monitor.controlRequests.get(requestId) : null;
const succeeded = !payload?.error
&& ["succeeded", "partially_succeeded"].includes(String(eventPayload?.status || ""));
if (tracked) {
monitor.controlRequests.delete(requestId);
if (!succeeded) {
const now = Date.now();
tracked.deviceIds.forEach((deviceId) => {
if ((monitor.motionUntil.get(deviceId) || 0) > tracked.until) return;
const remainingUntil = [...monitor.controlRequests.values()]
.filter((request) => request.deviceIds.includes(deviceId))
.reduce((maximum, request) => Math.max(maximum, request.until), now);
monitor.motionUntil.set(deviceId, remainingUntil);
});
}
return tracked.deviceIds;
}
return succeeded ? markControlActivity(payload) : [];
}
function seedHistory() {
const now = Date.now();
for (let offset = 23; offset >= 1; offset -= 1) {
sampleNow({ now: now - offset * SAMPLE_INTERVAL_MS, integrate: false, render: false });
}
monitor.lastSampleAt = now - SAMPLE_INTERVAL_MS;
sampleNow({ now, integrate: false, render: false });
monitor.startedAt = now;
}
function mount(attempt = 0) {
if (monitor.mounted) return;
if (!$("#v2PanelControlDock")) {
if (attempt < 20) window.setTimeout(() => mount(attempt + 1), 50);
return;
}
monitor.mounted = true;
createLayer();
seedHistory();
renderMonitor();
monitor.timer = window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS);
document.addEventListener("keydown", captureModalKeys, true);
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
const existingUpdater = window.updateDevicePowerTelemetry;
if (typeof existingUpdater === "function" && !existingUpdater.energyMonitorLinked) {
const linkedUpdater = (payload) => {
const result = existingUpdater(payload);
ingestPowerTelemetry(payload, { sample: false });
return result;
};
linkedUpdater.energyMonitorLinked = true;
window.updateDevicePowerTelemetry = linkedUpdater;
}
window.addEventListener("cognitive-agent:vehicle-can-control-request", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:validated-can-commands", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:vehicle-control-activity", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:request-complete", (event) => completeControlActivity(event.detail || {}));
}
window.CognitiveAgentEnergyMonitor = {
close: () => setOpen(false),
getDevices: () => ENERGY_MONITORED_DEVICES.map(({ id, name, telemetryKeys }) => ({ id, name, telemetryKeys: [...telemetryKeys] })),
getSnapshot: publicSnapshot,
ingestTelemetry: ingestPowerTelemetry,
isOpen: () => monitor.open,
completeControlActivity,
notifyControlActivity: markControlActivity,
open: () => setOpen(true),
sampleNow,
selectSeries,
};
document.addEventListener("DOMContentLoaded", () => mount());
}());
+29 -29
View File
@@ -1,40 +1,40 @@
:root {
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--radius: 0.625rem;
--background: oklch(0.968 0.019 244);
--foreground: oklch(0.29 0.042 252);
--card: oklch(0.993 0.007 245);
--card-foreground: oklch(0.34 0.032 250);
--primary: oklch(0.57 0.19 258);
--primary-foreground: oklch(0.99 0.004 245);
--secondary: oklch(0.94 0.03 244);
--secondary-foreground: oklch(0.38 0.045 250);
--muted: oklch(0.955 0.017 245);
--muted-foreground: oklch(0.53 0.035 248);
--accent: oklch(0.918 0.045 249);
--destructive: oklch(0.61 0.19 24);
--border: oklch(0.85 0.04 245);
--ring: oklch(0.65 0.14 256);
--radius: 0.875rem;
font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
}
.dark {
color-scheme: dark;
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--destructive: oklch(0.704 0.191 22.216);
--background: oklch(0.2 0.035 252);
--foreground: oklch(0.94 0.02 245);
--card: oklch(0.25 0.035 251);
--card-foreground: oklch(0.91 0.022 245);
--primary: oklch(0.7 0.15 252);
--primary-foreground: oklch(0.19 0.04 253);
--secondary: oklch(0.285 0.045 250);
--secondary-foreground: oklch(0.92 0.02 245);
--muted: oklch(0.275 0.032 250);
--muted-foreground: oklch(0.72 0.04 245);
--accent: oklch(0.32 0.065 250);
--destructive: oklch(0.7 0.18 24);
--border: oklch(1 0 0 / 10%);
--ring: oklch(0.542 0.034 322.5);
--ring: oklch(0.68 0.13 252);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, color-mix(in oklch, var(--primary) 12%, transparent) 0, transparent 35%), var(--background); }
@@ -49,7 +49,7 @@ h2 { font-size: 22px; }
.lead { margin-top: 12px; }
a, button { font: inherit; }
a { color: var(--primary); text-decoration: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; }
.policy, .card { color: var(--card-foreground); background: color-mix(in oklch, var(--card) 94%, transparent); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px); box-shadow: 0 20px 60px oklch(0.145 0.008 326 / 12%); }
.policy, .card { color: var(--card-foreground); background: color-mix(in oklch, var(--card) 94%, transparent); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px); box-shadow: 0 20px 60px oklch(0.29 0.042 252 / 12%); }
.policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; }
.policy div { display: grid; gap: 3px; flex: 1; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 16px color-mix(in oklch, var(--primary) 55%, transparent); }
+2 -2
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="oklch(0.488 0.243 264.376)">
<meta name="theme-color" content="oklch(0.57 0.19 258)">
<title>真实台架本地决策台</title>
<link rel="stylesheet" href="/hardware-control.css?v=shadcn-theme-1">
<link rel="stylesheet" href="/hardware-control.css?v=ai-blue-2">
</head>
<body>
<main class="shell">
+53 -45
View File
@@ -4,26 +4,30 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="oklch(0.488 0.243 264.376)" />
<meta name="theme-color" content="oklch(0.57 0.19 258)" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-21" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-50" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-42" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-5" />
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-65" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-22" />
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-114" defer></script>
<script src="/speech-input.js?v=sensevoice-7" defer></script>
<script src="/app.js?v=dual-seat-126" defer></script>
<script src="/speech-input.js?v=sensevoice-12" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=delivery-state-55" defer></script>
<script src="/self-extension.js?v=gscu-tcp-41" defer></script>
<script src="/console-v2.js?v=delivery-state-62" defer></script>
<script src="/self-extension.js?v=gscu-tcp-43" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-20" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
</head>
<body class="console-v2">
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
@@ -39,40 +43,53 @@
<div class="mode-rail" aria-label="功能模式">
<div class="mode-rail-bar">
<span class="mode-rail-title">功能模式</span>
<button class="mode-rail-toggle" id="modeRailToggle" type="button" aria-expanded="false" aria-controls="modeRailContent">
<i data-lucide="layout-grid" data-icon="inline-start"></i><span id="modeRailSelection">常用模式</span><i data-lucide="chevron-down" data-icon="inline-end"></i>
</button>
<div class="mode-rail-actions">
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="layout-grid" data-icon="inline-start"></i><span>自定义</span><strong id="modeGalleryCount">0</strong></button>
<button class="mode-create-toggle" id="saveModeToggle" type="button"><i data-lucide="bookmark-plus" data-icon="inline-start"></i><span>根据指令创建</span></button>
<div class="mode-rail-identity">
<span class="mode-rail-mark" aria-hidden="true"><i data-lucide="sparkles"></i></span>
<span><strong class="mode-rail-title">功能模式</strong><small>预设与我的模式</small></span>
</div>
</div>
<div class="mode-rail-content" id="modeRailContent" role="region" aria-label="功能模式列表" hidden>
<div class="mode-chips" aria-label="快捷功能模式" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
<div class="mode-rail-content" id="modeRailContent" role="region" aria-label="功能模式列表">
<div class="mode-chips" tabindex="0" aria-label="快捷功能模式,可横向滚动" aria-live="polite">
<button type="button" data-mode-profile-id="zero_gravity" data-mode-origin="preset" data-mode-command="进入零重力模式"><i data-lucide="orbit"></i><span>零重力</span></button>
<button type="button" data-mode-profile-id="office" data-mode-origin="preset" data-mode-command="进入办公模式"><i data-lucide="briefcase"></i><span>办公</span></button>
<button type="button" data-mode-profile-id="nap" data-mode-origin="preset" data-mode-command="进入小憩模式"><i data-lucide="moon-star"></i><span>小憩</span></button>
</div>
</div>
<div class="mode-rail-actions">
<button class="mode-gallery-toggle" id="modeGalleryToggle" type="button" aria-expanded="false" aria-controls="modeGallery"><i data-lucide="settings-2" data-icon="inline-start"></i><span>管理</span><strong id="modeGalleryCount">0</strong></button>
<button class="mode-create-toggle" id="saveModeToggle" type="button"><i data-lucide="sparkles" data-icon="inline-start"></i><span>AI 新建</span></button>
<button class="mode-rail-toggle" id="modeRailToggle" type="button" aria-expanded="true" aria-controls="modeRailContent" aria-label="收起功能模式" title="收起功能模式">
<span class="visually-hidden" id="modeRailSelection">静态模式</span><i data-lucide="chevron-up"></i>
</button>
</div>
</div>
</div>
<form id="requestForm">
<form id="requestForm" autocomplete="off">
<section class="assistant-transcript" id="assistantTranscript" role="log" aria-live="polite" aria-label="AI助手对话">
<div class="assistant-messages" id="assistantMessages">
<article class="assistant-message assistant-message-assistant">
<span class="assistant-avatar" aria-hidden="true"><i data-lucide="waypoints"></i></span>
<div class="assistant-message-body">
<div class="assistant-message-content">你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。</div>
</div>
</article>
</div>
</section>
<div class="field-label-row">
<label for="utterance">自然语言指令</label>
<label for="utterance">向 AI助手提问或交代工作</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="1000" placeholder="例如:有点热,把副驾驶调舒服些,再把天幕调暗">进入观影状态:空调24度、风量2档,天幕调暗,氛围灯换成暖橙色</textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音听写" aria-pressed="false" title="语音听写(自动识别说话结束)">
<textarea id="utterance" name="utterance" maxlength="4000" autocomplete="off" placeholder="向 AI助手提问或交代工作"></textarea>
<button class="speech-dictation" id="speechDictationButton" type="button" aria-label="开始语音输入" aria-pressed="false" title="开始语音输入">
<i class="speech-mic-icon" data-lucide="mic" aria-hidden="true"></i>
<span class="speech-waveform" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="speech-visually-hidden">开始语音听写</span>
<span class="speech-visually-hidden">开始语音输入</span>
</button>
<span class="speech-status" id="speechDictationStatus" role="status" aria-live="polite"></span>
<span class="composer-hint">Enter 执行 · Shift + Enter 换行</span>
<span class="composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="field-group">
@@ -155,9 +172,9 @@
<p class="provider-note" id="vehicleNote">正在读取座椅量程与角度标定</p>
</div>
<button class="primary-action" id="runButton" type="submit" aria-label="执行车舱控制指令">
<i data-lucide="play"></i>
<span>生成并执行</span>
<button class="primary-action" id="runButton" type="submit" aria-label="发送消息">
<i data-lucide="send-horizontal"></i>
<span>发送</span>
</button>
</form>
@@ -341,16 +358,6 @@
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="通过模型动画展示 CH0 副驾座椅与 CH1 主驾座椅、空调气流、智能天幕、阅读灯、四门车窗与前大灯状态的整车数字孪生"></canvas>
<div class="twin-seat-label passenger"><i></i><span>副驾座椅</span><small>CH0 · 右前排</small></div>
<div class="twin-seat-label driver"><i></i><span>主驾座椅</span><small>CH1 · 左前排</small></div>
<div class="twin-callout-layer" aria-hidden="true">
<div class="twin-callout" data-callout="longitudinal"><span>水平位置</span><b>50%</b><small>导轨</small></div>
<div class="twin-callout" data-callout="cushion_tilt"><span>坐垫俯仰</span><b>4.5°</b><small>50%</small></div>
<div class="twin-callout" data-callout="height"><span>座椅高度</span><b>50%</b><small>CH1 高度电机</small></div>
<div class="twin-callout" data-callout="backrest_recline"><span>靠背夹角</span><b>13.6°</b><small>20% · 相对归零位</small></div>
<div class="twin-callout" data-callout="legrest_height"><span>腿托夹角</span><b>0°</b><small>0% · 相对垂直零位</small></div>
<div class="twin-callout" data-callout="legrest_extension"><span>腿托伸缩</span><b>0%</b><small>行程</small></div>
<div class="twin-callout included-angle-callout" data-callout="backrest_included_angle" hidden><span>靠背-坐垫夹角</span><b>104°</b><small>本轮指定夹角</small></div>
<div class="twin-callout included-angle-callout" data-callout="legrest_included_angle" hidden><span>坐垫-腿托夹角</span><b>95°</b><small>本轮指定夹角</small></div>
</div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
@@ -544,13 +551,14 @@
<section class="mode-gallery" id="modeGallery" role="dialog" aria-modal="true" aria-labelledby="modeGalleryTitle">
<header class="mode-gallery-header">
<div>
<h2 id="modeGalleryTitle">自定义模式</h2>
<h2 id="modeGalleryTitle">管理功能模式</h2>
<p>按运行方式整理,预设与自定义模式统一归类</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭自定义模式"><i data-lucide="x"></i></button>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭功能模式管理"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>可用模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>执行状态与主界面同步:蓝色执行中、绿色已完成、红色执行失败。</span></footer>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>静态模式执行后保持目标状态;动态模式持续变化,可随时停止。执行状态与主界面同步。</span></footer>
</section>
</div>
+37 -37
View File
@@ -1,24 +1,24 @@
:root {
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--radius: 0.625rem;
--background: oklch(0.968 0.019 244);
--foreground: oklch(0.29 0.042 252);
--card: oklch(0.993 0.007 245);
--card-foreground: oklch(0.34 0.032 250);
--popover: oklch(0.998 0.004 245);
--popover-foreground: oklch(0.29 0.042 252);
--primary: oklch(0.57 0.19 258);
--primary-foreground: oklch(0.99 0.004 245);
--secondary: oklch(0.94 0.03 244);
--secondary-foreground: oklch(0.38 0.045 250);
--muted: oklch(0.955 0.017 245);
--muted-foreground: oklch(0.53 0.035 248);
--accent: oklch(0.918 0.045 249);
--accent-foreground: oklch(0.38 0.1 258);
--destructive: oklch(0.61 0.19 24);
--border: oklch(0.85 0.04 245);
--input: oklch(0.82 0.045 246);
--ring: oklch(0.65 0.14 256);
--radius: 0.875rem;
color: var(--foreground);
background: var(--background);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
@@ -27,24 +27,24 @@
.dark {
color-scheme: dark;
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--background: oklch(0.2 0.035 252);
--foreground: oklch(0.94 0.02 245);
--card: oklch(0.25 0.035 251);
--card-foreground: oklch(0.91 0.022 245);
--popover: oklch(0.235 0.038 252);
--popover-foreground: oklch(0.94 0.02 245);
--primary: oklch(0.7 0.15 252);
--primary-foreground: oklch(0.19 0.04 253);
--secondary: oklch(0.285 0.045 250);
--secondary-foreground: oklch(0.92 0.02 245);
--muted: oklch(0.275 0.032 250);
--muted-foreground: oklch(0.72 0.04 245);
--accent: oklch(0.32 0.065 250);
--accent-foreground: oklch(0.91 0.03 244);
--destructive: oklch(0.7 0.18 24);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5);
--ring: oklch(0.68 0.13 252);
}
* {
@@ -58,7 +58,7 @@ body {
background:
linear-gradient(color-mix(in oklch, var(--border) 70%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in oklch, var(--border) 70%, transparent) 1px, transparent 1px),
var(--muted);
linear-gradient(145deg, var(--background), var(--secondary));
background-size: 34px 34px;
}
@@ -77,7 +77,7 @@ body {
border: 1px solid var(--border);
border-top: 4px solid var(--primary);
border-radius: var(--radius);
box-shadow: 0 18px 50px oklch(0.145 0.008 326 / 12%);
box-shadow: 0 18px 50px oklch(0.29 0.042 252 / 12%);
}
.login-brand {
+2 -2
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="oklch(0.488 0.243 264.376)" />
<meta name="theme-color" content="oklch(0.57 0.19 258)" />
<title>登录 · Cognitive Agent</title>
<link rel="stylesheet" href="/login.css?v=shadcn-theme-1" />
<link rel="stylesheet" href="/login.css?v=ai-blue-2" />
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/login.js" defer></script>
</head>
+2 -2
View File
@@ -7,8 +7,8 @@
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "oklch(1 0 0)",
"theme_color": "oklch(0.488 0.243 264.376)",
"background_color": "oklch(0.968 0.019 244)",
"theme_color": "oklch(0.57 0.19 258)",
"icons": [
{
"src": "/icons/app-icon-192.png",
+89
View File
@@ -3893,3 +3893,92 @@ form > .v2-extension-primary {
background: var(--primary);
border-color: var(--primary);
}
/* AI light-blue closure for the full extension workspace ---------------- */
.console-v2 .v2-extension-view,
.console-v2 .v2-self-extension {
color: var(--body-foreground);
background:
radial-gradient(circle at 80% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 24rem),
linear-gradient(150deg, var(--background), var(--secondary));
}
.console-v2 .v2-extension-sidebar,
.console-v2 .v2-extension-config-card,
.console-v2 .v2-extension-type-card,
.console-v2 .v2-extension-vehicle-card,
.console-v2 .v2-extension-action-board,
.console-v2 .v2-extension-composer,
.console-v2 .v2-extension-no-device,
.console-v2 .v2-extension-twin-board {
color: var(--body-foreground);
background: color-mix(in oklch, var(--card) 86%, var(--secondary));
border-color: var(--border);
box-shadow: 0 8px 22px color-mix(in oklch, var(--foreground) 7%, transparent);
}
.console-v2 .v2-extension-heading h2,
.console-v2 .v2-extension-profile-title,
.console-v2 .v2-extension-config-card strong,
.console-v2 .v2-extension-type-card strong,
.console-v2 .v2-extension-vehicle-card strong,
.console-v2 .v2-extension-capability strong,
.console-v2 .v2-extension-command-row strong {
color: var(--heading-foreground);
}
.console-v2 .v2-extension-heading p,
.console-v2 .v2-field-caption,
.console-v2 .v2-extension-config-card small,
.console-v2 .v2-extension-type-card small,
.console-v2 .v2-extension-vehicle-card small,
.console-v2 .v2-extension-capability small,
.console-v2 .v2-extension-command-row small,
.console-v2 .v2-extension-footer {
color: var(--subtle-foreground);
}
.console-v2 .v2-extension-mode-switch,
.console-v2 .v2-extension-control,
.console-v2 .v2-extension-capability,
.console-v2 .v2-extension-command-row,
.console-v2 .v2-nfc-select-all,
.console-v2 .v2-nfc-command-option,
.console-v2 .v2-nfc-no-devices {
color: var(--body-foreground);
background: var(--muted);
border-color: var(--border);
}
.console-v2 .v2-extension-mode-switch button.active,
.console-v2 .v2-extension-type-card.active,
.console-v2 .v2-extension-vehicle-card.active,
.console-v2 .v2-extension-control.active {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
}
.console-v2 .v2-extension-execution-state.success,
.console-v2 .v2-extension-toast.success,
.console-v2 .v2-nfc-capability-card.learned,
.console-v2 .v2-nfc-inline[data-state="recognized"] {
color: var(--status-success-foreground);
background: var(--status-success-soft);
border-color: color-mix(in oklch, var(--status-success) 34%, var(--border));
}
.console-v2 .v2-extension-execution-state.pending,
.console-v2 .v2-nfc-capability-card.pending,
.console-v2 .v2-nfc-inline[data-state="new-card"] {
color: var(--status-warning-foreground);
background: var(--status-warning-soft);
border-color: color-mix(in oklch, var(--status-warning) 34%, var(--border));
}
.console-v2 .v2-extension-execution-state.error,
.console-v2 .v2-extension-toast.error {
color: var(--status-error-foreground);
background: var(--status-error-soft);
border-color: color-mix(in oklch, var(--status-error) 34%, var(--border));
}
-2
View File
@@ -3629,7 +3629,6 @@
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = devices.length
? "已感应 NFC 外设"
: sensedCardUid
@@ -3672,7 +3671,6 @@
<header class="v2-extension-sync-heading">
<div><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><i data-lucide="scan-line"></i><span>NFC 设备绑定</span></button></div>
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
</header>
<section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
+14 -10
View File
@@ -1,21 +1,25 @@
const CACHE_VERSION = "cognitive-agent-pad-v228";
const CACHE_VERSION = "cognitive-agent-pad-v269";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
"/",
"/index.html",
"/styles.css?v=dual-seat-21",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-114",
"/app.js?v=dual-seat-126",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-50",
"/console-v2.js?v=delivery-state-55",
"/self-extension.css?v=gscu-tcp-42",
"/self-extension.js?v=gscu-tcp-41",
"/speech-input.css?v=sensevoice-5",
"/speech-input.js?v=sensevoice-7",
"/speech-audio-worklet.js?v=sensevoice-4",
"/console-v2.css?v=delivery-state-65",
"/console-v2.js?v=delivery-state-62",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.js?v=gscu-tcp-43",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-12",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-22",
"/assistant-chat.js?v=codex-assistant-20",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
+41
View File
@@ -91,6 +91,26 @@
box-shadow: 0 0 0 5px color-mix(in oklch, var(--primary) 9%, transparent), 0 7px 18px color-mix(in oklch, var(--primary) 20%, transparent);
}
.console-v2 .v2-request-form .speech-dictation.is-armed,
.console-v2 .v2-request-form .speech-dictation.is-armed:hover:not(:disabled),
.console-v2 .v2-request-form .speech-dictation.is-armed:focus-visible {
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--background));
box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .speech-dictation.is-armed::after {
width: 7px;
height: 7px;
position: absolute;
top: 4px;
right: 3px;
content: "";
background: var(--primary);
border: 2px solid var(--background);
border-radius: 50%;
}
.console-v2 .v2-request-form .speech-dictation.is-listening .speech-mic-icon,
.console-v2 .v2-request-form .speech-dictation.is-processing .speech-mic-icon {
display: none;
@@ -149,14 +169,31 @@
box-shadow: 0 12px 35px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 0 0 1px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="armed"],
.console-v2 .v2-request-form .command-composer[data-speech-state="capturing"] {
border-color: color-mix(in oklch, var(--primary) 36%, transparent);
box-shadow: 0 12px 35px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 0 0 1px color-mix(in oklch, var(--primary) 8%, transparent);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="listening"] .speech-status {
color: var(--foreground);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="armed"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="capturing"] .speech-status {
color: var(--foreground);
}
.console-v2 .v2-request-form .command-composer[data-speech-state="processing"] .speech-status {
color: color-mix(in oklch, var(--primary) 72%, var(--foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="warming"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="arming"] .speech-status,
.console-v2 .v2-request-form .command-composer[data-speech-state="finalizing"] .speech-status {
color: color-mix(in oklch, var(--primary) 72%, var(--foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="success"] .speech-status {
color: var(--primary);
}
@@ -166,6 +203,10 @@
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--chart-1) 58%, transparent);
}
.console-v2 .v2-request-form #utterance[data-speech-stage="partial"] {
color: color-mix(in oklch, var(--foreground) 82%, var(--muted-foreground));
}
.console-v2 .v2-request-form .command-composer[data-speech-state="error"] .speech-status {
color: var(--destructive);
}
File diff suppressed because it is too large Load Diff
+90 -66
View File
@@ -1,39 +1,54 @@
:root {
color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.828 0.111 230.318);
--chart-2: oklch(0.685 0.169 237.323);
--chart-3: oklch(0.588 0.158 241.966);
--chart-4: oklch(0.5 0.134 242.749);
--chart-5: oklch(0.443 0.11 240.79);
--background: oklch(0.968 0.019 244);
--foreground: oklch(0.29 0.042 252);
--card: oklch(0.993 0.007 245);
--card-foreground: oklch(0.34 0.032 250);
--popover: oklch(0.998 0.004 245);
--popover-foreground: oklch(0.29 0.042 252);
--primary: oklch(0.57 0.19 258);
--primary-foreground: oklch(0.99 0.004 245);
--secondary: oklch(0.94 0.03 244);
--secondary-foreground: oklch(0.38 0.045 250);
--muted: oklch(0.955 0.017 245);
--muted-foreground: oklch(0.53 0.035 248);
--accent: oklch(0.918 0.045 249);
--accent-foreground: oklch(0.38 0.1 258);
--destructive: oklch(0.61 0.19 24);
--border: oklch(0.85 0.04 245);
--input: oklch(0.82 0.045 246);
--ring: oklch(0.65 0.14 256);
--chart-1: oklch(0.76 0.12 225);
--chart-2: oklch(0.66 0.16 247);
--chart-3: oklch(0.64 0.145 165);
--chart-4: oklch(0.72 0.15 78);
--chart-5: oklch(0.61 0.16 298);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62);
--sidebar-ring: oklch(0.711 0.019 323.02);
--status-online: oklch(0.64 0.18 152);
--status-offline: oklch(0.577 0.245 27.325);
--sidebar: oklch(0.938 0.033 244);
--sidebar-foreground: oklch(0.31 0.04 251);
--sidebar-primary: oklch(0.57 0.19 258);
--sidebar-primary-foreground: oklch(0.99 0.004 245);
--sidebar-accent: oklch(0.902 0.052 248);
--sidebar-accent-foreground: oklch(0.37 0.085 256);
--sidebar-border: oklch(0.83 0.047 245);
--sidebar-ring: oklch(0.65 0.14 256);
--status-online: oklch(0.64 0.145 165);
--status-offline: oklch(0.61 0.19 24);
--status-success: oklch(0.64 0.145 165);
--status-warning: oklch(0.72 0.15 78);
--status-info: var(--primary);
--status-error: var(--destructive);
--heading-foreground: oklch(0.255 0.052 253);
--body-foreground: var(--card-foreground);
--subtle-foreground: var(--muted-foreground);
--status-success-foreground: oklch(0.42 0.115 165);
--status-warning-foreground: oklch(0.46 0.105 76);
--status-info-foreground: oklch(0.42 0.14 258);
--status-error-foreground: oklch(0.48 0.16 24);
--status-success-soft: color-mix(in oklch, var(--status-success) 12%, var(--card));
--status-warning-soft: color-mix(in oklch, var(--status-warning) 12%, var(--card));
--status-info-soft: color-mix(in oklch, var(--status-info) 10%, var(--card));
--status-error-soft: color-mix(in oklch, var(--status-error) 10%, var(--card));
--command-channel: var(--amber);
--command-source: var(--chart-3);
--command-delivered: var(--status-online);
@@ -58,15 +73,15 @@
--teal-soft: color-mix(in oklch, var(--primary) 10%, var(--background));
--blue: var(--primary);
--blue-soft: color-mix(in oklch, var(--primary) 10%, var(--background));
--amber: oklch(0.65 0.17 70);
--amber: var(--status-warning);
--amber-soft: color-mix(in oklch, var(--amber) 12%, var(--background));
--red: var(--destructive);
--red-soft: color-mix(in oklch, var(--destructive) 10%, var(--background));
--shadow: 0 8px 24px oklch(0.145 0.008 326 / 7%);
--shadow: 0 8px 24px oklch(0.29 0.042 252 / 8%);
--accent-bright: color-mix(in oklch, var(--primary) 82%, white);
--accent-ink: var(--primary);
--accent-wash: color-mix(in oklch, var(--primary) 9%, var(--background));
--soft-shadow: 0 18px 50px oklch(0.145 0.008 326 / 9%);
--soft-shadow: 0 18px 50px oklch(0.29 0.042 252 / 10%);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--ink);
background: var(--bg);
@@ -75,39 +90,50 @@
.dark {
color-scheme: dark;
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--background: oklch(0.2 0.035 252);
--foreground: oklch(0.94 0.02 245);
--card: oklch(0.25 0.035 251);
--card-foreground: oklch(0.91 0.022 245);
--popover: oklch(0.235 0.038 252);
--popover-foreground: oklch(0.94 0.02 245);
--primary: oklch(0.7 0.15 252);
--primary-foreground: oklch(0.19 0.04 253);
--secondary: oklch(0.285 0.045 250);
--secondary-foreground: oklch(0.92 0.02 245);
--muted: oklch(0.275 0.032 250);
--muted-foreground: oklch(0.72 0.04 245);
--accent: oklch(0.32 0.065 250);
--accent-foreground: oklch(0.91 0.03 244);
--destructive: oklch(0.7 0.18 24);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5);
--ring: oklch(0.68 0.13 252);
--chart-1: oklch(0.828 0.111 230.318);
--chart-2: oklch(0.685 0.169 237.323);
--chart-3: oklch(0.588 0.158 241.966);
--chart-4: oklch(0.5 0.134 242.749);
--chart-5: oklch(0.443 0.11 240.79);
--sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.23 0.04 252);
--sidebar-foreground: oklch(0.92 0.02 245);
--sidebar-primary: oklch(0.7 0.15 252);
--sidebar-primary-foreground: oklch(0.19 0.04 253);
--sidebar-accent: oklch(0.3 0.055 250);
--sidebar-accent-foreground: oklch(0.92 0.02 245);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.542 0.034 322.5);
--status-online: oklch(0.76 0.18 152);
--status-offline: oklch(0.704 0.191 22.216);
--sidebar-ring: oklch(0.68 0.13 252);
--status-online: oklch(0.74 0.14 165);
--status-offline: oklch(0.72 0.17 24);
--status-success: oklch(0.74 0.14 165);
--status-warning: oklch(0.79 0.14 78);
--status-info: var(--primary);
--status-error: var(--destructive);
--heading-foreground: oklch(0.96 0.018 245);
--body-foreground: var(--card-foreground);
--subtle-foreground: var(--muted-foreground);
--status-success-foreground: oklch(0.82 0.12 165);
--status-warning-foreground: oklch(0.86 0.12 78);
--status-info-foreground: oklch(0.84 0.1 248);
--status-error-foreground: oklch(0.84 0.1 24);
}
html {
@@ -479,7 +505,7 @@ body.mode-editor-open {
.mode-gallery-groups {
min-height: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 13px;
padding-right: 4px;
@@ -531,12 +557,10 @@ body.mode-editor-open {
}
.mode-gallery-group > header small {
overflow: hidden;
color: #8a9797;
font-size: 8px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-wrap: balance;
}
.mode-gallery-group > header b {
@@ -552,7 +576,7 @@ body.mode-editor-open {
.mode-gallery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 7px;
}