feat: make mode creation preview-only and cache-safe

This commit is contained in:
Miayui
2026-09-20 19:32:03 +08:00
parent bfff1b515c
commit 89707a42e2
28 changed files with 1375 additions and 165 deletions
+11 -2
View File
@@ -552,7 +552,8 @@
}
.assistant-mode-schedule-summary,
.assistant-mode-schedule-hint {
.assistant-mode-schedule-hint,
.assistant-mode-draft-preview-notice {
margin: 0;
padding: 9px 12px;
color: var(--assistant-muted, #64748b);
@@ -568,10 +569,18 @@
}
.assistant-mode-schedule-phase + .assistant-mode-schedule-phase,
.assistant-mode-schedule-hint {
.assistant-mode-schedule-hint,
.assistant-mode-draft-preview-notice {
border-top: 1px solid var(--assistant-border, #e5eaf1);
}
.assistant-mode-draft-preview-notice {
margin: 0;
color: var(--status-success-foreground, #087854);
background: var(--status-success-soft, #ebf9f3);
font-weight: 700;
}
.assistant-mode-schedule-phase > header {
display: grid;
gap: 3px;
+167 -14
View File
@@ -569,6 +569,11 @@
"seat.set_backrest_recline": "靠背",
"seat.set_legrest_height": "腿托",
"seat.set_legrest_extension": "腿托",
"seat.set_ventilation": "通风",
"seat.set_heating": "加热",
"seat.set_driver_ventilation_native": "通风",
"seat.set_driver_heating_native": "加热",
"seat.set_driver_massage_native": "按摩",
}[capability];
if (component) return `${seat}${component}`;
if (capability === "seat.set_driver_axis_native") {
@@ -585,7 +590,19 @@
return seat;
}
if (capability.startsWith("outlet.")) return "空调风口";
return String(fallback || capability || "控制指令");
const domain = String(operation.domain || capability.split(".", 1)[0] || "");
return {
climate: "空调",
window: "车窗",
roof: "遮阳帘",
ambient_light: "氛围灯",
interior_light: "室内灯",
self_extension: "红外外设",
door: "车门",
outlet: "空调风口",
}[domain] || (/^[\u4e00-\u9fff]/.test(String(fallback || ""))
? String(fallback)
: "控制指令");
}
function fallbackAssistantControlCommands(result = {}) {
@@ -620,6 +637,8 @@
}
function assistantDispatchedCommands(result = {}) {
const previewOnly = String(result.execution_mode || "dry_run") !== "hardware"
|| result.orchestration_route === "mode_profile_create_preview";
const projections = Array.isArray(result.validated_control_commands)
? result.validated_control_commands
: [];
@@ -652,22 +671,30 @@
} catch (_error) {
commands = [];
}
if (!commands.length) return fallbackAssistantControlCommands(result);
if (!commands.length) commands = fallbackAssistantControlCommands(result);
return commands.map((item, index) => {
const operation = projectionByRef.get(String(item.operationRef || ""))
|| operationByRef.get(String(item.operationRef || ""))
|| projections[index]?.operation
|| {};
const pendingDelivery = result.status === "processing"
const pendingDelivery = result.status === "processing" && !previewOnly
? {
deliveryState: "submitting",
deliveryLabel: "正在下发",
deliveryDetail: "",
}
: {};
const previewDelivery = previewOnly
? {
deliveryState: "not_submitted",
deliveryLabel: "仅预览 · 未下发",
deliveryDetail: "创建模式期间不会发送控制指令",
}
: {};
return {
...item,
...pendingDelivery,
...previewDelivery,
category: assistantControlCategory(operation, item.category),
operation,
};
@@ -691,6 +718,11 @@
manual: "手动",
internal: "内循环",
external: "外循环",
romantic: "浪漫",
cinema: "影院",
rest: "休息",
camping: "露营",
meditation: "冥想",
}[normalized] || String(value ?? "").trim();
}
@@ -764,6 +796,9 @@
message.dispatchedControlResult = mergedResult;
const commands = assistantDispatchedCommands(mergedResult);
if (!commands.length) return;
const previewOnly = String(mergedResult.execution_mode || "dry_run") !== "hardware"
|| mergedResult.orchestration_route === "mode_profile_create_preview";
const headingText = previewOnly ? "控制指令预览(未下发)" : "下发控制指令";
const signature = JSON.stringify(commands.map((item) => [
item.operationRef,
@@ -794,7 +829,7 @@
deliveryLabel: String(item.deliveryLabel || "未下发"),
deliveryDetail: String(item.deliveryDetail || ""),
}));
if (vueRenderer(host, viewCommands)) {
if (vueRenderer(host, viewCommands, { heading: headingText })) {
host.dataset.commandSignature = signature;
message.article?.classList.add("assistant-message-with-dispatched-controls");
focusLatestDispatchedControls(host);
@@ -821,7 +856,7 @@
headingIcon.className = "assistant-dispatched-controls-heading-icon";
headingIcon.innerHTML = '<i data-lucide="list-tree"></i>';
const heading = document.createElement("b");
heading.textContent = "下发控制指令";
heading.textContent = headingText;
const count = document.createElement("strong");
count.className = "assistant-dispatched-controls-count";
const chevron = document.createElement("i");
@@ -835,6 +870,8 @@
}
card.dataset.commandSignature = signature;
const heading = $("summary b", card);
if (heading) heading.textContent = headingText;
const count = $(".assistant-dispatched-controls-count", card);
const list = $(".assistant-dispatched-controls-list", card);
if (count) count.textContent = String(commands.length);
@@ -925,6 +962,30 @@
indexes.forEach((index) => consumed.add(index));
rows.push({ label, value, icon, order });
};
const domainLabels = {
climate: "空调",
window: "车窗",
roof: "遮阳帘",
seat: "座椅",
ambient_light: "氛围灯",
interior_light: "室内灯",
self_extension: "红外外设",
door: "车门",
outlet: "空调风口",
};
const capabilityLabels = {
"climate.set_power": "空调电源",
"climate.set_temperature": "空调温度",
"climate.set_fan_level": "空调风量",
"climate.set_cycle_mode": "空调循环",
"climate.set_control_mode": "空调控制模式",
"climate.set_outlet_mode": "空调出风模式",
"window.set_position": "车窗位置",
"roof.set_shade_position": "遮阳帘位置",
"ambient_light.set_power": "氛围灯",
"ambient_light.set_mode": "氛围灯模式",
"infrared.emit": "红外外设",
};
const zeroGravityIndexes = actions
.map((action, index) => ({ action, index }))
@@ -978,6 +1039,65 @@
if (position === 100) return `完全打开(${target})`;
return target;
};
const localizedParameterValue = (value) => ({
on: "开启", off: "关闭", open: "打开", close: "关闭", stop: "停止",
auto: "自动", manual: "手动", internal: "内循环", external: "外循环",
romantic: "浪漫", cinema: "影院", rest: "休息", camping: "露营",
}[String(value)] || String(value));
const genericParameterText = (params) => Object.entries(params)
.filter(([key]) => key !== "slot" && key !== "capability_ref")
.map(([key, rawValue]) => {
const name = {
state: "状态", motion: "动作", action: "动作", mode: "模式",
level: "档位", temperature_c: "温度", position_pct: "目标开度",
brightness_pct: "亮度", brightness_level: "亮度档位",
color_hex: "颜色", color_index: "色号",
}[key] || "参数";
const suffix = key === "temperature_c" ? "℃"
: ["position_pct", "brightness_pct"].includes(key) ? "%"
: key === "level" || key === "brightness_level" ? "档" : "";
return `${name}:${localizedParameterValue(rawValue)}${suffix}`;
})
.join(" · ");
const passengerComfort = actions
.map((action, index) => ({ action, index }))
.filter(({ action, index }) => !consumed.has(index)
&& ["passenger", "front_right"].includes(targetZone(action))
&& [
"seat.set_ventilation",
"seat.set_backrest_recline",
"seat.set_legrest_height",
].includes(String(action.capability || "")));
const comfortByCapability = new Map(passengerComfort.map(({ action, index }) => [
String(action.capability || ""), { action, index },
]));
if ([
"seat.set_ventilation",
"seat.set_backrest_recline",
"seat.set_legrest_height",
].every((capability) => comfortByCapability.has(capability))) {
const ventilation = parameters(comfortByCapability.get("seat.set_ventilation").action);
const backrest = parameters(comfortByCapability.get("seat.set_backrest_recline").action);
const legrest = parameters(comfortByCapability.get("seat.set_legrest_height").action);
const comfortMentions = ["座椅", "副驾", "舒适"]
.map((term) => instruction.indexOf(term))
.filter((index) => index >= 0);
const comfortOrder = comfortMentions.length
? Math.min(...comfortMentions)
: 10_000 + Math.max(...passengerComfort.map(({ index }) => index));
addRow(
"副驾座椅舒适模式",
[
`通风${ventilation.state === "off" ? "关闭" : `开启 ${ventilation.level ?? 1}档`}`,
`靠背 ${backrest.position_pct ?? "--"}%`,
`腿托 ${legrest.position_pct ?? "--"}%`,
].join(" · "),
"armchair",
comfortOrder,
passengerComfort.map(({ index }) => index),
);
}
actions.forEach((action, index) => {
if (consumed.has(index)) return;
const capability = String(action.capability || "");
@@ -988,12 +1108,20 @@
front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
}[zone] || "";
const params = parameters(action);
let label = capability || domain || "座舱设备";
let label = capabilityLabels[capability] || domainLabels[domain] || "座舱设备";
let value = "已配置";
let icon = "sliders-horizontal";
let order = 10_000 + index;
if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") {
if (capability === "infrared.emit" || domain === "self_extension") {
label = String(action?.target?.name || action?.target?.device || "红外外设").trim()
|| "红外外设";
value = params.action != null
? `动作:${localizedParameterValue(params.action)}`
: "动作已配置";
icon = "radio-tower";
order = instructionOrder(label, "外设", "灯");
} else if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") {
label = "遮阳帘";
value = params.position_pct != null
? shadePositionLabel(params.position_pct)
@@ -1011,7 +1139,13 @@
rear_right: "右后车窗",
};
label = windowLabels[zone] || "车窗";
value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion);
value = params.position_pct != null
? Number(params.position_pct) === 0
? "完全关闭(目标开度 0%)"
: Number(params.position_pct) === 100
? "完全打开(目标开度 100%)"
: `目标开度 ${params.position_pct}%`
: motionLabel(params.motion);
icon = "panels-top-left";
order = instructionOrder("车窗");
} else if (domain === "seat") {
@@ -1071,7 +1205,8 @@
face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜",
},
};
label = capability === "climate.set_fan_level" ? "空调风量"
label = capability === "climate.set_power" ? "空调电源"
: capability === "climate.set_fan_level" ? "空调风量"
: capability === "climate.set_cycle_mode" ? "空调循环"
: capability === "climate.set_control_mode" ? "空调控制模式"
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
@@ -1104,6 +1239,9 @@
icon = "lightbulb";
order = instructionOrder(label, "灯光");
}
if (value === "已配置") {
value = genericParameterText(params) || "参数已配置";
}
addRow(label, value, icon, order, [index]);
});
return rows.sort((left, right) => left.order - right.order);
@@ -1219,12 +1357,12 @@
card.classList.add("is-busy");
status.hidden = false;
status.className = "assistant-mode-draft-status working";
status.textContent = "正在进行模拟试运行…";
status.textContent = "正在重新进行 dry-run 校验(不会下发)…";
try {
const result = await window.CognitiveAgentConsole?.tryModeDraft?.({ ...draft, actions });
if (!result || result.status !== "succeeded") throw new Error("试运行未通过,请检查动作设置。");
status.className = "assistant-mode-draft-status success";
status.textContent = "试运行通过 · 尚未保存";
status.textContent = "校验通过 · 未下发 · 尚未保存";
} catch (error) {
status.className = "assistant-mode-draft-status failed";
status.textContent = error.message || "试运行失败,请重试。";
@@ -1275,7 +1413,7 @@
const eyebrow = document.createElement("small");
eyebrow.textContent = draft.existing_mode_id
? "已载入现有功能模式"
: "已为你生成功能模式";
: "已为你生成功能模式(未下发)";
const title = document.createElement("strong");
title.textContent = `「${draft.name}」`;
headingCopy.append(eyebrow, title);
@@ -1314,6 +1452,10 @@
} else {
appendModeDraftRows(list, modeDraftRows(draft, actions));
}
const previewNotice = document.createElement("p");
previewNotice.className = "assistant-mode-draft-preview-notice";
previewNotice.textContent = "创建阶段仅生成并校验控制方案,不会向 CAN 或红外外设发送指令。";
list.append(previewNotice);
const controls = document.createElement("div");
controls.className = "assistant-mode-draft-actions";
@@ -1326,7 +1468,7 @@
const trial = document.createElement("button");
trial.type = "button";
trial.className = "assistant-mode-draft-trial";
trial.textContent = "先试运行";
trial.textContent = "重新校验(不下发)";
controls.append(save, trial);
const status = document.createElement("p");
status.className = "assistant-mode-draft-status";
@@ -1379,12 +1521,23 @@
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function isModeCreationPreviewRequest(message) {
const compact = String(message || "").trim().replace(/\s+/g, "");
if (!compact || /取消(?:创建|构建|生成)|(?:不要|别|禁止)(?:创建|构建|生成)/.test(compact)) {
return false;
}
return /(?:构建|创建|新建|新增|生成|添加)(?:一下)?(?:一个|个|一套|套)?[^,。!?!?]{1,64}?模式/.test(compact);
}
function requestBody(message) {
const executionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run";
const configuredExecutionMode = 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;
const modeProfileDraftContext = requestModeDraftContext(message);
const modeConfigurationPreview = Boolean(modeProfileDraftContext)
|| isModeCreationPreviewRequest(message);
const executionMode = modeConfigurationPreview ? "dry_run" : configuredExecutionMode;
return {
message,
conversation_id: conversationId(),
+1 -1
View File
@@ -45,7 +45,7 @@
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=local-first-77-performance" defer></script>
<script src="/self-extension.js?v=nfc-mqtt-56" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-35" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
<script src="/firmware-workbench-entry.js?v=3" defer></script>
</head>
+4 -4
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v328-nfc-mqtt";
const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -16,13 +16,13 @@ const APP_SHELL = [
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-29",
"/assistant-chat.js?v=codex-assistant-34",
"/assistant-chat.css?v=codex-assistant-30",
"/assistant-chat.js?v=codex-assistant-35",
"/energy-monitor.css?v=energy-monitor-5-dialog-size",
"/energy-monitor.js?v=energy-monitor-5-performance",
"/ui-consistency.css?v=spatial-ui-8",
"/codepen-glass-theme.css?v=creative-cloud-6-performance",
"/vue-ui.js?v=vue-presentation-3",
"/vue-ui.js?v=vue-presentation-4",
"/firmware-workbench-entry.css?v=2",
"/firmware-workbench-entry.js?v=3",
"/firmware-workbench.html",
+79 -2
View File
@@ -1,5 +1,13 @@
import { cpSync, copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { createHash } from "node:crypto";
import {
cpSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
@@ -45,6 +53,74 @@ const runtimeFiles = [
"codepen-glass-theme.css",
"vue-ui.js"
];
const fingerprintedRuntimeFiles = runtimeFiles.filter((file) => (
/\.(?:css|js)$/.test(file)
&& !["service-worker.js", "speech-audio-worklet.js"].includes(file)
));
const rewrittenHtmlFiles = [
"index.html",
"login.html",
"hardware-control.html",
"firmware-workbench.html",
];
function contentHash(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function hashedRuntimeName(file, hash) {
const extension = extname(file);
return `${basename(file, extension)}.${hash}${extension}`;
}
function rewriteRuntimeReferences(value, manifest) {
let rewritten = value;
for (const [source, target] of Object.entries(manifest)) {
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
rewritten = rewritten.replace(
new RegExp(`/${escaped}(?:\\?[^\\s\"'<>)]*)?`, "g"),
`/${target}`,
);
}
return rewritten;
}
function fingerprintRuntimeAssets() {
const manifest = {};
for (const file of fingerprintedRuntimeFiles) {
const source = resolve(outputRoot, file);
if (!existsSync(source)) continue;
const contents = readFileSync(source);
const hashedName = hashedRuntimeName(file, contentHash(contents));
writeFileSync(resolve(outputRoot, hashedName), contents);
manifest[file] = hashedName;
}
for (const file of rewrittenHtmlFiles) {
const target = resolve(outputRoot, file);
if (!existsSync(target)) continue;
const source = readFileSync(target, "utf8");
writeFileSync(target, rewriteRuntimeReferences(source, manifest), "utf8");
}
const serviceWorkerPath = resolve(outputRoot, "service-worker.js");
const buildHash = contentHash(JSON.stringify(manifest));
if (existsSync(serviceWorkerPath)) {
const serviceWorker = rewriteRuntimeReferences(
readFileSync(serviceWorkerPath, "utf8"),
manifest,
).replace(
/const CACHE_VERSION = "[^"]+";/,
`const CACHE_VERSION = "cognitive-agent-pad-build-${buildHash}";`,
);
writeFileSync(serviceWorkerPath, serviceWorker, "utf8");
}
writeFileSync(
resolve(outputRoot, "runtime-assets.json"),
`${JSON.stringify({ build_hash: buildHash, assets: manifest }, null, 2)}\n`,
"utf8",
);
}
function copyRuntimeAssets() {
return {
@@ -64,6 +140,7 @@ function copyRuntimeAssets() {
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
}
fingerprintRuntimeAssets();
}
};
}
+4 -3
View File
@@ -111,7 +111,7 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue";
}
function createCommandMount(container) {
const state = reactive({ commands: [], open: true });
const state = reactive({ commands: [], open: true, heading: "下发控制指令" });
const app = createApp({
name: "N7DispatchedControlsCard",
setup() {
@@ -131,7 +131,7 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue";
h("span", { class: "assistant-dispatched-controls-heading-icon" }, [
h("i", { "data-lucide": "list-tree" }),
]),
h("b", null, "下发控制指令"),
h("b", null, state.heading),
h("strong", { class: "assistant-dispatched-controls-count" }, String(state.commands.length)),
h("i", {
class: "assistant-dispatched-controls-chevron",
@@ -148,12 +148,13 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue";
return mount;
}
function renderDispatchedControls(container, commands) {
function renderDispatchedControls(container, commands, options = {}) {
if (!(container instanceof Element) || !Array.isArray(commands) || !commands.length) {
return false;
}
const mount = commandMounts.get(container) || createCommandMount(container);
replaceReactiveArray(mount.state.commands, commands);
mount.state.heading = text(options.heading, "下发控制指令");
container.dataset.vueSurface = "dispatched-controls";
refreshIcons();
return true;