feat: integrate N7Cockpit remote mode control

This commit is contained in:
vee
2026-08-13 10:36:24 +08:00
parent fd3c219c39
commit 78d758d3fb
108 changed files with 23150 additions and 16 deletions
+136 -5
View File
@@ -58,6 +58,11 @@ const state = {
physicalStateSyncPromise: null,
pendingModeProfileId: null,
modeRunTimer: null,
modeEventSequence: 0,
modeEventPoller: null,
modeEventStartedAt: Date.now(),
activeModeExecutions: new Set(),
renderedModeExecutions: new Set(),
modeDraftCommand: "",
lastSuccessfulUtterance: "",
};
@@ -4159,17 +4164,141 @@ function setModeChipsDisabled(disabled) {
});
}
function applyModeCommand(command, modeProfileId = null) {
async function applyModeCommand(command, modeProfileId = null) {
if (!command) return;
if ($("#runButton").disabled) {
showToast("上一条指令仍在执行,请稍候");
return;
}
if (!modeProfileId) {
$("#utterance").value = command;
$("#requestForm").requestSubmit();
return;
}
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware"
&& !driverOnlyCh1
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
showToast(state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权");
return;
}
closeModeGallery({ restoreFocus: false });
state.pendingModeProfileId = modeProfileId;
if (modeProfileId) setModeExecutionState(modeProfileId, "running");
$("#utterance").value = command;
$("#requestForm").requestSubmit();
setModeExecutionState(modeProfileId, "running");
setModeChipsDisabled(true);
try {
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
const response = await apiRequest(
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/mode-executions`,
{
method: "POST",
timeoutMs: 10000,
headers: {
"Content-Type": "application/json",
"Idempotency-Key": createOpaqueId("web-mode"),
},
body: JSON.stringify({
mode_id: modeProfileId,
execution_target: state.executionMode,
source: "web",
source_device_id: "web-console",
...(state.executionMode === "hardware" && !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
}),
},
);
const execution = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw new Error(execution.message || execution.error || `HTTP ${response.status}`);
state.activeModeExecutions.add(execution.execution_id);
showToast(execution.message || `${modeChipLabel({ name: command })}已进入执行队列`);
} catch (error) {
setModeExecutionState(modeProfileId, "failed");
setModeChipsDisabled(false);
showToast(`模式提交失败:${error.message || "服务不可用"}`);
}
}
function webModeState(status) {
if (["queued", "validating", "executing", "verifying"].includes(status)) return "running";
if (["succeeded", "partially_succeeded"].includes(status)) return "succeeded";
return "failed";
}
function modeExecutionTerminal(status) {
return ["succeeded", "partially_succeeded", "failed", "cancelled", "timed_out"].includes(status);
}
async function handleModeExecutionEvent(event) {
const execution = event?.execution || event;
if (!execution?.execution_id || !execution.mode_id) return;
setModeExecutionState(execution.mode_id, webModeState(execution.status));
if (modeExecutionTerminal(execution.status)) {
state.activeModeExecutions.delete(execution.execution_id);
} else {
state.activeModeExecutions.add(execution.execution_id);
}
setModeChipsDisabled(state.activeModeExecutions.size > 0);
$("#resultTitle").textContent = execution.message || `正在执行${execution.mode_name}`;
if (execution.source === "android") {
showToast(`N7Cockpit:${execution.message || execution.mode_name}`);
}
if (!modeExecutionTerminal(execution.status)
|| state.renderedModeExecutions.has(execution.execution_id)) return;
state.renderedModeExecutions.add(execution.execution_id);
try {
const response = await apiRequest(
`/api/v1/mode-executions/${encodeURIComponent(execution.execution_id)}`,
{ cache: "no-store", timeoutMs: 10000 },
);
const completed = await response.json();
if (response.ok && completed.result) {
renderResult(completed.result);
// renderResult updates the digital twin, but its generic domain summary must not
// replace the stable mode identity required by Web/Android remote operation.
$("#resultTitle").textContent = execution.message || `${execution.mode_name}执行完成`;
$("#requestMeta").textContent = [
execution.execution_id,
`mode:${execution.mode_id}`,
`source:${execution.source === "android" ? "N7Cockpit" : execution.source}`,
execution.execution_target,
].filter(Boolean).join(" / ");
}
} catch (_error) {
// The mode card and status message still contain the terminal state.
}
}
async function pollModeExecutionEvents({ initial = false } = {}) {
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
if (!vehicleId || document.visibilityState === "hidden") return;
try {
const response = await apiRequest(
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/events?after=${state.modeEventSequence}`,
{ cache: "no-store", timeoutMs: 8000 },
);
if (!response.ok) return;
const payload = await response.json();
state.modeEventSequence = Number(payload.next_sequence || state.modeEventSequence);
const events = Array.isArray(payload.events) ? payload.events : [];
for (const event of events) {
const createdAt = Date.parse(event.created_at || "");
if (initial && Number.isFinite(createdAt) && createdAt < state.modeEventStartedAt - 2000) continue;
await handleModeExecutionEvent(event);
}
if (initial && Array.isArray(payload.active_executions)) {
for (const execution of payload.active_executions) await handleModeExecutionEvent(execution);
}
} catch (_error) {
// The normal connection probe remains responsible for the global offline badge.
}
}
function updateModeDraftPreview() {
@@ -5941,6 +6070,8 @@ document.addEventListener("DOMContentLoaded", async () => {
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
await loadAuthStatus();
await loadRuntime();
await pollModeExecutionEvents({ initial: true });
state.modeEventPoller = window.setInterval(pollModeExecutionEvents, 1000);
if (!isDriverOnlyCh1Runtime()) {
await loadHardwareAccessStatus({ silent: true });
state.hardwareAccessPoller = window.setInterval(