diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 9647eab..68eef7d 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -6443,6 +6443,8 @@ function applyModeCommand(command, modeProfileId = null) { delete input.dataset.speechUtteranceId; delete input.dataset.speechValue; input.dispatchEvent(new Event("input", { bubbles: true })); + state.pendingModeProfileId = modeProfileId; + if (modeProfileId) setModeExecutionState(modeProfileId, "running"); form.requestSubmit(); return true; } diff --git a/apps/web-console/index.html b/apps/web-console/index.html index afd0d27..e4d1c87 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -20,7 +20,7 @@ - + diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index d9980f2..d82721f 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v276"; +const CACHE_VERSION = "cognitive-agent-pad-v277"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,7 +6,7 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-23", "/network.js?v=android-pad-2", - "/app.js?v=dual-seat-129", + "/app.js?v=dual-seat-130", "/gesture-control.js?v=android-pad-9", "/pwa.js?v=android-pad-3", "/console-v2.css?v=delivery-state-66", diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index e4a422d..b2ef03c 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -377,6 +377,65 @@ def _catalog_exact_mode_match_count( return len(matched_ids) +def _catalog_exact_mode_execution_id( + catalog: Mapping[str, object], + message: str, +) -> str | None: + """Resolve an explicit saved-mode run without asking the model to route it.""" + + source_status = catalog.get("source_status") + if not isinstance(source_status, Mapping) or source_status.get("modes") != "ready": + return None + + normalized = re.sub(r"[\s,。!?!?、;;::]", "", str(message).casefold()) + if not normalized: + return None + prefixes = ("", "请", "请帮我", "帮我", "麻烦", "麻烦帮我", "现在", "立即", "马上") + verbs = ("进入", "启动", "执行", "开启", "打开", "切换到") + suffixes = ("", "吧", "一下", "现在", "立即") + matched_ids: set[str] = set() + raw_modes = catalog.get("modes", []) + if not isinstance(raw_modes, Sequence) or isinstance( + raw_modes, + (str, bytes, bytearray), + ): + return None + for raw_mode in raw_modes: + if not isinstance(raw_mode, Mapping) or raw_mode.get("assistant_callable") is not True: + continue + mode_id = str(raw_mode.get("id") or "").strip() + if not mode_id: + continue + aliases = raw_mode.get("aliases", []) + candidates = [ + raw_mode.get("name"), + *( + aliases + if isinstance(aliases, Sequence) + and not isinstance(aliases, (str, bytes, bytearray)) + else [] + ), + ] + for candidate in candidates: + mode_name = re.sub( + r"[\s,。!?!?、;;::]", + "", + str(candidate or "").casefold(), + ) + if not mode_name: + continue + accepted = { + f"{prefix}{verb}{mode_name}{suffix}" + for prefix in prefixes + for verb in verbs + for suffix in suffixes + } + if normalized in accepted: + matched_ids.add(mode_id) + break + return next(iter(matched_ids)) if len(matched_ids) == 1 else None + + def _catalog_exact_scene_template_plan( catalog: Mapping[str, object], requested_name: str, @@ -822,6 +881,12 @@ class CodexAssistantService: control_terms, draft_context, ) + saved_mode_execution_id = _catalog_exact_mode_execution_id(catalog, message) + if saved_mode_execution_id is not None: + authorization = _VehicleAuthorization( + message, + "explicit_saved_mode_execute", + ) fast_comfort_control = ( self._fast_implicit_controls and authorization.reason in _FAST_COMFORT_REASONS ) @@ -877,6 +942,7 @@ class CodexAssistantService: direct_vehicle_control = ( fast_comfort_control or exact_quick_control + or saved_mode_execution_id is not None or direct_mode_profile_preview or authorization.reason == "explicit_mode_profile_draft_update" or saved_mode_management_route is not None @@ -890,6 +956,8 @@ class CodexAssistantService: if scene_template_plan is not None else saved_mode_management_route if saved_mode_management_route is not None + else "mode_profile_execute" + if saved_mode_execution_id is not None else "vehicle_control" if direct_vehicle_control else "scene_composer" @@ -905,6 +973,8 @@ class CodexAssistantService: if direct_mode_profile_preview or direct_mode_profile_update else "n7web-mode-profile-manager" if saved_mode_management_route is not None + else "n7web-mode-profile-runner" + if saved_mode_execution_id is not None else "codex-app-server-scene-composer" if scene_composition_preview else "n7web-control-router" @@ -944,6 +1014,8 @@ class CodexAssistantService: if saved_mode_management_route == "mode_profile_update" else "正在删除自定义功能模式……" if saved_mode_management_route == "mode_profile_delete" + else "正在载入并执行已保存的功能模式……" + if saved_mode_execution_id is not None else "正在根据实时能力目录快速组合模式……" if scene_composition_preview else "小迪正在理解场景并组合工程能力……" @@ -1039,6 +1111,8 @@ class CodexAssistantService: if scene_template_plan is not None else saved_mode_management_route if saved_mode_management_route is not None + else "mode_profile_execute" + if saved_mode_execution_id is not None else "vehicle_control" ), ) @@ -1679,6 +1753,7 @@ class CodexAssistantService: authorization.reason == "explicit_mode_profile_draft_update" ) exact_quick_control = authorization.reason == "exact_quick_control" + saved_mode_execution = authorization.reason == "explicit_saved_mode_execute" mode_profile_management_route = { "explicit_mode_profile_update": "mode_profile_update", "explicit_mode_profile_delete": "mode_profile_delete", @@ -1697,6 +1772,8 @@ class CodexAssistantService: if mode_profile_management_route == "mode_profile_update" else "正在删除自定义功能模式……" if mode_profile_management_route == "mode_profile_delete" + else "正在通过安全执行链进入功能模式……" + if saved_mode_execution else "正在通过车辆安全链执行快捷控制……" if exact_quick_control else "正在通过车辆安全链执行舒适性调节……" @@ -1721,6 +1798,8 @@ class CodexAssistantService: "display_text": ( "模式动作已解析,正在进行安全校验……" if mode_profile_preview + else "已保存模式动作已载入,正在校验并执行……" + if saved_mode_execution else "功能模式存储请求已解析,正在校验……" if mode_profile_management_route is not None else "控制指令已通过校验,正在执行……" @@ -1814,6 +1893,14 @@ class CodexAssistantService: if mode_profile_preview else completion_text if mode_profile_management_route is not None + else "功能模式已提交,等待车辆状态确认" + if saved_mode_execution and action_state == "submitted" + else "功能模式模拟编排已完成" + if saved_mode_execution and action_state == "simulated" + else "功能模式执行已完成" + if saved_mode_execution and successful + else "功能模式执行未完成" + if saved_mode_execution else "快捷控制已提交,等待车辆状态确认" if exact_quick_control and action_state == "submitted" else "快捷控制模拟编排已完成" @@ -2164,11 +2251,18 @@ class CodexAssistantService: result: Mapping[str, object], successful: bool, ) -> str: - if reason == "exact_quick_control": + if reason in {"exact_quick_control", "explicit_saved_mode_execute"}: instruction = re.sub( r"\s+", " ", - str(result.get("utterance") or "快捷控制指令"), + str( + result.get("utterance") + or ( + "功能模式" + if reason == "explicit_saved_mode_execute" + else "快捷控制指令" + ) + ), ).strip()[:160] if not successful: return f"未能完成“{instruction}”,请检查车辆状态后重试。" diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py index 955839a..1a39c21 100644 --- a/tests/e2e/test_assistant_web_api.py +++ b/tests/e2e/test_assistant_web_api.py @@ -757,6 +757,34 @@ class TestCodexAssistantWebApi: assert not FAKE_CODEX.turn_calls assert not FAKE_CODEX.bridge_results + def test_saved_mode_execution_uses_deterministic_route_over_http(self) -> None: + message = "进入办公模式" + FAKE_CODEX.available = False + with patch.object( + AssistantApiHandler, + "assistant_capability_catalog", + StaticSavedModeCatalog(), + ): + events = self.post_assistant( + { + "message": message, + "conversation_id": "conv-api-saved-mode-execute", + "execution_mode": "dry_run", + } + ) + + assert events[0]["route"] == "mode_profile_execute" + assert events[0]["engine"] == "n7web-mode-profile-runner" + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["payload"]["execution_mode"] == "dry_run" + assert events[-1]["route"] == "mode_profile_execute" + assert "模拟编排" in events[-1]["answer"] + assert len(AssistantApiHandler.service.calls) == 1 + assert AssistantApiHandler.service.calls[0]["utterance"] == message + assert not FAKE_CODEX.turn_calls + assert not FAKE_CODEX.bridge_results + def test_saved_mode_update_uses_deterministic_management_route_over_http( self, ) -> None: diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index cb75d2a..1c19d9d 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -621,7 +621,7 @@ 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-129", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-130", 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 @@ -697,7 +697,7 @@ 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-129"', html) + self.assertIn('src="/app.js?v=dual-seat-130"', html) self.assertIn('href="/styles.css?v=dual-seat-23"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertNotIn('class="vehicle-system-hud"', html) @@ -992,14 +992,14 @@ 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-v276"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v277"', service_worker) self.assertIn('/console-v2.css?v=delivery-state-66', service_worker) self.assertIn('/console-v2.js?v=delivery-state-62', service_worker) self.assertIn('/assistant-chat.css?v=codex-assistant-26', service_worker) self.assertIn('/assistant-chat.js?v=codex-assistant-26', service_worker) self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker) self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker) - self.assertIn('"/app.js?v=dual-seat-129"', service_worker) + self.assertIn('"/app.js?v=dual-seat-130"', service_worker) self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker) self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker) self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker) @@ -1456,7 +1456,7 @@ operations: ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-129", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-130", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py index 0cc217f..b6e43c1 100644 --- a/tests/integration/test_assistant_capability_execution_matrix.py +++ b/tests/integration/test_assistant_capability_execution_matrix.py @@ -645,7 +645,7 @@ def test_every_assistant_callable_capability_reaches_the_real_tool_boundary( assert action_event["success"] is True -def test_every_catalog_mode_can_execute_through_the_assistant_tool_boundary( +def test_every_catalog_mode_executes_through_the_deterministic_assistant_route( vehicle_service: SeatDemoService, catalog: AssistantCapabilityCatalog, ) -> None: @@ -690,7 +690,7 @@ def test_every_catalog_mode_can_execute_through_the_assistant_tool_boundary( return result client.on_turn = invoke_tool - _stream_with_catalog( + result, events = _stream_with_catalog( assistant, catalog, message, @@ -700,9 +700,11 @@ def test_every_catalog_mode_can_execute_through_the_assistant_tool_boundary( assert raw_results[0]["status"] == "succeeded", mode["id"] assert raw_results[0]["matched_modes"][0]["id"] == mode["id"] - assert tool_results[0]["verified"] is False - assert tool_results[0]["verification_status"] == "simulated" - assert tool_results[0]["actions"], mode["id"] + assert tool_results == [] + assert client.turn_calls == 0 + assert result["route"] == "mode_profile_execute" + assert result["success"] is True + assert events[0]["engine"] == "n7web-mode-profile-runner" def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes( diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index d866fb4..15205ec 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -19,7 +19,7 @@ def test_console_contains_ai_assistant_surface(): def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") - assert 'const CACHE_VERSION = "cognitive-agent-pad-v276"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker assert '"/assistant-chat.css?v=codex-assistant-26"' in service_worker assert '"/assistant-chat.js?v=codex-assistant-26"' in service_worker diff --git a/tests/unit/test_codex_assistant_service.py b/tests/unit/test_codex_assistant_service.py index 091c387..e59b69e 100644 --- a/tests/unit/test_codex_assistant_service.py +++ b/tests/unit/test_codex_assistant_service.py @@ -12,6 +12,7 @@ from cognitive_agent.application.codex_assistant import ( CodexAssistantService, CodexVehicleToolError, _authorize_vehicle_request, + _catalog_exact_mode_execution_id, _catalog_exact_mode_match_count, ) @@ -81,6 +82,60 @@ def test_catalog_mode_resolution_distinguishes_unknown_ambiguous_and_unavailable ) +@pytest.mark.parametrize( + "message", + ( + "进入小憩模式", + "请进入小憩模式", + "帮我启动午休模式吧", + "麻烦帮我执行小憩模式一下", + "立即开启小憩模式", + "切换到午休模式", + ), +) +def test_catalog_resolves_bounded_saved_mode_execution_phrasings(message: str) -> None: + catalog = { + "modes": [ + { + "id": "nap", + "name": "小憩模式", + "aliases": ["午休模式"], + "assistant_callable": True, + } + ], + "source_status": {"modes": "ready"}, + } + + assert _catalog_exact_mode_execution_id(catalog, message) == "nap" + + +@pytest.mark.parametrize( + "message", + ( + "不要进入小憩模式", + "小憩模式会执行什么", + "能否进入小憩模式?", + "进入不存在模式", + ), +) +def test_catalog_saved_mode_execution_rejects_denials_questions_and_unknown_modes( + message: str, +) -> None: + catalog = { + "modes": [ + { + "id": "nap", + "name": "小憩模式", + "aliases": ["午休模式"], + "assistant_callable": True, + } + ], + "source_status": {"modes": "ready"}, + } + + assert _catalog_exact_mode_execution_id(catalog, message) is None + + @pytest.mark.parametrize( "message", ( @@ -402,6 +457,59 @@ def test_general_question_streams_from_codex_without_vehicle_execution() -> None assert client.finished[0].turn_id == "turn-1" +@pytest.mark.parametrize( + "message", + ( + "进入小憩模式", + "请帮我启动午休模式吧", + "麻烦执行小憩模式一下", + ), +) +def test_registered_mode_execution_uses_fast_local_route_without_codex( + message: str, +) -> None: + client = FakeCodexClient(available=False) + assistant = CodexAssistantService(client) + calls: list[str] = [] + catalog = { + "modes": [ + { + "id": "nap", + "name": "小憩模式", + "aliases": ["午休模式"], + "assistant_callable": True, + } + ], + "source_status": {"modes": "ready"}, + } + + def execute(instruction: str, progress: Callable[[dict[str, object]], None]): + calls.append(instruction) + result = _sample_control_result(mode="dry_run") + result["utterance"] = instruction + progress(result) + return result + + result, events = _stream( + assistant, + message, + execute_control=execute, + capability_snapshot=catalog, + ) + + assert result["route"] == "mode_profile_execute" + assert result["success"] is True + assert calls == [message] + assert client.turn_calls == [] + assert events[0]["engine"] == "n7web-mode-profile-runner" + assert events[1]["display_text"] == "正在载入并执行已保存的功能模式……" + assert any( + event.get("display_text") == "功能模式模拟编排已完成" + for event in events + ) + assert "模拟编排" in result["answer"] + + def test_registered_name_only_mode_keeps_the_fast_local_route_without_codex() -> None: client = FakeCodexClient(available=False) assistant = CodexAssistantService(client) diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 7e7dca7..33af131 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -38,7 +38,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: assert html.index("/assistant-chat.js?v=codex-assistant-26") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) - assert 'const CACHE_VERSION = "cognitive-agent-pad-v276"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None: diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index fa8cee3..cb5e655 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: ): assert asset in html assert f'"{asset}"' in service_worker - assert 'const CACHE_VERSION = "cognitive-agent-pad-v276"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py index fdb27cf..288b867 100644 --- a/tests/unit/test_mode_gallery_grouping.py +++ b/tests/unit/test_mode_gallery_grouping.py @@ -271,6 +271,7 @@ const savedMode = { command_flow: { source_request_id: "saved-request" }, }; const context = { + state: { pendingModeProfileId: null }, Event: class Event { constructor(type, options = {}) { this.type = type; this.bubbles = options.bubbles; } }, @@ -282,6 +283,7 @@ const context = { }, modeProfiles() { return [savedMode]; }, setModeRailSelection(mode) { calls.push(["select", mode?.id]); }, + setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); }, closeModeGallery(options) { calls.push(["close", options.restoreFocus]); }, showToast(message) { calls.push(["toast", message]); }, $(selector) { @@ -295,6 +297,7 @@ console.log(JSON.stringify({ calls, value: input.value, speechDataset: input.dataset, + pendingModeProfileId: context.state.pendingModeProfileId, })); """ completed = subprocess.run( @@ -312,10 +315,12 @@ console.log(JSON.stringify({ ["close", False], ["open"], ["input", "input", True, "进入阅读模式"], + ["state", "reading-user", "running"], ["submit", "进入阅读模式"], ] assert result["value"] == "进入阅读模式" assert result["speechDataset"] == {} + assert result["pendingModeProfileId"] == "reading-user" assert "executeSavedModeProfile" not in snippet