Merge origin/feature/N7Web into feature/N7Web

Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
Miayui
2026-09-17 12:46:51 +08:00
135 changed files with 30180 additions and 513 deletions
+274 -25
View File
@@ -11,6 +11,7 @@
let pendingMessage = null;
let latestControlResult = null;
let activeModeDraftContext = null;
let activeSchedulePoll = null;
let initialized = false;
const $ = (selector, root = document) => root.querySelector(selector);
@@ -176,11 +177,13 @@
if (!compact || !context?.name) return false;
const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?";
const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)";
const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)";
const addPredicate = "(?:加入|添加|增加|补充|加上|追加|新增)";
const changePredicate = `(?:${addPredicate}|修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)`;
const modeName = "([^,。!?!?、;;::]{1,64}?模式)";
const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)";
const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`;
const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|(?:吗|么|呢)[。.!!]?$/;
const declinedAddition = new RegExp(`^(?:先|暂时)?(?:不要|别|不用|不必|无需|取消|撤销)(?:再|继续)?${addPredicate}`);
const candidates = [
{
pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`),
@@ -194,14 +197,29 @@
pattern: new RegExp(`^${politePrefix}${modeName}${modeMemberBoundary}[,,::]?(.+)$`),
predicateRequired: true,
},
{
pattern: new RegExp(`^${politePrefix}(?:在|给|为|往|向)${modeName}(?:${modePossessiveBoundary})?[,,::]?(.+)$`),
predicateRequired: true,
},
{
pattern: new RegExp(`^${politePrefix}${modeName}((?:再|也|还)?${addPredicate}.+)$`),
predicateRequired: true,
},
{
pattern: new RegExp(`^${politePrefix}(?:把|将)(.+?)${addPredicate}(?:到|进)?${modeName}(?:中|里|内)?[。.!!]?$`),
predicateRequired: false,
nameIndex: 2,
changeIndex: 1,
},
];
return candidates.some(({ pattern, predicateRequired }) => {
return candidates.some(({ pattern, predicateRequired, nameIndex = 1, changeIndex = 2 }) => {
const matched = compact.match(pattern);
const change = String(matched?.[2] || "");
const change = String(matched?.[changeIndex] || "");
return Boolean(
matched
&& normalizeModeName(matched[1]) === normalizeModeName(context.name)
&& normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)
&& !question.test(change)
&& !declinedAddition.test(change)
&& (!predicateRequired || new RegExp(changePredicate).test(change))
);
});
@@ -894,7 +912,7 @@
const consumed = new Set();
const rows = [];
const targetZone = (action) => String(
action?.target?.instance || action?.target?.zone || action?.target || "",
action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "",
);
const parameters = (action) => (
action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
@@ -965,6 +983,10 @@
const capability = String(action.capability || "");
const domain = String(action.domain || "");
const zone = targetZone(action);
const zoneLabel = {
driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾",
front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
}[zone] || "";
const params = parameters(action);
let label = capability || domain || "座舱设备";
let value = "已配置";
@@ -999,35 +1021,86 @@
"seat.set_cushion_tilt": "坐垫角度",
"seat.set_backrest_recline": "靠背角度",
"seat.set_legrest_height": "腿托高度",
"seat.set_ventilation": "座椅通风",
"seat.set_heating": "座椅加热",
"seat.set_driver_ventilation_native": "座椅通风",
"seat.set_driver_heating_native": "座椅加热",
"seat.set_driver_massage_native": "座椅按摩",
};
label = `${seatLabel}${seatCapabilityLabels[capability] || "座椅"}`;
value = params.position_pct != null
? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}`
: motionLabel(params.state || params.mode_id);
if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
// CH1 driver controls encode high as 1 and low as 2; passenger
// comfort levels use a different scale and must not reuse this map.
const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认";
value = params.state === "off" ? "关闭"
: params.state === "on" ? `开启 · ${level}` : "开关状态待确认";
} else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) {
value = params.state === "off" ? "关闭"
: params.state === "on"
? `开启${params.level != null ? ` · ${params.level}档` : ""}`
: "开关状态待确认";
} else if (capability === "seat.set_driver_massage_native") {
const massageModes = {
wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松",
};
const massageStrengths = { 1: "轻柔", 2: "中等", 3: "较强" };
value = params.action === "off" ? "关闭"
: params.action === "mode"
? (massageModes[params.mode] ? `${massageModes[params.mode]}模式` : "按摩模式待确认")
: params.action === "strength"
? (massageStrengths[params.strength] ? `${massageStrengths[params.strength]}力度` : "按摩力度待确认")
: "按摩动作待确认";
}
icon = "armchair";
order = instructionOrder("座椅", "副驾", "主驾");
} else if (capability === "climate.set_temperature") {
label = "空调温度";
label = `${zoneLabel}空调温度`;
value = `${params.temperature_c ?? "--"}℃`;
icon = "thermometer";
order = instructionOrder("空调", "温度");
} else if (domain === "climate") {
const cycleLabels = { internal: "内循环", external: "外循环", auto: "自动循环" };
const climateModeLabels = {
"climate.set_cycle_mode": { internal: "内循环", external: "外循环" },
"climate.set_control_mode": { manual: "手动控制", auto: "自动控制" },
"climate.set_outlet_mode": {
face: "吹面", face_feet: "吹面 / 吹脚", feet: "吹脚",
feet_defrost: "吹脚 / 除霜", defrost: "除霜",
face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜",
},
};
label = capability === "climate.set_fan_level" ? "空调风量"
: capability === "climate.set_cycle_mode" ? "空调循环" : "空调";
: capability === "climate.set_cycle_mode" ? "空调循环"
: capability === "climate.set_control_mode" ? "空调控制模式"
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
label = `${zoneLabel}${label}`;
value = params.level != null ? `${params.level}档`
: params.mode != null ? (cycleLabels[String(params.mode)] || String(params.mode))
: params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode))
: motionLabel(params.state);
icon = "fan";
order = instructionOrder("空调", "风量");
} else if (capability === "interior_light.set_reading_power") {
label = "阅读灯";
label = `${zoneLabel}阅读灯`;
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);
if (capability === "ambient_light.set_brightness_level") {
label = "氛围灯亮度";
value = params.brightness_level != null ? `${params.brightness_level}档` : "亮度档位待确认";
} else if (capability === "ambient_light.set_color_index") {
label = "氛围灯颜色";
value = params.color_index != null ? `色号 ${params.color_index}` : "颜色待确认";
} else if (capability === "ambient_light.set_illumination") {
value = [
params.color_hex != null ? `颜色 ${params.color_hex}` : "颜色待确认",
params.brightness_pct != null ? `亮度 ${params.brightness_pct}%` : "亮度待确认",
].join(" · ");
}
icon = "lightbulb";
order = instructionOrder(label, "灯光");
}
@@ -1036,6 +1109,63 @@
return rows.sort((left, right) => left.order - right.order);
}
function modeDraftScheduleView(draft) {
const schedule = draft?.schedule;
if (
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|| !Number.isInteger(schedule.repeat_count)
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
) return null;
const durationLabel = (durationMs) => {
const minutes = Math.floor(durationMs / 60000);
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
};
let offsetMs = 0;
const phases = schedule.phases.map((phase, index) => {
// Each phase is rendered separately, without instruction-based grouping:
// the same seat or temperature can have a different target later on.
const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({
...operation,
target: operation.target || (operation.domain === "seat"
? { instance: operation.target_scope }
: { zone: operation.target_scope }),
})));
const view = {
id: String(phase.id || `phase-${index + 1}`),
name: String(phase.name || `阶段 ${index + 1}`),
timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`,
rows,
};
offsetMs += phase.duration_ms;
return view;
});
return {
phases,
operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0),
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`,
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。",
};
}
function appendModeDraftRows(container, rows) {
rows.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);
container.append(row);
});
}
async function saveModeProfileDraft(message, card, draft, buttons, status) {
buttons.forEach((button) => { button.disabled = true; });
card.classList.add("is-busy");
@@ -1151,23 +1281,39 @@
headingCopy.append(eyebrow, title);
const badge = document.createElement("span");
badge.className = "assistant-mode-draft-badge";
badge.textContent = `${Number(draft.operation_count) || 0} 项动作`;
const scheduleView = modeDraftScheduleView(draft);
badge.textContent = `${scheduleView?.operationCount ?? (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);
});
if (scheduleView) {
card.classList.add("assistant-mode-draft-scheduled");
const summary = document.createElement("p");
summary.className = "assistant-mode-schedule-summary";
summary.textContent = scheduleView.summary;
list.append(summary);
scheduleView.phases.forEach((phase, index) => {
const section = document.createElement("section");
section.className = "assistant-mode-schedule-phase";
section.dataset.modeSchedulePhase = phase.id;
const phaseHeading = document.createElement("header");
const name = document.createElement("strong");
name.textContent = `${index + 1}. ${phase.name}`;
const timing = document.createElement("small");
timing.textContent = phase.timing;
phaseHeading.append(name, timing);
section.append(phaseHeading);
appendModeDraftRows(section, phase.rows);
list.append(section);
});
const hint = document.createElement("p");
hint.className = "assistant-mode-schedule-hint";
hint.textContent = scheduleView.stopHint;
list.append(hint);
} else {
appendModeDraftRows(list, modeDraftRows(draft, actions));
}
const controls = document.createElement("div");
controls.className = "assistant-mode-draft-actions";
@@ -1267,6 +1413,90 @@
}
}
function stopScheduleStatusPoll() {
if (!activeSchedulePoll) return;
window.clearTimeout(activeSchedulePoll.timer);
activeSchedulePoll.controller?.abort();
activeSchedulePoll = null;
}
function startScheduleStatusPoll(payload, message) {
stopScheduleStatusPoll();
const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id
|| payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || "");
if (!vehicleId) return;
const poll = {
vehicleId, conversationId: conversationId(), message,
sessionId: String(payload?.schedule_execution?.session_id || ""),
runId: String(payload?.schedule_execution?.run_id || ""),
modeProfileId: String(payload?.mode_profile?.id || ""),
timer: null, controller: null,
};
activeSchedulePoll = poll;
const isCurrent = () => activeSchedulePoll === poll
&& conversationId() === poll.conversationId
&& (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId);
const update = async () => {
if (!isCurrent()) {
if (activeSchedulePoll === poll) stopScheduleStatusPoll();
return;
}
poll.controller = new AbortController();
try {
const response = await window.CognitiveAgentNetwork.request(
`/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`,
{ method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 },
);
if (!isCurrent()) return;
if (response.status === 401) {
setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed");
stopScheduleStatusPoll();
return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
if (!isCurrent()) return;
if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) {
throw new Error("时间轴状态车型不匹配");
}
if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId)
|| (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) {
setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working");
stopScheduleStatusPoll();
return;
}
const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status);
if (status.status === "running") {
const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0
? ` · 阶段 ${status.phase_index + 1}` : "";
const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0
? ` · 第 ${status.repeat_index + 1} 轮` : "";
setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working");
} else if (terminal) {
const stopped = ["cancelled", "stopped"].includes(status.status);
const failed = status.status === "failed";
const copy = failed
? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。`
: stopped
? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。"
: status.status === "completed"
? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。"
: "当前没有正在运行的动态时间轴。";
setActionStatus(message, copy, failed ? "failed" : "success");
window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status);
stopScheduleStatusPoll();
return;
}
} catch (error) {
if (!isCurrent() || poll.controller?.signal.aborted) return;
setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working");
} finally {
if (isCurrent()) poll.timer = window.setTimeout(update, 2000);
}
};
poll.timer = window.setTimeout(update, 2000);
}
function applyAssistantEvent(event, message) {
if (event.type === "validated_control_commands" && event.payload) {
window.dispatchEvent(new CustomEvent(
@@ -1295,14 +1525,20 @@
if (event.type === "action.completed" || event.type === "work.completed") {
latestControlResult = event.payload || latestControlResult;
const completed = event.success !== false;
const scheduleRunning = completed
&& event.payload?.orchestration_route === "mode_schedule_execute"
&& (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running");
const defaultText = event.type === "work.completed"
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
: (completed ? "座舱控制已完成" : "座舱控制未完成");
setActionStatus(
message,
event.display_text || defaultText,
completed ? "success" : "failed",
scheduleRunning
? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。"
: event.display_text || defaultText,
scheduleRunning ? "working" : completed ? "success" : "failed",
);
if (scheduleRunning) startScheduleStatusPoll(event.payload, message);
if (event.payload) {
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
appendDispatchedControls(message, event.payload);
@@ -1574,6 +1810,8 @@
: beginModeExecution(profile, { command: commandText });
const message = activeHandle.message;
const status = String(result?.status || "");
const scheduleRunning = status === "running"
&& result?.schedule_execution?.status === "running";
const dryRun = result?.execution_mode === "dry_run";
const waitingForFeedback = result?.execution_outcome === "submitted_unverified";
@@ -1583,6 +1821,10 @@
if (error) {
content = `${modeName}执行失败:${error.message || "请稍后重试。"}`;
statusText = "功能模式执行失败";
} else if (scheduleRunning) {
content = `已启动动态时间轴:${commandText}。后续动作将分阶段执行。`;
statusText = "动态时间轴执行中,可输入“停止动态模式”提前结束";
statusState = "working";
} else if (status === "succeeded") {
content = dryRun
? `已完成模拟编排:${commandText}。`
@@ -1603,6 +1845,7 @@
setMessageContent(message, content, { final: true });
setActionStatus(message, statusText, statusState);
if (result) appendDispatchedControls(message, result);
if (scheduleRunning) startScheduleStatusPoll(result, message);
persistMessages();
return activeHandle;
}
@@ -1689,9 +1932,15 @@
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest("#newConversation")) return;
event.preventDefault?.();
stopScheduleStatusPoll();
resetConversation();
}, true);
document.addEventListener("change", (event) => {
if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll();
});
window.addEventListener("pagehide", stopScheduleStatusPoll);
document.addEventListener("DOMContentLoaded", initialize);
window.CognitiveAgentAssistant = {
submit: submitAssistant,