diff --git a/.gitignore b/.gitignore index 304a00e..28a5107 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ artifacts/self-repair/ artifacts/*.log tmp/*.log .codex_tmp/ +/build/ firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/ firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/ tools/zcan/seat_function_runtime/ diff --git a/Makefile b/Makefile index 1f21808..d6afe95 100755 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ -.PHONY: validate test dev dev-lan +.PHONY: validate test dev dev-lan can-protocol-generate can-protocol-check can-read-trans can-read-trans-test -validate: +CAN_READ_TRANS_BUILD_DIR ?= build/can_read_trans +CAN_READ_TRANS_CANFD_ROOT ?= /home/orin/project511/project-code/canfd + +can-protocol-generate: + python tools/generate_can_semantic_protocol.py + +can-protocol-check: + python tools/generate_can_semantic_protocol.py --check + +validate: can-protocol-check python tools/validate_repository.py -test: +test: can-protocol-check PYTHONPATH=src:domains/seat/src python -m unittest discover -s domains/seat/tests -p 'test_*.py' PYTHONPATH=src:domains/seat/src python -m unittest discover -s tests -p 'test_*.py' @@ -12,3 +21,12 @@ dev: dev-lan: PYTHONPATH=src:domains/seat/src python -m cognitive_agent.server --host 0.0.0.0 + +can-read-trans: can-protocol-generate + cmake -S tools/can_read_trans -B $(CAN_READ_TRANS_BUILD_DIR) \ + -DCMAKE_BUILD_TYPE=Release \ + -DCANFD_ROOT=$(CAN_READ_TRANS_CANFD_ROOT) + cmake --build $(CAN_READ_TRANS_BUILD_DIR) --parallel + +can-read-trans-test: can-read-trans + ctest --test-dir $(CAN_READ_TRANS_BUILD_DIR) --output-on-failure diff --git a/README.md b/README.md index 06b6b75..0b7a690 100755 --- a/README.md +++ b/README.md @@ -116,10 +116,18 @@ config/gesture/default.yaml Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用 `zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口 验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。 -同一页面也可选择本机 NanoMQ 无线链路,其中 CAN0 对应副驾座椅、CAN1 对应 -其余车辆状态和控制;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全 +有线 ZLG 与本机 NanoMQ 无线链路使用相同的通道角色:CAN0 只对应副驾座椅, +CAN1 对应空调、车窗、灯光、遮阳帘、主驾座椅等其余车辆状态和控制。车辆控制 +后端不允许回退到 CAN0;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全 门禁见同一文档。 +需要在终端独立核对双通道状态或下发一条明确的白名单语义命令时,可构建 +`can_read_trans`。该程序由同一个 C++ 进程持有 ZLG 设备:CAN0 监听并控制副驾 +座椅固件原生协议,CAN1 监听并控制其余车辆矩阵协议;默认只监听、默认不启用 +适配器内部终端电阻,也不提供任意原始帧注入入口。`console --execute` 可在同一 +设备会话中持续接收状态并交互下发 CAN0/CAN1 白名单命令。构建、命令示例、反馈 +判定和安全限制见 [can_read_trans 双通道检测与控制](docs/workflows/can-read-trans.md)。 + ## 认证与公网体验 公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 11dcc36..5164bd6 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -2478,8 +2478,8 @@ function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState | ...device, position: { ...position, - // The seat-domain compatibility key is cushion_tilt, but seat-ota - // board axis 1 is physical height. Keep the 3D cushion neutral and + // The seat-domain compatibility key is cushion_tilt, but the native + // CAN0 firmware axis 1 is physical height. Keep the 3D cushion neutral and // animate/display the firmware feedback as height. cushion_tilt: { position_pct: 0, position_valid: true }, height: seatHeight, @@ -4323,6 +4323,33 @@ function selectedVehicleTransport() { || null; } +function isHardwareExecutionReady() { + return Boolean( + state.runtime?.hardware?.configured || selectedVehicleTransport()?.configured, + ); +} + +function hardwareExecutionIssue( + hardware = state.runtime?.hardware || {}, + transport = selectedVehicleTransport() || {}, +) { + const stateError = String(transport?.state_sync?.last_error || "").trim(); + if (/VCI_OpenDevice failed/i.test(stateError)) { + return "ZLG 设备打开失败:请检查 USB 读写权限,或关闭占用适配器的其他程序"; + } + if (stateError) return stateError; + if (transport.state_configured === true && transport.configured !== true) { + return "CAN 状态监听已配置,但真实控制授权尚未配置"; + } + if (state.controlTransport === "wireless_mqtt") { + return "无线 MQTT 控制后端尚未配置"; + } + if (hardware.enabled === true && hardware.adapter_configured !== true) { + return "副驾 CH0 CAN 适配器尚未配置"; + } + return "ZLG 有线 CAN 后端尚未通过执行配置校验"; +} + function isDriverOnlyCh1Runtime() { const transport = selectedVehicleTransport(); return transport?.configured === true @@ -4343,7 +4370,9 @@ function renderControlTransport() { // shown as offline, but it must never disable or silently change the // operator's wired/wireless selection. button.disabled = false; - button.dataset.connectionState = runtime?.state_sync?.state === "online" ? "online" : "offline"; + button.dataset.connectionState = ["online", "ready"].includes(runtime?.state_sync?.state) + ? "online" + : "offline"; button.title = runtime?.state_configured === true ? runtime?.state_sync?.last_error || "" : `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`; @@ -4362,8 +4391,16 @@ function renderControlTransport() { const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接"; note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/n7-headunit/can/command"}`; } else { - const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接"; - note.textContent = `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`; + const connection = selected?.state_sync?.state === "online" + ? "已连接" + : selected?.state_sync?.state === "ready" + ? "已配置" + : "未连接"; + const dualChannel = selected?.target_policy + === "dual_channel_wired_can0_passenger_can1_vehicle"; + note.textContent = dualChannel + ? `ZLG 有线 ${connection} · CAN0 副驾经典 CAN 500k · CAN1 车辆 CAN FD 500k/2M` + : `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`; } } @@ -4445,11 +4482,7 @@ function updateSeatFeedbackModeLabel() { } function selectExecutionMode(mode, { remember = true } = {}) { - const selectedTransport = selectedVehicleTransport(); - const hardwareExecutionReady = Boolean( - state.runtime?.hardware?.configured || selectedTransport?.configured, - ); - if (mode === "hardware" && !hardwareExecutionReady) return false; + const hardwareExecutionReady = isHardwareExecutionReady(); if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return false; if (remember) state.preferredExecutionMode = mode; state.executionMode = mode; @@ -4476,6 +4509,9 @@ function selectExecutionMode(mode, { remember = true } = {}) { ? "真实执行" : simulatedHardware ? "运行故障仿真" : "生成并执行"; } + if (remember && mode === "hardware" && !hardwareExecutionReady) { + showToast(`已切换到真实台架;当前不可执行:${hardwareExecutionIssue()}`); + } updateSeatFeedbackModeLabel(); refreshIcons(); return true; @@ -5132,7 +5168,9 @@ function modeEditorReadableCommands(flow) { Object.values(plans).forEach((plan) => { modeEditorPlanCommands(plan).forEach((item) => { const debugCommand = String(item?.signals?.debug_command || item?.message || "CAN frame"); - const channel = modeEditorCanId(item?.signals?.frame_id) === "0x6A0" + const channel = new Set(["0x29E", "0x300", "0x3CE"]).has( + modeEditorCanId(item?.signals?.frame_id), + ) ? "CAN0" : "CAN1"; const readable = { channel, category: modeEditorCommandCategory(debugCommand), command: debugCommand }; @@ -5798,7 +5836,13 @@ async function loadRuntime() { syncReasoningControls(); $("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`; const hardwareButton = $("#hardwareMode"); - hardwareButton.disabled = !hardwareExecutionReady; + // “真实台架”是操作员手动选择的执行目标。硬件暂不可用时仍允许 + // 选择该目标并展示诊断信息;真正的控制提交继续由后端安全门拦截。 + hardwareButton.disabled = false; + hardwareButton.dataset.executionState = hardwareExecutionReady ? "available" : "blocked"; + hardwareButton.title = hardwareExecutionReady + ? "真实台架已通过后端执行配置校验" + : `可手动选择真实台架;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`; const simulation = state.runtime.simulated_hardware || {}; $("#simulationMode").disabled = !simulation.enabled; $("#simulationMode").title = simulation.enabled @@ -5822,7 +5866,7 @@ async function loadRuntime() { ? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${profileText} · ${flashText}`}${accessText}${concurrencyText}` : seatHardwareReady ? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}` - : `真实执行已锁定:${profileText};当前${mqttSelected ? "无线 MQTT" : "有线 CAN"}控制未通过后端审批配置`; + : `可手动选择真实台架;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`; $("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady ? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止` : linkedHardwareReady @@ -5839,9 +5883,7 @@ async function loadRuntime() { : "已阻断"; $("#hardwareCoverage").className = hardwareExecutionReady ? "available" : "blocked"; const preferredMode = state.preferredExecutionMode || DEFAULT_EXECUTION_MODE; - const modeAvailable = preferredMode === "hardware" - ? hardwareExecutionReady - : preferredMode === "simulated_hardware" + const modeAvailable = preferredMode === "simulated_hardware" ? simulation.enabled : true; selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false }); @@ -6725,6 +6767,7 @@ const CONFIG_CAPABILITY_LABELS = { "window.set_motion": "控制车窗动作", "window.set_position": "设置车窗开度", "outlet.set_position_percent": "设置电动风口位置", + "outlet.set_switch_state": "开关单个电动风口", "outlet.set_calibrated_position": "设置电动风口标定位置", "interior_light.set_indoor_power": "设置室内灯", "interior_light.set_wall_power": "设置壁灯", @@ -6991,6 +7034,38 @@ function renderCode(result) { } } +function operationVerificationStatus(item = {}) { + const explicit = String(item.telemetry?.verification_status || ""); + if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) { + return explicit; + } + if (item.status !== "succeeded") return "failed_or_blocked"; + if (item.telemetry?.physical_effect_confirmed === true || item.telemetry?.feedback_frame) { + return "verified_success"; + } + if (item.telemetry?.command_frame + || ["submitted", "queued", "published"].includes(item.telemetry?.delivery_status)) { + return "submitted_unverified"; + } + return "verified_success"; +} + +function executionVerificationStatus(result = {}) { + const explicit = String( + result.execution_outcome + || result.execution_report?.spec?.verification_status + || "", + ); + if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) { + return explicit; + } + const operations = result.execution_report?.spec?.results || []; + const statuses = operations.map(operationVerificationStatus); + if (statuses.includes("failed_or_blocked")) return "failed_or_blocked"; + if (statuses.includes("submitted_unverified")) return "submitted_unverified"; + return "verified_success"; +} + function renderExecution(result) { const summary = $("#executionSummary"); const list = $("#resultList"); @@ -7011,9 +7086,11 @@ function renderExecution(result) { refreshIcons(); return; } + const verificationStatus = executionVerificationStatus(result); + const submittedUnverified = verificationStatus === "submitted_unverified"; const reportSucceeded = report.spec.status === "succeeded"; const partiallySucceeded = result.status === "partially_succeeded"; - summary.className = `execution-summary ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`; + summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`; const presetSave = Boolean(result.preset_config); const modeSave = Boolean(result.mode_profile); const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware"; @@ -7024,14 +7101,12 @@ function renderExecution(result) { const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware"); const succeededOperations = operationResults.filter((item) => item.status === "succeeded"); const failedOperations = operationResults.filter((item) => item.status === "failed"); - const mqttSubmittedOperations = operationResults.filter( - (item) => ["queued", "published"].includes(item.telemetry?.delivery_status), + const submittedOperations = operationResults.filter( + (item) => operationVerificationStatus(item) === "submitted_unverified", ); const physicallyConfirmedOperations = operationResults.filter( (item) => item.telemetry?.physical_effect_confirmed === true, ); - const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt" - && mqttSubmittedOperations.length > physicallyConfirmedOperations.length; const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))]; const failedDomains = [...new Set(failedOperations.map((item) => item.domain))]; let executionDetail = modeSave @@ -7041,8 +7116,10 @@ function renderExecution(result) { : simulatedHil ? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN` : physical - ? result.control_transport === "wireless_mqtt" - ? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${mqttSubmittedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}` + ? submittedUnverified + ? `${executedDomains.join(" / ")} 已通过${result.control_transport === "wireless_mqtt" ? " MQTT" : "有线 CAN"}提交 ${submittedOperations.length || succeededOperations.length} 个白名单控制步骤;车辆效果尚未确认,以实时状态回读为准` + : result.control_transport === "wireless_mqtt" + ? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${succeededOperations.length} 个白名单控制步骤,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态` : selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only" ? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤` : `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤` @@ -7056,7 +7133,7 @@ function renderExecution(result) { if (result.deferred_requests?.length) { executionDetail += ";未接入域请求已保留为 deferred request"; } - if (!reportSucceeded && !partiallySucceeded) { + if (verificationStatus === "failed_or_blocked" && !partiallySucceeded) { const failure = report.spec.results.find((item) => item.status === "failed"); const failureTelemetry = failure?.telemetry || {}; executionDetail = simulatedHil @@ -7082,26 +7159,31 @@ function renderExecution(result) { : presetSave ? "姿态预设已保存" : simulatedHil ? "模拟台架执行成功" : physical - ? mqttPublishedUnconfirmed ? "MQTT 指令已提交" : "真实台架执行成功" + ? submittedUnverified + ? `${result.control_transport === "wireless_mqtt" ? "MQTT" : "CAN"} 指令已提交,等待状态确认` + : "真实台架执行并验证成功" : "虚拟执行成功" : partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功") : simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败"; - const executionIcon = reportSucceeded + const executionIcon = submittedUnverified + ? "circle-alert" + : reportSucceeded ? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big") : partiallySucceeded ? "circle-alert" : "circle-x"; summary.innerHTML = `
${executionTitle}${executionDetail}
`; $("#resultNavMeta").textContent = reportSucceeded - ? mqttPublishedUnconfirmed - ? `${mqttSubmittedOperations.length} 项已提交 · MQTT` + ? submittedUnverified + ? `${submittedOperations.length || succeededOperations.length} 项已提交 · 等待状态确认` : `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}` : partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细` : "执行失败 · 查看回读"; - $("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : partiallySucceeded ? "PARTIAL" : "FAILED"; - $("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`; + $("#resultPanelState").textContent = submittedUnverified ? "SUBMITTED" : reportSucceeded ? "VERIFIED" : partiallySucceeded ? "PARTIAL" : "FAILED"; + $("#resultPanelState").className = `workspace-panel-state ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`; report.spec.results.forEach((item) => { const row = document.createElement("div"); row.className = "result-item"; - row.innerHTML = ``; + const itemVerification = operationVerificationStatus(item); + row.innerHTML = ``; const text = document.createElement("div"); const title = document.createElement("strong"); title.textContent = item.ref; @@ -7250,11 +7332,7 @@ function renderResult(result) { state.result = result; const reportMode = result.execution_report?.spec?.mode; const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback; - const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt" - && (result.execution_report?.spec?.results || []).some( - (item) => ["queued", "published"].includes(item.telemetry?.delivery_status) - && item.telemetry?.physical_effect_confirmed !== true, - ); + const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified"; if (feedbackSource === "simulated_hardware") { state.seatFeedbackSource = "simulated_hardware"; if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware"; @@ -7289,10 +7367,10 @@ function renderResult(result) { ? "长期模式配置已保存" : result.preset_config ? "座椅姿态预设已保存" - : result.scene_config - ? `${domains.length} 域场景闭环完成` - : mqttPublishedUnconfirmed - ? `${domainLabels[domains[0]] || "车舱"}控制指令已提交` + : submittedUnverified + ? `${domains.length > 1 ? `${domains.length} 域场景` : domainLabels[domains[0]] || "车舱"}控制指令已提交` + : result.scene_config + ? `${domains.length} 域场景闭环完成` : `${domainLabels[domains[0]] || "车舱"}单域闭环完成` : "请求未执行"; const conversationLabel = result.conversation_id?.slice(-8) || "no-context"; @@ -7302,8 +7380,8 @@ function renderResult(result) { $("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`; } setRunStatus( - partiallySucceeded ? "partial" : executionSucceeded ? "succeeded" : "failed", - result.status.toUpperCase(), + partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed", + submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(), ); renderDecision(result); renderTrace(result.trace_events || []); @@ -7353,6 +7431,8 @@ async function postControlPhase(path, body, executionMode, onEvent = null) { phaseEvent.message || phaseEvent.error || "流式控制请求失败", ); requestError.code = phaseEvent.error; + requestError.outcome = phaseEvent.outcome; + requestError.detail = phaseEvent.detail; requestError.retryAfter = Number(phaseEvent.retry_after_seconds || 0); throw requestError; } @@ -7572,6 +7652,7 @@ async function submitRequest(event) { setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed"); } const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware"; + const submittedUnverified = executionVerificationStatus(payload) === "submitted_unverified"; showToast(supplementWarning || (completed ? (payload.mode_profile ? `${payload.mode_profile.name}已创建并添加到功能模式` @@ -7579,6 +7660,8 @@ async function submitRequest(event) { ? "已执行可用设备;部分设备未完成,详见执行结果" : simulated ? "模拟台架执行完成" + : submittedUnverified + ? "CAN 控制指令已提交;等待车辆实时状态确认" : payload.execution_report?.spec?.mode === "hil" ? "真实台架执行与反馈确认已完成" : "可执行动作的 dry-run 已完成") diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 98121d0..31ea19b 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -22,6 +22,31 @@ let reasoningPickerControl = null; let validatedCommandsVisible = false; + let validatedCommandContext = { requestId: "", utterance: "" }; + + function resetValidatedCommandContext() { + validatedCommandsVisible = false; + validatedCommandContext = { requestId: "", utterance: "" }; + } + + function rememberValidatedCommandContext(result = {}) { + validatedCommandContext = { + requestId: String(result.request_id || ""), + utterance: String(result.utterance || "").trim(), + }; + } + + function validatedCommandsBelongToTask(task = {}) { + if (!validatedCommandsVisible) return false; + const result = task.result && typeof task.result === "object" ? task.result : {}; + const requestId = String(result.request_id || ""); + const utterance = String(task.utterance || result.utterance || "").trim(); + if (validatedCommandContext.requestId && requestId + && validatedCommandContext.requestId !== requestId) return false; + if (validatedCommandContext.utterance && utterance + && validatedCommandContext.utterance !== utterance) return false; + return true; + } function make(tag, className, html) { const node = document.createElement(tag); @@ -71,10 +96,13 @@ ...(parseSource ? { parseSource } : {}), }); const hexByte = (value) => Number(value).toString(16).padStart(2, "0").toUpperCase(); - const otaCan0Frame = (opcode, selector, argument) => { - const numeric = Number(argument) >>> 0; - const argumentBytes = [0, 8, 16, 24].map((shift) => hexByte((numeric >>> shift) & 0xFF)); - return `0x6A0 · C7 ${hexByte(opcode)} ${hexByte(selector)} ${argumentBytes.join(" ")}`; + const littleEndian16 = (value) => { + const numeric = Number(value) & 0xFFFF; + return `${hexByte(numeric & 0xFF)} ${hexByte((numeric >>> 8) & 0xFF)}`; + }; + const nativeCan0Frame = (opcode, motor, direction, paramA, paramB) => { + const control = (Number(motor) & 0x03) | ((Number(direction) & 0x03) << 2) | 0x10; + return `0x300 · ${hexByte(opcode)} ${hexByte(control)} ${littleEndian16(paramA)} ${littleEndian16(paramB)}`; }; const isPassengerSeat = new Set(["passenger", "front-right"]).has(target); @@ -88,7 +116,6 @@ const axis = passengerAxes[capability]; const position = boundedInteger(parameters.position_pct, 0, 100); if (axis && position !== null) { - const targetPermille = position * 10; const motion = operation.motion || {}; const delta = boundedInteger(motion.delta_position_pct, 1, 100); const relativeDirections = { @@ -104,7 +131,9 @@ ? `seat passenger ${axis[1]} relative ${relativeDirection} ${delta}` : `seat passenger ${axis[1]} position ${position}`, "CAN0", - otaCan0Frame(0x64, axis[2], targetPermille), + position === 0 + ? nativeCan0Frame(0x0B, axis[2], 0, 0x4B52, 120) + : nativeCan0Frame(0x01, axis[2], 0, position * 100, 30000), ); } if (new Set(["seat.set_ventilation", "seat.set_heating"]).has(capability)) { @@ -124,14 +153,12 @@ if (capability === "seat.start_dynamic_mode") { const mode = boundedInteger(parameters.mode_id, 1, 2); if (mode !== null) { - const dynamic = mode === 1 - ? { name: "rocking-horse", selector: 2 } - : { name: "cradle", selector: 1 }; + const dynamic = mode === 1 ? "rocking-horse" : "cradle"; return command( "副驾座椅动态模式", - `seat passenger dynamic ${dynamic.name}`, + `seat passenger dynamic ${dynamic}`, "CAN0", - otaCan0Frame(0x66, dynamic.selector, 300), + nativeCan0Frame(0x0E, 0, 0, mode, 0), ); } } @@ -140,7 +167,7 @@ "副驾座椅动态模式", "seat passenger dynamic stop", "CAN0", - otaCan0Frame(0x63, 0xFF, 0), + nativeCan0Frame(0x0F, 0, 0, 0, 0), ); } } @@ -198,6 +225,13 @@ const mode = String(parameters.mode ?? parameters.cycle_mode ?? ""); if (new Set(["internal", "external"]).has(mode)) return command("空调循环", `ac cycle ${mode}`); } + if (capability === "outlet.set_switch_state") { + const state = String(parameters.state ?? ""); + if (new Set(["driver-left", "driver-right", "passenger-left", "passenger-right"]).has(target) + && new Set(["open", "close"]).has(state)) { + return command("单风口开关", `outlet ${target} ${state}`); + } + } if (capability === "outlet.set_position_percent") { const horizontal = boundedInteger(parameters.horizontal_pct, 0, 100); const vertical = boundedInteger(parameters.vertical_pct, 0, 100); @@ -330,7 +364,7 @@ ${escapeHtml(item.category)} ${escapeHtml(item.command)} - ${item.frame ? `seat-ota · ${escapeHtml(item.frame)}` : ""} + ${item.frame ? `固件原生 CAN0 · ${escapeHtml(item.frame)}` : ""} `).join("")}`; } else { list.innerHTML = `

${escapeHtml(message || "本次请求未生成已验证的 CAN 控制指令")}

`; @@ -359,6 +393,10 @@ ${escapeHtml(item.command)} `).join("")}`; validatedCommandsVisible = true; + validatedCommandContext = { + requestId: "", + utterance: items.map((item) => item.command).join(";"), + }; requestAnimationFrame(syncSidebarLogBaseline); } @@ -1184,6 +1222,12 @@ if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind; if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令"; const normalizedStatus = String(task.status || "idle").toLowerCase(); + const verificationStatus = String( + result?.execution_outcome + || result?.execution_report?.spec?.verification_status + || "", + ); + const submittedUnverified = verificationStatus === "submitted_unverified"; const statusMap = { idle: ["等待任务", "idle"], running: ["构建 / 执行中", "running"], @@ -1192,7 +1236,9 @@ needs_confirmation: ["等待确认", "partial"], failed: ["执行失败", "failed"], }; - const [label, state] = statusMap[normalizedStatus] || statusMap.idle; + const [label, state] = submittedUnverified && normalizedStatus === "succeeded" + ? ["指令已提交 · 等待状态确认", "partial"] + : statusMap[normalizedStatus] || statusMap.idle; const badge = $("#v2TaskState"); if (badge) { badge.textContent = label; @@ -1207,9 +1253,23 @@ }); renderTaskTimeline(task); if (normalizedStatus === "running") { - renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); + // The streaming endpoint can publish fully validated commands before the + // slower display-sync poll observes the terminal execution state. A stale + // `running` snapshot for the same task must not erase those commands. + if (!validatedCommandsBelongToTask(task)) { + resetValidatedCommandContext(); + renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); + } } else if (result) { - renderParsedCanCommands(result); + const commands = parsedCanCommands(result); + if (commands.length) { + validatedCommandsVisible = true; + rememberValidatedCommandContext(result); + renderParsedCanCommands(result); + } else if (!validatedCommandsBelongToTask(task)) { + resetValidatedCommandContext(); + renderParsedCanCommands(result); + } } } @@ -1245,12 +1305,14 @@ setTaskPresentation(task); const sourceLabel = task.source === "web_console" ? "网页端" : "车机端"; if (task.utterance) { + const submittedUnverified = task.result?.execution_outcome === "submitted_unverified" + || task.result?.execution_report?.spec?.verification_status === "submitted_unverified"; appendSyncLog({ key: `task:${revision}:${task.status}`, category: "task", title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`, - detail: `${task.utterance} · ${task.status || "idle"}`, - status: ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info", + detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`, + status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info", }); } const result = task.result; @@ -1487,26 +1549,44 @@ }); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); $("#requestForm")?.addEventListener("submit", () => { - validatedCommandsVisible = false; + resetValidatedCommandContext(); renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); }); window.addEventListener("cognitive-agent:validated-can-commands", (event) => { const result = event.detail || {}; validatedCommandsVisible = parsedCanCommands(result).length > 0; + if (validatedCommandsVisible) rememberValidatedCommandContext(result); + else resetValidatedCommandContext(); renderParsedCanCommands(result, { message: "已完成白名单校验,正在下发控制指令…", }); }); window.addEventListener("cognitive-agent:result", (event) => { const result = event.detail || {}; - validatedCommandsVisible = parsedCanCommands(result).length > 0; - renderParsedCanCommands(result); + const submittedUnverified = result.execution_outcome === "submitted_unverified" + || result.execution_report?.spec?.verification_status === "submitted_unverified"; + const commands = parsedCanCommands(result); + if (commands.length) { + validatedCommandsVisible = true; + rememberValidatedCommandContext(result); + renderParsedCanCommands(result); + } else if (!validatedCommandsBelongToTask({ + utterance: result.utterance, + result, + })) { + resetValidatedCommandContext(); + renderParsedCanCommands(result); + } appendSyncLog({ key: result.request_id ? `result:${result.request_id}` : "", category: "task", - title: ["succeeded", "partially_succeeded"].includes(result.status) ? "编排任务执行完成" : "编排任务返回结果", - detail: `${result.summary || result.message || "执行反馈已同步"} · ${result.status || "unknown"}`, - status: ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info", + title: submittedUnverified + ? "CAN 指令已提交,等待车辆状态确认" + : ["succeeded", "partially_succeeded"].includes(result.status) + ? "编排任务执行完成" + : "编排任务返回结果", + detail: `${result.summary || result.message || "执行反馈已同步"} · ${submittedUnverified ? "submitted_unverified" : result.status || "unknown"}`, + status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(result.status) ? "success" : result.status === "failed" ? "error" : "info", }); }); window.addEventListener("cognitive-agent:request-complete", (event) => { @@ -1514,7 +1594,19 @@ renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" }); } if (event.detail?.error) { - appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" }); + const error = event.detail.error; + const outcomeLabels = { + failed_or_blocked: "失败或已阻止", + submitted_unverified: "已提交但未验证", + verified_success: "反馈验证成功", + }; + const outcome = outcomeLabels[error.outcome]; + appendSyncLog({ + category: "task", + title: "编排任务执行失败", + detail: `${error.detail || error.message || "请求失败"}${outcome ? ` · ${outcome}` : ""}`, + status: "error", + }); } }); window.addEventListener("cognitive-agent:connection", (event) => { diff --git a/apps/web-console/index.html b/apps/web-console/index.html index ed235ec..091708d 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -17,10 +17,10 @@ - + - + @@ -111,7 +111,7 @@ -