完善模式卡片、NFC及多设备控制流程
This commit is contained in:
+221
-108
@@ -4949,9 +4949,6 @@ function modeEditorFlowFromResult(result) {
|
||||
const functionConfigs = result?.function_configs && typeof result.function_configs === "object"
|
||||
? result.function_configs
|
||||
: {};
|
||||
const executionSteps = Array.isArray(result?.execution_report?.spec?.results)
|
||||
? result.execution_report.spec.results
|
||||
: [];
|
||||
return {
|
||||
schema: "cognitive-agent-mode-command-flow/v1",
|
||||
source_request_id: result?.request_id || "",
|
||||
@@ -4959,7 +4956,7 @@ function modeEditorFlowFromResult(result) {
|
||||
control_transport: result?.control_transport || "",
|
||||
function_configs: functionConfigs,
|
||||
plans,
|
||||
execution_steps: executionSteps,
|
||||
execution_steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5283,7 +5280,7 @@ function renderModeEditorFlow() {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent = commandCount > 0
|
||||
? "已生成 CAN 控制流,但当前指令没有可读映射。"
|
||||
: "点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。";
|
||||
: "点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。";
|
||||
preview.append(empty);
|
||||
}
|
||||
}
|
||||
@@ -5299,9 +5296,9 @@ function setModeEditorFlowExpanded(expanded) {
|
||||
button.setAttribute("aria-expanded", String(nextExpanded));
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
nextExpanded ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域",
|
||||
nextExpanded ? "还原控制指令区域" : "展开控制指令区域",
|
||||
);
|
||||
button.title = nextExpanded ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域";
|
||||
button.title = nextExpanded ? "还原控制指令区域" : "展开控制指令区域";
|
||||
button.innerHTML = `<i data-lucide="${nextExpanded ? "minimize-2" : "maximize-2"}"></i>`;
|
||||
refreshIcons();
|
||||
}
|
||||
@@ -5328,8 +5325,8 @@ function openModeEditor(modeProfileId) {
|
||||
$("#modeEditorName").value = mode.name || "";
|
||||
$("#modeEditorInstruction").value = instruction;
|
||||
$("#modeEditorStatus").textContent = flow
|
||||
? "已载入永久保存的自然语言和 CAN 指令流,可修改后重新执行。"
|
||||
: "修改自然语言后需要重新执行,才能点击完成永久保存。";
|
||||
? "已载入永久保存的自然语言和控制指令流,可修改后重新生成。"
|
||||
: "修改自然语言后需要重新生成指令,才能点击完成永久保存。";
|
||||
setModeEditorFlowExpanded(false);
|
||||
backdrop.hidden = false;
|
||||
document.body.classList.add("mode-editor-open");
|
||||
@@ -5360,8 +5357,8 @@ function invalidateModeEditorFlow() {
|
||||
const instruction = $("#modeEditorInstruction").value.trim();
|
||||
const current = instruction === state.modeEditor.executedInstruction;
|
||||
$("#modeEditorStatus").textContent = current && modeEditorCommandCount(state.modeEditor.flow) > 0
|
||||
? "当前自然语言已有 CAN 指令流,可点击完成永久保存。"
|
||||
: "自然语言已修改,请重新点击执行生成 CAN 控制指令流。";
|
||||
? "当前自然语言已有控制指令流,可点击完成永久保存。"
|
||||
: "自然语言已修改,请重新点击“生成指令”。";
|
||||
renderModeEditorFlow();
|
||||
}
|
||||
|
||||
@@ -5376,38 +5373,37 @@ async function executeModeEditorInstruction() {
|
||||
const execute = $("#modeEditorExecute");
|
||||
execute.disabled = true;
|
||||
execute.classList.add("loading");
|
||||
execute.innerHTML = '<i data-lucide="loader-circle"></i><span>执行中…</span>';
|
||||
execute.innerHTML = '<i data-lucide="loader-circle"></i><span>生成中…</span>';
|
||||
$("#modeEditorComplete").disabled = true;
|
||||
$("#modeEditorStatus").textContent = "正在解析自然语言并生成、执行 CAN 控制指令流…";
|
||||
$("#modeEditorStatus").textContent = "正在复用主页解析流程生成控制指令;不会执行设备…";
|
||||
refreshIcons();
|
||||
try {
|
||||
const execution = await requestCommandAndWait(instruction);
|
||||
const result = execution.result;
|
||||
const { payload: result } = await requestNaturalLanguageControl(instruction, {
|
||||
executionMode: "dry_run",
|
||||
clientType: "mode_editor_compile",
|
||||
previewOnly: true,
|
||||
});
|
||||
const flow = modeEditorFlowFromResult(result);
|
||||
const commandCount = modeEditorCommandCount(flow);
|
||||
if (!result || commandCount === 0) {
|
||||
throw new Error(execution.error?.message || "未生成可保存的 CAN 控制指令流");
|
||||
throw new Error("未生成可保存的控制指令流");
|
||||
}
|
||||
editor.flow = flow;
|
||||
editor.sourceRequestId = result.request_id;
|
||||
editor.executedInstruction = instruction;
|
||||
$("#modeEditorStatus").textContent = `已生成 ${commandCount} 条 CAN 控制指令;执行状态:${result.status}。`;
|
||||
setModeExecutionState(
|
||||
editor.modeId,
|
||||
["succeeded", "partially_succeeded"].includes(result.status) ? "succeeded" : "failed",
|
||||
);
|
||||
$("#modeEditorStatus").textContent = `已生成 ${commandCount} 条控制指令;尚未执行设备。点击“完成”保存到模式卡片。`;
|
||||
renderModeEditorFlow();
|
||||
} catch (error) {
|
||||
editor.flow = null;
|
||||
editor.sourceRequestId = "";
|
||||
editor.executedInstruction = "";
|
||||
$("#modeEditorStatus").textContent = error.message || "CAN 控制指令流生成失败。";
|
||||
$("#modeEditorStatus").textContent = error.message || "控制指令流生成失败。";
|
||||
renderModeEditorFlow();
|
||||
showToast(error.message || "CAN 控制指令流生成失败");
|
||||
showToast(error.message || "控制指令流生成失败");
|
||||
} finally {
|
||||
execute.disabled = false;
|
||||
execute.classList.remove("loading");
|
||||
execute.innerHTML = '<i data-lucide="play"></i><span>执行</span>';
|
||||
execute.innerHTML = '<i data-lucide="wand-sparkles"></i><span>生成指令</span>';
|
||||
refreshIcons();
|
||||
}
|
||||
}
|
||||
@@ -5426,7 +5422,7 @@ async function completeModeEditor() {
|
||||
|| editor.executedInstruction !== instruction
|
||||
|| modeEditorCommandCount(editor.flow) === 0
|
||||
) {
|
||||
showToast("请先执行当前自然语言并生成 CAN 控制指令流");
|
||||
showToast("请先根据当前自然语言生成控制指令流");
|
||||
return;
|
||||
}
|
||||
const complete = $("#modeEditorComplete");
|
||||
@@ -5588,7 +5584,7 @@ async function deleteUserModeProfile(modeProfileId) {
|
||||
if (!mode || !isUserModeProfile(mode)) return;
|
||||
const presetOverride = mode.preset_override === true;
|
||||
const confirmation = presetOverride
|
||||
? `恢复“${mode.name}”的初始预设配置?当前保存的名称、自然语言和 CAN 指令流将被删除。`
|
||||
? `恢复“${mode.name}”的初始预设配置?当前保存的名称、自然语言和控制指令流将被删除。`
|
||||
: `删除自定义功能模式“${mode.name}”?删除后无法恢复。`;
|
||||
if (!window.confirm(confirmation)) return;
|
||||
|
||||
@@ -5655,12 +5651,83 @@ function setModeChipsDisabled(disabled) {
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSavedModeProfile(mode) {
|
||||
if (!mode?.id || !mode?.command_flow?.source_request_id) return false;
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
showToast(state.hardwareAccess?.state === "pending"
|
||||
? "申请仍在等待本地审批"
|
||||
: "请先申请并获得本地真实台架使用权");
|
||||
return true;
|
||||
}
|
||||
|
||||
closeModeGallery({ restoreFocus: false });
|
||||
const runButton = $("#runButton");
|
||||
runButton.disabled = true;
|
||||
setModeChipsDisabled(true);
|
||||
setModeExecutionState(mode.id, "running");
|
||||
$("#utterance").value = mode.name || mode.id;
|
||||
setRunStatus("processing", "MODE COMMAND FLOW");
|
||||
$("#resultTitle").textContent = `正在直接执行${mode.name}`;
|
||||
try {
|
||||
const payload = await postControlPhase(
|
||||
`/api/mode-profiles/${encodeURIComponent(mode.id)}/execute`,
|
||||
{
|
||||
client_type: "web_console_mode_card",
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
},
|
||||
state.executionMode,
|
||||
);
|
||||
if (!payload) throw new Error("模式卡片未返回执行结果");
|
||||
renderResult(payload);
|
||||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||||
$("#utterance").value = mode.name || mode.id;
|
||||
$("#resultTitle").textContent = completed
|
||||
? `${mode.name}执行完成`
|
||||
: `${mode.name}执行未完成`;
|
||||
setModeExecutionState(mode.id, completed ? "succeeded" : "failed");
|
||||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||||
showToast(completed
|
||||
? `${mode.name}已直接执行保存的控制指令流`
|
||||
: `${mode.name}未完成,请查看执行结果`);
|
||||
} catch (error) {
|
||||
setModeExecutionState(mode.id, "failed");
|
||||
setRunStatus("failed", "MODE FAILED");
|
||||
$("#resultTitle").textContent = `${mode.name}执行失败`;
|
||||
showToast(error.message || "模式执行失败");
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
setModeChipsDisabled(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyModeCommand(command, modeProfileId = null) {
|
||||
if (!command) return;
|
||||
if ($("#runButton").disabled) {
|
||||
showToast("上一条指令仍在执行,请稍候");
|
||||
return;
|
||||
}
|
||||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||||
if (mode?.command_flow?.source_request_id) {
|
||||
void executeSavedModeProfile(mode);
|
||||
return;
|
||||
}
|
||||
closeModeGallery({ restoreFocus: false });
|
||||
state.pendingModeProfileId = modeProfileId;
|
||||
if (modeProfileId) setModeExecutionState(modeProfileId, "running");
|
||||
@@ -7094,7 +7161,8 @@ function renderExecution(result) {
|
||||
.startsWith("nfc_peripheral");
|
||||
summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`;
|
||||
const presetSave = Boolean(result.preset_config);
|
||||
const modeSave = Boolean(result.mode_profile);
|
||||
const savedModeExecution = result.orchestration_route === "saved_mode_command_flow";
|
||||
const modeSave = Boolean(result.mode_profile) && !savedModeExecution;
|
||||
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||||
const physical = report.spec.mode === "hil" && !simulatedHil;
|
||||
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
|
||||
@@ -7379,7 +7447,9 @@ function renderResult(result) {
|
||||
? `${domains.length} 域场景部分完成`
|
||||
: executionSucceeded
|
||||
? result.mode_profile
|
||||
? "长期模式配置已保存"
|
||||
? savedModeExecution
|
||||
? `${result.mode_profile.name}执行完成`
|
||||
: "长期模式配置已保存"
|
||||
: result.preset_config
|
||||
? "座椅姿态预设已保存"
|
||||
: submittedUnverified
|
||||
@@ -7481,6 +7551,115 @@ async function postControlPhase(path, body, executionMode, onEvent = null) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function naturalLanguageRequestBody(
|
||||
utterance,
|
||||
{
|
||||
executionMode = state.executionMode,
|
||||
clientType = "web_console",
|
||||
} = {},
|
||||
) {
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
return {
|
||||
utterance,
|
||||
client_type: clientType,
|
||||
provider: state.provider,
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||
...(executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNaturalLanguageControl(
|
||||
utterance,
|
||||
{
|
||||
executionMode = state.executionMode,
|
||||
clientType = "web_console",
|
||||
previewOnly = false,
|
||||
onControlPhaseEvent = null,
|
||||
onPreview = null,
|
||||
onSupplementStart = null,
|
||||
} = {},
|
||||
) {
|
||||
const requestBody = naturalLanguageRequestBody(utterance, {
|
||||
executionMode,
|
||||
clientType,
|
||||
});
|
||||
const usePreview = previewOnly || state.provider === "openai";
|
||||
if (!usePreview) {
|
||||
const payload = await postControlPhase(
|
||||
"/api/requests",
|
||||
requestBody,
|
||||
executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
return { payload, supplementWarning: "" };
|
||||
}
|
||||
|
||||
const preview = await postControlPhase(
|
||||
"/api/requests/preview",
|
||||
requestBody,
|
||||
executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!preview) return { payload: null, supplementWarning: "" };
|
||||
if (typeof onPreview === "function") onPreview(preview);
|
||||
if (!preview.supplement_required) {
|
||||
return { payload: preview, supplementWarning: "" };
|
||||
}
|
||||
|
||||
if (typeof onSupplementStart === "function") onSupplementStart(preview);
|
||||
try {
|
||||
const payload = await postControlPhase(
|
||||
`/api/requests/${preview.request_id}/supplement`,
|
||||
{
|
||||
client_type: clientType,
|
||||
provider: state.provider,
|
||||
reasoning_effort: state.reasoningEffort,
|
||||
...(executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !isDriverOnlyCh1Runtime()
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
},
|
||||
executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
return { payload, supplementWarning: "" };
|
||||
} catch (error) {
|
||||
const localCommandCount = Object.values(preview.function_configs || {})
|
||||
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
|
||||
if (!localCommandCount) throw error;
|
||||
return {
|
||||
payload: {
|
||||
...preview,
|
||||
status: "partially_succeeded",
|
||||
supplement_required: false,
|
||||
cloud_supplement_error: {
|
||||
code: error.code || "cloud_supplement_failed",
|
||||
message: error.message,
|
||||
},
|
||||
decision_summary: [
|
||||
...(preview.decision_summary || []),
|
||||
`云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
|
||||
],
|
||||
},
|
||||
supplementWarning: previewOnly
|
||||
? "本地规则指令已生成;云端补充失败"
|
||||
: "本地规则命令已执行;云端补充失败,已保留本地结果",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) {
|
||||
const commands = Array.isArray(canCommands)
|
||||
? canCommands.map((command) => String(command || "").trim()).filter(Boolean)
|
||||
@@ -7568,26 +7747,6 @@ async function submitRequest(event) {
|
||||
setRunStatus("processing", "PROCESSING");
|
||||
$("#resultTitle").textContent = "正在执行车舱编排";
|
||||
try {
|
||||
const requestBody = {
|
||||
utterance,
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
conversation_id: state.conversationId,
|
||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||
execution_mode: state.executionMode,
|
||||
control_transport: state.controlTransport,
|
||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
...(state.executionMode === "simulated_hardware"
|
||||
? { simulation_fault: $("#simulationFault").value }
|
||||
: {}),
|
||||
};
|
||||
let payload;
|
||||
let supplementWarning = "";
|
||||
const onControlPhaseEvent = (phaseEvent) => {
|
||||
if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
|
||||
window.dispatchEvent(new CustomEvent(
|
||||
@@ -7595,67 +7754,21 @@ async function submitRequest(event) {
|
||||
{ detail: phaseEvent.payload },
|
||||
));
|
||||
};
|
||||
if (state.provider === "openai") {
|
||||
const preview = await postControlPhase(
|
||||
"/api/requests/preview",
|
||||
requestBody,
|
||||
state.executionMode,
|
||||
const { payload, supplementWarning } = await requestNaturalLanguageControl(
|
||||
utterance,
|
||||
{
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!preview) return;
|
||||
renderResult(preview);
|
||||
completedResult = preview;
|
||||
if (preview.supplement_required) {
|
||||
setRunStatus("processing", "CLOUD SUPPLEMENT");
|
||||
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
|
||||
try {
|
||||
payload = await postControlPhase(
|
||||
`/api/requests/${preview.request_id}/supplement`,
|
||||
{
|
||||
client_type: "web_console",
|
||||
provider: state.provider,
|
||||
reasoning_effort: state.reasoningEffort,
|
||||
...(state.executionMode === "hardware"
|
||||
&& state.controlTransport !== "wireless_mqtt"
|
||||
&& !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
},
|
||||
state.executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!payload) return;
|
||||
} catch (error) {
|
||||
const localCommandCount = Object.values(preview.function_configs || {})
|
||||
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
|
||||
if (!localCommandCount) throw error;
|
||||
payload = {
|
||||
...preview,
|
||||
status: "partially_succeeded",
|
||||
supplement_required: false,
|
||||
cloud_supplement_error: {
|
||||
code: error.code || "cloud_supplement_failed",
|
||||
message: error.message,
|
||||
},
|
||||
decision_summary: [
|
||||
...(preview.decision_summary || []),
|
||||
`云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
|
||||
],
|
||||
};
|
||||
supplementWarning = "本地规则命令已执行;云端补充失败,已保留本地结果";
|
||||
}
|
||||
} else {
|
||||
payload = preview;
|
||||
}
|
||||
} else {
|
||||
payload = await postControlPhase(
|
||||
"/api/requests",
|
||||
requestBody,
|
||||
state.executionMode,
|
||||
onControlPhaseEvent,
|
||||
);
|
||||
if (!payload) return;
|
||||
}
|
||||
onPreview: (preview) => {
|
||||
renderResult(preview);
|
||||
completedResult = preview;
|
||||
},
|
||||
onSupplementStart: () => {
|
||||
setRunStatus("processing", "CLOUD SUPPLEMENT");
|
||||
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!payload) return;
|
||||
renderResult(payload);
|
||||
completedResult = payload;
|
||||
const peripheralRoute = String(payload.orchestration_route || "")
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
<li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}">
|
||||
<span>
|
||||
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
|
||||
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
|
||||
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "saved_mode_command_flow" ? "模式卡片" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
|
||||
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml(`${item.deliveryTransport} · ${item.deliveryLabel}`)}">${escapeHtml(item.deliveryLabel)}</b>
|
||||
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
|
||||
</span>
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
<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-107" defer></script>
|
||||
<script src="/app.js?v=dual-seat-109" 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-49" defer></script>
|
||||
<script src="/console-v2.js?v=delivery-state-50" defer></script>
|
||||
<script src="/self-extension.js?v=gscu-tcp-40" defer></script>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
@@ -574,7 +574,7 @@
|
||||
<div>
|
||||
<span>MODE WORKFLOW</span>
|
||||
<h2 id="modeEditorTitle">编辑功能模式</h2>
|
||||
<p id="modeEditorDescription">执行后生成 CAN 控制指令流;完成后永久保存到当前模式卡片。</p>
|
||||
<p id="modeEditorDescription">复用主页自然语言解析流程生成控制指令;此步骤不会执行设备。</p>
|
||||
</div>
|
||||
<button id="modeEditorClose" type="button" aria-label="关闭模式编辑器"><i data-lucide="x"></i></button>
|
||||
</header>
|
||||
@@ -591,22 +591,22 @@
|
||||
</div>
|
||||
<section class="mode-editor-flow" aria-live="polite">
|
||||
<header>
|
||||
<span><i data-lucide="workflow"></i>CAN 控制指令流</span>
|
||||
<span><i data-lucide="workflow"></i>控制指令流</span>
|
||||
<div class="mode-editor-flow-tools">
|
||||
<b id="modeEditorFlowCount">尚未生成</b>
|
||||
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开 CAN 控制指令区域" title="展开 CAN 控制指令区域">
|
||||
<button id="modeEditorFlowExpand" type="button" aria-expanded="false" aria-label="展开控制指令区域" title="展开控制指令区域">
|
||||
<i data-lucide="maximize-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mode-editor-flow-preview empty" id="modeEditorFlowPreview">
|
||||
<p>点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。</p>
|
||||
<p>点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。</p>
|
||||
</div>
|
||||
</section>
|
||||
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新执行,才能点击完成永久保存。</p>
|
||||
<p class="mode-editor-status" id="modeEditorStatus">修改自然语言后需要重新生成指令,才能点击完成永久保存。</p>
|
||||
<footer class="mode-editor-actions">
|
||||
<button id="modeEditorCancel" type="button">取消</button>
|
||||
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="play"></i><span>执行</span></button>
|
||||
<button class="mode-editor-execute" id="modeEditorExecute" type="button"><i data-lucide="wand-sparkles"></i><span>生成指令</span></button>
|
||||
<button class="mode-editor-complete" id="modeEditorComplete" type="button" disabled><i data-lucide="save"></i><span>完成</span></button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v218";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v220";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||
const APP_SHELL = [
|
||||
@@ -6,11 +6,11 @@ const APP_SHELL = [
|
||||
"/index.html",
|
||||
"/styles.css?v=dual-seat-16",
|
||||
"/network.js?v=android-pad-2",
|
||||
"/app.js?v=dual-seat-107",
|
||||
"/app.js?v=dual-seat-109",
|
||||
"/gesture-control.js?v=android-pad-9",
|
||||
"/pwa.js?v=android-pad-3",
|
||||
"/console-v2.css?v=delivery-state-43",
|
||||
"/console-v2.js?v=delivery-state-49",
|
||||
"/console-v2.js?v=delivery-state-50",
|
||||
"/self-extension.css?v=gscu-tcp-39",
|
||||
"/self-extension.js?v=gscu-tcp-40",
|
||||
"/assets/pets/self-extension-cat.png?v=3",
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
"parameters": {"type": "object"},
|
||||
"parse_source": {
|
||||
"type": "string",
|
||||
"enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"]
|
||||
"enum": [
|
||||
"local_rule",
|
||||
"cloud_model",
|
||||
"cloud_supplement",
|
||||
"direct_control",
|
||||
"saved_mode_command_flow"
|
||||
]
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
|
||||
@@ -112,10 +112,16 @@ class ModeOperation:
|
||||
)
|
||||
|
||||
def to_request(self, mode_name: str) -> dict[str, Any]:
|
||||
target_scope = self.target_scope
|
||||
if self.domain == "seat" and target_scope == "front_right":
|
||||
# Compiled seat operations use the physical zone name, while the
|
||||
# model-plan adapter exposes the logical passenger target. Normalize
|
||||
# persisted cards so a validated target is not queried again.
|
||||
target_scope = "passenger"
|
||||
return {
|
||||
"domain": self.domain,
|
||||
"capability": self.capability,
|
||||
"target_scope": self.target_scope,
|
||||
"target_scope": target_scope,
|
||||
"operation": self.operation,
|
||||
"parameters": [
|
||||
{"name": name, "value": value}
|
||||
@@ -450,6 +456,26 @@ class ModeProfileRegistry:
|
||||
public.append(item)
|
||||
return public
|
||||
|
||||
def get_profile(
|
||||
self,
|
||||
mode_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> ModeProfile:
|
||||
"""Resolve the effective preset or user override by its stable ID."""
|
||||
|
||||
clean_mode_id = str(mode_id).strip()
|
||||
profile = next(
|
||||
(
|
||||
item
|
||||
for item in self._profiles_for_vehicle(vehicle_id)
|
||||
if item.id == clean_mode_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if profile is None:
|
||||
raise KeyError(clean_mode_id)
|
||||
return profile
|
||||
|
||||
def match_text(
|
||||
self,
|
||||
utterance: str,
|
||||
|
||||
@@ -608,6 +608,59 @@ class SeatDemoService:
|
||||
"vehicle_id": resolved_vehicle_id,
|
||||
}
|
||||
|
||||
def get_mode_profile_card(
|
||||
self,
|
||||
mode_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> ModeProfile:
|
||||
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
||||
return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
|
||||
|
||||
def execute_mode_profile_card(
|
||||
self,
|
||||
mode_id: str,
|
||||
*,
|
||||
conversation_id: str | None = None,
|
||||
execution_mode: str = "dry_run",
|
||||
approval_token: str | None = None,
|
||||
vehicle_id: str | None = None,
|
||||
simulation_fault: str = "normal",
|
||||
hardware_client_id: str | None = None,
|
||||
hardware_access_bypass: bool = True,
|
||||
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a persisted mode by ID without interpreting natural language."""
|
||||
|
||||
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
||||
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
|
||||
if not profile.command_flow or not profile.command_flow.get("plans"):
|
||||
raise ValueError("该模式尚未生成可直接执行的控制指令流。")
|
||||
result = self.process(
|
||||
profile.instruction or f"进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
execution_mode=execution_mode,
|
||||
approval_token=approval_token,
|
||||
vehicle_id=resolved_vehicle_id,
|
||||
simulation_fault=simulation_fault,
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=hardware_access_bypass,
|
||||
control_transport=control_transport,
|
||||
_direct_mode_profile_id=profile.id,
|
||||
)
|
||||
public_profile = profile.public_dict()
|
||||
result["provider_used"] = "saved_mode_command_flow"
|
||||
result["utterance"] = profile.name
|
||||
result["matched_modes"] = [public_profile]
|
||||
result["mode_profile"] = public_profile
|
||||
result["orchestration_route"] = "saved_mode_command_flow"
|
||||
result["decision_summary"] = [
|
||||
f"直接载入已保存的模式“{profile.name}”,未再次解析自然语言。",
|
||||
*list(result.get("decision_summary") or []),
|
||||
]
|
||||
self._persist(result)
|
||||
return result
|
||||
|
||||
def save_mode_profile_card(
|
||||
self,
|
||||
mode_id: str,
|
||||
@@ -616,7 +669,7 @@ class SeatDemoService:
|
||||
source_request_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist one edited mode from a server-generated CAN command flow."""
|
||||
"""Persist one edited mode from a server-generated control flow."""
|
||||
|
||||
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
||||
clean_mode_id = str(mode_id).strip()
|
||||
@@ -632,7 +685,7 @@ class SeatDemoService:
|
||||
if len(clean_instruction) > 1000:
|
||||
raise ValueError("自然语言指令不能超过 1000 个字符。")
|
||||
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id):
|
||||
raise ValueError("CAN 指令流来源请求编号无效。")
|
||||
raise ValueError("控制指令流来源请求编号无效。")
|
||||
|
||||
visible_profiles = self.mode_profiles._profiles_for_vehicle( # noqa: SLF001
|
||||
resolved_vehicle_id
|
||||
@@ -653,15 +706,15 @@ class SeatDemoService:
|
||||
|
||||
result_path = self.artifact_root / clean_request_id / "result.json"
|
||||
if not result_path.is_file():
|
||||
raise ValueError("未找到执行按钮生成的 CAN 指令流,请重新执行。")
|
||||
raise ValueError("未找到“生成指令”按钮产生的控制指令流,请重新生成。")
|
||||
try:
|
||||
source_result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("CAN 指令流来源结果无法读取,请重新执行。") from exc
|
||||
raise ValueError("控制指令流来源结果无法读取,请重新生成。") from exc
|
||||
if not isinstance(source_result, dict) or source_result.get(
|
||||
"request_id"
|
||||
) != clean_request_id:
|
||||
raise ValueError("CAN 指令流来源结果不完整,请重新执行。")
|
||||
raise ValueError("控制指令流来源结果不完整,请重新生成。")
|
||||
recorded_instruction = str(source_result.get("utterance", "")).strip()
|
||||
if not recorded_instruction:
|
||||
source_spans = {
|
||||
@@ -676,12 +729,12 @@ class SeatDemoService:
|
||||
if len(source_spans) == 1:
|
||||
recorded_instruction = next(iter(source_spans))
|
||||
if recorded_instruction and recorded_instruction != clean_instruction:
|
||||
raise ValueError("自然语言指令已修改,请重新点击执行生成 CAN 指令流。")
|
||||
raise ValueError("自然语言指令已修改,请重新点击“生成指令”。")
|
||||
|
||||
plans = source_result.get("command_plans") or {}
|
||||
configs = source_result.get("function_configs") or {}
|
||||
if not isinstance(plans, dict) or not isinstance(configs, dict):
|
||||
raise ValueError("执行结果中没有可永久化的 CAN 指令流。")
|
||||
raise ValueError("解析结果中没有可永久化的控制指令流。")
|
||||
executable_plan_keys = {
|
||||
key
|
||||
for key, plan in plans.items()
|
||||
@@ -691,7 +744,7 @@ class SeatDemoService:
|
||||
and plan["spec"]["commands"]
|
||||
}
|
||||
if not executable_plan_keys:
|
||||
raise ValueError("请先点击执行并成功生成 CAN 控制指令流。")
|
||||
raise ValueError("请先点击“生成指令”并成功生成控制指令流。")
|
||||
|
||||
operations: list[dict[str, Any]] = []
|
||||
signatures: set[tuple[str, str, str, str]] = set()
|
||||
@@ -707,7 +760,12 @@ class SeatDemoService:
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
capability = str(operation.get("capability", "")).strip()
|
||||
target = str((operation.get("target") or {}).get("zone", "")).strip()
|
||||
operation_target = operation.get("target") or {}
|
||||
target = str(
|
||||
operation_target.get("instance")
|
||||
or operation_target.get("zone")
|
||||
or ""
|
||||
).strip()
|
||||
parameters = operation.get("parameters") or {}
|
||||
if not domain or not capability or not target or not isinstance(
|
||||
parameters, dict
|
||||
@@ -733,37 +791,8 @@ class SeatDemoService:
|
||||
}
|
||||
)
|
||||
if not operations:
|
||||
raise ValueError("CAN 指令流没有关联到可复用的模式操作。")
|
||||
raise ValueError("控制指令流没有关联到可复用的模式操作。")
|
||||
|
||||
execution_steps = []
|
||||
for item in (
|
||||
(source_result.get("execution_report") or {}).get("spec", {}).get(
|
||||
"results", []
|
||||
)
|
||||
):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
telemetry = item.get("telemetry") or {}
|
||||
command_evidence = {
|
||||
key: deepcopy(telemetry[key])
|
||||
for key in (
|
||||
"command_frame",
|
||||
"sent_frames",
|
||||
"mqtt_messages",
|
||||
"response_frame",
|
||||
"requested_parameters",
|
||||
)
|
||||
if key in telemetry
|
||||
}
|
||||
if command_evidence:
|
||||
execution_steps.append(
|
||||
{
|
||||
"ref": item.get("ref"),
|
||||
"domain": item.get("domain"),
|
||||
"status": item.get("status"),
|
||||
**command_evidence,
|
||||
}
|
||||
)
|
||||
command_flow = {
|
||||
"schema": "cognitive-agent-mode-command-flow/v1",
|
||||
"source_request_id": clean_request_id,
|
||||
@@ -776,7 +805,10 @@ class SeatDemoService:
|
||||
if key in executable_plan_keys and key in configs
|
||||
},
|
||||
"plans": {key: deepcopy(plans[key]) for key in plans if key in executable_plan_keys},
|
||||
"execution_steps": execution_steps,
|
||||
# A mode card stores only the validated, deterministic plan. Live
|
||||
# delivery evidence belongs to each later execution, not to the
|
||||
# generation workflow.
|
||||
"execution_steps": [],
|
||||
}
|
||||
saved = self.mode_profiles.save_mode(
|
||||
clean_name,
|
||||
@@ -1677,6 +1709,7 @@ class SeatDemoService:
|
||||
_allow_empty_control_result: bool = False,
|
||||
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
|
||||
_direct_mode_profile_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
control_transport = self._validate_control_transport(control_transport)
|
||||
# Creating a reusable scene is a configuration workflow. Synthesize
|
||||
@@ -1702,6 +1735,7 @@ class SeatDemoService:
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
_direct_mode_profile_id,
|
||||
)
|
||||
if control_transport == WIRELESS_MQTT_TRANSPORT:
|
||||
# The head unit owns both physical CAN channels in wireless mode.
|
||||
@@ -1723,6 +1757,7 @@ class SeatDemoService:
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
_direct_mode_profile_id,
|
||||
)
|
||||
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
||||
control_transport
|
||||
@@ -1744,6 +1779,7 @@ class SeatDemoService:
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
_direct_mode_profile_id,
|
||||
)
|
||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||
with self.hardware_execution.reserve():
|
||||
@@ -1764,6 +1800,7 @@ class SeatDemoService:
|
||||
_allow_empty_control_result,
|
||||
_on_commands_validated,
|
||||
_direct_can_batch,
|
||||
_direct_mode_profile_id,
|
||||
)
|
||||
|
||||
def _process_once(
|
||||
@@ -1783,6 +1820,7 @@ class SeatDemoService:
|
||||
allow_empty_control_result: bool = False,
|
||||
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
|
||||
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
|
||||
direct_mode_profile_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
control_transport = self._validate_control_transport(control_transport)
|
||||
utterance = utterance.strip()
|
||||
@@ -1831,6 +1869,14 @@ class SeatDemoService:
|
||||
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
|
||||
raise ValueError("conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens")
|
||||
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
|
||||
direct_mode_profile = (
|
||||
self.mode_profiles.get_profile(
|
||||
direct_mode_profile_id,
|
||||
vehicle_profile.id,
|
||||
)
|
||||
if direct_mode_profile_id
|
||||
else None
|
||||
)
|
||||
|
||||
if mode_create_name is not None:
|
||||
return self._process_mode_profile_create(
|
||||
@@ -1852,7 +1898,9 @@ class SeatDemoService:
|
||||
)
|
||||
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
|
||||
matched_modes = (
|
||||
[]
|
||||
[direct_mode_profile]
|
||||
if direct_mode_profile is not None
|
||||
else []
|
||||
if direct_can_batch is not None
|
||||
else self.mode_profiles.match_text(
|
||||
utterance,
|
||||
@@ -1941,7 +1989,56 @@ class SeatDemoService:
|
||||
result,
|
||||
traces,
|
||||
)
|
||||
if direct_can_batch is not None:
|
||||
if direct_mode_profile is not None:
|
||||
mode_requests = self.mode_profiles.requests_for_profiles(
|
||||
[direct_mode_profile]
|
||||
)
|
||||
detected_domains = list(
|
||||
dict.fromkeys(
|
||||
str(item.get("domain") or "")
|
||||
for item in mode_requests
|
||||
if str(item.get("domain") or "")
|
||||
)
|
||||
)
|
||||
parsed = self.model_plan_adapter.adapt(
|
||||
{
|
||||
"detected_domains": detected_domains or ["seat"],
|
||||
"requests": mode_requests,
|
||||
"ambiguities": [],
|
||||
"decision_summary": [
|
||||
f"已载入模式“{direct_mode_profile.name}”保存的结构化操作。"
|
||||
],
|
||||
"confidence": 1.0,
|
||||
},
|
||||
context,
|
||||
vehicle_profile,
|
||||
)
|
||||
saved_native_actions: list[dict[str, Any]] = []
|
||||
for request in mode_requests:
|
||||
parameters = {
|
||||
str(item.get("name")): deepcopy(item.get("value"))
|
||||
for item in request.get("parameters", [])
|
||||
if isinstance(item, dict) and str(item.get("name") or "")
|
||||
}
|
||||
action = {
|
||||
"domain": str(request.get("domain") or ""),
|
||||
"capability": str(request.get("capability") or ""),
|
||||
"target": str(request.get("target_scope") or "vehicle"),
|
||||
"parameters": parameters,
|
||||
"operation": str(request.get("operation") or "set"),
|
||||
"reason": str(request.get("reason") or ""),
|
||||
"execution_backend": "native_can",
|
||||
"parse_source": "saved_mode_command_flow",
|
||||
}
|
||||
if self.native_can_runtime.supports(action):
|
||||
saved_native_actions.append(action)
|
||||
parsed["native_can_actions"] = [
|
||||
*list(parsed.get("native_can_actions", [])),
|
||||
*saved_native_actions,
|
||||
]
|
||||
provider_used = "saved_mode_command_flow"
|
||||
provider_notice = None
|
||||
elif direct_can_batch is not None:
|
||||
parsed = deepcopy(direct_can_batch[1])
|
||||
provider_used = "direct_can_control"
|
||||
provider_notice = None
|
||||
@@ -1980,6 +2077,11 @@ class SeatDemoService:
|
||||
relative_resolution.claimed_controls if relative_resolution else None
|
||||
),
|
||||
)
|
||||
if direct_mode_profile is not None:
|
||||
parsed = self._mark_control_parse_source(
|
||||
parsed,
|
||||
"saved_mode_command_flow",
|
||||
)
|
||||
if local_preview:
|
||||
parsed["ambiguities"] = []
|
||||
parsed["unresolved_spans"] = []
|
||||
@@ -1993,11 +2095,14 @@ class SeatDemoService:
|
||||
parsed = self._partition_native_actions(
|
||||
parsed,
|
||||
promote_supported=(
|
||||
hardware_request
|
||||
and (
|
||||
self.vehicle_mqtt_settings.enabled
|
||||
if control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else self.vehicle_can_settings.enabled
|
||||
direct_mode_profile is not None
|
||||
or (
|
||||
hardware_request
|
||||
and (
|
||||
self.vehicle_mqtt_settings.enabled
|
||||
if control_transport == WIRELESS_MQTT_TRANSPORT
|
||||
else self.vehicle_can_settings.enabled
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ MAX_PLAN_STEPS = 12
|
||||
MIN_PLAN_DELAY_MS = 1
|
||||
MAX_PLAN_DELAY_MS = 30_000
|
||||
MAX_TOTAL_PLAN_DELAY_MS = 60_000
|
||||
ADJACENT_INFRARED_COMMAND_DELAY_SECONDS = 0.8
|
||||
DEFAULT_GSCU_MQTT_HOST = "127.0.0.1"
|
||||
DEFAULT_GSCU_MQTT_PORT = 1883
|
||||
DEFAULT_GSCU_COMMAND_TOPIC = "topic/from_n7"
|
||||
@@ -2542,7 +2543,7 @@ class SelfExtensionService:
|
||||
and isinstance(next_step, dict)
|
||||
and next_step.get("kind") == "peripheral"
|
||||
):
|
||||
time.sleep(0.25)
|
||||
time.sleep(ADJACENT_INFRARED_COMMAND_DELAY_SECONDS)
|
||||
|
||||
succeeded = not interrupted and completed_steps == len(steps)
|
||||
status = "succeeded" if succeeded else (
|
||||
|
||||
@@ -795,15 +795,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
conversation_id: str | None,
|
||||
execution_mode: str,
|
||||
) -> dict[str, object]:
|
||||
"""Execute already validated local-rule infrared capabilities."""
|
||||
"""Execute already validated infrared capabilities."""
|
||||
|
||||
capabilities = [
|
||||
{
|
||||
**dict(entry["capability"]),
|
||||
"parse_source": "local_rule",
|
||||
**capability,
|
||||
"parse_source": str(capability.get("parse_source") or "local_rule"),
|
||||
}
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and isinstance(entry.get("capability"), dict)
|
||||
for capability in [dict(entry["capability"])]
|
||||
]
|
||||
if not capabilities:
|
||||
raise ExtensionIntegrationError("没有可执行的已学习红外能力。")
|
||||
@@ -1454,6 +1455,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
|
||||
def _handle_mode_profile_post(self, path: str) -> None:
|
||||
execute_match = re.fullmatch(
|
||||
r"/api/mode-profiles/(?P<mode_id>[a-z0-9][a-z0-9_-]{0,63})/execute",
|
||||
path,
|
||||
)
|
||||
if execute_match is not None:
|
||||
self._handle_mode_profile_execute(execute_match.group("mode_id"))
|
||||
return
|
||||
|
||||
mode_id = path.removeprefix("/api/mode-profiles/").strip()
|
||||
if not mode_id:
|
||||
self._json(
|
||||
@@ -1482,6 +1491,214 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _saved_mode_infrared_entries(
|
||||
self,
|
||||
profile: object,
|
||||
) -> list[dict[str, object]]:
|
||||
command_flow = getattr(profile, "command_flow", None)
|
||||
if not isinstance(command_flow, dict):
|
||||
return []
|
||||
configurations = command_flow.get("function_configs") or {}
|
||||
extension_config = None
|
||||
if isinstance(configurations, dict):
|
||||
extension_config = configurations.get("self_extension")
|
||||
if not isinstance(extension_config, dict):
|
||||
extension_config = next(
|
||||
(
|
||||
config
|
||||
for config in configurations.values()
|
||||
if isinstance(config, dict)
|
||||
and str((config.get("spec") or {}).get("domain"))
|
||||
== "self_extension"
|
||||
),
|
||||
None,
|
||||
)
|
||||
operations = (
|
||||
(extension_config.get("spec") or {}).get("operations") or []
|
||||
if isinstance(extension_config, dict)
|
||||
else []
|
||||
)
|
||||
if not operations:
|
||||
return []
|
||||
|
||||
snapshot = self.nfc.snapshot()
|
||||
card = snapshot.get("card") if isinstance(snapshot, dict) else None
|
||||
active_uid = re.sub(
|
||||
r"[^0-9A-Fa-f]",
|
||||
"",
|
||||
str((card or {}).get("uid") or ""),
|
||||
).upper()
|
||||
if not active_uid or str((card or {}).get("action") or "present") != "present":
|
||||
raise ExtensionIntegrationError(
|
||||
"该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。"
|
||||
)
|
||||
|
||||
catalog = self.extension.list_peripheral_capabilities()
|
||||
entries: list[dict[str, object]] = []
|
||||
for index, operation in enumerate(operations):
|
||||
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
|
||||
continue
|
||||
parameters = operation.get("parameters") or {}
|
||||
target = operation.get("target") or {}
|
||||
try:
|
||||
slot = int(parameters.get("slot"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ExtensionIntegrationError(
|
||||
"已保存模式包含无效的红外槽位。"
|
||||
) from exc
|
||||
device_id = str(target.get("device") or "").strip()
|
||||
capability = next(
|
||||
(
|
||||
item
|
||||
for item in catalog
|
||||
if item.get("status") == "learned"
|
||||
and item.get("enabled", True) is True
|
||||
and int(item.get("slot") or 0) == slot
|
||||
and str(item.get("device_id") or "") == device_id
|
||||
and re.sub(
|
||||
r"[^0-9A-Fa-f]",
|
||||
"",
|
||||
str(item.get("card_uid") or ""),
|
||||
).upper()
|
||||
== active_uid
|
||||
),
|
||||
None,
|
||||
)
|
||||
if capability is None:
|
||||
raise ExtensionIntegrationError(
|
||||
f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。"
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"index": index,
|
||||
"clause": str(parameters.get("action") or f"IR {slot}"),
|
||||
"capability": {
|
||||
**capability,
|
||||
"parse_source": "saved_mode_command_flow",
|
||||
},
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
def _handle_mode_profile_execute(self, mode_id: str) -> None:
|
||||
utterance = ""
|
||||
source = "web_console_mode_card"
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=16 * 1024)
|
||||
vehicle_id = str(payload.get("vehicle_id", "")) or None
|
||||
profile = self.service.get_mode_profile_card(mode_id, vehicle_id)
|
||||
utterance = profile.name
|
||||
source = str(payload.get("client_type") or source)
|
||||
execution_mode = str(payload.get("execution_mode", "dry_run"))
|
||||
control_transport = str(
|
||||
payload.get("control_transport", "wired_can")
|
||||
)
|
||||
conversation_id = (
|
||||
str(payload["conversation_id"])
|
||||
if payload.get("conversation_id")
|
||||
else None
|
||||
)
|
||||
hardware_client_id = (
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
)
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="running",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
)
|
||||
# Validate the complete saved flow, including the live NFC
|
||||
# binding, before any transport is allowed to execute.
|
||||
entries = self._saved_mode_infrared_entries(profile)
|
||||
can_result = self.service.execute_mode_profile_card(
|
||||
mode_id,
|
||||
conversation_id=conversation_id,
|
||||
execution_mode=execution_mode,
|
||||
approval_token=os.getenv(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
vehicle_id=vehicle_id,
|
||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
control_transport=control_transport,
|
||||
)
|
||||
if entries:
|
||||
infrared_result = self._execute_local_peripheral_actions(
|
||||
utterance,
|
||||
entries,
|
||||
provider="saved_mode_command_flow",
|
||||
conversation_id=conversation_id,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
result = self._merge_can_and_peripheral_results(
|
||||
utterance,
|
||||
can_result,
|
||||
infrared_result,
|
||||
entries,
|
||||
)
|
||||
result["provider_used"] = "saved_mode_command_flow"
|
||||
result["orchestration_route"] = "saved_mode_command_flow"
|
||||
result["mode_profile"] = profile.public_dict()
|
||||
self.service.persist_composed_result(result)
|
||||
else:
|
||||
result = can_result
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status") or "succeeded"),
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
result=result,
|
||||
)
|
||||
self._json(result, HTTPStatus.OK)
|
||||
except KeyError:
|
||||
self._json(
|
||||
{"error": "not_found", "message": "未找到需要执行的模式卡片。"},
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_busy",
|
||||
"message": str(exc),
|
||||
"retry_after_seconds": exc.retry_after_seconds,
|
||||
},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
except (HardwareSafetyError, CanGatewayError, GatewayError) as exc:
|
||||
if utterance:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="failed",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
error={"code": "mode_execution_failed", "message": str(exc)},
|
||||
)
|
||||
self._json(
|
||||
{"error": "mode_execution_failed", "message": str(exc)},
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except (ExtensionIntegrationError, ValueError, json.JSONDecodeError) as exc:
|
||||
if utterance:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="failed",
|
||||
utterance=utterance,
|
||||
source=source,
|
||||
error={"code": "invalid_mode_command_flow", "message": str(exc)},
|
||||
)
|
||||
self._json(
|
||||
{"error": "invalid_mode_command_flow", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _handle_extension_post(self, path: str) -> None:
|
||||
action_map = {
|
||||
"/api/extension/gscu/info": "info",
|
||||
|
||||
@@ -289,6 +289,247 @@ operations:
|
||||
self.service.mode_profiles = original_registry
|
||||
self.service.artifact_root = original_artifact_root
|
||||
|
||||
def test_office_mode_generation_and_card_execution_reuse_saved_flow(self) -> None:
|
||||
original_registry = self.service.mode_profiles
|
||||
original_artifact_root = self.service.artifact_root
|
||||
original_extension = SeatDemoHandler.extension
|
||||
original_nfc = SeatDemoHandler.nfc
|
||||
instruction = (
|
||||
"空调温度设置为25度,风速设置为1档,"
|
||||
"启动闪烁灯,颜色设置为红色"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
preset_root = base / "presets"
|
||||
user_root = base / "users"
|
||||
preset = preset_root / "vehicles" / "denza_n7" / "office.yaml"
|
||||
preset.parent.mkdir(parents=True)
|
||||
preset.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
icon: briefcase
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: driver
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
extension = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=base / "office-mode-ir.sqlite3",
|
||||
)
|
||||
extension.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "ir-slot:tony-light-001:1",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "tony-light-001",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"action_names": ["打开", "开启", "启动"],
|
||||
"risk": "low",
|
||||
"slot": 1,
|
||||
},
|
||||
{
|
||||
"id": "ir-slot:tony-light-001:3",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "tony-light-001",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "切换到红色",
|
||||
"action_names": [
|
||||
"切换到红色",
|
||||
"切换为红色",
|
||||
"设置为红色",
|
||||
],
|
||||
"risk": "low",
|
||||
"slot": 3,
|
||||
},
|
||||
]
|
||||
)
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
preset_root,
|
||||
user_root=user_root,
|
||||
)
|
||||
self.service.artifact_root = base / "runs"
|
||||
SeatDemoHandler.extension = extension
|
||||
SeatDemoHandler.nfc = SimpleNamespace(
|
||||
snapshot=lambda: {
|
||||
"revision": 1,
|
||||
"reader": {"online": True},
|
||||
"card": {"action": "present", "uid": "2CC8B103"},
|
||||
}
|
||||
)
|
||||
try:
|
||||
generate_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests/preview",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": instruction,
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-office-mode-generate",
|
||||
"vehicle_id": "denza_n7",
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "wired_can",
|
||||
"client_type": "mode_editor_compile",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(generate_request, timeout=3) as response:
|
||||
generated = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
save_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/mode-profiles/office",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"name": "办公模式",
|
||||
"instruction": instruction,
|
||||
"source_request_id": generated["request_id"],
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(save_request, timeout=3) as response:
|
||||
saved = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
execute_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/mode-profiles/office/execute",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"conversation_id": "conv-office-mode-execute",
|
||||
"vehicle_id": "denza_n7",
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "wired_can",
|
||||
"client_type": "web_console_mode_card",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with patch.object(
|
||||
self.service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError(
|
||||
"saved mode card must not reinterpret natural language"
|
||||
),
|
||||
), self.opener.open(execute_request, timeout=3) as response:
|
||||
executed = json.loads(response.read().decode("utf-8"))
|
||||
finally:
|
||||
self.service.mode_profiles = original_registry
|
||||
self.service.artifact_root = original_artifact_root
|
||||
SeatDemoHandler.extension = original_extension
|
||||
SeatDemoHandler.nfc = original_nfc
|
||||
|
||||
generated_commands = [
|
||||
(
|
||||
command.get("signals", {}).get("debug_command")
|
||||
or (
|
||||
f"infrared emit {command.get('signals', {}).get('slot')}"
|
||||
if command.get("message") == "infrared.emit"
|
||||
else None
|
||||
)
|
||||
)
|
||||
for plan in generated.get("command_plans", {}).values()
|
||||
for command in plan.get("spec", {}).get("commands", [])
|
||||
]
|
||||
self.assertEqual(
|
||||
generated_commands,
|
||||
[
|
||||
"ac temp driver 25",
|
||||
"ac fan 1",
|
||||
"infrared emit 1",
|
||||
"infrared emit 3",
|
||||
],
|
||||
)
|
||||
self.assertEqual(saved["mode_profile"]["command_flow"]["execution_steps"], [])
|
||||
self.assertEqual(executed["status"], "succeeded")
|
||||
self.assertEqual(executed["utterance"], "办公模式")
|
||||
self.assertEqual(executed["provider_used"], "saved_mode_command_flow")
|
||||
self.assertEqual(executed["orchestration_route"], "saved_mode_command_flow")
|
||||
self.assertEqual(
|
||||
[item["slot"] for item in executed["infrared_actions"]],
|
||||
[1, 3],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["parse_source"] == "saved_mode_command_flow"
|
||||
for item in executed["infrared_actions"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_saved_mode_accepts_prefixed_self_extension_flow_key(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
extension = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "prefixed-mode-ir.sqlite3"),
|
||||
)
|
||||
extension.sync_peripheral_catalog(
|
||||
[
|
||||
{
|
||||
"id": "ir-slot:tony-light-001:1",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "tony-light-001",
|
||||
"device_name": "闪灯玩具",
|
||||
"card_uid": "2CC8B103",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.extension = extension
|
||||
handler.nfc = SimpleNamespace(
|
||||
snapshot=lambda: {
|
||||
"card": {"action": "present", "uid": "2CC8B103"}
|
||||
}
|
||||
)
|
||||
profile = SimpleNamespace(
|
||||
command_flow={
|
||||
"function_configs": {
|
||||
"local_self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"capability": "infrared.emit",
|
||||
"target": {"device": "tony-light-001"},
|
||||
"parameters": {"action": "打开", "slot": 1},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entries = handler._saved_mode_infrared_entries(profile)
|
||||
|
||||
self.assertEqual([item["capability"]["slot"] for item in entries], [1])
|
||||
self.assertEqual(
|
||||
entries[0]["capability"]["parse_source"],
|
||||
"saved_mode_command_flow",
|
||||
)
|
||||
|
||||
def test_head_unit_display_sync_tracks_tasks_and_usb_catalog(self) -> None:
|
||||
task_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
@@ -344,14 +585,14 @@ operations:
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-107", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-109", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=delivery-state-49", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=delivery-state-50", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -394,11 +635,11 @@ operations:
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-107"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-109"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=delivery-state-49"', html)
|
||||
self.assertIn('src="/console-v2.js?v=delivery-state-50"', html)
|
||||
self.assertIn('href="/console-v2.css?v=delivery-state-43"', html)
|
||||
self.assertIn('<body class="console-v2">', html)
|
||||
self.assertIn('id="v2BootScreen"', html)
|
||||
@@ -641,7 +882,8 @@ operations:
|
||||
)
|
||||
self.assertIn("const stageHasLayout = () =>", app_script)
|
||||
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
|
||||
self.assertIn('client_type: "web_console"', app_script)
|
||||
self.assertIn('clientType = "web_console"', app_script)
|
||||
self.assertIn("client_type: clientType", app_script)
|
||||
self.assertIn("function executionVerificationStatus(result = {})", app_script)
|
||||
self.assertIn('"submitted_unverified"', app_script)
|
||||
self.assertIn("指令已提交,等待状态确认", app_script)
|
||||
@@ -649,8 +891,8 @@ operations:
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v218"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v220"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-109"', service_worker)
|
||||
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
||||
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
||||
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
||||
@@ -890,7 +1132,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-107", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-109", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
|
||||
@@ -621,7 +621,7 @@ operations:
|
||||
if item["id"] == "office"
|
||||
)
|
||||
self.assertEqual(persisted["name"], "专注办公模式")
|
||||
with self.assertRaisesRegex(ValueError, "重新点击执行"):
|
||||
with self.assertRaisesRegex(ValueError, "生成指令"):
|
||||
self.service.save_mode_profile_card(
|
||||
"office",
|
||||
"专注办公模式",
|
||||
@@ -630,6 +630,181 @@ operations:
|
||||
"denza_n7",
|
||||
)
|
||||
|
||||
def test_saved_mode_card_executes_without_reinterpreting_instruction(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "direct-mode-presets"
|
||||
user_root = Path(self.temporary_directory.name) / "direct-mode-user"
|
||||
preset = mode_root / "vehicles" / "denza_n7" / "office.yaml"
|
||||
preset.parent.mkdir(parents=True)
|
||||
preset.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
icon: briefcase
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: driver
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
mode_root, user_root=user_root
|
||||
)
|
||||
instruction = "空调温度设置为25度,风速设置为1档"
|
||||
generated = self.service.preview_native_can(
|
||||
instruction,
|
||||
provider="local",
|
||||
conversation_id="conv-generate-office-card",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
saved = self.service.save_mode_profile_card(
|
||||
"office",
|
||||
"办公模式",
|
||||
instruction,
|
||||
generated["request_id"],
|
||||
"denza_n7",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
self.service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError("saved mode must not reinterpret natural language"),
|
||||
):
|
||||
executed = self.service.execute_mode_profile_card(
|
||||
"office",
|
||||
conversation_id="conv-execute-office-card",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
operations = [
|
||||
operation
|
||||
for config in executed.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
self.assertEqual(executed["status"], "succeeded")
|
||||
self.assertEqual(executed["utterance"], "办公模式")
|
||||
self.assertEqual(executed["provider_used"], "saved_mode_command_flow")
|
||||
self.assertEqual(executed["orchestration_route"], "saved_mode_command_flow")
|
||||
self.assertEqual(
|
||||
[
|
||||
(
|
||||
operation["capability"],
|
||||
operation["target"]["zone"],
|
||||
operation["parameters"],
|
||||
operation["parse_source"],
|
||||
)
|
||||
for operation in operations
|
||||
],
|
||||
[
|
||||
(
|
||||
"climate.set_temperature",
|
||||
"driver",
|
||||
{"temperature_c": 25},
|
||||
"saved_mode_command_flow",
|
||||
),
|
||||
(
|
||||
"climate.set_fan_level",
|
||||
"vehicle",
|
||||
{"level": 1},
|
||||
"saved_mode_command_flow",
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
saved["mode_profile"]["command_flow"]["execution_steps"], []
|
||||
)
|
||||
|
||||
def test_saved_mode_card_replays_front_right_dynamic_target_as_passenger(
|
||||
self,
|
||||
) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "dynamic-mode-presets"
|
||||
user_root = Path(self.temporary_directory.name) / "dynamic-mode-user"
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
mode_root,
|
||||
user_root=user_root,
|
||||
)
|
||||
self.service.mode_profiles.save_mode(
|
||||
"小憩模式",
|
||||
[
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"target_scope": "front_right",
|
||||
"parameters": {"mode_id": 2},
|
||||
},
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_motion",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"motion": "open"},
|
||||
},
|
||||
],
|
||||
mode_id="nap",
|
||||
vehicle_id="denza_n7",
|
||||
instruction="打开摇篮模式,开启空调",
|
||||
command_flow={
|
||||
"schema": "cognitive-agent-mode-command-flow/v1",
|
||||
"source_request_id": "req-saved-dynamic-mode",
|
||||
"plans": {
|
||||
"local_seat": {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"message": "Seat_Dynamic_Mode_Virtual",
|
||||
"signals": {"Dynamic_Mode_ID": 2},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
self.service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError("saved mode must not reinterpret natural language"),
|
||||
):
|
||||
executed = self.service.execute_mode_profile_card(
|
||||
"nap",
|
||||
conversation_id="conv-execute-saved-dynamic-card",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
self.assertEqual(executed["status"], "succeeded")
|
||||
self.assertEqual(executed["utterance"], "小憩模式")
|
||||
self.assertEqual(executed["provider_used"], "saved_mode_command_flow")
|
||||
self.assertFalse(
|
||||
[item for item in executed.get("validation", []) if item["severity"] == "error"]
|
||||
)
|
||||
dynamic_request = next(
|
||||
item
|
||||
for item in executed["model_requests"]
|
||||
if item["capability"] == "seat.start_dynamic_mode"
|
||||
)
|
||||
self.assertEqual(dynamic_request["target_scope"], "passenger")
|
||||
self.assertTrue(executed["command_plans"])
|
||||
replayed_capabilities = {
|
||||
operation["capability"]
|
||||
for config in executed["function_configs"].values()
|
||||
for operation in config["spec"]["operations"]
|
||||
}
|
||||
self.assertIn("roof.set_shade_motion", replayed_capabilities)
|
||||
|
||||
def test_build_named_mode_executes_and_persists_in_one_turn(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "modes"
|
||||
self.service.mode_profiles = ModeProfileRegistry(mode_root)
|
||||
|
||||
@@ -139,4 +139,4 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
|
||||
assert 'frameLabel: "GSCU1B TCP"' in console
|
||||
assert "已解析控制指令" in console
|
||||
assert 'li[data-can-channel="IR"]' in styles
|
||||
assert "console-v2.js?v=delivery-state-49" in html
|
||||
assert "console-v2.js?v=delivery-state-50" in html
|
||||
|
||||
@@ -437,7 +437,19 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
|
||||
assert 'source: "published"' in app
|
||||
assert 'source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can"' in app
|
||||
assert "function_configs: functionConfigs" in app
|
||||
assert "execution_steps: executionSteps" in app
|
||||
assert "execution_steps: []" in app
|
||||
assert "requestNaturalLanguageControl(instruction" in app
|
||||
assert 'executionMode: "dry_run"' in app
|
||||
assert 'clientType: "mode_editor_compile"' in app
|
||||
assert "previewOnly: true" in app
|
||||
assert "function executeSavedModeProfile(mode)" in app
|
||||
assert "/api/mode-profiles/${encodeURIComponent(mode.id)}/execute" in app
|
||||
assert '$("#utterance").value = mode.name || mode.id;' in app
|
||||
assert 'const savedModeExecution = result.orchestration_route === "saved_mode_command_flow";' in app
|
||||
assert "`${result.mode_profile.name}执行完成`" in app
|
||||
|
||||
assert "生成指令" in html
|
||||
assert ">执行</span></button>" not in html
|
||||
|
||||
assert ".mode-editor-command-card" in styles
|
||||
assert ".mode-editor-command-meta" in styles
|
||||
|
||||
@@ -970,7 +970,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
["failed"],
|
||||
)
|
||||
|
||||
def test_submitted_emit_without_ack_continues_complete_infrared_plan(self) -> None:
|
||||
def test_power_on_then_white_waits_800ms_and_continues_without_ack(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
catalog = [
|
||||
{
|
||||
@@ -984,7 +984,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
}
|
||||
for capability_id, action, slot in (
|
||||
("light-on", "打开", 1),
|
||||
("light-green", "切换到绿色", 4),
|
||||
("light-white", "切换到白色", 6),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1009,16 +1009,18 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
"execute_gateway",
|
||||
side_effect=submit_without_ack,
|
||||
) as execute_gateway,
|
||||
patch("cognitive_agent.application.self_extension.time.sleep"),
|
||||
patch(
|
||||
"cognitive_agent.application.self_extension.time.sleep"
|
||||
) as sleep,
|
||||
):
|
||||
result = service.execute_peripheral_plan_request(
|
||||
"开启闪光灯,颜色设置为绿色",
|
||||
"启动闪烁灯,颜色设置为白色",
|
||||
{
|
||||
"provider_used": "infrared_local_rules",
|
||||
"summary": "打开闪光灯并切换为绿色",
|
||||
"summary": "启动闪烁灯并切换为白色",
|
||||
"steps": [
|
||||
{"kind": "peripheral", "capability_id": "light-on"},
|
||||
{"kind": "peripheral", "capability_id": "light-green"},
|
||||
{"kind": "peripheral", "capability_id": "light-white"},
|
||||
],
|
||||
},
|
||||
catalog,
|
||||
@@ -1027,11 +1029,12 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(execute_gateway.call_count, 2)
|
||||
sleep.assert_called_once_with(0.8)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["sequence"]["emit_count"], 2)
|
||||
self.assertEqual(
|
||||
[item["telemetry"]["slot"] for item in result["operation_results"]],
|
||||
[1, 4],
|
||||
[1, 6],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
|
||||
@@ -187,6 +187,7 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
assert "本地规则命令已执行;云端补充失败" in app
|
||||
assert 'class="v2-can-command-source"' in console
|
||||
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
|
||||
assert 'item.parseSource === "saved_mode_command_flow" ? "模式卡片"' in console
|
||||
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
|
||||
assert 'item.deliveryLabel' in console
|
||||
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
|
||||
|
||||
Reference in New Issue
Block a user