From c96970f06826b234fd68e6dfffa6fdff67532fe0 Mon Sep 17 00:00:00 2001 From: vee Date: Mon, 7 Sep 2026 15:34:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20N7=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=BA=A7=E8=88=B1=E4=BA=A4=E4=BA=92=E4=B8=8E=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 12 + .gitignore | 2 + Makefile | 12 +- README.md | 49 +- apps/web-console/README.md | 29 +- apps/web-console/app.js | 858 ++++++--- apps/web-console/assistant-chat.css | 1360 +++++++++++++++ apps/web-console/assistant-chat.js | 879 ++++++++++ apps/web-console/console-v2.css | 1156 ++++++++++++- apps/web-console/console-v2.js | 341 +++- apps/web-console/energy-monitor.css | 897 ++++++++++ apps/web-console/energy-monitor.js | 905 ++++++++++ apps/web-console/hardware-control.css | 58 +- apps/web-console/hardware-control.html | 4 +- apps/web-console/index.html | 98 +- apps/web-console/login.css | 74 +- apps/web-console/login.html | 4 +- apps/web-console/manifest.webmanifest | 4 +- apps/web-console/self-extension.css | 89 + apps/web-console/self-extension.js | 2 - apps/web-console/service-worker.js | 24 +- apps/web-console/speech-input.css | 41 + apps/web-console/speech-input.js | 865 ++++++--- apps/web-console/styles.css | 156 +- config/can_protocol/semantic_protocol.json | 4 +- deploy/server/Dockerfile | 33 +- deploy/server/README.md | 54 +- deploy/server/compose.yaml | 20 +- docs/CAN1控制指令白名单.md | 2 +- .../n7-ir-nfc-peripheral-scene-design.md | 424 +++++ .../ambient_light/config/capabilities.yaml | 2 +- domains/seat/src/seat_domain/__init__.py | 17 +- domains/seat/src/seat_domain/intent.py | 349 +++- .../tests/integration/test_seat_service.py | 31 + domains/seat/tests/unit/test_intent.py | 81 + .../agent/prompts/codex_cockpit_assistant.md | 27 + .../application/assistant_capabilities.py | 1531 ++++++++++++++++ .../application/assistant_plan_guard.py | 1084 ++++++++++++ .../application/codex_assistant.py | 1540 +++++++++++++++++ .../application/mode_profile.py | 6 +- .../application/model_plan_adapter.py | 16 +- .../application/native_can_intent.py | 433 ++++- .../application/native_can_relative.py | 66 +- .../application/native_can_runtime.py | 93 +- .../application/seat_service.py | 629 ++++++- .../application/self_extension.py | 321 +++- .../application/semantic_ir.py | 302 +++- src/cognitive_agent/auth.py | 53 +- .../generated/can_semantic_protocol.py | 6 +- .../infrastructure/codex/app_server.py | 1168 +++++++++++++ .../codex/vehicle_mcp_server.py | 697 ++++++++ .../infrastructure/speech/__init__.py | 8 + .../infrastructure/speech/contracts.py | 8 +- .../infrastructure/speech/sensevoice.py | 840 ++++++++- .../infrastructure/speech/understanding.py | 365 +++- .../infrastructure/speech/vocabulary.py | 19 +- .../infrastructure/speech/wake_word.py | 105 ++ src/cognitive_agent/server.py | 622 ++++++- tests/e2e/test_assistant_web_api.py | 847 +++++++++ tests/e2e/test_http_request_security.py | 166 ++ tests/e2e/test_seat_web_api.py | 201 ++- tests/e2e/test_speech_live_web_api.py | 309 ++++ tests/fixtures/driver_seat_memory.json | 17 + tests/fixtures/fake_codex_app_server.py | 218 +++ tests/fixtures/speech_text_regression.json | 17 +- ...t_assistant_capability_execution_matrix.py | 1020 +++++++++++ .../test_custom_mode_creation_matrix.py | 1005 +++++++++++ tests/integration/test_multidomain_service.py | 260 ++- .../test_speech_control_preview_matrix.py | 266 +++ .../test_vehicle_mcp_stdio_loopback.py | 427 +++++ tests/unit/speech_input_runtime.test.cjs | 753 ++++++++ .../unit/test_assistant_capability_catalog.py | 690 ++++++++ .../unit/test_assistant_frontend_contract.py | 273 +++ tests/unit/test_assistant_plan_guard.py | 759 ++++++++ .../test_assistant_security_boundaries.py | 496 ++++++ tests/unit/test_auth.py | 34 + tests/unit/test_can1_semantic_whitelist.py | 215 +++ tests/unit/test_ch1_default_targets.py | 10 +- tests/unit/test_codex_app_server_client.py | 401 +++++ tests/unit/test_codex_assistant_service.py | 1248 +++++++++++++ tests/unit/test_codex_deployment_contract.py | 82 + tests/unit/test_driver_seat_native_can.py | 14 + .../test_energy_monitor_frontend_contract.py | 425 +++++ tests/unit/test_frontend_theme_contract.py | 79 +- ...st_frontend_vehicle_state_sync_contract.py | 12 +- tests/unit/test_mode_gallery_grouping.py | 136 ++ tests/unit/test_mode_profile.py | 50 + tests/unit/test_nfc_frontend_contract.py | 6 +- .../test_requested_frontend_enhancements.py | 224 ++- tests/unit/test_self_extension.py | 392 ++++- .../unit/test_speech_capability_alignment.py | 23 + tests/unit/test_speech_duration_semantics.py | 114 ++ tests/unit/test_speech_transcription.py | 664 +++++++ tests/unit/test_transcript_assembler.py | 45 + tests/unit/test_two_stage_can_parsing.py | 24 + tests/unit/test_vehicle_mcp_server.py | 339 ++++ tests/unit/test_verified_body_can.py | 70 + tests/unit/test_wired_dual_channel_can.py | 14 +- .../generated/can_semantic_protocol.hpp | 6 +- tools/run_node.py | 36 + 100 files changed, 30088 insertions(+), 1174 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/web-console/assistant-chat.css create mode 100644 apps/web-console/assistant-chat.js create mode 100644 apps/web-console/energy-monitor.css create mode 100644 apps/web-console/energy-monitor.js create mode 100644 docs/product/n7-ir-nfc-peripheral-scene-design.md create mode 100644 src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md create mode 100644 src/cognitive_agent/application/assistant_capabilities.py create mode 100644 src/cognitive_agent/application/assistant_plan_guard.py create mode 100644 src/cognitive_agent/application/codex_assistant.py create mode 100644 src/cognitive_agent/infrastructure/codex/app_server.py create mode 100644 src/cognitive_agent/infrastructure/codex/vehicle_mcp_server.py create mode 100644 src/cognitive_agent/infrastructure/speech/wake_word.py create mode 100644 tests/e2e/test_assistant_web_api.py create mode 100644 tests/e2e/test_http_request_security.py create mode 100644 tests/e2e/test_speech_live_web_api.py create mode 100644 tests/fixtures/driver_seat_memory.json create mode 100644 tests/fixtures/fake_codex_app_server.py create mode 100644 tests/integration/test_assistant_capability_execution_matrix.py create mode 100644 tests/integration/test_custom_mode_creation_matrix.py create mode 100644 tests/integration/test_speech_control_preview_matrix.py create mode 100644 tests/integration/test_vehicle_mcp_stdio_loopback.py create mode 100644 tests/unit/speech_input_runtime.test.cjs create mode 100644 tests/unit/test_assistant_capability_catalog.py create mode 100644 tests/unit/test_assistant_frontend_contract.py create mode 100644 tests/unit/test_assistant_plan_guard.py create mode 100644 tests/unit/test_assistant_security_boundaries.py create mode 100644 tests/unit/test_codex_app_server_client.py create mode 100644 tests/unit/test_codex_assistant_service.py create mode 100644 tests/unit/test_codex_deployment_contract.py create mode 100644 tests/unit/test_energy_monitor_frontend_contract.py create mode 100644 tests/unit/test_mode_gallery_grouping.py create mode 100644 tests/unit/test_speech_duration_semantics.py create mode 100644 tests/unit/test_vehicle_mcp_server.py create mode 100644 tools/run_node.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0439ea5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.venv +.env +.env.auth +__pycache__ +*.py[cod] +artifacts +output +tmp +tests +firmware +docs/product diff --git a/.gitignore b/.gitignore index 49243ab..1ca1739 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ artifacts/*.log tmp/*.log .codex_tmp/ /build/ +/output/ +/tmp/imagegen/ 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 d6afe95..e4c3925 100755 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -.PHONY: validate test dev dev-lan can-protocol-generate can-protocol-check can-read-trans can-read-trans-test +.PHONY: validate test test-frontend dev dev-lan can-protocol-generate can-protocol-check can-read-trans can-read-trans-test CAN_READ_TRANS_BUILD_DIR ?= build/can_read_trans CAN_READ_TRANS_CANFD_ROOT ?= /home/orin/project511/project-code/canfd +TEST_PYTHONPATH := src:domains/_template/src:domains/ambient_light/src:domains/climate/src:domains/roof/src:domains/seat/src can-protocol-generate: python tools/generate_can_semantic_protocol.py @@ -12,9 +13,12 @@ can-protocol-check: validate: can-protocol-check python tools/validate_repository.py -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' +test-frontend: + python tools/run_node.py --test tests/unit/speech_input_runtime.test.cjs + python -m pytest tests/unit/test_energy_monitor_frontend_contract.py -q + +test: can-protocol-check test-frontend + PYTHONPATH=$(TEST_PYTHONPATH)$${PYTHONPATH:+:$${PYTHONPATH}} python -m pytest dev: PYTHONPATH=src:domains/seat/src python -m cognitive_agent.server diff --git a/README.md b/README.md index 1eca1ec..7496822 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,18 @@ 面向车载执行器的认知编排 Agent 单仓工程。系统把自然语言请求转换为可校验的语义配置,再由确定性的领域插件编译为 CAN 命令计划,最后经安全策略和执行器下发。 -启动前端:PYTHONPATH=src:domains/seat/src .omx/python311-venv/bin/python -m cognitive_agent.server +Web 控制台与同源 API 由同一个 Python 服务提供。本地启动: + +```bash +make dev +``` + +Windows PowerShell 未安装 `make` 时可直接运行: + +```powershell +$env:PYTHONPATH = "src;domains/seat/src" +python -m cognitive_agent.server +``` ## 建设阶段 @@ -96,20 +107,46 @@ make dev 浏览器访问 `http://127.0.0.1:8000`。设置 `OPENAI_API_KEY` 后,GPT Structured Outputs 会基于各领域的 `config/capabilities.yaml` 和当前会话状态直接生成通用能力请求;后端只做结构校验、领域路由和确定性执行适配,不用本地语义规则改写 GPT 动作。本地解析器用于未配置 GPT,或 GPT/`auto` 模式因 API Key、模型权限、网络或结构化响应异常而失败时的安全降级;结果会明确标记 `local_rules_fallback`。模型由 `OPENAI_MODEL=gpt-5.6-sol` 指定;兼容 OpenAI Responses API 的服务可通过 `OPENAI_BASE_URL` 指定。无论使用哪种解析器,配置校验、CAN 编译和虚拟执行均为同一确定性路径。 -### 一句话、语音听写、快捷模式与手势控制 +### Codex AI 助手 -首页左侧提供一句话控制,可同时描述座椅、空调、天幕和氛围灯目标;Enter 直接执行,Shift+Enter 换行。观影、午休和零重力是可直接调用的快捷模式,“保存当前”可把当前副驾驶姿态保存为命名模式。复杂跨域自然语言优先使用 GPT 解析,本地规则用于已明确覆盖的离线指令。 +首页 AI 助手以长驻 `codex app-server --stdio` 为会话与推理核心。浏览器仍只连接同源 `/api/assistant/stream`,不会接触 Codex 协议或凭据;后端把 App Server 的 thread、turn 和 item 事件转换为 NDJSON 流。普通问题由 Codex 直接回答。涉及本工程能力时,`/api/assistant/capabilities`、回合上下文和只读 MCP 工具 `n7_vehicle.get_n7web_capabilities` 都从同一个运行时能力注册表生成,座舱模式、NFC 绑定红外外设及 CAN0/CAN1 执行绑定会随实际状态更新,不允许模型凭常识猜测。常见能力询问直接由本地权威目录回答,无需等待模型生成。 -输入框右侧的麦克风按钮提供本地中文语音听写:优先选择 `TAKSTAR V2` 输入设备,`AudioWorklet` 在音频线程采集(旧浏览器才降级到 `ScriptProcessor`),浏览器端静音检测负责自动停录,16 kHz PCM WAV 发送到同源 `/api/speech/transcribe`。服务端再使用 Silero VAD 做权威分段并保留句首/句尾保护区,SenseVoiceSmall 只负责声学识别;统一 `TranscriptAssembler` 负责 final commit、顺序、重复回调和保守的相邻重叠消除。 +显式及复杂车辆需求通过唯一放行的 `n7_vehicle.execute_cockpit_request` MCP 工具进入工程。服务端把一次性工具令牌绑定到本回合原始指令;Codex 可以为原子设备控制提交结构化语义计划,但不能提交新的自然语言指令或原始 CAN。计划必须通过原文证据、能力白名单、目标和参数契约校验,本地确定性解析结果优先,之后仍进入既有 Schema、硬件授权、安全预检、互斥和 CAN 执行链。模型不能扩展用户未请求的控制,也不能覆盖执行模式和硬件授权。 -声学 `raw_text` 永远保留。独立的 `UnderstandingTranscriptProcessor` 根据工程能力目录生成 `clean_text`、结构化 `commands`、`cancelled_fragments` 和 `uncertain`,区分“然后”的正常多指令与“不对、改成、算了”等修正/撤销。无关聊天与否定不会激活控制;超出工程参数范围或表达残缺的结果会显示但禁止直接提交,用户修改确认后才能执行。听写本身不自动执行控制命令,也不保存录音。首次部署: +“车上有点热”“车里有点冷”等与受限模式完整匹配、且不包含否定或知识问法的高置信度舒适请求,使用 N7Web 内部快速路由:分别归一化为空调开启、全车 23℃/26℃、2 档风量等确定性指令,绕过模型和 MCP 往返以尽快给出进度与结果。该快速路由只缩短意图理解路径,不绕过能力注册、Schema、执行模式、硬件授权、安全预检、互斥、CAN 编译或反馈确认;其他隐式、复合或含糊请求仍交给 Codex 判断,并受上述 MCP 边界约束。 + +能力目录中的 `service_surfaces` 仅用于显示语音输入、手势输入、NFC 绑定、座椅自标定和自修复等服务的运行状态,条目固定标记为 `assistant_access=informational`。这里的 `available=true` 只表示对应服务面可用,不表示 AI 助手可以调用它;可执行边界以 `capabilities`、`modes`、`learned_peripherals` 中的 `assistant_callable` 以及服务端再次校验结果为准。 + +本地运行前需要安装并登录固定兼容版本的 Codex CLI: + +```bash +npm install --global @openai/codex@0.150.0-alpha.8 +codex login status +make dev +``` + +也可以让 Codex 使用进程环境中的 `OPENAI_API_KEY`。助手默认选择低推理强度的 `gpt-5.6-luna`,可用 `CODEX_ASSISTANT_MODEL` 覆盖;`CODEX_ASSISTANT_REASONING_EFFORT` 默认为 `low`,`CODEX_ASSISTANT_TURN_TIMEOUT_SECONDS` 默认为 300 秒,`CODEX_ASSISTANT_WORKSPACE` 用来指定隔离的只读工作目录。为避免兼容服务的 WebSocket 断流造成长时间重试,非 `openai`/`chatgpt` 的自定义 provider 默认关闭 WebSocket,并把模型流重试限制为一次;可通过 `CODEX_ASSISTANT_DISABLE_WEBSOCKETS` 和 `CODEX_ASSISTANT_STREAM_MAX_RETRIES` 调整。 + +启动采用失败关闭语义:CLI 缺失、App Server 握手失败或有效 MCP 配置未能收敛并验证为仅启用 `n7_vehicle` 时,`cognitive_agent.server` 会直接启动失败,不会继续提供一个看似可用的降级网页。进程启动后若 App Server 退出,`/api/health` 返回 `503`,`/api/assistant/status` 显示不可用;模型认证、权限、网络或生成失败则由当前问答流返回 `assistant_model_failed`。这些情况都不会静默回退到旧的正则问答路由;本地能力快答和冷热快速控制仍只按各自严格的确定性条件工作。 + +App Server 的协议与审批字段以 [Codex App Server 官方文档](https://learn.chatgpt.com/docs/app-server) 和 [Codex 配置参考](https://learn.chatgpt.com/zh-Hans/docs/config-file/config-reference) 为准。线程级 `approvalPolicy=never` 与 MCP 工具级 `default_tools_approval_mode=approve` 作用于不同边界:前者禁止命令/文件修改请求进入交互审批,后者仅避免受限的 N7Web MCP 工具产生前端无法处理的第二次审批;真实执行授权仍完全由 N7Web 的一次性令牌、原文绑定和车辆安全链决定。 + +### AI 助手语音输入、快捷模式与手势控制 + +首页左侧的 Codex AI 助手接受普通问题、工程工作和车辆控制需求;Enter 发送,Shift+Enter 换行。观影、午休和零重力是可直接调用的快捷模式,“保存当前”可把当前副驾驶姿态保存为命名模式。能力快答和高置信度舒适请求走确定性快速链,其他请求由 Codex App Server 理解后再通过工程能力与安全边界执行。 + +语音入口分为两种明确模式:用户点击输入框右侧的麦克风按钮时,以 `manual` 模式立即开始本地中文录音,不要求也不剥离唤醒词;浏览器已获得麦克风权限、AI 助手处于展开状态且页面可见时,则自动进入低干扰的 `wake` 待机,只在识别到“你好,双十一”后展示并继续接收问题。首次使用仍需通过一次手动点击完成浏览器麦克风授权。前端优先选择 `TAKSTAR V2`,使用 `AudioWorklet` 在音频线程采集(旧浏览器才降级到 `ScriptProcessor`),并通过 `X-Speech-Input-Mode: manual|wake` 将两条链路显式隔离。 + +累积音频按节流窗口发送到同源 `/api/speech/preview`:手动模式直接把未语义清洗的实时转录显示在输入框,唤醒模式在命中前不改动输入框、命中后只显示唤醒词之后的内容,partial 阶段均不会提交或执行。浏览器检测到静音端点后立即停止音轨,再把 16 kHz PCM WAV 发送到 `/api/speech/transcribe` 生成权威 final。服务端使用 Silero VAD 保留句首/句尾保护区,SenseVoiceSmall 负责声学识别,统一 `TranscriptAssembler` 负责 final commit、顺序、重复回调和保守的相邻重叠消除。 + +最终响应把不同信任层明确拆开:`captured_text` 保留完整声学识别结果;`raw_text` 在 `manual` 模式保留完整原文,在 `wake` 模式仅剥离已确认的唤醒词;`assistant_text` 在保留普通问答的前提下处理重复、结巴、false start、修正、撤销、否定、多设备、多数字和小数,供 AI 助手输入框使用;`clean_text/control_text`、结构化 `commands`、`cancelled_fragments` 和 `execution_eligible` 继续作为严格车控旁路。未知能力或非车控内容不会被清空,也不会绕过 Codex 助手直接执行。听写本身不自动发送消息、不自动执行控制,也不保存录音。首次部署: ```bash python -m pip install -e ".[speech]" python tools/install_sensevoice_model.py ``` -安装脚本同时校验并部署 `artifacts/models/silero-vad/silero_vad.onnx`。默认使用完整精度 SenseVoice、`language=zh`、ITN 和最多 8 个 CPU 线程;VAD 针对短车控语句使用阈值 `0.45`、最短语音 `0.16s`、结束静音 `0.65s`、最长分段 `12s`、前置 `0.25s`、后置 `0.35s`。可通过 `SPEECH_MODEL_*`、`SPEECH_LANGUAGE`、`SPEECH_USE_ITN`、`SPEECH_NUM_THREADS`、`SPEECH_PROVIDER` 与 `SPEECH_VAD_*` 覆盖。 +安装脚本同时校验并部署 `artifacts/models/silero-vad/silero_vad.onnx`。默认使用完整精度 SenseVoice、`language=zh`、ITN 和最多 8 个 CPU 线程;VAD 针对短会话使用阈值 `0.45`、最短语音 `0.16s`、结束静音 `0.65s`、最长分段 `12s`、前置 `0.25s`、后置 `0.35s`。可通过 `SPEECH_MODEL_*`、`SPEECH_LANGUAGE`、`SPEECH_USE_ITN`、`SPEECH_NUM_THREADS`、`SPEECH_PROVIDER` 与 `SPEECH_VAD_*` 覆盖;`SPEECH_WAKE_WORD_ENABLED` 控制唤醒门,`SPEECH_WAKE_WORDS` 使用 `|` 分隔主唤醒词和别名。 当前 sherpa-onnx 版本支持 SenseVoice 的 `hr_lexicon`/`hr_rule_fsts`,只有同时提供经过验证的文件时才会启用:`SPEECH_HR_LEXICON` 和 `SPEECH_HR_RULE_FSTS`。未对非 transducer 的 SenseVoice 使用 hotword decoding。可插拔 `RecoveryRecognizer` 默认关闭,只为将来让 uncertain case 使用二次识别,不会下载大型模型。 diff --git a/apps/web-console/README.md b/apps/web-console/README.md index 138be75..9b99ecd 100755 --- a/apps/web-console/README.md +++ b/apps/web-console/README.md @@ -5,8 +5,10 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留 当前页面包括: - 自编排和自扩展合并为一个实时界面,登录身份固定为 `user`,不再提供模块切换。 +- AI助手默认展开;左侧控制台、车端设备状态、外设与扩展能力默认收起。四个板块统一通过页面右上角的图标按钮展开或收回,主工作区随面板状态自适应。 +- AI助手支持普通问答、工程能力核对和车控工作。能力问答读取运行时权威目录,高置信度冷热请求走确定性快速控制链,其他问题和复杂车控由 Codex App Server 流式处理;车控结果仍以自然语言和可审计的动作进度呈现。 - 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。 -- 一句话输入框支持麦克风听写,优先采集 `TAKSTAR V2`;AudioWorklet 采集、浏览器端静音停录、服务端 Silero VAD、SenseVoiceSmall、幂等 TranscriptAssembler 和结构化理解层组成完整链路。只有生成 commands 的结果才回填;无关内容不覆盖输入框,uncertain 结果需人工修改后才能执行。 +- AI助手语音入口支持双模式:手动点击麦克风按钮会立即以 `manual` 模式录音,不要求唤醒词;浏览器已授权麦克风、助手展开且页面可见时自动进入 `wake` 待机,只有听到“你好,双十一”(兼容无标点与“双11”)后才展示并继续转录。AudioWorklet 采集后,服务端 SenseVoiceSmall 以节流预览返回未语义清洗的实时文字;静音端点立即停止采音,再由 Silero VAD、幂等 TranscriptAssembler 和理解层生成最终 `assistant_text`。普通问答、未知能力、否定和撤销表达均可回填,`commands` 仅作为受信任车控旁路,不能反向过滤助手输入。 - 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。 - 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。 - USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。 @@ -14,6 +16,16 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留 UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。 +## AI助手接口 + +浏览器只使用同源接口,不直接连接 App Server、MCP 或模型服务: + +- `GET /api/assistant/status`:返回 App Server 状态、可用性和当前会话数。 +- `GET /api/assistant/capabilities`:返回由运行时注册表、模式目录、CAN 绑定和已学习外设共同生成的权威能力快照。`service_surfaces` 只提供语音、手势、NFC、自标定和自修复的状态信息,不代表助手可调用;执行资格以 `assistant_callable` 和服务端校验为准。 +- `POST /api/assistant/stream`:提交 `message`、`conversation_id`、执行模式和当前车型等上下文,返回 `application/x-ndjson`。前端消费 `message.accepted`、`work.*`、`action.*`、`answer.delta`、`answer.completed` 与 `error` 事件,以增量方式展示回复和控制进度。 + +`POST /api/internal/codex/vehicle-control` 是 App Server MCP 子进程专用的回环接口,要求一次性内部令牌且与原始用户文本绑定,不是浏览器或第三方集成接口。浏览器也不能通过响应内容扩大执行目标、切换执行模式或提交原始 CAN。 + 当前座椅阶段一控制台为无构建静态页面,由 `cognitive_agent.server` 同源提供 API 和资源。运行: ```bash @@ -27,7 +39,7 @@ python -m pip install -e ".[speech]" python tools/install_sensevoice_model.py ``` -录音只用于当前请求,不落盘;`raw_text`、`clean_text` 与结构化 Intent 分字段返回。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。 +录音只用于当前请求,不落盘;`captured_text` 保留完整声学结果,`raw_text` 在 `manual` 模式保留完整原文、在 `wake` 模式剥离唤醒词,`assistant_text` 是面向问答的轻清洗会话,`clean_text/control_text` 与结构化 Intent 则保持严格车控语义。前后端通过 `X-Speech-Input-Mode: manual|wake` 显式区分模式;可用 `SPEECH_WAKE_WORD_ENABLED=false` 关闭唤醒门,或用竖线分隔的 `SPEECH_WAKE_WORDS` 配置主唤醒词与别名。远程浏览器必须通过 HTTPS 才能访问麦克风。诊断可设置后端 `SPEECH_DEBUG=1` 并用 `?speechDebug=1` 打开前端日志,默认关闭。 Android PAD/模拟器联调使用: @@ -114,6 +126,19 @@ TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的 - `POST /api/extension/display-sync`:车机刚识别 USB 外设时上报 `device` 与 `peripherals` 能力目录,无需等待首次执行。 - `POST /api/extension/assistant/plan` 和 `/api/extension/vehicle/execute`:联动规划与车端执行结果同样写入同步快照。 +右上角“能耗监测”入口以独立固定页面展示 7 个当前可控车端设备的实时功率、 +本次监测累计能耗、总功率趋势和分设备趋势。能耗链路优先接收 +`cognitive-agent:power-telemetry` 事件,也兼容调用 +`window.updateDevicePowerTelemetry(payload)` 的现有集成方式;载荷可使用 +`devices..power_w` 或 `total_power_w`,并可附带 `energy_wh`、 +`trip_energy_kwh` 或 `today_energy_kwh`。累计能耗独立保留,瞬时功率超过 15 秒 +未更新时只会回退到状态估算,不会让已上报的车端累计值消失。每条遥测应显式提供 +`source: "vehicle_power_sensor"`(实测)或 `source: "estimated"`(估算)。 +当前 CAN 状态协议没有独立电流/ +功率电表信号,因此未注入新鲜功率数据的设备会显示带 `≈` 和“状态估算”标签的 +确定性实时估算,绝不标记为车端实测。真实功率通道接入后页面会逐设备自动切换为 +“实测”,无需更换页面或曲线实现。 + 公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入 `/login`,主控制台提供退出按钮。固定入口是 `https://511.kendeji.fun`,应用容器只监听服务器回环地址,再由 Caddy 提供 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 1362e6e..41726d4 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -74,6 +74,8 @@ const state = { feedbackSignals: { passenger: false, vehicle: false }, pendingModeProfileId: null, modeRunTimer: null, + modeHighlightTimer: null, + latestModeRefreshKey: "", modeEditor: null, directCanCommandPending: false, }; @@ -1081,7 +1083,7 @@ function createTwinCabin(THREE, scene) { opacity: 0.56, }); const roofEntityEdge = new THREE.LineBasicMaterial({ - color: 0x086861, + color: 0x2879d8, transparent: true, opacity: 0.98, depthTest: true, @@ -1202,8 +1204,8 @@ function createTwinCabin(THREE, scene) { rearGlass.renderOrder = 4; const roofGlassMaterial = glassMaterial.clone(); - roofGlassMaterial.color.setHex(0x258f89); - roofGlassMaterial.emissive.setHex(0x124c49); + roofGlassMaterial.color.setHex(0x4b9bea); + roofGlassMaterial.emissive.setHex(0x164f91); roofGlassMaterial.emissiveIntensity = 0.38; roofGlassMaterial.transmission = 0; roofGlassMaterial.transparent = true; @@ -1222,8 +1224,8 @@ function createTwinCabin(THREE, scene) { roofGlass.castShadow = false; roofGlass.renderOrder = 7; const roofFrameMaterial = new THREE.MeshStandardMaterial({ - color: 0x086861, - emissive: 0x124c49, + color: 0x1b6fc4, + emissive: 0x164f91, emissiveIntensity: 0.38, roughness: 0.24, metalness: 0.62, @@ -1556,9 +1558,9 @@ function createTwinCabin(THREE, scene) { ventMeshes, airflow, colors: { - ambientTarget: new THREE.Color(0x74d9ff), - ambientIdle: new THREE.Color(0x17323a), - climateTarget: new THREE.Color(0x55bdec), + ambientTarget: new THREE.Color(0x6fb6ff), + ambientIdle: new THREE.Color(0x1f3f65), + climateTarget: new THREE.Color(0x5da7ee), roofClear: new THREE.Color(0x1f68b6), roofDark: new THREE.Color(0x071b3e), }, @@ -1605,35 +1607,51 @@ function createTwinCabin(THREE, scene) { const TWIN_THEME_PALETTES = { user: { - accent: 0x0e7773, - accentStrong: 0x095d5a, - accentSoft: 0xd8f4f1, - sceneBackground: 0xe8f2ef, - floor: 0xdceae6, - gridStrong: 0x69a49e, - gridSoft: 0xb9cfca, - fillLight: 0x9fd8cf, - vehicleOutline: 0x0e7773, - vehicleOccluded: 0x91b9b4, - seatShell: 0x0e7773, - seatInsert: 0xbfe9e4, - seatThermal: 0x79ccc4, - seatBolster: 0x095d5a, - seatStructure: 0x183f3d, - seatEdge: 0x62d2c8, - seatSeam: 0xd8f4f1, - glass: 0x258f89, - glassEmissive: 0x124c49, - glassEdge: 0x086861, - glassGlow: 0x49e1d6, - roofClear: 0x4aa9a3, - roofDark: 0x0a5552, - lamp: 0x087b74, - lampEmissive: 0x16aaa1, - lampEdge: 0x096d67, + accent: 0x2f80ed, + accentStrong: 0x1d5fbf, + accentSoft: 0xdbeeff, + sceneBackground: 0xeff6ff, + floor: 0xe5f0fd, + gridStrong: 0x75a9e8, + gridSoft: 0xc7daf2, + fillLight: 0xb7d9ff, + vehicleBody: 0x91c6f5, + vehicleDetail: 0x77a9d8, + vehicleInterior: 0x4d79a6, + vehicleTrim: 0x284c70, + vehicleMetal: 0x93b9da, + vehicleOutline: 0x2879d8, + vehicleOccluded: 0x9abbdc, + seatShell: 0x2b76c9, + seatInsert: 0xcfe7ff, + seatThermal: 0x72b5f5, + seatBolster: 0x1b5fae, + seatStructure: 0x25476e, + seatEdge: 0x69b4ff, + seatSeam: 0xe4f2ff, + glass: 0x4b9bea, + glassEmissive: 0x164f91, + glassEdge: 0x1b6fc4, + glassGlow: 0x79c7ff, + roofClear: 0x67aef4, + roofDark: 0x183b70, + lamp: 0x2e7fd4, + lampEmissive: 0x48a4f2, + lampEdge: 0x1e6bb8, }, }; +const TWIN_DEVICE_HOTSPOT_LAYOUT = Object.freeze({ + windows: { normalized: [0.08, 0.62, 0.54], labelOffset: [-115, 0] }, + shade: { normalized: [0.5, 0.94, 0.43], labelOffset: [0, -65] }, + driver: { normalized: [0.68, 0.55, 0.52], labelOffset: [110, -70] }, + passenger: { normalized: [0.32, 0.48, 0.58], labelOffset: [-105, 55] }, + reading: { normalized: [0.28, 0.86, 0.64], labelOffset: [-120, -70] }, + ambient: { normalized: [0.28, 0.52, 0.78], labelOffset: [-30, 85] }, + headlight: { normalized: [0.18, 0.4, 0.98], labelOffset: [15, 70] }, + climate: { normalized: [0.38, 0.55, 0.78], labelOffset: [94, 5] }, +}); + function createTwinGroundTexture(THREE, palette, size = 768) { const textureCanvas = document.createElement("canvas"); textureCanvas.width = size; @@ -2421,61 +2439,14 @@ function setTwinSelection(selected) { material.needsUpdate = true; }); }); - twin.view.targetX = focusedSeat === "driver" ? 0.34 : -0.34; - twin.view.yaw = focusedSeat === "driver" ? -0.48 : -0.72; + // Seat selection changes the highlighted actuator only. Keeping the camera + // centered on the vehicle prevents the default passenger selection from + // pushing the whole-car twin to the right. + twin.view.targetX = 0; + twin.view.yaw = -0.62; renderTwinReadout(); } -function renderTwinCalloutValues(device) { - const value = (axis, fallback) => Number(device?.position?.[axis]?.position_pct ?? fallback); - const angle = (axis, fallbackPosition) => Number( - device?.position?.[axis]?.angle_deg - ?? twinAngleForPosition(axis, value(axis, fallbackPosition)), - ); - const longitudinal = value("longitudinal", 50); - const cushion = value("cushion_tilt", 50); - const height = value("height", 50); - const backrest = value("backrest_recline", 20); - const legrest = value("legrest_height", 0); - const extension = value("legrest_extension", 0); - const postureAngles = twinPostureAngles(device); - const explicitAngles = state.seatTwin?.explicitAngleTargets[ - state.seatTwin.selected - ] || {}; - const backrestEstimated = isEstimatedMotion(device, "backrest_recline"); - const legrestEstimated = isEstimatedMotion(device, "legrest_height"); - const formatAngle = (number) => ( - Number.isInteger(number) ? String(number) : number.toFixed(1) - ); - const values = { - longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "前移区" : longitudinal > 55 ? "后移区" : "中位区"], - cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`], - height: [`${height}%`, "CH1 高度电机"], - backrest_recline: [ - `${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`, - `${backrestEstimated ? "估算 " : ""}${backrest}% · 相对归零位`, - ], - legrest_height: [ - `${legrestEstimated ? "≈" : ""}${formatAngle(postureAngles.legrest_height)}°`, - `${legrestEstimated ? "估算 " : ""}${legrest}% · 相对垂直零位`, - ], - legrest_extension: [`${extension}%`, "伸缩行程"], - backrest_included_angle: [ - `${backrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.backrest_recline ?? angle("backrest_recline", 20))}°`, - backrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角", - ], - legrest_included_angle: [ - `${legrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.legrest_height ?? angle("legrest_height", 0))}°`, - legrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角", - ], - }; - Object.entries(values).forEach(([axis, [primary, secondary]]) => { - const callout = $(`[data-callout="${axis}"]`); - $("b", callout).textContent = primary; - $("small", callout).textContent = secondary; - }); -} - function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) { const device = actuatorState?.[seat] || {}; const position = device.position || {}; @@ -2540,11 +2511,6 @@ function renderTwinReadout() { row.style.setProperty("--value", `${Math.max(0, Math.min(100, position))}%`); row.classList.toggle("fault", Boolean(device?.position?.[axis]?.fault)); }); - $$('[data-callout]').forEach((callout) => { - const axis = callout.dataset.callout; - if (axis?.endsWith("included_angle")) return; - callout.hidden = !supportedAxes.has(axis); - }); ["ventilation", "heating"].forEach((feature) => { const value = device?.[feature] || { state: "off", level: 0 }; const item = $(`[data-twin-function="${feature}"]`); @@ -2554,7 +2520,6 @@ function renderTwinReadout() { item.dataset.level = String(level); $("b", item).textContent = enabled ? `${level}档` : "关闭"; }); - renderTwinCalloutValues(device); } function updateTwinExplicitAngles(result) { @@ -2646,43 +2611,43 @@ function classifyReferenceMaterial(material) { const TWIN_OUTLINE_STYLE = { body: { - color: 0x315d69, - lineOpacity: 0.48, - occludedColor: 0xa7b8bb, - occludedLineOpacity: 0.1, - fillOpacity: 0.11, + color: 0x2a6fb8, + lineOpacity: 0.9, + occludedColor: 0xa9bfd7, + occludedLineOpacity: 0.13, + fillOpacity: 0.3, thresholdAngle: 38, renderOrder: 30, }, glass: { - color: 0x527c85, - lineOpacity: 0.38, + color: 0x4d83bd, + lineOpacity: 0.76, occludedColor: 0xa5b8bb, - occludedLineOpacity: 0.06, - fillOpacity: 0.045, + occludedLineOpacity: 0.08, + fillOpacity: 0.16, thresholdAngle: 38, renderOrder: 31, }, detail: { - color: 0x536f76, - lineOpacity: 0.16, + color: 0x5a7695, + lineOpacity: 0.5, occludedColor: 0xa8b7b9, - occludedLineOpacity: 0.035, - fillOpacity: 0.028, + occludedLineOpacity: 0.05, + fillOpacity: 0.1, thresholdAngle: 46, renderOrder: 32, }, wheel: { - color: 0x203d45, - lineOpacity: 0.52, + color: 0x284665, + lineOpacity: 0.84, occludedColor: 0x9eafb2, occludedLineOpacity: 0.14, - fillOpacity: 0.18, + fillOpacity: 0.3, thresholdAngle: 50, renderOrder: 33, }, - interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 }, - light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, renderOrder: 32 }, + interior: { color: 0x667f99, lineOpacity: 0, fillOpacity: 0.028, renderOrder: 36 }, + light: { color: 0x5a7898, lineOpacity: 0, fillOpacity: 0.12, renderOrder: 32 }, }; function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) { @@ -2919,7 +2884,86 @@ function visibleTwinMeshBounds(THREE, root) { return bounds; } +function twinObjectCollectionCenter(THREE, objects, target) { + const bounds = new THREE.Box3(); + const objectBounds = new THREE.Box3(); + let found = false; + objects.filter(Boolean).forEach((item) => { + const object = item.mesh || item; + if (!object?.isObject3D || !object.visible) return; + object.updateWorldMatrix(true, false); + objectBounds.setFromObject(object); + if (objectBounds.isEmpty()) return; + bounds.union(objectBounds); + found = true; + }); + if (!found || bounds.isEmpty()) return false; + bounds.getCenter(target); + return true; +} + +function twinDeviceWorldAnchor(THREE, twin, deviceId, target) { + const layout = TWIN_DEVICE_HOTSPOT_LAYOUT[deviceId]; + if (!layout) return false; + + if (["driver", "passenger"].includes(deviceId)) { + const axis = deviceId === "driver" ? "backrest_recline" : "cushion_tilt"; + const seatAnchor = twin.models?.[deviceId]?.anchors?.[axis]; + if (seatAnchor) { + seatAnchor.getWorldPosition(target); + return true; + } + } + + if (deviceId === "shade" && twin.vehicleSystems?.roofGlass) { + twin.vehicleSystems.roofGlass.getWorldPosition(target); + return true; + } + + if (deviceId === "windows") { + const nearSide = twin.camera.position.x < 0 ? "right" : "left"; + const panes = [ + ...(twin.vehicleSystems?.referenceWindowPanes?.[`front_${nearSide}`] || []), + ...(twin.vehicleSystems?.referenceWindowPanes?.[`rear_${nearSide}`] || []), + ]; + if (twinObjectCollectionCenter(THREE, panes, target)) return true; + } + + if (deviceId === "ambient") { + const ambientMeshes = twin.vehicleSystems?.ambientMeshes || []; + if (twinObjectCollectionCenter(THREE, ambientMeshes, target)) return true; + } + + if (deviceId === "climate") { + const dashboardVents = (twin.vehicleSystems?.ventMeshes || []).slice(0, 4); + if (twinObjectCollectionCenter(THREE, dashboardVents, target)) return true; + } + + if (deviceId === "headlight") { + const lamps = twin.vehicleSystems?.headlampMeshes || []; + const nearLamp = twin.camera.position.x < 0 ? lamps[0] : lamps[lamps.length - 1]; + if (nearLamp) { + nearLamp.getWorldPosition(target); + return true; + } + } + + const bounds = twin.referenceBounds?.isEmpty?.() === false + ? twin.referenceBounds + : visibleTwinMeshBounds(THREE, twin.vehicleSystems.root); + if (!bounds || bounds.isEmpty()) return false; + const size = bounds.getSize(new THREE.Vector3()); + const [x, y, z] = layout.normalized; + target.set( + bounds.min.x + size.x * x, + bounds.min.y + size.y * y, + bounds.min.z + size.z * z, + ); + return true; +} + function configureReferenceVehicleModel(THREE, vehicle) { + const palette = TWIN_THEME_PALETTES.user; const materialCache = new Map(); const report = { meshes: 0, materials: new Map(), nodes: [] }; vehicle.traverse((object) => { @@ -2954,7 +2998,7 @@ function configureReferenceVehicleModel(THREE, vehicle) { if ("emissiveIntensity" in material) material.emissiveIntensity = 0; } if (category === "glass") { - if (material.color) material.color.setHex(0x55727b); + if (material.color) material.color.setHex(palette.glass); material.transparent = true; material.opacity = 0.3; material.depthWrite = false; @@ -2962,26 +3006,26 @@ function configureReferenceVehicleModel(THREE, vehicle) { object.castShadow = false; object.renderOrder = 12; } else if (category === "body") { - if (material.color) material.color.setHex(0x829398); + if (material.color) material.color.setHex(palette.vehicleBody); if ("shininess" in material) material.shininess = 72; } else if (category === "light") { material.map = null; - if (material.color) material.color.setHex(0x697b80); + if (material.color) material.color.setHex(palette.lamp); if (material.emissive) material.emissive.setHex(0x000000); if ("emissiveIntensity" in material) material.emissiveIntensity = 0; } else if (category === "interior" && material.color) { - material.color.setHex(0x6c7778); + material.color.setHex(palette.vehicleInterior); } else if (/lines|\badd\b/.test(descriptor) && material.color) { material.map = null; - material.color.setHex(0x4c5a5d); + material.color.setHex(palette.vehicleTrim); } else if (/map[_ -]?[abc]/.test(descriptor) && material.color) { - if (/chrome|metal/.test(descriptor)) material.color.setHex(0xaeb9bb); - else if (/plastic/.test(descriptor)) material.color.setHex(0x303a3e); - else material.color.setHex(0x7f8e91); + if (/chrome|metal/.test(descriptor)) material.color.setHex(palette.vehicleMetal); + else if (/plastic/.test(descriptor)) material.color.setHex(palette.vehicleTrim); + else material.color.setHex(palette.vehicleDetail); } else if (/plastic|under|black/.test(descriptor) && material.color) { - material.color.setHex(0x303a3e); + material.color.setHex(palette.vehicleTrim); } else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) { - material.color.setHex(0xaeb9bb); + material.color.setHex(palette.vehicleMetal); } material.transparent = true; material.opacity = outlineStyle.fillOpacity; @@ -3342,8 +3386,8 @@ function emphasizeReferenceWindowPanes(THREE, panes) { const materials = Array.isArray(pane.mesh.material) ? pane.mesh.material : [pane.mesh.material]; const emphasizedMaterials = materials.filter(Boolean).map((source) => { const material = source.clone(); - if (material.color) material.color.setHex(0x258f89); - if (material.emissive) material.emissive.setHex(0x124c49); + if (material.color) material.color.setHex(0x4b9bea); + if (material.emissive) material.emissive.setHex(0x164f91); if ("emissiveIntensity" in material) material.emissiveIntensity = 0.38; material.transparent = true; material.opacity = 0.6; @@ -3360,7 +3404,7 @@ function emphasizeReferenceWindowPanes(THREE, panes) { pane.mesh.renderOrder = 58; pane.materials = emphasizedMaterials; pane.outline = addTwinMeshOutline(THREE, pane.mesh, { - color: 0x086861, + color: 0x1b6fc4, lineOpacity: 0.98, fillOpacity: 0.6, renderOrder: 59, @@ -3549,8 +3593,8 @@ function emphasizeReferenceFrontLampMeshes(THREE, meshes) { const emphasizedMaterials = sourceMaterials.map((source, index) => { if (!source || !selectedIndexes.has(index)) return source; const material = source.clone(); - if (material.color) material.color.setHex(0x087b74); - if (material.emissive) material.emissive.setHex(0x16aaa1); + if (material.color) material.color.setHex(0x2e7fd4); + if (material.emissive) material.emissive.setHex(0x48a4f2); if ("emissiveIntensity" in material) material.emissiveIntensity = 0.95; material.transparent = true; material.opacity = 0.9; @@ -3587,7 +3631,7 @@ function createReferenceFrontLampHighlights(THREE, meshes) { } const selectedGeometry = makeTriangleSubsetGeometry(THREE, source, selectedVertices); const material = new THREE.MeshBasicMaterial({ - color: 0x087b74, + color: 0x2e7fd4, transparent: true, opacity: 0.92, depthWrite: false, @@ -3605,7 +3649,7 @@ function createReferenceFrontLampHighlights(THREE, meshes) { highlight.receiveShadow = false; mesh.parent.add(highlight); addTwinMeshOutline(THREE, highlight, { - color: 0x003f3b, + color: 0x124a86, lineOpacity: 1, fillOpacity: 0.92, renderOrder: 47, @@ -3633,7 +3677,7 @@ function createReferenceFrontLampHighlights(THREE, meshes) { haloGeometry.computeBoundingBox(); haloGeometry.computeBoundingSphere(); const haloMaterial = new THREE.MeshBasicMaterial({ - color: 0x49e1d6, + color: 0x79c7ff, transparent: true, opacity: 0.38, depthWrite: false, @@ -3678,7 +3722,7 @@ function createReferenceFrontLampLensOutlines(THREE, vehicle) { const lens = object.clone(false); lens.name = `${object.name}_twin_lens_contour`; lens.material = new THREE.MeshBasicMaterial({ - color: 0x1b8f88, + color: 0x2d7fd0, transparent: true, opacity: 0.045, depthWrite: false, @@ -3692,7 +3736,7 @@ function createReferenceFrontLampLensOutlines(THREE, vehicle) { lens.receiveShadow = false; object.parent.add(lens); const outline = addTwinMeshOutline(THREE, lens, { - color: 0x096d67, + color: 0x1e6bb8, lineOpacity: 0.96, fillOpacity: 0.045, renderOrder: 44, @@ -3864,8 +3908,8 @@ function initSeatTwin() { renderer.shadowMap.type = THREE.PCFSoftShadowMap; const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xe8eeec); - scene.fog = new THREE.Fog(0xe8eeec, 12, 19); + scene.background = new THREE.Color(0xeff6ff); + scene.fog = new THREE.Fog(0xeff6ff, 12, 19); const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40); const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2); hemisphereLight.layers.enable(1); @@ -3880,7 +3924,7 @@ function initSeatTwin() { keyLight.shadow.camera.top = 6; keyLight.shadow.camera.bottom = -4; scene.add(keyLight); - const fillLight = new THREE.DirectionalLight(0x9fc7d7, 1.15); + const fillLight = new THREE.DirectionalLight(0xb7d9ff, 1.15); fillLight.layers.enable(1); fillLight.position.set(5, 3, -5); scene.add(fillLight); @@ -3902,7 +3946,7 @@ function initSeatTwin() { scene.add(floor); const vehicleSystems = createTwinCabin(THREE, scene); applyTwinOutlineStyle(THREE, vehicleSystems.root, { - color: 0x4f747c, + color: 0x5a7898, lineOpacity: 0.46, fillOpacity: 0.012, renderOrder: 40, @@ -3958,7 +4002,7 @@ function initSeatTwin() { rear: createTwinRearBench(THREE), }; applyTwinOutlineStyle(THREE, fixedModels.rear, { - color: 0x5e7479, + color: 0x627b97, lineOpacity: 0.42, fillOpacity: 0.01, renderOrder: 41, @@ -4148,54 +4192,59 @@ function initSeatTwin() { $$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat))); const degrees = THREE.MathUtils.degToRad; - const projectedPosition = new THREE.Vector3(); - function positionTwinCallouts() { + const hotspotWorldAnchor = new THREE.Vector3(); + const projectedHotspot = new THREE.Vector3(); + const clampLabelCoordinate = (value, minimum, maximum) => ( + maximum < minimum ? (minimum + maximum) / 2 : Math.max(minimum, Math.min(maximum, value)) + ); + + function positionTwinDeviceHotspots() { const twin = state.seatTwin; - const model = twin.models[twin.selected]; + const hotspotLayer = $("#v2TwinHotspots"); + if (!twin || !hotspotLayer) return; const width = canvas.clientWidth; const height = canvas.clientHeight; + const labelMargin = width < 520 ? 8 : 12; + const offsetScale = THREE.MathUtils.clamp(width / 620, 0.72, 1); scene.updateMatrixWorld(true); camera.updateMatrixWorld(); - Object.entries(model.anchors).forEach(([axis, anchor]) => { - anchor.getWorldPosition(projectedPosition); - projectedPosition.project(camera); - const callout = $(`[data-callout="${axis}"]`); - const includedAxis = { - backrest_included_angle: "backrest_recline", - legrest_included_angle: "legrest_height", - }[axis]; - const supportedAxes = twinAxesForSeat(twin.selected); - const supported = supportedAxes.has(includedAxis || axis); - const requested = ( - supported - && (!includedAxis - || twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null) - ); + Object.entries(TWIN_DEVICE_HOTSPOT_LAYOUT).forEach(([deviceId, layout]) => { + const button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer); + if (!button || !twinDeviceWorldAnchor(THREE, twin, deviceId, hotspotWorldAnchor)) return; + projectedHotspot.copy(hotspotWorldAnchor).project(camera); + const anchorX = (projectedHotspot.x * 0.5 + 0.5) * width; + const anchorY = (-projectedHotspot.y * 0.5 + 0.5) * height; const visible = ( - requested - && projectedPosition.z > -1 - && projectedPosition.z < 1 + projectedHotspot.z > -1 + && projectedHotspot.z < 1 + && anchorX >= 0 + && anchorX <= width + && anchorY >= 0 + && anchorY <= height ); - callout.hidden = !visible; + button.hidden = !visible; if (!visible) return; - const anchorX = (projectedPosition.x * 0.5 + 0.5) * width; - const anchorY = (-projectedPosition.y * 0.5 + 0.5) * height; - const offset = { - longitudinal: { x: -104, y: 50 }, - backrest_recline: { x: -108, y: -28 }, - cushion_tilt: { x: 106, y: -4 }, - legrest_height: { x: 112, y: 52 }, - backrest_included_angle: { x: -112, y: 42 }, - legrest_included_angle: { x: 114, y: 78 }, - }[axis] || { x: 0, y: 0 }; - const halfWidth = Math.max(46, callout.offsetWidth / 2); - const halfHeight = Math.max(22, callout.offsetHeight / 2); - const x = Math.max(halfWidth + 10, Math.min(width - halfWidth - 10, anchorX + offset.x)); - const y = Math.max(halfHeight + 10, Math.min(height - halfHeight - 10, anchorY + offset.y)); - callout.style.left = `${x}px`; - callout.style.top = `${y}px`; - const dx = anchorX - x; - const dy = anchorY - y; + button.style.left = `${anchorX}px`; + button.style.top = `${anchorY}px`; + button.dataset.anchorSource = "projected-3d"; + + const label = $(".v2-twin-hotspot-label", button); + const halfWidth = Math.max(44, (label?.offsetWidth || 94) / 2); + const halfHeight = Math.max(20, (label?.offsetHeight || 42) / 2); + const preferredX = anchorX + layout.labelOffset[0] * offsetScale; + const preferredY = anchorY + layout.labelOffset[1] * offsetScale; + const labelX = clampLabelCoordinate( + preferredX, + labelMargin + halfWidth, + width - labelMargin - halfWidth, + ); + const labelY = clampLabelCoordinate( + preferredY, + labelMargin + halfHeight, + height - labelMargin - halfHeight, + ); + const dx = labelX - anchorX; + const dy = labelY - anchorY; const distance = Math.max(1, Math.hypot(dx, dy)); const unitX = dx / distance; const unitY = dy / distance; @@ -4205,14 +4254,15 @@ function initSeatTwin() { const verticalBoundary = Math.abs(unitY) > 0.001 ? halfHeight / Math.abs(unitY) : Number.POSITIVE_INFINITY; - const startDistance = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary) + 3); - callout.style.setProperty("--leader-start-x", `${unitX * startDistance}px`); - callout.style.setProperty("--leader-start-y", `${unitY * startDistance}px`); - callout.style.setProperty("--leader-length", `${Math.max(0, distance - startDistance - 2)}px`); - callout.style.setProperty("--leader-angle", `${Math.atan2(dy, dx)}rad`); - callout.style.setProperty("--leader-dx", `${dx}px`); - callout.style.setProperty("--leader-dy", `${dy}px`); + const labelBoundary = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary)); + button.style.setProperty("--v2-hotspot-label-x", `${dx}px`); + button.style.setProperty("--v2-hotspot-label-y", `${dy}px`); + button.style.setProperty("--v2-hotspot-line-length", `${Math.max(0, distance - labelBoundary + 1)}px`); + button.style.setProperty("--v2-hotspot-line-angle", `${Math.atan2(dy, dx)}rad`); }); + hotspotLayer.dataset.anchorLayout = "projected-3d"; + canvas.dataset.hotspotLayout = "projected-3d"; + window.CognitiveAgentConsoleV2?.positionTwinDevicePopover?.(); } function animate(timestamp = 0) { @@ -4272,9 +4322,9 @@ function initSeatTwin() { // of view. The desktop twin is often almost portrait-shaped, where a // fixed narrow-viewport multiplier still clips the front bumper. const viewportFit = THREE.MathUtils.clamp( - 1.24 / Math.max(0.72, camera.aspect), + 1.28 / Math.max(0.64, camera.aspect), 1, - 1.4, + 1.9, ); const cameraDistance = view.distance * viewportFit; const horizontal = Math.cos(view.pitch) * cameraDistance; @@ -4286,7 +4336,8 @@ function initSeatTwin() { ); camera.lookAt(cameraTargetX, 1.14, -0.08); updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera); - positionTwinCallouts(); + positionTwinDeviceHotspots(); + canvas.dataset.framing = "whole-vehicle-centered"; renderer.clear(); camera.layers.set(0); renderer.render(scene, camera); @@ -4604,7 +4655,7 @@ function applySeatMotorAvailability(vehicle) { state.availableSeatAxes = availableAxes; Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => { const available = availableAxes.has(axis) || axis === "height"; - $$(`[data-axis="${axis}"], [data-twin-axis="${axis}"], [data-callout="${axis}"]`) + $$(`[data-axis="${axis}"], [data-twin-axis="${axis}"]`) .forEach((item) => { item.classList.toggle("seat-axis-unavailable", !available); item.setAttribute("aria-hidden", String(!available)); @@ -4617,13 +4668,9 @@ function applySeatMotorAvailability(vehicle) { legrest_height: "腿托夹角", legrest_extension: "腿托伸缩", }[axis]; - $$(`[data-twin-axis="${axis}"] > span, [data-callout="${axis}"] > span`) + $$(`[data-twin-axis="${axis}"] > span`) .forEach((label) => { label.textContent = twinLabel; }); }); - const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span'); - const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span'); - if (backrestIncludedLabel) backrestIncludedLabel.textContent = "靠背-坐垫夹角"; - if (legrestIncludedLabel) legrestIncludedLabel.textContent = "坐垫-腿托夹角"; } function renderSeatMotorRows(vehicle) { @@ -4715,6 +4762,17 @@ function modeChipLabel(mode) { : name; } +function modeChipSummary(mode) { + const domains = Array.isArray(mode?.domains) ? mode.domains : []; + const summary = domains.slice(0, 2) + .map((domain) => MODE_DOMAIN_LABELS[domain] || domain) + .join(" · "); + if (summary) return summary; + return isUserModeProfile(mode) + ? "自定义联动" + : String(mode?.description || "预设模式"); +} + function modeSecondsLabel(durationMs) { const seconds = Number(durationMs) / 1000; return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1); @@ -4722,26 +4780,16 @@ function modeSecondsLabel(durationMs) { const MODE_GALLERY_GROUPS = [ { - id: "personal", - title: "我的模式", - description: "根据自然语言指令创建,可执行或删除", - icon: "bookmark", - modeIds: [], - userOnly: true, - }, - { - id: "frequent", - title: "常用模式", - description: "高频座舱场景,可从主界面快捷执行", - icon: "sparkles", - modeIds: ["zero_gravity", "office", "nap", "queen_passenger_driving", "leisure_lounger"], - }, - { - id: "comfort", - title: "舒享模式", - description: "休憩、冥想与乘坐舒适场景", - icon: "armchair", - modeIds: [ + id: "static", + title: "静态模式", + description: "一次执行并保持目标状态,包含预设与自定义模式", + icon: "layout-grid", + preferredIds: [ + "zero_gravity", + "office", + "nap", + "queen_passenger_driving", + "leisure_lounger", "cruise_sofa", "easy_entry_exit", "electric_beach_chair", @@ -4753,17 +4801,20 @@ const MODE_GALLERY_GROUPS = [ { id: "dynamic", title: "动态模式", - description: "按受控节拍持续调整座椅姿态", + description: "按受控节拍持续变化,可随时停止或由保护时限结束", icon: "waves", - modeIds: ["rocking_horse", "dynamic_cradle"], + preferredIds: ["rocking_horse", "dynamic_cradle"], }, ]; +const LEGACY_DYNAMIC_MODE_IDS = new Set(["rocking_horse", "dynamic_cradle"]); + const MODE_DOMAIN_LABELS = { seat: "座椅", climate: "空调", roof: "天幕", ambient_light: "氛围灯", + interior_light: "座舱灯", window: "车窗", front_light: "车灯", vehicle_status: "车辆状态", @@ -4773,26 +4824,42 @@ function isUserModeProfile(mode) { return mode?.origin === "user" || mode?.deletable === true; } +function isDynamicModeProfile(mode) { + const behavior = String(mode?.behavior || "").trim().toLowerCase(); + if (["cyclic", "dynamic"].includes(behavior)) return true; + if (behavior === "static") return false; + if (mode?.timeline && typeof mode.timeline === "object") return true; + return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || "")); +} + +function orderModeGalleryProfiles(modes, preferredIds) { + const preferredOrder = new Map(preferredIds.map((modeId, index) => [modeId, index])); + return modes + .map((mode, sourceIndex) => ({ mode, sourceIndex })) + .sort((left, right) => { + const leftUser = isUserModeProfile(left.mode); + const rightUser = isUserModeProfile(right.mode); + if (leftUser !== rightUser) return leftUser ? -1 : 1; + const leftRank = preferredOrder.get(left.mode?.id) ?? Number.MAX_SAFE_INTEGER; + const rightRank = preferredOrder.get(right.mode?.id) ?? Number.MAX_SAFE_INTEGER; + return leftRank - rightRank || left.sourceIndex - right.sourceIndex; + }) + .map(({ mode }) => mode); +} + function modeGalleryBuckets() { const modes = modeProfiles(); - const assignedIds = new Set(MODE_GALLERY_GROUPS.flatMap((group) => group.modeIds)); - return MODE_GALLERY_GROUPS.map((definition) => { - let groupModes; - if (definition.userOnly) { - groupModes = modes.filter((mode) => mode?.origin === "user"); - } else if (definition.id === "comfort") { - groupModes = definition.modeIds - .map((modeId) => modes.find((mode) => mode.id === modeId)) - .filter(Boolean); - modes.filter((mode) => !isUserModeProfile(mode) && !assignedIds.has(mode.id)) - .forEach((mode) => groupModes.push(mode)); - } else { - groupModes = definition.modeIds - .map((modeId) => modes.find((mode) => mode.id === modeId)) - .filter(Boolean); - } - return { ...definition, modes: groupModes }; - }).filter((group) => group.modes.length > 0 || group.userOnly); + return MODE_GALLERY_GROUPS.map((definition) => ({ + ...definition, + modes: orderModeGalleryProfiles( + modes.filter((mode) => ( + definition.id === "dynamic" + ? isDynamicModeProfile(mode) + : !isDynamicModeProfile(mode) + )), + definition.preferredIds, + ), + })); } function createModeGalleryCard(mode) { @@ -4888,14 +4955,14 @@ function createModeGalleryAddCard() { const heading = document.createElement("span"); heading.className = "mode-gallery-card-heading"; const name = document.createElement("strong"); - name.textContent = "新增模式"; + name.textContent = "用 AI 新建"; const stateLabel = document.createElement("small"); stateLabel.className = "mode-gallery-state"; stateLabel.textContent = "点击创建"; heading.append(name, stateLabel); const description = document.createElement("span"); description.className = "mode-gallery-card-description"; - description.textContent = "用自然语言描述需要联动的车辆功能"; + description.textContent = "在 AI助手中描述需要联动的车辆功能"; const meta = document.createElement("span"); meta.className = "mode-gallery-card-meta"; meta.textContent = "解析 · 校验 · 生成控制指令"; @@ -4905,6 +4972,25 @@ function createModeGalleryAddCard() { return shell; } +function createModeGalleryEmpty(group) { + const empty = document.createElement("div"); + empty.className = "mode-gallery-empty"; + empty.dataset.modeGroupEmpty = group.id; + empty.setAttribute("role", "status"); + const icon = document.createElement("i"); + icon.dataset.lucide = group.id === "dynamic" ? "activity" : "layout-grid"; + const copy = document.createElement("span"); + const title = document.createElement("strong"); + title.textContent = `暂无${group.title}`; + const description = document.createElement("small"); + description.textContent = group.id === "dynamic" + ? "支持持续变化的模式会显示在这里" + : "可通过 AI助手创建新的静态模式"; + copy.append(title, description); + empty.append(icon, copy); + return empty; +} + function populateModeGallery() { const container = $("#modeGalleryGroups"); if (!container) return; @@ -4913,12 +4999,15 @@ function populateModeGallery() { const section = document.createElement("section"); section.className = "mode-gallery-group"; section.dataset.modeGroup = group.id; + const headingId = `modeGalleryGroup-${group.id}`; + section.setAttribute("aria-labelledby", headingId); const header = document.createElement("header"); const title = document.createElement("span"); const titleIcon = document.createElement("i"); titleIcon.dataset.lucide = group.icon; const titleCopy = document.createElement("span"); const strong = document.createElement("strong"); + strong.id = headingId; strong.textContent = group.title; const small = document.createElement("small"); small.textContent = group.description; @@ -4931,8 +5020,9 @@ function populateModeGallery() { const grid = document.createElement("div"); grid.className = "mode-gallery-grid"; - if (group.id === "personal") grid.append(createModeGalleryAddCard()); + if (group.id === "static") grid.append(createModeGalleryAddCard()); group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode))); + if (!group.modes.length) grid.append(createModeGalleryEmpty(group)); section.append(grid); container.append(section); }); @@ -4948,18 +5038,54 @@ function setModeRailExpanded(expanded) { if (!toggle || !content) return; const open = expanded === true; toggle.setAttribute("aria-expanded", String(open)); + toggle.setAttribute("aria-label", open ? "收起功能模式" : "展开功能模式"); + toggle.title = open ? "收起功能模式" : "展开功能模式"; content.hidden = !open; + $(".mode-rail")?.classList.toggle("is-collapsed", !open); + const icon = $("i", toggle); + if (icon) icon.dataset.lucide = open ? "chevron-up" : "chevron-down"; + refreshIcons(); } function setModeRailSelection(mode) { const selection = $("#modeRailSelection"); - if (selection && mode) selection.textContent = String(mode.name || mode.id || "常用模式"); + if (selection && mode) selection.textContent = String(mode.name || mode.id || "静态模式"); +} + +function revealModeProfile(modeProfileId, { focus = false } = {}) { + if (!modeProfileId) return false; + const mode = modeProfiles().find((item) => item.id === modeProfileId); + const button = $(`.mode-chips button[data-mode-profile-id="${CSS.escape(modeProfileId)}"]`); + if (!button) return false; + setModeRailExpanded(true); + setModeRailSelection(mode); + window.clearTimeout(state.modeHighlightTimer); + $$(".mode-chips .just-created").forEach((item) => item.classList.remove("just-created")); + button.classList.add("just-created"); + button.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + if (focus) button.focus({ preventScroll: true }); + state.modeHighlightTimer = window.setTimeout(() => { + button.classList.remove("just-created"); + state.modeHighlightTimer = null; + }, 4800); + return true; +} + +function refreshModeProfileFromResult(result) { + const profile = result?.mode_profile; + const savedModeExecution = result?.orchestration_route === "saved_mode_command_flow"; + if (!profile?.id || savedModeExecution) return; + const refreshKey = `${result.request_id || "mode"}:${profile.id}`; + if (state.latestModeRefreshKey === refreshKey) return; + state.latestModeRefreshKey = refreshKey; + void loadRuntime() + .then(() => revealModeProfile(profile.id)) + .catch(() => {}); } function openModeGallery() { const backdrop = $("#modeGalleryBackdrop"); if (!backdrop) return; - setModeRailExpanded(false); backdrop.hidden = false; document.body.classList.add("mode-gallery-open"); $("#modeGalleryToggle").setAttribute("aria-expanded", "true"); @@ -5444,6 +5570,15 @@ function openModeEditorForCreate({ restoreSelector = "#saveModeToggle" } = {}) { refreshIcons(); } +function startAssistantModeCreation({ restoreSelector = "#saveModeToggle" } = {}) { + closeModeGallery({ restoreFocus: false }); + if (window.CognitiveAgentAssistant?.startModeCreation) { + window.CognitiveAgentAssistant.startModeCreation(); + return; + } + openModeEditorForCreate({ restoreSelector }); +} + function closeModeEditor({ restoreFocus = true } = {}) { const backdrop = $("#modeEditorBackdrop"); const restoreSelector = state.modeEditor?.restoreSelector; @@ -5562,6 +5697,8 @@ async function completeModeEditor() { if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`); await loadRuntime(); closeModeEditor({ restoreFocus: false }); + revealModeProfile(payload.mode_profile?.id, { focus: true }); + if (editor.isNew) window.CognitiveAgentAssistant?.announceModeCreated?.(payload.mode_profile); showToast(`${payload.mode_profile?.name || modeName}已永久保存到模式卡片`); } catch (error) { $("#modeEditorStatus").textContent = error.message || "模式保存失败。"; @@ -5576,6 +5713,109 @@ async function completeModeEditor() { } } +function installModeChipScroller() { + const group = $(".mode-chips"); + if (!group || group.dataset.modeScrollerBound === "true") return; + group.dataset.modeScrollerBound = "true"; + if (!group.hasAttribute("tabindex")) group.tabIndex = 0; + group.setAttribute("aria-label", "快捷功能模式,可使用滚轮、拖动或方向键横向浏览"); + + const maximumScroll = () => Math.max(0, group.scrollWidth - group.clientWidth); + const updateScrollState = () => { + const maximum = maximumScroll(); + group.classList.toggle("can-scroll-left", group.scrollLeft > 1); + group.classList.toggle("can-scroll-right", group.scrollLeft < maximum - 1); + group.classList.toggle("is-overflowing", maximum > 1); + }; + const scrollBy = (amount) => { + const previous = group.scrollLeft; + const next = Math.max(0, Math.min(maximumScroll(), previous + amount)); + if (Math.abs(next - previous) < 0.5) return false; + group.scrollLeft = next; + updateScrollState(); + return true; + }; + + group.addEventListener("wheel", (event) => { + if (maximumScroll() <= 1) return; + const dominantDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (!dominantDelta) return; + const multiplier = event.deltaMode === 1 + ? 18 + : event.deltaMode === 2 + ? group.clientWidth + : 1; + if (scrollBy(dominantDelta * multiplier)) event.preventDefault(); + }, { passive: false }); + + group.addEventListener("keydown", (event) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const page = Math.max(120, group.clientWidth * 0.78); + let handled = false; + if (event.key === "ArrowLeft") handled = scrollBy(-132); + else if (event.key === "ArrowRight") handled = scrollBy(132); + else if (event.key === "PageUp") handled = scrollBy(-page); + else if (event.key === "PageDown") handled = scrollBy(page); + else if (event.key === "Home") handled = scrollBy(-maximumScroll()); + else if (event.key === "End") handled = scrollBy(maximumScroll()); + if (handled) event.preventDefault(); + }); + + let pointerId = null; + let pointerStartX = 0; + let pointerStartScroll = 0; + let dragged = false; + let suppressClick = false; + group.addEventListener("pointerdown", (event) => { + if (event.pointerType !== "mouse" || event.button !== 0 || maximumScroll() <= 1) return; + pointerId = event.pointerId; + pointerStartX = event.clientX; + pointerStartScroll = group.scrollLeft; + dragged = false; + group.setPointerCapture?.(pointerId); + }); + group.addEventListener("pointermove", (event) => { + if (pointerId !== event.pointerId) return; + const distance = event.clientX - pointerStartX; + if (!dragged && Math.abs(distance) < 5) return; + dragged = true; + group.classList.add("is-dragging"); + group.scrollLeft = Math.max(0, Math.min(maximumScroll(), pointerStartScroll - distance)); + updateScrollState(); + event.preventDefault(); + }); + const finishPointerDrag = (event) => { + if (pointerId !== event.pointerId) return; + group.releasePointerCapture?.(pointerId); + pointerId = null; + group.classList.remove("is-dragging"); + if (dragged) { + suppressClick = true; + window.setTimeout(() => { suppressClick = false; }, 0); + } + dragged = false; + }; + group.addEventListener("pointerup", finishPointerDrag); + group.addEventListener("pointercancel", finishPointerDrag); + group.addEventListener("click", (event) => { + if (!suppressClick) return; + event.preventDefault(); + event.stopImmediatePropagation(); + suppressClick = false; + }, true); + + group.addEventListener("scroll", updateScrollState, { passive: true }); + new MutationObserver(() => window.requestAnimationFrame(updateScrollState)).observe( + group, + { childList: true }, + ); + if ("ResizeObserver" in window) new ResizeObserver(updateScrollState).observe(group); + window.addEventListener("resize", updateScrollState, { passive: true }); + window.requestAnimationFrame(updateScrollState); +} + function populateModeChips() { const group = $(".mode-chips"); if (!group) return; @@ -5594,12 +5834,18 @@ function populateModeChips() { button.type = "button"; const icon = document.createElement("i"); icon.dataset.lucide = mode.icon || "bookmark"; + icon.className = "mode-chip-icon"; const labelNode = document.createElement("span"); labelNode.className = "mode-chip-label"; labelNode.textContent = label; button.append(icon, labelNode); group.append(button); } + const icon = $("i[data-lucide]", button); + if (icon) { + icon.classList.add("mode-chip-icon"); + icon.dataset.lucide = mode.icon || icon.dataset.lucide || "bookmark"; + } let labelNode = $(".mode-chip-label", button) || $("span", button); if (!labelNode) { labelNode = document.createElement("span"); @@ -5611,6 +5857,20 @@ function populateModeChips() { button.dataset.modeCommand = `进入${mode.name}`; button.dataset.modeOrigin = mode?.origin || "preset"; button.setAttribute("aria-pressed", "false"); + let summary = $(".mode-chip-summary", button); + if (!summary) { + summary = document.createElement("small"); + summary.className = "mode-chip-summary"; + button.append(summary); + } + summary.textContent = modeChipSummary(mode); + let runIcon = $(".mode-chip-run", button); + if (!runIcon) { + runIcon = document.createElement("i"); + runIcon.className = "mode-chip-run"; + runIcon.dataset.lucide = "play"; + button.append(runIcon); + } const timeline = mode.timeline; let cycleBadge = $(".mode-cycle-badge", button); if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { @@ -5670,22 +5930,32 @@ function populateModeChips() { const otherStaticButtons = staticButtons.filter( (button) => !preferredStaticButtons.includes(button), ); - $$(".mode-group-divider", group).forEach((divider) => divider.remove()); - userButtons.forEach((button) => group.append(button)); - if (userButtons.length && staticButtons.length) { - const userDivider = document.createElement("span"); - userDivider.className = "mode-group-divider"; - userDivider.dataset.modeDivider = "user"; - userDivider.setAttribute("aria-hidden", "true"); - group.append(userDivider); + $$(".mode-group-divider, .mode-group-label", group).forEach((divider) => divider.remove()); + const appendGroupLabel = (label, id) => { + const groupLabel = document.createElement("span"); + groupLabel.className = "mode-group-label"; + groupLabel.dataset.modeGroupLabel = id; + groupLabel.textContent = label; + group.append(groupLabel); + }; + const appendDivider = (id) => { + const divider = document.createElement("span"); + divider.className = "mode-group-divider"; + divider.dataset.modeDivider = id; + divider.setAttribute("aria-hidden", "true"); + group.append(divider); + }; + if (userButtons.length) { + appendGroupLabel("我的模式", "user"); + userButtons.forEach((button) => group.append(button)); } + if (userButtons.length && staticButtons.length) appendDivider("user"); + if (staticButtons.length) appendGroupLabel("预设模式", "preset"); preferredStaticButtons.forEach((button) => group.append(button)); otherStaticButtons.forEach((button) => group.append(button)); if (dynamicButtons.length && staticButtons.length) { - const divider = document.createElement("span"); - divider.className = "mode-group-divider"; - divider.setAttribute("aria-hidden", "true"); - group.append(divider); + appendDivider("dynamic"); + appendGroupLabel("动态模式", "dynamic"); } dynamicButtons.forEach((button) => group.append(button)); populateModeGallery(); @@ -5838,7 +6108,6 @@ function applyModeCommand(command, modeProfileId = null) { } const mode = modeProfiles().find((item) => item.id === modeProfileId); setModeRailSelection(mode); - setModeRailExpanded(false); if (mode?.command_flow?.source_request_id) { void executeSavedModeProfile(mode); return; @@ -6235,6 +6504,13 @@ function renderValidation(issues) { } function resetSeatState() { + $$('.seat-card[data-seat]').forEach((card) => { + card.dataset.ventilationState = "unknown"; + card.dataset.ventilationLevel = "0"; + card.dataset.heatingState = "unknown"; + card.dataset.heatingLevel = "0"; + card.dataset.massageState = "unknown"; + }); $$(".thermal-states > span").forEach((item) => { item.classList.remove("on"); $("b", item).textContent = "--"; @@ -6372,7 +6648,19 @@ function renderSeatState(actuatorState) { const displayFunctions = seat === "driver" ? twinDeviceForSeat("driver", { driver: functions }) : functions; + const seatCard = $(`.seat-card[data-seat="${seat}"]`); Object.entries(displayFunctions).forEach(([feature, value]) => { + if (seatCard && ["ventilation", "heating"].includes(feature)) { + seatCard.dataset[`${feature}State`] = String(value?.state || "unknown"); + seatCard.dataset[`${feature}Level`] = String(value?.level ?? 0); + } else if (seatCard && feature === "massage") { + const mode = String(value?.mode || "off"); + const strength = String(value?.strength || "off"); + seatCard.dataset.massageState = ( + !["off", "unknown", ""].includes(mode) + || !["off", "unknown", ""].includes(strength) + ) ? "on" : "off"; + } if (feature === "position_feedback" && seat === "driver") { const status = $('.seat-card[data-seat="driver"] [data-dynamic-mode] b'); const initialized = Object.values(value.initialized || {}).filter(Boolean).length; @@ -7469,6 +7757,7 @@ function renderResult(result) { } const partiallySucceeded = result.status === "partially_succeeded"; const executionSucceeded = result.status === "succeeded" || partiallySucceeded; + const savedModeExecution = result.orchestration_route === "saved_mode_command_flow"; const domains = Object.keys(result.function_configs || {}); const domainLabels = { seat: "座椅", @@ -7520,7 +7809,7 @@ function renderResult(result) { } updateTwinExplicitAngles(result); renderSavedPresets(result); - if (result.mode_profile) loadRuntime(); + refreshModeProfileFromResult(result); renderCode(result); renderExecution(result); window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result })); @@ -7996,10 +8285,50 @@ window.CognitiveAgentConsole = { sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId); } const input = $("#utterance"); - if (input && utterance && document.activeElement !== input) input.value = String(utterance); + if (input && utterance && document.activeElement !== input + && !document.body.classList.contains("assistant-chat-enabled")) input.value = String(utterance); + const hasFullExecutionResult = Boolean( + result.execution_report + || result.function_configs + || result.scene_config + || result.validation, + ); + if (!hasFullExecutionResult) { + refreshModeProfileFromResult(result); + return true; + } renderResult(result); return true; }, + async refreshModeProfiles(modeProfileId = "") { + await loadRuntime(); + if (!modeProfileId) return true; + return revealModeProfile(String(modeProfileId), { focus: true }); + }, + async tryModeDraft(draft) { + const instruction = typeof draft === "string" + ? draft.trim() + : String(draft?.instruction || "").trim(); + if (!instruction) throw new Error("功能模式草稿缺少可试运行的指令。"); + const { payload } = await requestNaturalLanguageControl(instruction, { + executionMode: "dry_run", + clientType: "assistant_mode_trial", + provider: "local", + previewOnly: false, + }); + if (!payload) throw new Error("模拟试运行没有返回结果。"); + const actualOperationCount = Object.values(payload.function_configs || {}) + .reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0); + const expectedOperationCount = Number( + typeof draft === "object" ? draft?.operation_count : 0, + ); + if (payload.status !== "succeeded" + || (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount)) { + throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。"); + } + return payload; + }, + revealModeProfile, notify(message) { showToast(String(message)); }, @@ -8017,6 +8346,16 @@ window.addEventListener("cognitive-agent:connection", (event) => { } }); +window.addEventListener("cognitive-agent:request-complete", (event) => { + const modeProfileId = state.pendingModeProfileId; + if (!modeProfileId) return; + state.pendingModeProfileId = null; + const result = event.detail?.result; + const succeeded = !event.detail?.error + && ["succeeded", "partially_succeeded"].includes(String(result?.status || "")); + setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed"); +}); + document.addEventListener("DOMContentLoaded", async () => { initJointTwinLayout(); resetVehicleStatusDisplay(); @@ -8034,6 +8373,7 @@ document.addEventListener("DOMContentLoaded", async () => { $("#vehicleSelect").addEventListener("change", (event) => { selectVehicle(event.target.value); }); + installModeChipScroller(); $(".mode-chips").addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; const button = target?.closest("button[data-mode-command]"); @@ -8045,18 +8385,6 @@ document.addEventListener("DOMContentLoaded", async () => { $("#modeRailToggle").addEventListener("click", () => { setModeRailExpanded($("#modeRailToggle").getAttribute("aria-expanded") !== "true"); }); - document.addEventListener("pointerdown", (event) => { - const rail = $(".mode-rail"); - const target = event.target instanceof Element ? event.target : null; - if ( - rail - && target - && !rail.contains(target) - && $("#modeRailToggle").getAttribute("aria-expanded") === "true" - ) { - setModeRailExpanded(false); - } - }); $("#modeGalleryToggle").addEventListener("click", openModeGallery); $("#modeGalleryClose").addEventListener("click", () => closeModeGallery()); $("#modeGalleryBackdrop").addEventListener("click", (event) => { @@ -8067,7 +8395,7 @@ document.addEventListener("DOMContentLoaded", async () => { const createButton = target?.closest("button[data-mode-create]"); if (createButton) { event.preventDefault(); - openModeEditorForCreate({ restoreSelector: "[data-mode-create]" }); + startAssistantModeCreation({ restoreSelector: "[data-mode-create]" }); return; } const editButton = target?.closest("button[data-mode-edit]"); @@ -8116,7 +8444,7 @@ document.addEventListener("DOMContentLoaded", async () => { $("#modeRailToggle").focus(); } }); - $("#saveModeToggle").addEventListener("click", () => openModeEditorForCreate()); + $("#saveModeToggle").addEventListener("click", () => startAssistantModeCreation()); $$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider))); $$('[data-reasoning-effort]').forEach((button) => button.addEventListener("click", () => selectReasoningEffort(button.dataset.reasoningEffort))); $$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode))); diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css new file mode 100644 index 0000000..6c26b57 --- /dev/null +++ b/apps/web-console/assistant-chat.css @@ -0,0 +1,1360 @@ +/* Unified conversational assistant. Loaded after the existing console cascade. */ + +.console-v2.assistant-chat-enabled { + --assistant-drawer-width: clamp(340px, 26vw, 410px); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .main-panel { + grid-template-rows: 56px auto minmax(0, 1fr); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { + width: var(--assistant-drawer-width); + height: auto; + position: fixed; + top: 72px; + right: 18px; + bottom: 18px; + z-index: 120; + overflow: hidden; + background: rgba(255, 255, 255, 0.96); + border-color: rgba(126, 146, 184, 0.25); + border-radius: 22px; + box-shadow: 0 26px 70px rgba(25, 44, 82, 0.2), 0 8px 24px rgba(25, 44, 82, 0.1); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateX(calc(100% + 36px)) scale(0.985); + transform-origin: right center; + transition: transform 220ms ease, opacity 180ms ease, visibility 180ms ease; +} + +@media (min-width: 901px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace { + transform-origin: left center; + transition: opacity 180ms ease, grid-template-columns 220ms ease; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .workspace { + width: 100vw; + grid-template-columns: clamp(250px, 19vw, 292px) minmax(0, 1fr); + margin: 0; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open.v2-sidebar-collapsed .workspace { + grid-template-columns: 64px minmax(0, 1fr); + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .command-panel.v2-sidebar { + display: block !important; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .main-panel { + width: 100%; + grid-template-columns: var(--assistant-drawer-width) minmax(0, 1fr); + grid-template-rows: 56px auto minmax(0, 1fr); + column-gap: 10px; + padding-bottom: 10px; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .v2-module-switcher { + grid-column: 1 / -1; + grid-row: 1; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .v2-operation-shell { + width: auto; + height: auto; + min-width: 0; + min-height: 0; + position: relative; + top: auto; + right: auto; + bottom: auto; + grid-column: 1; + grid-row: 3; + margin-left: 10px; + border-radius: 18px; + box-shadow: 0 14px 38px rgba(25, 44, 82, 0.12); + transform-origin: left center; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .v2-output-shell { + min-width: 0; + grid-column: 2; + grid-row: 3; + } +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .v2-operation-shell { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateX(0) scale(1); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-output-shell { + grid-row: 3; +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail { + min-width: 0; + grid-column: 1 / -1; + grid-row: 2; +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-natural-panel { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + gap: 8px; + padding: 16px 18px 18px; +} + +.console-v2.assistant-chat-enabled .v2-operation-heading { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(126, 146, 184, 0.16); +} + +.console-v2.assistant-chat-enabled .v2-operation-heading h2 { + margin: 0; + font-size: clamp(21px, 1.6vw, 26px); + letter-spacing: -0.035em; +} + +.assistant-drawer-actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.assistant-drawer-actions .context-reset, +.assistant-drawer-close { + width: 34px; + height: 34px; + min-height: 34px; + position: static; + display: grid !important; + place-items: center; + margin: 0; + padding: 0; + color: #65748a; + background: transparent; + border: 0; + border-radius: 50%; + cursor: pointer; + transform: none; + transition: color 150ms ease, background 150ms ease, transform 150ms ease; +} + +.assistant-drawer-actions .context-reset:hover, +.assistant-drawer-actions .context-reset:focus-visible, +.assistant-drawer-close:hover, +.assistant-drawer-close:focus-visible { + color: #275df5; + background: #eef3ff; + outline: none; + transform: scale(1.04); +} + +.assistant-drawer-actions svg { + width: 17px; + height: 17px; +} + +.assistant-drawer-toggle { + min-width: 128px; + height: 52px; + position: fixed; + right: auto; + bottom: 24px; + left: calc(clamp(250px, 19vw, 292px) + 20px); + z-index: 119; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 18px; + color: #fff; + background: #275df5; + border: 1px solid rgba(255, 255, 255, 0.48); + border-radius: 999px; + box-shadow: 0 14px 34px rgba(39, 93, 245, 0.28); + font: 760 14px/1 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; + cursor: pointer; + transition: transform 170ms ease, box-shadow 170ms ease, opacity 140ms ease; +} + +.console-v2.v2-sidebar-collapsed .assistant-drawer-toggle { + left: 84px; +} + +@media (min-width: 1201px) and (max-width: 1440px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .main-panel { + grid-template-columns: 330px minmax(0, 1fr); + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell.v2-output-page, + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(250px, 1fr) 352px; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(250px, 1fr) 52px; + } +} + +@media (min-width: 901px) and (max-width: 1200px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell.v2-output-page, + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(250px, 1fr) 292px; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(250px, 1fr) 52px; + } +} + +.assistant-drawer-toggle:hover, +.assistant-drawer-toggle:focus-visible { + box-shadow: 0 17px 38px rgba(39, 93, 245, 0.36); + outline: 3px solid rgba(39, 93, 245, 0.14); + outline-offset: 3px; + transform: translateY(-2px); +} + +.assistant-drawer-toggle svg { + width: 20px; + height: 20px; +} + +.assistant-drawer-toggle b { + width: 8px; + height: 8px; + position: absolute; + top: 5px; + right: 7px; + display: none; + background: #ffb84d; + border: 2px solid #275df5; + border-radius: 50%; +} + +.console-v2.assistant-drawer-busy .assistant-drawer-toggle b { + display: block; +} + +.console-v2.assistant-drawer-open .assistant-drawer-toggle { + opacity: 0; + pointer-events: none; + transform: translateX(12px); +} + +.console-v2.assistant-chat-enabled .v2-natural-grid { + min-height: 0; + display: block; + grid-column: 1; + grid-row: 2; +} + +.console-v2.assistant-chat-enabled .v2-request-form { + height: 100%; + min-height: 0; + display: grid !important; + grid-template-rows: minmax(0, 1fr) auto; + gap: 10px; +} + +.console-v2.assistant-chat-enabled .v2-request-form > .field-label-row, +.console-v2.assistant-chat-enabled .v2-request-form > .field-group, +.console-v2.assistant-chat-enabled .v2-request-status, +.console-v2.assistant-chat-enabled .v2-task-overview, +.console-v2.assistant-chat-enabled .v2-reasoning-trigger, +.console-v2.assistant-chat-enabled .v2-reasoning-field { + display: none !important; +} + +.assistant-transcript { + min-height: 0; + overflow: auto; + scrollbar-color: rgba(39, 93, 245, 0.22) transparent; + scrollbar-width: thin; + overscroll-behavior: contain; +} + +.assistant-messages { + min-height: 100%; + display: flex; + flex-direction: column; + gap: 11px; + padding: 2px 8px 4px 2px; +} + +.assistant-message { + max-width: min(82%, 860px); + display: flex; + align-items: flex-start; + gap: 9px; + animation: assistant-message-in 180ms ease-out both; +} + +.assistant-message-user { + align-self: flex-end; + justify-content: flex-end; +} + +.assistant-message-assistant { + align-self: flex-start; +} + +.assistant-avatar { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: grid; + place-items: center; + color: white; + background: #275df5; + border-radius: 50%; + box-shadow: 0 6px 14px rgba(39, 93, 245, 0.2); +} + +.assistant-avatar svg { + width: 15px; + height: 15px; + stroke-width: 2; +} + +.assistant-message-body { + min-width: 0; + display: grid; + gap: 7px; +} + +.assistant-message-content { + padding: 10px 13px; + color: #18242e; + background: #fff; + border: 1px solid #e1e6ee; + border-radius: 7px 16px 16px 16px; + box-shadow: 0 4px 14px rgba(32, 54, 88, 0.045); + font: 520 13px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; + overflow-wrap: anywhere; +} + +.assistant-message-user .assistant-message-content { + color: #152a58; + background: #f2f6ff; + border-color: #cbd8f7; + border-radius: 16px 7px 16px 16px; +} + +.assistant-message-content p { + margin: 0 0 6px; +} + +.assistant-message-content p:last-child, +.assistant-message-content ul:last-child, +.assistant-message-content pre:last-child { + margin-bottom: 0; +} + +.assistant-message-content ul { + margin: 4px 0 8px; + padding-left: 20px; +} + +.assistant-message-content li + li { + margin-top: 3px; +} + +.assistant-message-content code { + padding: 1px 5px; + color: #1741ad; + background: #edf2ff; + border-radius: 5px; + font: 500 0.92em/1.5 "SFMono-Regular", Consolas, monospace; +} + +.assistant-message-content pre { + max-width: 100%; + margin: 7px 0; + padding: 11px 13px; + overflow: auto; + color: #eaf0ff; + background: #17213a; + border-radius: 10px; +} + +.assistant-message-content pre code { + padding: 0; + color: inherit; + background: transparent; +} + +.assistant-inline-heading { + display: block; + margin: 6px 0 3px; + font-weight: 800; +} + +.assistant-action-status { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 2px; + color: #64748b; + font-size: 11px; + font-weight: 720; +} + +.assistant-action-status[hidden] { + display: none; +} + +.assistant-action-status svg { + width: 15px; + height: 15px; +} + +.assistant-action-status.working svg { + color: #275df5; + animation: assistant-spin 900ms linear infinite; +} + +.assistant-action-status.success { + color: #0c9b63; +} + +.assistant-action-status.failed { + color: #d24a55; +} + +.assistant-mode-guide .assistant-message-content { + background: linear-gradient(145deg, #f8faff, #f2f6ff); + border-color: #cfdbfb; +} + +.assistant-message-mode-draft { + width: auto; + max-width: none; + align-self: stretch; +} + +.assistant-message-mode-draft .assistant-message-body { + min-width: 0; + flex: 1 1 0; +} + +.assistant-mode-draft { + width: 100%; + max-width: 314px; + box-sizing: border-box; + overflow: hidden; + color: #233044; + background: #fff; + border: 1px solid #dce3ed; + border-radius: 14px; + box-shadow: 0 8px 24px rgba(32, 54, 88, 0.065); +} + +.assistant-mode-draft > header { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px 9px; +} + +.assistant-mode-draft > header > span:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.assistant-mode-draft > header small { + color: #718096; + font-size: 9px; + font-weight: 680; + line-height: 1.2; +} + +.assistant-mode-draft > header strong { + overflow: hidden; + font-size: 13px; + font-weight: 850; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assistant-mode-draft-badge { + flex: 0 0 auto; + padding: 4px 7px; + color: #3764d9; + background: #eef3ff; + border-radius: 999px; + font-size: 8px; + font-weight: 800; + white-space: nowrap; +} + +.assistant-mode-draft-list { + border-top: 1px solid #e5eaf1; + border-bottom: 1px solid #e5eaf1; +} + +.assistant-mode-draft-row { + min-height: 39px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px 12px; + font-size: 11px; +} + +.assistant-mode-draft-row + .assistant-mode-draft-row { + border-top: 1px solid #eef1f5; +} + +.assistant-mode-draft-row > svg { + width: 15px; + height: 15px; + color: #6e7b8d; + stroke-width: 1.8; +} + +.assistant-mode-draft-row > span { + min-width: 0; + color: #4a5668; + font-weight: 680; +} + +.assistant-mode-draft-row > strong { + color: #334155; + font-size: 10px; + font-weight: 760; + white-space: nowrap; +} + +.assistant-mode-draft-actions { + display: grid; + gap: 4px; + padding: 9px 10px 8px; +} + +.assistant-mode-draft-actions[hidden] { + display: none; +} + +.assistant-mode-draft-actions button { + min-height: 34px; + border-radius: 9px; + font: 800 10px/1 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease, + box-shadow 150ms ease, opacity 150ms ease; +} + +.assistant-mode-draft-actions button:disabled { + cursor: wait; + opacity: 0.58; +} + +.assistant-mode-draft-save { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + color: #fff; + background: linear-gradient(135deg, #0d9b8f, #087b72); + border: 1px solid #087b72; + box-shadow: 0 6px 14px rgba(8, 123, 114, 0.16); +} + +.assistant-mode-draft-save:hover, +.assistant-mode-draft-save:focus-visible { + background: linear-gradient(135deg, #0b8c82, #066c65); + outline: 3px solid rgba(13, 155, 143, 0.13); + outline-offset: 1px; +} + +.assistant-mode-draft-save svg { + width: 14px; + height: 14px; +} + +.assistant-mode-draft-trial { + color: #0b8278; + background: transparent; + border: 1px solid transparent; +} + +.assistant-mode-draft-trial:hover, +.assistant-mode-draft-trial:focus-visible { + color: #066c65; + background: #eef9f7; + border-color: #c9e9e4; + outline: none; +} + +.assistant-mode-draft-status { + margin: 0 10px 9px; + padding: 7px 9px; + border-radius: 8px; + font-size: 9px; + font-weight: 750; + line-height: 1.35; +} + +.assistant-mode-draft-status[hidden] { + display: none; +} + +.assistant-mode-draft-status.working { + color: #365bae; + background: #eff4ff; +} + +.assistant-mode-draft-status.success { + color: #087854; + background: #ebf9f3; +} + +.assistant-mode-draft-status.failed { + color: #b53845; + background: #fff0f2; +} + +.assistant-mode-draft.is-saved { + border-color: #bfe3d4; +} + +.assistant-mode-created { + width: 100%; + max-width: 290px; + min-width: 0; + box-sizing: border-box; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 9px 10px; + color: #163f34; + background: #f0faf6; + border: 1px solid #c9eadc; + border-radius: 12px; +} + +.assistant-mode-created-icon { + width: 30px; + height: 30px; + display: grid; + place-items: center; + color: #0c9b63; + background: #fff; + border-radius: 9px; + box-shadow: 0 4px 12px rgba(12, 155, 99, 0.1); +} + +.assistant-mode-created-icon svg { + width: 16px; + height: 16px; +} + +.assistant-mode-created-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.assistant-mode-created-copy small, +.assistant-mode-created-copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assistant-mode-created-copy small { + color: #578274; + font-size: 9px; + font-weight: 650; +} + +.assistant-mode-created-copy strong { + font-size: 12px; + font-weight: 820; +} + +.assistant-mode-created button { + min-height: 29px; + padding: 5px 9px; + color: #087854; + background: #fff; + border: 1px solid #b8ddcf; + border-radius: 8px; + font-size: 9px; + font-weight: 800; + cursor: pointer; +} + +.assistant-mode-created button:hover, +.assistant-mode-created button:focus-visible { + color: #fff; + background: #0c9b63; + border-color: #0c9b63; + outline: none; +} + +.assistant-thinking { + display: flex; + align-items: center; + gap: 4px; + color: #536277; +} + +.assistant-thinking span { + margin-right: 2px; +} + +.assistant-thinking i { + width: 4px; + height: 4px; + background: #7792d8; + border-radius: 50%; + animation: assistant-dot 1.2s ease-in-out infinite; +} + +.assistant-thinking i:nth-of-type(2) { animation-delay: 120ms; } +.assistant-thinking i:nth-of-type(3) { animation-delay: 240ms; } + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer { + height: auto; + min-height: 66px; + position: relative; + overflow: hidden; + background: #fff; + border: 1px solid #cfd7e3; + border-radius: 18px; + box-shadow: 0 9px 24px rgba(32, 54, 88, 0.07); + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer:focus-within { + border-color: rgba(39, 93, 245, 0.58); + box-shadow: 0 0 0 3px rgba(39, 93, 245, 0.08), 0 9px 24px rgba(32, 54, 88, 0.07); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form #utterance { + width: 100%; + height: 64px; + min-height: 64px; + max-height: 116px; + padding: 12px 100px 25px 16px; + color: #172033; + background: transparent; + border: 0; + border-radius: inherit; + outline: 0; + font: 520 13px/1.45 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; + overflow-y: hidden; + resize: none; +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form #utterance.is-scrollable { + overflow-y: auto; + scrollbar-width: thin; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.console-v2.assistant-chat-enabled .v2-request-form #utterance:focus { + box-shadow: none; +} + +.console-v2.assistant-chat-enabled .v2-request-form .composer-hint { + right: 108px; + bottom: 7px; + color: #8a96a8; + font-size: 8.5px; +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation { + top: 50%; + right: 53px; + bottom: auto; + z-index: 14; + display: grid !important; + color: #56657a; + transform: translateY(-50%); +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-armed { + color: #275df5; + background: rgba(39, 93, 245, 0.09); +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-listening { + color: #fff; + background: #275df5; +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-processing { + color: #275df5; + background: rgba(39, 93, 245, 0.09); +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-status { + right: 98px; + bottom: 5px; + left: 14px; + display: block; +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-status:empty { + display: none; +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action { + width: 38px; + height: 38px; + min-height: 38px; + top: 50%; + right: 9px; + bottom: auto; + transform: translateY(-50%); + background: #275df5; + box-shadow: 0 7px 17px rgba(39, 93, 245, 0.25); +} + +.console-v2.assistant-chat-enabled .v2-request-form .primary-action:hover:not(:disabled), +.console-v2.assistant-chat-enabled .v2-request-form .primary-action:focus-visible { + background: #174ce1; + transform: translateY(-50%) scale(1.04); +} + +.console-v2.assistant-chat-enabled .v2-request-form .primary-action.assistant-stop { + background: #17213a; +} + +.console-v2.assistant-chat-enabled .v2-request-form .primary-action.assistant-stop svg { + width: 12px; + height: 12px; + fill: currentColor; +} + +@keyframes assistant-message-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes assistant-spin { to { transform: rotate(360deg); } } + +@keyframes assistant-dot { + 0%, 70%, 100% { opacity: 0.35; transform: translateY(0); } + 35% { opacity: 1; transform: translateY(-2px); } +} + +@media (max-width: 900px) { + .console-v2.assistant-chat-enabled { + min-width: 0; + overflow: hidden; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace { + width: 100%; + min-width: 0; + height: 100dvh; + display: block; + } + + .console-v2.assistant-chat-enabled .command-panel.v2-sidebar { + display: none !important; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .main-panel { + width: 100%; + height: 100dvh; + min-height: 100dvh; + grid-template-rows: 52px auto minmax(0, 1fr); + gap: 8px; + padding: 0 8px 8px; + overflow: hidden; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-output-shell { + display: grid !important; + grid-row: 3; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page { + grid-template-columns: minmax(0, 1fr) !important; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .joint-twin-shell > .twin-band { + width: 100%; + max-width: none; + grid-column: 1 !important; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-unified-right-rail { + display: none !important; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { + width: calc(100vw - 16px); + height: auto; + min-height: 0; + top: 8px; + right: 8px; + bottom: 8px; + border-radius: 20px; + } + + .console-v2.assistant-chat-enabled .v2-module-switcher { + min-width: 0; + } + + .console-v2.assistant-chat-enabled .assistant-message { + max-width: 92%; + } + + .assistant-drawer-toggle { + right: auto; + bottom: 14px; + left: 14px; + } +} + +@media (max-width: 560px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-natural-panel { + padding: 14px 12px 12px; + } + + .console-v2.assistant-chat-enabled .v2-operation-heading h2 { + font-size: 22px; + } + + .assistant-messages { + padding-right: 2px; + } + + .assistant-message { + max-width: 96%; + } + + .assistant-avatar { + width: 27px; + height: 27px; + flex-basis: 27px; + } + + .assistant-message-content { + padding: 9px 11px; + font-size: 12.5px; + } + + .console-v2.assistant-chat-enabled .v2-request-form .composer-hint { + display: none; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form #utterance { + padding-right: 96px; + } + + .assistant-drawer-toggle { + width: 54px; + min-width: 54px; + height: 54px; + padding: 0; + } + + .assistant-drawer-toggle span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .assistant-message, + .assistant-thinking i, + .assistant-action-status.working svg, + .assistant-drawer-toggle, + .console-v2.assistant-chat-enabled .v2-operation-shell { + animation: none !important; + transition: none !important; + } +} + +/* Shared AI light-blue palette ------------------------------------------- + Text stays layered: ink blue for headings, blue-gray for reading text, + muted slate for metadata, and white only on saturated controls. */ +.console-v2.assistant-chat-enabled { + --assistant-heading: var(--heading-foreground); + --assistant-text: var(--body-foreground); + --assistant-muted: var(--subtle-foreground); + --assistant-surface: var(--card); + --assistant-surface-soft: color-mix(in oklch, var(--card) 74%, var(--secondary)); + --assistant-border: var(--border); + --assistant-shadow: color-mix(in oklch, var(--foreground) 12%, transparent); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { + color: var(--assistant-text); + background: + radial-gradient(circle at 88% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 16rem), + linear-gradient(165deg, var(--assistant-surface), var(--assistant-surface-soft)); + border-color: var(--assistant-border); + box-shadow: + 0 26px 70px var(--assistant-shadow), + 0 8px 24px color-mix(in oklch, var(--primary) 8%, transparent); +} + +.console-v2.assistant-chat-enabled .v2-natural-panel { + background: transparent; + border-color: transparent; + box-shadow: none; +} + +.console-v2.assistant-chat-enabled .v2-operation-heading { + border-bottom-color: var(--assistant-border); +} + +.console-v2.assistant-chat-enabled .v2-operation-heading h2 { + color: var(--assistant-heading); +} + +.assistant-drawer-actions .context-reset, +.assistant-drawer-close { + color: var(--assistant-muted); +} + +.assistant-drawer-actions .context-reset:hover, +.assistant-drawer-actions .context-reset:focus-visible, +.assistant-drawer-close:hover, +.assistant-drawer-close:focus-visible { + color: var(--accent-foreground); + background: var(--accent); +} + +.assistant-drawer-toggle, +.assistant-avatar, +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action { + color: var(--primary-foreground); + background: var(--primary); + border-color: color-mix(in oklch, var(--primary-foreground) 42%, transparent); + box-shadow: 0 12px 30px color-mix(in oklch, var(--primary) 28%, transparent); +} + +.assistant-drawer-toggle:hover, +.assistant-drawer-toggle:focus-visible { + box-shadow: 0 16px 36px color-mix(in oklch, var(--primary) 34%, transparent); + outline-color: color-mix(in oklch, var(--primary) 18%, transparent); +} + +.assistant-drawer-toggle b { + background: var(--status-warning); + border-color: var(--primary); +} + +.assistant-transcript { + scrollbar-color: color-mix(in oklch, var(--primary) 26%, transparent) transparent; +} + +.assistant-message-content { + color: var(--assistant-text); + background: color-mix(in oklch, var(--assistant-surface) 94%, transparent); + border-color: var(--assistant-border); + box-shadow: 0 4px 14px color-mix(in oklch, var(--foreground) 5%, transparent); +} + +.assistant-message-user .assistant-message-content { + color: var(--secondary-foreground); + background: var(--secondary); + border-color: color-mix(in oklch, var(--primary) 24%, var(--assistant-border)); +} + +.assistant-message-content code { + color: var(--status-info-foreground); + background: var(--status-info-soft); +} + +.assistant-message-content pre { + color: var(--primary-foreground); + background: color-mix(in oklch, var(--foreground) 91%, var(--primary)); +} + +.assistant-action-status, +.assistant-thinking { + color: var(--assistant-muted); +} + +.assistant-action-status.working svg { + color: var(--status-info); +} + +.assistant-action-status.success { + color: var(--status-success-foreground); +} + +.assistant-action-status.failed { + color: var(--status-error-foreground); +} + +.assistant-mode-guide .assistant-message-content { + background: linear-gradient(145deg, var(--card), var(--secondary)); + border-color: color-mix(in oklch, var(--primary) 24%, var(--border)); +} + +.assistant-mode-draft { + color: var(--assistant-text); + background: var(--assistant-surface); + border-color: var(--assistant-border); + box-shadow: 0 8px 24px color-mix(in oklch, var(--foreground) 7%, transparent); +} + +.assistant-mode-draft > header strong, +.assistant-mode-draft-row > strong, +.assistant-mode-created-copy strong { + color: var(--assistant-heading); +} + +.assistant-mode-draft > header small, +.assistant-mode-draft-row > svg, +.assistant-mode-created-copy small { + color: var(--assistant-muted); +} + +.assistant-mode-draft-badge { + color: var(--status-info-foreground); + background: var(--status-info-soft); +} + +.assistant-mode-draft-list, +.assistant-mode-draft-row + .assistant-mode-draft-row { + border-color: var(--assistant-border); +} + +.assistant-mode-draft-row > span { + color: var(--assistant-text); +} + +.assistant-mode-draft-save { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 6px 14px color-mix(in oklch, var(--primary) 20%, transparent); +} + +.assistant-mode-draft-save:hover, +.assistant-mode-draft-save:focus-visible { + color: var(--primary-foreground); + background: color-mix(in oklch, var(--primary) 86%, var(--heading-foreground)); + outline-color: color-mix(in oklch, var(--primary) 16%, transparent); +} + +.assistant-mode-draft-trial, +.assistant-mode-created button { + color: var(--primary); + background: transparent; + border-color: transparent; +} + +.assistant-mode-draft-trial:hover, +.assistant-mode-draft-trial:focus-visible, +.assistant-mode-created button:hover, +.assistant-mode-created button:focus-visible { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); +} + +.assistant-mode-draft-status.working { + color: var(--status-info-foreground); + background: var(--status-info-soft); +} + +.assistant-mode-draft-status.success { + color: var(--status-success-foreground); + background: var(--status-success-soft); +} + +.assistant-mode-draft-status.failed { + color: var(--status-error-foreground); + background: var(--status-error-soft); +} + +.assistant-mode-draft.is-saved { + border-color: color-mix(in oklch, var(--status-success) 36%, var(--border)); +} + +.assistant-mode-created { + color: var(--status-success-foreground); + background: var(--status-success-soft); + border-color: color-mix(in oklch, var(--status-success) 32%, var(--border)); +} + +.assistant-mode-created-icon { + color: var(--status-success-foreground); + background: var(--card); + box-shadow: 0 4px 12px color-mix(in oklch, var(--status-success) 12%, transparent); +} + +.assistant-thinking i { + background: color-mix(in oklch, var(--primary) 62%, var(--muted-foreground)); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer { + background: var(--card); + border-color: var(--input); + box-shadow: 0 9px 24px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer:focus-within { + border-color: var(--ring); + box-shadow: + 0 0 0 3px color-mix(in oklch, var(--ring) 15%, transparent), + 0 9px 24px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form #utterance { + color: var(--assistant-text); +} + +.console-v2.assistant-chat-enabled .v2-request-form .composer-hint, +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation { + color: var(--assistant-muted); +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-armed, +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-processing { + color: var(--status-info-foreground); + background: var(--status-info-soft); +} + +.console-v2.assistant-chat-enabled .v2-request-form .speech-dictation.is-listening { + color: var(--primary-foreground); + background: var(--primary); +} + +.console-v2.assistant-chat-enabled .v2-request-form .primary-action:hover:not(:disabled), +.console-v2.assistant-chat-enabled .v2-request-form .primary-action:focus-visible { + background: color-mix(in oklch, var(--primary) 86%, var(--heading-foreground)); +} + +.console-v2.assistant-chat-enabled .v2-request-form .primary-action.assistant-stop { + background: var(--heading-foreground); +} + +/* The AI assistant uses the same persistent top-right panel control. */ +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .assistant-drawer-toggle, +.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open #v2PanelControlDock > .assistant-drawer-toggle { + width: 38px !important; + height: 38px !important; + min-width: 38px !important; + min-height: 38px !important; + position: relative !important; + inset: auto !important; + display: inline-grid !important; + padding: 0 !important; + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; + transform: none !important; +} + +@media (min-width: 901px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open.v2-sidebar-collapsed .workspace { + grid-template-columns: 0 minmax(0, 1fr); + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open.v2-sidebar-collapsed .command-panel.v2-sidebar { + width: 0; + min-width: 0; + display: flex !important; + padding: 0; + overflow: hidden; + border: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 900px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync:not(.v2-sidebar-collapsed) .command-panel.v2-sidebar { + width: min(86vw, 320px); + height: auto; + position: fixed; + top: 60px; + bottom: 8px; + left: 8px; + z-index: 140; + display: flex !important; + padding: 12px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: 0 22px 58px color-mix(in oklch, var(--foreground) 20%, transparent); + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .joint-twin-shell .v2-unified-right-rail:not([hidden]) { + width: min(86vw, 360px); + height: auto; + position: fixed; + top: 60px; + right: 8px; + bottom: 8px; + z-index: 140; + display: grid !important; + padding: 0; + overflow: hidden; + background: var(--card); + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: 0 22px 58px color-mix(in oklch, var(--foreground) 20%, transparent); + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { + top: 60px; + } + + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-module-switcher { + position: relative; + z-index: 150; + } +} + +@media (max-width: 760px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .assistant-drawer-toggle, + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open #v2PanelControlDock > .assistant-drawer-toggle { + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + min-height: 36px !important; + } +} + +@media (max-width: 460px) { + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .assistant-drawer-toggle, + .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open #v2PanelControlDock > .assistant-drawer-toggle { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; + min-height: 32px !important; + } +} diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js new file mode 100644 index 0000000..fca929f --- /dev/null +++ b/apps/web-console/assistant-chat.js @@ -0,0 +1,879 @@ +(function initializeUnifiedAssistant() { + const STORAGE_KEY = "cognitive-agent.assistant-messages"; + const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id"; + const MAX_STORED_MESSAGES = 24; + const COMPOSER_MIN_HEIGHT = 64; + const COMPOSER_MAX_HEIGHT = 116; + const WELCOME_TEXT = "你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。"; + let pendingController = null; + let pendingMessage = null; + let latestControlResult = null; + let initialized = false; + + const $ = (selector, root = document) => root.querySelector(selector); + + function setDrawerOpen(open, { restoreFocus = false } = {}) { + const drawer = $("#assistantDrawer"); + const trigger = $("#assistantDrawerToggle"); + if (!drawer || !trigger) return; + document.body.classList.toggle("assistant-drawer-open", open); + trigger.classList.toggle("active", open); + trigger.setAttribute("aria-pressed", String(open)); + trigger.setAttribute("aria-expanded", String(open)); + const triggerLabel = open ? "收起AI助手" : "展开AI助手"; + trigger.setAttribute("aria-label", triggerLabel); + trigger.title = triggerLabel; + drawer.setAttribute("aria-hidden", String(!open)); + drawer.toggleAttribute("inert", !open); + const speech = window.CognitiveAgentSpeech; + if (typeof speech?.setAssistantOpen === "function") { + void speech.setAssistantOpen(open); + } + if (open) { + requestAnimationFrame(() => $("#utterance")?.focus({ preventScroll: true })); + } else { + // A collapsed assistant must never leave an invisible microphone or + // preview request running in the background. + if (typeof speech?.setAssistantOpen !== "function") { + void speech?.stop?.("AI助手已收起,语音输入已停止"); + } + if (restoreFocus) trigger.focus({ preventScroll: true }); + } + } + + function installAssistantDrawer() { + const drawer = $(".v2-operation-shell"); + const heading = $(".v2-operation-heading", drawer); + if (!drawer || !heading || $("#assistantDrawerToggle")) return; + + drawer.id = "assistantDrawer"; + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-label", "AI助手"); + + const actions = document.createElement("div"); + actions.className = "assistant-drawer-actions"; + const reset = $("#newConversation"); + if (reset) { + reset.classList.add("assistant-drawer-new"); + actions.append(reset); + } + heading.append(actions); + + const trigger = document.createElement("button"); + trigger.id = "assistantDrawerToggle"; + trigger.className = "assistant-drawer-toggle v2-panel-control-button"; + trigger.type = "button"; + trigger.setAttribute("aria-controls", drawer.id); + trigger.setAttribute("aria-pressed", "false"); + trigger.setAttribute("aria-expanded", "false"); + trigger.setAttribute("aria-label", "展开AI助手"); + trigger.title = "展开AI助手"; + trigger.innerHTML = 'AI助手'; + ($("#v2PanelControlDock") || document.body).append(trigger); + + trigger.addEventListener("click", () => { + setDrawerOpen(!document.body.classList.contains("assistant-drawer-open")); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && document.body.classList.contains("assistant-drawer-open")) { + event.preventDefault(); + setDrawerOpen(false, { restoreFocus: true }); + } + }); + window.addEventListener("cognitive-agent:module-change", (event) => { + if (event.detail?.module === "self-extension") setDrawerOpen(false); + }); + setDrawerOpen(true); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + } + + function createMessageId() { + if (globalThis.crypto?.randomUUID) return `ui-${globalThis.crypto.randomUUID()}`; + return `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } + + function createConversationId() { + if (globalThis.crypto?.randomUUID) return `conv-${globalThis.crypto.randomUUID()}`; + return `conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } + + function conversationId() { + const existing = sessionStorage.getItem(CONVERSATION_STORAGE_KEY) + || window.CognitiveAgentConsole?.getConversationId?.(); + if (existing) return existing; + const created = createConversationId(); + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, created); + return created; + } + + function loadStoredMessages() { + try { + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]"); + return Array.isArray(stored) + ? stored.filter((item) => item && ["user", "assistant"].includes(item.role)) + : []; + } catch (_error) { + return []; + } + } + + function persistMessages() { + const messages = [...document.querySelectorAll("#assistantMessages [data-message-role]")] + .map((node) => ({ + role: node.dataset.messageRole, + content: node.dataset.messageContent || "", + })) + .filter((item) => item.content && item.content !== WELCOME_TEXT) + .slice(-MAX_STORED_MESSAGES); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + } + + function appendInlineText(container, text) { + const parts = String(text).split(/(`[^`\n]+`)/g); + parts.forEach((part) => { + if (part.startsWith("`") && part.endsWith("`") && part.length > 2) { + const code = document.createElement("code"); + code.textContent = part.slice(1, -1); + container.append(code); + } else { + container.append(document.createTextNode(part)); + } + }); + } + + function renderRichText(container, value) { + container.replaceChildren(); + const sections = String(value).split(/```(?:[\w+-]+)?\n?([\s\S]*?)```/g); + sections.forEach((section, index) => { + if (index % 2 === 1) { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = section.replace(/\n$/, ""); + pre.append(code); + container.append(pre); + return; + } + const lines = section.split("\n"); + let list = null; + lines.forEach((line) => { + const bullet = line.match(/^\s*[-*]\s+(.+)$/); + if (bullet) { + if (!list) { + list = document.createElement("ul"); + container.append(list); + } + const item = document.createElement("li"); + appendInlineText(item, bullet[1]); + list.append(item); + return; + } + list = null; + if (!line.trim()) return; + const heading = line.match(/^#{1,3}\s+(.+)$/); + const block = document.createElement(heading ? "strong" : "p"); + if (heading) block.className = "assistant-inline-heading"; + appendInlineText(block, heading ? heading[1] : line); + container.append(block); + }); + }); + } + + function shouldFollowLatest(transcript) { + return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 90; + } + + function followLatest(force = false) { + const transcript = $("#assistantTranscript"); + if (!transcript || (!force && !shouldFollowLatest(transcript))) return; + requestAnimationFrame(() => { + transcript.scrollTop = transcript.scrollHeight; + }); + } + + function resizeComposerInput(input = $("#utterance")) { + if (!input) return; + input.style.height = "auto"; + const contentHeight = input.scrollHeight; + const nextHeight = Math.max( + COMPOSER_MIN_HEIGHT, + Math.min(contentHeight, COMPOSER_MAX_HEIGHT), + ); + input.style.height = `${nextHeight}px`; + const scrollable = contentHeight > COMPOSER_MAX_HEIGHT + 1; + input.classList.toggle("is-scrollable", scrollable); + if (!scrollable) input.scrollTop = 0; + } + + function createMessage(role, content = "", { pending = false } = {}) { + const article = document.createElement("article"); + article.className = `assistant-message assistant-message-${role}`; + article.dataset.messageId = createMessageId(); + article.dataset.messageRole = role; + article.dataset.messageContent = content; + + if (role === "assistant") { + const avatar = document.createElement("span"); + avatar.className = "assistant-avatar"; + avatar.setAttribute("aria-hidden", "true"); + avatar.innerHTML = ''; + article.append(avatar); + } + + const body = document.createElement("div"); + body.className = "assistant-message-body"; + const contentNode = document.createElement("div"); + contentNode.className = "assistant-message-content"; + if (pending && !content) { + contentNode.classList.add("assistant-thinking"); + contentNode.innerHTML = '正在理解你的需求'; + } else if (role === "assistant") { + renderRichText(contentNode, content); + } else { + contentNode.textContent = content; + } + body.append(contentNode); + + const action = document.createElement("div"); + action.className = "assistant-action-status"; + action.hidden = true; + action.setAttribute("role", "status"); + body.append(action); + article.append(body); + $("#assistantMessages").append(article); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + followLatest(true); + return { article, body, contentNode, action, content }; + } + + function setMessageContent(message, content, { final = false } = {}) { + message.content = content; + message.article.dataset.messageContent = content; + message.contentNode.classList.remove("assistant-thinking"); + if (final) renderRichText(message.contentNode, content); + else message.contentNode.textContent = content; + followLatest(); + } + + function setActionStatus(message, text, state = "working") { + message.action.hidden = !text; + message.action.className = `assistant-action-status ${state}`; + message.action.replaceChildren(); + if (!text) return; + const icon = document.createElement("i"); + icon.setAttribute( + "data-lucide", + state === "success" ? "circle-check" : state === "failed" ? "circle-x" : "loader-circle", + ); + const label = document.createElement("span"); + label.textContent = text; + message.action.append(icon, label); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + followLatest(); + } + + function appendModeCreatedState(message, profile) { + if (!message?.body || !profile?.id) return; + const selector = `[data-assistant-mode-created="${CSS.escape(String(profile.id))}"]`; + if ($(selector, message.body)) return; + const card = document.createElement("div"); + card.className = "assistant-mode-created"; + card.dataset.assistantModeCreated = String(profile.id); + + const icon = document.createElement("span"); + icon.className = "assistant-mode-created-icon"; + icon.innerHTML = ''; + + const copy = document.createElement("span"); + copy.className = "assistant-mode-created-copy"; + const label = document.createElement("small"); + label.textContent = "已添加到「我的模式」"; + const name = document.createElement("strong"); + name.textContent = String(profile.name || "新功能模式"); + copy.append(label, name); + + const reveal = document.createElement("button"); + reveal.type = "button"; + reveal.textContent = "查看"; + reveal.addEventListener("click", () => { + window.CognitiveAgentConsole?.revealModeProfile?.(String(profile.id), { focus: true }); + }); + card.append(icon, copy, reveal); + message.body.append(card); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + followLatest(true); + } + + function modeDraftRows(draft, rawActions) { + const instruction = String(draft?.instruction || ""); + const actions = Array.isArray(rawActions) + ? rawActions.filter((item) => item && typeof item === "object") + : []; + const consumed = new Set(); + const rows = []; + const targetZone = (action) => String( + action?.target?.instance || action?.target?.zone || action?.target || "", + ); + const parameters = (action) => ( + action?.parameters && typeof action.parameters === "object" ? action.parameters : {} + ); + const instructionOrder = (...terms) => { + const indexes = terms.map((term) => instruction.indexOf(term)).filter((index) => index >= 0); + return indexes.length ? Math.min(...indexes) : 10_000 + rows.length; + }; + const addRow = (label, value, icon, order, indexes = []) => { + indexes.forEach((index) => consumed.add(index)); + rows.push({ label, value, icon, order }); + }; + + const zeroGravityIndexes = actions + .map((action, index) => ({ action, index })) + .filter(({ action }) => String(action.capability || "").startsWith("seat.set_") + && ["passenger", "front_right"].includes(targetZone(action))) + .map(({ index }) => index); + if (/零重力/.test(instruction) && zeroGravityIndexes.length >= 2) { + addRow( + "副驾座椅", + "零重力模式", + "armchair", + instructionOrder("副驾", "零重力"), + zeroGravityIndexes, + ); + } + + const lightIndexes = actions + .map((action, index) => ({ action, index })) + .filter(({ action }) => ["interior_light", "ambient_light"].includes(String(action.domain || ""))) + .map(({ index }) => index); + if ( + lightIndexes.length >= 2 + && /(?:灯光.*(?:全部|所有|全车).*(?:关闭|关掉|熄灭)|(?:全部|所有|全车).*灯光.*(?:关闭|关掉|熄灭))/.test(instruction) + ) { + addRow( + "座舱灯光", + "全部关闭", + "lightbulb-off", + instructionOrder("灯光"), + lightIndexes, + ); + } + + const motionLabel = (value) => ({ + open: "打开", + close: "关闭", + stop: "停止", + half: "半开", + on: "开启", + off: "关闭", + }[String(value)] || String(value || "已配置")); + actions.forEach((action, index) => { + if (consumed.has(index)) return; + const capability = String(action.capability || ""); + const domain = String(action.domain || ""); + const zone = targetZone(action); + const params = parameters(action); + let label = capability || domain || "座舱设备"; + let value = "已配置"; + let icon = "sliders-horizontal"; + let order = 10_000 + index; + + if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") { + label = "遮阳帘"; + value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion); + icon = "blinds"; + order = instructionOrder("遮阳帘", "天幕帘"); + } else if (domain === "window") { + const windowLabels = { + all: "全部车窗", + front: "前排车窗", + rear: "后排车窗", + front_left: "主驾车窗", + front_right: "副驾车窗", + rear_left: "左后车窗", + rear_right: "右后车窗", + }; + label = windowLabels[zone] || "车窗"; + value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion); + icon = "panels-top-left"; + order = instructionOrder("车窗"); + } else if (domain === "seat") { + label = ["passenger", "front_right"].includes(zone) ? "副驾座椅" : "主驾座椅"; + value = params.position_pct != null + ? `${params.position_pct}%` + : motionLabel(params.state || params.mode_id); + icon = "armchair"; + order = instructionOrder("座椅", "副驾", "主驾"); + } else if (capability === "climate.set_temperature") { + label = "空调温度"; + value = `${params.temperature_c ?? "--"}℃`; + icon = "thermometer"; + order = instructionOrder("空调", "温度"); + } else if (domain === "climate") { + label = "空调"; + value = params.level != null ? `${params.level}档` : motionLabel(params.state); + icon = "fan"; + order = instructionOrder("空调", "风量"); + } else if (capability === "interior_light.set_reading_power") { + label = "阅读灯"; + value = motionLabel(params.state); + icon = "book-open"; + order = instructionOrder("阅读灯", "灯光"); + } else if (["interior_light", "ambient_light"].includes(domain)) { + label = domain === "ambient_light" ? "氛围灯" : "室内灯"; + value = motionLabel(params.state); + icon = "lightbulb"; + order = instructionOrder(label, "灯光"); + } + addRow(label, value, icon, order, [index]); + }); + return rows.sort((left, right) => left.order - right.order); + } + + async function saveModeProfileDraft(message, card, draft, buttons, status) { + buttons.forEach((button) => { button.disabled = true; }); + card.classList.add("is-busy"); + status.hidden = false; + status.className = "assistant-mode-draft-status working"; + status.textContent = "正在保存到「我的模式」…"; + try { + const response = await window.CognitiveAgentNetwork.request("/api/mode-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: draft.name, + instruction: draft.instruction, + source_request_id: draft.source_request_id, + vehicle_id: draft.vehicle_id || $("#vehicleSelect")?.value || null, + }), + timeoutMs: 45 * 1000, + retries: 0, + }); + const payload = await response.json(); + if (response.status === 401) { + window.location.replace("/login"); + return; + } + if (!response.ok) throw new Error(payload.message || payload.error || `HTTP ${response.status}`); + card.classList.remove("is-busy"); + card.classList.add("is-saved"); + status.className = "assistant-mode-draft-status success"; + status.textContent = "模式已保存"; + card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", ""); + await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id); + appendModeCreatedState(message, payload.mode_profile); + persistMessages(); + } catch (error) { + card.classList.remove("is-busy"); + status.className = "assistant-mode-draft-status failed"; + status.textContent = error.message || "保存失败,请重试。"; + buttons.forEach((button) => { button.disabled = false; }); + } + } + + async function tryModeProfileDraft(card, draft, buttons, status) { + buttons.forEach((button) => { button.disabled = true; }); + card.classList.add("is-busy"); + status.hidden = false; + status.className = "assistant-mode-draft-status working"; + status.textContent = "正在进行模拟试运行…"; + try { + const result = await window.CognitiveAgentConsole?.tryModeDraft?.(draft); + if (!result || result.status !== "succeeded") throw new Error("试运行未通过,请检查动作设置。"); + status.className = "assistant-mode-draft-status success"; + status.textContent = "试运行通过 · 尚未保存"; + } catch (error) { + status.className = "assistant-mode-draft-status failed"; + status.textContent = error.message || "试运行失败,请重试。"; + } finally { + card.classList.remove("is-busy"); + buttons.forEach((button) => { button.disabled = false; }); + } + } + + function appendModeDraftState(message, draft, actions) { + if (!message?.body || !draft?.name || !draft?.source_request_id) return; + const selector = `[data-assistant-mode-draft="${CSS.escape(String(draft.source_request_id))}"]`; + if ($(selector, message.body)) return; + const card = document.createElement("section"); + card.className = "assistant-mode-draft"; + card.dataset.assistantModeDraft = String(draft.source_request_id); + message.article?.classList.add("assistant-message-mode-draft"); + + const heading = document.createElement("header"); + const headingCopy = document.createElement("span"); + const eyebrow = document.createElement("small"); + eyebrow.textContent = "已为你生成功能模式"; + const title = document.createElement("strong"); + title.textContent = `「${draft.name}」`; + headingCopy.append(eyebrow, title); + const badge = document.createElement("span"); + badge.className = "assistant-mode-draft-badge"; + badge.textContent = `${Number(draft.operation_count) || 0} 项动作`; + heading.append(headingCopy, badge); + + const list = document.createElement("div"); + list.className = "assistant-mode-draft-list"; + modeDraftRows(draft, actions).forEach((item) => { + const row = document.createElement("div"); + row.className = "assistant-mode-draft-row"; + const icon = document.createElement("i"); + icon.dataset.lucide = item.icon; + const label = document.createElement("span"); + label.textContent = item.label; + const value = document.createElement("strong"); + value.textContent = item.value; + row.append(icon, label, value); + list.append(row); + }); + + const controls = document.createElement("div"); + controls.className = "assistant-mode-draft-actions"; + const save = document.createElement("button"); + save.type = "button"; + save.className = "assistant-mode-draft-save"; + save.innerHTML = '保存为功能模式'; + const trial = document.createElement("button"); + trial.type = "button"; + trial.className = "assistant-mode-draft-trial"; + trial.textContent = "先试运行"; + controls.append(save, trial); + const status = document.createElement("p"); + status.className = "assistant-mode-draft-status"; + status.hidden = true; + status.setAttribute("role", "status"); + const buttons = [save, trial]; + save.addEventListener("click", () => { + void saveModeProfileDraft(message, card, draft, buttons, status); + }); + trial.addEventListener("click", () => { + void tryModeProfileDraft(card, draft, buttons, status); + }); + + card.append(heading, list, controls, status); + message.body.append(card); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + followLatest(true); + } + + function startModeCreation() { + setDrawerOpen(true); + const input = $("#utterance"); + if (!input) return; + const helper = createMessage( + "assistant", + "告诉我模式名称和希望联动的座舱状态。比如:创建宝宝睡眠模式,副驾半躺、空调 24℃、关闭阅读灯。我会先生成动作预览,确认后再加入「我的模式」。", + ); + helper.article.classList.add("assistant-mode-guide"); + input.value = "请帮我创建一个"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + } + + function setComposerPending(pending) { + const button = $("#runButton"); + const input = $("#utterance"); + if (!button || !input) return; + document.body.classList.toggle("assistant-drawer-busy", pending); + button.classList.toggle("assistant-stop", pending); + button.setAttribute("aria-label", pending ? "停止生成" : "发送消息"); + button.title = pending ? "停止生成" : "发送消息"; + // General questions must stay available even when no physical CAN adapter + // is connected; hardware eligibility is evaluated only for control routes. + button.disabled = false; + button.innerHTML = pending + ? '停止' + : '发送'; + input.setAttribute("aria-busy", String(pending)); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + } + + function requestBody(message) { + const executionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run"; + const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can"; + const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id"); + const vehicleId = $("#vehicleSelect")?.value || null; + return { + message, + conversation_id: conversationId(), + client_type: "web_console_codex_assistant", + execution_mode: executionMode, + control_transport: controlTransport, + ...(vehicleId ? { vehicle_id: vehicleId } : {}), + ...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId + ? { hardware_client_id: hardwareClientId } + : {}), + ...(executionMode === "simulated_hardware" + ? { simulation_fault: $("#simulationFault")?.value || "normal" } + : {}), + }; + } + + async function readErrorResponse(response) { + try { + const payload = await response.json(); + return payload.message || payload.error || `HTTP ${response.status}`; + } catch (_error) { + return `HTTP ${response.status}`; + } + } + + function applyAssistantEvent(event, message) { + if (event.type === "answer.delta") { + setMessageContent(message, `${message.content}${event.delta || ""}`); + return; + } + if (["action.started", "action.progress", "work.started", "work.progress"].includes(event.type)) { + const defaultText = event.type.startsWith("work.") + ? "AI助手正在处理工作……" + : "正在执行座舱控制……"; + setActionStatus(message, event.display_text || defaultText, "working"); + if (event.type === "action.progress" && event.payload) { + window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-activity", { + detail: event.payload, + })); + } + return; + } + if (event.type === "action.completed" || event.type === "work.completed") { + latestControlResult = event.payload || latestControlResult; + const completed = event.success !== false; + const defaultText = event.type === "work.completed" + ? (completed ? "AI助手已完成工作" : "AI助手未能完成工作") + : (completed ? "座舱控制已完成" : "座舱控制未完成"); + setActionStatus( + message, + event.display_text || defaultText, + completed ? "success" : "failed", + ); + if (event.payload) { + window.CognitiveAgentConsole?.applySyncedResult?.(event.payload); + if ( + event.payload.mode_profile_draft + && event.payload.orchestration_route === "mode_profile_create_preview" + ) { + appendModeDraftState( + message, + event.payload.mode_profile_draft, + event.payload.actions, + ); + } else if ( + event.payload.mode_profile + && event.payload.orchestration_route === "mode_profile_create" + ) { + appendModeCreatedState(message, event.payload.mode_profile); + } + } + return; + } + if (event.type === "answer.completed") { + const authoritativeAnswer = Object.prototype.hasOwnProperty.call(event, "answer") + ? String(event.answer || "") + : message.content; + setMessageContent(message, authoritativeAnswer, { final: true }); + return; + } + if (event.type === "error") { + const error = new Error(event.message || "请求处理失败"); + error.code = event.error; + throw error; + } + } + + async function streamAssistant(messageText, assistantMessage, signal) { + const response = await window.CognitiveAgentNetwork.request("/api/assistant/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson", + }, + body: JSON.stringify(requestBody(messageText)), + timeoutMs: 15 * 60 * 1000, + retries: 0, + signal, + }); + if (!response.ok) throw new Error(await readErrorResponse(response)); + if (!response.body?.getReader) throw new Error("当前浏览器不支持流式问答"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + let answerCompleted = false; + const abortError = () => { + const error = new Error("助手响应已取消"); + error.name = "AbortError"; + return error; + }; + const cancelReader = () => { + // CognitiveAgentNetwork resolves once response headers arrive. Cancelling + // the body reader keeps the Stop button effective for the remaining + // long-lived NDJSON stream as well. + reader.cancel(signal.reason).catch(() => {}); + }; + signal.addEventListener("abort", cancelReader, { once: true }); + try { + if (signal.aborted) { + await reader.cancel(signal.reason).catch(() => {}); + throw abortError(); + } + while (true) { + const { done, value } = await reader.read(); + if (signal.aborted) throw abortError(); + buffered += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffered.split("\n"); + buffered = done ? "" : lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + const event = JSON.parse(line); + applyAssistantEvent(event, assistantMessage); + if (event.type === "answer.completed") answerCompleted = true; + } + if (done) break; + } + if (buffered.trim()) { + const event = JSON.parse(buffered); + applyAssistantEvent(event, assistantMessage); + if (event.type === "answer.completed") answerCompleted = true; + } + if (!answerCompleted) throw new Error("助手响应意外中断,请重试。"); + } finally { + signal.removeEventListener("abort", cancelReader); + reader.releaseLock(); + } + } + + async function submitAssistant() { + if (pendingController) { + pendingController.abort("user_stop"); + return; + } + const input = $("#utterance"); + const speechPhase = window.CognitiveAgentSpeech?.phase; + const speechInputMode = window.CognitiveAgentSpeech?.inputMode; + if (["capturing", "finalizing"].includes(speechPhase) + || (speechPhase === "arming" && speechInputMode === "manual")) { + window.CognitiveAgentConsole?.notify?.("请等待语音整理完成后再发送"); + return; + } + const messageText = input?.value.trim() || ""; + if (!messageText) { + window.CognitiveAgentConsole?.notify?.("请输入问题或工作需求"); + return; + } + + createMessage("user", messageText); + input.value = ""; + delete input.dataset.speechStage; + delete input.dataset.speechUtteranceId; + delete input.dataset.speechValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + const assistantMessage = createMessage("assistant", "", { pending: true }); + pendingMessage = assistantMessage; + latestControlResult = null; + pendingController = new AbortController(); + setComposerPending(true); + + let completedError = null; + try { + await streamAssistant(messageText, assistantMessage, pendingController.signal); + setMessageContent(assistantMessage, assistantMessage.content, { final: true }); + persistMessages(); + } catch (error) { + completedError = error; + if (pendingController.signal.aborted) { + const stopped = assistantMessage.content + ? `${assistantMessage.content}\n\n已停止继续生成。已下发的车辆操作仍会由安全执行链路完成。` + : "已停止生成。已下发的车辆操作仍会由安全执行链路完成。"; + setMessageContent(assistantMessage, stopped, { final: true }); + setActionStatus(assistantMessage, "已停止显示", "failed"); + } else { + setMessageContent(assistantMessage, error.message || "请求处理失败,请稍后再试。", { + final: true, + }); + setActionStatus(assistantMessage, "本次请求未完成", "failed"); + } + persistMessages(); + } finally { + pendingController = null; + pendingMessage = null; + setComposerPending(false); + input.focus({ preventScroll: true }); + window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", { + detail: { + utterance: messageText, + result: latestControlResult, + error: completedError, + }, + })); + } + } + + function renderInitialMessages() { + const container = $("#assistantMessages"); + if (!container) return; + const stored = loadStoredMessages(); + container.replaceChildren(); + createMessage("assistant", WELCOME_TEXT); + stored.forEach((message) => createMessage(message.role, String(message.content || ""))); + } + + function initialize() { + if (initialized || !$("#requestForm") || !$("#assistantMessages")) return; + initialized = true; + document.body.classList.add("assistant-chat-enabled"); + const title = $(".v2-operation-heading h2"); + if (title) title.textContent = "AI助手"; + const input = $("#utterance"); + input.value = ""; + input.placeholder = "向 AI助手提问或交代工作"; + input.setAttribute("aria-label", "向 AI助手提问或交代工作"); + installAssistantDrawer(); + input.addEventListener("input", () => resizeComposerInput(input)); + resizeComposerInput(input); + // Some browsers restore the last submitted textarea value after + // DOMContentLoaded. Clear that restoration once the initial page settles so + // a reload cannot accidentally resend an already handled vehicle command. + window.setTimeout(() => { + if (pendingController) return; + input.value = ""; + resizeComposerInput(input); + }, 300); + renderInitialMessages(); + setComposerPending(false); + } + + document.addEventListener("submit", (event) => { + if (!(event.target instanceof HTMLFormElement) || event.target.id !== "requestForm") return; + event.preventDefault(); + event.stopImmediatePropagation(); + submitAssistant(); + }, true); + + document.addEventListener("click", (event) => { + const target = event.target instanceof Element ? event.target : null; + if (!target?.closest("#newConversation")) return; + sessionStorage.removeItem(STORAGE_KEY); + sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId()); + }, true); + + document.addEventListener("DOMContentLoaded", initialize); + window.CognitiveAgentAssistant = { + submit: submitAssistant, + open: () => setDrawerOpen(true), + close: () => setDrawerOpen(false), + startModeCreation, + announceModeCreated(profile) { + if (!profile?.id) return; + setDrawerOpen(true); + const message = createMessage("assistant", `已完成「${profile.name || "新功能模式"}」的配置。`); + appendModeCreatedState(message, profile); + persistMessages(); + }, + stop() { + pendingController?.abort("user_stop"); + }, + isPending: () => Boolean(pendingMessage), + }; + if (window.__N7WEB_ASSISTANT_TEST__ === true) { + window.CognitiveAgentAssistant.__test = { setDrawerOpen }; + } +}()); diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css index 3c198dc..a561fbf 100644 --- a/apps/web-console/console-v2.css +++ b/apps/web-console/console-v2.css @@ -1431,7 +1431,9 @@ } .console-v2 .joint-twin-shell > .twin-band { + grid-template-columns: minmax(0, 1fr); grid-template-rows: 62px minmax(0, 1fr); + container-type: inline-size; } .console-v2 .joint-twin-shell > .cabin-band { @@ -1441,6 +1443,8 @@ } .console-v2 .joint-twin-shell .twin-heading { + width: 100%; + min-width: 0; min-height: 0; padding: 9px 12px; background: @@ -1473,6 +1477,7 @@ .console-v2 .joint-twin-shell .twin-toolbar { width: auto; + min-width: 0; justify-content: flex-end; } @@ -1489,11 +1494,13 @@ .console-v2 .joint-twin-shell .twin-stage { width: 100%; + max-width: 100%; + min-width: 0; height: 100% !important; min-height: 0; background: - radial-gradient(ellipse at 55% 82%, rgba(62, 119, 115, 0.13), transparent 38%), - linear-gradient(165deg, #f0f5f3 0%, #e3ebe8 100%); + radial-gradient(ellipse at 50% 82%, rgba(47, 128, 237, 0.14), transparent 40%), + linear-gradient(165deg, #f4f9ff 0%, #e5f1ff 100%); } .console-v2 .joint-twin-shell #seatTwinCanvas { @@ -2753,7 +2760,10 @@ } .console-v2 .v2-twin-hotspot-label { - min-width: 78px; + width: 94px; + min-width: 94px; + max-width: 94px; + box-sizing: border-box; min-height: 34px; position: absolute; top: calc(50% + var(--v2-hotspot-label-y)); @@ -2770,8 +2780,9 @@ border: 1px solid rgba(151, 232, 222, 0.24); border-radius: 9px; box-shadow: 0 8px 20px rgba(21, 70, 69, 0.2); + overflow: hidden; text-align: left; - white-space: nowrap; + white-space: normal; transform: translate(-50%, -50%) scale(var(--v2-hotspot-label-scale)); transform-origin: center; backdrop-filter: blur(8px); @@ -2787,19 +2798,61 @@ } .console-v2 .v2-twin-hotspot-label b { + min-width: 0; grid-column: 2; grid-row: 1; + overflow: hidden; font-size: 9px; line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-width: 420px) { + .console-v2 .joint-twin-shell .twin-title-copy { + display: none; + } + + .console-v2 .joint-twin-shell .twin-toolbar { + width: 100%; + } + + .console-v2 .joint-twin-shell .twin-selector { + min-width: 0; + flex: 1; + } + + .console-v2 .joint-twin-shell .twin-seat-button { + min-width: 0; + flex: 1; + padding-inline: 5px; + overflow: hidden; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .console-v2 .v2-twin-hotspot-label { + width: 84px; + min-width: 84px; + max-width: 84px; + grid-template-columns: 18px minmax(0, 1fr); + gap: 1px 4px; + padding: 5px 6px; + } } .console-v2 .v2-twin-hotspot-label small { + min-width: 0; grid-column: 2; grid-row: 2; + overflow: hidden; color: rgba(221, 246, 242, 0.7); font-size: 7px; font-weight: 700; line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; } .console-v2 .v2-twin-hotspots button:hover, @@ -2838,8 +2891,8 @@ width: min(360px, calc(100% - 34px)); max-height: min(190px, calc(100% - 78px)); position: absolute; - top: 52px; - left: 50%; + top: 12px; + left: 12px; z-index: 25; padding: 10px; overflow: auto; @@ -2848,8 +2901,10 @@ border: 1px solid rgba(10, 115, 108, 0.18); border-radius: 14px; box-shadow: 0 18px 42px rgba(27, 74, 72, 0.2); - transform: translateX(-50%); + transform: none; + transform-origin: center; backdrop-filter: blur(14px); + will-change: left, top; } .console-v2 .v2-twin-device-popover[hidden] { @@ -2857,9 +2912,14 @@ } .console-v2 .v2-twin-device-popover[data-pinned="false"] { + width: min(280px, calc(100% - 24px)); pointer-events: none; } +.console-v2 .v2-twin-device-popover[data-pinned="false"] dl { + grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); +} + .console-v2 .v2-twin-device-popover[data-pinned="false"] header { grid-template-columns: 30px minmax(0, 1fr) auto; } @@ -5928,7 +5988,7 @@ body.device-power-drawer-open { display: none !important; } -/* Anchors follow the rendered vehicle; labels fan out along readable leader lines. */ +/* Boot-time fallbacks; WebGL projection replaces these values every frame. */ .console-v2 .v2-twin-hotspots [data-twin-device="windows"] { top: 50%; left: 42%; --v2-hotspot-label-x: -115px; --v2-hotspot-label-y: 0px; --v2-hotspot-line-length: 115px; --v2-hotspot-line-angle: 180deg; } .console-v2 .v2-twin-hotspots [data-twin-device="shade"] { top: 25%; left: 53%; --v2-hotspot-label-x: 0px; --v2-hotspot-label-y: -65px; --v2-hotspot-line-length: 65px; --v2-hotspot-line-angle: -90deg; } .console-v2 .v2-twin-hotspots [data-twin-device="driver"] { top: 45%; left: 62%; --v2-hotspot-label-x: 110px; --v2-hotspot-label-y: -70px; --v2-hotspot-line-length: 130px; --v2-hotspot-line-angle: -32deg; } @@ -6889,6 +6949,927 @@ body.device-power-drawer-open { margin: 1px 0; } +/* AI assistant light-blue semantic layer -------------------------------- */ +.console-v2 { + --v2-accent: var(--primary); + --v2-accent-strong: color-mix(in oklch, var(--primary) 78%, var(--heading-foreground)); + --v2-accent-soft: var(--status-info-soft); + --v2-ink: var(--body-foreground); + --v2-muted: var(--subtle-foreground); + --v2-shadow: 0 16px 42px color-mix(in oklch, var(--foreground) 10%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync, +.console-v2.v2-passive-display.v2-interactive-sync .workspace { + color: var(--body-foreground); + background: + radial-gradient(circle at 76% -10%, color-mix(in oklch, var(--primary) 13%, transparent), transparent 32%), + linear-gradient(145deg, var(--background), var(--secondary)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel { + color: var(--body-foreground); + background: + radial-gradient(circle at 42% -12%, color-mix(in oklch, var(--card) 92%, transparent), transparent 37%), + linear-gradient(150deg, var(--background), color-mix(in oklch, var(--secondary) 78%, var(--background))); +} + +.console-v2.v2-passive-display.v2-interactive-sync .command-panel.v2-sidebar { + color: var(--body-foreground); + background: + radial-gradient(circle at 12% 2%, color-mix(in oklch, var(--card) 88%, transparent), transparent 24%), + linear-gradient(158deg, var(--sidebar), var(--sidebar-accent)); + border-right-color: var(--sidebar-border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-module-switcher, +.console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 88%, var(--accent)); + border-color: var(--border); + box-shadow: 0 6px 20px color-mix(in oklch, var(--foreground) 7%, transparent); +} + +.console-v2 .v2-unified-title, +.console-v2 .v2-sidebar-title, +.console-v2 .v2-operation-heading h2, +.console-v2 .v2-output-heading h2, +.console-v2 .joint-twin-shell .twin-heading h3, +.console-v2 .v2-device-sync-board > header h3, +.console-v2 .v2-extension-sync-heading h3, +.console-v2 .workspace-panel-title h3, +.console-v2 .config-panel-heading h3 { + color: var(--heading-foreground); +} + +.console-v2 .v2-unified-subtitle, +.console-v2 .v2-operation-heading p, +.console-v2 .v2-output-heading p, +.console-v2 .provider-note, +.console-v2 .v2-task-timeline, +.console-v2 .v2-device-sync-board > header small, +.console-v2 .v2-extension-sync-heading small { + color: var(--subtle-foreground); +} + +.console-v2 .v2-module-tab { + color: var(--subtle-foreground); + background: transparent; + border-color: transparent; +} + +.console-v2 .v2-module-tab:hover, +.console-v2 .v2-module-tab:focus-visible { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--border); +} + +.console-v2 .v2-module-tab.active, +.console-v2 .v2-module-tab[aria-selected="true"] { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 5px 14px color-mix(in oklch, var(--primary) 20%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button:hover, +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button:focus-visible { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--ring); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.active, +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button[aria-pressed="true"], +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.running { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.succeeded { + color: var(--status-success-foreground); + background: var(--status-success-soft); + border-color: color-mix(in oklch, var(--status-success) 38%, var(--border)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.failed { + color: var(--status-error-foreground); + background: var(--status-error-soft); + border-color: color-mix(in oklch, var(--status-error) 38%, var(--border)); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-icon { + color: var(--primary); + background: var(--status-info-soft); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-summary, +.console-v2 .main-panel > .v2-mode-rail .mode-chip-run, +.console-v2 .main-panel > .v2-mode-rail .mode-group-label { + color: var(--subtle-foreground); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell > .twin-band, +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-sync-mount { + color: var(--body-foreground); + background: + radial-gradient(circle at 82% 0%, color-mix(in oklch, var(--primary) 8%, transparent), transparent 18rem), + var(--card); + border-color: var(--border); + box-shadow: 0 14px 32px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-heading, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-sync-board > header, +.console-v2.v2-passive-display.v2-interactive-sync .v2-extension-sync-heading { + background: linear-gradient(90deg, var(--secondary), color-mix(in oklch, var(--card) 92%, var(--accent))); + border-bottom-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-stage { + background: + radial-gradient(ellipse at 53% 57%, color-mix(in oklch, var(--primary) 13%, transparent), transparent 41%), + linear-gradient(165deg, var(--card), var(--secondary)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 82%, var(--secondary)); + border-color: var(--border); + box-shadow: 0 5px 14px color-mix(in oklch, var(--foreground) 6%, transparent); +} + +/* Unified top-right panel controls --------------------------------------- + All four workspace panels share one stable icon dock. */ +.console-v2.v2-passive-display.v2-interactive-sync .v2-module-switcher { + position: relative; + z-index: 150; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 10px; +} + +.console-v2 .v2-panel-control-dock { + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + padding: 3px; + background: color-mix(in oklch, var(--card) 74%, var(--secondary)); + border: 1px solid color-mix(in oklch, var(--primary) 12%, var(--border)); + border-radius: 14px; + box-shadow: inset 0 1px 0 color-mix(in oklch, var(--card) 82%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button, +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > #v2SidebarToggle.v2-panel-control-button { + width: 38px !important; + height: 38px !important; + min-width: 38px !important; + min-height: 38px !important; + position: relative !important; + inset: auto !important; + display: inline-grid !important; + grid-column: auto !important; + grid-row: auto !important; + place-items: center; + flex-direction: row !important; + gap: 0 !important; + margin: 0 !important; + padding: 0 !important; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--card) 92%, var(--secondary)); + border: 1px solid transparent; + border-radius: 11px !important; + box-shadow: none; + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; + transform: none !important; + cursor: pointer; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button:hover, +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button:focus-visible { + color: var(--primary); + background: var(--accent); + border-color: color-mix(in oklch, var(--primary) 24%, var(--border)); + box-shadow: 0 5px 14px color-mix(in oklch, var(--primary) 14%, transparent); + outline: 3px solid color-mix(in oklch, var(--ring) 14%, transparent); + outline-offset: 1px; + transform: translateY(-1px) !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button.active, +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button[aria-pressed="true"] { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 7px 17px color-mix(in oklch, var(--primary) 24%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button svg { + width: 18px; + height: 18px; +} + +.console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button > span { + width: 1px !important; + height: 1px !important; + position: absolute !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; +} + +/* A collapsed left panel and empty right rail must consume no canvas width. */ +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .workspace { + grid-template-columns: 0 minmax(0, 1fr); +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .command-panel.v2-sidebar { + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(360px, 1fr) clamp(320px, 27vw, 402px); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(0, 1fr); + gap: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-unified-right-rail, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] .v2-unified-right-rail { + grid-template-columns: minmax(0, 1fr); + gap: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-unified-right-rail[hidden], +.console-v2.v2-passive-display.v2-interactive-sync .v2-unified-right-rail.is-collapsed, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] .v2-unified-right-rail { + display: none !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock { + display: none !important; +} + +@media (max-width: 1180px) { + .console-v2.v2-passive-display.v2-interactive-sync .v2-module-switcher { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + } + + .console-v2.v2-passive-display.v2-interactive-sync .v2-remote-control-state { + display: none; + } + + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(300px, 1fr) clamp(288px, 35vw, 342px); + } + + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 760px) { + .console-v2.v2-passive-display.v2-interactive-sync .v2-module-switcher { + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + padding-inline: 8px; + } + + .console-v2.v2-passive-display.v2-interactive-sync .v2-unified-live { + display: none; + } + + .console-v2 .v2-panel-control-dock { + gap: 3px; + padding: 2px; + } + + .console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button, + .console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > #v2SidebarToggle.v2-panel-control-button { + width: 36px !important; + height: 36px !important; + min-width: 36px !important; + min-height: 36px !important; + } +} + +@media (max-width: 460px) { + .console-v2.v2-passive-display.v2-interactive-sync .v2-unified-brand h1 { + max-width: 108px; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .console-v2.v2-passive-display.v2-interactive-sync .v2-unified-brand-mark { + width: 30px; + height: 30px; + } + + .console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > .v2-panel-control-button, + .console-v2.v2-passive-display.v2-interactive-sync #v2PanelControlDock > #v2SidebarToggle.v2-panel-control-button { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; + min-height: 32px !important; + } +} + +.console-v2 .workspace-panel-state.succeeded, +.console-v2 .execution-summary.succeeded, +.console-v2 .v2-sync-log li[data-log-status="success"] > span { + color: var(--status-success-foreground); + background: var(--status-success-soft); + border-color: color-mix(in oklch, var(--status-success) 34%, var(--border)); +} + +.console-v2 .workspace-panel-state.partial, +.console-v2 .execution-summary.partial, +.console-v2 .validation-item:not(.error) { + color: var(--status-warning-foreground); + background: var(--status-warning-soft); + border-color: color-mix(in oklch, var(--status-warning) 34%, var(--border)); +} + +.console-v2 .workspace-panel-state.failed, +.console-v2 .execution-summary.failed, +.console-v2 .validation-item.error, +.console-v2 .v2-sync-log li[data-log-status="error"] > span { + color: var(--status-error-foreground); + background: var(--status-error-soft); + border-color: color-mix(in oklch, var(--status-error) 34%, var(--border)); +} + +/* Compact function-mode strip ------------------------------------------- */ +.visually-hidden { + width: 1px !important; + height: 1px !important; + position: absolute !important; + margin: -1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail { + min-width: 0; + min-height: 54px; + position: relative; + z-index: 16; + display: block; + margin: 0; + padding: 0; +} + +.console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar { + width: 100%; + min-height: 54px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 5px 7px; + overflow: hidden; + color: var(--card-foreground); + background: + linear-gradient(100deg, color-mix(in oklch, var(--primary) 5%, var(--card)), var(--card) 34%), + var(--card); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 8px 22px color-mix(in oklch, var(--foreground) 6%, transparent); +} + +.console-v2 .v2-mode-rail .mode-rail-identity { + min-width: 122px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 5px 0 2px; +} + +.console-v2 .v2-mode-rail .mode-rail-mark { + width: 32px; + height: 32px; + flex: 0 0 32px; + display: grid; + place-items: center; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 10%, var(--card)); + border: 1px solid color-mix(in oklch, var(--primary) 15%, var(--border)); + border-radius: 10px; +} + +.console-v2 .v2-mode-rail .mode-rail-mark svg { + width: 15px; + height: 15px; +} + +.console-v2 .v2-mode-rail .mode-rail-identity > span:last-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.console-v2 .v2-mode-rail .mode-rail-title { + color: var(--foreground); + font-size: 11px; + font-weight: 850; + line-height: 1.05; + white-space: nowrap; +} + +.console-v2 .v2-mode-rail .mode-rail-identity small { + color: var(--muted-foreground); + font-size: 8px; + font-weight: 650; + line-height: 1.1; + white-space: nowrap; +} + +.console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar > .mode-rail-content { + width: auto; + min-width: 0; + max-height: none; + position: static; + inset: auto; + display: block; + margin: 0; + padding: 0; + overflow: hidden; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar > .mode-rail-content[hidden] { + display: none !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 1px 2px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-behavior: smooth; + overscroll-behavior-x: contain; + touch-action: pan-x; + cursor: grab; + outline: none; + transition: box-shadow 150ms ease; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips::-webkit-scrollbar { + display: none; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips:focus-visible { + box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--primary) 34%, transparent); + border-radius: 12px; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips.is-overflowing.can-scroll-right { + box-shadow: inset -14px 0 12px -14px color-mix(in oklch, var(--foreground) 48%, transparent); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips.is-overflowing.can-scroll-left { + box-shadow: inset 14px 0 12px -14px color-mix(in oklch, var(--foreground) 48%, transparent); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips.is-overflowing.can-scroll-left.can-scroll-right { + box-shadow: + inset 14px 0 12px -14px color-mix(in oklch, var(--foreground) 48%, transparent), + inset -14px 0 12px -14px color-mix(in oklch, var(--foreground) 48%, transparent); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips.is-dragging { + cursor: grabbing; + scroll-behavior: auto; + user-select: none; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips.is-dragging > * { + pointer-events: none; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-group-label { + flex: 0 0 auto; + padding: 0 1px 0 3px; + color: var(--muted-foreground); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-group-divider { + width: 1px; + height: 26px; + flex: 0 0 1px; + align-self: center; + margin: 0 2px; + background: var(--border); + border-radius: 999px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button { + width: auto; + min-width: 116px; + max-width: 154px; + min-height: 40px; + height: 40px; + flex: 0 0 auto; + display: grid; + grid-template-columns: 25px minmax(55px, 1fr) 13px; + grid-template-rows: 1fr 1fr; + align-items: center; + justify-content: stretch; + column-gap: 6px; + row-gap: 0; + padding: 4px 8px 4px 5px; + overflow: hidden; + text-align: left; + white-space: nowrap; + border-radius: 11px; + box-shadow: none; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-icon { + width: 25px; + height: 25px; + grid-column: 1; + grid-row: 1 / 3; + align-self: center; + padding: 6px; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 10%, var(--card)); + border-radius: 8px; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-label { + min-width: 0; + grid-column: 2; + grid-row: 1; + align-self: end; + overflow: hidden; + font-size: 10px; + font-weight: 820; + line-height: 1.15; + text-overflow: ellipsis; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-summary { + min-width: 0; + grid-column: 2; + grid-row: 2; + align-self: start; + overflow: hidden; + color: var(--muted-foreground); + font-size: 7.5px; + font-weight: 650; + line-height: 1.15; + text-overflow: ellipsis; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chip-run { + width: 12px; + height: 12px; + grid-column: 3; + grid-row: 1 / 3; + align-self: center; + color: var(--muted-foreground); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button:hover .mode-chip-run, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button:focus-visible .mode-chip-run { + color: var(--primary); +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button.active .mode-chip-icon, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button[aria-pressed="true"] .mode-chip-icon, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button.active .mode-chip-summary, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button[aria-pressed="true"] .mode-chip-summary, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button.active .mode-chip-run, +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button[aria-pressed="true"] .mode-chip-run { + color: currentColor; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-cycle-badge { + display: none; +} + +.console-v2 .v2-mode-rail .mode-rail-actions { + min-width: max-content; + display: flex; + align-items: center; + gap: 5px; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > button, +.console-v2 .v2-mode-rail #saveModeToggle { + min-width: 0; + min-height: 34px; + height: 34px; + display: inline-flex !important; + align-items: center; + justify-content: center; + gap: 5px; + grid-column: auto; + grid-row: auto; + padding: 5px 9px; + color: var(--foreground); + background: color-mix(in oklch, var(--secondary) 86%, var(--card)); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: none; + font-size: 9px; + font-weight: 800; + white-space: nowrap; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > .mode-create-toggle, +.console-v2 .v2-mode-rail #saveModeToggle { + color: var(--primary); + background: color-mix(in oklch, var(--primary) 8%, var(--card)); + border-color: color-mix(in oklch, var(--primary) 20%, var(--border)); +} + +.console-v2 .v2-mode-rail .mode-rail-actions > button:hover, +.console-v2 .v2-mode-rail .mode-rail-actions > button:focus-visible { + color: var(--primary); + background: color-mix(in oklch, var(--primary) 11%, var(--card)); + border-color: color-mix(in oklch, var(--primary) 30%, var(--border)); + outline: none; + transform: none; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > button svg { + width: 14px; + height: 14px; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > .mode-gallery-toggle strong { + min-width: 17px; + min-height: 17px; + height: 17px; + padding: 0 4px; + font-size: 8px; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > .mode-rail-toggle { + width: 32px; + padding: 0; +} + +.console-v2 .v2-mode-rail .mode-rail-actions > .mode-rail-toggle svg, +.console-v2 .v2-mode-rail .mode-rail-actions > .mode-rail-toggle[aria-expanded="true"] svg:last-child { + transform: none !important; +} + +.console-v2 .main-panel > .v2-mode-rail.is-collapsed > .mode-rail-bar { + grid-template-columns: minmax(0, 1fr) auto; +} + +.console-v2 .main-panel > .v2-mode-rail.is-collapsed .mode-rail-identity { + min-width: 0; +} + +.console-v2 .main-panel > .v2-mode-rail .mode-chips > button.just-created { + border-color: color-mix(in oklch, var(--primary) 58%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 13%, transparent), + 0 8px 18px color-mix(in oklch, var(--primary) 12%, transparent); + animation: v2-mode-created-pulse 900ms ease-out 2; +} + +@keyframes v2-mode-created-pulse { + 0%, 100% { transform: translateY(0); } + 45% { transform: translateY(-2px); } +} + +@media (max-width: 1180px) { + .console-v2 .v2-mode-rail .mode-rail-identity { + min-width: 36px; + } + + .console-v2 .v2-mode-rail .mode-rail-identity > span:last-child { + display: none; + } + + .console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button { + min-width: 108px; + } +} + +@media (max-width: 760px) { + .console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar { + gap: 6px; + padding-inline: 5px; + } + + .console-v2 .v2-mode-rail .mode-rail-mark { + width: 30px; + height: 30px; + flex-basis: 30px; + } + + .console-v2 .v2-mode-rail .mode-rail-actions > button span, + .console-v2 .v2-mode-rail .mode-rail-actions > .mode-gallery-toggle strong { + display: none; + } + + .console-v2 .v2-mode-rail .mode-rail-actions > button { + width: 32px; + padding: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .console-v2 .main-panel > .v2-mode-rail .mode-chips > button.just-created { + animation: none; + } +} + +/* Fixed side-panel dock: buttons never move while panel content expands. */ +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(420px, 1fr) clamp(330px, 27vw, 402px); + grid-template-rows: minmax(0, 1fr); + gap: 10px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(420px, 1fr) 52px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell > .twin-band { + min-width: 0; + grid-column: 1; + grid-row: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-unified-right-rail, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] .v2-unified-right-rail { + min-width: 0; + min-height: 0; + display: grid !important; + grid-column: 2; + grid-row: 1; + grid-template-columns: minmax(0, 1fr) 52px; + grid-template-rows: minmax(0, 1fr) !important; + gap: 8px; + overflow: hidden; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] .v2-unified-right-rail { + grid-template-columns: 52px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack { + min-width: 0; + min-height: 0; + display: grid; + grid-column: 1; + grid-row: 1; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + overflow: hidden; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="devices extensions"] .v2-side-status-stack { + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] .v2-side-status-stack { + display: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack #v2DevicePanel, +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack #v2ExtensionPanel { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + grid-column: 1; + align-self: stretch; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack #v2DevicePanel { + grid-row: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack #v2ExtensionPanel { + grid-row: 2; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="extensions"] .v2-side-status-stack #v2ExtensionPanel { + grid-row: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-status-stack .v2-side-status-panel.is-collapsed { + display: none !important; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock { + min-width: 0; + min-height: 0; + display: grid; + grid-column: 2; + grid-row: 1; + grid-auto-rows: 52px; + align-content: start; + gap: 8px; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] .v2-side-panel-dock { + grid-column: 1; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock button { + width: 52px; + height: 52px; + min-width: 52px; + min-height: 52px; + display: grid; + place-items: center; + padding: 0; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--card) 94%, transparent); + border: 1px solid var(--border); + border-radius: 15px; + box-shadow: 0 8px 20px color-mix(in oklch, var(--foreground) 7%, transparent); + cursor: pointer; + transition: color 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock button:hover, +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock button:focus-visible { + color: var(--primary); + background: var(--accent); + border-color: color-mix(in oklch, var(--primary) 26%, var(--border)); + outline: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock button.active { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 10px 24px color-mix(in oklch, var(--primary) 24%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-side-panel-dock button svg { + width: 20px; + height: 20px; +} + +@media (max-width: 1180px) { + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell.v2-output-page, + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] { + grid-template-columns: minmax(320px, 1fr) clamp(292px, 36vw, 342px); + } + + .console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(320px, 1fr) 52px; + } +} + .console-v2 .mode-gallery-add-card { padding-right: 9px; border-style: dashed; @@ -8417,3 +9398,162 @@ body.device-power-drawer-open { grid-column: 1 / -1; margin: 1px 0; } + +/* AI assistant light-blue final override -------------------------------- + This is the actual final color layer; layout rules above remain intact. */ +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .workspace { + grid-template-columns: 0 minmax(0, 1fr); +} + +.console-v2.v2-passive-display.v2-interactive-sync.v2-sidebar-collapsed .command-panel.v2-sidebar { + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] { + grid-template-columns: minmax(0, 1fr); + gap: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] .v2-unified-right-rail { + grid-template-columns: minmax(0, 1fr); + gap: 0; +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels="none"] .v2-unified-right-rail, +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] .v2-unified-right-rail[hidden], +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] .v2-unified-right-rail.is-collapsed { + display: none !important; +} + +@media (max-width: 1180px) { + .console-v2.v2-passive-display.v2-interactive-sync { + min-width: 0; + } +} + +.console-v2.v2-passive-display.v2-interactive-sync, +.console-v2.v2-passive-display.v2-interactive-sync .workspace { + color: var(--body-foreground); + background: + radial-gradient(circle at 76% -10%, color-mix(in oklch, var(--primary) 13%, transparent), transparent 32%), + linear-gradient(145deg, var(--background), var(--secondary)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel { + color: var(--body-foreground); + background: + radial-gradient(circle at 42% -12%, color-mix(in oklch, var(--card) 92%, transparent), transparent 37%), + linear-gradient(150deg, var(--background), color-mix(in oklch, var(--secondary) 78%, var(--background))); +} + +.console-v2.v2-passive-display.v2-interactive-sync .command-panel.v2-sidebar { + color: var(--body-foreground); + background: + radial-gradient(circle at 12% 2%, color-mix(in oklch, var(--card) 88%, transparent), transparent 24%), + linear-gradient(158deg, var(--sidebar), var(--sidebar-accent)); + border-right-color: var(--sidebar-border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-module-switcher, +.console-v2 .main-panel > .v2-mode-rail > .mode-rail-bar { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 88%, var(--accent)); + border-color: var(--border); + box-shadow: 0 6px 20px color-mix(in oklch, var(--foreground) 7%, transparent); +} + +.console-v2 .v2-unified-title, +.console-v2 .v2-sidebar-title, +.console-v2 .v2-operation-heading h2, +.console-v2 .v2-output-heading h2, +.console-v2 .joint-twin-shell .twin-heading h3, +.console-v2 .v2-device-sync-board > header h3, +.console-v2 .v2-extension-sync-heading h3, +.console-v2 .workspace-panel-title h3, +.console-v2 .config-panel-heading h3 { + color: var(--heading-foreground); +} + +.console-v2 .v2-unified-subtitle, +.console-v2 .v2-operation-heading p, +.console-v2 .v2-output-heading p, +.console-v2 .provider-note, +.console-v2 .v2-task-timeline, +.console-v2 .v2-device-sync-board > header small, +.console-v2 .v2-extension-sync-heading small { + color: var(--subtle-foreground); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button:hover, +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button:focus-visible { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--ring); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.active, +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button[aria-pressed="true"], +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.running { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.succeeded { + color: var(--status-success-foreground); + background: var(--status-success-soft); + border-color: color-mix(in oklch, var(--status-success) 38%, var(--border)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail .mode-chips > button.failed { + color: var(--status-error-foreground); + background: var(--status-error-soft); + border-color: color-mix(in oklch, var(--status-error) 38%, var(--border)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell > .twin-band, +.console-v2.v2-passive-display.v2-interactive-sync #v2DevicePanel, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-sync-mount { + color: var(--body-foreground); + background: + radial-gradient(circle at 82% 0%, color-mix(in oklch, var(--primary) 8%, transparent), transparent 18rem), + var(--card); + border-color: var(--border); + box-shadow: 0 14px 32px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-heading, +.console-v2.v2-passive-display.v2-interactive-sync .v2-device-sync-board > header, +.console-v2.v2-passive-display.v2-interactive-sync .v2-extension-sync-heading { + color: var(--heading-foreground); + background: linear-gradient(90deg, var(--secondary), color-mix(in oklch, var(--card) 92%, var(--accent))); + border-bottom-color: var(--border); +} + +.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-stage { + background: + radial-gradient(ellipse at 53% 57%, color-mix(in oklch, var(--primary) 13%, transparent), transparent 41%), + linear-gradient(165deg, var(--card), var(--secondary)); +} + +.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway, +.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device { + color: var(--body-foreground); + background: color-mix(in oklch, var(--card) 82%, var(--secondary)); + border-color: var(--border); + box-shadow: 0 5px 14px color-mix(in oklch, var(--foreground) 6%, transparent); +} diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index c256dcc..ce92d6d 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -17,7 +17,7 @@ : "natural", activeModule: "self-orchestration", activePage: 0, - sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true", + sidebarCollapsed: true, }; let reasoningPickerControl = null; @@ -580,13 +580,17 @@ function buildSidebar(commandPanel) { commandPanel.classList.add("v2-sidebar"); + commandPanel.id = "v2SidebarPanel"; commandPanel.innerHTML = ""; - const sidebarToggle = make("button", "v2-sidebar-toggle", ''); + const sidebarToggle = make("button", "v2-sidebar-toggle v2-panel-control-button", '左侧控制台'); sidebarToggle.id = "v2SidebarToggle"; sidebarToggle.type = "button"; - sidebarToggle.setAttribute("aria-label", "折叠左侧控制台"); - sidebarToggle.setAttribute("aria-expanded", "true"); + sidebarToggle.setAttribute("aria-controls", commandPanel.id); + sidebarToggle.setAttribute("aria-label", "展开左侧控制台"); + sidebarToggle.setAttribute("aria-pressed", "false"); + sidebarToggle.setAttribute("aria-expanded", "false"); + sidebarToggle.title = "展开左侧控制台"; const footer = make("footer", "v2-sidebar-footer", ` @@ -617,7 +621,7 @@ }); } - function buildOperation(mainPanel, originalForm, panelHeading, modeRail, requestHeader, gestureDrawer) { + function buildOperation(mainPanel, originalForm, panelHeading, requestHeader, gestureDrawer) { const operation = make("section", "v2-operation-shell", `
@@ -628,7 +632,7 @@ if (panelHeading) { panelHeading.classList.add("v2-operation-heading", "v2-module-heading"); const title = $("h2", panelHeading); - if (title) title.textContent = "一句话远程编排"; + if (title) title.textContent = "AI助手"; naturalPanel.append(panelHeading); } @@ -641,11 +645,6 @@ naturalGrid.append(requestHeader); } naturalPanel.append(naturalGrid); - if (modeRail) { - modeRail.classList.add("v2-mode-rail"); - naturalPanel.append(modeRail); - } - const gesturePanel = $("#v2GesturePanel"); const gestureHeading = make("header", "v2-operation-heading v2-module-heading v2-gesture-operation-heading", `

抬抬手,控制整车

`); @@ -699,12 +698,16 @@

511工程-智能座舱

网页控制链路已启用
-
LIVE--:--:--
`; +
LIVE--:--:--
+ `; mainPanel.prepend(switcher); + const panelDock = $("#v2PanelControlDock", switcher); + const sidebarToggle = $("#v2SidebarToggle"); + if (panelDock && sidebarToggle) panelDock.append(sidebarToggle); } - function buildOutput(mainPanel) { + function buildOutput(mainPanel, modeRail) { const output = make("section", "v2-output-shell", `
@@ -717,6 +720,11 @@ `); mainPanel.append(output); + if (modeRail) { + modeRail.classList.add("v2-mode-rail"); + mainPanel.insertBefore(modeRail, output); + } + const content = $("#v2OutputContent"); const twin = $(".joint-twin-shell", mainPanel); const resultWorkspace = $(".result-workspace", mainPanel); @@ -1165,14 +1173,17 @@ function setSidePanelVisibility(key, visible) { const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel"); if (!panel) return; - panel.hidden = false; + const panelName = key === "devices" ? "车端设备状态" : "外设与扩展能力"; + panel.hidden = !visible; panel.classList.toggle("is-collapsed", !visible); panel.dataset.collapsed = String(!visible); sessionStorage.setItem(`cognitive-agent.sync-panel.${key}`, visible ? "visible" : "hidden"); $$(`[data-panel-toggle="${key}"], [data-panel-close="${key}"]`).forEach((button) => { button.classList.toggle("active", visible); button.setAttribute("aria-pressed", String(visible)); - button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${key === "devices" ? "车端设备状态" : "外设与扩展能力"}`); + button.setAttribute("aria-expanded", String(visible)); + button.setAttribute("aria-label", `${visible ? "收起" : "展开"}${panelName}`); + button.title = `${visible ? "收起" : "展开"}${panelName}`; const actionLabel = $("[data-panel-action-label]", button); if (actionLabel) actionLabel.textContent = visible ? "收起" : "展开"; const stateLabel = $("[data-panel-state-label]", button); @@ -1183,9 +1194,234 @@ return candidate && !candidate.classList.contains("is-collapsed"); }); $(".joint-twin-shell")?.setAttribute("data-open-panels", openPanels.join(" ") || "none"); + const rightRail = $(".v2-unified-right-rail"); + if (rightRail) { + const collapsed = openPanels.length === 0; + rightRail.hidden = collapsed; + rightRail.classList.toggle("is-collapsed", collapsed); + rightRail.toggleAttribute("inert", collapsed); + rightRail.dataset.openPanels = openPanels.join(" ") || "none"; + } + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); window.dispatchEvent(new Event("resize")); } + const twinPopoverClamp = (value, minimum, maximum) => ( + maximum < minimum ? minimum : Math.max(minimum, Math.min(maximum, value)) + ); + + function twinPopoverIntersectionArea(first, second) { + const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left)); + const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top)); + return width * height; + } + + function positionTwinDevicePopover(deviceId = displaySync.activeDevice) { + const stage = $("#seatTwin"); + const popover = $("#v2TwinDevicePopover"); + const hotspot = deviceId + ? $(`[data-twin-device="${deviceId}"]`, stage) + : null; + const hotspotLabel = hotspot ? $(".v2-twin-hotspot-label", hotspot) : null; + if (!stage || !popover || popover.hidden || !hotspot || !hotspotLabel) return null; + if (hotspot.hidden) { + popover.dataset.anchorVisible = "false"; + if (popover.dataset.pinned === "false") popover.hidden = true; + return null; + } + popover.dataset.anchorVisible = "true"; + + const stageRect = stage.getBoundingClientRect(); + const labelClientRect = hotspotLabel.getBoundingClientRect(); + const popoverWidth = popover.offsetWidth; + const popoverHeight = popover.offsetHeight; + if (!stageRect.width || !stageRect.height || !popoverWidth || !popoverHeight) return null; + + const margin = stageRect.width < 520 ? 8 : 12; + const gap = stageRect.width < 520 ? 8 : 12; + const bounds = { + left: margin, + top: margin, + right: Math.max(margin, stageRect.width - margin), + bottom: Math.max(margin, stageRect.height - margin), + }; + const labelRect = { + left: labelClientRect.left - stageRect.left, + top: labelClientRect.top - stageRect.top, + right: labelClientRect.right - stageRect.left, + bottom: labelClientRect.bottom - stageRect.top, + }; + labelRect.width = labelRect.right - labelRect.left; + labelRect.height = labelRect.bottom - labelRect.top; + labelRect.centerX = (labelRect.left + labelRect.right) / 2; + labelRect.centerY = (labelRect.top + labelRect.bottom) / 2; + + const toStageRect = (element, padding = 0) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left - stageRect.left - padding, + top: rect.top - stageRect.top - padding, + right: rect.right - stageRect.left + padding, + bottom: rect.bottom - stageRect.top + padding, + }; + }; + const visibleElement = (element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const labelObstacles = $$(".v2-twin-hotspot-label", stage) + .filter((element) => element !== hotspotLabel && visibleElement(element)) + .map((element) => ({ + deviceId: element.closest("[data-twin-device]")?.dataset.twinDevice || "", + rect: toStageRect(element, 3), + })); + const chromeObstacles = $$([".twin-seat-label", ".twin-orientation", ".twin-readout:not([hidden])"].join(","), stage) + .filter(visibleElement) + .map((element) => toStageRect(element, 5)); + const activeLabelObstacle = { + left: labelRect.left - 10, + top: labelRect.top - 10, + right: labelRect.right + 10, + bottom: labelRect.bottom + 10, + }; + const geometrySignature = [ + deviceId, + popover.dataset.pinned, + Math.round(stageRect.width), + Math.round(stageRect.height), + Math.round(popoverWidth), + Math.round(popoverHeight), + Math.round(labelRect.left), + Math.round(labelRect.top), + ...labelObstacles.flatMap(({ deviceId: obstacleDeviceId, rect }) => [ + obstacleDeviceId, + Math.round(rect.left), + Math.round(rect.top), + ]), + ...chromeObstacles.flatMap((rect) => [Math.round(rect.left), Math.round(rect.top)]), + ].join(":"); + if (popover.dataset.layoutSignature === geometrySignature) return null; + popover.dataset.layoutSignature = geometrySignature; + + const availableSpace = { + right: bounds.right - labelRect.right, + left: labelRect.left - bounds.left, + bottom: bounds.bottom - labelRect.bottom, + top: labelRect.top - bounds.top, + }; + const sideOrder = Object.keys(availableSpace).sort((first, second) => ( + availableSpace[second] - availableSpace[first] + )); + const rawCandidates = []; + const pushSideCandidates = (side) => { + const horizontal = side === "left" || side === "right"; + const alignments = horizontal + ? [ + [labelRect.centerY - popoverHeight / 2, "center"], + [labelRect.top, "start"], + [labelRect.bottom - popoverHeight, "end"], + ] + : [ + [labelRect.centerX - popoverWidth / 2, "center"], + [labelRect.left, "start"], + [labelRect.right - popoverWidth, "end"], + ]; + alignments.forEach(([crossAxis, alignment], alignmentIndex) => { + let left = crossAxis; + let top = crossAxis; + if (side === "right") { + left = labelRect.right + gap; + top = crossAxis; + } else if (side === "left") { + left = labelRect.left - gap - popoverWidth; + top = crossAxis; + } else if (side === "bottom") { + left = crossAxis; + top = labelRect.bottom + gap; + } else { + left = crossAxis; + top = labelRect.top - gap - popoverHeight; + } + rawCandidates.push({ left, top, side, alignment, alignmentIndex, anchored: true }); + }); + }; + sideOrder.forEach(pushSideCandidates); + + const maximumLeft = bounds.right - popoverWidth; + const maximumTop = bounds.bottom - popoverHeight; + [bounds.left, labelRect.left - gap - popoverWidth, labelRect.centerX - popoverWidth / 2, + labelRect.right + gap, maximumLeft].forEach((left) => { + [bounds.top, labelRect.top - gap - popoverHeight, labelRect.centerY - popoverHeight / 2, + labelRect.bottom + gap, maximumTop].forEach((top) => { + rawCandidates.push({ left, top, side: "adaptive", alignment: "free", alignmentIndex: 0, anchored: false }); + }); + }); + + const seen = new Set(); + const previousPlacement = popover.dataset.anchorDevice === deviceId + ? `${popover.dataset.placement || ""}:${popover.dataset.alignment || ""}` + : ""; + const candidates = rawCandidates.map((candidate) => { + const left = twinPopoverClamp(candidate.left, bounds.left, maximumLeft); + const top = twinPopoverClamp(candidate.top, bounds.top, maximumTop); + const key = `${Math.round(left)}:${Math.round(top)}`; + if (seen.has(key)) return null; + seen.add(key); + const rect = { + left, + top, + right: left + popoverWidth, + bottom: top + popoverHeight, + }; + const clampDistance = Math.abs(left - candidate.left) + Math.abs(top - candidate.top); + const activeOverlap = twinPopoverIntersectionArea(rect, activeLabelObstacle); + const shadeOverlap = labelObstacles.reduce((total, obstacle) => ( + total + (obstacle.deviceId === "shade" ? twinPopoverIntersectionArea(rect, obstacle.rect) : 0) + ), 0); + const labelOverlap = labelObstacles.reduce((total, obstacle) => ( + total + (obstacle.deviceId === "shade" ? 0 : twinPopoverIntersectionArea(rect, obstacle.rect)) + ), 0); + const chromeOverlap = chromeObstacles.reduce((total, obstacle) => ( + total + twinPopoverIntersectionArea(rect, obstacle) + ), 0); + const distance = Math.hypot( + left + popoverWidth / 2 - labelRect.centerX, + top + popoverHeight / 2 - labelRect.centerY, + ); + const placement = candidate.side === "adaptive" + ? (Math.abs(left + popoverWidth / 2 - labelRect.centerX) > Math.abs(top + popoverHeight / 2 - labelRect.centerY) + ? (left + popoverWidth / 2 >= labelRect.centerX ? "right" : "left") + : (top + popoverHeight / 2 >= labelRect.centerY ? "bottom" : "top")) + : candidate.side; + const placementKey = `${placement}:${candidate.alignment}`; + const sideRank = sideOrder.indexOf(placement); + const score = activeOverlap * 1000 + + shadeOverlap * 640 + + labelOverlap * 36 + + chromeOverlap * 12 + + clampDistance * 90 + + distance * 0.35 + + Math.max(0, sideRank) * 70 + + candidate.alignmentIndex * 8 + + (candidate.anchored ? 0 : 90) + - (placementKey === previousPlacement ? 28 : 0); + return { ...candidate, left, top, placement, score, activeOverlap, shadeOverlap, labelOverlap }; + }).filter(Boolean); + const best = candidates.sort((first, second) => first.score - second.score)[0]; + if (!best) return null; + + popover.style.left = `${Math.round(best.left)}px`; + popover.style.top = `${Math.round(best.top)}px`; + popover.style.removeProperty("right"); + popover.style.removeProperty("bottom"); + popover.dataset.anchorDevice = deviceId; + popover.dataset.placement = best.placement; + popover.dataset.alignment = best.alignment; + popover.dataset.shadeOverlap = String(Math.round(best.shadeOverlap)); + popover.dataset.labelOverlap = String(Math.round(best.labelOverlap)); + return best; + } + function presentTwinDevice(deviceId, { pinned = false, selectSeat = pinned } = {}) { const device = syncedDevices.find((item) => item.id === deviceId); const popover = $("#v2TwinDevicePopover"); @@ -1209,6 +1445,7 @@ $(`[data-twin-seat="${deviceId}"]`)?.click(); } window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + positionTwinDevicePopover(deviceId); } function clearTwinDevicePreview({ force = false } = {}) { @@ -1323,7 +1560,8 @@ const utterance = String(task.utterance || ""); const result = task.result && typeof task.result === "object" ? task.result : null; const input = $("#utterance"); - if (input && utterance && document.activeElement !== input) input.value = utterance; + if (input && utterance && document.activeElement !== input + && !document.body.classList.contains("assistant-chat-enabled")) input.value = utterance; const kind = taskKind(utterance, result, String(task.source || "")); if ($("#v2TaskKind")) $("#v2TaskKind").textContent = kind; if ($("#v2TaskCommand")) $("#v2TaskCommand").textContent = utterance || "等待车机端或网页端选择预设模式、输入一句话指令"; @@ -1447,14 +1685,14 @@ if (input) { input.readOnly = false; input.removeAttribute("aria-readonly"); - input.setAttribute("aria-label", "输入一句话远程编排车端功能"); - input.placeholder = "例如:副驾调到舒适姿态,空调 24℃,氛围灯改成暖橙色"; + input.setAttribute("aria-label", "输入问题或座舱控制需求"); + input.placeholder = "输入问题或座舱控制需求"; } form?.addEventListener("submit", () => { const utterance = input?.value.trim(); if (utterance) appendSyncLog({ category: "task", - title: "网页端发送编排指令", + title: "网页端发送助手请求", detail: utterance, status: "info", }); @@ -1468,7 +1706,7 @@ function syncSidebarLogBaseline() { const sidebar = $(".v2-sidebar"); const lowerPanel = $("#v2DevicePanel") || $(".joint-twin-shell > .twin-band"); - if (!sidebar || !lowerPanel) return; + if (!sidebar || !lowerPanel || ui.sidebarCollapsed) return; const sidebarRect = sidebar.getBoundingClientRect(); const overview = $(".v2-sync-sidebar-overview", sidebar); const overviewRect = overview?.getBoundingClientRect(); @@ -1539,8 +1777,6 @@ }); } syncOverview?.append(sidebarFooter); - const sidebarToggle = $("#v2SidebarToggle", sidebar); - if (sidebarToggle) $(".v2-sync-sidebar-heading", syncSidebar)?.append(sidebarToggle); } const naturalPanel = $("#v2NaturalPanel"); @@ -1554,16 +1790,34 @@ const cabinBand = $(".cabin-band", jointShell); if (jointShell && cabinBand) { const rightRail = make("aside", "v2-unified-right-rail", ` -
-

车端设备状态

实时反馈
-
-
-
`); +
+
+

车端设备状态

实时反馈
+
+
+
+
`); jointShell.insertBefore(rightRail, cabinBand); - const panelControls = make("div", "v2-panel-visibility-controls", ` - - `); - $(".twin-toolbar", jointShell)?.prepend(panelControls); + const panelDock = $("#v2PanelControlDock"); + if (panelDock) { + const deviceToggle = make("button", "v2-panel-control-button", '车端设备状态'); + deviceToggle.type = "button"; + deviceToggle.dataset.panelToggle = "devices"; + deviceToggle.setAttribute("aria-controls", "v2DevicePanel"); + deviceToggle.setAttribute("aria-pressed", "false"); + deviceToggle.setAttribute("aria-expanded", "false"); + deviceToggle.setAttribute("aria-label", "展开车端设备状态"); + deviceToggle.title = "展开车端设备状态"; + const extensionToggle = make("button", "v2-panel-control-button", '外设与扩展能力'); + extensionToggle.type = "button"; + extensionToggle.dataset.panelToggle = "extensions"; + extensionToggle.setAttribute("aria-controls", "v2ExtensionPanel"); + extensionToggle.setAttribute("aria-pressed", "false"); + extensionToggle.setAttribute("aria-expanded", "false"); + extensionToggle.setAttribute("aria-label", "展开外设与扩展能力"); + extensionToggle.title = "展开外设与扩展能力"; + panelDock.append(deviceToggle, extensionToggle); + } buildTwinDeviceInteractions(); cabinBand.classList.add("v2-source-state-only"); new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, { @@ -1580,8 +1834,8 @@ subtree: true, }); } - setSidePanelVisibility("devices", panelVisible("devices")); - setSidePanelVisibility("extensions", panelVisible("extensions")); + setSidePanelVisibility("devices", false); + setSidePanelVisibility("extensions", false); window.addEventListener("resize", syncSidebarLogBaseline); requestAnimationFrame(syncSidebarLogBaseline); document.addEventListener("click", (event) => { @@ -1842,14 +2096,21 @@ String(ui.sidebarCollapsed), ); document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed); + const sidebar = $("#v2SidebarPanel"); + if (sidebar) { + sidebar.setAttribute("aria-hidden", String(ui.sidebarCollapsed)); + sidebar.toggleAttribute("inert", ui.sidebarCollapsed); + } const button = $("#v2SidebarToggle"); if (button) { + const expanded = !ui.sidebarCollapsed; + button.classList.toggle("active", expanded); + button.setAttribute("aria-pressed", String(expanded)); button.setAttribute("aria-expanded", String(!ui.sidebarCollapsed)); - button.setAttribute( - "aria-label", - ui.sidebarCollapsed ? "展开左侧控制台" : "折叠左侧控制台", - ); - button.innerHTML = `${ui.sidebarCollapsed ? "展开" : "收起"}`; + const label = ui.sidebarCollapsed ? "展开左侧控制台" : "收起左侧控制台"; + button.setAttribute("aria-label", label); + button.title = label; + button.innerHTML = '左侧控制台'; } window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180); @@ -1959,12 +2220,11 @@ mainPanel, originalForm, panelHeading, - modeRail, requestHeader, gestureDrawer, ); buildModuleSwitcher(mainPanel); - buildOutput(mainPanel); + buildOutput(mainPanel, modeRail); buildSyncControlLayer(); bindEvents(); setControlMode(ui.controlMode, { triggerGesture: false }); @@ -1983,6 +2243,7 @@ getModule: () => ui.activeModule, getRole: () => "user", getParsedCanCommands: (result) => parsedCanCommands(result).map((item) => ({ ...item })), + positionTwinDevicePopover, setModule, }; diff --git a/apps/web-console/energy-monitor.css b/apps/web-console/energy-monitor.css new file mode 100644 index 0000000..5312b64 --- /dev/null +++ b/apps/web-console/energy-monitor.css @@ -0,0 +1,897 @@ +/* Independent whole-vehicle energy monitor -------------------------------- */ +.console-v2 .energy-monitor-layer { + position: fixed; + z-index: 5000; + inset: 0; + display: grid; + place-items: center; + padding: 68px 20px 20px; + background: color-mix(in oklch, var(--background) 58%, transparent); + backdrop-filter: blur(6px) saturate(.9); + -webkit-backdrop-filter: blur(6px) saturate(.9); + overscroll-behavior: contain; +} + +.console-v2 .energy-monitor-layer[hidden] { + display: none !important; +} + +.console-v2 .energy-monitor-page { + width: min(1180px, 100%); + height: min(664px, 100%); + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + color: var(--foreground); + background: + radial-gradient(circle at 7% 0%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 30%), + color-mix(in oklch, var(--card) 97%, var(--background)); + border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border)); + border-radius: 22px; + box-shadow: + 0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent), + 0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent), + inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent); + font-variant-numeric: tabular-nums; + animation: energy-monitor-enter 180ms ease-out both; +} + +.console-v2 .energy-monitor-header { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 18px 13px 20px; + border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border)); + background: color-mix(in oklch, var(--card) 85%, transparent); +} + +.console-v2 .energy-monitor-heading, +.console-v2 .energy-monitor-live-state, +.console-v2 .energy-monitor-heading > div, +.console-v2 .energy-monitor-heading > span, +.console-v2 .energy-monitor-live-label, +.console-v2 .energy-trend-card > header, +.console-v2 .energy-devices-card > header, +.console-v2 .energy-trend-card > footer, +.console-v2 .energy-device-identity small { + display: flex; + align-items: center; +} + +.console-v2 .energy-monitor-heading { + min-width: 0; + gap: 12px; +} + +.console-v2 .energy-monitor-heading > span { + width: 46px; + height: 46px; + flex: 0 0 46px; + justify-content: center; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 10%, var(--card)); + border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border)); + border-radius: 15px; + box-shadow: inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 70%, transparent); +} + +.console-v2 .energy-monitor-heading > span svg { + width: 24px; + height: 24px; + stroke-width: 1.8; +} + +.console-v2 .energy-monitor-heading > div { + min-width: 0; + align-items: flex-start; + flex-direction: column; + gap: 3px; +} + +.console-v2 .energy-monitor-heading h2, +.console-v2 .energy-trend-card h3, +.console-v2 .energy-devices-card h3 { + margin: 0; + color: var(--heading-foreground, var(--foreground)); + letter-spacing: -.025em; +} + +.console-v2 .energy-monitor-heading h2 { + font-size: 20px; + line-height: 1.2; +} + +.console-v2 .energy-monitor-heading p, +.console-v2 .energy-trend-card header p, +.console-v2 .energy-devices-card header p { + margin: 0; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.45; +} + +.console-v2 .energy-monitor-live-state { + flex: 0 0 auto; + gap: 10px; + color: var(--muted-foreground); + font-size: 12px; +} + +.console-v2 .energy-monitor-live-label { + gap: 6px; + color: var(--primary); + font-weight: 700; +} + +.console-v2 .energy-monitor-live-label > i, +.console-v2 .energy-trend-card > footer span > i { + width: 7px; + height: 7px; + display: block; + background: var(--primary); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in oklch, var(--primary) 12%, transparent); +} + +.console-v2 .energy-monitor-source { + padding: 5px 9px; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 9%, var(--card)); + border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border)); + border-radius: 999px; + font-weight: 700; +} + +.console-v2 .energy-monitor-source[data-source="simulated"], +.console-v2 .energy-monitor-source[data-source="simulated_estimated"], +.console-v2 .energy-monitor-source[data-source="measured_simulated"], +.console-v2 .energy-monitor-source[data-source="measured_simulated_estimated"] { + color: color-mix(in oklch, var(--primary) 72%, #7c3aed); +} + +.console-v2 #energyMonitorClose { + width: 38px; + height: 38px; + display: grid; + flex: 0 0 38px; + place-items: center; + padding: 0; + color: var(--foreground); + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border)); + border-radius: 12px; + box-shadow: 0 4px 12px color-mix(in oklch, var(--foreground) 7%, transparent); + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease, background 150ms ease, transform 150ms ease; +} + +.console-v2 #energyMonitorClose:hover, +.console-v2 #energyMonitorClose:focus-visible { + color: var(--primary); + background: var(--accent); + border-color: color-mix(in oklch, var(--primary) 28%, var(--border)); + outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent); + outline-offset: 1px; + transform: translateY(-1px); +} + +.console-v2 #energyMonitorClose svg { + width: 18px; + height: 18px; +} + +.console-v2 .energy-monitor-content { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + padding: 14px 16px 16px; +} + +.console-v2 .energy-monitor-kpis { + display: grid; + grid-template-columns: 1.18fr 1fr 1fr 1fr; + gap: 10px; +} + +.console-v2 .energy-kpi { + min-width: 0; + min-height: 94px; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + overflow: hidden; + padding: 13px 15px; + background: color-mix(in oklch, var(--card) 94%, var(--secondary)); + border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border)); + border-radius: 15px; + box-shadow: 0 6px 16px color-mix(in oklch, var(--foreground) 5%, transparent); +} + +.console-v2 .energy-kpi-primary { + background: + linear-gradient(135deg, color-mix(in oklch, var(--primary) 10%, var(--card)), color-mix(in oklch, var(--primary) 3%, var(--card))); + border-color: color-mix(in oklch, var(--primary) 42%, var(--border)); + box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 10%, transparent); +} + +.console-v2 .energy-kpi > small { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 650; +} + +.console-v2 .energy-kpi > strong { + min-width: 0; + color: var(--heading-foreground, var(--foreground)); + font-size: clamp(19px, 1.9vw, 28px); + line-height: 1.1; + white-space: nowrap; +} + +.console-v2 .energy-kpi-primary > strong { + display: flex; + align-items: baseline; + gap: 6px; + color: var(--primary); + font-size: clamp(27px, 2.6vw, 38px); +} + +.console-v2 .energy-kpi-primary > strong span { + color: var(--foreground); + font-size: 14px; + font-weight: 700; +} + +.console-v2 .energy-kpi > em { + max-width: 100%; + overflow: hidden; + color: var(--muted-foreground); + font-size: 10px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .energy-kpi-primary > svg { + width: 42px; + height: 42px; + position: absolute; + right: 10px; + top: 10px; + color: color-mix(in oklch, var(--primary) 20%, transparent); + stroke-width: 1.4; +} + +.console-v2 .energy-monitor-dashboard { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.58fr) minmax(330px, .92fr); + gap: 12px; +} + +.console-v2 .energy-trend-card, +.console-v2 .energy-devices-card { + min-width: 0; + min-height: 0; + overflow: hidden; + background: color-mix(in oklch, var(--card) 96%, var(--secondary)); + border: 1px solid color-mix(in oklch, var(--primary) 11%, var(--border)); + border-radius: 16px; + box-shadow: 0 7px 18px color-mix(in oklch, var(--foreground) 5%, transparent); +} + +.console-v2 .energy-trend-card { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + padding: 15px 16px 12px; +} + +.console-v2 .energy-trend-card > header, +.console-v2 .energy-devices-card > header { + justify-content: space-between; + gap: 12px; +} + +.console-v2 .energy-trend-card h3, +.console-v2 .energy-devices-card h3 { + font-size: 15px; +} + +.console-v2 .energy-trend-card > header > strong { + color: var(--primary); + font-size: 16px; + white-space: nowrap; +} + +.console-v2 .energy-trend-tabs { + display: flex; + gap: 7px; + padding: 11px 0 4px; +} + +.console-v2 .energy-trend-tabs button { + min-height: 30px; + padding: 5px 13px; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border: 1px solid var(--border); + border-radius: 9px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease; +} + +.console-v2 .energy-trend-tabs button:hover, +.console-v2 .energy-trend-tabs button:focus-visible { + color: var(--primary); + border-color: color-mix(in oklch, var(--primary) 28%, var(--border)); + outline: 2px solid color-mix(in oklch, var(--ring) 14%, transparent); + outline-offset: 1px; +} + +.console-v2 .energy-trend-tabs button.active { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 5px 13px color-mix(in oklch, var(--primary) 20%, transparent); +} + +.console-v2 .energy-chart-wrap { + min-height: 210px; + position: relative; + overflow: hidden; +} + +.console-v2 .energy-monitor-chart { + width: 100%; + height: 100%; + min-height: 210px; + display: block; + color: var(--primary); + overflow: visible; +} + +.console-v2 .energy-chart-grid line { + stroke: color-mix(in oklch, var(--primary) 10%, var(--border)); + stroke-width: 1; + stroke-dasharray: 3 5; +} + +.console-v2 .energy-chart-area { + fill: url(#energyMonitorAreaGradient); + stroke: none; +} + +.console-v2 .energy-chart-line { + fill: none; + stroke: currentColor; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 3px 5px color-mix(in oklch, var(--primary) 18%, transparent)); + transition: points 260ms linear; +} + +.console-v2 .energy-chart-y { + position: absolute; + z-index: 2; + left: 0; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--card) 82%, transparent); + font-size: 9px; + line-height: 1; + pointer-events: none; +} + +.console-v2 .energy-chart-y-top { top: 8px; } +.console-v2 .energy-chart-y-mid { top: 49%; } +.console-v2 .energy-chart-y-bottom { bottom: 26px; } + +.console-v2 .energy-chart-x { + position: absolute; + right: 0; + bottom: 3px; + left: 42px; + display: flex; + justify-content: space-between; + color: var(--muted-foreground); + font-size: 9px; +} + +.console-v2 .energy-trend-card > footer { + justify-content: space-between; + gap: 10px; + padding-top: 5px; + color: var(--muted-foreground); + border-top: 1px solid color-mix(in oklch, var(--primary) 8%, var(--border)); + font-size: 10px; +} + +.console-v2 .energy-trend-card > footer span { + display: flex; + align-items: center; + gap: 8px; + color: var(--foreground); + font-weight: 650; +} + +.console-v2 .energy-trend-card > footer p { + margin: 0; +} + +.console-v2 .energy-devices-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + padding: 15px 12px 10px; +} + +.console-v2 .energy-devices-card > header { + padding: 0 3px 10px; +} + +.console-v2 .energy-devices-card > header > span { + padding: 5px 8px; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 8%, var(--card)); + border-radius: 999px; + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} + +.console-v2 .energy-device-list { + min-height: 0; + display: grid; + align-content: start; + gap: 6px; + overflow: auto; + padding: 1px 3px 4px; + scrollbar-color: color-mix(in oklch, var(--primary) 24%, var(--border)) transparent; + scrollbar-width: thin; +} + +.console-v2 .energy-device-row { + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: 34px minmax(82px, 1fr) auto 72px 34px; + align-items: center; + gap: 8px; + padding: 8px 7px; + color: var(--foreground); + text-align: left; + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border: 1px solid transparent; + border-radius: 11px; + cursor: pointer; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.console-v2 .energy-device-row:hover, +.console-v2 .energy-device-row:focus-visible, +.console-v2 .energy-device-row.active { + color: var(--foreground); + background: color-mix(in oklch, var(--primary) 7%, var(--card)); + border-color: color-mix(in oklch, var(--primary) 25%, var(--border)); + outline: none; +} + +.console-v2 .energy-device-row:focus-visible { + box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 14%, transparent); +} + +.console-v2 .energy-device-row.active { + box-shadow: inset 3px 0 0 var(--primary); +} + +.console-v2 .energy-device-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 9%, var(--card)); + border: 1px solid color-mix(in oklch, var(--primary) 14%, var(--border)); + border-radius: 10px; +} + +.console-v2 .energy-device-icon svg { + width: 16px; + height: 16px; +} + +.console-v2 .energy-device-identity { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.console-v2 .energy-device-identity strong { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .energy-device-identity small { + min-width: 0; + gap: 4px; + color: var(--muted-foreground); + font-size: 8px; +} + +.console-v2 .energy-device-identity small b { + color: var(--primary); + font-weight: 750; +} + +.console-v2 .energy-device-identity small b[data-source="estimated"] { + color: var(--muted-foreground); +} + +.console-v2 .energy-device-identity small i { + width: 2px; + height: 2px; + flex: 0 0 2px; + background: currentColor; + border-radius: 50%; +} + +.console-v2 .energy-device-identity small span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .energy-device-metrics { + min-width: 64px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + white-space: nowrap; +} + +.console-v2 .energy-device-metrics strong { + color: var(--heading-foreground, var(--foreground)); + font-size: 11px; +} + +.console-v2 .energy-device-metrics small { + color: var(--muted-foreground); + font-size: 8px; +} + +.console-v2 .energy-device-sparkline { + width: 72px; + height: 24px; + display: block; + overflow: visible; +} + +.console-v2 .energy-device-sparkline polyline { + fill: none; + stroke: var(--primary); + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.console-v2 .energy-device-share { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.console-v2 .energy-device-share > b { + color: var(--primary); + font-size: 8px; +} + +.console-v2 .energy-device-share > i { + width: 30px; + height: 3px; + display: block; + overflow: hidden; + background: color-mix(in oklch, var(--primary) 10%, var(--border)); + border-radius: 999px; +} + +.console-v2 .energy-device-share em { + width: var(--energy-share, 0%); + height: 100%; + display: block; + background: var(--primary); + border-radius: inherit; + transition: width 260ms linear; +} + +.console-v2 .energy-devices-card > footer { + display: flex; + align-items: flex-start; + gap: 7px; + margin-top: 6px; + padding: 8px 9px; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--primary) 5%, var(--secondary)); + border-radius: 9px; + font-size: 9px; + line-height: 1.45; +} + +.console-v2 .energy-devices-card > footer svg { + width: 13px; + height: 13px; + flex: 0 0 13px; + color: var(--primary); +} + +.console-v2 #v2PanelControlDock > .energy-monitor-toggle { + overflow: visible !important; +} + +.console-v2 .energy-monitor-toggle-live { + width: 6px; + height: 6px; + position: absolute; + right: 6px; + top: 6px; + display: block; + background: var(--primary); + border: 1.5px solid var(--card); + border-radius: 50%; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 10%, transparent); +} + +@keyframes energy-monitor-enter { + from { opacity: 0; transform: translateY(8px) scale(.992); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@media (max-width: 980px) { + .console-v2 .energy-monitor-content { + overflow: auto; + } + + .console-v2 .energy-monitor-dashboard { + grid-template-columns: minmax(0, 1fr); + } + + .console-v2 .energy-trend-card { + min-height: 390px; + } + + .console-v2 .energy-devices-card { + min-height: 450px; + } + + .console-v2 .energy-device-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: visible; + } + + .console-v2 .energy-device-row { + grid-template-columns: 34px minmax(72px, 1fr) auto 52px; + } + + .console-v2 .energy-device-share { + display: none; + } + + .console-v2 .energy-device-sparkline { + width: 52px; + } +} + +@media (max-width: 760px) { + .console-v2 .energy-monitor-layer { + inset: 0; + padding: 56px 8px 8px; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + } + + .console-v2 .energy-monitor-page { + width: 100%; + height: 100%; + border-radius: 17px; + } + + .console-v2 .energy-monitor-header { + min-height: 66px; + gap: 8px; + padding: 10px 10px 9px 12px; + } + + .console-v2 .energy-monitor-heading { + gap: 8px; + } + + .console-v2 .energy-monitor-heading > span { + width: 38px; + height: 38px; + flex-basis: 38px; + border-radius: 12px; + } + + .console-v2 .energy-monitor-heading > span svg { + width: 20px; + height: 20px; + } + + .console-v2 .energy-monitor-heading h2 { + font-size: 16px; + } + + .console-v2 .energy-monitor-heading p, + .console-v2 #energyMonitorFreshness { + display: none; + } + + .console-v2 .energy-monitor-live-state { + gap: 6px; + } + + .console-v2 .energy-monitor-live-label { + font-size: 10px; + } + + .console-v2 .energy-monitor-source { + padding: 4px 7px; + font-size: 9px; + } + + .console-v2 #energyMonitorClose { + width: 34px; + height: 34px; + flex-basis: 34px; + } + + .console-v2 .energy-monitor-content { + gap: 9px; + padding: 10px; + } + + .console-v2 .energy-monitor-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + } + + .console-v2 .energy-kpi { + min-height: 82px; + padding: 10px 11px; + border-radius: 12px; + } + + .console-v2 .energy-kpi > strong { + font-size: 18px; + } + + .console-v2 .energy-kpi-primary > strong { + font-size: 26px; + } + + .console-v2 .energy-trend-card { + min-height: 340px; + padding: 12px 11px 9px; + } + + .console-v2 .energy-chart-wrap, + .console-v2 .energy-monitor-chart { + min-height: 190px; + } + + .console-v2 .energy-trend-tabs { + gap: 5px; + overflow-x: auto; + padding-bottom: 7px; + } + + .console-v2 .energy-trend-tabs button { + flex: 0 0 auto; + padding-inline: 11px; + } + + .console-v2 .energy-devices-card { + min-height: 560px; + } + + .console-v2 .energy-device-list { + grid-template-columns: minmax(0, 1fr); + } + + .console-v2 .energy-device-row { + grid-template-columns: 34px minmax(70px, 1fr) auto 62px 34px; + } + + .console-v2 .energy-device-share { + display: flex; + } + + .console-v2 .energy-device-sparkline { + width: 62px; + } +} + +@media (max-width: 460px) { + .console-v2 .energy-monitor-heading p, + .console-v2 .energy-monitor-live-label { + display: none; + } + + .console-v2 .energy-monitor-source { + max-width: 74px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .console-v2 .energy-monitor-kpis { + gap: 6px; + } + + .console-v2 .energy-kpi { + min-height: 78px; + } + + .console-v2 .energy-kpi > small { + font-size: 9px; + } + + .console-v2 .energy-kpi > em { + font-size: 8px; + } + + .console-v2 .energy-device-row { + grid-template-columns: 32px minmax(64px, 1fr) auto 50px; + gap: 6px; + padding-inline: 5px; + } + + .console-v2 .energy-device-share { + display: none; + } + + .console-v2 .energy-device-sparkline { + width: 50px; + } + + .console-v2 .energy-trend-card > footer p { + display: none; + } +} + +@media (max-width: 340px) { + .console-v2 .energy-monitor-heading > span { + display: none; + } + + .console-v2 .energy-monitor-source { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .console-v2 .energy-monitor-page { + animation: none; + } + + .console-v2 .energy-chart-line, + .console-v2 .energy-device-share em, + .console-v2 .energy-device-row, + .console-v2 #energyMonitorClose { + transition: none; + } +} diff --git a/apps/web-console/energy-monitor.js b/apps/web-console/energy-monitor.js new file mode 100644 index 0000000..b90ec29 --- /dev/null +++ b/apps/web-console/energy-monitor.js @@ -0,0 +1,905 @@ +(function () { + "use strict"; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + const SAMPLE_INTERVAL_MS = 1000; + const MEASURED_STALE_MS = 15000; + const HISTORY_LIMIT = 60; + const MAX_DEVICE_POWER_W = 100000; + const MAX_REPORTED_ENERGY_WH = 1000000000; + + const ENERGY_MONITORED_DEVICES = Object.freeze([ + { id: "passenger", telemetryKeys: ["passenger", "passenger_seat"], name: "副驾座椅", icon: "armchair", phase: 1 }, + { id: "driver", telemetryKeys: ["driver", "driver_seat"], name: "主驾座椅", icon: "armchair", phase: 2 }, + { id: "climate", telemetryKeys: ["climate", "hvac"], name: "空调", icon: "snowflake", phase: 3 }, + { id: "shade", telemetryKeys: ["shade", "roof", "sunshade"], name: "遮阳帘", icon: "panels-top-left", phase: 4 }, + { id: "windows", telemetryKeys: ["windows", "window"], name: "车窗", icon: "gallery-vertical-end", phase: 5 }, + { id: "reading", telemetryKeys: ["reading", "reading_light", "interior_light"], name: "阅读灯", icon: "book-open", phase: 6 }, + { id: "ambient", telemetryKeys: ["ambient", "ambient_light"], name: "氛围灯", icon: "sparkles", phase: 7 }, + ]); + + const SERIES = Object.freeze([ + { id: "total", name: "总功率", devices: ENERGY_MONITORED_DEVICES.map(({ id }) => id) }, + { id: "climate", name: "空调", devices: ["climate"] }, + { id: "seats", name: "座椅", devices: ["passenger", "driver"] }, + { id: "lights", name: "灯光", devices: ["reading", "ambient"] }, + ]); + + const aliasToDevice = new Map(); + ENERGY_MONITORED_DEVICES.forEach((device) => { + device.telemetryKeys.forEach((key) => aliasToDevice.set(key, device.id)); + }); + + const monitor = { + mounted: false, + open: false, + activeSeries: "total", + timer: null, + startedAt: Date.now(), + lastSampleAt: null, + frames: [], + latest: null, + measured: new Map(), + reportedEnergy: new Map(), + energyWh: Object.fromEntries(ENERGY_MONITORED_DEVICES.map(({ id }) => [id, 0])), + motionUntil: new Map(), + controlRequests: new Map(), + returnFocus: null, + backgroundInert: [], + }; + + function finiteNonNegative(value) { + if (value === null || value === undefined || value === "") return null; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : null; + } + + function finitePower(value) { + const number = finiteNonNegative(value); + return number !== null && number <= MAX_DEVICE_POWER_W ? number : null; + } + + function finiteReportedEnergy(value) { + const number = finiteNonNegative(value); + return number !== null && number <= MAX_REPORTED_ENERGY_WH ? number : null; + } + + function normalizeSource(value) { + return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_"); + } + + function telemetrySource(telemetry, payload) { + if (telemetry?.simulated === true || payload?.simulated === true) return "simulated"; + if (telemetry?.estimated === true || payload?.estimated === true) return "estimated"; + + const declared = normalizeSource( + telemetry?.source + || telemetry?.data_source + || payload?.source + || payload?.data_source, + ); + if (/(^|_)(simulated|simulation|demo|mock|synthetic|virtual)(_|$)/.test(declared)) { + return "simulated"; + } + if (/(^|_)(estimated|estimate|derived|inferred|calculated|projected|unknown)(_|$)/.test(declared)) { + return "estimated"; + } + if (telemetry?.measured === true || payload?.measured === true) return "measured"; + if (/(^|_)(measured|meter|sensor|hardware|physical|vehicle|can|mqtt)(_|$)/.test(declared)) { + return "measured"; + } + // An unlabelled caller is not enough evidence to claim hardware measurement. + return "estimated"; + } + + function text(selector, fallback = "") { + return $(selector)?.textContent?.trim() || fallback; + } + + function stateFlag(value) { + const normalized = String(value || "").trim(); + if (/OFF|关闭|停止|未运行/i.test(normalized)) return false; + if (/ON|ACTIVE|AMBIENT|INTERIOR|开启|运行|L[1-9]|高|中|低/i.test(normalized)) return true; + return null; + } + + function numericText(selector, fallback) { + const match = text(selector).match(/-?\d+(?:\.\d+)?/); + const value = match ? Number(match[0]) : NaN; + return Number.isFinite(value) ? value : fallback; + } + + function deterministicWave(now, phase, amplitude = 0.04) { + const seconds = now / 1000; + return 1 + + Math.sin(seconds / 2.4 + phase * 1.37) * amplitude + + Math.sin(seconds / 5.7 + phase * 0.71) * amplitude * 0.45; + } + + function controlIsActive(deviceId, now) { + if ((monitor.motionUntil.get(deviceId) || 0) > now) return true; + const card = $(`.v2-synced-device[data-device="${deviceId}"]`); + return /等待车机同步/.test(card?.querySelector("[data-device-control-apply]")?.textContent || ""); + } + + function estimateDevicePower(device, now) { + let watts = 0; + let activity = "待机"; + const moving = controlIsActive(device.id, now); + + if (device.id === "climate") { + const power = stateFlag(text("#climatePower")); + const fan = Math.max(0, Math.min(7, numericText("#fanLevel", 3))); + const target = (numericText("#driverTemperature", 24) + numericText("#passengerTemperature", 24)) / 2; + if (power === false) { + watts = 3.2; + activity = "待机"; + } else { + watts = 390 + fan * 48 + Math.abs(24 - target) * 32; + activity = power === true ? `${fan} 档运行` : "状态估算"; + } + if (moving) { + watts += 95; + activity = "调节执行中"; + } + } else if (device.id === "passenger") { + const card = $('.seat-card[data-seat="passenger"]'); + const ventilation = stateFlag(card?.dataset.ventilationState); + const heating = stateFlag(card?.dataset.heatingState); + const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1)); + const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1)); + watts = 5.6 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (moving ? 130 : 0); + activity = moving ? "执行器动作" : ventilation || heating ? "舒适功能运行" : "待机"; + } else if (device.id === "driver") { + const card = $('.seat-card[data-seat="driver"]'); + const ventilation = stateFlag(card?.dataset.ventilationState || text('.seat-card[data-seat="driver"] [data-function="ventilation"] b')); + const heating = stateFlag(card?.dataset.heatingState || text('.seat-card[data-seat="driver"] [data-function="heating"] b')); + const massage = stateFlag(card?.dataset.massageState || text('.seat-card[data-seat="driver"] [data-function="massage"] b')); + const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1)); + const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1)); + watts = 6.4 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (massage ? 31 : 0) + (moving ? 92 : 0); + activity = moving ? "执行器动作" : ventilation || heating || massage ? "舒适功能运行" : "待机"; + } else if (device.id === "windows") { + watts = moving ? 118 : 2.8; + activity = moving ? "车窗动作" : "待机"; + } else if (device.id === "shade") { + watts = moving ? 86 : 1.9; + activity = moving ? "遮阳帘动作" : "待机"; + } else if (device.id === "reading") { + const power = stateFlag(text("#interiorLightPower")); + watts = power === false ? 0.8 : power === true ? 18 : 7.5; + activity = power === true ? "已开启" : power === false ? "已关闭" : "状态估算"; + if (moving) { + watts += 6; + activity = "切换执行中"; + } + } else if (device.id === "ambient") { + const power = stateFlag(text("#ambientPower")); + const brightness = Math.max(1, Math.min(5, numericText("#ambientBrightness", 3))); + watts = power === false ? 1.1 : power === true ? 8 + brightness * 6.2 : 18; + activity = power === true ? `${brightness} 级亮度` : power === false ? "已关闭" : "状态估算"; + if (moving) { + watts += 5; + activity = "调光执行中"; + } + } + + const amplitude = watts < 10 ? 0.025 : device.id === "climate" ? 0.055 : 0.04; + return { + watts: Math.max(0, watts * deterministicWave(now, device.phase, amplitude)), + source: "estimated", + activity, + updatedAt: now, + }; + } + + function readingForDevice(device, now) { + const measured = monitor.measured.get(device.id); + const reportedEnergy = monitor.reportedEnergy.get(device.id); + if (measured && now - measured.receivedAt <= MEASURED_STALE_MS) { + const activity = measured.source === "measured" + ? "车端功率通道" + : measured.source === "simulated" ? "模拟功率通道" : "外部估算通道"; + return { + watts: measured.watts, + source: measured.source, + activity, + updatedAt: measured.updatedAt || measured.receivedAt, + reportedEnergyWh: reportedEnergy?.wattsHours ?? null, + reportedEnergyLabel: reportedEnergy?.label || "", + reportedEnergySource: reportedEnergy?.source || "", + }; + } + return { + ...estimateDevicePower(device, now), + reportedEnergyWh: reportedEnergy?.wattsHours ?? null, + reportedEnergyLabel: reportedEnergy?.label || "", + reportedEnergySource: reportedEnergy?.source || "", + }; + } + + function frameSeriesValue(frame, seriesId) { + const exactDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === seriesId); + const deviceIds = exactDevice + ? [exactDevice.id] + : SERIES.find(({ id }) => id === seriesId)?.devices || SERIES[0].devices; + return deviceIds.reduce((sum, id) => sum + (frame.values[id] || 0), 0); + } + + function publicSnapshot() { + if (!monitor.latest) return null; + return { + timestamp: monitor.latest.timestamp, + total_power_w: monitor.latest.total, + total_energy_wh: Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0), + source: monitor.latest.source, + measured_count: monitor.latest.measuredCount, + simulated_count: monitor.latest.simulatedCount, + estimated_count: monitor.latest.estimatedCount, + total_energy_scope: "monitor_power_integral_estimate", + devices: Object.fromEntries(ENERGY_MONITORED_DEVICES.map((device) => { + const reading = monitor.latest.readings[device.id]; + return [device.id, { + name: device.name, + power_w: reading.watts, + energy_wh: monitor.energyWh[device.id], + reported_energy_wh: reading.reportedEnergyWh, + reported_energy_label: reading.reportedEnergyLabel, + reported_energy_source: reading.reportedEnergySource, + source: reading.source, + activity: reading.activity, + }]; + })), + sample_count: monitor.frames.length, + }; + } + + function pruneExpiredControlRequests(now) { + monitor.controlRequests.forEach((request, requestId) => { + if (request.until <= now) monitor.controlRequests.delete(requestId); + }); + } + + function sampleNow({ now = Date.now(), integrate = true, render = true } = {}) { + const timestamp = Number(now); + if (!Number.isFinite(timestamp)) return publicSnapshot(); + pruneExpiredControlRequests(timestamp); + const readings = {}; + const values = {}; + let total = 0; + let measuredCount = 0; + let simulatedCount = 0; + + ENERGY_MONITORED_DEVICES.forEach((device) => { + const reading = readingForDevice(device, timestamp); + readings[device.id] = reading; + values[device.id] = reading.watts; + total += finitePower(reading.watts) ?? 0; + if (reading.source === "measured") measuredCount += 1; + if (reading.source === "simulated") simulatedCount += 1; + }); + + const elapsedMs = monitor.lastSampleAt === null + ? 0 + : Math.max(0, timestamp - monitor.lastSampleAt); + if (integrate && elapsedMs > 0) { + ENERGY_MONITORED_DEVICES.forEach(({ id }) => { + const previous = monitor.frames.at(-1)?.values?.[id] ?? values[id]; + monitor.energyWh[id] += ((previous + values[id]) / 2) * elapsedMs / 3600000; + }); + } + + monitor.lastSampleAt = timestamp; + monitor.frames.push({ timestamp, values, total }); + if (monitor.frames.length > HISTORY_LIMIT) monitor.frames.splice(0, monitor.frames.length - HISTORY_LIMIT); + const estimatedCount = ENERGY_MONITORED_DEVICES.length - measuredCount - simulatedCount; + const source = measuredCount === ENERGY_MONITORED_DEVICES.length + ? "measured" + : simulatedCount === ENERGY_MONITORED_DEVICES.length + ? "simulated" + : estimatedCount === ENERGY_MONITORED_DEVICES.length + ? "estimated" + : measuredCount && simulatedCount && estimatedCount + ? "measured_simulated_estimated" + : measuredCount && simulatedCount + ? "measured_simulated" + : measuredCount ? "mixed" : "simulated_estimated"; + monitor.latest = { + timestamp, + readings, + total, + measuredCount, + simulatedCount, + estimatedCount, + source, + }; + if (render && monitor.mounted) renderMonitor(); + window.dispatchEvent(new CustomEvent("cognitive-agent:energy-snapshot", { detail: publicSnapshot() })); + return publicSnapshot(); + } + + function ingestPowerTelemetry(payload, { sample = true } = {}) { + const receivedAt = Date.now(); + const updates = payload?.devices && typeof payload.devices === "object" + ? Object.entries(payload.devices) + : payload?.device ? [[payload.device, payload]] : []; + updates.forEach(([rawKey, rawTelemetry]) => { + const deviceId = aliasToDevice.get(String(rawKey || "").trim().toLowerCase()); + if (!deviceId) return; + const telemetry = rawTelemetry && typeof rawTelemetry === "object" + ? rawTelemetry + : { power_w: rawTelemetry }; + const directEnergyWh = finiteReportedEnergy(telemetry.energy_wh ?? telemetry.total_energy_wh); + const tripEnergyKwh = finiteNonNegative(telemetry.trip_energy_kwh); + const todayEnergyKwh = finiteNonNegative(telemetry.today_energy_kwh); + const tripEnergyWh = tripEnergyKwh === null ? null : finiteReportedEnergy(tripEnergyKwh * 1000); + const todayEnergyWh = todayEnergyKwh === null ? null : finiteReportedEnergy(todayEnergyKwh * 1000); + const reportedEnergyWh = directEnergyWh + ?? tripEnergyWh + ?? todayEnergyWh + ?? null; + const reportedEnergyLabel = directEnergyWh !== null + ? String(telemetry.energy_label || "车端累计") + : tripEnergyWh !== null ? "本次行程" + : todayEnergyWh !== null ? "今日累计" + : ""; + const source = telemetrySource(telemetry, payload); + if (reportedEnergyWh !== null) { + monitor.reportedEnergy.set(deviceId, { + wattsHours: reportedEnergyWh, + label: reportedEnergyLabel, + source, + receivedAt, + }); + } + const watts = finitePower( + telemetry.power_w ?? telemetry.total_power_w ?? telemetry.current_power_w, + ); + if (watts === null) return; + const parsedUpdatedAt = telemetry.updated_at ? new Date(telemetry.updated_at).getTime() : NaN; + monitor.measured.set(deviceId, { + watts, + receivedAt, + updatedAt: Number.isFinite(parsedUpdatedAt) ? parsedUpdatedAt : receivedAt, + source, + }); + }); + return sample ? sampleNow() : publicSnapshot(); + } + + function formatPowerParts(watts) { + const value = finiteNonNegative(watts) || 0; + if (value >= 1000) return { value: (value / 1000).toFixed(value >= 10000 ? 1 : 2), unit: "kW" }; + return { value: String(Math.round(value)), unit: "W" }; + } + + function formatPower(watts, estimated = false) { + const parts = formatPowerParts(watts); + return `${estimated ? "≈ " : ""}${parts.value} ${parts.unit}`; + } + + function formatEnergy(wh, estimated = false) { + const value = finiteNonNegative(wh) || 0; + const prefix = estimated ? "≈ " : ""; + if (value >= 1000) return `${prefix}${(value / 1000).toFixed(3)} kWh`; + return `${prefix}${value < 10 ? value.toFixed(2) : value.toFixed(1)} Wh`; + } + + function sourceLabel(source) { + return { + measured: "车端实测", + simulated: "模拟数据", + simulated_estimated: "模拟 + 估算", + measured_simulated: "实测 + 模拟", + measured_simulated_estimated: "实测 + 模拟 + 估算", + mixed: "实测 + 估算", + estimated: "状态估算", + }[source] || "状态估算"; + } + + function sourceShortLabel(source) { + return source === "measured" ? "实测" : source === "simulated" ? "模拟" : "估算"; + } + + function totalPowerQualifier(source) { + if (source === "measured") return "实测总功率"; + if (source === "simulated") return "模拟总功率"; + if (source === "estimated") return "估算总功率"; + return "混合来源总功率"; + } + + function niceMaximum(value) { + const safe = Math.max(1, value); + const magnitude = 10 ** Math.floor(Math.log10(safe)); + const normalized = safe / magnitude; + const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return nice * magnitude; + } + + function chartPoints(values, maxValue, width = 700, height = 238, padding = { left: 42, right: 14, top: 16, bottom: 28 }) { + if (values.length < 2) return []; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + return values.map((value, index) => ({ + x: padding.left + innerWidth * index / (values.length - 1), + y: padding.top + innerHeight * (1 - Math.max(0, value) / maxValue), + })); + } + + function polylinePoints(values, maxValue, width, height, padding) { + return chartPoints(values, maxValue, width, height, padding) + .map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`) + .join(" "); + } + + function renderMainChart() { + const values = monitor.frames.map((frame) => frameSeriesValue(frame, monitor.activeSeries)); + const maximum = niceMaximum(Math.max(...values, 1)); + const points = chartPoints(values, maximum); + const line = $("#energyMonitorChartLine"); + const area = $("#energyMonitorChartArea"); + const svg = $("#energyMonitorChart"); + const baseline = 210; + line?.setAttribute("points", points.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ")); + area?.setAttribute("d", points.length + ? `M ${points[0].x.toFixed(1)} ${baseline} L ${points.map(({ x, y }) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1).x.toFixed(1)} ${baseline} Z` + : ""); + + const selectedDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === monitor.activeSeries); + const selectedSeries = SERIES.find(({ id }) => id === monitor.activeSeries); + const label = selectedDevice?.name || selectedSeries?.name || "总功率"; + const current = values.at(-1) || 0; + const currentReading = selectedDevice ? monitor.latest?.readings?.[selectedDevice.id] : null; + const estimated = currentReading ? currentReading.source !== "measured" : monitor.latest?.source !== "measured"; + $("#energyMonitorTrendName").textContent = label; + $("#energyMonitorTrendCurrent").textContent = formatPower(current, estimated); + $("#energyMonitorChartMax").textContent = formatPower(maximum); + $("#energyMonitorChartMid").textContent = formatPower(maximum / 2); + const spanSeconds = Math.max(1, Math.round(((monitor.frames.at(-1)?.timestamp || 0) - (monitor.frames[0]?.timestamp || 0)) / 1000)); + const windowSeconds = monitor.frames.length >= HISTORY_LIMIT ? 60 : Math.min(60, spanSeconds); + $("#energyMonitorTrendWindow").textContent = `最近 ${windowSeconds} 秒`; + $("#energyMonitorChartStart").textContent = `${windowSeconds} 秒前`; + if (svg) svg.setAttribute("aria-label", `${label}最近 ${windowSeconds} 秒功率趋势,当前${formatPower(current, estimated)}`); + + $$('[data-energy-series]').forEach((button) => { + const selected = button.dataset.energySeries === monitor.activeSeries; + button.classList.toggle("active", selected); + button.setAttribute("aria-pressed", String(selected)); + }); + $$('[data-energy-device]').forEach((button) => { + const selected = button.dataset.energyDevice === monitor.activeSeries; + button.classList.toggle("active", selected); + button.setAttribute("aria-pressed", String(selected)); + }); + } + + function renderDeviceRows() { + if (!monitor.latest) return; + const total = Math.max(monitor.latest.total, 0.0001); + ENERGY_MONITORED_DEVICES.forEach((device) => { + const row = $(`[data-energy-device="${device.id}"]`); + const reading = monitor.latest.readings[device.id]; + if (!row || !reading) return; + const estimated = reading.source !== "measured"; + $("[data-energy-device-power]", row).textContent = formatPower(reading.watts, estimated); + const hasReportedEnergy = reading.reportedEnergyWh !== null && reading.reportedEnergyWh !== undefined; + const displayedEnergyWh = hasReportedEnergy ? reading.reportedEnergyWh : monitor.energyWh[device.id]; + const reportedEnergyEstimated = !hasReportedEnergy || reading.reportedEnergySource !== "measured"; + $("[data-energy-device-energy]", row).textContent = formatEnergy(displayedEnergyWh, reportedEnergyEstimated); + $("[data-energy-device-energy]", row).title = hasReportedEnergy + ? `${reading.reportedEnergyLabel || "车端累计"}能耗` + : "本次监测功率积分估算"; + $("[data-energy-device-source]", row).textContent = sourceShortLabel(reading.source); + $("[data-energy-device-source]", row).dataset.source = reading.source; + $("[data-energy-device-activity]", row).textContent = reading.activity; + const share = Math.max(0, Math.min(100, reading.watts / total * 100)); + $("[data-energy-device-share]", row).textContent = `${share.toFixed(1)}%`; + row.style.setProperty("--energy-share", `${share}%`); + const values = monitor.frames.map((frame) => frame.values[device.id] || 0); + const maximum = Math.max(...values, 1); + $("[data-energy-device-sparkline]", row)?.setAttribute( + "points", + polylinePoints(values, maximum, 92, 28, { left: 1, right: 1, top: 3, bottom: 3 }), + ); + const energyScope = hasReportedEnergy ? reading.reportedEnergyLabel || "车端累计" : "本次监测估算"; + row.setAttribute("aria-label", `${device.name},当前${formatPower(reading.watts, estimated)},${energyScope}${formatEnergy(displayedEnergyWh, reportedEnergyEstimated)},点击查看趋势`); + }); + } + + function renderMonitor() { + if (!monitor.latest) return; + const totalParts = formatPowerParts(monitor.latest.total); + const estimated = monitor.latest.source !== "measured"; + $("#energyMonitorTotalPower").textContent = totalParts.value; + $("#energyMonitorTotalUnit").textContent = totalParts.unit; + $("#energyMonitorTotalQualifier").textContent = totalPowerQualifier(monitor.latest.source); + $("#energyMonitorAccumulated").textContent = formatEnergy( + Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0), + true, + ); + const elapsedHours = Math.max(1 / 3600, (monitor.latest.timestamp - monitor.startedAt) / 3600000); + $("#energyMonitorAverage").textContent = formatPower( + Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0) / elapsedHours, + true, + ); + $("#energyMonitorCoverage").textContent = `${ENERGY_MONITORED_DEVICES.length} / ${ENERGY_MONITORED_DEVICES.length}`; + $("#energyMonitorSource").textContent = sourceLabel(monitor.latest.source); + $("#energyMonitorSource").dataset.source = monitor.latest.source; + const sourceBreakdown = [ + monitor.latest.measuredCount ? `${monitor.latest.measuredCount} 路实测` : "", + monitor.latest.simulatedCount ? `${monitor.latest.simulatedCount} 路模拟` : "", + monitor.latest.estimatedCount ? `${monitor.latest.estimatedCount} 路估算` : "", + ].filter(Boolean).join(" · "); + $("#energyMonitorCoverageNote").textContent = sourceBreakdown || "等待设备采样"; + $("#energyMonitorLiveLabelText").textContent = monitor.latest.measuredCount + ? "实时监测" + : monitor.latest.simulatedCount ? "动态模拟" : "状态估算"; + $("#energyMonitorFreshness").textContent = `采样更新 ${new Date(monitor.latest.timestamp).toLocaleTimeString("zh-CN", { hour12: false })}`; + $("#energyMonitorPage").dataset.revision = String(Number($("#energyMonitorPage").dataset.revision || 0) + 1); + $("#energyMonitorPage").dataset.sampleCount = String(monitor.frames.length); + renderMainChart(); + renderDeviceRows(); + } + + function deviceRowsMarkup() { + return ENERGY_MONITORED_DEVICES.map((device) => ` + `).join(""); + } + + function createLayer() { + const trigger = document.createElement("button"); + trigger.id = "energyMonitorToggle"; + trigger.className = "energy-monitor-toggle v2-panel-control-button"; + trigger.type = "button"; + trigger.setAttribute("aria-controls", "energyMonitorLayer"); + trigger.setAttribute("aria-pressed", "false"); + trigger.setAttribute("aria-expanded", "false"); + trigger.setAttribute("aria-label", "打开整车能耗监测"); + trigger.title = "打开整车能耗监测"; + trigger.innerHTML = '整车能耗监测'; + + const layer = document.createElement("div"); + layer.id = "energyMonitorLayer"; + layer.className = "energy-monitor-layer"; + layer.hidden = true; + layer.inert = true; + layer.setAttribute("role", "dialog"); + layer.setAttribute("aria-modal", "true"); + layer.setAttribute("aria-labelledby", "energyMonitorTitle"); + layer.innerHTML = ` +
+
+
+ +

整车能耗监测

实时追踪各可控车端设备功率与累计能耗

+
+
+ 状态估算 + 最后更新 --:--:-- + 状态估算 + +
+
+
+
+
实时总功率--W等待采样
+
本次监测累计能耗-- Wh按实时功率积分
+
本次监测平均功率-- W自页面加载开始
+
监测设备7 / 7等待设备采样
+
+
+
+
+

功率趋势

总功率 · 最近 24 秒

+ -- W +
+
+ ${SERIES.map((series) => ``).join("")} +
+
+ -- W + -- W + 0 + + + + + + +
60 秒前现在
+
+
当前选中曲线

点击右侧设备可查看其独立趋势

+
+
+

设备实时功率

功率 · 累计能耗 · 占比

${ENERGY_MONITORED_DEVICES.length} 个可控设备
+
${deviceRowsMarkup()}
+
优先读取车端功率通道;未接入独立电表的设备显示明确标注的状态估算值。
+
+
+
+
`; + + $("#v2PanelControlDock").append(trigger); + document.body.append(layer); + trigger.addEventListener("click", () => setOpen(!monitor.open)); + $("#energyMonitorClose", layer).addEventListener("click", () => setOpen(false)); + layer.addEventListener("click", (event) => { + if (event.target === layer) setOpen(false); + }); + layer.addEventListener("click", (event) => { + const seriesButton = event.target.closest("[data-energy-series]"); + const deviceButton = event.target.closest("[data-energy-device]"); + if (seriesButton) selectSeries(seriesButton.dataset.energySeries); + if (deviceButton) selectSeries(deviceButton.dataset.energyDevice); + }); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + } + + function focusableElements() { + return $$('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', $("#energyMonitorLayer")) + .filter((element) => !element.hidden && element.offsetParent !== null); + } + + function setBackgroundInert(inert) { + const layer = $("#energyMonitorLayer"); + if (!layer || !document.body) return; + if (inert) { + monitor.backgroundInert = [...document.body.children] + .filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName)) + .map((element) => ({ element, wasInert: element.hasAttribute("inert") })); + monitor.backgroundInert.forEach(({ element }) => { element.toggleAttribute("inert", true); }); + return; + } + monitor.backgroundInert.forEach(({ element, wasInert }) => { element.toggleAttribute("inert", wasInert); }); + monitor.backgroundInert = []; + } + + function captureModalKeys(event) { + if (!monitor.open) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopImmediatePropagation(); + setOpen(false); + return; + } + if (event.key !== "Tab") return; + const focusable = focusableElements(); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + const layer = $("#energyMonitorLayer"); + if (!layer?.contains(document.activeElement)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + function setOpen(open) { + const next = Boolean(open); + const layer = $("#energyMonitorLayer"); + const trigger = $("#energyMonitorToggle"); + if (!layer || !trigger || monitor.open === next) return; + monitor.open = next; + trigger.classList.toggle("active", next); + trigger.setAttribute("aria-pressed", String(next)); + trigger.setAttribute("aria-expanded", String(next)); + trigger.setAttribute("aria-label", next ? "关闭整车能耗监测" : "打开整车能耗监测"); + trigger.title = next ? "关闭整车能耗监测" : "打开整车能耗监测"; + if (next) { + monitor.returnFocus = document.activeElement; + layer.hidden = false; + layer.inert = false; + layer.dataset.open = "true"; + setBackgroundInert(true); + renderMonitor(); + requestAnimationFrame(() => $("#energyMonitorClose", layer)?.focus()); + } else { + layer.dataset.open = "false"; + layer.inert = true; + layer.hidden = true; + setBackgroundInert(false); + const target = monitor.returnFocus instanceof HTMLElement && monitor.returnFocus.isConnected + ? monitor.returnFocus + : trigger; + monitor.returnFocus = null; + target.focus(); + } + } + + function selectSeries(seriesId) { + const valid = SERIES.some(({ id }) => id === seriesId) + || ENERGY_MONITORED_DEVICES.some(({ id }) => id === seriesId); + if (!valid) return; + monitor.activeSeries = seriesId; + renderMainChart(); + } + + function deviceIdsForControlDomain(domain, context = "") { + const normalizedDomain = normalizeSource(domain).replace(/^native_/, ""); + const normalizedContext = normalizeSource(context).replace(/[^a-z0-9\u4e00-\u9fff]+/g, "_"); + if (normalizedDomain === "seat" || /(^|_)seat(_|$)/.test(normalizedContext)) { + if (/(^|_)(driver|front_left|left_front)(_|$)|主驾/.test(normalizedContext)) return ["driver"]; + if (/(^|_)(passenger|front_right|right_front)(_|$)|副驾/.test(normalizedContext)) return ["passenger"]; + return ["passenger"]; + } + if (["window", "windows"].includes(normalizedDomain) || /(^|_)window(s)?(_|$)/.test(normalizedContext)) return ["windows"]; + if (["roof", "shade", "sunshade"].includes(normalizedDomain) || /(^|_)(roof|shade|sunshade)(_|$)/.test(normalizedContext)) return ["shade"]; + if (["climate", "hvac", "outlet"].includes(normalizedDomain) || /(^|_)(climate|hvac|outlet|ac)(_|$)/.test(normalizedContext)) return ["climate"]; + if (["interior_light", "reading", "reading_light"].includes(normalizedDomain) || /(^|_)(interior|reading)_?light(_|$)/.test(normalizedContext)) return ["reading"]; + if (["ambient", "ambient_light"].includes(normalizedDomain) || /(^|_)ambient_?light(_|$)/.test(normalizedContext)) return ["ambient"]; + return []; + } + + function controlActivityDevices(payload = {}) { + const wrappedResult = payload?.result && typeof payload.result === "object"; + const eventPayload = wrappedResult ? payload.result : payload; + const status = String(eventPayload?.status || ""); + if (payload?.error) return []; + if (wrappedResult && !["succeeded", "partially_succeeded"].includes(status)) return []; + if (!wrappedResult && status && !["processing", "succeeded", "partially_succeeded", "submitted"].includes(status)) return []; + if (eventPayload?.preview_only === true || /_preview$/.test(String(eventPayload?.orchestration_route || ""))) { + return []; + } + const deviceIds = new Set(); + const directId = aliasToDevice.get(normalizeSource(eventPayload?.device_id)) + || normalizeSource(eventPayload?.device_id); + if (ENERGY_MONITORED_DEVICES.some(({ id }) => id === directId)) deviceIds.add(directId); + + const actions = [ + ...(Array.isArray(eventPayload?.actions) ? eventPayload.actions : []), + ...(Array.isArray(eventPayload?.validated_actions) ? eventPayload.validated_actions : []), + ...(Array.isArray(eventPayload?.intent_ir?.spec?.actions) ? eventPayload.intent_ir.spec.actions : []), + ]; + actions.forEach((action) => { + const capabilityDomain = String(action?.capability || "").split(".")[0]; + const context = JSON.stringify({ + capability: action?.capability, + target: action?.target, + }); + deviceIdsForControlDomain(action?.domain || capabilityDomain, context).forEach((id) => deviceIds.add(id)); + }); + + const functionConfigs = Object.entries(eventPayload?.function_configs || {}); + if (eventPayload?.function_config && typeof eventPayload.function_config === "object") { + functionConfigs.push([eventPayload.function_config?.spec?.domain || "function", eventPayload.function_config]); + } + functionConfigs.forEach(([configKey, config]) => { + const domain = config?.spec?.domain || config?.domain || configKey; + const operations = config?.spec?.operations || config?.operations || []; + if (!operations.length) { + deviceIdsForControlDomain(domain, configKey).forEach((id) => deviceIds.add(id)); + return; + } + operations.forEach((operation) => { + const context = JSON.stringify({ + capability: operation?.capability, + target: operation?.target, + }); + deviceIdsForControlDomain(operation?.domain || domain, context).forEach((id) => deviceIds.add(id)); + }); + }); + + Object.entries(eventPayload?.command_plans || {}).forEach(([planKey, plan]) => { + const domain = plan?.spec?.domain || plan?.domain || planKey; + const commands = plan?.spec?.commands || plan?.commands || []; + const context = commands.length ? JSON.stringify(commands) : planKey; + deviceIdsForControlDomain(domain, context).forEach((id) => deviceIds.add(id)); + }); + return [...deviceIds]; + } + + function markControlActivity(payload) { + const until = Date.now() + 4000; + const requestId = controlRequestId(payload); + const currentDeviceIds = controlActivityDevices(payload); + const existing = requestId ? monitor.controlRequests.get(requestId) : null; + const deviceIds = [...new Set([...(existing?.deviceIds || []), ...currentDeviceIds])]; + deviceIds.forEach((deviceId) => { + monitor.motionUntil.set(deviceId, Math.max(monitor.motionUntil.get(deviceId) || 0, until)); + }); + if (requestId && deviceIds.length) { + monitor.controlRequests.set(requestId, { deviceIds, until }); + } + return currentDeviceIds; + } + + function controlRequestId(payload = {}) { + const eventPayload = payload?.result && typeof payload.result === "object" + ? payload.result + : payload; + return String(eventPayload?.request_id || payload?.request_id || "").trim(); + } + + function completeControlActivity(payload = {}) { + const eventPayload = payload?.result && typeof payload.result === "object" + ? payload.result + : payload; + const requestId = controlRequestId(payload); + const tracked = requestId ? monitor.controlRequests.get(requestId) : null; + const succeeded = !payload?.error + && ["succeeded", "partially_succeeded"].includes(String(eventPayload?.status || "")); + + if (tracked) { + monitor.controlRequests.delete(requestId); + if (!succeeded) { + const now = Date.now(); + tracked.deviceIds.forEach((deviceId) => { + if ((monitor.motionUntil.get(deviceId) || 0) > tracked.until) return; + const remainingUntil = [...monitor.controlRequests.values()] + .filter((request) => request.deviceIds.includes(deviceId)) + .reduce((maximum, request) => Math.max(maximum, request.until), now); + monitor.motionUntil.set(deviceId, remainingUntil); + }); + } + return tracked.deviceIds; + } + return succeeded ? markControlActivity(payload) : []; + } + + function seedHistory() { + const now = Date.now(); + for (let offset = 23; offset >= 1; offset -= 1) { + sampleNow({ now: now - offset * SAMPLE_INTERVAL_MS, integrate: false, render: false }); + } + monitor.lastSampleAt = now - SAMPLE_INTERVAL_MS; + sampleNow({ now, integrate: false, render: false }); + monitor.startedAt = now; + } + + function mount(attempt = 0) { + if (monitor.mounted) return; + if (!$("#v2PanelControlDock")) { + if (attempt < 20) window.setTimeout(() => mount(attempt + 1), 50); + return; + } + monitor.mounted = true; + createLayer(); + seedHistory(); + renderMonitor(); + monitor.timer = window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS); + document.addEventListener("keydown", captureModalKeys, true); + window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false })); + const existingUpdater = window.updateDevicePowerTelemetry; + if (typeof existingUpdater === "function" && !existingUpdater.energyMonitorLinked) { + const linkedUpdater = (payload) => { + const result = existingUpdater(payload); + ingestPowerTelemetry(payload, { sample: false }); + return result; + }; + linkedUpdater.energyMonitorLinked = true; + window.updateDevicePowerTelemetry = linkedUpdater; + } + window.addEventListener("cognitive-agent:vehicle-can-control-request", (event) => markControlActivity(event.detail || {})); + window.addEventListener("cognitive-agent:validated-can-commands", (event) => markControlActivity(event.detail || {})); + window.addEventListener("cognitive-agent:vehicle-control-activity", (event) => markControlActivity(event.detail || {})); + window.addEventListener("cognitive-agent:request-complete", (event) => completeControlActivity(event.detail || {})); + } + + window.CognitiveAgentEnergyMonitor = { + close: () => setOpen(false), + getDevices: () => ENERGY_MONITORED_DEVICES.map(({ id, name, telemetryKeys }) => ({ id, name, telemetryKeys: [...telemetryKeys] })), + getSnapshot: publicSnapshot, + ingestTelemetry: ingestPowerTelemetry, + isOpen: () => monitor.open, + completeControlActivity, + notifyControlActivity: markControlActivity, + open: () => setOpen(true), + sampleNow, + selectSeries, + }; + + document.addEventListener("DOMContentLoaded", () => mount()); +}()); diff --git a/apps/web-console/hardware-control.css b/apps/web-console/hardware-control.css index b644bea..1d69227 100644 --- a/apps/web-console/hardware-control.css +++ b/apps/web-console/hardware-control.css @@ -1,40 +1,40 @@ :root { color-scheme: light; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0.008 326); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0.008 326); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.96 0.003 325.6); - --muted-foreground: oklch(0.542 0.034 322.5); - --accent: oklch(0.96 0.003 325.6); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0.005 325.62); - --ring: oklch(0.711 0.019 323.02); - --radius: 0.625rem; + --background: oklch(0.968 0.019 244); + --foreground: oklch(0.29 0.042 252); + --card: oklch(0.993 0.007 245); + --card-foreground: oklch(0.34 0.032 250); + --primary: oklch(0.57 0.19 258); + --primary-foreground: oklch(0.99 0.004 245); + --secondary: oklch(0.94 0.03 244); + --secondary-foreground: oklch(0.38 0.045 250); + --muted: oklch(0.955 0.017 245); + --muted-foreground: oklch(0.53 0.035 248); + --accent: oklch(0.918 0.045 249); + --destructive: oklch(0.61 0.19 24); + --border: oklch(0.85 0.04 245); + --ring: oklch(0.65 0.14 256); + --radius: 0.875rem; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: var(--background); color: var(--foreground); } .dark { color-scheme: dark; - --background: oklch(0.145 0.008 326); - --foreground: oklch(0.985 0 0); - --card: oklch(0.212 0.019 322.12); - --card-foreground: oklch(0.985 0 0); - --primary: oklch(0.424 0.199 265.638); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.263 0.024 320.12); - --muted-foreground: oklch(0.711 0.019 323.02); - --accent: oklch(0.263 0.024 320.12); - --destructive: oklch(0.704 0.191 22.216); + --background: oklch(0.2 0.035 252); + --foreground: oklch(0.94 0.02 245); + --card: oklch(0.25 0.035 251); + --card-foreground: oklch(0.91 0.022 245); + --primary: oklch(0.7 0.15 252); + --primary-foreground: oklch(0.19 0.04 253); + --secondary: oklch(0.285 0.045 250); + --secondary-foreground: oklch(0.92 0.02 245); + --muted: oklch(0.275 0.032 250); + --muted-foreground: oklch(0.72 0.04 245); + --accent: oklch(0.32 0.065 250); + --destructive: oklch(0.7 0.18 24); --border: oklch(1 0 0 / 10%); - --ring: oklch(0.542 0.034 322.5); + --ring: oklch(0.68 0.13 252); } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, color-mix(in oklch, var(--primary) 12%, transparent) 0, transparent 35%), var(--background); } @@ -49,7 +49,7 @@ h2 { font-size: 22px; } .lead { margin-top: 12px; } a, button { font: inherit; } a { color: var(--primary); text-decoration: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; } -.policy, .card { color: var(--card-foreground); background: color-mix(in oklch, var(--card) 94%, transparent); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px); box-shadow: 0 20px 60px oklch(0.145 0.008 326 / 12%); } +.policy, .card { color: var(--card-foreground); background: color-mix(in oklch, var(--card) 94%, transparent); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px); box-shadow: 0 20px 60px oklch(0.29 0.042 252 / 12%); } .policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; } .policy div { display: grid; gap: 3px; flex: 1; } .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 16px color-mix(in oklch, var(--primary) 55%, transparent); } diff --git a/apps/web-console/hardware-control.html b/apps/web-console/hardware-control.html index 8580b42..f398f84 100644 --- a/apps/web-console/hardware-control.html +++ b/apps/web-console/hardware-control.html @@ -4,9 +4,9 @@ - + 真实台架本地决策台 - +
diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 44f8415..d34a6ab 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -4,26 +4,30 @@ - + 511工程-智能座舱 - - - - + + + + + + - - + + - - + + + +
@@ -39,40 +43,53 @@
- 功能模式 - -
- - +
+ + 功能模式预设与我的模式
-
- -
+ +
+
+
+ +
+
你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。
+
+
+
+
- +
- - - Enter 执行 · Shift + Enter 换行 + Enter 发送 · Shift + Enter 换行
@@ -155,9 +172,9 @@

正在读取座椅量程与角度标定

-
@@ -341,16 +358,6 @@
副驾座椅CH0 · 右前排
主驾座椅CH1 · 左前排
-
车头方向