feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
}());
|
||||
+1148
-8
File diff suppressed because it is too large
Load Diff
+301
-40
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}());
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+669
-196
File diff suppressed because it is too large
Load Diff
+90
-66
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user