feat: complete dual-seat whole-vehicle orchestration

This commit is contained in:
vee
2026-08-12 15:12:40 +08:00
parent 32379a1573
commit 9ce64b3a01
62 changed files with 9741 additions and 3950 deletions
+264 -66
View File
@@ -151,47 +151,121 @@
},
};
const steppedActions = (values, makeAction) => values.map(makeAction);
const percentSteps = [0, 25, 50, 75, 100];
const VEHICLE_DEVICES = [
{ name: "主驾座椅", code: "SEAT · CH1 BODY CAN", icon: "armchair", actions: [
{ name: "通风低档", command: "seat driver vent low" },
{ name: "通风高档", command: "seat driver vent high" },
{ name: "关闭通风", command: "seat driver vent off" },
{ name: "加热低档", command: "seat driver heat low" },
{ name: "加热高档", command: "seat driver heat high" },
{ name: "关闭加热", command: "seat driver heat off" },
{ name: "按摩弱档", command: "seat driver massage strength weak" },
{ name: "舒缓按摩", command: "seat driver massage mode relax" },
{ name: "关闭按摩", command: "seat driver massage off" },
] },
{ name: "空调", code: "CLIMATE · CAN", icon: "snowflake", actions: [
{ name: "开启空调", command: "ac on" },
{ name: "关闭空调", command: "ac off" },
{ name: "24℃", command: "ac temp driver 24" },
{ name: "风量 3", command: "ac fan 3" },
{ name: "除霜出风", command: "ac mode defrost" },
{ name: "自动模式", command: "ac control auto" },
] },
{ name: "遮阳帘", code: "ROOF · SHADE", icon: "panels-top-left", actions: [
{ name: "开启 10%", command: "shade position 10" },
{ name: "打开", command: "shade position 100" },
{ name: "关闭", command: "shade position 0" },
{ name: "半开", command: "shade position 50" },
] },
{ name: "车内灯光", code: "INTERIOR · LIGHT", icon: "lamp-ceiling", actions: [
{ name: "室内灯", command: "light indoor on" },
{ name: "关闭室内灯", command: "light indoor off" },
{ name: "阅读灯", command: "light reading all on" },
{ name: "关闭阅读灯", command: "light reading all off" },
{ name: "氛围灯", command: "light ambient on" },
{ name: "关闭氛围灯", command: "light ambient off" },
{ name: "氛围色号 32", command: "light ambient color 32" },
{ name: "氛围亮度", command: "light ambient brightness 3" },
] },
{ name: "车窗", code: "WINDOW · CH1", icon: "gallery-vertical-end", actions: [
{ name: "左前窗 10%", command: "window front-left position 10" },
{ name: "左前窗一半", command: "window front-left position 50" },
{ name: "左前窗关闭", command: "window front-left position 0" },
] },
{
id: "passenger-seat",
name: "副驾座椅",
code: "SEAT · CH0 T2G CAN",
channel: "CH0",
icon: "armchair",
parameters: ["通风 0–3 档", "加热 0–2 档", "水平 0–100%", "坐盆 0–100%", "靠背 95–163°", "腿托 95–167°"],
actions: [
...steppedActions([1, 2, 3], (level) => ({ name: `副驾通风 ${level} 档`, command: `副驾座椅通风${level}档` })),
{ name: "关闭副驾通风", command: "关闭副驾座椅通风" },
...steppedActions([1, 2], (level) => ({ name: `副驾加热 ${level} 档`, command: `副驾座椅加热${level}档` })),
{ name: "关闭副驾加热", command: "关闭副驾座椅加热" },
...["水平", "坐盆", "靠背", "腿托"].flatMap((axis) => steppedActions(percentSteps, (position) => ({
name: `副驾${axis} ${position}%`,
command: `副驾${axis}调到${position}%`,
}))),
],
},
{
id: "driver-seat",
name: "主驾座椅",
code: "SEAT · CH1 BODY CAN",
channel: "CH1",
icon: "armchair",
parameters: ["通风 / 加热", "按摩强度 / 模式", "水平前后", "整椅高低", "靠背前后", "坐盆高低"],
actions: [
{ name: "通风低档", command: "seat driver vent low" },
{ name: "通风高档", command: "seat driver vent high" },
{ name: "关闭通风", command: "seat driver vent off" },
{ name: "加热低档", command: "seat driver heat low" },
{ name: "加热高档", command: "seat driver heat high" },
{ name: "关闭加热", command: "seat driver heat off" },
...steppedActions(["weak", "medium", "strong"], (strength) => ({ name: `按摩 ${strength}`, command: `seat driver massage strength ${strength}` })),
...steppedActions(["wave", "pulse", "stretch", "slack", "relax"], (mode) => ({ name: `按摩 ${mode}`, command: `seat driver massage mode ${mode}` })),
{ name: "关闭按摩", command: "seat driver massage off" },
...["horizontal", "backrest"].flatMap((axis) => ["forward", "backward", "stop"].map((direction) => ({
name: `${axis} ${direction}`,
command: `seat driver ${axis} ${direction}${direction === "stop" ? "" : " 500"}`,
}))),
...["height", "basin"].flatMap((axis) => ["up", "down", "stop"].map((direction) => ({
name: `${axis} ${direction}`,
command: `seat driver ${axis} ${direction}${direction === "stop" ? "" : " 500"}`,
}))),
],
},
{
id: "climate",
name: "空调",
code: "CLIMATE · CH1 CAN",
channel: "CH1",
icon: "snowflake",
parameters: ["电源", "主 / 副驾 18–32℃", "风量 1–7", "出风模式", "内外循环", "前除霜"],
actions: [
{ name: "开启空调", command: "ac on" },
{ name: "关闭空调", command: "ac off" },
...["driver", "passenger"].flatMap((zone) => steppedActions(
Array.from({ length: 15 }, (_, index) => index + 18),
(temperature) => ({ name: `${zone} ${temperature}℃`, command: `ac temp ${zone} ${temperature}` }),
)),
...steppedActions(Array.from({ length: 7 }, (_, index) => index + 1), (level) => ({ name: `风量 ${level}`, command: `ac fan ${level}` })),
...steppedActions(["face", "feet", "face-feet", "defrost", "feet-defrost"], (mode) => ({ name: `出风 ${mode}`, command: `ac mode ${mode}` })),
{ name: "自动模式", command: "ac control auto" },
{ name: "手动模式", command: "ac control manual" },
{ name: "内循环", command: "ac cycle internal" },
{ name: "外循环", command: "ac cycle external" },
{ name: "开启前除霜", command: "ac defrost on" },
{ name: "关闭前除霜", command: "ac defrost off" },
],
},
{
id: "roof",
name: "遮阳帘",
code: "ROOF · CH1 CAN",
channel: "CH1",
icon: "panels-top-left",
parameters: ["位置 0–100%", "开启", "关闭", "暂停"],
actions: steppedActions(Array.from({ length: 11 }, (_, index) => index * 10), (position) => ({
name: `遮阳帘 ${position}%`,
command: `shade position ${position}`,
})),
},
{
id: "interior-light",
name: "车内灯光",
code: "INTERIOR · CH1 CAN",
channel: "CH1",
icon: "lamp-ceiling",
parameters: ["室内灯", "四区阅读灯", "氛围灯电源", "亮度 1–5", "色号 1–127"],
actions: [
{ name: "室内灯开启", command: "light indoor on" },
{ name: "室内灯关闭", command: "light indoor off" },
{ name: "阅读灯开启", command: "light reading all on" },
{ name: "阅读灯关闭", command: "light reading all off" },
{ name: "氛围灯开启", command: "light ambient on" },
{ name: "氛围灯关闭", command: "light ambient off" },
...steppedActions([1, 2, 3, 4, 5], (level) => ({ name: `氛围亮度 ${level}`, command: `light ambient brightness ${level}` })),
...steppedActions([1, 16, 32, 48, 64, 80, 96, 112, 127], (color) => ({ name: `氛围色号 ${color}`, command: `light ambient color ${color}` })),
],
},
{
id: "windows",
name: "车窗",
code: "WINDOW · CH1 CAN",
channel: "CH1",
icon: "gallery-vertical-end",
parameters: ["左前 0–100%", "右前 0–100%", "左后 0–100%", "右后 0–100%", "开 / 关 / 停"],
actions: ["front-left", "front-right", "rear-left", "rear-right"].flatMap((positionName) => steppedActions(percentSteps, (position) => ({
name: `${positionName} ${position}%`,
command: `window ${positionName} position ${position}`,
}))),
},
];
const DEFAULT_DEVICE = {
@@ -214,6 +288,8 @@
let healthPollTimer = null;
let healthToastRequested = false;
let latestVehicleStateTurn = -1;
let sharedTwinBand = null;
let sharedTwinHome = null;
function uid(prefix) {
if (globalThis.crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
@@ -254,6 +330,7 @@
settings: { actionDelayMs: 520 },
history: [],
lastProtocol: null,
vehicleExecutions: {},
};
}
@@ -265,6 +342,9 @@
}
saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : [];
saved.history = Array.isArray(saved.history) ? saved.history : [];
saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
? saved.vehicleExecutions
: {};
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
return saved;
@@ -417,8 +497,17 @@
<div class="v2-extension-vehicle-grid" id="extensionVehicleGrid"></div>
</section>
</div>
<section class="v2-extension-twin-board" aria-labelledby="extensionTwinTitle">
<header>
<div><span class="section-kicker">SHARED VEHICLE DIGITAL TWIN</span><h2 id="extensionTwinTitle">整车孪生实时映射</h2><p>与自编排复用同一孪生实例,CH0 副驾座椅与 CH1 主驾座椅 / 车身状态持续同步。</p></div>
<span><i></i>实际反馈 / 当前动作</span>
</header>
<div class="v2-extension-twin-host" id="extensionTwinHost">
<div class="v2-extension-twin-waiting"><i data-lucide="loader-circle"></i><span>正在挂载整车孪生界面</span></div>
</div>
</section>
<div class="v2-extension-run-plan" id="extensionVehicleStateSummary" data-state="unknown">
<span><i>CH1</i>等待权威车端状态</span>
<span><i>CH0 / CH1</i>等待权威车端状态</span>
</div>
<div class="v2-extension-run-plan" id="extensionRunPlan" hidden></div>
</section>`;
@@ -527,9 +616,13 @@
return conversationId;
}
function isPhysicalVehicleSource(value) {
return ["physical_can", "physical_can_ch0", "physical_can_ch1", "physical_partial"].includes(value);
}
function containsAuthoritativePhysicalCh1State(value) {
if (!value || typeof value !== "object") return false;
if (value.source === "physical_can_ch1" || value.feedback_source === "physical_can_ch1") {
if (isPhysicalVehicleSource(value.source) || isPhysicalVehicleSource(value.feedback_source)) {
return true;
}
return Object.values(value).some((item) => containsAuthoritativePhysicalCh1State(item));
@@ -551,21 +644,37 @@
if (!summary) return false;
const requestLabel = String(payload.request_id || "-").slice(-8);
const hasCh0 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.passenger);
const hasCh1 = containsAuthoritativePhysicalCh1State(payload.actuator_state?.driver)
|| containsAuthoritativePhysicalCh1State(payload.domain_state);
const channelLabel = hasCh0 && hasCh1 ? "CH0 + CH1" : hasCh0 ? "CH0" : "CH1";
const items = [
`<span class="done"><i>CH1</i>真实车况 · TURN ${Number.isFinite(turn) ? turn : "-"} · ${escapeHtml(requestLabel)}</span>`,
`<span class="done"><i>${channelLabel}</i>真实车况 · TURN ${Number.isFinite(turn) ? turn : "-"} · ${escapeHtml(requestLabel)}</span>`,
];
const feedback = Array.isArray(payload.physical_feedback)
? payload.physical_feedback.filter((item) => (
item?.confirmed === true
&& item?.channel === 1
&& item?.source === "physical_can_ch1"
&& [0, 1].includes(Number(item?.channel))
&& isPhysicalVehicleSource(item?.source)
))
: [];
if (feedback.length) {
const frames = [...new Set(feedback.map((item) => item.frame?.frame_id).filter(Boolean))];
items.push(`<span><i>FB</i>${feedback.length}/${payload.physical_feedback.length} MATCH · ${escapeHtml(frames.join(" / ") || "CH1")}</span>`);
items.push(`<span><i>FB</i>${feedback.length}/${payload.physical_feedback.length} MATCH · ${escapeHtml(frames.join(" / ") || channelLabel)}</span>`);
}
const driver = payload.actuator_state?.driver || {};
const passenger = payload.actuator_state?.passenger || {};
if (isPhysicalVehicleSource(passenger.ventilation?.feedback_source)) {
items.push(`<span><i>P-VENT</i>${passenger.ventilation.state === "on" ? `ON L${escapeHtml(passenger.ventilation.level)}` : "OFF"}</span>`);
}
if (isPhysicalVehicleSource(passenger.heating?.feedback_source)) {
items.push(`<span><i>P-HEAT</i>${passenger.heating.state === "on" ? `ON L${escapeHtml(passenger.heating.level)}` : "OFF"}</span>`);
}
const passengerPosition = passenger.position || {};
Object.entries(passengerPosition).forEach(([axis, value]) => {
if (!isPhysicalVehicleSource(value?.feedback_source)) return;
items.push(`<span><i>P-SEAT</i>${escapeHtml(axis)} ${escapeHtml(value.position_pct ?? "-")}%</span>`);
});
const ventilation = driver.ventilation;
const heating = driver.heating;
if (ventilation?.feedback_source === "physical_can_ch1") {
@@ -602,11 +711,33 @@
const color = `${ambientLight.front_color_index ?? "-"}/${ambientLight.rear_color_index ?? "-"}`;
items.push(`<span><i>AMBIENT</i>${escapeHtml(ambientLight.power || "unknown")} · L${escapeHtml(brightness)} · C${escapeHtml(color)}</span>`);
}
summary.dataset.state = "physical_can_ch1";
summary.dataset.state = hasCh0 && hasCh1 ? "physical_can_dual" : hasCh0 ? "physical_can_ch0" : "physical_can_ch1";
summary.innerHTML = items.join("");
return true;
}
function syncSharedTwinMount() {
const host = $("#extensionTwinHost");
const shouldMount = document.body.dataset.module === "self-extension"
&& activeMode === "control";
if (!sharedTwinBand) {
sharedTwinBand = $("#seatTwin")?.closest(".twin-band") || null;
if (sharedTwinBand?.parentNode) {
sharedTwinHome = document.createComment("shared-vehicle-twin-home");
sharedTwinBand.parentNode.insertBefore(sharedTwinHome, sharedTwinBand);
}
}
if (!sharedTwinBand || !sharedTwinHome || !host) return;
if (shouldMount) {
if (sharedTwinBand.parentNode !== host) host.replaceChildren(sharedTwinBand);
sharedTwinBand.classList.add("v2-extension-shared-twin");
} else if (sharedTwinHome.parentNode && sharedTwinBand.parentNode !== sharedTwinHome.parentNode) {
sharedTwinHome.parentNode.insertBefore(sharedTwinBand, sharedTwinHome.nextSibling);
sharedTwinBand.classList.remove("v2-extension-shared-twin");
}
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
}
function setMode(mode) {
activeMode = mode === "control" ? "control" : "study";
sessionStorage.setItem(MODE_KEY, activeMode);
@@ -620,6 +751,7 @@
view.hidden = view.dataset.extensionView !== activeMode;
});
if (activeMode === "control") renderControl();
syncSharedTwinMount();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
@@ -687,8 +819,70 @@
return `<button class="v2-extension-learned-action" type="button" data-run-extension-capability="${escapeHtml(capability.id)}" data-extension-type="${escapeHtml(device?.type || "other")}"><span><i data-lucide="${type.icon}"></i></span><div><small>${escapeHtml(device?.name || type.label)}</small><strong>${escapeHtml(capability.name)}</strong></div><i data-lucide="send"></i></button>`;
}).join("");
}
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => `
<article class="v2-extension-vehicle-card"><header><span><i data-lucide="${device.icon}"></i></span><div><strong>${device.name}</strong><small>${device.code}</small></div></header><div>${device.actions.map((action) => `<button type="button" data-extension-vehicle-command="${escapeHtml(action.command)}" title="${escapeHtml(action.command)}">${escapeHtml(action.name)}</button>`).join("")}</div></article>`).join("");
$("#extensionVehicleGrid").innerHTML = VEHICLE_DEVICES.map((device) => {
const execution = state.vehicleExecutions?.[device.id];
const executionState = execution?.state || "idle";
return `
<article class="v2-extension-vehicle-card" data-extension-vehicle-device="${escapeHtml(device.id)}" data-execution-state="${escapeHtml(executionState)}">
<header>
<span><i data-lucide="${device.icon}"></i></span>
<div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.code)}</small></div>
<b>${escapeHtml(device.channel)}</b>
</header>
<div class="v2-extension-parameter-list" aria-label="${escapeHtml(device.name)}可控参数">
${device.parameters.map((parameter) => `<span>${escapeHtml(parameter)}</span>`).join("")}
</div>
<footer>
<small>当前动作</small>
<strong data-extension-current-action>${escapeHtml(execution?.action || "等待指令")}</strong>
<span data-extension-action-detail>${escapeHtml(execution?.detail || "输入自然语言后在此显示执行结果")}</span>
</footer>
</article>`;
}).join("");
}
function findVehicleAction(command) {
for (const device of VEHICLE_DEVICES) {
const action = device.actions.find((item) => item.command === command);
if (action) return { device, action };
}
const inferredDeviceId = /^副驾/.test(command)
? "passenger-seat"
: /^seat driver /.test(command)
? "driver-seat"
: /^ac /.test(command)
? "climate"
: /^shade /.test(command)
? "roof"
: /^light /.test(command)
? "interior-light"
: /^window /.test(command)
? "windows"
: null;
const device = VEHICLE_DEVICES.find((item) => item.id === inferredDeviceId);
if (device) return { device, action: { name: command, command } };
return null;
}
function updateVehicleCardExecution(command, executionState, detail) {
const match = findVehicleAction(command);
if (!match) return;
const actionText = match.action.name || command;
state.vehicleExecutions ||= {};
state.vehicleExecutions[match.device.id] = {
state: executionState,
action: actionText,
detail,
command,
at: new Date().toISOString(),
};
const card = $(`[data-extension-vehicle-device="${match.device.id}"]`);
if (!card) return;
card.dataset.executionState = executionState;
const actionNode = card.querySelector("[data-extension-current-action]");
const detailNode = card.querySelector("[data-extension-action-detail]");
if (actionNode) actionNode.textContent = actionText;
if (detailNode) detailNode.textContent = detail;
}
function renderTypeChooser() {
@@ -970,8 +1164,11 @@
function vehicleCatalog() {
return VEHICLE_DEVICES.map((device) => ({
id: device.id,
name: device.name,
code: device.code,
channel: device.channel,
parameters: device.parameters,
actions: device.actions,
}));
}
@@ -1155,17 +1352,20 @@
conversationId: result.conversation_id || null,
conversationTurn: result.conversation_turn ?? null,
};
saveState();
renderAuthoritativeVehicleState(result);
$$('[data-extension-vehicle-command]').forEach((button) => {
if (button.dataset.extensionVehicleCommand !== command) return;
button.dataset.executionState = completed ? "succeeded" : "failed";
button.setAttribute(
"aria-label",
`${button.textContent.trim()}:${completed ? "执行成功" : "执行未完成"}`,
);
button.title = `${command} · ${result.message || result.status || "执行结果未知"}`;
});
const operationCount = Array.isArray(result.operation_results)
? result.operation_results.length
: 0;
const physicalCount = Array.isArray(result.physical_feedback)
? result.physical_feedback.filter((item) => item?.confirmed === true).length
: 0;
const detail = completed
? result.execution_mode === "hardware"
? `物理回读已确认 · ${physicalCount || operationCount} 项`
: `安全演练已完成 · ${operationCount || 1} 项`
: vehicleExecutionError(result);
updateVehicleCardExecution(command, completed ? "succeeded" : "failed", detail);
saveState();
window.dispatchEvent(new CustomEvent("cognitive-agent:extension-vehicle-result", {
detail: result,
}));
@@ -1186,7 +1386,7 @@
result = await api("vehicle/execute", {
method: "POST",
// Channel, seat target, provider, vehicle profile and execution mode
// are intentionally absent: those are server-side CH1 safety policy.
// are intentionally absent: the server owns CH0/CH1 routing and safety policy.
body: JSON.stringify({ command, conversation_id: sharedConversationId() }),
});
} catch (error) {
@@ -1222,6 +1422,8 @@
if (step.kind === "delay") {
await delay(step.delayMs);
} else if (step.kind === "vehicle") {
updateVehicleCardExecution(step.command, "running", `正在执行:${step.label || step.command}`);
saveState();
const result = await executeVehicle(step.command);
vehicleDryRun ||= result.execution_mode === "dry_run";
if (result.execution_mode === "hardware") {
@@ -1473,12 +1675,6 @@
const run = event.target.closest("[data-run-extension-capability]");
if (run) runCapability(run.dataset.runExtensionCapability);
});
$("#extensionVehicleGrid").addEventListener("click", (event) => {
const action = event.target.closest("[data-extension-vehicle-command]");
if (!action) return;
$("#extensionUtterance").value = action.dataset.extensionVehicleCommand;
executeInstruction(action.dataset.extensionVehicleCommand);
});
$("#extensionExecuteButton").addEventListener(
"click",
() => executeInstruction($("#extensionUtterance").value),
@@ -1510,6 +1706,8 @@
if (event.detail?.module === "self-extension") {
setMode(activeMode);
refreshHealth();
} else {
syncSharedTwinMount();
}
});
window.addEventListener("cognitive-agent:result", (event) => {