Compare commits

..
Author SHA1 Message Date
jiang 209ee8c2ea fix(map): clamp layer fade progress to the valid range
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m12s
A frame timestamp may precede performance.now sampled when hiding starts, producing negative progress and opacity above 1. Bound progress at both ends; cover the exact label failure and real MapLibre paint validation.
2026-11-12 13:49:36 +08:00
jiang 3de96f9d7b fix(workbench): stabilize presentation history hover
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
Rapid pointer changes repeatedly restarted inherited color transitions in both presentation menu levels. Disable transitions on these rows while preserving focus and submenu behavior. Previous coverage checked open state only; the browser regression now compares next-frame highlight colors against the theme and fails with the old transition restored.

Also verify header timeline hide/restore after expansion at 320, 375, 1024 and 1440 pixels.

Validation: lint, typecheck, 1178 unit tests, five focused browser cases and production build passed.
2026-11-12 13:16:15 +08:00
jiang 03827a2fc0 feat(workbench): group live presentation steps into expandable categories
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback.

The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes.

Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide.
2026-11-12 12:54:09 +08:00
jiang 7a304a91a8 feat(workbench): dock task and chart workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m14s
2026-11-12 11:42:25 +08:00
jiang 33f0b90a7d fix: preserve SCADA comparison device coverage 2026-11-12 10:38:55 +08:00
jiang a617aa95e9 fix(workbench): stabilize investigation presentation rendering
Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
2026-11-11 19:10:41 +08:00
jiang 0ae0a41e61 feat(charts): manage investigation charts in a scrolling workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s
2026-11-10 17:56:23 +08:00
jiang 865491cb23 fix: show alert focus rings only for keyboard input
Radix focus transfers can preserve :focus-visible during mouse hover and
when returning focus to the alert trigger. Track actual input modality so
pointer interactions keep the thin hover border and keyboard navigation
retains its focus rings.

Cover desktop and narrow layouts, pointer selection, keyboard return focus,
and switching input modes with browser regressions.
2026-11-10 10:33:43 +08:00
140 changed files with 7998 additions and 1409 deletions
+5 -3
View File
@@ -6,13 +6,15 @@ Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以
样式规范统一见 [样式设计指南](docs/style-design-guide.md),Agent 功能与报告交互见 [交互设计](docs/agent-chat-ui-design.md),形象、配色与状态动画见 [Persona 设计](docs/agent-persona-design.md)。 样式规范统一见 [样式设计指南](docs/style-design-guide.md),Agent 功能与报告交互见 [交互设计](docs/agent-chat-ui-design.md),形象、配色与状态动画见 [Persona 设计](docs/agent-persona-design.md)。
完整索引、历史归档与合并去向见 [文档目录](docs/README.md)。
## 主要功能 ## 主要功能
- 供水管网地图、业务图层和设备状态展示 - 供水管网地图、业务图层和设备状态展示
- 地图缩放、测量、绘制、图层控制和视图导出 - 地图缩放、测量、绘制、图层控制和视图导出
- 异常工况列表、详情分析和调度任务跟踪 - 异常工况列表、详情分析和调度任务跟踪
- Agent 流式对话、推荐问题、会话管理和权限批准 - Agent 流式对话、推荐问题、会话管理和权限批准
- 经过 Schema 校验的 UI Envelope 和前端动作执行 - 经过 Schema 校验的 `answer-ui@1` 内容与受控前端动作,兼容旧 UI Envelope
- 运行时配置注入、MSW 本地模拟和开发面板 - 运行时配置注入、MSW 本地模拟和开发面板
Dev 的“调查测试”提供统一 command 格式的案例、JSONL 校验与本地预览,无需先选择调查;选中调查后还可检查实时地图、图表和报告界面块。操作说明与验证边界见 [调查展示 Dev 测试](docs/investigation-dev-tools.md)。 Dev 的“调查测试”提供统一 command 格式的案例、JSONL 校验与本地预览,无需先选择调查;选中调查后还可检查实时地图、图表和报告界面块。操作说明与验证边界见 [调查展示 Dev 测试](docs/investigation-dev-tools.md)。
@@ -52,7 +54,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | | `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
| `TJWATER_AGENT_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | | `TJWATER_AGENT_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | | `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 前端定时工况的日期与演示模式;定时工况仍由 mock 生成。`auto` 和 `backend` 使用当天日期,`demo` 和 `history` 优先使用兼容运行态中的工单日期,`demo` 标记演示时间;不决定 Server 持久任务的调度或执行状态 |
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | `/peripheral` | 外围工单服务地址;生产推荐使用同源代理路径,留空时不发起连接 | | `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | `/peripheral` | 外围工单服务地址;生产推荐使用同源代理路径,留空时不发起连接 |
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | | `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | | `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
@@ -135,7 +137,7 @@ src/
## 构建说明 ## 构建说明
调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。 调查任务以 Server 保存的轮次、报告、证据和审批动作展示进展。人工补充事实与外围工单反馈按各自契约触发续调,不沿用旧“反馈导入后保存复核草稿”的页面说明。当前边界见 [调查轮次与协作](../next-tjwater-server/docs/operational-tasks.md#调查轮次与协作边界)。
“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围系统面板管理业务工单,新建工单来源为 `system_data`;显式演练数据保留 `synthetic` 标记,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围系统说明](../next-tjwater-server/docs/peripheral-sample.md)。 “分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围系统面板管理业务工单,新建工单来源为 `system_data`;显式演练数据保留 `synthetic` 标记,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围系统说明](../next-tjwater-server/docs/peripheral-sample.md)。
+31
View File
@@ -0,0 +1,31 @@
# 供水前端文档目录
当前功能与交互以本目录的维护文档和源码为准,环境、命令与发布入口见 [仓库 README](../README.md),开发边界见 [AGENTS.md](../AGENTS.md)。整理日期:2026-11-10。
## 当前维护文档
| 主题 | 维护入口 |
| --- | --- |
| 样式、组件、材质、字体、图标与动效 | [样式设计指南](style-design-guide.md),唯一的通用样式规范 |
| Agent 聊天布局、会话、输入、报告与阅读 | [Agent 交互与报告](agent-chat-ui-design.md) |
| `answer-ui@1`、组件语义、生成契约与验证 | [本地交互式回答](agent-answer-ui.md) |
| 聊天控件状态、宿主提问与尚未实施的阅读位置 | [交互状态持久化](agent-interaction-state-design.md) |
| Persona 视觉、Rive 状态、资源再生成与缓存 | [Persona 设计与资源维护](agent-persona-design.md) |
| 调查展示、滚动图表窗口、自动弹出、回看及 Dev | [调查任务展示与 Dev 测试](investigation-dev-tools.md) |
| 项目启动、切换与授权项目缓存 | [项目切换](project-switching.md) |
| 风险、状态与错误文案 | [状态语言](status-language.md) |
三端职责见 [工作区受控 UI 架构](../../agent_ui_architecture_without_sandbox.md);正式任务和报告契约由 [Server 文档目录](../../next-tjwater-server/docs/README.md) 管理。自由代码运行时、HistoryExplorer、聊天阅读位置和报告私人状态在功能文档中明确标注为提案或待实施,不能作为现有能力。
## 历史与合并去向
| 原文档或记录 | 当前去向 |
| --- | --- |
| `agent-persona-resource.md` | 已合并到 [Persona 第 8 节](agent-persona-design.md#8-资源生成与维护),不再单独维护 |
| `non-map-ui.md` | [2026 年 9 月面板整理记录](history/non-map-ui-2026-09.md),现行样式归样式设计指南 |
| `rive-persona-incident.md` | [Rive 历史排查](history/rive-persona-incident.md),保留故障依据,当前行为见 Persona |
| 回答 UI 的阶段测试数量与发布状态 | [2026 年 10 月验证记录](history/answer-ui-validation-2026-10.md) |
| 含分析篮的旧交互状态方案 | [原始提案](history/agent-interaction-state-proposal-2026-10.md),分析篮已取消 |
| 聊天与 Persona 的阶段实现 | [实现记录](history/agent-chat-ui-implementation-notes.md) |
历史文档保留当时的测试证据与限制,不代表当前全量测试、真实账号验收或线上服务状态。修改代码后按本仓库要求重新验证,不能只更新文档中的日期或测试数量。
+23 -23
View File
@@ -2,45 +2,31 @@
统一样式见 [样式设计指南](style-design-guide.md),聊天布局与报告交互见 [Agent 交互与报告设计](agent-chat-ui-design.md)。本文保留协议、组件参数与业务交互约定。 统一样式见 [样式设计指南](style-design-guide.md),聊天布局与报告交互见 [Agent 交互与报告设计](agent-chat-ui-design.md)。本文保留协议、组件参数与业务交互约定。
## 实施进度(2026-10-30 更新) ## 实现范围与后续设计
原生组件版的主要界面与交互状态保存已实现,已完成本地自动化验证;真实授权项目联调和发布尚待完成。下表的“已实现”指当前工作区代码,不代表运行服务已同步更新。相关改动仍未提交、未部署。 下表区分当前源码已具备的能力与独立设计提案。“已实现”不证明运行服务已加载此版本;提交、发布与真实项目验收需核对对应交付记录。整理日期为 2026-11-10。
| 能力 | 状态 | 已有范围与剩余边界 | | 能力 | 状态 | 已有范围与剩余边界 |
| --- | --- | --- | | --- | --- | --- |
| Agent 内容组织 | 已实现 | shadcn/ui 原生组件、内容分区、导航、指标与证据展示;沿用现有对话和工具执行通道 | | Agent 内容组织 | 已实现 | shadcn/ui 原生组件、内容分区、导航、指标与证据展示;沿用现有对话和工具执行通道 |
| 任务语义与结果表达 | 2026-10-28 已接入工作区 | `Result`、回答用途、分区角色、疏密及具体说明标题;Agent 选择表达语义,宿主统一视觉与动作边界,真实模型联调待完成 | | 任务语义与结果表达 | 已实现 | `Result`、回答用途、分区角色、疏密及具体说明标题;Agent 选择表达语义,宿主统一视觉与动作边界,真实模型联调待完成 |
| 表格与图表 | 已实现 | 搜索、排序、折线/柱状/数据表切换、区间选择和序列显隐;只处理已加载数据 | | 表格与图表 | 已实现 | 搜索、排序、折线/柱状/数据表切换、区间选择和序列显隐;只处理已加载数据 |
| 报告与 Markdown 阅读 | 已实现 | 表格完整内容可横纵滚动;报告与 Mermaid 有界放大、暗色遮罩、图标按钮、焦点恢复及减少动态效果适配 | | 报告与 Markdown 阅读 | 已实现 | 表格完整内容可横纵滚动;报告与 Mermaid 有界放大、暗色遮罩、图标按钮、焦点恢复及减少动态效果适配 |
| 定位与资料补充 | 已实现 | 保留专用地图定位和补充资料表单;数据组件不提供勾选、分析篮或组件追问 | | 定位与资料补充 | 已实现 | 保留专用地图定位和补充资料表单;数据组件不提供勾选、分析篮或组件追问 |
| 交互状态持久化(A) | 核心已实现 | 图表、表格、表单与证据状态按会话保存;支持刷新恢复、兼容迁移、失败重试和显式冲突处理 | | 交互状态持久化(A) | 核心已实现 | 图表、表格、表单与证据状态按会话保存;支持刷新恢复、兼容迁移、失败重试和显式冲突处理 |
| 阅读位置持久化 | 设计待实施 | reader 记录尚未保存到宿主;分析篮已按产品决定移除,旧 basket 设计不再作为实施目标 | | 阅读位置持久化 | 设计待实施 | reader 记录尚未保存到宿主;分析篮已按产品决定移除,旧 basket 设计不再作为实施目标 |
| 提问与回答生命周期 | 2026-10-28 已接入工作区 | 自有 ask_user、独立草稿、答案持久化与固定 ID 续跑;历史 question 权威核对。真实模型完整链路及发布未验收 | | 提问与回答生命周期 | 已实现 | 自有 ask_user、独立草稿、答案持久化与固定 ID 续跑;历史 question 权威核对。真实模型完整链路及发布未验收 |
| 压力历史探索(B) | 设计提案 | `HistoryExplorer` 和新时间窗分页查询尚未实现,不能将本地筛选当成重新查询 | | 压力历史探索(B) | 设计提案 | `HistoryExplorer` 和新时间窗分页查询尚未实现,不能将本地筛选当成重新查询 |
| 自由代码运行时(C) | 未接入 | 仍采用受控原生组件;独立 iframe、生成代码运行及其通信协议需要另行架构评审 | | 自由代码运行时(C) | 未接入 | 仍采用受控原生组件;独立 iframe、生成代码运行及其通信协议需要另行架构评审 |
| 外围与 Dev 窗口交互 | 已修复 | 窗口外地图可点击、拖动和滚轮缩放;窗口内部操作不穿透地图 | | 外围与 Dev 窗口交互 | 已修复 | 窗口外地图可点击、拖动和滚轮缩放;窗口内部操作不穿透地图 |
对应根目录研究文档的四个阶段:阶段一的标准组件路线已实现;阶段三的原生组件状态管理已提前落地;阶段四已接入部分地图、报告和追问流程;阶段二的自由代码运行时尚未接入。本文后续 A/B/C 是实施包名称,与研究文档的阶段编号不同。 对应根目录研究文档的四个阶段:阶段一的标准组件路线已实现;阶段三的原生组件状态管理已提前落地;阶段四已接入部分地图、报告和追问流程;阶段二的自由代码运行时尚未接入。本文后续 A/B/C 是实施包名称,与研究文档的阶段编号不同。
2026-10-30 展示范围调整已通过 lint、typecheck、146 个文件共 838 项单元测试,以及 25 项浏览器回归(含复跑,使用模拟接口)。浏览器覆盖桌面与窄屏、内联与展开表格、图表动效、会话状态恢复、地图定位和补充表单;不代表真实项目联调或部署完成。 历史静态、浏览器和模型验证分开记录,见 [2026 年 10 月验证记录](history/answer-ui-validation-2026-10.md)。当前验证命令见下文,不能将历史测试数量作为当前通过结论。
2026-10-30 分区、导航与来源精简已通过 lint、typecheck、842 项单元测试和 20 项浏览器回归(含复跑,使用模拟接口);另覆盖节点追问移除后的详情与地图定位。
以下为 2026-10-26 的历史验证记录,不代表后续工作区变更的验证结果:
| 验证层 | 结果 | 覆盖边界 |
| --- | --- | --- |
| 前端静态检查与单元测试 | lint、typecheck 通过;121 个文件、631 项测试通过 | 当前原生 UI、状态管理和既有前端回归 |
| Agent 检查与聚焦测试 | `bun run check` 通过;33 项测试通过 | 状态存储、权限、版本冲突、重试和会话生命周期 |
| UI 浏览器验收 | 10 项通过 | 当时的交互状态、引用追问、Markdown 阅读,使用模拟接口;引用追问现已移除 |
| 窗口鼠标行为回归 | 4 项通过 | 外围与 Dev 的窗口内外事件、地图拖动与缩放;修复前已复现遮挡 |
| 前端构建 | 通过 | SPA、生产 CSS 校验和 TTS adapter 构建;不等于部署完成 |
当前实现入口为 [原生回答组件](../src/features/agent/answer-ui/answer-ui-view.tsx)、[会话状态同步](../src/features/agent/session-state/answer-view-state-scope.ts) 和 [Agent 状态存储](../../next-tjwater-agent/src/sessions/answer-view-state-store.ts)。验证命令见下文“验证”,后续顺序见“后续待办与验收”。 当前实现入口为 [原生回答组件](../src/features/agent/answer-ui/answer-ui-view.tsx)、[会话状态同步](../src/features/agent/session-state/answer-view-state-scope.ts) 和 [Agent 状态存储](../../next-tjwater-agent/src/sessions/answer-view-state-store.ts)。验证命令见下文“验证”,后续顺序见“后续待办与验收”。
2026-10-27 已补齐 [交互状态设计](agent-interaction-state-design.md),覆盖阅读位置、分析篮、正式报告私人状态、接口与存储、版本冲突和验收矩阵。其中阅读位置和正式报告状态尚未实施;分析篮于 2026-10-30 移除,旧设计不再适用。正式报告状态需要新增项目 BizDB 状态表,不能沿用聊天核心 A 阶段“无数据库迁移”的交付结论。后续追加的宿主提问实现状态单列如下。 状态保存、宿主提问与后续阅读位置设计统一见 [交互状态](agent-interaction-state-design.md)。分析篮、行选择和组件追问已取消;聊天阅读位置和正式报告私人状态仍属待实施范围,不能沿用聊天控件状态“无需数据库迁移”的结论。
同日补充的宿主提问方案已于 2026-10-28 接入工作区,见上述文档第 11 节:以自有 ask_user 管理等待、草稿、答案与续跑,同时核对历史原生 question 的可用状态。独立完成此项不代表 reader 或正式报告状态已实施;此前历史故障的具体触发条件仍未取得现场响应。
## 接入方式 ## 接入方式
@@ -78,7 +64,21 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。 短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。 `Chart` 的页签、位于绘图区下方的图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。
SCADA 的压力、水头(m)及流量、管流(m³/h)数值轴使用对齐的整数上下界与整数刻度,不直接显示数据极值外加余量的小数端点。序列、数据表与点详情保持原始读数精度;其他单位及通用小量程图沿用原有刻度,不自动换算单位。
图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:管理区占用右侧分析区域,收纳过程图组与报告 Chart,子图表保留独立弹窗放大。管理区提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄区域使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表仅定位、置顶和统一关闭独立子图表与表格浮窗,不包含图表管理区。拖动分隔条或缩放独立浮窗时,图表按实际尺寸适配并保留图表实例与筛选状态;独立浮窗缩放支持 Escape 或指针取消恢复原尺寸。
任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。
切换调查任务时,报告内容立即替换,展示数据先与新任务范围对齐,再读取其历史结果;旧任务图表不在新任务范围下重新挂载。
历史请求未完成时显示骨架,失败时显示具体错误;图表接近可视区域后分帧挂载,离开任务会取消尚未执行的绘制。
骨架改善等待反馈,不缩短接口响应时间,也不表示调查已经完成。流程图按源码和渲染配置复用最多 32 份浏览器内 SVG,
复用时重新分配实例标识,配置变化和失败重试会重新绘制;报告版本、证据引用和实际任务状态仍以保存的数据为准。
流程图首次排版显示骨架,后续更新保留旧图直到新排版完成,维持阅读位置与缩放;连续更新时忽略迟到的旧排版结果。
其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
@@ -87,7 +87,7 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | | `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | | `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 直接展示原始数值、单位与来源 | | `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 | | `Table` | 搜索排序即时响应;表头悬停与排序点击覆盖整列宽度;仅当前排序列显示升序或降序箭头,未排序时不显示;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | | `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | | `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | | `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
@@ -300,7 +300,7 @@ Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓
尚未持久化:阅读锚点与相对偏移。当前同实例展开/收起的内存保留,不等于刷新后的宿主恢复。 尚未持久化:阅读锚点与相对偏移。当前同实例展开/收起的内存保留,不等于刷新后的宿主恢复。
补齐方案见 [Intelligent UI 交互状态补齐设计](agent-interaction-state-design.md):保留现有 controls 接口,计划新增独立 reader 记录;旧 basket 部分已停止实施;正式报告按当前读者及报告版本保存到 Server,不使用发布报告的 Agent 会话充当读者身份。 后续补齐范围见 [交互状态](agent-interaction-state-design.md):保留现有 controls 接口,阅读位置计划独立保存;正式报告按当前读者及报告版本保存到 Server,不使用发布报告的 Agent 会话充当读者身份。
状态记录附带完整 spec 摘要 `schema_digest`、单块 `revision` 与最后请求 ID。`schema_digest` 由服务端从会话里已保存的块计算;客户端摘要只用于检查过期。摘要变化后执行上表的兼容迁移,再写新版本。按数据整体设置 key 的逻辑改为稳定节点身份加显式兼容处理,不能只删除 key 而沿用越界索引。 状态记录附带完整 spec 摘要 `schema_digest`、单块 `revision` 与最后请求 ID。`schema_digest` 由服务端从会话里已保存的块计算;客户端摘要只用于检查过期。摘要变化后执行上表的兼容迁移,再写新版本。按数据整体设置 key 的逻辑改为稳定节点身份加显式兼容处理,不能只删除 key 而沿用越界索引。
+7 -5
View File
@@ -134,15 +134,17 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
在线动画由 Rive WebGL2 播放原资源的 `default` 状态机。沿用原生 `thinking`、`listening`、`speaking` 和 `hover` 控制项,状态过渡及水纹细节由资源自身完成。原版公开组件可见 [AI Elements Persona 源码](https://github.com/vercel/ai-elements/blob/main/packages/elements/src/persona.tsx);图形制作数据位于运行资源中,可编辑设计文件是 `.rev`,见 [作者的定制说明](https://elements.surge.studio/docs/customization)。已移除之前自行生成球缘曲线、光带和局部高光的实现,也移除改写原版明暗的 CSS 滤镜。配色直接写入 Rive 材质,暗部保持蓝色倾向,高光使用白色。 在线动画由 Rive WebGL2 播放原资源的 `default` 状态机。沿用原生 `thinking`、`listening`、`speaking` 和 `hover` 控制项,状态过渡及水纹细节由资源自身完成。原版公开组件可见 [AI Elements Persona 源码](https://github.com/vercel/ai-elements/blob/main/packages/elements/src/persona.tsx);图形制作数据位于运行资源中,可编辑设计文件是 `.rev`,见 [作者的定制说明](https://elements.surge.studio/docs/customization)。已移除之前自行生成球缘曲线、光带和局部高光的实现,也移除改写原版明暗的 CSS 滤镜。配色直接写入 Rive 材质,暗部保持蓝色倾向,高光使用白色。
离线通过同一 Rive 资源中的 `asleep` 状态呈现“静水待机”:关闭外围三层光圈,保留完整蓝色球体及内部柔光的低速、小幅度流动。在线与离线切换保持同一个动画实例,通过资源内的状态混合衔接,恢复连接后回到在线光圈与节奏。参数和图层说明统一维护在 [Agent 动画资源说明](agent-persona-resource.md)。首次加载、加载失败和系统开启减少动态效果时显示从当前资源渲染的在线或离线静态帧,离线静态帧同样不带外围光圈;兜底形象与按钮功能持续可用。 离线通过同一 Rive 资源中的 `asleep` 状态呈现“静水待机”:关闭外围三层光圈,保留完整蓝色球体及内部柔光的低速、小幅度流动。在线与离线切换保持同一个动画实例,通过资源内的状态混合衔接,恢复连接后回到在线光圈与节奏。参数和图层说明统一维护在 [Agent 动画资源说明](agent-persona-design.md#8-资源生成与维护)。首次加载、加载失败和系统开启减少动态效果时显示从当前资源渲染的在线或离线静态帧,离线静态帧同样不带外围光圈;兜底形象与按钮功能持续可用。
鼠标悬停沿用原生 `hover` 动画和光圈尺寸;进入、移动、离开坐标通过共享组件交给 Rive 原生监听,使白色高光跟随鼠标局部移动,避免只开 hover 却使反射固定在中心。移出后恢复常态。球体组件移除 Motion,取消按压缩放、弹簧回弹和明暗变化;触摸按下不触发额外动画。点击、触摸轻点及 Enter / Space 继续展开对话,离线时同样可用。系统开启减少动态效果时显示静态帧,不初始化 Rive。强制颜色模式显示高对比圆形轮廓,保留键盘操作。球体动画与面板布局独立,不对 Resizable 连续调宽增加动画。 鼠标悬停沿用原生 `hover` 动画和光圈尺寸;进入、移动、离开坐标通过共享组件交给 Rive 原生监听,使白色高光跟随鼠标局部移动,避免只开 hover 却使反射固定在中心。移出后恢复常态。球体组件移除 Motion,取消按压缩放、弹簧回弹和明暗变化;触摸按下不触发额外动画。点击、触摸轻点及 Enter / Space 继续展开对话,离线时同样可用。系统开启减少动态效果时显示静态帧,不初始化 Rive。强制颜色模式显示高对比圆形轮廓,保留键盘操作。球体动画与面板布局独立,不对 Resizable 连续调宽增加动画。
渲染生命周期继续遵守 [Rive 内存问题记录](rive-persona-incident.md):延迟一帧初始化,元素离开视口或页面隐藏时卸载渲染实例,不绑定 View Model;仅缓存不可变的 `.riv` 字节,展开、折叠和不同入口复用一次成功下载。失败下载不永久缓存,后续挂载可重试。动态组件准备完成前保留本地静态帧,原生进入动画在显示切换前完成。静态帧与动画通过 180ms CSS 透明度渐变衔接,结束后才隐藏静态帧,避免首次加载瞬间切换造成的跳动;画布的位置与尺寸始终固定。本轮没有新增依赖,也没有恢复已知存在内存问题的颜色绑定。 渲染生命周期继续遵守 [Rive 内存问题记录](history/rive-persona-incident.md):延迟一帧初始化,元素离开视口或页面隐藏时卸载渲染实例,不绑定 View Model;仅缓存不可变的 `.riv` 字节,展开、折叠和不同入口复用一次成功下载。失败下载不永久缓存,后续挂载可重试。动态组件准备完成前保留本地静态帧,原生进入动画在显示切换前完成。静态帧与动画通过 180ms CSS 透明度渐变衔接,结束后才隐藏静态帧,避免首次加载瞬间切换造成的跳动;画布的位置与尺寸始终固定。本轮没有新增依赖,也没有恢复已知存在内存问题的颜色绑定。
### 离线动画的资源改造 ### Persona 资源与生命周期
已解析原版的图形、属性、关键帧和状态机,保存为可读的运行时 JSON,并验证可以逐字节恢复原始 `.riv`。修改后的资源保留 5 个原生状态输入,在原有 20 段动画后增加 3 段离线循环,修正 `asleep` / `wake` 中隐藏和缩小球体的关键帧。修改、再生成及保真验证见 [Agent 动画资源说明](agent-persona-resource.md)。这份 JSON 是运行时数据,不是完整恢复的 `.rev` 编辑器工程。 Persona 的视觉、尺寸、离线状态、悬停、缓存和再生成统一维护在 [Persona 设计](agent-persona-design.md)。聊天面板与悬浮入口复用同一资源和指针通路,保留静态降级、减少动态效果和可见性卸载。
历史 Rive 内存问题的触发条件与修复依据见 [排查记录](history/rive-persona-incident.md);不根据历史记录重建已经替换的悬停通路或 View Model 绑定。
### 历史窗口、缓存与后台会话 ### 历史窗口、缓存与后台会话
@@ -184,7 +186,7 @@ Agent 面板、历史和主题窗口及正文阅读窗口共用选区复制处
本次无需新增 UI 协议、环境变量或数据库迁移。自由代码沙箱、跨会话保存控件状态和参数驱动的新数据查询属于后续能力;当前表单、筛选与选择在聊天和展开工作区之间移动时保留,历史恢复仍从已保存的回答结构重新建立控件。 本次无需新增 UI 协议、环境变量或数据库迁移。自由代码沙箱、跨会话保存控件状态和参数驱动的新数据查询属于后续能力;当前表单、筛选与选择在聊天和展开工作区之间移动时保留,历史恢复仍从已保存的回答结构重新建立控件。
后续的状态保存、压力历史探索与沙箱接入条件见 [可持续分析工作区设计](agent-answer-ui.md#下一阶段设计可持续分析工作区)。该部分是未实现的提案,不能作为当前功能清单。 后续的状态保存、压力历史探索与沙箱接入条件见 [可持续分析工作区设计](agent-answer-ui.md#可持续分析工作区实现与后续设计)。该部分是未实现的提案,不能作为当前功能清单。
`answer-ui@1` 仍由严格参数校验与本地注册组件驱动。已有的 `Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、`MapLocation`、`FollowUpForm` 使用新外观。聊天继续使用现有 Responses 事件;正式报告增加可选 `ui_blocks` 字段,复用相同的内容块协议。实现契约见 [本地交互式回答](agent-answer-ui.md)。 `answer-ui@1` 仍由严格参数校验与本地注册组件驱动。已有的 `Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、`MapLocation`、`FollowUpForm` 使用新外观。聊天继续使用现有 Responses 事件;正式报告增加可选 `ui_blocks` 字段,复用相同的内容块协议。实现契约见 [本地交互式回答](agent-answer-ui.md)。
+35 -355
View File
@@ -1,375 +1,55 @@
# Intelligent UI 交互状态补齐设计 # 交互状态持久化与后续设计
> 2026-10-30 产品决定:数据组件仅用于展示与本地浏览,已移除表格勾选、分析内容篮及组件追问;专用地图定位和补充资料表单保留。本文分析篮、行选择和引用操作的方案属于历史设计,不再作为当前实施目标。阅读位置、表单草稿及宿主提问的独立设计仍可参考,当前行为以 [本地交互式回答](agent-answer-ui.md) 为准。 整理日期:2026-11-10。本文只维护已实现的状态边界和仍有效的后续工作。2026 年 10 月原提案保存于 [历史设计](history/agent-interaction-state-proposal-2026-10.md),其中分析篮、行选择和组件追问已取消,不再作为实施目标。组件内容与交互入口见 [本地交互式回答](agent-answer-ui.md)。
历史设计状态:2026-10-27 设计稿;2026-10-28 第 11 节宿主提问已接入工作区代码,其余补齐项仍待实现。当前代码进度见 [本地交互式回答](agent-answer-ui.md)。本设计补齐阅读位置、分析篮和正式报告的私人交互状态,不改变模型可生成的 `answer-ui@1` 组件协议。第 11 节单独处理 question 的执行生命周期。 ## 当前能力
## 1. 目标与方案 | 范围 | 状态 | 边界 |
| --- | --- | --- |
| 聊天图表、表格、表单和证据控件状态 | 已实现 | 按用户、会话和内容身份保存;包含版本比较、失败重试和冲突处理 |
| 宿主 `ask_user` 请求、草稿、答案和续跑投递 | 已实现 | 使用独立请求与投递记录;历史原生 question 仍需核对当前原生请求是否有效 |
| 阅读位置 reader | 待实施 | 当前页面的阅读状态不等于刷新后的持久记录 |
| 正式报告私人状态 | 待实施 | 需要按读者、任务及报告版本独立保存,不借用报告发布会话 |
| 分析篮、行勾选、组件追问 | 已取消 | 不恢复旧 basket 协议或为其新增存储 |
用户刷新后,再次打开同一回答或同一版报告,可以继续阅读、筛选和填写资料。恢复只改变当前用户的界面,不发送消息、不提交表单、不调用业务工具、不定位地图,也不自动打开窗口。 ## 聊天控件状态
推荐保留已实现的聊天控件状态接口,新增阅读与分析篮的补充记录;正式报告的状态由 Server 按当前读者保存。三种记录独立比较版本,避免滚动保存与表单输入、选行互相冲突。前端继续复用受控 store 和现有保存协调器,只由来源适配器决定请求 Agent 还是 Server。 控件状态与回答内容分别保存。内容由消息或报告持有,状态仅改变当前读者的本地浏览方式;恢复不能发送消息、提交表单、调用业务工具、定位地图或自动打开窗口。
最小方案是仅增加会话内存缓存,但刷新后仍会丢失,不能满足目标。把全部状态塞入原接口会破坏旧客户端的严格校验,也会让分析篮挤占控件的 32 KiB 限额,因此采用补充记录。正式报告不能借用 `source_session_id` 保存:它指向发布报告的执行会话,报告读者未必拥有这个会话。 状态身份需要保持会话、block 和内容版本边界。同块数据更新时清理已经失效的选择;迟到的恢复结果不能覆盖用户的新输入。失败保存保留内存交互并明确提示未保存,冲突需显式处理,不静默覆盖其他窗口的新版本。
新增服务、账号、密钥和环境变量均为零;正式报告需要在已有项目 BizDB 增加一张私人状态表。预计涉及三个仓库、超过 8 个实现文件,按第 10 节拆成可独立交付的两批。 当前实现入口:
- [前端状态同步](../src/features/agent/session-state/answer-view-state-scope.ts) 与 [原生回答组件](../src/features/agent/answer-ui/answer-ui-view.tsx)。
- [Agent 状态路由](../../next-tjwater-agent/src/routes/answer-view-state-routes.ts) 与 [状态存储](../../next-tjwater-agent/src/sessions/answer-view-state-store.ts)。
- [会话归属与存储](../../next-tjwater-agent/docs/user-isolation.md),负责授权、删除及持久目录边界。
## 宿主提问与回复
聊天及人工任务协作通过宿主 `ask_user` 保存等待状态。问题定义、表单草稿、已提交答案和续跑投递是不同记录;工具和浏览器不能自行指定所有者、项目或目标会话。后台调查的任务提问由 Server 管理,见 [工况任务契约](../../next-tjwater-server/docs/operational-tasks.md),不以浏览器存活为条件。
```text ```text
聊天最终回答 -> Agent 来源适配器 -> 已授权会话 / 控件记录 + 补充记录 模型 ask_user → 可信工具桥 → 宿主保存请求 → 显示问题卡,等待回答
^ 用户提交 → 保存答案与投递记录 → 运行协调器 → 原会话续跑
| ↑ 固定消息 ID 核对投递状态
读取/编辑 -> 前端状态协调器 -> 受控组件、阅读位置、分析篮
|
v
指定版本报告 -> Workbench 来源适配器 -> Server 报告权限 / 当前读者状态
用户主动“带入对话” -> 现有输入区 -> 用户确认发送 -> 既有 Agent 流程
``` ```
基础能力沿用 [json-render 的运行时状态支持](https://json-render.dev/docs/data-binding) 和 [React 的外部 store 订阅](https://react.dev/reference/react/useSyncExternalStore)。阅读容器尺寸变化使用 [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 观测;这些机制不替代宿主的权限、持久化和版本校验。实现以仓库当前安装版本为准,不因本设计升级依赖。 浏览器通过 `/api/v1/session/:session_id/interactions` 读取请求,按 `interaction_id` 保存草稿并执行 `reply`、`skip`、`cancel` 或 `resume`。独立列表用于恢复问题;不能据此认定所有历史请求已经嵌入会话详情的分页投影。
## 2. 当前依据与状态分工 保存答案成功与推理继续成功分开反馈。投递结果不明时核对固定原生消息 ID,不生成新 ID 重复注入。宿主恢复需要有效用户上下文,不持久保存 Bearer,也不悄悄创建新会话重放业务过程。旧 question 卡片能显示,不代表原生请求仍可回答。
| 当前实现 | 补齐方式 | 实现入口为 [请求路由](../../next-tjwater-agent/src/routes/interaction-request-routes.ts)、[请求存储](../../next-tjwater-agent/src/sessions/interaction-request-store.ts) 与 [投递核对](../../next-tjwater-agent/src/sessions/interaction-delivery-reconciliation.ts)。原始字段设计、故障假设和当时的验证边界见历史设计第 11 节;“拟新增”和“尚未验证”仅反映提案阶段,不能替代当前代码核对。
| --- | --- |
| `answer-view-state@1` 保存图表、表格、表单、证据状态 | 保留接口、32 KiB 限额和迁移规则;复用其保存/恢复机制 |
| `ExpandableReading` 用 `useId()` 标识实例;阅读偏移放在 DOM dataset | 注入业务来源身份;只保存可序列化的阅读位置,DOM、host、focus 回调留在内存 |
| Markdown 表格的行内和放大视图是两份 DOM,图示可移动同一个 host | 按阅读面分别记录位置,不假设搬动 DOM 等于持久化 |
| `AnswerUiView` 的分析篮只存 `{title,text}`,意图由 Tray 的 `useState` 保存 | 增加来源、选择描述和摘要;篮与意图一起受控保存 |
| `InvestigationReportBlocks` 接收报告快照,并用任务证据覆盖 Evidence 展示 | 按报告版本及当前读者加载状态;两端核对实际渲染内容摘要 |
| 正式报告属于项目任务,读取使用 `operational_task.view` | 私人状态沿用报告读取权限,不要求读者拥有发布会话,不写回正式报告 |
| 记录 | 持久化内容 | 保持临时的内容 | ## 后续阅读位置与正式报告状态
| --- | --- | --- |
| controls,按块 | 现有节点控件状态和证据类别筛选 | 请求中的 loading/error、悬停、菜单和下拉框开关 |
| reader,按文档 | 阅读面的位置、锚点、局部偏移和内容摘要 | 是否打开弹窗、当前焦点、地图相机、窗口尺寸、DOM 引用 |
| basket,按块 | 有序选择项、来源描述、选取时文本快照、分析意图 | 正在带入对话、临时提示、浏览器文本选区 |
说明:分析篮仍以一个回答块为边界,本次不扩展为跨消息、跨报告的全局收藏。证据大分区、范围工具栏的折叠开关沿用当前内存行为;它们不决定所选数据。Mermaid 平移/缩放也只在当前实例内保留,重新打开时按可用空间适配,不保存会随图形重新布局失效的像素变换。 阅读位置应与高频控件和表单保存分开,使用内容身份和稳定锚点;数据变化或章节不存在时回退到有效位置。恢复位置不抢夺用户操作,不产生地图或业务副作用。
## 3. 身份与版本 正式报告私人状态应绑定当前读者、项目、任务和报告版本,并在每次读写时检查当前读取权限。`source_session_id` 是报告的发布会话,不能作为读者授权或私人状态存储身份。该能力需要 Server API 与项目 BizDB 表及逐项目迁移,尚无可直接调用的已交付接口。
| 来源 | 文档身份 | 块身份 | 权威校验 | 历史提案中的接口草案和容量数值仍是设计参考;实施前重新核对当前契约,不提前对外宣布可用。无需为已经取消的分析篮新增表、记录或权限。
| --- | --- | --- | --- |
| 聊天 | 当前主体、个人/项目范围、sessionId、assistantMessageId | 文档身份 + blockId | Agent 校验会话归属、已保存的助手消息及最终回答引用;旧版没有 final 的记录沿用既有兼容规则 |
| 正式报告 | 当前 user_id、project_id、task_id、report_revision | 文档身份 + blockId | Server 校验当前成员身份、`operational_task.view` 和指定报告是否存在 |
`nodeId` 来自验证后的 `spec.elements` 键;不得使用标题、数组当前序号、React `useId()` 或 DOM selector 作为业务身份。客户端不能指定状态所有者,主体和项目必须来自服务端认证上下文。 ## 验证与交付
报告的 `revision` 在任务内唯一,`execution_generation` 由服务端从报告推导并回传,不作为另一个客户端命名空间。历史 `null` 保留为 `null`,不能替换为 0 或任务当前轮次。文档与块的 `state_revision` 是私人交互版本,不能与报告 revision 混用。 前端与 Agent 的检查命令统一见 [回答 UI 验证](agent-answer-ui.md#验证)。重点覆盖刷新恢复、同块数据更新、两窗口冲突、保存失败、会话切换后迟到响应、删除后的迟到写入、问题提交与续跑结果不明,以及窄屏和键盘操作。
打开任务仍默认查看当前报告,不自动恢复为上次选中的历史版本。用户主动选择某个版本后才恢复该版本的状态。新报告使用新的身份和空状态,不自动复制旧版表单或分析篮;旧版记录保留供用户回看。放大窗口保留旧版内容时,状态写入也绑定当时的旧版,直到用户点击“查看最新内容”。 模拟 HTTP、浏览器交互、隔离原生投递验证、真实模型完整续跑和部署验收分别记录。此轮文档整理不重新执行推理、迁移或发布,也不将历史检查更新为当前验收结论。
最容易失效的前提是“相同 blockId 就是相同数据”。本设计用内容摘要、节点类型和选择描述复核,无法唯一匹配时保留可核对的草稿并清理无效选择,不将旧数组下标套到新数据。
## 4. 记录与接口
### 4.1 保留现有接口,新增补充接口
| 所属服务 | 接口 | 状态范围 |
| --- | --- | --- |
| Agent,已有 | `GET/PUT /api/v1/session/:sessionId/ui-state` | 聊天 controls,保持原 `answer-view-state@1` 协议 |
| Agent,拟新增 | `GET/PUT /api/v1/session/:sessionId/interaction-state` | 聊天 reader 或 basket;以 message_id、kind 和 basket 所需的 block_id 定位 |
| Server,拟新增 | `GET/PUT /api/v1/operational-tasks/:taskId/reports/:reportRevision/interaction-state` | 正式报告 controls、reader 或 basket;controls/basket 需要 block_id |
GET 的 kind 只有 `reader`、`basket`,Server 额外允许 `controls`。reader 属于整个文档,不接受 block_id;其内部阅读面可以引用块。GET 无记录返回对应空 payload、state_revision 0 和当前内容摘要,不写入空记录。未知 kind、无效字段或不存在的块返回 400/404,不隐式建立来源。
GET 选择器放在 query,PUT 放在 body;Agent 两种方法均要求 message_id,Server 报告身份只取 URL。PUT 另要求 `version="ui-interaction-state@1"`。两端错误在前端适配器内统一为 code、message、current:Agent 沿用顶层 code/detail,Server 沿用现有 detail envelope,不让通用组件处理两套响应形状。所有响应使用 private/no-store。
新接口的记录版本为 `ui-interaction-state@1`。字段固定如下;不接受任意对象透传。
| 字段 | 规则 |
| --- | --- |
| `source` | 只出现在响应中,返回服务端确认的聊天或报告身份;不返回用户 token、宿主路径 |
| `kind`、`block_id` | 标识记录种类及块;reader 的 block_id 不存在 |
| `state_revision` | 每个记录独立的非负整数,只出现在响应中 |
| `source_digest` | 服务端规范化内容的 SHA-256;PUT 原样带回,用于拒绝过期内容上的保存 |
| `payload` | 由 kind 选择严格 schema,字段见第 5、6 节;report controls 使用已有 nodes/block 的允许字段与迁移规则 |
| `updated_at` | 服务端写入时间;空记录为 null,不用客户端时间决定覆盖顺序 |
| `item_status` | 仅 basket 响应包含,按 item_id 返回当前有效性与原因;不接受客户端写入 |
| `expected_revision`、`request_id` | 只用于 PUT;版本比较和最近请求重试沿用现有规则 |
选择器对应的身份摘要、version、revision 和请求标识均受限长与整数范围校验。source_digest 为 64 位小写十六进制;request_id 为 1 至 128 字符;message_id 为 1 至 200 字符;block_id 沿用原协议。state_revision/expected_revision 使用 JavaScript 安全整数范围,禁止负数或溢出。
`source_digest` 的材料:聊天 reader 为已发布报告文字与引用块,basket 为所在块的实际 spec;报告 reader 为指定版本的报告正文及其可见证据投影,controls/basket 为对应块的实际渲染 spec。规范化 JSON 对对象键排序,数组顺序不变,摘要算法跨语言使用同一组固定测试样例。摘要是过期检查依据,不是客户端提交文字的真实性证明。
### 4.2 正式报告的 Evidence 投影
报告原 spec 与实际显示的 Evidence 可能不同:当前实现从任务证据补充摘要、来源、时间、类别,找不到证据时显示提示文本。Server 与前端必须采用相同投影规则,控件验证及摘要计算以投影后的 spec 为准。
Server 状态读取先按现有报告/证据访问规则取得来源,计算投影及 source_digest;前端对已经加载的报告与证据执行同一规则并计算摘要。两者不一致时,不把旧状态应用到当前节点:对指定报告和任务证据做一次有界刷新,再核对摘要;仍不一致则显示“内容已更新,交互状态暂未恢复”,允许用户重试,禁止无限刷新。无需在状态响应中再复制整份报告或原始图表数据。
PUT 在事务中重读报告和证据,投影变化返回 409 `UI_INTERACTION_SOURCE_CHANGED`,并返回当前记录和摘要。前端先更新来源,再迁移合法控件;无权访问的证据不以篮子快照重新显示。此投影规则需要前后端共用测试样例,不能各自猜测字段映射。
### 4.3 存储与容量
聊天补充记录放在宿主 `chat/answer-interaction-state.json`,加入 `SessionWorkspaceStore` 允许列表,复用同一队列、所有权校验、删除标记及原子写入。已有 controls 文件保持不变,旧客户端读写不会抹掉新状态。新文件每个 document reader、每个 block basket 独立版本,文件总量最多 1 MiB,包含回执;与已有 controls 文件合计最多 2 MiB/会话。
正式报告新增项目 BizDB 表 `workbench.report_user_view_states`,每个 `(project_id,user_id,task_id,report_revision)` 一行,JSONB document 内分别保存 controls、reader、baskets 和最近回执。每个子记录有独立 state_revision;行锁只用于版本比较、容量检查和原子替换,不把滚动和表单合并成一个逻辑版本。报告外键 `(project_id,task_id,report_revision)` 使用物理删除级联;user_id 来自 `ProjectContext`,不建立跨数据库外键。
首次写入采用插入空行且忽略唯一键冲突,再锁定同一私人状态行,避免两个 revision 0 请求同时成功。事务失败回滚空行和状态修改;报告删除由外键与事务约束阻止迟到写入复活。状态表不是报告事实表,保存私人状态只要求报告读权限,不借此授予任务写入或审批权限。
拟新增迁移 `next-tjwater-server/resources/sql/026_report_user_view_states.sql`,沿用项目 BizDB 显式迁移流程。当前最高编号为 025,实施时若编号已占用,顺延编号。接口不执行建表;未迁移的项目返回安全的 503,报告仍可阅读,草稿留在当前内存。
| 项目 | 限额 |
| --- | --- |
| controls payload | 32 KiB UTF-8/块,沿用现有节点数量和字段限制 |
| reader payload | 8 KiB UTF-8/文档,最多 16 个阅读面 |
| basket payload | 64 KiB UTF-8/块,最多 8 项;正文组合不超过现有 8000 个 UTF-16 代码单元;分析意图最多 800 个 UTF-16 代码单元 |
| 正式报告私人状态行 | 1 MiB,计入所有子记录和回执 |
| 补充记录重试回执 | 每个聊天补充文件或报告私人状态行,最近 32 条且最多 128 KiB;过期请求仍受旧 expected_revision 阻止重放 |
大小检查包括标识与元数据的序列化体积,不截断表单或选择内容来勉强保存。超限返回 413,保留当前草稿,提示先减少分析篮内容或清除不再需要的记录。业务限制 8000/800 由三端按相同 UTF-16 计数规则校验,不能用 Python 字符数直接替代。发送消息时仍检查问题、意图、引用及来源说明的完整文本是否超过现有 10000 限额。
不按每次滚动追加历史;当前快照覆盖、回执有界。记录数量随实际访问的用户/报告组合增长,使用上述复合主键直接读写,不扫描全部用户状态;本版本不增加自动 TTL,避免无提示删除草稿。
## 5. 阅读位置
reader payload 为 `positions` 和 `recent_reader_keys`,前者按宿主生成的 readerKey 保存位置,后者是最近使用顺序。两者最多 16 项且键集合一致;位置键、锚点 id 最多 200 个字符,不接受 DOM selector 或脚本。每项位置字段如下。
| 字段 | 含义 |
| --- | --- |
| `content_digest` | 该阅读面实际内容的摘要;用于判断是否可复用整体滚动比例 |
| `anchor` | 可空的 `{kind,id,occurrence}`;kind 为 section、node、markdown,id 来自稳定业务键或规范化 Markdown 块摘要 |
| `anchor_offset_ratio` | 锚点内部偏移,有限数值,范围 0 到 1 |
| `scroll_y_ratio`、`scroll_x_ratio` | 容器可滚动距离的比例,范围 0 到 1;无可滚动距离时为 0 |
readerKey 只由宿主注册:`workspace`、`report-result`、原生 `blockId/nodeId` 对应阅读面,以及 Markdown 内容块摘要派生的表格/图示阅读面。纯文本最终回答也可建立 document reader,无须伪造 answer-ui 块。标题或“第 n 个表”不是跨内容更新的稳定键;相同内容重复出现时 occurrence 只在摘要相同的文档内使用。
这里只保存版本明确的内容。当前调查进展、实时任务时间线、审批卡和报告版本选择器仍属于实时任务界面,不绑定到某个报告 revision 的阅读状态。用户输入、审批焦点和地图位置均不通过 reader 恢复。
恢复流程:
1. 用户主动打开对应阅读面,绑定不可变的 source 身份并订阅状态。尚未加载历史消息/报告时不为了恢复位置自动翻完全部历史,也不自动切换会话或报告版本。
2. 内容挂载、控件恢复和必要字体准备完成后,用连续两帧相同尺寸判断可定位;最多等待 1500ms。`ResizeObserver` 仅用于这次定位,不能持续把用户拉回旧位置。
3. 优先定位仍唯一存在的锚点,再恢复锚点内部偏移。没有锚点但 content_digest 相同,则使用整体滚动比例;内容已变化且锚点无法对应,回到本阅读面起点,提示“内容已变化,原阅读位置无法恢复”。
4. 比例始终按当前实际滚动范围计算并夹紧;窄屏和宽屏互切不能直接复用旧像素 scrollTop。Markdown 行内与放大表格使用不同 readerKey,横向位置也分别记录。
5. 每次主动打开最多执行一次自动定位。等待期间出现用户滚轮、触摸、指针按下、滚动键、文本选择、编辑或内容导航,则取消自动定位;迟到 GET 只进入缓存,不移动当前视口。需要回到上次位置时由用户点击入口。
阅读保存使用 1000ms 防抖,连续滚动每 5 秒最多提交一次;关闭阅读面或切换来源时保存最后位置。位置未变化不发送;程序恢复产生的 scroll 事件不标记为用户编辑,不反向触发保存循环。最多 16 个位置,超出时只淘汰最久未使用的阅读位置,保留当前阅读面;分析篮和表单不参与此淘汰。
reader 冲突不弹出阻断窗口:保留当前视口,接收远端的新 revision 供下一次真实用户滚动保存,显示轻量“阅读位置未同步”状态;不自动覆盖远端,也不因远端更新跳动当前页面。下次用户操作只更新对应位置,合并远端其他阅读面的位置和最近使用顺序,不能用整个旧 positions 覆盖它们。网络失败可随下一次用户滚动重试或由用户手动重试。
## 6. 分析篮与表单草稿
basket payload 为同一份 `{items,intent}`。items 是有序数组,新增、移除、清空和意图编辑由一份受控状态管理;不拆成八个各自保存的项目。
| 选择项字段 | 规则 |
| --- | --- |
| `item_id` | 本地生成的稳定 UUID,保存与重排时保持;不用于权限证明 |
| `node_id`、`selection_kind` | 必须对应来源中的真实节点;首批 kind 为 metric、table_rows、chart_point、chart_range |
| `selector` | table_rows 为原始行索引及数据摘要;chart 为点/区间索引、对应标签、可见序列索引与名称;metric 不带行索引 |
| `source_digest` | 选取时所在块的有效内容摘要,由已读取来源取得 |
| `title`、`snapshot_text` | 选取时的纯文本显示快照,title 最多 200 个 UTF-16 代码单元;正文计入组合总限额 |
服务端校验节点类型、索引上限和选择描述,并校验来源摘要;快照文本是用户引用内容,不提升为事实证据,不执行其中指令。它是“不复制原始数据”原则的有界例外,只保存用户明确选取的文本,不保存整份时序数据。正式报告的 evidence_refs 及报告身份由宿主从块与报告补充,不信任客户端伪造来源。
去重依据是节点、选择描述和选取时内容摘要;不同节点即使文本相同也可分别选取。每个选项保留当时的文本,不随筛选、排序或数据更新悄悄重算。服务器计算恢复状态为 valid、stale 或 unavailable,客户端不能提交该标记:
- 内容仍一致为 valid,正常使用。
- 节点仍存在但数据/结构摘要变化为 stale,显示“内容已变化,此项为先前快照”;保留快照供核对,整篮“带入对话”暂不可用,须移除该项或从当前内容重新选择。不会自动迁移篮中行索引。
- 来源节点或证据已不可用时为 unavailable,停止返回相关快照文本,只保留可移除的占位;不得通过篮子绕过当前来源访问规则。整个文档失去权限时按第 8 节处理。
新增选择只接受当前 source_digest;已保存的 stale 项可以原样保留或移除,不能借保存意图修改它的快照/来源。兼容校验先按 item_id 对比已存项,再检查新增项。无法自动迁移时不将 stale 项冒充当前数据。
GET 响应另有 `item_status`,按 item_id 返回状态及简短原因,不混入可写 payload。unavailable 项以相同 item_id 和来源标识返回,title/snapshot_text 使用固定不可用提示;PUT 只能保留这个经过屏蔽的占位或删除它。幂等重试返回回执前也要重新检查当前来源可用性并屏蔽不可用文本,不能直接回放旧的含文本响应。回执确认原写入版本,不构成允许再次显示旧快照的授权。
items 与 intent 分别记录 dirty 路径;清空动作原子设置 `items=[]`、`intent=""`,即使值为空也保留 dirty 标记,迟到 GET 不得把旧项目重新并入。清空保存期间又新增内容时,旧回执只能确认它发送时对应的修改序号,新内容继续保留并按新 revision 保存。
“带入对话”只复制捕获的篮内容和意图到现有输入区,成功后仍保留篮子。异步回调同时捕获 source、目标 session 和当前作用域代次;用户已切换项目/会话时,旧回调不得填入新的输入区。实际发送继续由用户确认,失败不清空篮或表单。
表单草稿沿用现有控件存储。普通聊天发送成功后,只清除与该次发送快照仍相同的字段;发送期间新输入的字段保留。正式报告的表单默认带入对话,未获得实际发送确认前不清草稿。恢复只填充字段,不调用提交回调,不把报告表单写入正式证据。
## 7. 保存与冲突规则
共同流程为读取、合并本地编辑、待保存、保存中、已保存;异常分为未保存、冲突、来源变化和失去访问权限。展示不要求网络先成功,写入必须等已知 revision 和来源摘要。控件及分析篮继续使用 500ms 防抖,读取失败只保留一次首次编辑触发的补读,其余由显式重试触发,不轮询。
| 情况 | 处理 |
| --- | --- |
| GET 比用户编辑晚到 | 按 dirty 字段合并;items 是一个整体,不做数组 union;清空和删除也不能被恢复覆盖 |
| PUT 尚未返回又发生编辑 | 保留更新的 changeSeq;回执只清除已确认的旧 dirty 字段,再保存后续版本 |
| 响应丢失,无法判断写入是否成功 | 先以原 request_id 和原始 payload 重试;确认后再发后续编辑 |
| 409 `UI_INTERACTION_STATE_CONFLICT` | controls/basket 保留本地草稿,停止自动重试;允许“保留本地并重新保存”或确认后“加载远端” |
| 409 `UI_INTERACTION_SOURCE_CHANGED` | 先有界刷新来源并兼容迁移;不可直接拿新摘要覆盖旧索引;stale 篮项目按第 6 节处理 |
| 同 request_id 携带不同 payload | 409 `UI_INTERACTION_REQUEST_CONFLICT`;视为客户端错误,保留草稿,不能自动换 ID 继续重放 |
| 413 或服务不可用 | 保留内存草稿,说明未保存;不切片、截断或循环提交 |
| 服务端确认目标回答正在生成或等待落盘 | 返回 503 `UI_INTERACTION_SOURCE_NOT_READY`,保留草稿;首次用户编辑可触发一次补读,之后显式重试,不等同于来源被删除;仅会话存在不能证明回答正在落盘,不存在的来源仍返回 404 |
| 临时 401 | 沿用现有认证刷新机制;不能因此扩大权限或换用其他主体保存 |
来源摘要冲突必须先解决来源变化,再决定是否覆盖交互版本;“保留本地”不是绕过数据校验。不同浏览器不增加 WebSocket 或后台同步:打开来源时读取,保存时发现冲突。自动读取到较新状态也不能改动用户正在操作的视口或表单。
状态提示按记录聚合:“已保存”表示本界面当前脏记录全部已确认;控件已保存而篮子未保存时明确标出分析篮。纯阅读位置同步异常只在阅读面显示轻量状态,不让每次滚动占据对话通知区。
“加载远端”的确认绑定当前记录和确认时的 changeSeq;确认后又出现新的本地输入,旧确认结果不得覆盖它,应保留新输入并重新呈现冲突选择。
## 8. 清理、权限与删除
- 切换会话时,未失败的待保存写入仍使用捕获的旧 source;新来源创建独立订阅。组件卸载不立即丢掉同一认证作用域内的草稿。
- 登出、切换主体或项目时,取消未发送计时器、移除旧订阅并清理对应缓存。已发出的请求不能撤销既成写入,但回执不得进入新的作用域。
- 401 经认证处理确认失效、403 或来源不存在的 404,关闭来源恢复通道,清除该来源缓存中的正文快照和草稿,不在新用户界面显示;不能将这些响应当普通离线错误长期保留敏感快照。后续重新获得访问权限时重新授权读取。
- 显式“清空”使用带 expected_revision 的空 payload 写入,并增加 state_revision;保留空记录和近期回执,不删成 revision 0,防止迟到保存使已清内容复活。表单、篮和 reader 各自清空,不连带删除业务报告。
- 聊天删除沿用宿主删除标记与队列,清除两个状态文件;聊天分叉不复制私人控件、篮和阅读位置。
- 正式报告物理删除时由外键级联删除私人状态。任务取消、源撤回或进入新轮次不等于报告删除;只要旧报告仍按现有规则可读,其私人状态继续按旧 revision 保留。
- 每次 GET/PUT 重新检查报告权限。成员撤权对后续请求生效;已经通过鉴权的在途请求可能按当时权限完成,不承诺跨 metadata 与 BizDB 事务的即时强撤回。错误响应不能返回其他用户状态或证据快照。
## 9. 验收矩阵
| 场景 | 必须满足的结果 |
| --- | --- |
| 刷新后主动打开同一回答/报告 | 恢复控件、篮意图和阅读位置;不自动打开弹窗、定位地图或发送消息 |
| 桌面与 320px 窄屏互换 | 锚点/比例按新布局恢复,表格横纵滚动均有效,无越界和全页横向溢出 |
| 阅读恢复晚到、字体/图表延迟渲染 | 用户未操作时最多定位一次;用户已滚动或导航后不抢位置;1500ms 后不无限等待 |
| 同一 Markdown 内有重复标题/表格 | 同内容版本可按 occurrence 恢复;内容改变后匹配不唯一只清该位置 |
| 篮清空后 GET 或旧 PUT 回来 | 已清项目不复活,清空之后的新编辑不丢失 |
| 数据换序、重复标签、列变化或 Evidence 消失 | 控件执行明确迁移;篮快照标为 stale/unavailable,不静默换成别的数据 |
| 报告当前版变成下一版 | 旧阅读窗仍向旧版保存;用户选择新版本才加载其独立状态 |
| 两位有报告权限的用户 | 可分别保存同一报告,不能互读或互相冲突;均不要求拥有 source_session_id |
| 同一用户两窗口修改 | 控件/篮出现明确冲突;阅读位置不跳动;原请求重试不增加重复版本 |
| 状态保存中切换用户、项目或来源 | 旧响应和带入对话回调不污染新作用域;失去权限不返回缓存快照 |
| 32/64 KiB 边界、emoji/CJK、文件或报告行满额 | 字节和 UTF-16 限制一致,失败不截断、不丢当前草稿 |
| 会话删除、报告删除、空记录清除竞态 | 不复活删除对象,不把空记录误当从未保存 |
| Server 项目未迁移或接口回退 | 保持可读和当前内存交互,明确未保存;旧版聊天 controls 接口继续工作 |
| 回答正在落盘与来源已删除 | 前者保留草稿并有界重试,后者清理来源缓存;不能将普通 404 无限转成等待 |
| reduced motion、键盘打开和关闭 | 定位不增加动画;恢复不抢焦点,关闭回到触发按钮 |
单元测试覆盖状态归一化、dirty/tombstone、字节预算和迁移;浏览器使用有意延迟、响应丢失和冲突的模拟接口验证行为;Server/Agent 聚焦测试验证真实存储事务、权限和删除。最后以隔离 Agent、已授权项目和真实报告完成保存读回,明确区分模拟接口与真实服务结果。
## 10. 实施顺序与文件交接
第一批补齐聊天 reader/basket,可独立上线,不等待报告表迁移。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| frontend | `src/features/agent/session-state/`、`src/features/agent/api/` | 增加补充记录 schema 与来源适配器,复用现有队列/dirty/retry;保持旧 controls 接口 |
| frontend | `answer-ui/answer-selection.ts`、`answer-selection-tray.tsx`、`answer-ui-interactions.tsx`、`answer-ui-view.tsx` | 选择描述、稳定项目身份、受控 items/intent、快照有效性及带入对话保护 |
| frontend | `src/shared/ui/reading-workspace-context.ts`、`reading-workspace.tsx`、`src/shared/ai-elements/markdown-reading.tsx` | 只接收通用 source key 和位置读写回调;锚点、容器测量与恢复取消,不导入业务模块 |
| Agent | `src/sessions/workspace-store.ts`;拟新增 `src/sessions/answer-interaction-state-store.ts`、`src/routes/answer-interaction-state-routes.ts`;`src/routes/chat.ts`、`src/server.ts` | 受控补充文件与新接口、来源校验、CAS、容量和删除 |
第二批接入正式报告,复用第一批组件和补充记录协议。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| Server | 拟新增 `resources/sql/026_report_user_view_states.sql` | 项目 BizDB 私人状态表及报告删除外键 |
| Server | 拟新增 `app/domain/schemas/report_view_state.py`、`app/services/report_view_states.py`、`app/infra/db/postgresql/report_view_states.py`;`app/api/v1/endpoints/operational_tasks.py` | 状态模型、Evidence 投影、读者权限、行锁、独立子记录版本和错误契约 |
| frontend | `src/features/workbench/api/operational-task-client.ts`;拟新增 `api/report-view-state-client.ts` | 读取指定报告版本、通过现有项目请求头访问私人状态接口,解析 Server 错误 envelope |
| frontend | `components/investigation-report-blocks.tsx`、`investigation-task-report.tsx`、`investigation-expand-card.tsx` | 显式报告身份、冻结阅读版本对应的状态绑定、投影摘要核对和只读者状态接线 |
Workbench 通过 Agent 公共 API 注入保存适配器,Agent 组件不能反向导入 Workbench;shared 阅读组件只处理通用锚点与滚动,不接触 token、taskId 或业务请求。
验证命令沿用各仓库 AGENTS:前端 `pnpm lint`、`pnpm typecheck`、`pnpm test`,新增 `tests/browser/agent-interaction-state.e2e.ts` 并回归既有阅读/状态用例;Agent `bun run check`,新增 `tests/sessions/answerInteractionState.test.ts`、`tests/routes/answerInteractionStateRoutes.test.ts`;Server 新增 `tests/unit/test_report_view_states.py`、`tests/api/test_report_view_states.py`,覆盖本节矩阵的存储与权限分支。新测试文件在实现时创建,本设计不将它们标为已存在或已通过。
发布顺序:聊天先 Agent 补充接口、后前端;报告先对目标项目备份并应用迁移,再发布 Server 和前端。迁移必须包含可重复执行与旧报告外键校验,不能对未授权项目批量执行。回退前端保留两类状态文件与报告状态表;回退 Server 关闭新接口即可,不需要删除数据。每批完成后记录实际提交、迁移项目、服务版本和读回结果,不以本地测试代替发布验收。
## 11. 宿主管理提问与回复
2026-10-28 实施记录:已接入宿主存储、工具桥与门禁、问题卡、草稿保存、Responses 续跑及历史原生 question 核对。当前实现通过独立列表恢复问题,明确重试继续;尚未将全部历史请求嵌入会话详情的分页投影。已使用隔离原生实例验证固定输入 ID 去重与重启持久化,未调用付费模型验证实际工具选择和完整推理续跑。下述契约保留为设计与验收依据;未提交、未部署。
原生验证发现:已回答或取消的 form 在重启后可能返回不存在。因此兼容入口优先保留宿主已知终态,未知且原生缺失的历史卡片显示失效;不能仅凭 form 缺失推断用户已经回答。新协议不依赖原生 form 保持等待状态。
本次验证:Agent 静态检查及 67 项聚焦测试通过,追加边界修改后对应 13 项回归通过;前端 lint、typecheck、125 个文件共 655 项测试及生产构建通过。桌面和 320px 浏览器场景通过,一次重复运行在打开面板前超时,独立重跑通过。构建使用 Node 24.13.0,低于 package.json 要求的 24.22.1,发布前还需在规定 Node 版本复验。隔离原生检查不调用模型,不替代真实授权项目完整推理验收。
### 11.1 决策与当前证据
用户允许在必要时自行设计提问机制,不依赖 OpenCode 内置 question。推荐新增受控工具 `ask_user`:模型提出问题,Agent 宿主保存请求并结束当前模型轮次;用户提交答案后,宿主在原会话启动一次有身份绑定的续跑。等待人的时间不占用一个原生 question Promise,问题是否可回答也不由前端卡片判断。
这不是替换推理引擎。继续使用 OpenCode 执行模型和既有工具,但提问协议、草稿、答案、取消及投递记录由宿主管理。正式报告 FollowUpForm 仍是补充资料入口,不自动变成阻塞提问;Shell/MCP 权限审批继续走原有审批通道,不由 ask_user 代替。
当前代码依据:
- 前端 `QuestionRequestCard` 的选项草稿在组件 `useState` 中;切换后重建组件会重新初始化草稿。
- Agent 的 `chatInteractionRoutes.ts` 要求内存 `activeRuns` 和 `pendingQuestions` 存在;`runtime/opencode.ts` 随后读取并回复原生 form。历史卡片存在不能证明原生请求仍有效。
- 当前切换会话不直接终止旧运行,尚未复现用户此前的错误,因此不能将历史故障确定归因为切换动作。
- `ResponseInputTracker` 本身是内存 Map,不能直接当作持久化队列。可复用的是 runtime 中固定消息 ID、inbox 与 message 读回的投递核对,以及 `chatStream.ts` 在工具完成后中断并等待 idle 的已有轮次结束机制。
最小替代方案是补原生 form 状态核对与选项缓存,改动较少,但恢复仍受原生请求生命周期约束。选择宿主方案是为了独立保存等待状态,不再为每次运行状态丢失拼接恢复逻辑。官方支持[自定义工具及独立工具名称](https://opencode.ai/docs/custom-tools/);本站示例 API 与当前仓库 OpenCode 2 插件 API 不完全相同,实施使用本地 `.opencode/tool.ts` 和插件注册方式,不照搬官网导入路径。
### 11.2 请求与草稿分离
首批仅用于 `ordinary_chat` 和 `task_collaboration`,一个会话最多一个未解决的提问组,每组最多 3 个问题。后台 `task_execution` 不等待浏览器,继续使用缺失资料报告及人工证据流程。工具和浏览器都不能指定所有者、项目或目标续跑会话。
新增宿主记录协议 `agent-interaction-request@1`,身份为已授权 actor/project/session + 宿主生成的 interaction_id。绑定原始消息、宿主 turn_id、工具 call_id;用 session/turn/call 唯一键去重创建,原生 form_id 不参与新协议。
| 记录 | 字段和规则 |
| --- | --- |
| 提问定义 | 不可变的 questions;每题包含稳定 question_id、标题、正文、选项 id/label、single/multiple、allow_custom、required;每题最多 12 个选项 |
| 请求状态 | revision、pending/answered/skipped/cancelled/invalidated、创建时间、解决时间;已回答答案不可覆盖,修正需发新消息 |
| 草稿 | 独立 draft_revision、各题 selected_option_ids/custom_text;请求待回答时可保存;迟到草稿回执不能恢复终态请求 |
| 用户答案 | 按 question_id 提交选项 ID 和自定义文字;宿主按原始定义补齐标签,拒绝不存在的选项、重复 ID 和非法多选 |
| 续跑记录 | 稳定 native_message_id、queued/dispatching/delivered/blocked/cancelled、错误码和关联 response_id;与答案在同一次原子写入中建立 |
所有 schema 严格校验。标识最多 128 字符;标题、问题正文、选项标签分别最多 200、2000、200 个 UTF-16 代码单元;每题自定义输入最多 2000,问题或答案的完整文本各最多 10000。定义最多 32 KiB,答案和草稿各最多 16 KiB UTF-8。默认选中不是已提交,也不能触发自动回复。
新增 `chat/interaction-requests.json`,复用宿主所有权、原子替换、串行更新及删除标记,最多 2 MiB、1000 个请求,先到限额者生效;连同第 4 节两类状态文件,交互文件预算最多 4 MiB/会话。已解决记录保留用于重试和审计,不自动淘汰未回答项;满额拒绝新提问并返回普通文字说明,不截断已有答案。首次实现沿用单宿主进程拥有会话的部署边界,不能把进程内队列宣传为跨实例锁。
### 11.3 提问、等待与续跑
```text
模型 ask_user -> 可信工具桥 -> 宿主原子保存请求 -> 问题卡片
|
本轮结束,等待回答
|
用户提交 -> 宿主保存答案和投递记录 -> 既有运行协调器 -> OpenCode 原会话续跑
^ |
+--- 按固定消息 ID 核对回执 ---+
```
1. 工具桥复用现有内部桥认证及运行会话上下文,session/turn/call 从可信上下文获得,模型参数只包含问题定义。严格注册工具 schema,拒绝模型传入身份、URL、命令或恢复回调。首次发布前确认没有其他正在执行的工具;同组并行调用时拒绝创建提问并要求在结果收齐后重试,不能把已开始的业务动作说成已撤销。
2. 先原子保存 pending 请求,再发布含 interaction_id/revision 的事件。保存失败不显示可提交卡片。工具立即返回“已登记,等待用户”的结果,不挂起 Promise;宿主在原生工具结果完成后结束该轮并等待 idle,记录专门的 `waiting_input` 结束原因,不显示为执行失败或最终报告完成。
3. 从登记成功到模型 idle,插件和宿主都拒绝该轮新工具调用,避免模型尚未收到答案就继续依赖答案执行。已有 final_answer 结束机制可复用,但 ask_user 的结束原因、落盘及重连投影需要单独实现;仅增加一句“请等待”提示词不满足验收。创建后崩溃时,宿主从持久记录重建等待状态并核对旧轮次,不继续旧的工具链。
4. 浏览器显式提交答案,宿主比较请求 revision、验证答案并原子写入 answered 和 queued 投递记录,返回已保存回执。原轮次尚未 idle 时只排队,不并发启动第二轮。提交不能依赖 `activeRuns` 必须仍有旧对象。
5. 抽取并复用既有 `/responses` 的会话启动、事件订阅、鉴权上下文、消息持久化和模型选择,建立单一运行协调入口,不从路由外直接裸调用 runtime.prompt。新轮次采用宿主固定 native_message_id,通过已有原生 prompt 的 id 能力投递结构化问题与答案,标注为用户回答,不伪装成原 question 工具返回值。
6. 投递前先按固定 ID 查原生 inbox/message:已入队则接续观察,已消费则补记 delivered,仅确认不存在才投递。网络失败保留 dispatching 并核对,不生成新 ID。已消费后生成失败只允许继续该轮结果处理,不能再次注入同一答案;不承诺跨系统恰好执行一次业务动作。
7. 宿主重启不会丢掉已保存答案。恢复在下一次已授权的会话打开、回复或用户重试时触发,先恢复事件订阅,再核对投递与原生运行状态;不持久保存 Bearer,也不在无有效用户上下文时自动发起推理。OpenCode 会话不可恢复时标记 blocked,允许复制答案,但不能悄悄创建新会话、重放整段业务操作或伪造工具结果。
本方案最脆弱的前提是固定消息 ID 的核对能够区分未投递与已消费,以及提问后能形成可靠的轮次结束边界。上线门槛是用当前原生版本验证这两个行为;若无法确认,保持“答案已保存,继续失败”并停止自动重试,不回退到不受控的重复执行。当前设计没有执行这些原生故障注入验证。
### 11.4 接口与 UI 行为
接口均拟新增在 Agent 的 `/api/v1/session/:sessionId/interactions` 下,沿用会话鉴权;不建立 Server 表或引入新服务。内部创建桥为 `/internal/chat-interactions`,不对浏览器开放创建权限。会话详情增加未解决请求和当前消息页涉及的请求投影;独立列表使用稳定游标,默认 20、最多 50 条,不为恢复卡片拉取全部历史。
| 方法与相对路径 | 行为 |
| --- | --- |
| GET 根路径;GET `/:interactionId` | 返回权威请求、当前草稿和续跑状态;以 interaction_id 去重,读取不自动回答 |
| PUT `/:interactionId/draft` | 带 expected_draft_revision、request_id 和草稿;500ms 防抖,终态拒绝写入;与正式提交使用不同版本 |
| POST `/:interactionId/reply` | 带 expected_revision、request_id 和完整答案;同请求同内容返回原回执,不重复建续跑记录 |
| POST `/:interactionId/skip` | 所有问题均非必填时允许;原子记录 skipped 并安排一次明确的跳过输入,不编造默认答案 |
| POST `/:interactionId/cancel` | 仅 pending 可取消;关闭提问且不续跑,用户可以改发普通消息 |
| POST `/:interactionId/resume` | 只核对并继续原投递记录;不修改答案、不创建新的投递 ID |
写接口均使用严格 version、expected_revision 和 request_id 校验,draft 使用自己的 revision;resume 比较续跑记录 revision。400 表示定义或答案非法,409 区分版本冲突、请求已解决与幂等键内容不一致,413 表示容量不足,503 表示服务暂不可用。回复已落盘但模型暂不可用时返回已保存状态及 queued/blocked,不把答案当作提交失败让用户反复重发。GET 使用 private/no-store,权限失效与删除沿用第 8 节。
前端复用 shadcn 的 RadioGroup、Checkbox、Textarea、Button、状态提示和现有卡片布局,独立封装宿主提问卡,不新增任意代码渲染。可恢复的状态如下。
| 状态 | 界面和操作 |
| --- | --- |
| 核对中 | 显示问题与草稿,提交暂不可用;不能凭历史 pending 直接放行 |
| 待回答 | 支持单选、多选、自定义输入,草稿保存提示;必填项不提供跳过 |
| 提交中 | 禁止双击;保留完整输入;响应丢失按同请求 ID 核对 |
| 已回答,等待继续 | 只读显示已确认答案和续跑状态;重试只操作续跑记录 |
| 已回答并继续 | 显示答案摘要和后续回复;不再展示提交按钮 |
| 已跳过、已取消、已失效 | 展示对应原因,不显示可点击的过期选项;不统一称为“状态丢失” |
切换对话只切换订阅,旧回执绑定旧 session/interaction,不改变新对话;切回与刷新时先读权威状态,再合并当前作用域未保存的草稿。打开页面不自动提交、弹窗、聚焦或滚动。两窗口提交不同答案只有一个成功,另一窗口显示服务端已接受答案;保留冲突输入供复制,不覆盖已经提交的答案。
有 pending 提问时,普通输入区提示先回答或取消;服务端也拒绝绕过等待直接启动新一轮。已提交答案的续跑排队时同样阻止抢占,避免问答顺序错乱。“停止”会显式取消仍 pending 的提问;已经提交的答案仍保留,未投递记录可停止,已投递的运行沿用既有停止语义,不伪造未执行状态。停止与投递通过同一会话协调入口串行化。
### 11.5 兼容、实施与验收
新请求使用独立类型和 ID,不把历史原生 question 卡片直接改成宿主 pending。旧请求读取时核对原生状态:仍待回答才保留旧回复入口,已回复/取消则同步终态,明确不存在时显示已失效;503 时显示暂不可核对,不能误判失效。用户可以复制旧问题另起交互,但不会自动替旧工具伪造回答。
前端通过 `host-question@1` 能力声明支持新卡片,宿主在每轮启动时固定能力快照。有能力的新轮次注册 ask_user 并禁用内置 question;旧客户端不生成其无法操作的新卡片。既有原生请求的回复路由保留。灰度按新轮次边界生效,不热替换正在等待的工具。旧客户端打开新记录至少能看到文本和已解决状态,待回答请求提示使用支持该能力的客户端,不能静默降级后绕过等待。
该项可在 reader/basket 和正式报告持久化之前独立交付。预计涉及 Agent 与 frontend 两个仓库、超过 8 个文件;新增服务、数据库、账号、环境变量均为零,内部桥复用现有服务凭据。
| 位置 | 交付内容 |
| --- | --- |
| Agent `.opencode/tools/ask_user.ts`、`.opencode/plugins/tjwater-tools.ts`、工具与 instruction 策略 | 新工具、严格 schema、轮次能力判断和等待期工具约束;前者为拟新增文件 |
| Agent 拟新增 `src/sessions/interaction-request-store.ts`、`src/routes/interaction-request-routes.ts`、`src/routes/chat-interaction-bridge.ts` | 单文件事务、草稿版本、原子答案/投递记录、内部创建与用户接口 |
| Agent `src/sessions/workspace-store.ts`、`src/routes/chatStreamRoute.ts`、`chatStream.ts`、`chatSessionRoutes.ts`、`src/runtime/opencode.ts` | 文件允许列表、提问轮次结束、共享运行入口、重连核对、稳定消息 ID、删除及旧请求兼容 |
| frontend `src/features/agent/api/`、拟新增 `components/agent-interaction-request-card.tsx`、`src/features/workbench/hooks/agent-session-runtime.ts`、`use-workbench-agent.ts` | 协议解析、受控提问卡、草稿保存、请求状态核对与会话隔离 |
验收必须包括:A 会话提问后切到 B 再切回提交;刷新恢复草稿;两窗口同时提交;清空草稿后的迟到 GET;提交成功但回执丢失;原生已消费但宿主未记回执时崩溃;宿主重启后授权恢复;原生会话丢失;提问与另一工具并行;提问后模型继续调用工具;停止与续跑竞争;旧客户端及旧 question;权限失效和会话删除。前端覆盖键盘操作、320px 窄屏、焦点及 reduced motion。
实现时运行前端 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和拟新增浏览器 `tests/browser/agent-question-lifecycle.e2e.ts`;Agent 运行 `bun run check`,为状态存储、路由及原生续跑新增聚焦测试。浏览器模拟通过后,还需隔离原生运行验证固定 ID 与故障恢复,不能把模拟 Map 的幂等行为当作 OpenCode 的保证。
发布先增加服务端读写、文本投影与旧请求核对,再上线客户端,最后在支持能力的新轮次启用 ask_user。回退时先停止创建新请求,保留新协议的读取、提交和续跑兼容接口,直到待回答及待投递请求被解决或用户取消;不能直接删除接口把已发布问题遗留成死卡片。记录和答案始终保留,不因回退重置 revision。
+80 -4
View File
@@ -1,6 +1,6 @@
# Agent Persona 设计 # Agent Persona 设计
更新日期:2026-10-28。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。 整理日期:2026-11-10。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-design.md#8-资源生成与维护);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
## 1. 形象与材质 ## 1. 形象与材质
@@ -80,7 +80,7 @@
系统开启“减少动态效果”时显示静态帧,不初始化 Rive;资源失败时保留相应静态形象与按钮功能。强制颜色模式显示高对比轮廓,保留焦点和键盘操作。元素离开视口或页面隐藏时卸载渲染实例,可见后恢复。 系统开启“减少动态效果”时显示静态帧,不初始化 Rive;资源失败时保留相应静态形象与按钮功能。强制颜色模式显示高对比轮廓,保留焦点和键盘操作。元素离开视口或页面隐藏时卸载渲染实例,可见后恢复。
客户端按页面内存、Cache Storage、网络的顺序取得资源。仅共享不可变 `.riv` 字节,每个可见入口独立创建动画实例;不共享实例或绑定 View Model。资源重建时同步生成 SHA-256 版本,URL 带版本参数,使旧缓存自然更新。缓存范围与校验逻辑见 [动画资源说明](agent-persona-resource.md)。 客户端按页面内存、Cache Storage、网络的顺序取得资源。仅共享不可变 `.riv` 字节,每个可见入口独立创建动画实例;不共享实例或绑定 View Model。资源重建时同步生成 SHA-256 版本,URL 带版本参数,使旧缓存自然更新。缓存范围与校验逻辑见 [动画资源说明](agent-persona-design.md#8-资源生成与维护)。
## 7. 实现与维护 ## 7. 实现与维护
@@ -93,8 +93,84 @@
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) | | 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) | | 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束,仅调整时间轴与指针跟随节奏;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。 材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束,仅调整时间轴与指针跟随节奏;改动时同步本文的设计规格与资源维护章节。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。 验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
资源级检查使用固定时间步对照原版与修改版:在线状态保持相同材质下的逐像素一致;悬停检查左右移动时局部高光跟随指针,移出后完全恢复。工作台检查鼠标坐标实际到达悬浮入口和标题形象的 Rive 监听器,不能仅断言 hover 开关变化。离线检查完整球体、持续变化和光圈关闭。运行命令见 [资源说明](agent-persona-resource.md),各次实际验证结果记录在 [实施记录](history/agent-chat-ui-implementation-notes.md)。 资源级检查使用固定时间步对照原版与修改版:在线状态保持相同材质下的逐像素一致;悬停检查左右移动时局部高光跟随指针,移出后完全恢复。工作台检查鼠标坐标实际到达悬浮入口和标题形象的 Rive 监听器,不能仅断言 hover 开关变化。离线检查完整球体、持续变化和光圈关闭。运行命令见 [资源说明](agent-persona-design.md#8-资源生成与维护),各次实际验证结果记录在 [实施记录](history/agent-chat-ui-implementation-notes.md)。
## 8. 资源生成与维护
### 文件与再生成
| 文件 | 用途 |
| --- | --- |
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 |
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,929 字节资源 |
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
在前端仓库运行:
```bash
node scripts/build-agent-persona.mjs
node scripts/render-agent-persona-stills.mjs
node scripts/build-agent-persona.mjs --check
# 将原版还原到一个尚不存在的文件,应用资源不会被替换
node scripts/build-agent-persona.mjs --restore-original /tmp/obsidian-original.riv
pnpm test:browser tests/browser/agent-persona-resource.e2e.ts tests/browser/agent-orb.e2e.ts --workers=1
```
JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验证原始数据能逐字节恢复,并检查生成结果与应用资源一致。基线 SHA-256 为 `c6ae7d08f5787a560e38d3453ba155c28250acf2db75e6acf2b0d1a0cd6017db`。替换上游基线时需重新解析和验证,不应只修改散列值跳过检查。
配色编辑 `obsidian-material.json`,其中 `bodyColor` 只控制球面底色,`bodyOpacity` 控制它在待机/活动时的遮蔽度,其他蓝色反射沿用 `colors` 映射;离线动画编辑 `obsidian-offline.json`;重新生成 `.riv` 和静态帧后刷新前端。静态帧脚本使用已安装的 Playwright、esbuild 与 Rive WASM,无需访问外部动画服务。`fps` 控制离线时间轴速度,`amplitude` 控制位移与缩放的变化幅度,`transitionMs` 控制在线/离线的混合时长。各层 `opacity` 中的 `rest` 和 `crest` 是相对原始透明度的比例,分别对应循环开始/结束与中点。数值需满足 `0 ≤ rest ≤ crest ≤ 1`;保留循环端点一致,避免接缝。外围三个光圈均固定为 `0`。
`--restore-original` 使用只创建模式,目标文件已存在时会报错,避免覆盖已有编辑。原版还原文件与基线逐字节相同,包含原版离线消失的行为;前端继续加载带离线设计的应用资源。
### 离线图层参数
内部柔光的三层强度分别为 56%–68%、48%–62%、60%–72%,反射强度均为 0;周期与外观以第 4 节和 `obsidian-offline.json` 为准。参数中的图层名称是运行时结构的语义别名,不是作者原始命名。`objectId` 是画板组件编号,不能用 JSON 数组索引替代,绑定记录不占组件编号。
### 解析结果与修改
原版有 1 个 `Obsidian` 画板、20 段动画、13 个状态机层和 5 个布尔输入。`asleep` 动画在第 45 帧将节点 4 的透明度从 1 降至 0,并将节点 3、190、199、208 的缩放降至 0.6–0.8,因此离线时球体消失。
修改版保留原有图形、渐变结构、羽化、裁剪和在线运动,改变以下部分:
- 根据已选定的配色映射 RGB,并独立设置底色填充节点 189 的颜色、节点 186 的静止及活动透明度;原始 JSON 不改动,仍可完整还原银灰原版 `.riv`。
- `asleep` / `wake` 的透明度与缩放关键帧保持 1,避免隐藏或缩小球体。
- 在原有 20 段动画后追加 `offline_loop_1`、`offline_loop_2`、`offline_loop_3`,保持原有动画编号。
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。
- 悬停保持原版反射几何、渐变与亮度,将 `hover_in` / `hover_out` 分别调整为 9 / 36 帧,即 150ms / 600ms,并使用原有贝塞尔插值器。退出时间轴持续保持节点 129 的指针约束,完全淡出后才释放;进出切换使用 100ms 混合。
生成结果包含 960 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
### 前端接入与生命周期
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 与 `persona-hover.ts` 向画布转交鼠标的进入、移动和离开坐标;移动坐标以 80ms 时间常数平滑,移出延迟 100ms 后交给资源淡出,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
读取顺序为页面内存、当前版本的持久缓存、网络。下载及缓存命中均检查字节数和 `RIVE` 文件头;支持 Web Crypto 时进一步验证 SHA-256。损坏缓存重新下载,错误页或不匹配的资源不写入缓存。新版本成功保存后,只删除本缓存中该资源路径的旧版本,不清理其他应用数据。重新生成动画时同步更新版本清单,`--check` 同时验证资源与清单。
持久缓存不可用、被禁用或空间不足时仍可下载并在当前页面复用。Cache Storage 需要安全上下文,生产环境使用 HTTPS,本机 localhost 开发可用;浏览器清理站点数据也可能移除缓存,见 [Cache API 说明](https://developer.mozilla.org/en-US/docs/Web/API/Cache)。缓存测试覆盖真实页面刷新,并阻断资源网络路径验证动画仍能播放。
Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.riv`,Rive WASM、应用代码与其他资源沿用各自的加载策略;不构成整站离线支持。浏览器完全断网且应用或运行时尚未加载时,仍可能无法播放动画。
在线与离线切换不卸载可见的 Rive 实例,由状态机处理过渡。静态帧仅用于首次加载、资源失败、减少动态效果及未挂载动画的情况:在线使用 `obsidian-water-idle.png`,离线使用无外围光圈的 `obsidian-water-offline.png`。两张 192px PNG 均由当前 Rive 材质直接渲染,URL 携带资源版本;首次加载维持 180ms 淡入,不再出现银灰占位图。保留可见性卸载、Strict Mode 延迟初始化和原版悬停;没有重新加入 Motion 按压效果或 View Model 绑定。历史约束见 [Rive 内存记录](history/rive-persona-incident.md)。
### 验证边界与来源
运行时 JSON 能恢复当前 `.riv`,但不等于作者的 `.rev` 编辑器工程;导出时省略的编辑信息没有凭空恢复。原作者提供可编辑 `.rev` 的说明见 [资源定制文档](https://elements.surge.studio/docs/customization),格式差异见 [Rive 备份说明](https://rive.app/docs/editor/exporting/exporting-for-backup)。
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
-93
View File
@@ -1,93 +0,0 @@
# Agent 动画资源与离线水纹
Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比选定的 02「冰水蓝」配色,并在 Rive 文件内部增加“静水待机”离线设计。在线保留外围水纹光圈;离线关闭外围三层光圈,仅保留球体内部的缓慢流动。在线、离线和交互使用同一个 `default` 状态机。
当前视觉规格、尺寸、标题区与交互约定统一见 [Persona 设计](agent-persona-design.md)。本文维护资源结构、生成过程与缓存实现。
## 文件与再生成
| 文件 | 用途 |
| --- | --- |
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 |
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,929 字节资源 |
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
在前端仓库运行:
```bash
node scripts/build-agent-persona.mjs
node scripts/render-agent-persona-stills.mjs
node scripts/build-agent-persona.mjs --check
# 将原版还原到一个尚不存在的文件,应用资源不会被替换
node scripts/build-agent-persona.mjs --restore-original /tmp/obsidian-original.riv
pnpm test:browser tests/browser/agent-persona-resource.e2e.ts tests/browser/agent-orb.e2e.ts --workers=1
```
JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验证原始数据能逐字节恢复,并检查生成结果与应用资源一致。基线 SHA-256 为 `c6ae7d08f5787a560e38d3453ba155c28250acf2db75e6acf2b0d1a0cd6017db`。替换上游基线时需重新解析和验证,不应只修改散列值跳过检查。
配色编辑 `obsidian-material.json`,其中 `bodyColor` 只控制球面底色,`bodyOpacity` 控制它在待机/活动时的遮蔽度,其他蓝色反射沿用 `colors` 映射;离线动画编辑 `obsidian-offline.json`;重新生成 `.riv` 和静态帧后刷新前端。静态帧脚本使用已安装的 Playwright、esbuild 与 Rive WASM,无需访问外部动画服务。`fps` 控制离线时间轴速度,`amplitude` 控制位移与缩放的变化幅度,`transitionMs` 控制在线/离线的混合时长。各层 `opacity` 中的 `rest` 和 `crest` 是相对原始透明度的比例,分别对应循环开始/结束与中点。数值需满足 `0 ≤ rest ≤ crest ≤ 1`;保留循环端点一致,避免接缝。外围三个光圈均固定为 `0`。
`--restore-original` 使用只创建模式,目标文件已存在时会报错,避免覆盖已有编辑。原版还原文件与基线逐字节相同,包含原版离线消失的行为;前端继续加载带离线设计的应用资源。
## 冰水蓝与离线设计
材质采用经原生 Rive 预览选定的 02 方案:水体 `#299FDF`、同色相暗部 `#1482BC`、白色高光 `#FFFFFF`、外围水纹 `#4C9ECC`。外围水纹从初选时的 `#B0E3FF` 加深,以提高浅色地图上的辨识度;光圈粗细、渐变 alpha 和运动保持原定义。球面底色独立设置为 `#DCEFFA`,待机/活动时的不透明度为 94%/98%,同步调整对应的活动状态关键帧。浅色且高遮蔽度的底层减少地图道路穿过球体的现象,蓝色反射仍由原来的运动图层呈现;暗部沿用蓝色调,避免叠加灰黑阴影。渐变位置、羽化、裁剪、运动曲线及其他节点的原有 alpha 保留,不添加 CSS 滤镜或 View Model 绑定。
此前 A「水务蓝」使用灰蓝主体、浅蓝高光与 78%/90% 的底色不透明度,在白底实际动画中显得发灰、浑浊,已被本方案替换。后续调整以同一背景下的真实动画及 40/64px 入口效果为准,不以色卡单独判断材质。
离线以“水面逐渐平静”表达连接不可用:外围三层光圈在 600ms 混合期间消退至完全不可见,内部柔光降低并保持缓慢流动。球体整体透明度和缩放保持完整;悬停不会重新打开离线光圈。恢复在线时重新显现光圈与在线节奏。业务状态仍由入口按钮的可访问名称和提示提供。
| 层 | 柔光强度 | 反射强度 | 循环周期 |
| --- | --- | --- | --- |
| 内层与下侧柔光 | 56%–68% | 0,关闭 | 15 秒 |
| 中层与内部柔光 | 48%–62% | 0,关闭 | 19.25 秒 |
| 外层与上侧柔光 | 60%–72% | 0,关闭 | 31.375 秒 |
上表是设计参数当前值的解释。内部柔光以原版贝塞尔缓动从 `rest` 升到 `crest` 再回落,周期错开,避免同步闪烁;光圈透明度保持为零。40/64px 入口共用同一资源,状态切换不改变布局尺寸。
参数中的图层名称是根据运行时结构补充的语义别名,不是恢复出的作者原始命名。`objectId` 是画板组件编号,不能用 JSON 对象数组的索引替代:绑定记录不占组件编号。
## 解析结果与修改
原版有 1 个 `Obsidian` 画板、20 段动画、13 个状态机层和 5 个布尔输入。`asleep` 动画在第 45 帧将节点 4 的透明度从 1 降至 0,并将节点 3、190、199、208 的缩放降至 0.6–0.8,因此离线时球体消失。
修改版保留原有图形、渐变结构、羽化、裁剪和在线运动,改变以下部分:
- 根据已选定的配色映射 RGB,并独立设置底色填充节点 189 的颜色、节点 186 的静止及活动透明度;原始 JSON 不改动,仍可完整还原银灰原版 `.riv`。
- `asleep` / `wake` 的透明度与缩放关键帧保持 1,避免隐藏或缩小球体。
- 在原有 20 段动画后追加 `offline_loop_1`、`offline_loop_2`、`offline_loop_3`,保持原有动画编号。
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。
- 悬停保持原版反射几何、渐变与亮度,将 `hover_in` / `hover_out` 分别调整为 9 / 36 帧,即 150ms / 600ms,并使用原有贝塞尔插值器。退出时间轴持续保持节点 129 的指针约束,完全淡出后才释放;进出切换使用 100ms 混合。
生成结果包含 960 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
## 前端接入与生命周期
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 与 `persona-hover.ts` 向画布转交鼠标的进入、移动和离开坐标;移动坐标以 80ms 时间常数平滑,移出延迟 100ms 后交给资源淡出,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
读取顺序为页面内存、当前版本的持久缓存、网络。下载及缓存命中均检查字节数和 `RIVE` 文件头;支持 Web Crypto 时进一步验证 SHA-256。损坏缓存重新下载,错误页或不匹配的资源不写入缓存。新版本成功保存后,只删除本缓存中该资源路径的旧版本,不清理其他应用数据。重新生成动画时同步更新版本清单,`--check` 同时验证资源与清单。
持久缓存不可用、被禁用或空间不足时仍可下载并在当前页面复用。Cache Storage 需要安全上下文,生产环境使用 HTTPS,本机 localhost 开发可用;浏览器清理站点数据也可能移除缓存,见 [Cache API 说明](https://developer.mozilla.org/en-US/docs/Web/API/Cache)。缓存测试覆盖真实页面刷新,并阻断资源网络路径验证动画仍能播放。
Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.riv`,Rive WASM、应用代码与其他资源沿用各自的加载策略;不构成整站离线支持。浏览器完全断网且应用或运行时尚未加载时,仍可能无法播放动画。
在线与离线切换不卸载可见的 Rive 实例,由状态机处理过渡。静态帧仅用于首次加载、资源失败、减少动态效果及未挂载动画的情况:在线使用 `obsidian-water-idle.png`,离线使用无外围光圈的 `obsidian-water-offline.png`。两张 192px PNG 均由当前 Rive 材质直接渲染,URL 携带资源版本;首次加载维持 180ms 淡入,不再出现银灰占位图。保留可见性卸载、Strict Mode 延迟初始化和原版悬停;没有重新加入 Motion 按压效果或 View Model 绑定。历史约束见 [Rive 内存记录](rive-persona-incident.md)。
## 验证边界与来源
运行时 JSON 能恢复当前 `.riv`,但不等于作者的 `.rev` 编辑器工程;导出时省略的编辑信息没有凭空恢复。原作者提供可编辑 `.rev` 的说明见 [资源定制文档](https://elements.surge.studio/docs/customization),格式差异见 [Rive 备份说明](https://rive.app/docs/editor/exporting/exporting-for-backup)。
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
@@ -44,7 +44,7 @@ Agent 类型检查及 16 项相关测试通过;隔离 OpenCode 工具发现验
## 2026-10-21 Rive 资源内离线水纹 ## 2026-10-21 Rive 资源内离线水纹
解析原版 775 个运行时对象,JSON 重新编码与原文件逐字节一致。保留原图形和在线动画,新增三段离线循环,并修正 asleep / wake 的透明度与缩放关键帧;同源资源为 `public/agent/obsidian-water-v1.riv`。实现与再生成说明见 [Agent 动画资源](../agent-persona-resource.md)。 解析原版 775 个运行时对象,JSON 重新编码与原文件逐字节一致。保留原图形和在线动画,新增三段离线循环,并修正 asleep / wake 的透明度与缩放关键帧;同源资源为 `public/agent/obsidian-water-v1.riv`。实现与再生成说明见 [Agent 动画资源](../agent-persona-design.md#8-资源生成与维护)。
验证通过:生成一致性检查、lint、类型检查、547 项单测、构建、`git diff --check`。10 项相关浏览器用例分批通过,包含真实 WebGL2 在线状态采样逐像素对照、原版离线消失与修改版离线持续运动、初始离线、反复恢复、工作台单实例、加载衔接、按压无反馈、资源失败、减少动态效果、键盘及 320/375px。首次运行有 1 项动画就绪等待超时;未修改运行代码,带 trace 复测通过,首次超时根因未确认。 验证通过:生成一致性检查、lint、类型检查、547 项单测、构建、`git diff --check`。10 项相关浏览器用例分批通过,包含真实 WebGL2 在线状态采样逐像素对照、原版离线消失与修改版离线持续运动、初始离线、反复恢复、工作台单实例、加载衔接、按压无反馈、资源失败、减少动态效果、键盘及 320/375px。首次运行有 1 项动画就绪等待超时;未修改运行代码,带 trace 复测通过,首次超时根因未确认。
@@ -0,0 +1,377 @@
# Intelligent UI 交互状态补齐设计
> 历史记录:2026 年 10 月交互状态原始提案,其中分析篮、行选择和组件追问已取消,当前范围见 [交互状态](../agent-interaction-state-design.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
> 2026-10-30 产品决定:数据组件仅用于展示与本地浏览,已移除表格勾选、分析内容篮及组件追问;专用地图定位和补充资料表单保留。本文分析篮、行选择和引用操作的方案属于历史设计,不再作为当前实施目标。阅读位置、表单草稿及宿主提问的独立设计仍可参考,当前行为以 [本地交互式回答](../agent-answer-ui.md) 为准。
历史设计状态:2026-10-27 设计稿;2026-10-28 第 11 节宿主提问已接入工作区代码,其余补齐项仍待实现。当前代码进度见 [本地交互式回答](../agent-answer-ui.md)。本设计补齐阅读位置、分析篮和正式报告的私人交互状态,不改变模型可生成的 `answer-ui@1` 组件协议。第 11 节单独处理 question 的执行生命周期。
## 1. 目标与方案
用户刷新后,再次打开同一回答或同一版报告,可以继续阅读、筛选和填写资料。恢复只改变当前用户的界面,不发送消息、不提交表单、不调用业务工具、不定位地图,也不自动打开窗口。
推荐保留已实现的聊天控件状态接口,新增阅读与分析篮的补充记录;正式报告的状态由 Server 按当前读者保存。三种记录独立比较版本,避免滚动保存与表单输入、选行互相冲突。前端继续复用受控 store 和现有保存协调器,只由来源适配器决定请求 Agent 还是 Server。
最小方案是仅增加会话内存缓存,但刷新后仍会丢失,不能满足目标。把全部状态塞入原接口会破坏旧客户端的严格校验,也会让分析篮挤占控件的 32 KiB 限额,因此采用补充记录。正式报告不能借用 `source_session_id` 保存:它指向发布报告的执行会话,报告读者未必拥有这个会话。
新增服务、账号、密钥和环境变量均为零;正式报告需要在已有项目 BizDB 增加一张私人状态表。预计涉及三个仓库、超过 8 个实现文件,按第 10 节拆成可独立交付的两批。
```text
聊天最终回答 -> Agent 来源适配器 -> 已授权会话 / 控件记录 + 补充记录
^
|
读取/编辑 -> 前端状态协调器 -> 受控组件、阅读位置、分析篮
|
v
指定版本报告 -> Workbench 来源适配器 -> Server 报告权限 / 当前读者状态
用户主动“带入对话” -> 现有输入区 -> 用户确认发送 -> 既有 Agent 流程
```
基础能力沿用 [json-render 的运行时状态支持](https://json-render.dev/docs/data-binding) 和 [React 的外部 store 订阅](https://react.dev/reference/react/useSyncExternalStore)。阅读容器尺寸变化使用 [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 观测;这些机制不替代宿主的权限、持久化和版本校验。实现以仓库当前安装版本为准,不因本设计升级依赖。
## 2. 当前依据与状态分工
| 当前实现 | 补齐方式 |
| --- | --- |
| `answer-view-state@1` 保存图表、表格、表单、证据状态 | 保留接口、32 KiB 限额和迁移规则;复用其保存/恢复机制 |
| `ExpandableReading` 用 `useId()` 标识实例;阅读偏移放在 DOM dataset | 注入业务来源身份;只保存可序列化的阅读位置,DOM、host、focus 回调留在内存 |
| Markdown 表格的行内和放大视图是两份 DOM,图示可移动同一个 host | 按阅读面分别记录位置,不假设搬动 DOM 等于持久化 |
| `AnswerUiView` 的分析篮只存 `{title,text}`,意图由 Tray 的 `useState` 保存 | 增加来源、选择描述和摘要;篮与意图一起受控保存 |
| `InvestigationReportBlocks` 接收报告快照,并用任务证据覆盖 Evidence 展示 | 按报告版本及当前读者加载状态;两端核对实际渲染内容摘要 |
| 正式报告属于项目任务,读取使用 `operational_task.view` | 私人状态沿用报告读取权限,不要求读者拥有发布会话,不写回正式报告 |
| 记录 | 持久化内容 | 保持临时的内容 |
| --- | --- | --- |
| controls,按块 | 现有节点控件状态和证据类别筛选 | 请求中的 loading/error、悬停、菜单和下拉框开关 |
| reader,按文档 | 阅读面的位置、锚点、局部偏移和内容摘要 | 是否打开弹窗、当前焦点、地图相机、窗口尺寸、DOM 引用 |
| basket,按块 | 有序选择项、来源描述、选取时文本快照、分析意图 | 正在带入对话、临时提示、浏览器文本选区 |
说明:分析篮仍以一个回答块为边界,本次不扩展为跨消息、跨报告的全局收藏。证据大分区、范围工具栏的折叠开关沿用当前内存行为;它们不决定所选数据。Mermaid 平移/缩放也只在当前实例内保留,重新打开时按可用空间适配,不保存会随图形重新布局失效的像素变换。
## 3. 身份与版本
| 来源 | 文档身份 | 块身份 | 权威校验 |
| --- | --- | --- | --- |
| 聊天 | 当前主体、个人/项目范围、sessionId、assistantMessageId | 文档身份 + blockId | Agent 校验会话归属、已保存的助手消息及最终回答引用;旧版没有 final 的记录沿用既有兼容规则 |
| 正式报告 | 当前 user_id、project_id、task_id、report_revision | 文档身份 + blockId | Server 校验当前成员身份、`operational_task.view` 和指定报告是否存在 |
`nodeId` 来自验证后的 `spec.elements` 键;不得使用标题、数组当前序号、React `useId()` 或 DOM selector 作为业务身份。客户端不能指定状态所有者,主体和项目必须来自服务端认证上下文。
报告的 `revision` 在任务内唯一,`execution_generation` 由服务端从报告推导并回传,不作为另一个客户端命名空间。历史 `null` 保留为 `null`,不能替换为 0 或任务当前轮次。文档与块的 `state_revision` 是私人交互版本,不能与报告 revision 混用。
打开任务仍默认查看当前报告,不自动恢复为上次选中的历史版本。用户主动选择某个版本后才恢复该版本的状态。新报告使用新的身份和空状态,不自动复制旧版表单或分析篮;旧版记录保留供用户回看。放大窗口保留旧版内容时,状态写入也绑定当时的旧版,直到用户点击“查看最新内容”。
最容易失效的前提是“相同 blockId 就是相同数据”。本设计用内容摘要、节点类型和选择描述复核,无法唯一匹配时保留可核对的草稿并清理无效选择,不将旧数组下标套到新数据。
## 4. 记录与接口
### 4.1 保留现有接口,新增补充接口
| 所属服务 | 接口 | 状态范围 |
| --- | --- | --- |
| Agent,已有 | `GET/PUT /api/v1/session/:sessionId/ui-state` | 聊天 controls,保持原 `answer-view-state@1` 协议 |
| Agent,拟新增 | `GET/PUT /api/v1/session/:sessionId/interaction-state` | 聊天 reader 或 basket;以 message_id、kind 和 basket 所需的 block_id 定位 |
| Server,拟新增 | `GET/PUT /api/v1/operational-tasks/:taskId/reports/:reportRevision/interaction-state` | 正式报告 controls、reader 或 basket;controls/basket 需要 block_id |
GET 的 kind 只有 `reader`、`basket`,Server 额外允许 `controls`。reader 属于整个文档,不接受 block_id;其内部阅读面可以引用块。GET 无记录返回对应空 payload、state_revision 0 和当前内容摘要,不写入空记录。未知 kind、无效字段或不存在的块返回 400/404,不隐式建立来源。
GET 选择器放在 query,PUT 放在 body;Agent 两种方法均要求 message_id,Server 报告身份只取 URL。PUT 另要求 `version="ui-interaction-state@1"`。两端错误在前端适配器内统一为 code、message、current:Agent 沿用顶层 code/detail,Server 沿用现有 detail envelope,不让通用组件处理两套响应形状。所有响应使用 private/no-store。
新接口的记录版本为 `ui-interaction-state@1`。字段固定如下;不接受任意对象透传。
| 字段 | 规则 |
| --- | --- |
| `source` | 只出现在响应中,返回服务端确认的聊天或报告身份;不返回用户 token、宿主路径 |
| `kind`、`block_id` | 标识记录种类及块;reader 的 block_id 不存在 |
| `state_revision` | 每个记录独立的非负整数,只出现在响应中 |
| `source_digest` | 服务端规范化内容的 SHA-256;PUT 原样带回,用于拒绝过期内容上的保存 |
| `payload` | 由 kind 选择严格 schema,字段见第 5、6 节;report controls 使用已有 nodes/block 的允许字段与迁移规则 |
| `updated_at` | 服务端写入时间;空记录为 null,不用客户端时间决定覆盖顺序 |
| `item_status` | 仅 basket 响应包含,按 item_id 返回当前有效性与原因;不接受客户端写入 |
| `expected_revision`、`request_id` | 只用于 PUT;版本比较和最近请求重试沿用现有规则 |
选择器对应的身份摘要、version、revision 和请求标识均受限长与整数范围校验。source_digest 为 64 位小写十六进制;request_id 为 1 至 128 字符;message_id 为 1 至 200 字符;block_id 沿用原协议。state_revision/expected_revision 使用 JavaScript 安全整数范围,禁止负数或溢出。
`source_digest` 的材料:聊天 reader 为已发布报告文字与引用块,basket 为所在块的实际 spec;报告 reader 为指定版本的报告正文及其可见证据投影,controls/basket 为对应块的实际渲染 spec。规范化 JSON 对对象键排序,数组顺序不变,摘要算法跨语言使用同一组固定测试样例。摘要是过期检查依据,不是客户端提交文字的真实性证明。
### 4.2 正式报告的 Evidence 投影
报告原 spec 与实际显示的 Evidence 可能不同:当前实现从任务证据补充摘要、来源、时间、类别,找不到证据时显示提示文本。Server 与前端必须采用相同投影规则,控件验证及摘要计算以投影后的 spec 为准。
Server 状态读取先按现有报告/证据访问规则取得来源,计算投影及 source_digest;前端对已经加载的报告与证据执行同一规则并计算摘要。两者不一致时,不把旧状态应用到当前节点:对指定报告和任务证据做一次有界刷新,再核对摘要;仍不一致则显示“内容已更新,交互状态暂未恢复”,允许用户重试,禁止无限刷新。无需在状态响应中再复制整份报告或原始图表数据。
PUT 在事务中重读报告和证据,投影变化返回 409 `UI_INTERACTION_SOURCE_CHANGED`,并返回当前记录和摘要。前端先更新来源,再迁移合法控件;无权访问的证据不以篮子快照重新显示。此投影规则需要前后端共用测试样例,不能各自猜测字段映射。
### 4.3 存储与容量
聊天补充记录放在宿主 `chat/answer-interaction-state.json`,加入 `SessionWorkspaceStore` 允许列表,复用同一队列、所有权校验、删除标记及原子写入。已有 controls 文件保持不变,旧客户端读写不会抹掉新状态。新文件每个 document reader、每个 block basket 独立版本,文件总量最多 1 MiB,包含回执;与已有 controls 文件合计最多 2 MiB/会话。
正式报告新增项目 BizDB 表 `workbench.report_user_view_states`,每个 `(project_id,user_id,task_id,report_revision)` 一行,JSONB document 内分别保存 controls、reader、baskets 和最近回执。每个子记录有独立 state_revision;行锁只用于版本比较、容量检查和原子替换,不把滚动和表单合并成一个逻辑版本。报告外键 `(project_id,task_id,report_revision)` 使用物理删除级联;user_id 来自 `ProjectContext`,不建立跨数据库外键。
首次写入采用插入空行且忽略唯一键冲突,再锁定同一私人状态行,避免两个 revision 0 请求同时成功。事务失败回滚空行和状态修改;报告删除由外键与事务约束阻止迟到写入复活。状态表不是报告事实表,保存私人状态只要求报告读权限,不借此授予任务写入或审批权限。
拟新增迁移 `next-tjwater-server/resources/sql/026_report_user_view_states.sql`,沿用项目 BizDB 显式迁移流程。当前最高编号为 025,实施时若编号已占用,顺延编号。接口不执行建表;未迁移的项目返回安全的 503,报告仍可阅读,草稿留在当前内存。
| 项目 | 限额 |
| --- | --- |
| controls payload | 32 KiB UTF-8/块,沿用现有节点数量和字段限制 |
| reader payload | 8 KiB UTF-8/文档,最多 16 个阅读面 |
| basket payload | 64 KiB UTF-8/块,最多 8 项;正文组合不超过现有 8000 个 UTF-16 代码单元;分析意图最多 800 个 UTF-16 代码单元 |
| 正式报告私人状态行 | 1 MiB,计入所有子记录和回执 |
| 补充记录重试回执 | 每个聊天补充文件或报告私人状态行,最近 32 条且最多 128 KiB;过期请求仍受旧 expected_revision 阻止重放 |
大小检查包括标识与元数据的序列化体积,不截断表单或选择内容来勉强保存。超限返回 413,保留当前草稿,提示先减少分析篮内容或清除不再需要的记录。业务限制 8000/800 由三端按相同 UTF-16 计数规则校验,不能用 Python 字符数直接替代。发送消息时仍检查问题、意图、引用及来源说明的完整文本是否超过现有 10000 限额。
不按每次滚动追加历史;当前快照覆盖、回执有界。记录数量随实际访问的用户/报告组合增长,使用上述复合主键直接读写,不扫描全部用户状态;本版本不增加自动 TTL,避免无提示删除草稿。
## 5. 阅读位置
reader payload 为 `positions` 和 `recent_reader_keys`,前者按宿主生成的 readerKey 保存位置,后者是最近使用顺序。两者最多 16 项且键集合一致;位置键、锚点 id 最多 200 个字符,不接受 DOM selector 或脚本。每项位置字段如下。
| 字段 | 含义 |
| --- | --- |
| `content_digest` | 该阅读面实际内容的摘要;用于判断是否可复用整体滚动比例 |
| `anchor` | 可空的 `{kind,id,occurrence}`;kind 为 section、node、markdown,id 来自稳定业务键或规范化 Markdown 块摘要 |
| `anchor_offset_ratio` | 锚点内部偏移,有限数值,范围 0 到 1 |
| `scroll_y_ratio`、`scroll_x_ratio` | 容器可滚动距离的比例,范围 0 到 1;无可滚动距离时为 0 |
readerKey 只由宿主注册:`workspace`、`report-result`、原生 `blockId/nodeId` 对应阅读面,以及 Markdown 内容块摘要派生的表格/图示阅读面。纯文本最终回答也可建立 document reader,无须伪造 answer-ui 块。标题或“第 n 个表”不是跨内容更新的稳定键;相同内容重复出现时 occurrence 只在摘要相同的文档内使用。
这里只保存版本明确的内容。当前调查进展、实时任务时间线、审批卡和报告版本选择器仍属于实时任务界面,不绑定到某个报告 revision 的阅读状态。用户输入、审批焦点和地图位置均不通过 reader 恢复。
恢复流程:
1. 用户主动打开对应阅读面,绑定不可变的 source 身份并订阅状态。尚未加载历史消息/报告时不为了恢复位置自动翻完全部历史,也不自动切换会话或报告版本。
2. 内容挂载、控件恢复和必要字体准备完成后,用连续两帧相同尺寸判断可定位;最多等待 1500ms。`ResizeObserver` 仅用于这次定位,不能持续把用户拉回旧位置。
3. 优先定位仍唯一存在的锚点,再恢复锚点内部偏移。没有锚点但 content_digest 相同,则使用整体滚动比例;内容已变化且锚点无法对应,回到本阅读面起点,提示“内容已变化,原阅读位置无法恢复”。
4. 比例始终按当前实际滚动范围计算并夹紧;窄屏和宽屏互切不能直接复用旧像素 scrollTop。Markdown 行内与放大表格使用不同 readerKey,横向位置也分别记录。
5. 每次主动打开最多执行一次自动定位。等待期间出现用户滚轮、触摸、指针按下、滚动键、文本选择、编辑或内容导航,则取消自动定位;迟到 GET 只进入缓存,不移动当前视口。需要回到上次位置时由用户点击入口。
阅读保存使用 1000ms 防抖,连续滚动每 5 秒最多提交一次;关闭阅读面或切换来源时保存最后位置。位置未变化不发送;程序恢复产生的 scroll 事件不标记为用户编辑,不反向触发保存循环。最多 16 个位置,超出时只淘汰最久未使用的阅读位置,保留当前阅读面;分析篮和表单不参与此淘汰。
reader 冲突不弹出阻断窗口:保留当前视口,接收远端的新 revision 供下一次真实用户滚动保存,显示轻量“阅读位置未同步”状态;不自动覆盖远端,也不因远端更新跳动当前页面。下次用户操作只更新对应位置,合并远端其他阅读面的位置和最近使用顺序,不能用整个旧 positions 覆盖它们。网络失败可随下一次用户滚动重试或由用户手动重试。
## 6. 分析篮与表单草稿
basket payload 为同一份 `{items,intent}`。items 是有序数组,新增、移除、清空和意图编辑由一份受控状态管理;不拆成八个各自保存的项目。
| 选择项字段 | 规则 |
| --- | --- |
| `item_id` | 本地生成的稳定 UUID,保存与重排时保持;不用于权限证明 |
| `node_id`、`selection_kind` | 必须对应来源中的真实节点;首批 kind 为 metric、table_rows、chart_point、chart_range |
| `selector` | table_rows 为原始行索引及数据摘要;chart 为点/区间索引、对应标签、可见序列索引与名称;metric 不带行索引 |
| `source_digest` | 选取时所在块的有效内容摘要,由已读取来源取得 |
| `title`、`snapshot_text` | 选取时的纯文本显示快照,title 最多 200 个 UTF-16 代码单元;正文计入组合总限额 |
服务端校验节点类型、索引上限和选择描述,并校验来源摘要;快照文本是用户引用内容,不提升为事实证据,不执行其中指令。它是“不复制原始数据”原则的有界例外,只保存用户明确选取的文本,不保存整份时序数据。正式报告的 evidence_refs 及报告身份由宿主从块与报告补充,不信任客户端伪造来源。
去重依据是节点、选择描述和选取时内容摘要;不同节点即使文本相同也可分别选取。每个选项保留当时的文本,不随筛选、排序或数据更新悄悄重算。服务器计算恢复状态为 valid、stale 或 unavailable,客户端不能提交该标记:
- 内容仍一致为 valid,正常使用。
- 节点仍存在但数据/结构摘要变化为 stale,显示“内容已变化,此项为先前快照”;保留快照供核对,整篮“带入对话”暂不可用,须移除该项或从当前内容重新选择。不会自动迁移篮中行索引。
- 来源节点或证据已不可用时为 unavailable,停止返回相关快照文本,只保留可移除的占位;不得通过篮子绕过当前来源访问规则。整个文档失去权限时按第 8 节处理。
新增选择只接受当前 source_digest;已保存的 stale 项可以原样保留或移除,不能借保存意图修改它的快照/来源。兼容校验先按 item_id 对比已存项,再检查新增项。无法自动迁移时不将 stale 项冒充当前数据。
GET 响应另有 `item_status`,按 item_id 返回状态及简短原因,不混入可写 payload。unavailable 项以相同 item_id 和来源标识返回,title/snapshot_text 使用固定不可用提示;PUT 只能保留这个经过屏蔽的占位或删除它。幂等重试返回回执前也要重新检查当前来源可用性并屏蔽不可用文本,不能直接回放旧的含文本响应。回执确认原写入版本,不构成允许再次显示旧快照的授权。
items 与 intent 分别记录 dirty 路径;清空动作原子设置 `items=[]`、`intent=""`,即使值为空也保留 dirty 标记,迟到 GET 不得把旧项目重新并入。清空保存期间又新增内容时,旧回执只能确认它发送时对应的修改序号,新内容继续保留并按新 revision 保存。
“带入对话”只复制捕获的篮内容和意图到现有输入区,成功后仍保留篮子。异步回调同时捕获 source、目标 session 和当前作用域代次;用户已切换项目/会话时,旧回调不得填入新的输入区。实际发送继续由用户确认,失败不清空篮或表单。
表单草稿沿用现有控件存储。普通聊天发送成功后,只清除与该次发送快照仍相同的字段;发送期间新输入的字段保留。正式报告的表单默认带入对话,未获得实际发送确认前不清草稿。恢复只填充字段,不调用提交回调,不把报告表单写入正式证据。
## 7. 保存与冲突规则
共同流程为读取、合并本地编辑、待保存、保存中、已保存;异常分为未保存、冲突、来源变化和失去访问权限。展示不要求网络先成功,写入必须等已知 revision 和来源摘要。控件及分析篮继续使用 500ms 防抖,读取失败只保留一次首次编辑触发的补读,其余由显式重试触发,不轮询。
| 情况 | 处理 |
| --- | --- |
| GET 比用户编辑晚到 | 按 dirty 字段合并;items 是一个整体,不做数组 union;清空和删除也不能被恢复覆盖 |
| PUT 尚未返回又发生编辑 | 保留更新的 changeSeq;回执只清除已确认的旧 dirty 字段,再保存后续版本 |
| 响应丢失,无法判断写入是否成功 | 先以原 request_id 和原始 payload 重试;确认后再发后续编辑 |
| 409 `UI_INTERACTION_STATE_CONFLICT` | controls/basket 保留本地草稿,停止自动重试;允许“保留本地并重新保存”或确认后“加载远端” |
| 409 `UI_INTERACTION_SOURCE_CHANGED` | 先有界刷新来源并兼容迁移;不可直接拿新摘要覆盖旧索引;stale 篮项目按第 6 节处理 |
| 同 request_id 携带不同 payload | 409 `UI_INTERACTION_REQUEST_CONFLICT`;视为客户端错误,保留草稿,不能自动换 ID 继续重放 |
| 413 或服务不可用 | 保留内存草稿,说明未保存;不切片、截断或循环提交 |
| 服务端确认目标回答正在生成或等待落盘 | 返回 503 `UI_INTERACTION_SOURCE_NOT_READY`,保留草稿;首次用户编辑可触发一次补读,之后显式重试,不等同于来源被删除;仅会话存在不能证明回答正在落盘,不存在的来源仍返回 404 |
| 临时 401 | 沿用现有认证刷新机制;不能因此扩大权限或换用其他主体保存 |
来源摘要冲突必须先解决来源变化,再决定是否覆盖交互版本;“保留本地”不是绕过数据校验。不同浏览器不增加 WebSocket 或后台同步:打开来源时读取,保存时发现冲突。自动读取到较新状态也不能改动用户正在操作的视口或表单。
状态提示按记录聚合:“已保存”表示本界面当前脏记录全部已确认;控件已保存而篮子未保存时明确标出分析篮。纯阅读位置同步异常只在阅读面显示轻量状态,不让每次滚动占据对话通知区。
“加载远端”的确认绑定当前记录和确认时的 changeSeq;确认后又出现新的本地输入,旧确认结果不得覆盖它,应保留新输入并重新呈现冲突选择。
## 8. 清理、权限与删除
- 切换会话时,未失败的待保存写入仍使用捕获的旧 source;新来源创建独立订阅。组件卸载不立即丢掉同一认证作用域内的草稿。
- 登出、切换主体或项目时,取消未发送计时器、移除旧订阅并清理对应缓存。已发出的请求不能撤销既成写入,但回执不得进入新的作用域。
- 401 经认证处理确认失效、403 或来源不存在的 404,关闭来源恢复通道,清除该来源缓存中的正文快照和草稿,不在新用户界面显示;不能将这些响应当普通离线错误长期保留敏感快照。后续重新获得访问权限时重新授权读取。
- 显式“清空”使用带 expected_revision 的空 payload 写入,并增加 state_revision;保留空记录和近期回执,不删成 revision 0,防止迟到保存使已清内容复活。表单、篮和 reader 各自清空,不连带删除业务报告。
- 聊天删除沿用宿主删除标记与队列,清除两个状态文件;聊天分叉不复制私人控件、篮和阅读位置。
- 正式报告物理删除时由外键级联删除私人状态。任务取消、源撤回或进入新轮次不等于报告删除;只要旧报告仍按现有规则可读,其私人状态继续按旧 revision 保留。
- 每次 GET/PUT 重新检查报告权限。成员撤权对后续请求生效;已经通过鉴权的在途请求可能按当时权限完成,不承诺跨 metadata 与 BizDB 事务的即时强撤回。错误响应不能返回其他用户状态或证据快照。
## 9. 验收矩阵
| 场景 | 必须满足的结果 |
| --- | --- |
| 刷新后主动打开同一回答/报告 | 恢复控件、篮意图和阅读位置;不自动打开弹窗、定位地图或发送消息 |
| 桌面与 320px 窄屏互换 | 锚点/比例按新布局恢复,表格横纵滚动均有效,无越界和全页横向溢出 |
| 阅读恢复晚到、字体/图表延迟渲染 | 用户未操作时最多定位一次;用户已滚动或导航后不抢位置;1500ms 后不无限等待 |
| 同一 Markdown 内有重复标题/表格 | 同内容版本可按 occurrence 恢复;内容改变后匹配不唯一只清该位置 |
| 篮清空后 GET 或旧 PUT 回来 | 已清项目不复活,清空之后的新编辑不丢失 |
| 数据换序、重复标签、列变化或 Evidence 消失 | 控件执行明确迁移;篮快照标为 stale/unavailable,不静默换成别的数据 |
| 报告当前版变成下一版 | 旧阅读窗仍向旧版保存;用户选择新版本才加载其独立状态 |
| 两位有报告权限的用户 | 可分别保存同一报告,不能互读或互相冲突;均不要求拥有 source_session_id |
| 同一用户两窗口修改 | 控件/篮出现明确冲突;阅读位置不跳动;原请求重试不增加重复版本 |
| 状态保存中切换用户、项目或来源 | 旧响应和带入对话回调不污染新作用域;失去权限不返回缓存快照 |
| 32/64 KiB 边界、emoji/CJK、文件或报告行满额 | 字节和 UTF-16 限制一致,失败不截断、不丢当前草稿 |
| 会话删除、报告删除、空记录清除竞态 | 不复活删除对象,不把空记录误当从未保存 |
| Server 项目未迁移或接口回退 | 保持可读和当前内存交互,明确未保存;旧版聊天 controls 接口继续工作 |
| 回答正在落盘与来源已删除 | 前者保留草稿并有界重试,后者清理来源缓存;不能将普通 404 无限转成等待 |
| reduced motion、键盘打开和关闭 | 定位不增加动画;恢复不抢焦点,关闭回到触发按钮 |
单元测试覆盖状态归一化、dirty/tombstone、字节预算和迁移;浏览器使用有意延迟、响应丢失和冲突的模拟接口验证行为;Server/Agent 聚焦测试验证真实存储事务、权限和删除。最后以隔离 Agent、已授权项目和真实报告完成保存读回,明确区分模拟接口与真实服务结果。
## 10. 实施顺序与文件交接
第一批补齐聊天 reader/basket,可独立上线,不等待报告表迁移。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| frontend | `src/features/agent/session-state/`、`src/features/agent/api/` | 增加补充记录 schema 与来源适配器,复用现有队列/dirty/retry;保持旧 controls 接口 |
| frontend | `answer-ui/answer-selection.ts`、`answer-selection-tray.tsx`、`answer-ui-interactions.tsx`、`answer-ui-view.tsx` | 选择描述、稳定项目身份、受控 items/intent、快照有效性及带入对话保护 |
| frontend | `src/shared/ui/reading-workspace-context.ts`、`reading-workspace.tsx`、`src/shared/ai-elements/markdown-reading.tsx` | 只接收通用 source key 和位置读写回调;锚点、容器测量与恢复取消,不导入业务模块 |
| Agent | `src/sessions/workspace-store.ts`;拟新增 `src/sessions/answer-interaction-state-store.ts`、`src/routes/answer-interaction-state-routes.ts`;`src/routes/chat.ts`、`src/server.ts` | 受控补充文件与新接口、来源校验、CAS、容量和删除 |
第二批接入正式报告,复用第一批组件和补充记录协议。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| Server | 拟新增 `resources/sql/026_report_user_view_states.sql` | 项目 BizDB 私人状态表及报告删除外键 |
| Server | 拟新增 `app/domain/schemas/report_view_state.py`、`app/services/report_view_states.py`、`app/infra/db/postgresql/report_view_states.py`;`app/api/v1/endpoints/operational_tasks.py` | 状态模型、Evidence 投影、读者权限、行锁、独立子记录版本和错误契约 |
| frontend | `src/features/workbench/api/operational-task-client.ts`;拟新增 `api/report-view-state-client.ts` | 读取指定报告版本、通过现有项目请求头访问私人状态接口,解析 Server 错误 envelope |
| frontend | `components/investigation-report-blocks.tsx`、`investigation-task-report.tsx`、`investigation-expand-card.tsx` | 显式报告身份、冻结阅读版本对应的状态绑定、投影摘要核对和只读者状态接线 |
Workbench 通过 Agent 公共 API 注入保存适配器,Agent 组件不能反向导入 Workbench;shared 阅读组件只处理通用锚点与滚动,不接触 token、taskId 或业务请求。
验证命令沿用各仓库 AGENTS:前端 `pnpm lint`、`pnpm typecheck`、`pnpm test`,新增 `tests/browser/agent-interaction-state.e2e.ts` 并回归既有阅读/状态用例;Agent `bun run check`,新增 `tests/sessions/answerInteractionState.test.ts`、`tests/routes/answerInteractionStateRoutes.test.ts`;Server 新增 `tests/unit/test_report_view_states.py`、`tests/api/test_report_view_states.py`,覆盖本节矩阵的存储与权限分支。新测试文件在实现时创建,本设计不将它们标为已存在或已通过。
发布顺序:聊天先 Agent 补充接口、后前端;报告先对目标项目备份并应用迁移,再发布 Server 和前端。迁移必须包含可重复执行与旧报告外键校验,不能对未授权项目批量执行。回退前端保留两类状态文件与报告状态表;回退 Server 关闭新接口即可,不需要删除数据。每批完成后记录实际提交、迁移项目、服务版本和读回结果,不以本地测试代替发布验收。
## 11. 宿主管理提问与回复
2026-10-28 实施记录:已接入宿主存储、工具桥与门禁、问题卡、草稿保存、Responses 续跑及历史原生 question 核对。当前实现通过独立列表恢复问题,明确重试继续;尚未将全部历史请求嵌入会话详情的分页投影。已使用隔离原生实例验证固定输入 ID 去重与重启持久化,未调用付费模型验证实际工具选择和完整推理续跑。下述契约保留为设计与验收依据;未提交、未部署。
原生验证发现:已回答或取消的 form 在重启后可能返回不存在。因此兼容入口优先保留宿主已知终态,未知且原生缺失的历史卡片显示失效;不能仅凭 form 缺失推断用户已经回答。新协议不依赖原生 form 保持等待状态。
本次验证:Agent 静态检查及 67 项聚焦测试通过,追加边界修改后对应 13 项回归通过;前端 lint、typecheck、125 个文件共 655 项测试及生产构建通过。桌面和 320px 浏览器场景通过,一次重复运行在打开面板前超时,独立重跑通过。构建使用 Node 24.13.0,低于 package.json 要求的 24.22.1,发布前还需在规定 Node 版本复验。隔离原生检查不调用模型,不替代真实授权项目完整推理验收。
### 11.1 决策与当前证据
用户允许在必要时自行设计提问机制,不依赖 OpenCode 内置 question。推荐新增受控工具 `ask_user`:模型提出问题,Agent 宿主保存请求并结束当前模型轮次;用户提交答案后,宿主在原会话启动一次有身份绑定的续跑。等待人的时间不占用一个原生 question Promise,问题是否可回答也不由前端卡片判断。
这不是替换推理引擎。继续使用 OpenCode 执行模型和既有工具,但提问协议、草稿、答案、取消及投递记录由宿主管理。正式报告 FollowUpForm 仍是补充资料入口,不自动变成阻塞提问;Shell/MCP 权限审批继续走原有审批通道,不由 ask_user 代替。
当前代码依据:
- 前端 `QuestionRequestCard` 的选项草稿在组件 `useState` 中;切换后重建组件会重新初始化草稿。
- Agent 的 `chatInteractionRoutes.ts` 要求内存 `activeRuns` 和 `pendingQuestions` 存在;`runtime/opencode.ts` 随后读取并回复原生 form。历史卡片存在不能证明原生请求仍有效。
- 当前切换会话不直接终止旧运行,尚未复现用户此前的错误,因此不能将历史故障确定归因为切换动作。
- `ResponseInputTracker` 本身是内存 Map,不能直接当作持久化队列。可复用的是 runtime 中固定消息 ID、inbox 与 message 读回的投递核对,以及 `chatStream.ts` 在工具完成后中断并等待 idle 的已有轮次结束机制。
最小替代方案是补原生 form 状态核对与选项缓存,改动较少,但恢复仍受原生请求生命周期约束。选择宿主方案是为了独立保存等待状态,不再为每次运行状态丢失拼接恢复逻辑。官方支持[自定义工具及独立工具名称](https://opencode.ai/docs/custom-tools/);本站示例 API 与当前仓库 OpenCode 2 插件 API 不完全相同,实施使用本地 `.opencode/tool.ts` 和插件注册方式,不照搬官网导入路径。
### 11.2 请求与草稿分离
首批仅用于 `ordinary_chat` 和 `task_collaboration`,一个会话最多一个未解决的提问组,每组最多 3 个问题。后台 `task_execution` 不等待浏览器,继续使用缺失资料报告及人工证据流程。工具和浏览器都不能指定所有者、项目或目标续跑会话。
新增宿主记录协议 `agent-interaction-request@1`,身份为已授权 actor/project/session + 宿主生成的 interaction_id。绑定原始消息、宿主 turn_id、工具 call_id;用 session/turn/call 唯一键去重创建,原生 form_id 不参与新协议。
| 记录 | 字段和规则 |
| --- | --- |
| 提问定义 | 不可变的 questions;每题包含稳定 question_id、标题、正文、选项 id/label、single/multiple、allow_custom、required;每题最多 12 个选项 |
| 请求状态 | revision、pending/answered/skipped/cancelled/invalidated、创建时间、解决时间;已回答答案不可覆盖,修正需发新消息 |
| 草稿 | 独立 draft_revision、各题 selected_option_ids/custom_text;请求待回答时可保存;迟到草稿回执不能恢复终态请求 |
| 用户答案 | 按 question_id 提交选项 ID 和自定义文字;宿主按原始定义补齐标签,拒绝不存在的选项、重复 ID 和非法多选 |
| 续跑记录 | 稳定 native_message_id、queued/dispatching/delivered/blocked/cancelled、错误码和关联 response_id;与答案在同一次原子写入中建立 |
所有 schema 严格校验。标识最多 128 字符;标题、问题正文、选项标签分别最多 200、2000、200 个 UTF-16 代码单元;每题自定义输入最多 2000,问题或答案的完整文本各最多 10000。定义最多 32 KiB,答案和草稿各最多 16 KiB UTF-8。默认选中不是已提交,也不能触发自动回复。
新增 `chat/interaction-requests.json`,复用宿主所有权、原子替换、串行更新及删除标记,最多 2 MiB、1000 个请求,先到限额者生效;连同第 4 节两类状态文件,交互文件预算最多 4 MiB/会话。已解决记录保留用于重试和审计,不自动淘汰未回答项;满额拒绝新提问并返回普通文字说明,不截断已有答案。首次实现沿用单宿主进程拥有会话的部署边界,不能把进程内队列宣传为跨实例锁。
### 11.3 提问、等待与续跑
```text
模型 ask_user -> 可信工具桥 -> 宿主原子保存请求 -> 问题卡片
|
本轮结束,等待回答
|
用户提交 -> 宿主保存答案和投递记录 -> 既有运行协调器 -> OpenCode 原会话续跑
^ |
+--- 按固定消息 ID 核对回执 ---+
```
1. 工具桥复用现有内部桥认证及运行会话上下文,session/turn/call 从可信上下文获得,模型参数只包含问题定义。严格注册工具 schema,拒绝模型传入身份、URL、命令或恢复回调。首次发布前确认没有其他正在执行的工具;同组并行调用时拒绝创建提问并要求在结果收齐后重试,不能把已开始的业务动作说成已撤销。
2. 先原子保存 pending 请求,再发布含 interaction_id/revision 的事件。保存失败不显示可提交卡片。工具立即返回“已登记,等待用户”的结果,不挂起 Promise;宿主在原生工具结果完成后结束该轮并等待 idle,记录专门的 `waiting_input` 结束原因,不显示为执行失败或最终报告完成。
3. 从登记成功到模型 idle,插件和宿主都拒绝该轮新工具调用,避免模型尚未收到答案就继续依赖答案执行。已有 final_answer 结束机制可复用,但 ask_user 的结束原因、落盘及重连投影需要单独实现;仅增加一句“请等待”提示词不满足验收。创建后崩溃时,宿主从持久记录重建等待状态并核对旧轮次,不继续旧的工具链。
4. 浏览器显式提交答案,宿主比较请求 revision、验证答案并原子写入 answered 和 queued 投递记录,返回已保存回执。原轮次尚未 idle 时只排队,不并发启动第二轮。提交不能依赖 `activeRuns` 必须仍有旧对象。
5. 抽取并复用既有 `/responses` 的会话启动、事件订阅、鉴权上下文、消息持久化和模型选择,建立单一运行协调入口,不从路由外直接裸调用 runtime.prompt。新轮次采用宿主固定 native_message_id,通过已有原生 prompt 的 id 能力投递结构化问题与答案,标注为用户回答,不伪装成原 question 工具返回值。
6. 投递前先按固定 ID 查原生 inbox/message:已入队则接续观察,已消费则补记 delivered,仅确认不存在才投递。网络失败保留 dispatching 并核对,不生成新 ID。已消费后生成失败只允许继续该轮结果处理,不能再次注入同一答案;不承诺跨系统恰好执行一次业务动作。
7. 宿主重启不会丢掉已保存答案。恢复在下一次已授权的会话打开、回复或用户重试时触发,先恢复事件订阅,再核对投递与原生运行状态;不持久保存 Bearer,也不在无有效用户上下文时自动发起推理。OpenCode 会话不可恢复时标记 blocked,允许复制答案,但不能悄悄创建新会话、重放整段业务操作或伪造工具结果。
本方案最脆弱的前提是固定消息 ID 的核对能够区分未投递与已消费,以及提问后能形成可靠的轮次结束边界。上线门槛是用当前原生版本验证这两个行为;若无法确认,保持“答案已保存,继续失败”并停止自动重试,不回退到不受控的重复执行。当前设计没有执行这些原生故障注入验证。
### 11.4 接口与 UI 行为
接口均拟新增在 Agent 的 `/api/v1/session/:sessionId/interactions` 下,沿用会话鉴权;不建立 Server 表或引入新服务。内部创建桥为 `/internal/chat-interactions`,不对浏览器开放创建权限。会话详情增加未解决请求和当前消息页涉及的请求投影;独立列表使用稳定游标,默认 20、最多 50 条,不为恢复卡片拉取全部历史。
| 方法与相对路径 | 行为 |
| --- | --- |
| GET 根路径;GET `/:interactionId` | 返回权威请求、当前草稿和续跑状态;以 interaction_id 去重,读取不自动回答 |
| PUT `/:interactionId/draft` | 带 expected_draft_revision、request_id 和草稿;500ms 防抖,终态拒绝写入;与正式提交使用不同版本 |
| POST `/:interactionId/reply` | 带 expected_revision、request_id 和完整答案;同请求同内容返回原回执,不重复建续跑记录 |
| POST `/:interactionId/skip` | 所有问题均非必填时允许;原子记录 skipped 并安排一次明确的跳过输入,不编造默认答案 |
| POST `/:interactionId/cancel` | 仅 pending 可取消;关闭提问且不续跑,用户可以改发普通消息 |
| POST `/:interactionId/resume` | 只核对并继续原投递记录;不修改答案、不创建新的投递 ID |
写接口均使用严格 version、expected_revision 和 request_id 校验,draft 使用自己的 revision;resume 比较续跑记录 revision。400 表示定义或答案非法,409 区分版本冲突、请求已解决与幂等键内容不一致,413 表示容量不足,503 表示服务暂不可用。回复已落盘但模型暂不可用时返回已保存状态及 queued/blocked,不把答案当作提交失败让用户反复重发。GET 使用 private/no-store,权限失效与删除沿用第 8 节。
前端复用 shadcn 的 RadioGroup、Checkbox、Textarea、Button、状态提示和现有卡片布局,独立封装宿主提问卡,不新增任意代码渲染。可恢复的状态如下。
| 状态 | 界面和操作 |
| --- | --- |
| 核对中 | 显示问题与草稿,提交暂不可用;不能凭历史 pending 直接放行 |
| 待回答 | 支持单选、多选、自定义输入,草稿保存提示;必填项不提供跳过 |
| 提交中 | 禁止双击;保留完整输入;响应丢失按同请求 ID 核对 |
| 已回答,等待继续 | 只读显示已确认答案和续跑状态;重试只操作续跑记录 |
| 已回答并继续 | 显示答案摘要和后续回复;不再展示提交按钮 |
| 已跳过、已取消、已失效 | 展示对应原因,不显示可点击的过期选项;不统一称为“状态丢失” |
切换对话只切换订阅,旧回执绑定旧 session/interaction,不改变新对话;切回与刷新时先读权威状态,再合并当前作用域未保存的草稿。打开页面不自动提交、弹窗、聚焦或滚动。两窗口提交不同答案只有一个成功,另一窗口显示服务端已接受答案;保留冲突输入供复制,不覆盖已经提交的答案。
有 pending 提问时,普通输入区提示先回答或取消;服务端也拒绝绕过等待直接启动新一轮。已提交答案的续跑排队时同样阻止抢占,避免问答顺序错乱。“停止”会显式取消仍 pending 的提问;已经提交的答案仍保留,未投递记录可停止,已投递的运行沿用既有停止语义,不伪造未执行状态。停止与投递通过同一会话协调入口串行化。
### 11.5 兼容、实施与验收
新请求使用独立类型和 ID,不把历史原生 question 卡片直接改成宿主 pending。旧请求读取时核对原生状态:仍待回答才保留旧回复入口,已回复/取消则同步终态,明确不存在时显示已失效;503 时显示暂不可核对,不能误判失效。用户可以复制旧问题另起交互,但不会自动替旧工具伪造回答。
前端通过 `host-question@1` 能力声明支持新卡片,宿主在每轮启动时固定能力快照。有能力的新轮次注册 ask_user 并禁用内置 question;旧客户端不生成其无法操作的新卡片。既有原生请求的回复路由保留。灰度按新轮次边界生效,不热替换正在等待的工具。旧客户端打开新记录至少能看到文本和已解决状态,待回答请求提示使用支持该能力的客户端,不能静默降级后绕过等待。
该项可在 reader/basket 和正式报告持久化之前独立交付。预计涉及 Agent 与 frontend 两个仓库、超过 8 个文件;新增服务、数据库、账号、环境变量均为零,内部桥复用现有服务凭据。
| 位置 | 交付内容 |
| --- | --- |
| Agent `.opencode/tools/ask_user.ts`、`.opencode/plugins/tjwater-tools.ts`、工具与 instruction 策略 | 新工具、严格 schema、轮次能力判断和等待期工具约束;前者为拟新增文件 |
| Agent 拟新增 `src/sessions/interaction-request-store.ts`、`src/routes/interaction-request-routes.ts`、`src/routes/chat-interaction-bridge.ts` | 单文件事务、草稿版本、原子答案/投递记录、内部创建与用户接口 |
| Agent `src/sessions/workspace-store.ts`、`src/routes/chatStreamRoute.ts`、`chatStream.ts`、`chatSessionRoutes.ts`、`src/runtime/opencode.ts` | 文件允许列表、提问轮次结束、共享运行入口、重连核对、稳定消息 ID、删除及旧请求兼容 |
| frontend `src/features/agent/api/`、拟新增 `components/agent-interaction-request-card.tsx`、`src/features/workbench/hooks/agent-session-runtime.ts`、`use-workbench-agent.ts` | 协议解析、受控提问卡、草稿保存、请求状态核对与会话隔离 |
验收必须包括:A 会话提问后切到 B 再切回提交;刷新恢复草稿;两窗口同时提交;清空草稿后的迟到 GET;提交成功但回执丢失;原生已消费但宿主未记回执时崩溃;宿主重启后授权恢复;原生会话丢失;提问与另一工具并行;提问后模型继续调用工具;停止与续跑竞争;旧客户端及旧 question;权限失效和会话删除。前端覆盖键盘操作、320px 窄屏、焦点及 reduced motion。
实现时运行前端 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和拟新增浏览器 `tests/browser/agent-question-lifecycle.e2e.ts`;Agent 运行 `bun run check`,为状态存储、路由及原生续跑新增聚焦测试。浏览器模拟通过后,还需隔离原生运行验证固定 ID 与故障恢复,不能把模拟 Map 的幂等行为当作 OpenCode 的保证。
发布先增加服务端读写、文本投影与旧请求核对,再上线客户端,最后在支持能力的新轮次启用 ask_user。回退时先停止创建新请求,保留新协议的读取、提交和续跑兼容接口,直到待回答及待投递请求被解决或用户取消;不能直接删除接口把已发布问题遗留成死卡片。记录和答案始终保留,不因回退重置 revision。
@@ -0,0 +1,17 @@
# 回答 UI 的 2026 年 10 月验证记录
> 历史快照,仅保留当时的验证范围,不证明当前工作区或线上服务已通过同样检查。当前协议见 [本地交互式回答](../agent-answer-ui.md)。
2026-10-30 展示范围调整已通过 lint、typecheck、146 个文件共 838 项单元测试,以及 25 项浏览器回归(含复跑,使用模拟接口)。浏览器覆盖桌面与窄屏、内联与展开表格、图表动效、会话状态恢复、地图定位和补充表单;不代表真实项目联调或部署完成。
2026-10-30 分区、导航与来源精简已通过 lint、typecheck、842 项单元测试和 20 项浏览器回归(含复跑,使用模拟接口);另覆盖节点追问移除后的详情与地图定位。
以下为 2026-10-26 的历史验证记录,不代表后续工作区变更的验证结果:
| 验证层 | 结果 | 覆盖边界 |
| --- | --- | --- |
| 前端静态检查与单元测试 | lint、typecheck 通过;121 个文件、631 项测试通过 | 当前原生 UI、状态管理和既有前端回归 |
| Agent 检查与聚焦测试 | `bun run check` 通过;33 项测试通过 | 状态存储、权限、版本冲突、重试和会话生命周期 |
| UI 浏览器验收 | 10 项通过 | 当时的交互状态、引用追问、Markdown 阅读,使用模拟接口;引用追问现已移除 |
| 窗口鼠标行为回归 | 4 项通过 | 外围与 Dev 的窗口内外事件、地图拖动与缩放;修复前已复现遮挡 |
| 前端构建 | 通过 | SPA、生产 CSS 校验和 TTS adapter 构建;不等于部署完成 |
@@ -1,5 +1,7 @@
# 非地图面板整理 # 非地图面板整理
> 历史记录:2026 年 9 月的面板整理过程,当前规范见 [样式设计指南](../style-design-guide.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。 2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。
## 视觉与交互 ## 视觉与交互
@@ -104,7 +106,7 @@
## 新增业务组件统一设计 ## 新增业务组件统一设计
2026-09-18 对调查任务与外围样板的新组件完成统一重构。现行规则见 [样式设计指南](style-design-guide.md),视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。 2026-09-18 对调查任务与外围样板的新组件完成统一重构。现行规则见 [样式设计指南](../style-design-guide.md),视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。 - 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。 - 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
@@ -1,5 +1,7 @@
# Rive Persona 内存问题排查记录 # Rive Persona 内存问题排查记录
> 历史记录:保留 Rive 内存故障与修复依据,当前指针通路、缓存与离线行为见 [Persona 设计](../agent-persona-design.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
日期:2026-07-21 日期:2026-07-21
## 背景 ## 背景
@@ -84,7 +86,7 @@ pnpm build
Agent 入口使用基于原版 Obsidian 定制的冰水蓝资源。继续保留 Strict Mode 延迟初始化、按可见性挂载和禁止 View Model 绑定;配色直接写入资源,不应用旧版的 brightness / contrast 滤镜。`persona-resource.ts` 在页面内存与浏览器 Cache Storage 中缓存动画字节,按内容版本更新,不共享 Rive 实例或 View Model。 Agent 入口使用基于原版 Obsidian 定制的冰水蓝资源。继续保留 Strict Mode 延迟初始化、按可见性挂载和禁止 View Model 绑定;配色直接写入资源,不应用旧版的 brightness / contrast 滤镜。`persona-resource.ts` 在页面内存与浏览器 Cache Storage 中缓存动画字节,按内容版本更新,不共享 Rive 实例或 View Model。
业务离线由本地 `obsidian-water-v1.riv` 的 `asleep` 状态播放内部缓慢水纹并关闭外围光圈,在线/离线切换保留同一实例。减少动态效果、加载和失败时显示从当前资源渲染的 `obsidian-water-idle.png` 或无光圈的 `obsidian-water-offline.png`。隐藏和减少动态效果会卸载实例,不通过 CSS 同时隐藏多个仍运行的 Persona。资源改造见 [Agent 动画资源说明](agent-persona-resource.md)。`hover` 输入由外层交互控制,Rive 自带的 DOM 监听关闭,避免和原生入口按钮及触摸操作重复响应。 业务离线由本地 `obsidian-water-v1.riv` 的 `asleep` 状态播放内部缓慢水纹并关闭外围光圈,在线/离线切换保留同一实例。减少动态效果、加载和失败时显示从当前资源渲染的 `obsidian-water-idle.png` 或无光圈的 `obsidian-water-offline.png`。隐藏和减少动态效果会卸载实例,不通过 CSS 同时隐藏多个仍运行的 Persona。资源改造见 [Agent 动画资源说明](../agent-persona-design.md#8-资源生成与维护)。`hover` 输入由外层交互控制,Rive 自带的 DOM 监听关闭,避免和原生入口按钮及触摸操作重复响应。
回归验证已覆盖原始字节缓存、失败后的重试、无 View Model 绑定、原生状态及 hover 切换、真实 canvas 动画和桌面/手机单实例。 回归验证已覆盖原始字节缓存、失败后的重试、无 View Model 绑定、原生状态及 hover 切换、真实 canvas 动画和桌面/手机单实例。
+48 -9
View File
@@ -20,13 +20,33 @@ Dev 分为“调查测试”“通知测试”和“地图调试”三个页签
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。 Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
调查与本地预览在展示分析图表时自动打开可拖动、缩放和最大化的独立窗口,覆盖 `open_chart` 指令和结构化界面中的 `Chart`。自动打开不抢走键盘焦点;关闭窗口仅收起放大视图,卡片中的图表和放大入口继续保留。同一图表内容未变化时,其他地图动作不会使已关闭的窗口再次弹出;图表内容更新或进入、退出历史回看时可再次自动打开。回放窗口随所选快照更新,保留已有窗口的位置和尺寸,期间收到的实时数据不替换历史图表。关闭图表指令或切换任务时移除对应窗口;普通对话图表仍由用户手动放大。 ## 图表和窗口管理
调查、本地预览与对话展示面板提供“图表窗口”入口,在右侧分析区域打开图表管理区,集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。管理区不提供浮窗拖动、边缘缩放或最大化按钮,通过工作区底部入口切换地图对照与专注图表。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭管理区收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
图表管理区与地图分栏时,两侧最小宽度均为 600px;空间不足时显示专注图表。工况任务沿用展开按钮,展开后占用右侧分析区域,任务区最小宽度为 880px;普通打开或重新打开时默认不选择条目。切换工况任务与图表管理区保留已加载的图表、布局和筛选状态。
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
槽内图表使用紧凑控件,绘图区下方的图例独立滚动,筛选、详情与依据通过入口展开。多对象图在宽槽右侧显示对象按钮,窄槽使用对象选择器;独立放大后使用完整控件。
整条图表位置栏均为拖拽手柄,悬停反馈覆盖整栏;在目标位置释放后交换两处内容,拖到空槽则移动到空槽,其他位置不推移。悬停时整张卡片显示轻量浅色高亮,拖动源与目标位置保留整卡浅色提示;位置栏使用柔和浅底和深色文字,不使用深色横幅。图表保持原有清晰度,目标位置使用细线提示,轻量标题预览沿用中性边框。原图表留在原位,不克隆或随指针重绘。不再提供“移动到”下拉菜单,下方图表通过纵向滚动和快速定位查看。
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
工作台顶部的窗口列表入口仅列出已打开的独立子图表和表格浮窗,图表管理区不计入列表和窗口数量。选择条目可将对应浮窗置顶并定位到标题栏。“关闭全部窗口”收起独立浮窗,保留图表管理区和已加载结果;图表管理区可从原展示入口重新打开。
拖动地图与分析区域的分隔条时,管理区图表随实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择。独立子图表仍支持拖动窗口边缘缩放;松开后确认最终尺寸,拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸,标题栏的 Shift 加方向键用于逐步调整大小。
只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,切换历史动作或播放回看会同步更新窗口内的快照;窗口关闭时不自动打开,手动打开后显示当前回看位置。实时数据继续记录,但不覆盖正在查看的历史;“回到当前”显示最新结果。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。
open_chart 仍只提交 chart_id/chart/evidence_refs,close_chart 仍按 chart_id 移除对象;不新增 display 或 objects 字段。排列、布局与窗口打开意图仅存在当前浏览器中,不进入保存的展示快照或事件。displayed 回执表示展示内容已加载,不保证弹出了独立窗口。
## 本地测试步骤 ## 本地测试步骤
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。 1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。 2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。 3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。 4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。 样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
@@ -37,8 +57,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 案例 | 核对内容 | | 案例 | 核对内容 |
| --- | --- | | --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 | | 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 | | 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 | | 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 | | 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon | | 合成影响范围 | 当前地图中心附近的合成 Polygon |
@@ -49,6 +71,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 | | 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
| 错误:不支持的动作 | 拒绝协议未支持的动作 | | 错误:不支持的动作 | 拒绝协议未支持的动作 |
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 | | 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 |
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。 切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。
@@ -62,7 +85,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"} {"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"}
``` ```
仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。 仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、两个可聚合的对象复合图、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。
每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。 每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。
@@ -75,15 +98,16 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
```bash ```bash
pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts
PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line
PLAYWRIGHT_PORT=5195 pnpm exec playwright test tests/browser/investigation-chart-manager.e2e.ts tests/browser/investigation-chart-groups.e2e.ts tests/browser/investigation-chart-layout.e2e.ts tests/browser/investigation-chart-resize.e2e.ts --workers=1 --reporter=line
``` ```
浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务切换清理、紧凑窗口及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。 浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务静默恢复、任务切换清理、子图表管理、紧凑窗口、实际尺寸适配及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。
## 统一展示动作 ## 统一展示动作
Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。 Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。
动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema 和 Agent/Server JSON Schema;运行同一命令加 `--check` 检查一致性。Server 任务入口继续执行证据归属和快照校验。 动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema、Agent/Server JSON Schema,以及两端 tests/fixtures 下的统一 JSONL 示例与图表正反例;运行同一命令加 `--check` 检查内容一致性。JSON Schema 表达字段和数量约束,跨字段的时间语义由运行时校验和共享测试矩阵保证。Server 任务入口继续执行证据归属和快照校验。
## 已有案例与示例迁移 ## 已有案例与示例迁移
@@ -97,12 +121,27 @@ Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.fr
| 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 | | 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 |
| Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 | | Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 |
本轮迁移只覆盖仓库内的内置案例、夹具和文档示例,未修改历史会话、数据库或外部下载文件。没有增加运行时旧格式转换。 迁移覆盖内置复杂案例、可复制 JSONL、跨端校验夹具及文档,不修改历史会话、数据库或外部下载文件。旧的已保存图表仍按结构校验读取;新输入与发布按下述规则校验。
## 对象复合图与验证规则
协议继续使用 `open_chart`,每个对象有独立稳定的 `chart_id`;不增加 `objects` 字段。多对象同期对比使用 `chart_type: time_comparison`。标题写为 `<对象ID> <同组标题>`,每条序列名以同一 `<对象ID> ` 开头,建议后接 `当前 YYYY-MM-DD` 或历史日期。对象 ID 以字母或数字开头,仅使用字母、数字、下划线、点或短横线。图例显示在绘图区下方;宽槽右侧的对象按钮单选切换数据集,窄槽使用对象选择器;独立放大的窄窗口把对象按钮移到下方。
仅 `open_chart` 中带历史 scatter 的明确对象图自动聚合。同组标题、chart_type、横纵轴名称、横轴槽位及当前序列名称去掉对象前缀后的部分、当前 sample_times 必须完全一致;不同单位或窗口分开。普通 line/bar 图和结构化回答中的独立 Chart 保持各自图表,统一在总窗口管理。关闭子窗口只收起放大视图,关闭聚合卡片移除组内所有对象;按原 chart_id 的 close_chart 仅移除该对象。
历史读取还兼容已保存的 `测点 <对象ID> <同组标题>`、`压力测点 <对象ID> <描述>` 和 `流量测点 <对象ID> <描述>` 标题;后两种以指标名称连接描述作为同组标题,压力和流量分开。对象归属仍要求序列名带同一对象前缀。历史 scatter 仅写 `MM-DD` 的旧图,只有在标题与唯一当前 line 明确指向同一对象,且每条日期序列至少有一个有效采样时间、有效值与时间一一对应、时间换算为 UTC+8 后均为同一完整日期并与日期名相符时,才兼容日期型历史图例;缺失时间、日期不符、跨对象序列或歧义标题保持单图。这些兼容只影响显示聚合,保留原指令、标题、序列、采样时间和证据,不修改保存的数据,也不放宽新发布的同期规则。
当前与历史共用 UTC+8 的 HH:mm 或 HH:mm:ss 槽位,时刻精度一致,窗口按顺序且短于一天,允许跨午夜。每对象最多 100 个槽位、8 组序列,包含当前 line 和历史 scatter,不能混入 bar。每组 sample_times 与 data 等长,有效值保留带时区的真实采样时间;缺测值和时间均为 null,零值保留。采样时间换算为 UTC+8 后须落在对应槽位;当前曲线共享同一窗口,每组历史来自此前某个对应日期,不能混入当天、未来或不同日期。普通 line/bar 不套用这些同期语义,但不能用 line 冒充 line 与历史 scatter 复合图。
Dev 的 presentation 与 ui_block.Chart、新的 Chat open_chart(Agent 主机与原生工具)及任务图表发布使用相同的同期规则。历史读取的结构校验不追溯拒绝旧混合图。Server 在任务展示和报告发布时另外校验证据归属与同期语义,错误码为 TIME_COMPARISON_INVALID;本地预览不证明证据有效或业务发布成功。共享正反例的维护源为 [chart-publication-cases.json](../tests/fixtures/chart-publication-cases.json),随导出脚本同步到 Agent 与 Server,并在各仓库独立运行测试。
## 展示动作历史 ## 展示动作历史
调查实时展示按所选任务读取 Server 已保存的展示动作,各任务的历史在浏览器中分别保留、切换时按任务展示。对话展示和本地预览展示继续保留当前项目、本次页面会话内收到的动作。清除类动作不纳入历史。展示面板的“切换展示动作”以最新优先倒序排列,“当前(最新)”始终置顶,动作编号保留发生顺序。可选择任一已完成、未完成或取消的动作,也可用上一步/下一步逐步回看;进行中的动作显示状态但不可选择。“回到当前”恢复最新展示。 当前结果与最后一条已完成动作相同时,合并显示为“当前(最新)”,下拉和前后导航不再重复这一项。清理后当前结果与最后快照不同,最后一条仍作为历史结果可选。切换任务、切换案例、关闭图表和清除当前展示都会保留历史;切换项目或刷新页面后清空浏览器缓存,重新选择调查任务可再次读取 Server 保存的事件。不设最近若干条的截断。载入和回看不向 Server 发布事件,不重启调查、审批或现场操作;重复选择同一任务按任务编号和事件序号去重。 调查实时展示按所选任务读取 Server 已保存的展示动作,各任务的历史在浏览器中分别保留、切换时按任务展示。对话展示和本地预览展示继续保留当前项目、本次页面会话内收到的动作。清除类动作不纳入历史。“切换展示动作”始终置顶“当前(最新)”;调查历史按相邻类别组成一级菜单:图表、SCADA 分析、地图操作、覆盖层、报告与数据。点击类别查看该组已完成步骤的合并快照,点击箭头展开二级原始步骤,动作编号保留发生顺序,菜单按最新优先倒序排列。同一类别被其他类别隔开时分别成组;历史轮次相邻同类步骤可在同组查看。桌面使用子菜单,窄屏进入类别后提供“返回类别”。
二级菜单可选择任一已完成、未完成或取消的动作,包括最新原始步骤;进行中的动作显示状态但不可选择。上一步/下一步按一级分组顺序导航,保留失败和取消组,跳过仅进行中的组;历史播放只播放含已完成结果的组。明确选择原始步骤或合并组后固定该快照,新到实时结果不会替换它;“回到当前”恢复最新展示。对话和无调查的本地预览继续使用单层动作列表,相同的当前与最后完成结果不重复列出。切换任务、切换案例、关闭图表和清除当前展示都会保留历史;切换项目或刷新页面后清空浏览器缓存,重新选择调查任务可再次读取 Server 保存的事件。不设最近若干条的截断。载入和回看不向 Server 发布事件,不重启调查、审批或现场操作;重复选择同一任务按任务编号和事件序号去重。
本地快照保存图表、界面块、数据、证据摘要、地图几何及镜头。Server 历史保留原发布指令和空间证据快照;只有要素编号的旧定位指令在首次载入时按当前项目读取可访问几何,不声称这些是历史时刻的资产位置。载入后回看使用本地快照,不重复查询。上一轮结果可作为历史查看,不混入本轮当前状态。单条过期或不合法指令显示失败记录,其他有效结果继续载入。历史导航直接渲染快照,不重新查询资产、执行原指令或发送调查请求。关闭图表、显示/隐藏及定位覆盖层、界面块关联要素定位等操作会产生记录;清除临时展示、清除地图展示,以及 `clear_highlights/clear_overlay/clear_scada_analysis` 指令只更新当前状态,不创建历史条目,已有结果快照仍保留;地图手动拖拽和证据展开属于查看操作,不逐帧记录。记录不是完整底图或外部实时数据的离线副本。 本地快照保存图表、界面块、数据、证据摘要、地图几何及镜头。Server 历史保留原发布指令和空间证据快照;只有要素编号的旧定位指令在首次载入时按当前项目读取可访问几何,不声称这些是历史时刻的资产位置。载入后回看使用本地快照,不重复查询。上一轮结果可作为历史查看,不混入本轮当前状态。单条过期或不合法指令显示失败记录,其他有效结果继续载入。历史导航直接渲染快照,不重新查询资产、执行原指令或发送调查请求。关闭图表、显示/隐藏及定位覆盖层、界面块关联要素定位等操作会产生记录;清除临时展示、清除地图展示,以及 `clear_highlights/clear_overlay/clear_scada_analysis` 指令只更新当前状态,不创建历史条目,已有结果快照仍保留;地图手动拖拽和证据展开属于查看操作,不逐帧记录。记录不是完整底图或外部实时数据的离线副本。
+4
View File
@@ -194,6 +194,10 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。 每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
MapTool、地名搜索候选项、Header 普通菜单与窗口列表通过 `mapOptionInteractionVariants` 统一交互。普通项仅在支持 hover 的设备上切换为 `surface-reading` 底色、`surface-border` 边框与 `action-blue-hover` 标题及操作图标;键盘 `focus-visible`、菜单 `data-highlighted` 使用相同反馈并保留焦点提示。说明文字保持 muted,图层示意图保留业务颜色。整行、整张卡片响应,边框预留空间;行和卡片使用 12px 圆角,紧凑工具保持原有尺寸与圆角。颜色过渡为 150ms,减少动态效果时关闭,不增加缩放或位移。 标注工具切换选项时,图标继承按钮实时文字颜色;图标容器仅对底色做同周期过渡,避免按钮底色仍在渐变时图标先切白色。
选中项保留底色、文字和状态标识,悬停及键盘高亮仅加深边框;主操作使用蓝底白字并在高亮时加深,危险操作统一使用 `status-danger-soft/border/foreground`。禁用项不响应 hover 或按压;危险语义由业务显式声明,不按文案推断。`MAP_ACTION_ROW_INTERACTION_CLASS_NAME` 为普通菜单提供布局之外的默认底色、圆角与上述交互组合。此规则仅用于地图和工作台入口;Agent、调查展示等面板及共享 DropdownMenu 保留本地 shadcn/ui 主题,业务状态色继续独立表达。
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。 地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。 弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
+2
View File
@@ -24,6 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.9", "@ai-sdk/react": "^4.0.9",
"@dnd-kit/collision": "0.5.3",
"@dnd-kit/react": "0.5.3",
"@fontsource-variable/noto-sans-sc": "5.3.0", "@fontsource-variable/noto-sans-sc": "5.3.0",
"@json-render/core": "^0.21.0", "@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0", "@json-render/react": "^0.21.0",
+71
View File
@@ -15,6 +15,12 @@ importers:
'@ai-sdk/react': '@ai-sdk/react':
specifier: ^4.0.9 specifier: ^4.0.9
version: 4.0.34(react@19.2.7)(zod@3.25.76) version: 4.0.34(react@19.2.7)(zod@3.25.76)
'@dnd-kit/collision':
specifier: 0.5.3
version: 0.5.3
'@dnd-kit/react':
specifier: 0.5.3
version: 0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@fontsource-variable/noto-sans-sc': '@fontsource-variable/noto-sans-sc':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
@@ -470,6 +476,27 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@dnd-kit/abstract@0.5.3':
resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==}
'@dnd-kit/collision@0.5.3':
resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==}
'@dnd-kit/dom@0.5.3':
resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==}
'@dnd-kit/geometry@0.5.3':
resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==}
'@dnd-kit/react@0.5.3':
resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@dnd-kit/state@0.5.3':
resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -825,6 +852,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@preact/signals-core@1.14.5':
resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==}
'@radix-ui/number@1.1.2': '@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -4567,6 +4597,45 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/abstract@0.5.3':
dependencies:
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/collision@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/geometry': 0.5.3
tslib: 2.8.1
'@dnd-kit/dom@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/collision': 0.5.3
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/geometry@0.5.3':
dependencies:
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/react@0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/dom': 0.5.3
'@dnd-kit/state': 0.5.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/state@0.5.3':
dependencies:
'@preact/signals-core': 1.14.5
tslib: 2.8.1
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -4860,6 +4929,8 @@ snapshots:
dependencies: dependencies:
playwright: 1.61.1 playwright: 1.61.1
'@preact/signals-core@1.14.5': {}
'@radix-ui/number@1.1.2': {} '@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {} '@radix-ui/primitive@1.1.6': {}
+9 -1
View File
@@ -1,5 +1,6 @@
import process from "node:process"; import process from "node:process";
import { readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts"; import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts";
@@ -22,11 +23,18 @@ const targets = [
], ],
[new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema] [new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema]
]; ];
// The same executable examples and rejection matrix travel with each independent repository.
for (const name of ["unified-presentation.jsonl", "chart-publication-cases.json"]) {
const fixture = await readFile(new URL(`../tests/fixtures/${name}`, import.meta.url), "utf8");
for (const repository of ["next-tjwater-agent", "next-tjwater-server"])
targets.push([new URL(`../../${repository}/tests/fixtures/${name}`, import.meta.url), fixture]);
}
for (const [url, content] of targets) { for (const [url, content] of targets) {
if (process.argv.includes("--check")) { if (process.argv.includes("--check")) {
if ((await readFile(url, "utf8")) !== content) if ((await readFile(url, "utf8")) !== content)
throw new Error(`View command contract differs: ${fileURLToPath(url)}`); throw new Error(`View command contract differs: ${fileURLToPath(url)}`);
} else { } else {
await mkdir(dirname(fileURLToPath(url)), { recursive: true });
await writeFile(url, content); await writeFile(url, content);
} }
} }
@@ -7,10 +7,10 @@ export type AnswerUiMapTarget = {
}; };
export type AnswerUiInteractions = { export type AnswerUiInteractions = {
disabled: boolean; disabled: boolean;
chartPresentation?: "tile";
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string; evidenceFilter?: string;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
chartWindowKey?: string;
}; };
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true }); export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -189,6 +189,30 @@ describe("local interactive answer renderer", () => {
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("table")).not.toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument();
}); });
it("updates chart titles and readings after the same spec is changed in place", async () => {
const user = userEvent.setup();
const fixture = block("Chart", {
title: "原始压力图", chart_type: "line", source: "监测数据",
x_data: ["08:00", "09:00"], series: [{ name: "原始压力", data: [0.3, 0.2] }]
});
const { rerender } = render(<AnswerUiView block={fixture} />);
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.2");
const props = fixture.spec.elements.node.props;
props.title = "更新压力图";
const series = props.series as { name: string; data: number[] }[];
series[0].name = "更新后压力";
series[0].data[1] = 0.17;
rerender(<AnswerUiView block={fixture} />);
expect(screen.getByRole("group", { name: "更新压力图" })).toBeVisible();
expect(screen.queryByRole("group", { name: "原始压力图" })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("columnheader", { name: "更新后压力" })).toBeVisible());
expect(screen.getByRole("table")).toHaveTextContent("0.17");
expect(screen.getByRole("table")).not.toHaveTextContent("0.2");
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
});
it("runs map location only after a user click and reports missing IDs", async () => { it("runs map location only after a user click and reports missing IDs", async () => {
const locate = vi const locate = vi
.fn() .fn()
@@ -1,6 +1,6 @@
import { LocateButton, FollowUpForm } from "./answer-action-components"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { Renderer, defineRegistry } from "@json-render/react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react"; import { useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
Info, Info,
TriangleAlert, TriangleAlert,
@@ -103,17 +103,19 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>; type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) { export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
const serializedSpec = JSON.stringify(props.block.spec);
const displayedSpec = useMemo(() => withAnswerNodeIds(withAnswerDisplayTimes(JSON.parse(serializedSpec) as AnswerUiBlock["spec"])), [serializedSpec]);
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}> return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} /> <AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
</AnswerViewStateProvider>; </AnswerViewStateProvider>;
} }
function AnswerUiBody({ function AnswerUiBody({
block, block,
disabled = false, disabled = false,
chartPresentation,
onLocate, onLocate,
onSubmit, onSubmit,
chartWindowKey,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState(); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
@@ -132,10 +134,10 @@ function AnswerUiBody({
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section"); const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />); const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return ( return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}> <InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartPresentation }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}> <AnswerMotionScope className={chartPresentation === "tile" ? "answer-ui-root flex h-full min-h-0 flex-col" : "answer-ui-root"} data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} /> {chartPresentation !== "tile" ? <AnswerContentNav block={block} contentRef={contentRef} /> : null}
<div ref={contentRef}> <div ref={contentRef} className={chartPresentation === "tile" ? "flex h-full min-h-0 flex-1 flex-col" : undefined}>
<> <>
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}> {taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null} {taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
describe("monitoring chart comparison", () => { describe("monitoring chart comparison", () => {
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => { it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
@@ -17,6 +17,39 @@ describe("monitoring chart comparison", () => {
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 }); expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
}); });
it("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
const original = structuredClone(series);
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
expect(series).toEqual(original);
});
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
.toEqual({ min: 28, max: 29, interval: 1 });
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
.toEqual({ min: 28, max: 30, interval: 1 });
});
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
.toEqual({ min: -150, max: 50, interval: 50 });
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
.toEqual({ min: -1, max: 1, interval: 1 });
});
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
expect(chartUsesIntegerTicks(name)).toBe(true);
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
expect(chartUsesIntegerTicks(name)).toBe(false);
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
expect(bounds.min).toBeGreaterThan(0.21);
expect(bounds.max).toBeLessThan(0.22);
expect(bounds).not.toHaveProperty("interval");
});
it("preserves the original historical date in tooltip and marks missing values explicitly", () => { it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] }, const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] }, { name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
@@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string {
return time ? formatDisplayTimestamp(time) : ""; return time ? formatDisplayTimestamp(time) : "";
} }
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) { /** Only confirmed monitoring units opt into integer ticks; small generic units retain their scale. */
export function chartUsesIntegerTicks(axisName: string | undefined): boolean {
const normalized = axisName?.replace(/\s+/g, "").replace(/(/g, "(").replace(/)/g, ")").replace(/³/g, "3").toLowerCase();
return /^(?:(?:压力|压力水头|水头)\(m\)|(?:流量|管流)\(m3\/h\))$/.test(normalized ?? "");
}
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number, integerTicks = false) {
const values = series.flatMap((item) => item.data.slice(start, end + 1)) const values = series.flatMap((item) => item.data.slice(start, end + 1))
.filter((value): value is number => value !== null && Number.isFinite(value)); .filter((value): value is number => value !== null && Number.isFinite(value));
if (!values.length) return {}; if (!values.length) return {};
const min = Math.min(...values), max = Math.max(...values); const min = Math.min(...values), max = Math.max(...values);
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08; const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
return { min: min - padding, max: max + padding }; const bounds = { min: min - padding, max: max + padding };
if (!integerTicks) return bounds;
const target = Math.max(1, (bounds.max - bounds.min) / 4);
const magnitude = 10 ** Math.floor(Math.log10(target));
const interval = ([1, 2, 5, 10].find((step) => step * magnitude >= target) ?? 10) * magnitude;
return { min: Math.floor(bounds.min / interval) * interval, max: Math.ceil(bounds.max / interval) * interval, interval };
} }
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */ /** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
+141 -105
View File
@@ -1,6 +1,6 @@
import { memo, useContext, useLayoutEffect, useRef, useState } from "react"; import { memo, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react"; import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; import { BarChart3, X, Database, FileText, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { FloatingWindow } from "@/shared/ui/floating-window";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
@@ -11,8 +11,9 @@ import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart"; import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion"; import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data"; import type { SafeChartSeries } from "../chart-data";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { AnswerInteractionContext } from "./answer-ui-interaction-context"; import { AnswerInteractionContext } from "./answer-ui-interaction-context";
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
@@ -35,7 +36,7 @@ function PointSelect({ label, value, labels, onChange }: {
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent> <SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>; </Select>;
} }
type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string }; type ChartPresentation = { tile?: boolean; expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
const standaloneRegistry = {}; const standaloneRegistry = {};
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */ /** Legacy chart envelopes use the same controls and chart engine as structured answers. */
@@ -47,7 +48,9 @@ export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresen
} }
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) { export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartWindowKey } = useContext(AnswerInteractionContext); const { chartPresentation } = useContext(AnswerInteractionContext);
const tile = props.tile ?? chartPresentation === "tile";
const fitted = Boolean(props.expanded || tile);
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const pointer = useRef(false); const pointer = useRef(false);
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const [plotWidth, setPlotWidth] = useState(360); const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = plot.current; const element = plot.current;
if (props.expanded || !element || typeof ResizeObserver === "undefined") return; if (fitted || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth); const resize = () => setPlotWidth(element.clientWidth);
resize(); resize();
const observer = new ResizeObserver(resize); const observer = new ResizeObserver(resize);
observer.observe(element); observer.observe(element);
return () => observer.disconnect(); return () => observer.disconnect();
}, [view, props.expanded]); }, [view, fitted]);
const contentMotion = useAnimationControls(); const contentMotion = useAnimationControls();
const pendingTransition = useRef(false); const pendingTransition = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
}, [contentMotion, view]); }, [contentMotion, view]);
const setView = (next: string) => { const setView = (next: string) => {
if (next === view) return; if (next === view) return;
const animate = pointer.current && !reducedMotion; const animate = pointer.current && !reducedMotion && !tile;
setAnimateInteraction(animate); setAnimateInteraction(animate);
contentMotion.stop(); contentMotion.stop();
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 }); contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
@@ -85,8 +88,11 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]); const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
const setPoint = (point: number) => patch({ point }); const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting }); const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index)); const hiddenKey = hidden.join(",");
const labels = props.x_data.slice(range[0], range[1] + 1); const hiddenSeries = useMemo(() => new Set(hiddenKey.split(",").filter(Boolean).map(Number)), [hiddenKey]);
const visible = useMemo(() => props.series.filter((_, index) => !hiddenSeries.has(index)), [props.series, hiddenSeries]);
const [rangeStart, rangeEnd] = range;
const labels = useMemo(() => props.x_data.slice(rangeStart, rangeEnd + 1), [props.x_data, rangeStart, rangeEnd]);
const pointIndex = Math.min(range[1], Math.max(range[0], point)); const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => { const summaries = visible.map((series) => {
@@ -98,29 +104,38 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const categoryColors = visible.length === 1; const categoryColors = visible.length === 1;
const horizontalBars = props.x_data.some((label) => label.length > 10); const horizontalBars = props.x_data.some((label) => label.length > 10);
const numberLabel = chartValueLabel; const numberLabel = chartValueLabel;
const xAxisName = props.xAxisName ?? props.x_axis_name;
const yAxisName = props.yAxisName ?? props.y_axis_name;
// Keep tooltip functions and options stable across sizing, focus and table-only updates.
const options = useMemo(() => {
const option = (type: "line" | "bar") => { const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars; const horizontal = type === "bar" && horizontalBars;
const horizontalAxisName = horizontal ? yAxisName : xAxisName;
const verticalAxisName = horizontal ? xAxisName : yAxisName;
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type; const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar"); const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) }, ...(tile ? { margin: 4, lineHeight: 12 } : {}),
...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
const valueAxis = { type: "value", scale: !includesBars, const valueAxis = { type: "value", scale: !includesBars,
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}), ...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel }, ...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
...(tile ? { margin: 4, lineHeight: 12 } : {}) },
splitLine: { lineStyle: { color: "#e2e8f0" } } }; splitLine: { lineStyle: { color: "#e2e8f0" } } };
return { return {
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true }, color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
...(composite ? { formatter: (params: unknown) => { ...(composite ? { formatter: (params: unknown) => {
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
const text = typeof first?.dataIndex === "number" const text = typeof first?.dataIndex === "number"
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : ""; ? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML. // readable and never interprets model-provided labels as HTML.
const content = document.createElement("div"); const content = document.createElement("div");
@@ -128,31 +143,24 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
content.style.whiteSpace = "pre-line"; content.style.whiteSpace = "pre-line";
return content; return content;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } }, } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true }, grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name, bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
nameLocation: "middle", nameGap: 30 }, xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name }, nameLocation: "middle", nameGap: tile ? 16 : 30,
...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
series: visible.map((series) => ({ name: series.name, type: seriesType(series), series: visible.map((series) => ({ name: series.name, type: seriesType(series),
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value), ? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false, barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } })) showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
}; };
}; };
const metadata = <> return { line: option("line"), bar: option("bar") };
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要"> }, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
<div className="mb-2 flex flex-wrap items-center justify-between gap-1"> props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span> const pointDetails = view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false} initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}> animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</dd> </dd>
</div>)} </div>)}
</dl> </dl>
</motion.section> : null} </motion.section> : null;
const metadata = <>
{!props.embedded && !tile && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
{pointDetails}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground"> <p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span> <Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列{props.embedded ? "" : <><br />来源:{props.source}</>}</span>
</p> </p>
</>; </>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title} const rangeControls = <div className="space-y-2">
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
{() => <InteractiveChart {...props} expanded />}
</FloatingWindow> : null}
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded ? metadata : null}
</aside>
<div className={props.expanded ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} <PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> onChange={(start) => setRange([start, Math.max(start, range[1])])} />
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</div> </div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange} <Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent> </div>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} data-chart-tile={tile || undefined} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
{!props.embedded ? <CardHeader className={`shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${tile ? "px-2 py-1.5" : "px-3.5 py-2.5"}`}>
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent>
{() => <InteractiveChart {...props} expanded tile={false} />}
</FloatingWindow> : null}
{tile ? <Popover><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="查看图表来源" title="查看图表来源"><FileText size={14} aria-hidden /></Button>
</PopoverTrigger><PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5">{props.source}</PopoverContent></Popover> : <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className="shrink-0 text-muted-foreground"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger>}
</div> </div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}> </CardHeader> : null}
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}> <CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
</TabsList>
{tile ? <>
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
</Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
</Popover>
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
</Popover> : null}
</> : props.embedded ? <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger> : null}
</div>
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
</div> : null}
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0} <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`} className={`${fitted ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
@@ -259,13 +275,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
if (event.key === "Escape") setInspecting(false); if (event.key === "Escape") setInspecting(false);
}}> }}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span> <span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220, <div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}> minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} /> <ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div> </div>
</div> </div>
</TabsContent>)} </TabsContent>)}
<TabsContent value="table" className={props.expanded ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}> <TabsContent value="table" className={fitted ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]} <InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => { rows={labels.map((label, index) => [label, ...visible.map((series) => {
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]); const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
@@ -274,7 +290,27 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} /> state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent> </TabsContent>
</motion.div> </motion.div>
{!props.expanded ? metadata : null} <div aria-label="图表图例" className={fitted ? `col-start-1 ${tile ? "row-start-3 min-h-8 max-h-14" : `row-start-4 min-h-0 ${props.embedded ? "max-h-56" : "max-h-40"}`} min-w-0 overflow-y-auto border-t border-border ${tile ? "pt-1" : "pt-2"}` : "border-t border-border pt-3"}>
<div className={`answer-ui-chart-series flex flex-wrap ${tile ? "gap-1" : "mb-2 gap-2"}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto ${tile ? "min-h-6 px-2 py-0.5" : "min-h-8"} min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion && !tile); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${fitted ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded && !tile ? metadata : null}
</div>
{!fitted ? metadata : null}
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card></Collapsible>; </Card></Collapsible>;
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { FloatingWindow } from "@/shared/ui/floating-window";
@@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Table> <Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption> <TableCaption className="caption-top font-medium">{title}</TableCaption>
<TableHeader className="bg-muted/50"><TableRow> <TableHeader className="bg-muted/50"><TableRow>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap" {columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap p-0"
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}> aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`} <Button variant="ghost" className="h-10 w-full justify-start gap-1 rounded-none px-4 text-xs focus-visible:ring-inset focus-visible:ring-offset-0" aria-label={`按${column}排序`}
onClick={() => patch({ sort: sort?.column === index onClick={() => patch({ sort: sort?.column === index
? sort.descending ? null : { column: index, descending: true } ? sort.descending ? null : { column: index, descending: true }
: { column: index, descending: false } })}> : { column: index, descending: false } })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />} {column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} aria-hidden /> : <ArrowUp size={12} aria-hidden /> : null}
</Button> </Button>
</TableHead>)} </TableHead>)}
</TableRow></TableHeader> </TableRow></TableHeader>
@@ -0,0 +1,130 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ResponsiveChart } from "./responsive-chart";
const engine = vi.hoisted(() => ({ resize: vi.fn() }));
vi.mock("echarts-for-react", async () => {
const React = await import("react");
return { default: React.forwardRef(function MockChart({ onChartReady }: { onChartReady?: (instance: typeof engine) => void }, ref) {
React.useImperativeHandle(ref, () => ({ getEchartsInstance: () => engine }), []);
React.useLayoutEffect(() => { onChartReady?.(engine); }, [onChartReady]);
return <div data-testid="chart-engine" />;
}) };
});
const observers: ResizeObserverCallback[] = [];
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) { observers.push(callback); }
observe() {}
disconnect() {}
});
});
afterEach(() => {
cleanup();
engine.resize.mockReset();
observers.length = 0;
frames.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function fixture(resizing: boolean, count = 1) {
const observerStart = observers.length;
const view = render(<div data-floating-window="" data-floating-window-resizing={resizing ? "true" : undefined}>
{Array.from({ length: count }, (_, index) => <ResponsiveChart key={index} option={{}} height="100%" onPointSelect={() => undefined} />)}
</div>);
const shell = view.container.firstElementChild as HTMLElement;
const size = { width: 320, height: 200 };
for (const host of shell.children) Object.defineProperties(host, { clientWidth: { get: () => size.width }, clientHeight: { get: () => size.height } });
const notify = (index = 0) => observers[observerStart + index]([], {} as ResizeObserver);
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(performance.now())); };
engine.resize.mockClear();
return { view, shell, size, notify, paint };
}
it("coalesces active resizes to the latest actual dimensions with a 64ms interval", () => {
const chart = fixture(true);
const sizes: number[] = [];
engine.resize.mockImplementation(() => { sizes.push(chart.size.width); });
chart.notify();
chart.notify();
expect(frames.size).toBe(1);
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.size.width = 400;
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
chart.size.width = 520;
chart.notify();
act(() => vi.advanceTimersByTime(48));
chart.paint();
expect(sizes).toEqual([320, 520]);
expect(engine.resize).toHaveBeenLastCalledWith({ width: "auto", height: "auto", animation: { duration: 0 } });
});
it("spaces charts in the same window from the last completed draw", () => {
const chart = fixture(true, 2);
engine.resize.mockImplementation(() => { vi.advanceTimersByTime(70); });
chart.notify(0);
chart.notify(1);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(64));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("does not delay a different window behind another window's draw", () => {
const first = fixture(true);
const second = fixture(true);
first.notify();
second.notify();
first.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("flushes the final geometry on release without waiting for the remaining interval", async () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(vi.getTimerCount()).toBe(1);
await act(async () => { delete chart.shell.dataset.floatingWindowResizing; await Promise.resolve(); });
expect(vi.getTimerCount()).toBe(0);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("keeps normal layout changes immediate instead of applying pointer throttling", () => {
const chart = fixture(false);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("cancels scheduled drawing on unmount", () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
chart.view.unmount();
act(() => vi.advanceTimersByTime(100));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
@@ -1,7 +1,9 @@
import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { useLayoutEffect, useRef, type ComponentProps } from "react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
/** Resize the existing drawing at most once per frame without replacing its options. */ const resizedWindows = new WeakMap<HTMLElement, number>();
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
export function ResponsiveChart({ option, height, onPointSelect }: { export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"]; option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%"; height: number | "100%";
@@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: {
useLayoutEffect(() => { useLayoutEffect(() => {
const element = host.current; const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return; if (!element || typeof ResizeObserver === "undefined") return;
const shell = element.closest<HTMLElement>("[data-floating-window]");
let frame = 0; let frame = 0;
const observer = new ResizeObserver(() => { let timer = 0;
cancelAnimationFrame(frame); const resize = () => {
frame = requestAnimationFrame(() => { frame = 0;
if (!ready.current || !element.clientWidth || !element.clientHeight) return; if (!ready.current || !element.clientWidth || !element.clientHeight) return;
// Space expensive drawings in one window instead of redrawing every chart together.
const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
return;
}
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } }); chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
}); if (shell) resizedWindows.set(shell, performance.now());
};
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
const observer = new ResizeObserver(schedule);
const settled = new MutationObserver(() => {
if (shell?.dataset.floatingWindowResizing === "true") return;
clearTimeout(timer);
timer = 0;
schedule();
}); });
observer.observe(element); observer.observe(element);
return () => { cancelAnimationFrame(frame); observer.disconnect(); }; if (shell) settled.observe(shell, { attributes: true, attributeFilter: ["data-floating-window-resizing"] });
return () => { clearTimeout(timer); cancelAnimationFrame(frame); observer.disconnect(); settled.disconnect(); };
}, []); }, []);
return <div ref={host} style={{ height }}> return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false} <ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
@@ -72,13 +72,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
); );
} }
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) { export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false, plotHeight, tile = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type, return <SafeChartEnvelope embedded={embedded} expanded={expanded} plotHeight={plotHeight} tile={tile} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}), ...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />; ...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
}); });
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) { function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
const parsed = parseChartSpec(envelope.spec); const parsed = parseChartSpec(envelope.spec);
const raw = normalizeSafeChartData(envelope.data, 100); const raw = normalizeSafeChartData(envelope.data, 100);
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName, const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
@@ -97,7 +97,7 @@ function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { e
series={data.series.slice(0, 8)} series={data.series.slice(0, 8)}
source="来源见关联依据。" source="来源见关联依据。"
embedded={embedded} embedded={embedded}
expanded={expanded} expanded={expanded} plotHeight={plotHeight} tile={tile}
xAxisName={spec.xAxisName} xAxisName={spec.xAxisName}
yAxisName={spec.yAxisName} yAxisName={spec.yAxisName}
/>; />;
+1
View File
@@ -116,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests";
export type { InteractionRequestsController } from "./hooks/use-interaction-requests"; export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command"; export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command";
export { chartPublicationError, safeChartPublicationInput } from "./presentation/command-schema";
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import cases from "../../../../tests/fixtures/chart-publication-cases.json";
import examples from "../../../../tests/fixtures/unified-presentation.jsonl?raw";
import { createViewSchemas, safeChartInput, safeChartPublicationInput } from "./command-schema";
describe("shared chart publication conformance", () => {
it.each(cases)("$name", ({ chart, accepted }) => {
expect(safeChartPublicationInput.safeParse(chart).success).toBe(accepted);
for (const publication of [false, true]) {
const schema = createViewSchemas(z, publication, true).command;
expect(schema.safeParse({ action: "open_chart", chart_id: "comparison", evidence_refs: ["dev:synthetic"], chart }).success).toBe(accepted);
}
});
it("keeps historical mixed charts structurally readable", () => {
expect(safeChartInput.safeParse(cases.find((item) => item.name === "old mixed charts are read only")!.chart).success).toBe(true);
});
it("accepts every exported command example through the publication schema", () => {
const schema = createViewSchemas(z, true).command;
for (const record of examples.trim().split("\n").map((line) => JSON.parse(line)))
if (record.record_type === "presentation") expect(schema.safeParse(record.command).success).toBe(true);
});
});
@@ -7,14 +7,90 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
); );
} }
type PublicationChart = {
chart_type: string;
scada?: { role: "current" | "comparison"; window_start: string };
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
export function chartPublicationError(chart: PublicationChart): string | null {
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
const labels = chart.x_data;
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
const offsets = [0];
for (let index = 1; index < seconds.length; index++) {
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
offsets.push(offsets[index - 1] + step);
}
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
const current = new Set<number>();
const historical: number[] = [];
for (const series of chart.series) {
const times = series.sample_times;
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
const bases = new Set<number>();
for (let index = 0; index < times.length; index++) {
const at = times[index];
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
if (at === null) continue;
const local = new Date(Date.parse(at) + 8 * 3600_000);
const base = local.getTime() - offsets[index] * 1000;
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
return "sample_times 超出同期对比可表达的日期范围";
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
bases.add(base);
}
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
const emptyScada = current.size === 0 && chart.scada?.role === "comparison";
if (current.size !== 1 && !emptyScada) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const windowStart = emptyScada ? Date.parse(chart.scada!.window_start) + 8 * 3600_000 : 0;
const midnight = Math.floor(windowStart / 86400_000) * 86400_000;
const firstSlot = midnight + seconds[0] * 1000;
const start = emptyScada ? firstSlot + (firstSlot < windowStart ? 86400_000 : 0) : [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */ /** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) { export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod; const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128); const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const scadaChartSchema = z.object({
role: z.enum(["current", "comparison"]),
metric: z.enum(["pressure", "flow"]),
unit: presentationIdentifierSchema,
window_start: createChartSampleTimeSchema(z),
window_end: createChartSampleTimeSchema(z),
value_field: z.enum(["monitored_value", "cleaned_value"]).optional(),
// Populated by Server after checking observations, never a display exemption.
gaps: z.array(z.object({
device_id: presentationIdentifierSchema,
day_offset: z.number().int().min(0).max(7),
missing_count: z.number().int().min(1).max(100),
reason: z.enum(["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"])
}).strict()).max(8).optional()
}).strict();
const chartSchema = z const chartSchema = z
.object({ .object({
title: z.string().max(200), title: z.string().max(200),
scada: scadaChartSchema.optional(),
chart_type: z.enum(["line", "bar", "time_comparison"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(), x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(),
@@ -24,6 +100,8 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
z z
.object({ .object({
name: z.string().max(128), name: z.string().max(128),
device_id: presentationIdentifierSchema.optional(),
day_offset: z.number().int().min(0).max(7).optional(),
type: z.enum(["line", "bar", "scatter"]).optional(), type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100), data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional() sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
@@ -39,7 +117,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
(!series.sample_times || series.sample_times.length === value.x_data.length && (!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))), series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data" "series must align with x_data"
); ).superRefine((value, ctx) => {
if (!validateCharts) return;
const message = chartPublicationError(value);
if (message) ctx.addIssue({ code: "custom", message });
});
const position = z const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]) .tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite()) .rest(z.number().finite())
@@ -213,8 +295,9 @@ export const {
overlayDataSchema, overlayDataSchema,
variants: viewCommandVariants variants: viewCommandVariants
} = createViewSchemas(); } = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) => export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
createViewSchemas(z, options.publication).command; export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>; type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand = export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }> | Exclude<Command, { action: "set_overlay" }>
@@ -2,13 +2,13 @@ import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react"; import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
@@ -105,18 +105,18 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -159,22 +159,22 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150", "flex h-[84px] w-[84px] flex-col p-1 text-center text-slate-800",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)]",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
<span <span
data-base-layer-label="true" data-base-layer-label="true"
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800" className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none"
> >
{option.label} {option.label}
</span> </span>
@@ -185,9 +185,10 @@ export function BaseLayersControl({
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}> <div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<button <button
type="button" type="button"
data-map-option=""
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`} title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)} className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, mapOptionInteractionVariants())}
> >
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
@@ -195,7 +196,7 @@ export function BaseLayersControl({
) : ( ) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800"> <span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none">
{activeLayer?.label ?? title} {activeLayer?.label ?? title}
</span> </span>
</span> </span>
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,14 +80,15 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
return ( return (
<button <button
type="button" type="button"
data-map-option=""
aria-pressed={active} aria-pressed={active}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150", "flex h-14 flex-col items-center justify-center gap-1 border border-transparent text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]", active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<Icon size={17} aria-hidden="true" /> <Icon size={17} aria-hidden="true" />
@@ -109,13 +111,14 @@ export function MapActionChip({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]", "flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700" mapOptionInteractionVariants()
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -157,23 +160,20 @@ export function MapActionRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left", "flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary isPrimary
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25"
: strong : strong
? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]" ? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
: "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "surface-control border-transparent text-slate-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]", selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
mapOptionInteractionVariants({ state: isDanger ? "danger" : isPrimary ? "primary" : selected || strong ? "selected" : "default" }),
muted && "opacity-70", muted && "opacity-70",
disabled &&
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
disabled && "shadow-none" disabled && "shadow-none"
)} )}
> >
@@ -185,8 +185,8 @@ export function MapActionRow({
? "bg-[var(--surface-reading)]/16 text-white" ? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong : selected || strong
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-control)] text-[var(--ink-muted)]", : "bg-[var(--surface-control)] text-inherit",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -7,6 +7,7 @@ import {
TooltipTrigger TooltipTrigger
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -46,27 +47,26 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
onClick={item.onClick} onClick={item.onClick}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150", "flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "text-slate-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME, !item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-7 w-7 shrink-0 place-items-center", "grid h-7 w-7 shrink-0 place-items-center text-inherit transition-[background-color] duration-150 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]", item.active ? "bg-[var(--surface-reading)]/16" : "bg-[var(--surface-control)]",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -100,6 +100,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
data-map-option=""
aria-label={item.label} aria-label={item.label}
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
@@ -109,9 +110,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]", : undefined,
isDanger && "hover:bg-red-50 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)} )}
> >
<Icon size={16} aria-hidden="true" /> <Icon size={16} aria-hidden="true" />
@@ -1,6 +1,7 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react"; import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,23 +80,24 @@ export function MapLayerControl({
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150", "flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "surface-control border-[var(--surface-border)] text-slate-900" ? "surface-control border-[var(--surface-border)] text-slate-900"
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]", : "surface-well border-transparent text-[var(--ink-muted)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center", "grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -4,12 +4,12 @@ import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control"; import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control"; import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
export type MapLayerPanelProps = { export type MapLayerPanelProps = {
@@ -61,14 +61,14 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)} onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border" className="surface-well group/layer overflow-hidden rounded-xl border"
> >
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]"> <summary data-map-option="" className={cn("flex min-h-11 cursor-pointer list-none items-center gap-2 border border-transparent px-2.5 py-1.5 text-left text-slate-700 marker:content-none", mapOptionInteractionVariants())}>
<ChevronRight <ChevronRight
size={14} size={14}
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90" className="shrink-0 text-inherit transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span> <span className="block truncate text-xs font-semibold">{group.label}</span>
{group.description ? ( {group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description} {group.description}
@@ -169,18 +169,19 @@ function LayerVisibilityRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`} aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta} title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1", "flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5" ? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75", : "border-transparent bg-[var(--surface-control)]/55 text-slate-600",
disabled && "cursor-not-allowed opacity-55 active:scale-100" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<LayerVisual visual={item.visual} visible={item.visible} /> <LayerVisual visual={item.visual} visible={item.visible} />
@@ -195,7 +196,7 @@ function LayerVisibilityRow({
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none", "grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -297,18 +298,18 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME, active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -1,4 +1,4 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { cva } from "class-variance-authority";
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl"; export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
@@ -28,5 +28,27 @@ export const MAP_READABLE_SURFACE_CLASS_NAME =
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME = export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border"; "surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME = export const mapOptionInteractionVariants = cva(
buttonInteractionVariants({ tone: "neutral" }); "map-option-interaction outline-hidden transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55 data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
{
variants: {
state: {
default: "[@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] focus-visible:border-[var(--surface-border)] focus-visible:bg-[var(--surface-reading)] focus-visible:text-[var(--action-blue-hover)] data-[highlighted]:border-[var(--surface-border)] data-[highlighted]:bg-[var(--surface-reading)] data-[highlighted]:text-[var(--action-blue-hover)]",
selected: "[@media(hover:hover)]:hover:border-[var(--action-border-hover)] focus-visible:border-[var(--action-border-hover)] data-[highlighted]:border-[var(--action-border-hover)]",
primary: "[@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [@media(hover:hover)]:hover:text-white focus-visible:border-[var(--action-blue-hover)] focus-visible:bg-[var(--action-blue-hover)] focus-visible:text-white data-[highlighted]:border-[var(--action-blue-hover)] data-[highlighted]:bg-[var(--action-blue-hover)] data-[highlighted]:text-white",
danger: "[@media(hover:hover)]:hover:border-[var(--status-danger-border)] [@media(hover:hover)]:hover:bg-[var(--status-danger-soft)] [@media(hover:hover)]:hover:text-[var(--status-danger-foreground)] focus-visible:border-[var(--status-danger-border)] focus-visible:bg-[var(--status-danger-soft)] focus-visible:text-[var(--status-danger-foreground)] data-[highlighted]:border-[var(--status-danger-border)] data-[highlighted]:bg-[var(--status-danger-soft)] data-[highlighted]:text-[var(--status-danger-foreground)]"
}
},
defaultVariants: { state: "default" }
}
);
export const MAP_CONTROL_HOVER_CLASS_NAME = mapOptionInteractionVariants();
export const MAP_ACTION_ROW_INTERACTION_CLASS_NAME = [
MAP_COMPACT_RADIUS_CLASS_NAME,
"surface-control border border-transparent text-slate-700",
// Radix menu items receive focus while the pointer highlights a row.
"focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
].join(" ");
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react"; import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
@@ -91,16 +92,17 @@ export function MapMeasurePanel({
<button <button
key={mode.id} key={mode.id}
type="button" type="button"
data-map-option=""
aria-pressed={mode.id === activeModeId} aria-pressed={mode.id === activeModeId}
disabled={mode.disabled} disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)} onClick={() => onSelectMode?.(mode.id)}
className={cn( className={cn(
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId mode.id === activeModeId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]", : "text-slate-600",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100" mapOptionInteractionVariants({ state: mode.id === activeModeId ? "selected" : "default" })
)} )}
> >
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" /> <mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
@@ -134,14 +136,16 @@ export function MapMeasurePanel({
<button <button
key={unit.id} key={unit.id}
type="button" type="button"
data-map-option=""
aria-pressed={unit.id === activeUnitId} aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)} onClick={() => onSelectUnit?.(unit.id)}
className={cn( className={cn(
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "h-10 min-w-10 touch-manipulation border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId unit.id === activeUnitId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-600",
mapOptionInteractionVariants({ state: unit.id === activeUnitId ? "selected" : "default" })
)} )}
> >
{unit.label} {unit.label}
@@ -228,28 +232,27 @@ function MeasureActionButton({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
title={action.description} title={action.description}
data-state={active ? "active" : undefined} data-state={active ? "active" : undefined}
disabled={action.disabled} disabled={action.disabled}
onClick={action.onClick} onClick={action.onClick}
className={cn( className={cn(
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none", "flex w-full touch-manipulation items-center gap-2 font-semibold",
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs", primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
primary primary
? active ? active
? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70" ? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)]"
: action.variant === "primary" : "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-xs"
? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn( : cn(
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
danger danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50" ? "text-[var(--status-danger-foreground)]"
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-700"
), ),
action.disabled && mapOptionInteractionVariants({ state: danger ? "danger" : active ? "selected" : primary ? "primary" : "default" }),
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700" action.disabled && "shadow-none"
)} )}
> >
{primary ? ( {primary ? (
@@ -258,7 +261,7 @@ function MeasureActionButton({
"grid h-8 w-8 shrink-0 place-items-center rounded-lg", "grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active active
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]" : "bg-[var(--surface-reading)]/16 text-white"
)} )}
> >
<Icon size={15} strokeWidth={2.2} aria-hidden="true" /> <Icon size={15} strokeWidth={2.2} aria-hidden="true" />
@@ -269,7 +272,7 @@ function MeasureActionButton({
{primary ? ( {primary ? (
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span> <span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]"> <span className={cn("mt-0.5 block truncate text-[10px] font-medium leading-3", active ? "text-[var(--ink-muted)]" : "text-white/85")}>
{action.description} {action.description}
</span> </span>
</span> </span>
+3 -2
View File
@@ -8,6 +8,7 @@ import {
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -70,9 +71,9 @@ export function MapToolbar({
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700", "relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tool.active tool.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? cn("border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_CONTROL_HOVER_CLASS_NAME, : MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0" tool.disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
<Icon size={20} strokeWidth={2.15} aria-hidden="true" /> <Icon size={20} strokeWidth={2.15} aria-hidden="true" />
+3 -2
View File
@@ -10,6 +10,7 @@ import {
} from "./camera-actions"; } from "./camera-actions";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -158,9 +159,9 @@ function ControlButton({
"grid h-10 w-10 place-items-center border border-transparent", "grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700", "bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME, pressed ? mapOptionInteractionVariants({ state: "selected" }) : MAP_CONTROL_HOVER_CLASS_NAME,
pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]", pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]" disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content} {Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
+2
View File
@@ -29,6 +29,8 @@ export {
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel"; export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend"; export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export { export {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
@@ -0,0 +1,19 @@
import { z } from "zod";
export const investigationNodeAnnotations = ["reference", "recorded", "needs_input", "failed", "not_taken"] as const;
export type InvestigationNodeAnnotation = (typeof investigationNodeAnnotations)[number];
const legacyAnnotations = {
pending: "reference", running: "reference", completed: "recorded",
waiting: "needs_input", waiting_human: "needs_input", skipped: "not_taken", cancelled: "not_taken"
} as const satisfies Record<string, InvestigationNodeAnnotation>;
export function normalizeInvestigationNodeAnnotation(status: string | null | undefined): InvestigationNodeAnnotation {
if (status == null) return "reference";
if (status in legacyAnnotations) return legacyAnnotations[status as keyof typeof legacyAnnotations];
return investigationNodeAnnotations.includes(status as InvestigationNodeAnnotation) ? status as InvestigationNodeAnnotation : "reference";
}
/** Decode saved diagrams without rewriting historical documents. */
export const investigationNodeAnnotationReadSchema = z.enum([
...investigationNodeAnnotations, "pending", "running", "completed", "waiting", "waiting_human", "skipped", "cancelled"
]).nullish().transform((status) => status == null ? status : normalizeInvestigationNodeAnnotation(status));
@@ -59,14 +59,24 @@ describe("investigation report read contract", () => {
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]); expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
expect(value.data_gaps?.[0]?.node_id).toBeNull(); expect(value.data_gaps?.[0]?.node_id).toBeNull();
}); });
it("distinguishes waiting for dependencies from human feedback without inventing missing states", () => { it("reads legacy waits as saved input conditions without inferring live waiting", () => {
const value = investigationReportSchema.parse({ ...base, diagram: { const value = investigationReportSchema.parse({ ...base, diagram: {
title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]", title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]",
node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" }, node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" },
{ node_id: "B", label: "人工复核", status: "waiting_human" }, { node_id: "B", label: "人工复核", status: "waiting_human" },
{ node_id: "C", label: "下一步" }] { node_id: "C", label: "下一步" }]
} }); } });
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["waiting", "waiting_human", undefined]); expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["needs_input", "needs_input", undefined]);
});
it("reads host-generated cumulative graphs with failed attempts and failure reasons", () => {
const value = investigationReportSchema.parse({ ...base, diagram: {
title: "累计调查脉络", mermaid: 'flowchart TD\nold["首次读取(失败)"] --> retry["重新读取"]',
node_refs: [{ node_id: "old", label: "首次读取", status: "failed", detail: "接口超时" },
{ node_id: "retry", label: "重新读取", status: "completed" }],
edges: [{ source: "old", target: "retry", label: "重试" }]
} });
expect(value.diagram?.node_refs[0]).toMatchObject({ status: "failed", detail: "接口超时" });
expect(value.diagram?.edges).toEqual([{ source: "old", target: "retry", label: "重试" }]);
}); });
it.each([ it.each([
{ outcome: "other" }, { outcome: "other" },
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentTaskDiscussionContext, AnswerUiSelection } from "@/features/agent"; import type { AgentTaskDiscussionContext, AnswerUiSelection } from "@/features/agent";
import { reportUiBlocksSchema } from "@/features/agent"; import { reportUiBlocksSchema } from "@/features/agent";
import { investigationNodeAnnotationReadSchema } from "./investigation-node-state";
export type InvestigationDiscussion = { export type InvestigationDiscussion = {
prompt?: string; prompt?: string;
@@ -28,13 +29,20 @@ export const investigationDiagramSchema = z
!/(?:^|[;\n])\s*click\b/.test(source), !/(?:^|[;\n])\s*click\b/.test(source),
"调查图只接受不含指令或回调的 Mermaid 流程描述" "调查图只接受不含指令或回调的 Mermaid 流程描述"
), ),
edges: z.array(z.object({
source: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
target: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text.max(200).nullish()
}).strict()).max(200).nullish(),
source_mermaid: text.max(20000).nullish(),
node_refs: z node_refs: z
.array( .array(
z z
.object({ .object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/), node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text, label: text,
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish(), detail: text.max(2000).nullish(),
status: investigationNodeAnnotationReadSchema,
map_targets: z.array(investigationMapTargetSchema).max(100).optional(), map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
evidence_refs: z.array(text).default([]), evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]) action_refs: z.array(z.string().uuid()).default([])
@@ -77,7 +85,8 @@ export const investigationReportSchema = z
stop_reason: text.nullish(), stop_reason: text.nullish(),
data_gaps: z.array(dataGapSchema).nullish(), data_gaps: z.array(dataGapSchema).nullish(),
diagram: investigationDiagramSchema.nullish(), diagram: investigationDiagramSchema.nullish(),
ui_blocks: reportUiBlocksSchema.optional() ui_blocks: reportUiBlocksSchema.optional(),
scada_device_ids: z.array(text).max(1000).nullish()
}) })
.strict() .strict()
.superRefine((report, ctx) => { .superRefine((report, ctx) => {
@@ -175,7 +175,7 @@ describe("operational task contract", () => {
const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session", const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session",
progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } }; progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } };
const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress }); const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress });
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("running"); expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("reference");
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1"); expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1");
expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow(); expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow();
}); });
@@ -1,5 +1,6 @@
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react"; import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react"; import { useId, useLayoutEffect, useRef } from "react";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
@@ -8,7 +9,7 @@ import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-sea
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]"; const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) { function PlaceSummary({ item, index, annotation = item.district, interactive = false }: { item: PlaceSearchItem; index?: number; annotation?: string; interactive?: boolean }) {
return ( return (
<> <>
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]"> {index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
@@ -16,7 +17,7 @@ function PlaceSummary({ item, index, annotation = item.district }: { item: Place
</span> : null} </span> : null}
<span className="min-w-0 flex-1 space-y-1"> <span className="min-w-0 flex-1 space-y-1">
<span className="flex items-start justify-between gap-2 leading-5"> <span className="flex items-start justify-between gap-2 leading-5">
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span> <span className={cn("min-w-0 flex-1 break-words font-semibold", !interactive && "text-[var(--ink)]")}>{item.name}</span>
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span> <span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
</span> </span>
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span> <span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
@@ -123,16 +124,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}> {!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => ( {results.items.map((item, index) => (
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0"> <li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft" <button type="button" aria-pressed={result?.id === item.id}
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled} aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]", className={cn("flex min-h-16 w-full items-center justify-start gap-3 rounded-xl border border-transparent whitespace-normal px-1 py-3 text-left text-xs text-[var(--ink)]",
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")} mapOptionInteractionVariants({ state: result?.id === item.id ? "selected" : "default" }),
result?.id === item.id && "border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]")}
onClick={(event) => { onClick={(event) => {
geocoding.select(item.id); geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus()); if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}> }}>
<PlaceSummary item={item} index={index} /> <PlaceSummary item={item} index={index} interactive />
</Button> </button>
</li> </li>
))} ))}
</ul> : null} </ul> : null}
@@ -0,0 +1,105 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Check, FileText, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { DeferredRender } from "@/shared/ui/deferred-render";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
group: ChartGroup;
tracked?: boolean;
tile?: boolean;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const card = useRef<HTMLDivElement>(null);
const [plotHeight, setPlotHeight] = useState(220);
useLayoutEffect(() => {
if (tile) return;
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
if (!viewport || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
resize();
const observer = new ResizeObserver(resize);
observer.observe(viewport);
return () => observer.disconnect();
}, [tile]);
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
const multiple = group.items.length > 1;
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
const gaps = selected.command.chart.scada?.gaps ?? [];
const missingCurrent = gaps.filter((gap) => gap.day_offset === 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
const missingHistory = gaps.filter((gap) => gap.day_offset > 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
const gapNotice = gaps.length ? <p role="status" aria-label="监测数据缺口" className="shrink-0 text-xs text-muted-foreground">
{[missingCurrent ? `当前缺测 ${missingCurrent} 点` : "", missingHistory ? `历史缺测 ${missingHistory} 点` : "",
gaps.some((gap) => gap.reason.startsWith("outside_frozen_")) ? "部分数据不在冻结范围内" : "",
gaps.some((gap) => gap.reason === "no_scheduled_samples") ? "窗口内无计划采样点" : ""].filter(Boolean).join(";")},已保留空槽。
</p> : null;
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
</div>
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
onClick={() => setSelectedId(item.command.chart_id)}>
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
</Button>)}
</div>
</aside>;
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
{gapNotice}
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
</Select>
</div> : null}
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
{multiple ? objectButtons(true) : null}
</div>
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
{gapNotice}
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
</div>
{multiple ? objectButtons() : null}
</div>
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
</div>;
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
<div className="min-w-0">
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
</div>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
</Popover> : null}
<FloatingWindow title={title} fillContent>{() => <DeferredRender label={`正在加载图表 ${title}`} className="h-full min-h-0">{content(true)}</DeferredRender>}</FloatingWindow>
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
</div>
</CardHeader>
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
<DeferredRender label={`正在加载图表 ${title}`} className={tile ? "h-full min-h-0" : undefined}>{content(tile, tile)}</DeferredRender>
</CardContent>
</Card>;
}
@@ -0,0 +1,120 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { DragDropProvider, DragOverlay, type DragEndEvent } from "@dnd-kit/react";
import { Columns2, GripVertical, LayoutGrid, LocateFixed, RotateCcw, Square } from "lucide-react";
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { useChartWindowLayout } from "../hooks/use-chart-window-layout";
import type { InvestigationChartGroup } from "../utils/investigation-chart-groups";
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
import { useChartWorkspace } from "../workspace/chart-workspace-context";
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle, reviewing, onCloseChart, references }: {
groups: InvestigationChartGroup[];
blocks: AnswerUiBlock[];
/** Transient live intent, never derived from restored chart data or history. */
autoOpen: boolean;
taskTitle: string;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const dock = useChartWorkspace();
const entries = useMemo<ManagedChart[]>(() => [
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
], [groups, blocks]);
const titles = useMemo(() => new Map(entries.map(({ id, title }) => [id, title])), [entries]);
const arrangement = useChartWindowLayout(entries.map(({ id }) => id));
const { layout, slots, columns } = arrangement;
const scrollRef = useRef<HTMLDivElement>(null);
const [located, setLocated] = useState<{ id: string } | null>(null);
const [announcement, setAnnouncement] = useState("");
const cards = useRef(new Map<string, HTMLElement>());
const pendingLocation = useRef<string | null>(null);
const helpId = useId();
useEffect(() => {
if (!located) return;
const timer = window.setTimeout(() => setLocated(null), 1600);
return () => window.clearTimeout(timer);
}, [located]);
const move = (id: string, target: number) => {
arrangement.move(id, target);
setAnnouncement(`${titles.get(id)},已移至位置 ${target + 1}`);
};
const drop = (event: DragEndEvent) => {
const { source, target } = event.operation;
if (!event.canceled && source && target && typeof target.data.slot === "number") move(String(source.id), target.data.slot);
};
const count = entries.length;
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
initialSize={{ width: 1120, height: 760 }} fillContent dock={dock}
// A session gets one automatic request. Updates never revoke a user's dismissal.
openRequestKey={autoOpen ? "live-charts" : undefined}>
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
<div className="shrink-0 space-y-2 border-b border-border pb-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖动交换位置,支持单图放大</p></div>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
</div>
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
<Select value="" onValueChange={(id) => { setLocated({ id }); pendingLocation.current = id; }} disabled={!count}>
<SelectTrigger aria-label="快速定位图表" className="h-8 w-auto min-w-0 max-w-72 flex-[1_1_160px] gap-2 border-transparent bg-transparent px-2 text-xs shadow-none [--field-focus-border:transparent] hover:bg-background/70 focus-visible:ring-1 focus-visible:ring-border [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"><LocateFixed size={14} className="shrink-0" aria-hidden /><SelectValue placeholder="快速定位图表" /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
onCloseAutoFocus={(event) => {
const id = pendingLocation.current;
pendingLocation.current = null;
const card = id ? cards.current.get(id) : undefined;
if (card) {
event.preventDefault();
card.scrollIntoView({ block: "start", inline: "nearest", behavior: "auto" });
card.focus({ preventScroll: true });
}
}}>
{slots.map((id, index) => id ? <SelectItem key={id} value={id} className="whitespace-normal text-xs [&>span:last-child]:break-words">位置 {index + 1} · {titles.get(id)}</SelectItem> : null)}
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div role="group" aria-label="图表排列方式" className="flex items-center gap-1">
{layouts.map(({ id, label, icon: Icon }) => <Button key={id} type="button" variant={layout === id ? "secondary" : "ghost"} size="icon-sm"
aria-label={label} title={label} aria-pressed={layout === id} onClick={() => arrangement.setLayout(id)}><Icon size={14} aria-hidden /></Button>)}
</div>
<span className="inline-flex border-l border-border pl-1">
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs" aria-label="重置图表排列" disabled={!count}
onClick={() => { arrangement.reset(); scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); setAnnouncement("已恢复默认图表位置"); }}><RotateCcw size={14} aria-hidden />重置</Button>
</span>
</div>
</div>
</div>
<p id={helpId} className="sr-only">拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。</p>
<p role="status" aria-live="polite" className="sr-only">{announcement}</p>
<DragDropProvider onDragEnd={drop}>
<div ref={scrollRef} tabIndex={0} aria-label="图表滚动区" className="min-h-0 flex-1 overflow-auto overscroll-contain p-0.5" style={{ containerType: "size", scrollbarGutter: "stable" }} data-chart-manager-scroll="">
{count ? <div data-chart-layout={layout} className={`isolate grid min-h-full min-w-0 content-start gap-3 ${layout === "four" ? "grid-cols-2 auto-rows-[max(256px,calc((100cqh-12px)/2))]" : layout === "two" ? "grid-cols-2 auto-rows-[max(256px,100cqh)]" : "grid-cols-1 auto-rows-[max(256px,100cqh)]"}`}>
{slots.map((id, index) => <ChartDropSlot key={index} index={index} columns={columns} occupied={Boolean(id)} />)}
{entries.map((entry) => {
const index = slots.indexOf(entry.id);
return <ManagedChartSlot key={entry.id} id={entry.id} title={entry.title} index={index} columns={columns}
located={located?.id === entry.id} helpId={helpId}
register={(id, node) => { if (node) cards.current.set(id, node); else cards.current.delete(id); }}>
{entry.group ? <ChartGroup group={entry.group} tile tracked={false} reviewing={reviewing} onCloseChart={onCloseChart} references={references} /> : <AnswerUiView block={entry.block} chartPresentation="tile" disabled={reviewing} />}
</ManagedChartSlot>;
})}
</div> : <p role="status" className="py-8 text-center text-sm text-muted-foreground">当前没有图表,新的图表将在这里更新。</p>}
</div>
<DragOverlay dropAnimation={null} style={{ position: "absolute", zIndex: 100 }}>
{(source) => <div data-chart-drag-preview="" className="workbench-shadcn-surface flex max-w-80 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground shadow-xs"><GripVertical size={14} className="shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 break-words">{String(source.data.title)}</span></div>}
</DragOverlay>
</DragDropProvider>
</section>}
</FloatingWindow>
</span>;
}
@@ -0,0 +1,44 @@
import { useDraggable, useDroppable } from "@dnd-kit/react";
import { pointerIntersection } from "@dnd-kit/collision";
import { GripVertical } from "lucide-react";
import type { CSSProperties, ReactNode } from "react";
function chartSlotPosition(index: number, columns: number): CSSProperties {
return { gridRow: Math.floor(index / columns) + 1, gridColumn: index % columns + 1 };
}
export function ChartDropSlot({ index, columns, occupied }: { index: number; columns: number; occupied: boolean }) {
const { ref, isDropTarget } = useDroppable({ id: `slot:${index}`, type: "slot", accept: "chart", data: { slot: index }, collisionDetector: pointerIntersection });
return <div ref={ref} data-chart-slot={index} style={chartSlotPosition(index, columns)}
className={`pointer-events-none relative flex min-h-0 min-w-0 items-center justify-center rounded-xl border border-dashed ${isDropTarget ? "z-10 border-primary/40 bg-primary/5 ring-1 ring-primary/15 ring-inset" : occupied ? "border-transparent" : "border-border bg-muted/20"}`}>
{!occupied ? <p className="text-center text-xs leading-6 text-muted-foreground"><span className="block tabular-nums">位置 {index + 1}</span>拖动图表到这里</p> : null}
</div>;
}
/** The chart stays under the same grid parent when its CSS position changes. */
export function ManagedChartSlot({ id, title, index, columns, located, helpId, register, children }: {
id: string;
title: string;
index: number;
columns: number;
located: boolean;
helpId: string;
register: (id: string, element: HTMLElement | null) => void;
children: ReactNode;
}) {
const { ref, handleRef, isDragSource } = useDraggable({ id, type: "chart", data: { title } });
return <article ref={(node) => { ref(node); register(id, node); }} tabIndex={-1}
data-managed-chart={id} data-chart-title={title} data-slot-index={index} data-chart-located={located} data-chart-dragging={isDragSource}
aria-label={`图表窗口 ${title}`} style={chartSlotPosition(index, columns)}
className="group/chart-slot relative z-0 flex min-h-0 min-w-0 flex-col rounded-xl outline-hidden">
<div className={`flex h-9 shrink-0 items-center rounded-lg ${located ? "bg-primary/10" : ""}`}>
<button ref={handleRef} type="button" data-slot="button" className={`flex h-full w-full touch-none select-none items-center justify-start gap-1 rounded-lg border border-transparent px-2 text-xs font-normal outline-hidden focus-visible:outline-1 focus-visible:outline-ring focus-visible:-outline-offset-1 active:cursor-grabbing ${isDragSource ? "cursor-grabbing bg-muted text-foreground" : "cursor-grab bg-transparent text-muted-foreground [@media(hover:hover)]:hover:bg-muted/70 [@media(hover:hover)]:hover:text-foreground"}`}
aria-label={`拖动图表 ${title}`} aria-describedby={helpId}>
<GripVertical size={14} aria-hidden /><span className="tabular-nums">位置 {index + 1}</span>
{located ? <span aria-hidden="true" className="ml-auto shrink-0 text-xs font-medium text-primary">已定位</span> : null}
</button>
</div>
<div className="min-h-0 min-w-0 flex-1">{children}</div>
<div aria-hidden="true" data-chart-hover-highlight="" className={`pointer-events-none absolute inset-0 z-10 rounded-xl bg-primary/[0.04] ${isDragSource ? "opacity-100" : "opacity-0 [@media(hover:hover)]:group-hover/chart-slot:opacity-100"}`} />
</article>;
}
@@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react"; import { cloneElement, isValidElement, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type SVGProps } from "react";
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message"; import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { LoadingSkeleton } from "@/shared/ui/deferred-render";
import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report"; import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report";
import { InvestigationExpandCard } from "./investigation-expand-card"; import { InvestigationExpandCard } from "./investigation-expand-card";
import { import {
@@ -48,34 +49,62 @@ function DiagramUnavailable() {
export function InvestigationDiagramView({ export function InvestigationDiagramView({
diagram, diagram,
renderDetails, renderDetails,
running = false,
onLocate onLocate
}: { }: {
diagram: InvestigationDiagram; diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode; renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
running?: boolean;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void; onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
}) { }) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [hasRendered, setHasRendered] = useState(false);
const source = styleInvestigationDiagram(diagram);
const latestSource = useRef({ source, revision: 0 });
if (latestSource.current.source !== source) latestSource.current = { source, revision: latestSource.current.revision + 1 };
const components = useMemo(() => ({ pre: ({ children }: { children?: ReactNode }) => {
if (!isValidElement<{ className?: string; "data-block"?: string }>(children)) return children;
// Streamdown memoizes code by class and source position. A same-line edit
// needs a new revision class to update the mounted Mermaid without a reset.
return cloneElement(children, { "data-block": "true",
className: `${children.props.className ?? ""} investigation-diagram-revision-${latestSource.current.revision}` });
} }), []);
const plugins = useMemo(() => ({ mermaid: {
...investigationDiagramPlugins.mermaid,
getMermaid(config?: Parameters<typeof investigationDiagramPlugins.mermaid.getMermaid>[0]) {
const renderer = investigationDiagramPlugins.mermaid.getMermaid(config);
return { ...renderer, async render(id: string, nextSource: string) {
const result = await renderer.render(id, nextSource);
// Keep the mounted graph during refresh without allowing a late layout
// to overwrite a newer source (including an immediately cached result).
// Markdown code blocks append a newline to their text content.
if (nextSource.trimEnd() !== latestSource.current.source.trimEnd()) throw new Error("调查图示已更新,忽略旧排版结果");
return result;
} };
}
} }), []);
const diagramRoot = useRef<HTMLDivElement>(null); const diagramRoot = useRef<HTMLDivElement>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null); const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId); const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))]; const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node)))];
if (!displayedStates.length) displayedStates.push("unknown"); if (!displayedStates.length) displayedStates.push("reference");
const [fontsReady, setFontsReady] = useState( const [fontsReady, setFontsReady] = useState(
() => typeof document === "undefined" || !document.fonts () => typeof document === "undefined" || !document.fonts
); );
useLayoutEffect(() => { useLayoutEffect(() => {
const root = diagramRoot.current; const root = diagramRoot.current;
if (!root) return; if (!root) return;
const paint = () => paintInvestigationDiagramStates(root, diagram, running); const paint = () => {
paintInvestigationDiagramStates(root, diagram);
if (root.querySelector("[data-investigation-renderer] svg, [data-investigation-renderer] [role=status]")) {
setHasRendered(true);
}
};
paint(); paint();
// Mermaid renders asynchronously. Reapply the latest snapshot when it // Mermaid renders asynchronously. Reapply the latest snapshot when it
// replaces the SVG; observing children avoids reacting to our own styles. // replaces the SVG; observing children avoids reacting to our own styles.
const observer = new MutationObserver(paint); const observer = new MutationObserver(paint);
observer.observe(root, { childList: true, subtree: true }); observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect(); return () => observer.disconnect();
}, [diagram, running]); }, [diagram]);
useEffect(() => { useEffect(() => {
if (!document.fonts) return; if (!document.fonts) return;
let active = true; let active = true;
@@ -92,11 +121,11 @@ export function InvestigationDiagramView({
const content = (close: (restoreFocus?: boolean) => void) => ( const content = (close: (restoreFocus?: boolean) => void) => (
<> <>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]"> <p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
核查方向与判断分支。可放大查看,点击节点阅读关联依据。 调查路线与分支参考,标记来自最近整理,不实时同步执行状态。点击节点查看说明与依据。
</p> </p>
<div <div
ref={diagramRoot} ref={diagramRoot}
className="investigation-diagram mt-3" className="investigation-diagram relative mt-3"
onPointerDownCapture={(event) => { onPointerDownCapture={(event) => {
const node = (event.target as Element).closest?.(".node"); const node = (event.target as Element).closest?.(".node");
const ref = diagram.node_refs.find((item) => getNodeId(node) === item.node_id); const ref = diagram.node_refs.find((item) => getNodeId(node) === item.node_id);
@@ -116,11 +145,14 @@ export function InvestigationDiagramView({
if (ref) setSelectedNodeId(ref.node_id); if (ref) setSelectedNodeId(ref.node_id);
}} }}
> >
{!hasRendered ? <LoadingSkeleton label="正在加载调查流程图" className="border-0" /> : null}
<div data-investigation-renderer="" aria-hidden={!hasRendered || undefined}
className={hasRendered ? undefined : "pointer-events-none absolute inset-x-0 top-0 opacity-0"}>
{fontsReady ? ( {fontsReady ? (
<MessageResponse <MessageResponse
key={diagram.mermaid}
icons={diagramControlIcons} icons={diagramControlIcons}
plugins={investigationDiagramPlugins} plugins={plugins}
components={components}
mermaid={{ mermaid={{
errorComponent: DiagramUnavailable, errorComponent: DiagramUnavailable,
config: investigationDiagramConfig config: investigationDiagramConfig
@@ -128,10 +160,9 @@ export function InvestigationDiagramView({
controls={{ controls={{
mermaid: { fullscreen: false, download: false, copy: false, panZoom: true } mermaid: { fullscreen: false, download: false, copy: false, panZoom: true }
}} }}
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram, running)}\n\`\`\``}</MessageResponse> >{`\`\`\`mermaid\n${source}\n\`\`\``}</MessageResponse>
) : ( ) : null}
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p> </div>
)}
</div> </div>
<div <div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]" className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
@@ -165,7 +196,7 @@ export function InvestigationDiagramView({
} }
> >
{node.label} {node.label}
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span> <span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node)].label}</span>
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""} {node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
</button> </button>
))} ))}
@@ -177,7 +208,8 @@ export function InvestigationDiagramView({
aria-label={`节点详情 ${selected.label}`} aria-label={`节点详情 ${selected.label}`}
> >
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p> <p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p> <p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected)].label}</p>
{selected.detail ? <p className="whitespace-pre-wrap break-words text-xs leading-5 text-[var(--ink-muted)]">{selected.detail}</p> : null}
{renderDetails(selected)} {renderDetails(selected)}
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null} {onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
</div> </div>
@@ -1,11 +1,11 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react"; import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent"; import type { AnswerUiBlock } from "@/features/agent";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card"; import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { LoadingSkeleton } from "@/shared/ui/deferred-render";
import { import {
PresentationItem, PresentationItem,
PresentationViewport, PresentationViewport,
@@ -19,7 +19,9 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation"; import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation"; import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { InvestigationChartManager } from "./investigation-chart-manager";
import { InvestigationChartGroup } from "./investigation-chart-group";
import { groupInvestigationCharts } from "../utils/investigation-chart-groups";
import { PresentationHistorySwitcher } from "./presentation-history-switcher"; import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results"; import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history"; import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -36,6 +38,8 @@ type Props = {
task: OperationalTask | null; task: OperationalTask | null;
localPreview?: boolean; localPreview?: boolean;
charts: InvestigationChart[]; charts: InvestigationChart[];
chartWindowAutoOpen?: boolean;
chartWindowScope?: string;
overlay: InvestigationOverlay | null; overlay: InvestigationOverlay | null;
cameraPaused: boolean; cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error"; status: "connecting" | "live" | "history" | "disconnected" | "error";
@@ -68,6 +72,8 @@ export function InvestigationPresentationPanel({
onLocateTargets, onLocateTargets,
localPreview = false, localPreview = false,
charts, charts,
chartWindowAutoOpen = false,
chartWindowScope,
overlay, overlay,
cameraPaused, cameraPaused,
status, status,
@@ -80,15 +86,18 @@ export function InvestigationPresentationPanel({
onFitOverlay, onFitOverlay,
onToggleLayer onToggleLayer
}: Props) { }: Props) {
const chartGroups = groupInvestigationCharts(charts);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [keyboardAction, setKeyboardAction] = useState(false); const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const immediate = Boolean(reducedMotion) || keyboardAction; const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId(); const contentId = useId();
const reviewing = history?.selectedId != null; const reviewing = history?.selectedId != null;
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]); const selected = history?.mergedEntry?.id === history?.selectedId
const selected = history?.entries.find((entry) => entry.id === history.selectedId); ? history?.mergedEntry
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0; : history?.entries.find((entry) => entry.id === history.selectedId);
const selectedLastId = selected?.mergedStepIds?.at(-1) ?? selected?.id;
const pending = selectedLastId !== undefined ? history!.entries.filter((entry) => entry.id > selectedLastId).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示"; const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
const references = (ids: string[]) => const references = (ids: string[]) =>
ids.length ? ( ids.length ? (
@@ -121,7 +130,7 @@ export function InvestigationPresentationPanel({
<motion.header <motion.header
layout={immediate || localPreview ? false : "position"} layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)} transition={presentationTransition(immediate)}
className={`shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`} className={`@container shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
> >
<div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}> <div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
<div className={localPreview ? "contents" : "min-w-0 space-y-1"}> <div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
@@ -137,7 +146,7 @@ export function InvestigationPresentationPanel({
: statusLabels[status]} : statusLabels[status]}
</Badge> </Badge>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length} 图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span> </span>
{reviewing && pending > 0 ? ( {reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground"> <span role="status" className="text-[11px] text-muted-foreground">
@@ -146,6 +155,10 @@ export function InvestigationPresentationPanel({
) : null} ) : null}
</div> </div>
</div> </div>
<div className={`flex shrink-0 items-center gap-1 ${localPreview ? "col-start-2 row-start-1" : ""}`}>
<InvestigationChartManager key={chartWindowScope ?? JSON.stringify([scope, task?.task_id, task?.execution_generation])}
groups={chartGroups} blocks={blocks} autoOpen={chartWindowAutoOpen} taskTitle={task?.title ?? title}
reviewing={reviewing} onCloseChart={onCloseChart} references={references} />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -162,6 +175,7 @@ export function InvestigationPresentationPanel({
</motion.span> </motion.span>
</Button> </Button>
</div> </div>
</div>
<p <p
className={ className={
history?.entries.length history?.entries.length
@@ -180,6 +194,9 @@ export function InvestigationPresentationPanel({
: "跟随所选调查,展示地图操作与结果"} : "跟随所选调查,展示地图操作与结果"}
</p> </p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null} {history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
{selected?.mergedSummary ? <p aria-label="合并展示类别" className="sr-only">
合并内容:{selected.mergedSummary}
</p> : null}
</motion.header> </motion.header>
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{!collapsed ? ( {!collapsed ? (
@@ -201,6 +218,9 @@ export function InvestigationPresentationPanel({
{error} {error}
</p> </p>
) : null} ) : null}
{!reviewing && !localPreview && status === "connecting" && !charts.length && !blocks.length ? (
<LoadingSkeleton label="正在加载调查展示" />
) : null}
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{cameraPaused ? ( {cameraPaused ? (
<PresentationItem key="camera" immediate={immediate}> <PresentationItem key="camera" immediate={immediate}>
@@ -242,7 +262,7 @@ export function InvestigationPresentationPanel({
</div> </div>
</section> </section>
) : null} ) : null}
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence key={chartWindowScope} initial={false} mode="popLayout">
{overlay ? ( {overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}> <PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none"> <Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
@@ -291,51 +311,10 @@ export function InvestigationPresentationPanel({
</Card> </Card>
</PresentationItem> </PresentationItem>
) : null} ) : null}
{charts.map((item) => ( {chartGroups.map((group) => (
<PresentationItem key={item.chart_id} immediate={immediate}> <PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
<Card <InvestigationChartGroup group={group} reviewing={reviewing ?? false}
data-investigation-chart={item.chart_id} onCloseChart={onCloseChart} references={references} />
role="group"
aria-label={formatChartDisplayText(item.chart.title)}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{formatChartDisplayText(item.chart.title)}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded />
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem> </PresentationItem>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -343,13 +322,13 @@ export function InvestigationPresentationPanel({
blocks={blocks} blocks={blocks}
records={records} records={records}
reviewing={reviewing} reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets} onLocateTargets={onLocateTargets}
/> />
{!charts.length && {!charts.length &&
!overlay && !overlay &&
!blocks.length && !blocks.length &&
!records.length && !records.length &&
status !== "connecting" &&
!relatedLayers.length ? ( !relatedLayers.length ? (
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
当前没有展示结果,可切换历史动作回看地图与结果。 当前没有展示结果,可切换历史动作回看地图与结果。
@@ -181,10 +181,11 @@ export function InvestigationTaskReport({
// progress snapshot owns its whole graph, including an explicit null. A prior // progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress. // round's final report must never hide the new round's progress.
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot); const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
const savedDiagram = task.latest_progress?.progress.diagram;
const cumulativeDiagram = savedDiagram?.edges != null ? savedDiagram : null;
const diagram = live const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot) ? cumulativeDiagram ?? (progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot))
: selection.diagram; : selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const latestAvailable = !live; const latestAvailable = !live;
const { currentInsufficient } = getReportPresentationState(task, task.latest_report); const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
const pendingActions = task.actions.filter(isPendingInvestigationAction); const pendingActions = task.actions.filter(isPendingInvestigationAction);
@@ -531,7 +532,6 @@ export function InvestigationTaskReport({
<InvestigationDiagramView <InvestigationDiagramView
key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`} key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
diagram={diagram} diagram={diagram}
running={diagramRunning}
onLocate={onLocate} onLocate={onLocate}
renderDetails={(node) => ( renderDetails={(node) => (
<> <>
@@ -7,7 +7,6 @@ type Props = {
blocks: AnswerUiBlock[]; blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>; records: Array<Record<string, unknown>>;
reviewing: boolean; reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>; onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
}; };
@@ -15,7 +14,6 @@ export function PresentationBusinessResults({
blocks, blocks,
records, records,
reviewing, reviewing,
chartWindowKey,
onLocateTargets onLocateTargets
}: Props) { }: Props) {
if (!blocks.length && !records.length) return null; if (!blocks.length && !records.length) return null;
@@ -26,7 +24,6 @@ export function PresentationBusinessResults({
key={block.block_id} key={block.block_id}
block={block} block={block}
disabled={reviewing} disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={ onLocate={
onLocateTargets && !reviewing onLocateTargets && !reviewing
? (target) => ? (target) =>
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal,
DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { groupAdjacentPresentationHistory, presentationHistoryStepLabel, type PresentationHistoryGroup } from "../utils/presentation-history-categories";
import type { PresentationHistoryControls, PresentationHistoryEntry } from "../utils/presentation-view-history";
const compactQuery = "(max-width: 639px)";
const compactSnapshot = () => window.matchMedia(compactQuery).matches;
function subscribeCompact(listener: () => void) {
const media = window.matchMedia(compactQuery);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
const rowClass = "min-h-8 gap-2 text-xs transition-none active:bg-accent";
export function PresentationHistoryMenu({ entries, selectedId, mergedEntry, mergedEntries, onSelect }: PresentationHistoryControls) {
const [open, setOpen] = useState(false);
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
const compact = useSyncExternalStore(subscribeCompact, compactSnapshot, () => false);
const contentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const choices = mergedEntries ?? (mergedEntry ? [mergedEntry] : []);
const groups = groupAdjacentPresentationHistory(entries);
const activeGroup = groups.find((group) => group.id === activeGroupId);
const selected = mergedEntry?.id === selectedId ? mergedEntry
: choices.find((entry) => entry.id === selectedId) ?? entries.find((entry) => entry.id === selectedId);
const triggerLabel = selected?.mergedStepIds ? selected.label
: selected ? `${entries.indexOf(selected) + 1}. ${presentationHistoryStepLabel(selected)}` : "当前(最新)";
useEffect(() => {
if (!compact || !open) return;
const frame = requestAnimationFrame(() => contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
return () => cancelAnimationFrame(frame);
}, [activeGroupId, compact, open]);
const close = () => { setOpen(false); setActiveGroupId(null); setExpandedGroupId(null); };
const select = (id: number | null) => { onSelect(id); close(); };
const items = (group: PresentationHistoryGroup) => <GroupHistoryItems
group={group} entries={entries} selectedId={selectedId} onSelect={select} />;
return <DropdownMenu modal={false} open={open} onOpenChange={(next) => { if (next) setOpen(true); else close(); }}>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} type="button" variant="outline" aria-label="切换展示动作" title={selected?.label ?? triggerLabel}
className="form-field h-8 min-w-0 flex-1 justify-between gap-2 px-2 text-xs font-normal">
<span className="min-w-0 truncate">{triggerLabel}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent ref={contentRef} align="start" sideOffset={4} collisionPadding={12}
onPointerDownOutside={(event) => {
// The trigger owns toggling. An exiting menu must not dismiss a fresh
// open from the same pointer event while its closing animation finishes.
if (event.target instanceof Node && triggerRef.current?.contains(event.target)) event.preventDefault();
}}
aria-label={compact && activeGroup ? `${activeGroup.label}展示步骤` : "展示类别"} aria-labelledby={undefined}
className={`workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)] p-1 ${compact ? "w-72" : "w-40"}`}>
{compact && activeGroup ? <>
<DropdownMenuItem className={rowClass} onSelect={(event) => { event.preventDefault(); setActiveGroupId(null); }}>
<ChevronLeft aria-hidden />返回类别
</DropdownMenuItem>
<DropdownMenuSeparator />
{items(activeGroup)}
</> : <>
<DropdownMenuItem className={rowClass} aria-current={selectedId === null ? "true" : undefined} onSelect={() => select(null)}>
<Radio aria-hidden />当前(最新)
{selectedId === null ? <Check className="ml-auto" aria-hidden /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
{groups.slice().reverse().map((group) => {
const view = choices.find((entry) => entry.id === group.id) ?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
const selectedGroup = selectedId === group.id || group.entries.some((entry) => entry.id === selectedId);
const row = <div className="flex items-center" data-history-group-row data-history-group-id={group.id}>
<DropdownMenuItem className={`${rowClass} min-w-0 flex-1 justify-between`} aria-label={group.label}
aria-current={selectedGroup ? "true" : undefined} disabled={!view}
title={`步骤 ${entries.indexOf(group.entries[0]) + 1}–${entries.indexOf(group.entries.at(-1)!) + 1}`}
onKeyDown={(event) => {
if (!compact && event.key === "ArrowRight") { event.preventDefault(); setExpandedGroupId(group.id); }
}} onSelect={() => { if (view) select(view.id); }}>
{group.label}{selectedGroup ? <Check aria-hidden /> : null}
</DropdownMenuItem>
{compact ? <DropdownMenuItem aria-label={`展开${group.label}步骤`} className={`${rowClass} size-8 justify-center px-2`}
onSelect={(event) => { event.preventDefault(); setActiveGroupId(group.id); }}>
<ChevronRight aria-hidden />
</DropdownMenuItem> : <DropdownMenuSubTrigger aria-label={`展开${group.label}步骤`}
className={`${rowClass} size-8 justify-center px-2 [&>svg]:ml-0`} />}
</div>;
return compact ? <div key={group.id}>{row}</div> : <DropdownMenuSub key={group.id}
open={expandedGroupId === group.id} onOpenChange={(next) => setExpandedGroupId((previous) => next ? group.id : previous === group.id ? null : previous)}>
{row}
<DropdownMenuPortal>
<DropdownMenuSubContent aria-label={`${group.label}展示步骤`} aria-labelledby={undefined} collisionPadding={12} sideOffset={4}
onFocusOutside={(event) => {
const target = event.target;
const parentMenu = contentRef.current;
// Radix focuses the parent menu between pointer targets. Both actions
// in this split row still belong to the same expanded category.
if (target instanceof Node && (target === parentMenu ||
parentMenu?.querySelector(`[data-history-group-id="${group.id}"]`)?.contains(target)))
event.preventDefault();
}}
className="workbench-shadcn-surface z-[91] max-h-(--radix-dropdown-menu-content-available-height) w-80 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain">
{items(group)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>;
})}
</>}
</DropdownMenuContent>
</DropdownMenu>;
}
function GroupHistoryItems({ group, entries, selectedId, onSelect }: {
group: PresentationHistoryGroup;
entries: PresentationHistoryEntry[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
return group.entries.slice().reverse().map((entry) => <DropdownMenuItem key={entry.id}
className={`${rowClass} items-start`} disabled={entry.status === "running"}
aria-label={`${entries.indexOf(entry) + 1}. ${presentationHistoryStepLabel(entry)}`}
aria-current={entry.id === selectedId ? "true" : undefined} onSelect={() => onSelect(entry.id)}>
<span className="min-w-0 flex-1 break-words leading-5">{entries.indexOf(entry) + 1}. {presentationHistoryStepLabel(entry)}</span>
{entry.status === "running" ? <LoaderCircle className="mt-0.5 animate-spin" aria-label="进行中" />
: entry.status !== "completed" ? <CircleAlert className="mt-0.5 text-muted-foreground" aria-label={entry.status === "error" ? "未完成" : "已取消"} />
: entry.id === selectedId ? <Check className="mt-0.5" aria-hidden /> : null}
</DropdownMenuItem>);
}
@@ -1,11 +1,60 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest"; import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup"; import "@/test/radix-select-test-setup";
import { PresentationViewHistory } from "../utils/presentation-view-history"; import { PresentationViewHistory } from "../utils/presentation-view-history";
import { PresentationHistorySwitcher } from "./presentation-history-switcher"; import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
afterEach(cleanup); beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it.each(["error", "cancelled"] as const)("keeps navigation ordered when a %s step is selected", async (status) => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts" });
const failed = history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
history.finish(failed, status);
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
const onSelect = vi.fn();
const user = userEvent.setup();
render(<PresentationHistorySwitcher entries={history.entries} currentEntryId={history.currentEntryId}
selectedId={failed} mergedEntries={groups} onSelect={onSelect} />);
expect(screen.getByRole("button", { name: "上一个动作" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "上一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[0].id);
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
});
it("selects merged adjacent steps from their concise first-level title and lists only original steps below", async () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("打开图表 · 压力曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
history.record("打开图表 · 流量曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
history.record("SCADA 分析", "live", "SCADA 异常调查", { ...metadata, category: "scada" });
const mergedEntries = mergeAdjacentPresentationHistory(history.entries, "task", "SCADA 异常调查");
const onSelect = vi.fn();
const user = userEvent.setup();
render(<PresentationHistorySwitcher entries={history.entries} currentEntryId={history.entries[2].id}
selectedId={null} mergedEntry={mergedEntries[0]} mergedEntries={mergedEntries} onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: "切换展示动作" }));
const menu = screen.getByRole("menu", { name: "展示类别" });
expect([...menu.querySelectorAll("[data-history-group-row]")].map((item) => item.textContent)).toEqual(["SCADA 分析", "图表"]);
expect(screen.queryByText("合并查看")).not.toBeInTheDocument();
await user.click(within(menu).getByRole("menuitem", { name: "图表" }));
expect(onSelect).toHaveBeenLastCalledWith(mergedEntries[0].id);
await user.click(screen.getByRole("button", { name: "切换展示动作" }));
await user.click(screen.getByRole("menuitem", { name: "展开图表步骤" }));
const charts = screen.getByRole("menu", { name: "图表展示步骤" });
expect(within(charts).getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["2. 流量曲线", "1. 压力曲线"]);
within(charts).getByRole("menuitem", { name: "1. 压力曲线" }).focus();
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenLastCalledWith(history.entries[0].id);
});
function entries() { function entries() {
const history = new PresentationViewHistory(); const history = new PresentationViewHistory();
@@ -79,3 +128,39 @@ it("offers playback, speed, progress and keeps the latest snapshot pinned until
await user.click(screen.getByRole("button", { name: "回到当前" })); await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null); expect(onSelect).toHaveBeenCalledWith(null);
}); });
it("counts and navigates first-level groups, mapping an original step to its parent position", async () => {
const user = userEvent.setup();
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("chart one", "live", "调查", { ...metadata, category: "charts" });
const child = history.record("chart two", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
history.record("chart three", "live", "调查", { ...metadata, category: "charts" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
const onSelect = vi.fn();
const playback = { entries: groups, playing: false, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
const props = { entries: history.entries, currentEntryId: history.currentEntryId,
mergedEntries: groups, onSelect, playback };
const { rerender } = render(<PresentationHistorySwitcher {...props} selectedId={groups[0].id} mergedEntry={groups[0]} />);
const slider = screen.getByRole("slider", { name: "历史播放进度" });
expect(slider).toHaveAttribute("max", "2");
expect(slider).toHaveValue("0");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
expect(screen.getByRole("button", { name: "上一个动作" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
fireEvent.change(slider, { target: { value: "1" } });
expect(playback.onSeek).toHaveBeenLastCalledWith(groups[1].id);
rerender(<PresentationHistorySwitcher {...props} selectedId={child} />);
expect(slider).toHaveValue("0");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
rerender(<PresentationHistorySwitcher {...props} selectedId={groups[1].id} mergedEntry={groups[1]} />);
expect(slider).toHaveValue("1");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/3");
rerender(<PresentationHistorySwitcher {...props} selectedId={null} />);
expect(slider).toHaveValue("2");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("3/3");
});
@@ -2,6 +2,9 @@ import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history"; import type { PresentationHistoryControls } from "../utils/presentation-view-history";
import { groupAdjacentPresentationHistory } from "../utils/presentation-history-categories";
import { PresentationHistoryMenu } from "./presentation-history-menu";
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" }; const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
@@ -9,19 +12,28 @@ export function PresentationHistorySwitcher({
entries, entries,
currentEntryId, currentEntryId,
selectedId, selectedId,
mergedEntry,
mergedEntries,
onSelect, onSelect,
playback playback
}: PresentationHistoryControls) { }: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId); const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter( const playable = playback?.entries ?? (mergedEntries?.length ? mergedEntries : entries).filter((entry) => entry.status === "completed");
const grouped = entries.some((entry) => entry.taskId);
const available = grouped ? groupAdjacentPresentationHistory(entries).flatMap((group) => {
const view = mergedEntries?.find((entry) => entry.id === group.id)
?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
return view ? [{ id: view.id, stepIds: group.entries.map((entry) => entry.id) }] : [];
}) : entries.filter(
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId) (entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
); ).map((entry) => ({ id: entry.id, stepIds: [entry.id] }));
const index = const index =
selectedId === null selectedId === null
? available.length ? available.length
: available.findIndex((entry) => entry.id === selectedId); : available.findIndex((entry) => entry.id === selectedId ||
const playable = entries.filter((entry) => entry.status === "completed"); entry.stepIds.includes(selectedId));
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId)); const progress = selectedId === null ? Math.max(0, playable.length - 1)
: Math.max(0, playable.findIndex((entry) => entry.id === selectedId || entry.mergedStepIds?.includes(selectedId)));
return ( return (
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史"> <div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
@@ -36,7 +48,10 @@ export function PresentationHistorySwitcher({
> >
<ChevronLeft size={14} aria-hidden /> <ChevronLeft size={14} aria-hidden />
</Button> </Button>
<Select {entries.some((entry) => entry.taskId) ? (
<PresentationHistoryMenu entries={entries} currentEntryId={currentEntryId}
selectedId={selectedId} mergedEntry={mergedEntry} mergedEntries={mergedEntries} onSelect={onSelect} />
) : <Select
value={selectedId === null ? "current" : String(selectedId)} value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))} onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
> >
@@ -72,7 +87,7 @@ export function PresentationHistorySwitcher({
) )
)} )}
</SelectContent> </SelectContent>
</Select> </Select>}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -99,22 +114,26 @@ export function PresentationHistorySwitcher({
</div> </div>
{playback ? ( {playback ? (
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放"> <div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0" <Button type="button" variant="ghost" size="icon-sm" className="size-8 shrink-0 text-muted-foreground after:inset-0"
aria-label={playback.playing ? "暂停历史播放" : "播放历史"} aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
title={playback.playing ? "暂停历史播放" : "播放历史"}
disabled={!playable.length} onClick={playback.onToggle}> disabled={!playable.length} onClick={playback.onToggle}>
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />} {playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
</Button> </Button>
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default" <input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress} min={0} max={Math.max(0, playable.length - 1)} step={1} value={progress}
disabled={playable.length < 2} disabled={playable.length < 2}
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`} aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} /> onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置"> <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length} {playable.length ? progress + 1 : 0}/{playable.length}
</span> </span>
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}> <Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger> <SelectTrigger aria-label="历史播放速度"
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]"> className="form-field-inset h-8 w-[54px] shrink-0 justify-center gap-1 border-0 px-1.5 text-xs tabular-nums text-muted-foreground shadow-none hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground [&>svg]:size-3">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end" sideOffset={4} className="workbench-shadcn-surface z-[90] min-w-[72px]">
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)} {[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -216,18 +216,7 @@ export function ConditionDetailPanel({
); );
return ( return (
<AnimatePresence initial={false} mode="wait"> <div key={detailKey} className="relative h-full min-h-0">{content}</div>
<motion.div
key={detailKey}
className="relative h-full min-h-0"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
{content}
</motion.div>
</AnimatePresence>
); );
} }
@@ -4,6 +4,8 @@ import {
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
History, History,
Maximize2,
Minimize2,
RefreshCw, RefreshCw,
X, X,
XCircle XCircle
@@ -61,6 +63,7 @@ const CONDITION_CONTROL_SURFACE_CLASS_NAME =
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000; const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
export type ScheduledConditionFeedPresentation = export type ScheduledConditionFeedPresentation =
| "workspace"
| "desktop-dock" | "desktop-dock"
| "desktop-floating" | "desktop-floating"
| "mobile-sheet"; | "mobile-sheet";
@@ -82,6 +85,7 @@ type ScheduledConditionFeedProps = {
onSelectedConditionChange?: (conditionId: string | null) => void; onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void; onExpandAgent: () => void;
onClose?: () => void; onClose?: () => void;
reserveAgentRail?: boolean;
onLoadHistorySession: (sessionId: string) => void | Promise<void>; onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>; onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>; onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
@@ -124,6 +128,7 @@ export function ScheduledConditionFeed({
onSelectedConditionChange, onSelectedConditionChange,
onExpandAgent, onExpandAgent,
onClose, onClose,
reserveAgentRail = false,
onLoadHistorySession, onLoadHistorySession,
onSubmitPrompt, onSubmitPrompt,
onOpenTaskCollaboration, onOpenTaskCollaboration,
@@ -160,8 +165,10 @@ export function ScheduledConditionFeed({
const desktopDock = presentation === "desktop-dock"; const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock; const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating"; const desktopFloating = presentation === "desktop-floating";
const workspace = presentation === "workspace";
const expanded = controlledExpanded ?? uncontrolledExpanded; const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId; const selectedConditionId = controlledSelectedConditionId === undefined
? uncontrolledSelectedConditionId : controlledSelectedConditionId;
const conditionsByCategory = useMemo( const conditionsByCategory = useMemo(
() => groupConditionsByTaskCategory(conditions), () => groupConditionsByTaskCategory(conditions),
[conditions] [conditions]
@@ -219,17 +226,14 @@ export function ScheduledConditionFeed({
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`; : `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition = const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ?? timelineConditions.find((condition) => condition.id === selectedConditionId) ??
timelineConditions[0] ??
null; null;
const showCompactDetail = expanded && Boolean(selectedCondition);
const focusConditionId = focusRequest?.conditionId; const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId; const focusRequestId = focusRequest?.requestId;
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null); const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
const updateExpanded = useCallback( const updateExpanded = useCallback(
(nextExpanded: boolean) => { (nextExpanded: boolean) => {
if (controlledExpanded === undefined) { if (controlledExpanded === undefined) setUncontrolledExpanded(nextExpanded);
setUncontrolledExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded); onExpandedChange?.(nextExpanded);
}, },
[controlledExpanded, onExpandedChange] [controlledExpanded, onExpandedChange]
@@ -256,10 +260,7 @@ export function ScheduledConditionFeed({
}, [taskFilter, taskFilterOptions]); }, [taskFilter, taskFilterOptions]);
useEffect(() => { useEffect(() => {
if (!expanded) { if (!expanded) return;
return;
}
// A controlled task selection survives panel remounts and detail-loading windows. // A controlled task selection survives panel remounts and detail-loading windows.
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId); const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
if (selected) { if (selected) {
@@ -308,9 +309,6 @@ export function ScheduledConditionFeed({
function handleToggleExpanded() { function handleToggleExpanded() {
updateExpanded(!expanded); updateExpanded(!expanded);
updateSelectedConditionId(
selectedConditionId ?? timelineConditions[0]?.id ?? null
);
} }
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) { function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
@@ -318,9 +316,7 @@ export function ScheduledConditionFeed({
setTaskFilter(CONDITION_FILTER_ALL); setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(null); setRetainedScheduledCondition(null);
updateSelectedConditionId( updateSelectedConditionId(null);
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
} }
async function handleRefresh() { async function handleRefresh() {
@@ -360,9 +356,7 @@ export function ScheduledConditionFeed({
); );
} }
updateSelectedConditionId(conditionId); updateSelectedConditionId(conditionId);
if (!expanded) { if (!expanded) updateExpanded(true);
updateExpanded(true);
}
} }
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) { function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
@@ -427,22 +421,20 @@ export function ScheduledConditionFeed({
<section <section
aria-label="工况任务" aria-label="工况任务"
data-task-category={taskCategory} data-task-category={taskCategory}
data-presentation={presentation}
aria-hidden={!visible} aria-hidden={!visible}
className={cn( className={cn(
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none", "scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit", visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
mobileSheet workspace
? "surface-dock flex h-full w-full min-w-0 flex-col border-l border-[var(--surface-divider)] pb-16"
: mobileSheet
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none" ? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
: cn( : cn(
MAP_TOOL_PANEL_SURFACE_CLASS_NAME, MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
"max-w-[calc(100vw-7rem)]", "max-w-[calc(100vw-7rem)]",
desktopFloating desktopFloating
? cn( ? cn("rounded-2xl", expanded ? "h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]")
"rounded-2xl",
expanded
? "h-[calc(100dvh-8rem)]"
: "max-h-[calc(100dvh-8rem)]"
)
: "h-full rounded-l-2xl rounded-r-none", : "h-full rounded-l-2xl rounded-r-none",
expanded && !desktopDock expanded && !desktopDock
? "scheduled-feed-panel-expanded flex flex-col" ? "scheduled-feed-panel-expanded flex flex-col"
@@ -453,7 +445,8 @@ export function ScheduledConditionFeed({
<header <header
className={cn( className={cn(
"flex items-center gap-2.5 px-2.5 py-2", "flex items-center gap-2.5 px-2.5 py-2",
CONDITION_CONTROL_SURFACE_CLASS_NAME CONDITION_CONTROL_SURFACE_CLASS_NAME,
workspace && reserveAgentRail && "pl-20"
)} )}
> >
<span <span
@@ -486,18 +479,13 @@ export function ScheduledConditionFeed({
) : null} ) : null}
<button <button
type="button" type="button"
data-condition-dock-toggle
aria-expanded={expanded} aria-expanded={expanded}
title={expanded ? "收起工况任务" : "展开工况任务"}
onClick={handleToggleExpanded} onClick={handleToggleExpanded}
className={cn( className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)] active:scale-95 focus-visible:outline-2 focus-visible:outline-[var(--action-blue)]"
"grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
"rounded-lg"
)}
> >
<ChevronDown {expanded ? <Minimize2 size={17} aria-hidden="true" /> : <Maximize2 size={17} aria-hidden="true" />}
size={17}
aria-hidden="true"
className={cn("transition-transform duration-150", expanded && "rotate-180")}
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span> <span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button> </button>
{mobileSheet && onClose ? ( {mobileSheet && onClose ? (
@@ -513,8 +501,7 @@ export function ScheduledConditionFeed({
) : null} ) : null}
</header> </header>
{expanded ? ( {expanded ? <TaskCategorySwitch
<TaskCategorySwitch
activeCategory={taskCategory} activeCategory={taskCategory}
counts={{ counts={{
scheduled: conditionsByCategory.scheduled.length, scheduled: conditionsByCategory.scheduled.length,
@@ -522,29 +509,22 @@ export function ScheduledConditionFeed({
appointment: conditionsByCategory.appointment.length appointment: conditionsByCategory.appointment.length
}} }}
onChange={handleTaskCategoryChange} onChange={handleTaskCategoryChange}
/> /> : null}
) : null}
<div <div
className={cn( className={cn(
"scheduled-feed-layout mt-3 grid min-h-0", "scheduled-feed-layout mt-3 grid min-h-0",
compactPresentation compactPresentation
? "flex flex-1" ? "flex flex-1"
: expanded : expanded ? "scheduled-feed-layout-expanded flex-1" : "scheduled-feed-layout-collapsed"
? "scheduled-feed-layout-expanded flex-1"
: "scheduled-feed-layout-collapsed"
)} )}
> >
<div <div
className={cn( className={cn(
"relative min-h-0 min-w-0 overflow-hidden", "relative min-h-0 min-w-0 overflow-hidden",
compactPresentation compactPresentation
? expanded ? showCompactDetail ? "scheduled-feed-detail-enter h-full w-full" : "hidden"
? "scheduled-feed-detail-enter h-full w-full" : expanded ? "scheduled-feed-detail-enter h-full" : "pointer-events-none opacity-0"
: "hidden"
: expanded
? "scheduled-feed-detail-enter h-full"
: "pointer-events-none opacity-0"
)} )}
aria-hidden={!expanded} aria-hidden={!expanded}
> >
@@ -571,12 +551,19 @@ export function ScheduledConditionFeed({
onReportViewed={onReportViewed} onReportViewed={onReportViewed}
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined} focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined}
/> />
) : expanded && !compactPresentation ? (
<div className={cn("flex h-full items-center justify-center p-6 text-center", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<div>
<p className="text-sm font-medium text-slate-700">请选择工况任务</p>
<p className="mt-1 text-xs text-[var(--ink-muted)]">从右侧列表选择条目后查看详情。</p>
</div>
</div>
) : null} ) : null}
</div> </div>
<div <div
className={cn( className={cn(
"min-h-0 min-w-0 overflow-hidden", "min-h-0 min-w-0 overflow-hidden",
compactPresentation && (expanded ? "hidden" : "h-full w-full") compactPresentation && (showCompactDetail ? "hidden" : "h-full w-full")
)} )}
> >
<ConditionTimelinePanel <ConditionTimelinePanel
@@ -720,9 +707,7 @@ function ConditionTimelinePanel({
"flex min-h-0 flex-col overflow-hidden p-2.5", "flex min-h-0 flex-col overflow-hidden p-2.5",
presentation === "mobile-sheet" presentation === "mobile-sheet"
? "h-full max-h-none" ? "h-full max-h-none"
: expanded : expanded ? "h-full max-h-none" : "h-[420px] max-h-[420px]",
? "h-full max-h-none"
: "h-[420px] max-h-[420px]",
CONDITION_CONTENT_SURFACE_CLASS_NAME CONDITION_CONTENT_SURFACE_CLASS_NAME
)} )}
> >
@@ -0,0 +1,37 @@
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { createPortal } from "react-dom";
/** Move the live panel without resetting filters, report state or pending input. */
export function ScheduledConditionWorkspace({
docked, dockTarget, hidden, children
}: {
docked: boolean;
dockTarget: RefObject<HTMLDivElement | null>;
hidden: boolean;
children: ReactNode;
}) {
const floating = useRef<HTMLDivElement>(null);
const restoreFocus = useRef(false);
const [host] = useState(() => {
const element = document.createElement("div");
element.className = "h-full min-h-0 min-w-0";
return element;
});
useLayoutEffect(() => {
const target = docked ? dockTarget.current : floating.current;
target?.append(host);
// Moving the host can drop keyboard focus. Restore the corresponding control.
if (restoreFocus.current && !hidden) host.querySelector<HTMLButtonElement>("[data-condition-dock-toggle]")?.focus({ preventScroll: true });
return () => {
restoreFocus.current = host.contains(document.activeElement);
host.remove();
};
}, [dockTarget, docked, hidden, host]);
return <>
<div ref={floating}
className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]"
style={hidden || docked ? { display: "none" } : undefined} />
{createPortal(children, host)}
</>;
}
@@ -16,10 +16,12 @@ import {
UserRound, UserRound,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog"; import { ProjectSwitchDialog } from "./project-switch-dialog";
import { import {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -53,11 +55,12 @@ const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900" "acrylic-panel border p-2 text-slate-900"
); );
const menuReadableRowClassName = const alertReadableRowClassName =
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950"; "surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
const alertActionClassName = cn( const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95" "h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
); );
export function AnalysisViewMenu({ export function AnalysisViewMenu({
@@ -143,13 +146,28 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) { }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false); const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; const skipRemovalMotion = reduceMotion === true || keyboardInteraction;
const hasAlerts = alerts.length > 0; const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time; const latestAlertTime = alerts[0]?.time;
useEffect(() => {
// Radix can preserve :focus-visible through pointer-driven focus transfers.
// Track actual input, including Tab from controls outside this portaled menu.
const handleKeyboard = () => setKeyboardInteraction(true);
const handlePointer = () => setKeyboardInteraction(false);
document.addEventListener("keydown", handleKeyboard, true);
document.addEventListener("pointerdown", handlePointer, true);
document.addEventListener("pointermove", handlePointer, true);
return () => {
document.removeEventListener("keydown", handleKeyboard, true);
document.removeEventListener("pointerdown", handlePointer, true);
document.removeEventListener("pointermove", handlePointer, true);
};
}, []);
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}> <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -158,6 +176,7 @@ export function AlertMenu({
className={cn( className={cn(
"group relative px-2", "group relative px-2",
headerControlButtonClassName, headerControlButtonClassName,
!keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0",
hasAlerts && !compact && hasAlerts && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"), (hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact && hasAlerts && !compact &&
@@ -197,10 +216,7 @@ export function AlertMenu({
)} )}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)} <DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<AlertQueueSummary <AlertQueueSummary
count={alerts.length} count={alerts.length}
latestAlertTime={latestAlertTime} latestAlertTime={latestAlertTime}
@@ -230,12 +246,15 @@ export function AlertMenu({
tone === "warning" ? "status-tone-warning" : "status-tone-danger", tone === "warning" ? "status-tone-warning" : "status-tone-danger",
"relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]", "relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName alertReadableRowClassName
)} )}
> >
<DropdownMenuItem <DropdownMenuItem
asChild asChild
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]" className={cn(
"absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
keyboardInteraction && "focus-visible:ring-2"
)}
onSelect={() => onSelectAlert(alert)} onSelect={() => onSelectAlert(alert)}
> >
<button <button
@@ -381,8 +400,8 @@ export function UserMenu({
onSelect={onLogout ? () => void onLogout() : undefined} onSelect={onLogout ? () => void onLogout() : undefined}
className={cn( className={cn(
"px-2 py-2", "px-2 py-2",
!onLogout && "text-[var(--ink-muted)]", MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME !onLogout && "text-[var(--ink-muted)]"
)} )}
> >
<LogOut size={15} aria-hidden="true" /> <LogOut size={15} aria-hidden="true" />
@@ -466,7 +485,7 @@ function MenuPlainButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn(MAP_ACTION_ROW_INTERACTION_CLASS_NAME, "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]")
)} )}
> >
<span className="truncate text-xs font-semibold leading-4">{label}</span> <span className="truncate text-xs font-semibold leading-4">{label}</span>
@@ -496,7 +515,7 @@ function MenuPrimaryButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn("border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20", mapOptionInteractionVariants({ state: "primary" }))
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -554,9 +573,8 @@ function MenuAction({
return ( return (
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition", "my-0.5 items-center px-2 py-2 transition-colors",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_ACTION_ROW_INTERACTION_CLASS_NAME
menuReadableRowClassName
)} )}
onSelect={onSelect} onSelect={onSelect}
> >
@@ -566,13 +584,13 @@ function MenuAction({
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]", tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
tone === "warning" && "status-icon status-tone-warning", tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600" tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span> <span className="block text-sm font-semibold leading-5">{label}</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,9 +1,12 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName = export const headerControlButtonClassName =
cn( cn(
buttonInteractionVariants({ tone: "neutral" }), buttonInteractionVariants({ tone: "neutral" }),
mapOptionInteractionVariants(),
"data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--surface-reading)] data-[state=open]:text-[var(--action-blue-hover)]",
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']" "inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
); );
@@ -11,6 +14,6 @@ export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0"; "h-10 w-10 justify-center px-0";
export const headerControlIconClassName = export const headerControlIconClassName =
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-[var(--ink-muted)] transition-colors group-hover:bg-[var(--action-soft)] group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:bg-[var(--action-soft)] group-data-[state=open]:text-[var(--action-blue-hover)]"; "header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-inherit";
export const compactHeaderTextClassName = "hidden min-[1800px]:inline"; export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useSyncExternalStore } from "react";
import { import {
Box, Box,
CalendarClock, CalendarClock,
@@ -14,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import type { import type {
WorkbenchAlert, WorkbenchAlert,
WorkbenchAlertDataSource, WorkbenchAlertDataSource,
@@ -21,6 +22,7 @@ import type {
} from "../types"; } from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model"; import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus"; import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
import { WorkbenchWindowMenu } from "./workbench-window-menu";
import { import {
compactHeaderTextClassName, compactHeaderTextClassName,
headerControlButtonClassName, headerControlButtonClassName,
@@ -63,7 +65,7 @@ export type WorkbenchTopBarProps = {
onLogout?: () => Promise<void>; onLogout?: () => Promise<void>;
}; };
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user"; type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user" | "windows";
export function WorkbenchTopBar({ export function WorkbenchTopBar({
dataTime, dataTime,
@@ -101,12 +103,13 @@ export function WorkbenchTopBar({
onLogout onLogout
}: WorkbenchTopBarProps) { }: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null); const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const hasWindows = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries).length > 0;
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => { const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null); setOpenMenu(open ? menuId : null);
}; };
return ( return (
<header className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5"> <header data-floating-window-inset-top="" className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div <div
className={cn( className={cn(
@@ -116,7 +119,7 @@ export function WorkbenchTopBar({
> >
<Droplets size={18} aria-hidden="true" /> <Droplets size={18} aria-hidden="true" />
</div> </div>
<div className="min-w-0"> <div className={cn("min-w-0", hasWindows && "max-[399px]:sr-only")}>
<h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950"> <h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
<span className="sm:hidden">供水</span> <span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智慧运维系统</span> <span className="hidden sm:inline">供水管网智慧运维系统</span>
@@ -172,6 +175,7 @@ export function WorkbenchTopBar({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<WorkbenchWindowMenu open={openMenu === "windows"} onOpenChange={createMenuOpenChange("windows")} />
<button <button
type="button" type="button"
aria-label="项目分析成果" aria-label="项目分析成果"
@@ -0,0 +1,58 @@
import { useRef, useSyncExternalStore } from "react";
import { AppWindow, Check, PanelsTopLeft, X } from "lucide-react";
import { mapOptionInteractionVariants, MAP_ACTION_ROW_INTERACTION_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { headerControlButtonClassName, headerControlIconClassName } from "./workbench-top-bar-styles";
export function WorkbenchWindowMenu({ open, onOpenChange }: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const entries = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries);
const trigger = useRef<HTMLButtonElement>(null);
const afterClose = useRef<(() => void) | null>(null);
if (!entries.length) return null;
return <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button ref={trigger} type="button" aria-label={`窗口管理,当前 ${entries.length} 个窗口`} title="窗口管理"
className={cn("group relative px-1.5 sm:px-2", headerControlButtonClassName)}>
<span className={headerControlIconClassName}><PanelsTopLeft size={14} aria-hidden /></span>
<span className="hidden text-xs xl:inline">窗口</span>
<span aria-hidden className="absolute -right-0.5 -top-0.5 grid min-w-3.5 place-items-center rounded-full bg-[var(--action-blue)] px-0.5 text-[9px] leading-3.5 text-white tabular-nums xl:static xl:min-w-4 xl:px-1 xl:text-xs xl:leading-4">{entries.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label="已打开的窗口" aria-labelledby={undefined}
className={cn("acrylic-panel z-[90] w-80 max-w-[calc(100vw-24px)] border p-2 text-slate-900", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}
onCloseAutoFocus={(event) => {
const focus = afterClose.current;
afterClose.current = null;
if (focus) { event.preventDefault(); focus(); }
}}>
<DropdownMenuLabel className="flex items-center justify-between px-2 py-1.5 text-xs text-[var(--ink-muted)]">
<span>已打开的窗口</span><span className="tabular-nums">{entries.length} 个</span>
</DropdownMenuLabel>
{[...entries].reverse().map((entry, index) => <DropdownMenuItem key={entry.id}
aria-label={`定位窗口 ${entry.title}`} aria-current={index === 0 ? "true" : undefined}
className={cn("min-h-10 items-start px-2 py-2.5 text-xs", index === 0
? cn("rounded-xl border border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] focus:bg-[var(--action-selection-soft)] focus:text-[var(--action-blue-hover)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_ACTION_ROW_INTERACTION_CLASS_NAME)}
onSelect={() => {
afterClose.current = () => entry.focus();
onOpenChange(false);
}}>
<AppWindow size={15} className="mt-0.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 break-words leading-5">{entry.title}</span>
{index === 0 ? <Check size={14} className="mt-0.5 shrink-0 text-[var(--action-blue)]" aria-hidden /> : null}
</DropdownMenuItem>)}
<DropdownMenuSeparator className="my-1.5 bg-[var(--surface-divider)]/70" />
<DropdownMenuItem className={cn("min-h-10 px-2 text-xs", MAP_ACTION_ROW_INTERACTION_CLASS_NAME)} onSelect={() => {
const fallback = trigger.current?.closest("header")?.querySelector<HTMLButtonElement>('button[aria-label="打开用户菜单"]');
afterClose.current = () => fallback?.focus({ preventScroll: true });
onOpenChange(false);
[...entries].reverse().forEach((entry) => entry.close());
}}><X size={15} aria-hidden />关闭全部窗口</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
}
@@ -0,0 +1,43 @@
import { useLayoutEffect, useState } from "react";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartLayout, type ChartSlot } from "../utils/chart-window-slots";
/** Keep chart identities in fixed positions throughout the scrolling grid. */
export function useChartWindowLayout(ids: string[]) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [narrow, setNarrow] = useState(window.innerWidth < 760);
const [preferredLayout, setPreferredLayout] = useState<ChartLayout>("four");
const layout = narrow ? "one" : preferredLayout;
const columns = columnsForLayout(layout);
const [slots, setSlots] = useState<ChartSlot[]>([]);
const available = reconcileChartSlots(slots, ids, columns);
if (available !== slots) setSlots(available);
useLayoutEffect(() => {
if (!container) return;
const measure = () => setNarrow(container.clientWidth < 720);
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [container]);
return {
containerRef: setContainer,
layout,
preferredLayout,
narrow,
setLayout: setPreferredLayout,
slots: available,
columns,
move: (id: string, target: number) => {
const source = available.indexOf(id);
const next = swapChartSlots(available, source, target);
if (next !== available) {
setSlots(next);
}
},
reset: () => {
setSlots(reconcileChartSlots([], ids, columns));
}
};
}
@@ -1,6 +1,6 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest"; import { afterEach, expect, it, vi } from "vitest";
import type { RefObject } from "react"; import { useLayoutEffect, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import type { WorkbenchMapController } from "../map/workbench-map-controller"; import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync"; import type { WorkbenchSyncState } from "./use-workbench-sync";
@@ -15,6 +15,33 @@ const options = { scope: "preview" as const, enabled: false, task: null, project
afterEach(() => { cleanup(); vi.useRealTimers(); }); afterEach(() => { cleanup(); vi.useRealTimers(); });
it("pins an explicitly selected latest step until returning to current", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "selected" }], commands: [] }));
const latestId = result.current.history.entries.at(-1)!.id;
act(() => result.current.history.onSelect(latestId));
expect(result.current.history.selectedId).toBe(latestId);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "new current" }], commands: [] }));
expect(result.current.records).toEqual([{ value: "selected" }]);
act(() => result.current.history.onSelect(null));
expect(result.current.records).toEqual([{ value: "new current" }]);
});
it("never commits a previous task's display under the newly selected task scope", async () => {
const commits: Array<{ scope: string; records: unknown[] }> = [];
const { result, rerender } = renderHook(({ scopeId }) => {
const presentation = useInvestigationPresentation({ ...options, scopeId });
useLayoutEffect(() => {
commits.push({ scope: scopeId, records: presentation.records });
});
return presentation;
}, { initialProps: { scopeId: "task-a" } });
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old task" }], commands: [] }));
expect(result.current.records).toEqual([{ value: "old task" }]);
rerender({ scopeId: "task-b" });
expect(commits.filter((entry) => entry.scope === "task-b").every((entry) => entry.records.length === 0)).toBe(true);
});
it("restores the last successful result when an unfinished display is aborted", async () => { it("restores the last successful result when an unfinished display is aborted", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options)); const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] })); await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
@@ -95,3 +122,59 @@ it("advances saved snapshots on its timer, ignores new results and stops when th
act(() => vi.advanceTimersByTime(3000)); act(() => vi.advanceTimersByTime(3000));
expect(result.current.records).toEqual([{ value: 3 }]); expect(result.current.records).toEqual([{ value: 3 }]);
}); });
it("requests only the live task manager and forgets automatic intent across task switches", async () => {
vi.useFakeTimers();
const task = { task_id: "task-a", title: "运行中调查", execution_generation: 1, status: "running", evidence: [] } as unknown as import("../api/operational-task-client").OperationalTask;
const command = { action: "open_chart" as const, chart_id: "pressure", evidence_refs: ["test"], chart: {
title: "压力", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "压力", data: [20] }]
} };
const panel = document.createElement("div");
panel.dataset.presentationScope = "investigation";
const marker = document.createElement("div");
marker.dataset.investigationChart = "pressure";
marker.getBoundingClientRect = () => ({ height: 10 }) as DOMRect;
panel.append(marker);
document.body.append(panel);
const initial = { ...options, scope: "investigation" as const, task, enabled: true };
const { result, rerender } = renderHook((next) => useInvestigationPresentation(next), { initialProps: initial });
const execute = async (origin: "live" | "preview" | "chat") => {
await act(async () => {
const pending = result.current.executeCommand(command, new AbortController().signal, origin);
await vi.advanceTimersByTimeAsync(700);
await pending;
});
};
try {
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("preview");
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("chat");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "true";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "false";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(true);
rerender({ ...initial, task: { ...task, task_id: "task-b" } });
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, enabled: false });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, task: { ...task, status: "completed", lifecycle_status: "completed" } });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "历史结果" }], commands: [] }));
act(() => result.current.history.onSelect(result.current.history.entries[0].id));
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
act(() => result.current.history.onSelect(null));
expect(result.current.chartWindowAutoOpen).toBe(false);
} finally {
panel.remove();
}
});
@@ -16,6 +16,8 @@ import {
} from "../api/investigation-presentation"; } from "../api/investigation-presentation";
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive"; import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
import { presentationCommandLabel } from "../utils/presentation-command-label"; import { presentationCommandLabel } from "../utils/presentation-command-label";
import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
import { presentationCommandCategory } from "../utils/presentation-history-categories";
import type { import type {
InvestigationPresentationCommand InvestigationPresentationCommand
} from "../api/investigation-presentation"; } from "../api/investigation-presentation";
@@ -92,13 +94,15 @@ async function waitForChart(
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`); const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
// A deliberate collapse defers rendering, rather than failing a valid command. // A deliberate collapse defers rendering, rather than failing a valid command.
if (panel?.dataset.investigationCollapsed === "true") return; if (panel?.dataset.investigationCollapsed === "true") return;
const mapHidden = Boolean(panel?.closest('[aria-hidden="true"]'));
const element = [ const element = [
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? []) ...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
].find( ].find(
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]') (item) => item.dataset.investigationChart === chartId && (mapHidden || !item.closest('[aria-hidden="true"]'))
); );
if (element && element.getBoundingClientRect().height > 0) { if (element && (mapHidden || element.getBoundingClientRect().height > 0)) {
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]"); // A focused workspace deliberately hides the map's inline chart copy.
const viewport = mapHidden ? null : panel?.querySelector<HTMLElement>("[data-investigation-content]");
if (viewport) { if (viewport) {
const plot = element.querySelector("svg[role=img]") ?? element; const plot = element.querySelector("svg[role=img]") ?? element;
const overflow = const overflow =
@@ -114,19 +118,31 @@ async function waitForChart(
} }
export function useInvestigationPresentation(options: Options) { export function useInvestigationPresentation(options: Options) {
const windowScope = JSON.stringify([options.scope, options.scopeId, options.projectId, options.task?.task_id, options.task?.execution_generation]);
const [chartWindow, setChartWindow] = useState({ scope: windowScope, requested: false });
if (chartWindow.scope !== windowScope) setChartWindow({ scope: windowScope, requested: false });
const valuesRef = useRef(options); const valuesRef = useRef(options);
valuesRef.current = options; valuesRef.current = options;
const storeRef = useRef(new PresentationViewHistory()); const storeRef = useRef(new PresentationViewHistory());
const displayScope = useRef(windowScope);
if (displayScope.current !== windowScope) {
displayScope.current = windowScope;
// The first render of a new task must not re-key the previous task's charts.
// Keep saved history; effects below still own cancellation and map cleanup.
storeRef.current.select(null);
storeRef.current.update(emptyPresentationView());
}
const [, refresh] = useState(0); const [, refresh] = useState(0);
const publish = useCallback(() => refresh((value) => value + 1), []); const publish = useCallback(() => refresh((value) => value + 1), []);
const [localPreview, setLocalPreview] = useState(false); const [localPreview, setLocalPreview] = useState(false);
const localPreviewRef = useRef(false); const localPreviewRef = useRef(false);
const [cameraPaused, setCameraPaused] = useState(false); const [cameraPaused, setCameraPaused] = useState(false);
const cameraPausedRef = useRef(false); const cameraPausedRef = useRef(false);
const [status, setStatus] = useState< const [connection, setConnection] = useState<{
"connecting" | "live" | "history" | "disconnected" | "error" scope: string;
>("disconnected"); status: "connecting" | "live" | "history" | "disconnected" | "error";
const [error, setError] = useState<string | null>(null); error: string | null;
}>({ scope: windowScope, status: "disconnected", error: null });
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]); const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0); const activityIdRef = useRef(0);
@@ -155,6 +171,14 @@ export function useInvestigationPresentation(options: Options) {
: "live", : "live",
[] []
); );
const requestChartWindow = useCallback((origin: InvestigationPresentationActivity["source"]) => {
const current = valuesRef.current;
if (storeRef.current.selectedId !== null || current.scope === "chat" || current.scope === "preview") return;
if (origin !== "live" || !current.enabled || current.task?.status !== "running") return;
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${current.scope ?? "investigation"}"]`);
if (panel?.dataset.investigationCollapsed === "true") return;
setChartWindow({ scope: JSON.stringify([current.scope, current.scopeId, current.projectId, current.task?.task_id, current.task?.execution_generation]), requested: true });
}, []);
const update = useCallback( const update = useCallback(
(updater: (view: PresentationView) => PresentationView) => { (updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current)); storeRef.current.update(updater(storeRef.current.current));
@@ -165,12 +189,28 @@ export function useInvestigationPresentation(options: Options) {
const record = useCallback( const record = useCallback(
(label: string, origin = source()) => { (label: string, origin = source()) => {
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, { storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
taskId: valuesRef.current.task?.task_id taskId: valuesRef.current.task?.task_id,
executionGeneration: valuesRef.current.task?.execution_generation
}); });
publish(); publish();
}, },
[publish, source] [publish, source]
); );
const historyEntries = storeRef.current.entries;
const taskEntries =
!options.scope || options.scope === "investigation"
? historyEntries.filter((entry) => entry.taskId === taskId)
: historyEntries;
const mergedEntries = useMemo(() => {
if ((options.scope && options.scope !== "investigation") || !options.task) return [];
return mergeAdjacentPresentationHistory(historyEntries, options.task.task_id, options.task.title);
}, [options.scope, options.task, historyEntries]);
const selectedMerged = storeRef.current.selected?.mergedStepIds ? storeRef.current.selected : null;
const mergedHistory = mergedEntries.map((entry) => selectedMerged && entry.id === selectedMerged.id ? selectedMerged : entry);
// Keep both group order and composed snapshots fixed until the user leaves playback.
const playbackEntries = storeRef.current.playbackEntries.length
? storeRef.current.playbackEntries
: (mergedHistory.length ? mergedHistory : taskEntries).filter((entry) => entry.status === "completed");
const selectHistory = useCallback( const selectHistory = useCallback(
(id: number | null, pin = false) => { (id: number | null, pin = false) => {
const store = storeRef.current; const store = storeRef.current;
@@ -180,12 +220,18 @@ export function useInvestigationPresentation(options: Options) {
camera: camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
}); });
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return; if (pin && id !== null) {
if (!store.seekPlayback(id, playbackEntries)) return;
} else if (id !== null && id < 0) {
const merged = mergedHistory.find((entry) => entry.id === id);
if (!merged) return;
store.selectMerged(merged);
} else if (!store.select(id)) return;
publish(); publish();
const map = valuesRef.current.mapRef.current; const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible); if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
}, },
[publish, scene] [publish, scene, mergedHistory, playbackEntries]
); );
const clearMapOverlays = useCallback( const clearMapOverlays = useCallback(
@@ -252,7 +298,10 @@ export function useInvestigationPresentation(options: Options) {
presentationCommandLabel(command, store.current), presentationCommandLabel(command, store.current),
origin, origin,
valuesRef.current.task?.title ?? null, valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id } { key: eventKey, taskId: valuesRef.current.task?.task_id,
executionGeneration: event?.execution_generation ?? valuesRef.current.task?.execution_generation,
category: presentationCommandCategory(command.action),
chartId: command.action === "open_chart" || command.action === "close_chart" ? command.chart_id : undefined }
); );
const pendingEntry = store.entries.find((entry) => entry.id === historyId); const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current; const id = ++activityIdRef.current;
@@ -278,13 +327,15 @@ export function useInvestigationPresentation(options: Options) {
update: apply, update: apply,
rendering: () => store.selectedId === null, rendering: () => store.selectedId === null,
cameraAllowed: () => !cameraPausedRef.current, cameraAllowed: () => !cameraPausedRef.current,
waitForChart: (chartId, nextSignal) => waitForChart: (chartId, nextSignal) => {
waitForChart( requestChartWindow(origin);
return waitForChart(
chartId, chartId,
nextSignal, nextSignal,
valuesRef.current.scope ?? "investigation", valuesRef.current.scope ?? "investigation",
() => store.selectedId === null () => store.selectedId === null
), );
},
frame frame
}) })
); );
@@ -327,7 +378,7 @@ export function useInvestigationPresentation(options: Options) {
throw cause; throw cause;
} }
}, },
[scene, publish, update] [scene, publish, update, requestChartWindow]
); );
useEffect(() => { useEffect(() => {
@@ -397,8 +448,9 @@ export function useInvestigationPresentation(options: Options) {
clear, clear,
stopPulse: () => scene.stopPulse(), stopPulse: () => scene.stopPulse(),
onStatus: (next, message) => { onStatus: (next, message) => {
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next); setConnection({ scope: windowScope,
setError(message ?? null); status: !valuesRef.current.enabled && next === "live" ? "history" : next,
error: message ?? null });
}, },
execute: async (command, signal, event) => { execute: async (command, signal, event) => {
await executeCommand(command, signal, "live", event); await executeCommand(command, signal, "live", event);
@@ -422,7 +474,8 @@ export function useInvestigationPresentation(options: Options) {
publish, publish,
scene, scene,
subscribe, subscribe,
taskId taskId,
windowScope
]); ]);
useEffect( useEffect(
() => () => { () => () => {
@@ -535,10 +588,6 @@ export function useInvestigationPresentation(options: Options) {
return createInvestigationDevCase(caseId); return createInvestigationDevCase(caseId);
}, []); }, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const playing = storeRef.current.playing; const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId; const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => { useEffect(() => {
@@ -555,6 +604,8 @@ export function useInvestigationPresentation(options: Options) {
const visible = storeRef.current.visible; const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null; const reviewing = storeRef.current.selectedId !== null;
return { return {
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
chartWindowScope: windowScope,
charts: visible.charts, charts: visible.charts,
blocks: visible.blocks, blocks: visible.blocks,
records: visible.records, records: visible.records,
@@ -562,17 +613,20 @@ export function useInvestigationPresentation(options: Options) {
evidence: visible.evidence, evidence: visible.evidence,
highlightLayers: visible.highlightLayers, highlightLayers: visible.highlightLayers,
cameraPaused: reviewing ? false : cameraPaused, cameraPaused: reviewing ? false : cameraPaused,
status, status: connection.scope === windowScope ? connection.status : taskId ? "connecting" as const : "disconnected" as const,
error, error: connection.scope === windowScope ? connection.error : null,
activity, activity,
localPreview, localPreview,
hasHistory: taskEntries.length > 0, hasHistory: taskEntries.length > 0,
history: { history: {
currentEntryId: storeRef.current.currentEntryId, currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries, entries: taskEntries,
mergedEntry: selectedMerged ?? mergedEntries.at(-1) ?? null,
mergedEntries: mergedHistory,
selectedId: storeRef.current.selectedId, selectedId: storeRef.current.selectedId,
onSelect: selectHistory, onSelect: selectHistory,
playback: { playback: {
entries: playbackEntries,
playing, playing,
speed: playbackSpeed, speed: playbackSpeed,
onToggle: () => { onToggle: () => {
@@ -581,7 +635,7 @@ export function useInvestigationPresentation(options: Options) {
else { else {
if (store.selectedId === null) if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera }); store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
store.startPlayback(taskEntries); store.startPlayback(playbackEntries);
} }
publish(); publish();
const map = valuesRef.current.mapRef.current; const map = valuesRef.current.mapRef.current;
@@ -50,10 +50,12 @@ describe("workbench responsive layout", () => {
act(() => result.current.handleConditionExpandedChange(true)); act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true); expect(result.current.conditionFeedExpanded).toBe(true);
expect(result.current.conditionFeedDocked).toBe(true);
expect(collapseAgentPanel).toHaveBeenCalledOnce(); expect(collapseAgentPanel).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(false)); act(() => result.current.handleConditionExpandedChange(false));
expect(result.current.conditionFeedExpanded).toBe(false); expect(result.current.conditionFeedExpanded).toBe(false);
expect(result.current.conditionFeedDocked).toBe(false);
expect(expandAgentPanel).toHaveBeenCalledOnce(); expect(expandAgentPanel).toHaveBeenCalledOnce();
}); });
@@ -76,6 +78,10 @@ describe("workbench responsive layout", () => {
expect(result.current.mobileSheetSnap).toBe("half"); expect(result.current.mobileSheetSnap).toBe("half");
expect(onClearActiveTool).toHaveBeenCalledOnce(); expect(onClearActiveTool).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true);
expect(result.current.conditionFeedDocked).toBe(false);
act(() => result.current.toggleConditionFeedForViewport()); act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBeNull(); expect(result.current.mobileSheet).toBeNull();
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet"; import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout"; import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
import { CONDITION_PANE_MIN_WIDTH, type ArtifactRequestedView } from "../workspace/workspace-model";
const CONDITION_FEED_EXIT_MS = 170; const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440; const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
@@ -33,6 +34,8 @@ export function useWorkbenchResponsiveLayout({
const [conditionFeedVisible, setConditionFeedVisible] = useState(false); const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(false); const [conditionFeedMounted, setConditionFeedMounted] = useState(false);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false); const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [conditionFeedDocked, setConditionFeedDocked] = useState(false);
const [conditionView, setConditionView] = useState<ArtifactRequestedView>("map_split");
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null); const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half"); const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false); const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
@@ -87,7 +90,11 @@ export function useWorkbenchResponsiveLayout({
function openAgentPanelForViewport() { function openAgentPanelForViewport() {
if (isLargeScreen) { if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) { if (conditionFeedDocked && viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
setConditionFeedDocked(false);
setConditionFeedExpanded(false);
}
if (!conditionFeedDocked && conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setConditionFeedExpanded(false); setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
} }
@@ -100,10 +107,11 @@ export function useWorkbenchResponsiveLayout({
} }
function handleConditionExpandedChange(nextExpanded: boolean) { function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) { if (isLargeScreen) {
setAgentCollapsedForCondition(agentPanelOpen); setConditionFeedDocked(nextExpanded);
collapseAgentPanel(); if (nextExpanded) setConditionView("map_split");
} else if (!nextExpanded && agentCollapsedForCondition) { }
if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
expandAgentPanel(); expandAgentPanel();
} }
@@ -111,22 +119,22 @@ export function useWorkbenchResponsiveLayout({
} }
useEffect(() => { useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) { if (isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && agentPanelOpen
return; && viewportWidth - agentPanelWidth < CONDITION_PANE_MIN_WIDTH) {
setAgentCollapsedForCondition(true);
collapseAgentPanel();
} }
}, [agentPanelOpen, agentPanelWidth, collapseAgentPanel, conditionFeedDocked, isLargeScreen, shouldShowConditionFeed, viewportWidth]);
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) return;
setConditionFeedExpanded(false); setConditionFeedExpanded(false);
setConditionFeedDocked(false);
if (agentCollapsedForCondition) { if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false); setAgentCollapsedForCondition(false);
expandAgentPanel(); expandAgentPanel();
} }
}, [ }, [agentCollapsedForCondition, conditionFeedExpanded, expandAgentPanel, isLargeScreen, shouldShowConditionFeed]);
agentCollapsedForCondition,
conditionFeedExpanded,
expandAgentPanel,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() { function toggleConditionFeedForViewport() {
onClearActiveTool(); onClearActiveTool();
@@ -161,14 +169,16 @@ export function useWorkbenchResponsiveLayout({
} }
const leftPanelOpen = isLargeScreen && agentPanelOpen; const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed); const rightPanelOpen = isLargeScreen && (devPanelOpen || (shouldShowConditionFeed && !conditionFeedDocked));
const rightPanelExpanded = const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen; isLargeScreen && shouldShowConditionFeed && !conditionFeedDocked && conditionFeedExpanded && !devPanelOpen;
return { return {
agentPanelWidth, agentPanelWidth,
closeMobileSheet, closeMobileSheet,
conditionFeedExpanded, conditionFeedExpanded,
conditionFeedDocked,
conditionView,
conditionFeedMounted, conditionFeedMounted,
handleConditionExpandedChange, handleConditionExpandedChange,
isLargeScreen, isLargeScreen,
@@ -181,6 +191,7 @@ export function useWorkbenchResponsiveLayout({
rightPanelExpanded, rightPanelExpanded,
rightPanelOpen, rightPanelOpen,
setAgentPanelWidth, setAgentPanelWidth,
setConditionView,
setMobileSheetSnap, setMobileSheetSnap,
shouldShowConditionFeed, shouldShowConditionFeed,
toggleConditionFeedForViewport, toggleConditionFeedForViewport,
+71 -12
View File
@@ -44,6 +44,11 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { ScheduledConditionWorkspace } from "./components/scheduled-condition-workspace";
import { CONDITION_MAP_SPLIT_MIN_WIDTH } from "./workspace/workspace-model";
import { ARTIFACT_MAP_SPLIT_MIN_WIDTH, ARTIFACT_PANE_MIN_WIDTH } from "./workspace/workspace-model";
import { ChartWorkspaceProvider } from "./workspace/chart-workspace-provider";
import { useChartWorkspace } from "./workspace/chart-workspace-context";
import type { InvestigationDiscussion } from "./api/investigation-report"; import type { InvestigationDiscussion } from "./api/investigation-report";
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client"; import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
@@ -136,7 +141,7 @@ type MapWorkbenchPageProps = {
projectSelection?: WorkbenchProjectSelection; projectSelection?: WorkbenchProjectSelection;
}; };
export function MapWorkbenchPage(props: MapWorkbenchPageProps) { export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>; return <ReadingWorkspaceProvider><ChartWorkspaceProvider><MapWorkbenchContent {...props} /></ChartWorkspaceProvider></ReadingWorkspaceProvider>;
} }
function MapWorkbenchContent({ function MapWorkbenchContent({
user = WORKBENCH_USER, user = WORKBENCH_USER,
@@ -145,6 +150,7 @@ function MapWorkbenchContent({
projectSelection projectSelection
}: MapWorkbenchPageProps) { }: MapWorkbenchPageProps) {
const readingWorkspace = useReadingWorkspace(); const readingWorkspace = useReadingWorkspace();
const chartWorkspace = useChartWorkspace()!;
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN); const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken; const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
@@ -269,8 +275,10 @@ function MapWorkbenchContent({
agentPanelWidth, agentPanelWidth,
closeMobileSheet, closeMobileSheet,
conditionFeedExpanded, conditionFeedExpanded,
conditionFeedDocked,
conditionView,
conditionFeedMounted, conditionFeedMounted,
handleConditionExpandedChange, handleConditionExpandedChange: updateConditionExpanded,
isLargeScreen, isLargeScreen,
leftPanelOpen, leftPanelOpen,
mobileSheet, mobileSheet,
@@ -281,6 +289,7 @@ function MapWorkbenchContent({
rightPanelExpanded, rightPanelExpanded,
rightPanelOpen, rightPanelOpen,
setAgentPanelWidth, setAgentPanelWidth,
setConditionView,
setMobileSheetSnap, setMobileSheetSnap,
shouldShowConditionFeed, shouldShowConditionFeed,
toggleConditionFeedForViewport, toggleConditionFeedForViewport,
@@ -295,6 +304,20 @@ function MapWorkbenchContent({
}); });
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth; const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth); const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const chartCollapsedAgent = useRef(false);
useEffect(() => {
if (isLargeScreen && chartWorkspace.activeId && leftPanelOpen && businessWorkspaceWidth < ARTIFACT_PANE_MIN_WIDTH) {
chartCollapsedAgent.current = true;
collapseAgentPanel();
} else if (!chartWorkspace.activeId && chartCollapsedAgent.current) {
chartCollapsedAgent.current = false;
expandAgentPanel();
}
}, [businessWorkspaceWidth, chartWorkspace.activeId, collapseAgentPanel, expandAgentPanel, isLargeScreen, leftPanelOpen]);
const handleConditionExpandedChange = useCallback((expanded: boolean) => {
if (expanded) chartWorkspace.deactivate();
updateConditionExpanded(expanded);
}, [chartWorkspace, updateConditionExpanded]);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth); const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null; const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null; const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
@@ -356,6 +379,7 @@ function MapWorkbenchContent({
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]); }, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
if (!isLargeScreen || (chartWorkspace.activeId && viewportWidth - agentPanelWidth < ARTIFACT_PANE_MIN_WIDTH)) chartWorkspace.deactivate();
openAgentPanelForViewport(); openAgentPanelForViewport();
} }
@@ -417,10 +441,24 @@ function MapWorkbenchContent({
} }
function openConditionWorkspaceForViewport() { function openConditionWorkspaceForViewport() {
chartWorkspace.deactivate();
setSelectedConditionId(null);
setConditionFocusRequest(null);
openConditionFeedForViewport(); openConditionFeedForViewport();
} }
function toggleConditionWorkspaceForViewport() { function toggleConditionWorkspaceForViewport() {
if (chartWorkspace.activeId) {
chartWorkspace.deactivate();
if (!shouldShowConditionFeed) openConditionWorkspaceForViewport();
else if (!isLargeScreen) openConditionFeedForViewport();
handleConditionExpandedChange(true);
return;
}
if (isLargeScreen ? !shouldShowConditionFeed : mobileSheet !== "condition") {
setSelectedConditionId(null);
setConditionFocusRequest(null);
}
toggleConditionFeedForViewport(); toggleConditionFeedForViewport();
} }
@@ -517,8 +555,15 @@ function MapWorkbenchContent({
: { ...padding, bottom: Math.max(padding.bottom, 420) }); : { ...padding, bottom: Math.max(padding.bottom, 420) });
} }
}); });
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) { function showMapForLocation() {
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only"); if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
else if (chartWorkspace.activeId) chartWorkspace.setView(isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
else if (isLargeScreen && conditionFeedDocked) {
setConditionView(businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH ? "map_split" : "map_only");
}
}
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
showMapForLocation();
return presentation.locateTargets(targets).then(() => true).catch((error) => { return presentation.locateTargets(targets).then(() => true).catch((error) => {
showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }); showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" });
return false; return false;
@@ -838,6 +883,7 @@ function MapWorkbenchContent({
function handleOpenLatestAnalysis() { function handleOpenLatestAnalysis() {
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return; if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
chartWorkspace.deactivate();
artifactWorkspace.openArtifact(latestAnalysisArtifact); artifactWorkspace.openArtifact(latestAnalysisArtifact);
} }
@@ -1285,7 +1331,7 @@ function MapWorkbenchContent({
} : undefined, } : undefined,
onSubmitPrompt: agent.submitPrompt, onSubmitPrompt: agent.submitPrompt,
onLocateAnswerFeatures: async (target) => { onLocateAnswerFeatures: async (target) => {
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only"); showMapForLocation();
return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat"); return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat");
}, },
onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt, onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
@@ -1294,8 +1340,10 @@ function MapWorkbenchContent({
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
}; };
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active); const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active) || Boolean(chartWorkspace.activeId);
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen); const conditionDockTarget = useRef<HTMLDivElement>(null);
const conditionDockActive = isLargeScreen && conditionFeedDocked && shouldShowConditionFeed && !conditionFeedSuspended;
const mobileSheetSuspended = Boolean(chartWorkspace.activeId) || Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
return ( return (
<main <main
@@ -1369,6 +1417,7 @@ function MapWorkbenchContent({
onClose={() => setAnalysisResultsPanelOpen(false)} onClose={() => setAnalysisResultsPanelOpen(false)}
getAccessToken={getAccessToken} getAccessToken={getAccessToken}
onOpenArtifact={(artifact) => { onOpenArtifact={(artifact) => {
chartWorkspace.deactivate();
artifactWorkspace.openArtifact(artifact); artifactWorkspace.openArtifact(artifact);
setAnalysisResultsPanelOpen(false); setAnalysisResultsPanelOpen(false);
}} }}
@@ -1400,10 +1449,18 @@ function MapWorkbenchContent({
viewportWidth={viewportWidth} viewportWidth={viewportWidth}
conditionExpanded={rightPanelExpanded} conditionExpanded={rightPanelExpanded}
onCollapsePanel={agent.collapsePanel} onCollapsePanel={agent.collapsePanel}
onExpandPanel={agent.expandPanel} onExpandPanel={openAgentWorkspaceForViewport}
onWidthCommit={setAgentPanelWidth} onWidthCommit={setAgentPanelWidth}
> >
<WorkbenchMainFrame <WorkbenchMainFrame
chartDock={{ window: chartWorkspace, splitAvailable: isLargeScreen && businessWorkspaceWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH, reserveAgentRail: !leftPanelOpen }}
conditionDock={{
active: conditionDockActive,
splitAvailable: businessWorkspaceWidth >= CONDITION_MAP_SPLIT_MIN_WIDTH,
target: conditionDockTarget,
view: conditionView,
onSetView: setConditionView
}}
activeArtifact={artifactWorkspace.activeArtifact} activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact} pendingArtifact={artifactWorkspace.pendingArtifact}
surfaceMode={artifactWorkspace.surfaceMode} surfaceMode={artifactWorkspace.surfaceMode}
@@ -1477,6 +1534,7 @@ function MapWorkbenchContent({
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview } { scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel ].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused} key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
chartWindowAutoOpen={surface.chartWindowAutoOpen} chartWindowScope={surface.chartWindowScope}
status={scope === "chat" ? "live" : surface.status} error={surface.error} status={scope === "chat" ? "live" : surface.status} error={surface.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))} width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer} relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
@@ -1522,9 +1580,10 @@ function MapWorkbenchContent({
</WorkbenchAgentPanels> </WorkbenchAgentPanels>
{conditionFeedMounted ? ( {conditionFeedMounted ? (
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}> <ScheduledConditionWorkspace docked={conditionDockActive} dockTarget={conditionDockTarget} hidden={conditionFeedSuspended}>
<ScheduledConditionFeed <ScheduledConditionFeed
presentation="desktop-floating" presentation={conditionDockActive ? "workspace" : "desktop-floating"}
reserveAgentRail={!leftPanelOpen && (conditionView === "focus" || businessWorkspaceWidth < CONDITION_MAP_SPLIT_MIN_WIDTH)}
conditions={scheduledConditions} conditions={scheduledConditions}
dataSource={runtimeDataSource} dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource} workOrderDataSource={workOrderDataSource}
@@ -1557,7 +1616,7 @@ function MapWorkbenchContent({
onReportViewed={operationalTasks.markReportRead} onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh} onRefresh={workbenchSync.refresh}
/> />
</div> </ScheduledConditionWorkspace>
) : null} ) : null}
{devPanelEnabled && devPanelOpen ? ( {devPanelEnabled && devPanelOpen ? (
@@ -1637,7 +1696,7 @@ function MapWorkbenchContent({
type="button" type="button"
aria-label="打开 Agent 面板" aria-label="打开 Agent 面板"
title="打开 Agent 面板" title="打开 Agent 面板"
onClick={openAgentPanelForViewport} onClick={openAgentWorkspaceForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700" className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
> >
<AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} /> <AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
@@ -1653,7 +1712,7 @@ function MapWorkbenchContent({
type="button" type="button"
aria-label="打开工况任务" aria-label="打开工况任务"
title="打开工况任务" title="打开工况任务"
onClick={toggleConditionFeedForViewport} onClick={toggleConditionWorkspaceForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700" className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
> >
<CalendarClock size={20} aria-hidden="true" /> <CalendarClock size={20} aria-hidden="true" />
@@ -82,6 +82,21 @@ describe("map layer visibility fades", () => {
expect(frames.size).toBe(0); expect(frames.size).toBe(0);
}); });
it("keeps opacity in range when the first frame timestamp precedes the fade start", () => {
const fixture = createMap();
now = 100;
setMapLayersVisibility(fixture.map, ["simulation-backend-analysis-label", "pipes"], false);
// RAF reports the frame start, which can precede performance.now() sampled
// later in that frame when a visibility change begins.
advance(75.8);
expect(fixture.paint.get("simulation-backend-analysis-label:text-opacity")).toBe(1);
expect(fixture.paint.get("pipes:line-opacity")).toBe(0.92);
advance(260);
expect(fixture.paint.get("simulation-backend-analysis-label:text-opacity")).toBe(0.5);
advance(420);
expect(fixture.visibility.get("simulation-backend-analysis-label")).toBe("none");
});
it("starts fading in after vector tiles are ready", () => { it("starts fading in after vector tiles are ready", () => {
const fixture = createMap(); const fixture = createMap();
let loaded = false; let loaded = false;
@@ -102,7 +117,8 @@ describe("map layer visibility fades", () => {
}); });
function createMap() { function createMap() {
const layers = new Map([ ["pipes", { type: "line", source: "pipes" }], ["pipes-hit", { type: "line", source: "pipes" }] ]); const layers = new Map([ ["pipes", { type: "line", source: "pipes" }], ["pipes-hit", { type: "line", source: "pipes" }],
["simulation-backend-analysis-label", { type: "symbol", source: "simulation-backend-analysis" }] ]);
const visibility = new Map([ ["pipes", "visible"], ["pipes-hit", "visible"] ]); const visibility = new Map([ ["pipes", "visible"], ["pipes-hit", "visible"] ]);
const paint = new Map<string, unknown>([ const paint = new Map<string, unknown>([
["pipes:line-opacity", 0.92], ["pipes:line-opacity-transition", { duration: 120 }], ["pipes:line-opacity", 0.92], ["pipes:line-opacity-transition", { duration: 120 }],
@@ -110,7 +110,8 @@ export function setMapLayersVisibility(
return; return;
} }
startedAt ??= now; startedAt ??= now;
const progress = Math.min(1, (now - startedAt) / LAYER_VISIBILITY_FADE_MS); // RAF's frame timestamp may precede performance.now() at the fade start.
const progress = Math.max(0, Math.min(1, (now - startedAt) / LAYER_VISIBILITY_FADE_MS));
fade.opacity = startOpacity + (targetOpacity - startOpacity) * progress; fade.opacity = startOpacity + (targetOpacity - startOpacity) * progress;
if (progress >= 1) finish(); if (progress >= 1) finish();
else { else {
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartSlot } from "./chart-window-slots";
describe("fixed chart slots", () => {
it("maps supported layouts to grid columns", () => {
expect([columnsForLayout("one"), columnsForLayout("two"), columnsForLayout("four")]).toEqual([1, 2, 2]);
});
it("keeps chart positions when publications update in another order", () => {
const slots: ChartSlot[] = ["flow", null, "pressure", "plans"];
expect(reconcileChartSlots(slots, ["plans", "pressure", "flow"], 2)).toBe(slots);
});
it("leaves deleted positions vacant without moving surviving charts", () => {
expect(reconcileChartSlots(["pressure", "flow", "plans", null], ["pressure", "plans"], 2))
.toEqual(["pressure", null, "plans", null]);
});
it("fills the earliest vacant slots before appending a padded row", () => {
expect(reconcileChartSlots(["pressure", null, "flow", null], ["new-1", "flow", "new-2", "pressure", "new-3"], 2))
.toEqual(["pressure", "new-1", "flow", "new-2", "new-3", null]);
});
it("uses vacancies created by a deletion in the same update", () => {
expect(reconcileChartSlots(["pressure", "removed", "flow", null], ["pressure", "flow", "new"], 2))
.toEqual(["pressure", "new", "flow", null]);
});
it("preserves linear positions when the column count changes", () => {
const slots: ChartSlot[] = ["a", null, "b", "c", "d", null];
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 1)).toEqual(["a", null, "b", "c", "d"]);
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 2)).toBe(slots);
});
it("trims only empty trailing rows, retaining leading and middle vacancies", () => {
expect(reconcileChartSlots([null, null, "a", null, null, null, null, null], ["a"], 2))
.toEqual([null, null, "a", null]);
expect(reconcileChartSlots(["a", null, null, null], ["a"], 2)).toEqual(["a", null]);
expect(reconcileChartSlots(["a", null, null, null], [], 2)).toEqual([]);
});
it("builds default slots in available order and never duplicates a chart", () => {
expect(reconcileChartSlots([], ["a", "b", "a"], 2)).toEqual(["a", "b"]);
expect(reconcileChartSlots(["a", "a", "b", null], ["a", "b", "c"], 2)).toEqual(["a", "c", "b", null]);
});
it("swaps occupied slots without inserting or shifting adjacent charts", () => {
const slots: ChartSlot[] = ["a", "b", "c", "d"];
expect(swapChartSlots(slots, 0, 2)).toEqual(["c", "b", "a", "d"]);
expect(slots).toEqual(["a", "b", "c", "d"]);
});
it("swaps across rows and permits an empty destination", () => {
expect(swapChartSlots(["a", "b", "c", "d", "e", null, null, null], 0, 5))
.toEqual([null, "b", "c", "d", "e", "a", null, null]);
});
it("keeps the original array for invalid or unchanged swaps", () => {
const slots: ChartSlot[] = ["a", null, null];
for (const [source, target] of [[-1, 1], [0, 3], [3, 0], [0.5, 1], [0, Number.NaN], [0, 0], [1, 2]]) {
expect(swapChartSlots(slots, source, target)).toBe(slots);
}
});
});
@@ -0,0 +1,38 @@
export type ChartSlot = string | null;
export type ChartLayout = "one" | "two" | "four";
export function columnsForLayout(layout: ChartLayout): 1 | 2 {
return layout === "one" ? 1 : 2;
}
/** Retain fixed positions, fill vacancies, and pad the final row without compacting. */
export function reconcileChartSlots(slots: ChartSlot[], availableIds: string[], columns: 1 | 2): ChartSlot[] {
const available = new Set(availableIds);
const retained = new Set<string>();
const next = slots.map((id) => {
if (id === null || !available.has(id) || retained.has(id)) return null;
retained.add(id);
return id;
});
for (const id of available) {
if (retained.has(id)) continue;
const empty = next.indexOf(null);
if (empty === -1) next.push(id);
else next[empty] = id;
}
let last = next.length - 1;
while (last >= 0 && next[last] === null) last -= 1;
const length = Math.ceil((last + 1) / columns) * columns;
if (next.length > length) next.length = length;
while (next.length < length) next.push(null);
return next.length === slots.length && next.every((id, index) => id === slots[index]) ? slots : next;
}
/** Exchange two fixed positions, including vacant destinations; never shift neighbors. */
export function swapChartSlots(slots: ChartSlot[], sourceIndex: number, targetIndex: number): ChartSlot[] {
if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex) || sourceIndex < 0 || targetIndex < 0 ||
sourceIndex >= slots.length || targetIndex >= slots.length || slots[sourceIndex] === slots[targetIndex]) return slots;
const next = [...slots];
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
return next;
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
const comparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16"): InvestigationChart => ({
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`evidence:${id}`],
chart: { title: `${id} ${metric}七日同时刻对照(${unit})`, chart_type: "time_comparison",
x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: ["07:45", "08:00"],
series: [
{ name: `${id} 当前 ${date}`, type: "line", data: [30, 25], sample_times: [`${date}T07:45:00+08:00`, `${date}T08:00:00+08:00`] },
{ name: `${id} 2026-07-09`, type: "scatter", data: [null, 32], sample_times: [null, "2026-07-09T08:00:00+08:00"] }
] }
});
const legacyComparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16") => {
const command = comparison(id, metric, unit, date);
command.chart.title = `${id} ${metric}三小时趋势与七天同期对照(${unit})`;
command.chart.series[0].name = `${id} ${date.slice(5)}(${unit})`;
command.chart.series[1].name = "07-09";
return command;
};
describe("investigation comparison groups", () => {
it("groups all four bound devices even when titles differ and one device has no observations", () => {
const ids = ["486033", "542017", "541061", "524529"];
const charts = ids.map((id, index) => {
const command = comparison(id, "流量", "m3/h");
command.chart.title = `不同的标题 ${index}`;
command.chart.scada = { role: "comparison", metric: "flow", unit: "m3/h",
window_start: "2026-07-16T05:00:00+08:00", window_end: "2026-07-16T08:00:00+08:00" };
command.chart.series.forEach((series, offset) => {
series.device_id = id;
series.day_offset = offset;
series.name = offset ? "历史" : "当前";
if (id === "524529") { series.data = [null, null]; series.sample_times = [null, null]; }
});
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(ids);
expect(groups[0].id).toBe(groupInvestigationCharts([charts[3]])[0].id);
expect(groups[0].items[3].command).toBe(charts[3]);
charts[3].chart.scada!.unit = "m³/h";
charts[3].chart.scada!.window_start = "2026-07-15T21:00:00Z";
charts[3].chart.scada!.window_end = "2026-07-16T00:00:00Z";
expect(groupInvestigationCharts(charts)).toHaveLength(1);
});
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(2);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
expect(groups[1].items.map((item) => item.label)).toEqual(["542017", "486033"]);
expect(groups[0].title).toBe("压力七日同时刻对照(m)");
expect(groups[0].items[1].command).toBe(charts[1]);
expect(charts[1].chart.series[1].data).toEqual([null, 32]);
});
it("groups saved pressure and flow charts whose titles put the metric before the object ID", () => {
const charts = [["170486", "压力", "m"], ["175618", "压力", "m"], ["541061", "流量", "m³/h"], ["542017", "流量", "m³/h"]].map(([id, metric, unit]) => {
const command = comparison(id, metric, unit);
command.chart.title = `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`;
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups.map((group) => ({ title: group.title, objects: group.items.map((item) => item.label) }))).toEqual([
{ title: "压力当前曲线与前七天同期对比(m)", objects: ["170486", "175618"] },
{ title: "流量当前曲线与前七天同期对比(m³/h)", objects: ["541061", "542017"] }
]);
});
it("recognizes the same explicit object comparison in all three saved title formats", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("159673")];
charts[0].chart.title = "170486 压力三小时与历史对照(m)";
charts[1].chart.title = "测点 175618 压力三小时与历史对照(m)";
charts[2].chart.title = "压力测点 159673 三小时与历史对照(m)";
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("压力三小时与历史对照(m)");
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618", "159673"]);
});
it("groups saved date-only historical labels without rewriting commands, readings or evidence", () => {
const charts = [legacyComparison("170486"), legacyComparison("175618")];
charts[1].chart.series[1].data[1] = 0;
const original = structuredClone(charts);
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
groups[0].items.forEach((item, index) => {
expect(item.command).toBe(charts[index]);
expect(item.command.chart.series).toBe(charts[index].chart.series);
expect(item.command.chart.series[1].sample_times).toBe(charts[index].chart.series[1].sample_times);
expect(item.command.evidence_refs).toBe(charts[index].evidence_refs);
});
expect(charts).toEqual(original);
});
it("keeps legacy comparisons with different units, windows or current dates separate", () => {
const shifted = legacyComparison("175618");
shifted.chart.x_data = ["08:00", "08:15"];
shifted.chart.series[0].sample_times = ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"];
shifted.chart.series[1].sample_times = [null, "2026-07-09T08:15:00+08:00"];
const charts = [legacyComparison("170486"), legacyComparison("P2", "压力", "MPa"), shifted, legacyComparison("P3", "压力", "m", "2026-07-17")];
expect(groupInvestigationCharts(charts)).toHaveLength(charts.length);
});
it("leaves ambiguous or mixed-object legacy charts and unproven historical dates separate", () => {
const ambiguous = legacyComparison("P2"); ambiguous.chart.title = "压力测点历史对照";
const foreign = legacyComparison("P3"); foreign.chart.series[1].name = "P4 07-09";
const mixed = legacyComparison("P4"); mixed.chart.series.push({ ...mixed.chart.series[0], name: "P5 当前 2026-07-16" });
const missing = legacyComparison("P5"); delete missing.chart.series[1].sample_times;
const mismatched = legacyComparison("P6"); mismatched.chart.series[1].sample_times = [null, "2026-07-10T08:00:00+08:00"];
const empty = legacyComparison("P7"); empty.chart.series[1].data = [null, null]; empty.chart.series[1].sample_times = [null, null];
const sameDateInUtc8 = legacyComparison("P8"); sameDateInUtc8.chart.series[1].sample_times = [null, "2026-07-09T00:00:00Z"];
const charts = [legacyComparison("170486"), ambiguous, foreign, mixed, missing, mismatched, empty, sameDateInUtc8];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(charts.length - 1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "P8"]);
});
it("keeps incompatible units, sampling windows, comparison dates and ordinary trends separate", () => {
const base = comparison("170486");
const window = comparison("175618"); window.chart.x_data = ["08:00", "08:15"];
const trend = comparison("159673"); trend.chart.series = [trend.chart.series[0]];
expect(groupInvestigationCharts([base, comparison("P2", "压力", "MPa"), comparison("P3", "压力", "m", "2026-07-17"), window, trend])).toHaveLength(5);
const noTimes = [comparison("P4"), comparison("P5", "压力", "m", "2026-07-17")];
for (const command of noTimes) for (const series of command.chart.series) delete series.sample_times;
expect(groupInvestigationCharts(noTimes)).toHaveLength(2);
});
it("keeps group identity stable as objects arrive or are closed and leaves ambiguous titles alone", () => {
const first = comparison("170486"), second = comparison("175618");
expect(groupInvestigationCharts([first])[0].id).toBe(groupInvestigationCharts([second, first])[0].id);
const ambiguous = comparison("159673"); ambiguous.chart.title = "压力同期对照";
expect(groupInvestigationCharts([first, ambiguous])).toHaveLength(2);
expect(groupInvestigationCharts([])).toEqual([]);
});
});
@@ -0,0 +1,73 @@
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
export type InvestigationChartGroup = {
id: string;
title: string;
items: Array<{ label: string; command: InvestigationChart }>;
};
type ChartSeries = InvestigationChart["chart"]["series"][number];
/** Saved date-only legends identify a day only when their actual observations prove it. */
function matchesHistoricalDate(series: ChartSeries) {
if (!/^(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/.test(series.name) ||
!series.sample_times || series.sample_times.length !== series.data.length) return false;
let observedDate: string | null = null;
for (let index = 0; index < series.data.length; index++) {
const at = series.sample_times[index];
if (series.data[index] === null) {
if (at !== null) return false;
continue;
}
if (typeof at !== "string") return false;
const time = Date.parse(at);
if (!Number.isFinite(time)) return false;
const date = new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
if (date.slice(5) !== series.name || observedDate !== null && observedDate !== date) return false;
observedDate = date;
}
return observedDate !== null;
}
function matchesObjectSeries(command: InvestigationChart, id: string) {
const { chart } = command;
const prefixed = (series: ChartSeries) => series.name.startsWith(`${id} `);
if (chart.series.every(prefixed)) return true;
const current = chart.series.filter((series) => series.type !== "scatter");
return current.length === 1 && prefixed(current[0]) &&
(current[0].type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)) === "line" &&
chart.series.every((series) => prefixed(series) || series.type === "scatter" && matchesHistoricalDate(series));
}
/** Group only explicitly named objects with the same comparison, axes and sampling window. */
export function groupInvestigationCharts(charts: InvestigationChart[]): InvestigationChartGroup[] {
const groups = new Map<string, InvestigationChartGroup>();
for (const command of charts) {
const { chart } = command;
const scada = chart.scada;
const boundDevices = new Set(chart.series.map((series) => series.device_id));
const typed = scada?.role === "comparison" && boundDevices.size === 1 &&
!boundDevices.has(undefined) && chart.series.every((series) => series.day_offset !== undefined);
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
const object = typed ? {
id: chart.series[0].device_id!,
title: `${scada.metric === "pressure" ? "压力" : "流量"}当前曲线与前七天同期对比(${scada.unit})`
} : match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && (typed || chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id));
const id = typed ? JSON.stringify(["scada-comparison", scada.metric, scada.unit.replace("³", "3").replace(/\s/g, "").toLowerCase(),
Date.parse(scada.window_start), Date.parse(scada.window_end), scada.value_field ?? "monitored_value", chart.x_data
]) : comparable ? JSON.stringify([
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
chart.series.filter((series) => series.type !== "scatter").map((series) => [
series.name.slice(object.id.length).trim(), series.sample_times ?? null
])
]) : command.chart_id;
let group = groups.get(id);
if (!group) {
group = { id, title: comparable ? object.title : chart.title, items: [] };
groups.set(id, group);
}
group.items.push({ label: comparable ? object.id : chart.title, command });
}
return [...groups.values()];
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases"; import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview"; import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
@@ -47,9 +48,18 @@ describe("switchable investigation Dev cases", () => {
expect(nodes[0].props.rows).toHaveLength(50); expect(nodes[0].props.rows).toHaveLength(50);
expect(nodes[1].props.rows).toEqual([]); expect(nodes[1].props.rows).toEqual([]);
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts")); const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props; const commands = charts.commands.filter((command) => command.action === "open_chart");
expect(pressure.x_data).toHaveLength(96); expect(charts.blocks).toHaveLength(0);
expect(pressure.series).toHaveLength(8); expect(commands).toHaveLength(13);
const groups = groupInvestigationCharts(commands);
expect(groups.map((group) => group.items.length)).toEqual([8, 4, 1]);
for (const { chart } of commands.slice(0, 12)) {
expect(chart.chart_type).toBe("time_comparison");
expect(chart.x_data).toHaveLength(96);
expect(chart.series).toHaveLength(8);
expect(chart.series[0].sample_times).toHaveLength(96);
expect(chart.series[1].data.filter((value) => value !== null)).toHaveLength(3);
}
}); });
it.each(["data_only", "candidates_table", "valves_table"] as const)( it.each(["data_only", "candidates_table", "valves_table"] as const)(
@@ -65,6 +65,11 @@ export const investigationDevCases = [
id: "invalid_chart", id: "invalid_chart",
label: "错误:图表数据不齐", label: "错误:图表数据不齐",
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。" expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
},
{
id: "invalid_comparison",
label: "错误:同期时刻错位",
expected: "第 1 行拒绝历史采样时间与横轴槽位不一致的复合图,整批不执行。"
} }
] as const; ] as const;
@@ -258,5 +263,14 @@ export function createInvestigationDevCase(
} }
} }
]); ]);
case "invalid_comparison":
return invalidJsonl([{ record_type: "presentation", command: {
action: "open_chart", chart_id: "dev-invalid-comparison", evidence_refs: ["dev:synthetic"],
chart: { title: "错误案例:历史点时刻错位", chart_type: "time_comparison", x_data: ["08:00", "08:15"],
series: [
{ name: "当前", type: "line", data: [32, 30], sample_times: ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"] },
{ name: "历史", type: "scatter", data: [33, null], sample_times: ["2026-07-15T08:15:00+08:00", null] }
] }
} }]);
} }
} }
@@ -3,9 +3,9 @@ import type { AnswerUiBlock } from "@/features/agent";
import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview"; import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview";
export const complexInvestigationDevCases = [ export const complexInvestigationDevCases = [
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "3 张图、2 张表与合成覆盖面;可同时放大对比,单位分别展示,不查询真实要素。" }, { id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "压力与管流分别聚合,另有方案柱状图、2 张表与合成覆盖面;右侧切换对象,下方控制图例。" },
{ id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" }, { id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" },
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "96 个时间点、8 条压力序列,另有流量与方案柱状图;可切换数据表、筛选区间并独立放大。" } { id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "8 个压力点与 4 个管段各含 96 个时刻、当前曲线与七日历史散点,聚合为 2 个子图表,由总图表窗口统一管理,另有方案柱状图。" }
] as const; ] as const;
type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"]; type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"];
@@ -19,25 +19,40 @@ const ui = (id: string, node: Node) => createUiRecord({
const table = (id: string, title: string, columns: string[], rows: string[][]) => const table = (id: string, title: string, columns: string[], rows: string[][]) =>
ui(id, { type: "Table", props: { title, columns, rows }, children: [] }); ui(id, { type: "Table", props: { title, columns, rows }, children: [] });
const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`); const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const pressure = { const comparisonChart = (id: string, title: string, axis: string, baseline: number[], current: number[]) => ({
title: "合成全天压力对比", chart_type: "line" as const, x_data: times, title: `${id} ${title}`, chart_type: "time_comparison" as const,
series: Array.from({ length: 8 }, (_, series) => ({ x_axis_name: "时间", y_axis_name: axis, x_data: times,
name: `合成节点 ${INVESTIGATION_DEV_NETWORK.nodeAnchors[series].id} · ${series < 4 ? "主干管" : "末端支线"}压力水头(m)`, series: [{ name: `${id} 当前 2026-07-16`, type: "line" as const, data: current,
data: times.map((_, index) => Number((34 + series * 0.6 + Math.sin(index / 7 + series) * 2.4 - (index >= 32 && index < 44 ? 7 - series * 0.4 : 0)).toFixed(2))) sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
})) ...Array.from({ length: 7 }, (_, day) => {
}; const date = `2026-07-${String(9 + day).padStart(2, "0")}`;
const flow = { return { name: `${id} ${date}`, type: "scatter" as const,
title: "合成分区流量趋势", chart_type: "line" as const, x_data: times, data: times.map((_, index) => index >= 40 && index <= 42 ? Number((baseline[index] + day * 0.1).toFixed(2)) : null),
series: Array.from({ length: 4 }, (_, series) => ({ name: `合成分区 ${series + 1} 流量(m³/h)`, sample_times: times.map((time, index) => index >= 40 && index <= 42 ? `${date}T${time}:00+08:00` : null) };
data: times.map((_, index) => Number((120 + series * 28 + Math.sin(index / 9 + series) * 24 + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))) })) })]
}; });
const pressure = Array.from({ length: 8 }, (_, object) => {
const baseline = times.map((_, index) => Number((34 + object * 0.6 + Math.sin(index / 7 + object) * 2.4).toFixed(2)));
return comparisonChart(INVESTIGATION_DEV_NETWORK.nodeAnchors[object].id, "合成全天压力对比", "压力水头(m)", baseline,
baseline.map((value, index) => Number((value - (index >= 32 && index < 44 ? 7 - object * 0.4 : 0)).toFixed(2))));
});
const flow = Array.from({ length: 4 }, (_, object) => {
const baseline = times.map((_, index) => Number((120 + object * 28 + Math.sin(index / 9 + object) * 24).toFixed(1)));
return comparisonChart(INVESTIGATION_DEV_NETWORK.pipeIds[object], "合成分区流量趋势", "流量(m³/h)", baseline,
baseline.map((value, index) => Number((value + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))));
});
const chart = (id: string, value: ReturnType<typeof comparisonChart>) => createPresentationRecord({
action: "open_chart", chart_id: `dev-complex-${id}`, evidence_refs: ["dev:synthetic"], chart: value
});
const objectCharts = () => [
...pressure.map((value, index) => chart(`pressure-${index + 1}`, value)),
...flow.map((value, index) => chart(`flow-${index + 1}`, value))
];
const comparison = { const comparison = {
title: "合成候选方案影响对比", chart_type: "bar" as const, title: "合成候选方案影响对比", chart_type: "bar" as const,
x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"], x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"],
series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }] series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }]
}; };
const chart = (id: string, value: typeof pressure | typeof flow | typeof comparison) =>
ui(id, { type: "Chart", props: { ...value, source: "本地确定性合成数据,仅供展示测试;曲线变化和方案名称均非实际分析结果。" }, children: [] });
const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"]; const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"];
const rows = Array.from({ length: 50 }, (_, index) => [ const rows = Array.from({ length: 50 }, (_, index) => [
@@ -53,7 +68,8 @@ const rows = Array.from({ length: 50 }, (_, index) => [
export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string { export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string {
if (caseId === "complex_charts") return serializeInvestigationDevJsonl([ if (caseId === "complex_charts") return serializeInvestigationDevJsonl([
chart("pressure", pressure), chart("flow", flow), chart("comparison", comparison) ...objectCharts(),
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison })
]); ]);
if (caseId === "complex_tables") return serializeInvestigationDevJsonl([ if (caseId === "complex_tables") return serializeInvestigationDevJsonl([
table("wide-table", "合成候选核查宽表", columns, rows), table("wide-table", "合成候选核查宽表", columns, rows),
@@ -69,7 +85,7 @@ export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longit
createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic", createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic",
data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` }, data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` },
geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }), geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }),
chart("pressure", pressure), chart("flow", flow), ...objectCharts(),
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }), createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }),
table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)), table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)),
table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"], table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"],
@@ -5,8 +5,22 @@ import {
parseInvestigationDevJsonl parseInvestigationDevJsonl
} from "./investigation-dev-preview"; } from "./investigation-dev-preview";
import type { OperationalTask } from "../api/operational-task-client"; import type { OperationalTask } from "../api/operational-task-client";
import chartCases from "../../../../tests/fixtures/chart-publication-cases.json";
describe("investigation Dev JSONL", () => { describe("investigation Dev JSONL", () => {
it.each(chartCases)("uses publication semantics for command and UI examples: $name", ({ chart, accepted }) => {
const records = [
{ record_type: "presentation", command: { action: "open_chart", chart_id: "comparison", chart, evidence_refs: ["dev:synthetic"] } },
{ record_type: "ui_block", block: { version: "answer-ui@1", block_id: "comparison", fallback_text: "合成测试", spec: {
root: "chart", elements: { chart: { type: "Chart", props: { ...chart, source: "合成测试" }, children: [] } }
} } }
];
for (const record of records) {
const parse = () => parseInvestigationDevJsonl(JSON.stringify(record));
if (accepted) expect(parse).not.toThrow();
else expect(parse).toThrow("第 1 行");
}
});
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => { it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
const result = parseInvestigationDevJsonl( const result = parseInvestigationDevJsonl(
createInvestigationDevSample() createInvestigationDevSample()
@@ -130,7 +144,7 @@ describe("migrated example data", () => {
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw"); await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
const { PRESENTATION_ACTIONS } = await import("@/features/agent"); const { PRESENTATION_ACTIONS } = await import("@/features/agent");
const preview = parseInvestigationDevJsonl(input); const preview = parseInvestigationDevJsonl(input);
expect(preview.commands.map((command) => command.action).sort()).toEqual( expect([...new Set(preview.commands.map((command) => command.action))].sort()).toEqual(
[...PRESENTATION_ACTIONS].sort() [...PRESENTATION_ACTIONS].sort()
); );
expect(preview.blocks).toHaveLength(1); expect(preview.blocks).toHaveLength(1);
@@ -2,6 +2,8 @@ import { z } from "zod";
import { import {
parseAnswerUiBlock, parseAnswerUiBlock,
presentationCommandSchema, presentationCommandSchema,
safeChartPublicationInput,
chartPublicationError,
type AnswerUiBlock type AnswerUiBlock
} from "@/features/agent"; } from "@/features/agent";
import { type InvestigationPresentationCommand } from "../api/investigation-presentation"; import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
@@ -36,7 +38,11 @@ export const investigationActionLabels: Record<InvestigationPresentationCommand[
}; };
const references = z.array(z.string().trim().min(1).max(128)).max(100); const references = z.array(z.string().trim().min(1).max(128)).max(100);
const command = presentationCommandSchema; const command = presentationCommandSchema.superRefine((value, ctx) => {
if (value.action !== "open_chart") return;
const chart = safeChartPublicationInput.safeParse(value.chart);
if (!chart.success) for (const issue of chart.error.issues) ctx.addIssue({ ...issue, path: ["chart", ...issue.path] });
});
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict(); const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
const uiRecord = z const uiRecord = z
.object({ .object({
@@ -78,7 +84,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
if (record.record_type === "presentation") { if (record.record_type === "presentation") {
const parsed = presentationRecord.safeParse(value); const parsed = presentationRecord.safeParse(value);
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`); if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法:${parsed.error.issues[0]?.message ?? "请检查动作、参数和范围。"}`);
const data = parsed.data; const data = parsed.data;
result.commands.push(data.command); result.commands.push(data.command);
} else if (record.record_type === "ui_block") { } else if (record.record_type === "ui_block") {
@@ -86,6 +92,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null); const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
if (!block || blockIds.has(block.block_id)) if (!block || blockIds.has(block.block_id))
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`); throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
validateChartNodes(block, `第 ${line} 行`);
blockIds.add(block.block_id); blockIds.add(block.block_id);
result.blocks.push(block); result.blocks.push(block);
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。"); if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
@@ -107,6 +114,7 @@ export function createPresentationRecord(command: InvestigationPresentationComma
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) { export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" }); const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
if (!normalized) throw new Error("界面块未通过当前组件协议校验。"); if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
validateChartNodes(normalized, "界面块");
return uiRecord.parse({ return uiRecord.parse({
record_type: "ui_block", record_type: "ui_block",
block: normalized, block: normalized,
@@ -114,6 +122,15 @@ export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []
}); });
} }
function validateChartNodes(block: AnswerUiBlock, prefix: string) {
for (const node of Object.values(block.spec.elements)) {
if (node.type !== "Chart") continue;
// The answer catalog owns its text limits; only publication time semantics are shared.
const message = chartPublicationError(node.props as Parameters<typeof chartPublicationError>[0]);
if (message) throw new Error(`${prefix}图表不合法:${message}`);
}
}
export function serializeInvestigationDevJsonl(records: unknown[]) { export function serializeInvestigationDevJsonl(records: unknown[]) {
const input = records.map((record) => JSON.stringify(record)).join("\n"); const input = records.map((record) => JSON.stringify(record)).join("\n");
parseInvestigationDevJsonl(input); parseInvestigationDevJsonl(input);
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { investigationDiagramSchema } from "../api/investigation-report";
import { styleInvestigationDiagram } from "./investigation-diagram-style";
describe("investigation diagrams as process references", () => {
it("reads a legacy running node as a neutral reference without suggesting live execution", () => {
const diagram = investigationDiagramSchema.parse({ title: "流程参考", mermaid: "flowchart TD\nA[核查]",
node_refs: [{ node_id: "A", label: "核查", status: "running" }] });
const styled = styleInvestigationDiagram(diagram);
expect(styled).toContain("class A tjReference;");
expect(diagram.node_refs[0].status).toBe("reference");
expect(styled).not.toContain("class A tjRunning;");
expect(styled).not.toContain("class A tjRecordedRunning;");
});
it("distinguishes a recorded finding from task completion and preserves failed paths", () => {
const diagram = investigationDiagramSchema.parse({ title: "调查脉络", mermaid: "flowchart TD\nA[原尝试] --> B[替代路线]",
node_refs: [{ node_id: "A", label: "原尝试", status: "failed", detail: "接口超时" },
{ node_id: "B", label: "替代路线", status: "recorded" }] });
const styled = styleInvestigationDiagram(diagram);
expect(styled).toContain("class A tjFailed;");
expect(styled).toContain("class B tjRecorded;");
expect(styled.startsWith(diagram.mermaid)).toBe(true);
expect(diagram.node_refs[0].detail).toBe("接口超时");
});
});
@@ -70,54 +70,79 @@ describe("investigation SVG post-processing", () => {
}); });
describe("investigation diagram presentation", () => { describe("investigation diagram presentation", () => {
it("applies recorded states to bare class-attached declarations and ignores partial IDs", () => { it("styles explicit reference annotations and ignores partial node ids", () => {
const styled = styleInvestigationDiagram({ title: "核查", mermaid: "flowchart TD\nQUERY:::tjAction --> REVIEW:::tjHuman", const styled = styleInvestigationDiagram({ title: "核查", mermaid: "flowchart TD\nQUERY:::tjAction --> REVIEW:::tjHuman",
node_refs: [{ node_id: "QUERY", label: "核查", status: "running", evidence_refs: [], action_refs: [] }, node_refs: [{ node_id: "QUERY", label: "核查", status: "reference", evidence_refs: [], action_refs: [] },
{ node_id: "REVIEW", label: "复核", status: "waiting_human", evidence_refs: [], action_refs: [] }, { node_id: "REVIEW", label: "复核", status: "needs_input", evidence_refs: [], action_refs: [] },
{ node_id: "QUER", label: "无效引用", status: "completed", evidence_refs: [], action_refs: [] }] }, true); { node_id: "QUER", label: "无效引用", status: "recorded", evidence_refs: [], action_refs: [] }] });
expect(styled).toContain("class QUERY tjRunning;"); expect(styled).toContain("class QUERY tjReference;");
expect(styled).toContain("class REVIEW tjWaitingHuman;"); expect(styled).toContain("class REVIEW tjNeedsInput;");
expect(styled).not.toContain("class QUER "); expect(styled).not.toContain("class QUER ");
}); });
it("retains source relationships and only styles declared business references", () => { it("keeps relationships and evidence neutral until a result is explicitly annotated", () => {
const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]'; const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]';
const styled = styleInvestigationDiagram({ const styled = styleInvestigationDiagram({ title: "调查脉络", mermaid: source, node_refs: [
title: "调查脉络",
mermaid: source,
node_refs: [
{ node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] }, { node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] },
{ node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] } { node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] }
] ] });
});
expect(styled.startsWith(source)).toBe(true); expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain("class B tjUnknown;"); expect(styled).toContain("class B tjReference;");
expect(styled).not.toContain("class missing "); expect(styled).not.toContain("class missing ");
expect(styled).not.toContain("class B tjCompleted;"); expect(styled).not.toContain("class B tjRecorded;");
expect(styled).not.toContain("class B tjRunning;");
}); });
it("uses blue only for an explicitly running current diagram, and neutralizes historical running classes", () => { it("neutralizes old running classes even when the legacy source supplied them", () => {
const diagram = { title: "当前核查", mermaid: "flowchart TD\nA[查询]:::tjAgent --> B[判断]:::tjDecision", const styled = styleInvestigationDiagram({ title: "旧图参考", mermaid: "flowchart TD\nA[查询]:::tjRunning",
node_refs: [{ node_id: "A", label: "查询", status: "running" as const, evidence_refs: ["e1"], action_refs: [] }] }; node_refs: [] });
expect(styleInvestigationDiagram(diagram, true)).toContain("class A tjRunning;"); expect(styled).toContain("classDef tjRunning fill:#FFFFFF");
expect(styleInvestigationDiagram(diagram, true)).not.toContain("class A tjEvidence;"); expect(styled).not.toContain("#4263C7");
const historical = styleInvestigationDiagram(diagram, false);
expect(historical).not.toContain("class A tjRunning;");
expect(historical).toContain("class A tjRecordedRunning;");
expect(historical).not.toContain("#4263C7");
}); });
it.each([ it.each([
["pending", "tjPending"], ["running", "tjRunning"], ["reference", "tjReference"], ["recorded", "tjRecorded"], ["needs_input", "tjNeedsInput"],
["completed", "tjCompleted"], ["waiting", "tjWaiting"], ["failed", "tjFailed"], ["not_taken", "tjNotTaken"]
["waiting_human", "tjWaitingHuman"], ["failed", "tjFailed"], ] as const)("displays the saved %s annotation without inferring other node results", (status, className) => {
["skipped", "tjSkipped"], ["cancelled", "tjCancelled"]
] as const)("uses the explicit %s state even when evidence or actions are linked", (status, className) => {
const source = "flowchart LR\nA[核查] --> B[下一步]"; const source = "flowchart LR\nA[核查] --> B[下一步]";
const styled = styleInvestigationDiagram({ title: "核查", mermaid: source, const styled = styleInvestigationDiagram({ title: "核查", mermaid: source,
node_refs: [{ node_id: "A", label: "核查", status, evidence_refs: ["e1"], action_refs: ["a1"] }] }, true); node_refs: [{ node_id: "A", label: "核查", status, evidence_refs: ["e1"], action_refs: ["a1"] }] });
expect(styled.startsWith(source)).toBe(true); expect(styled.startsWith(source)).toBe(true);
expect(styled).toContain(`class A ${className};`); expect(styled).toContain(`class A ${className};`);
expect(styled).not.toContain("class A tjEvidence;"); expect(styled).not.toContain("class A tjEvidence;");
expect(styled).not.toContain("class A tjAction;"); expect(styled).not.toContain("class A tjAction;");
expect(styled).not.toContain("class B tjCompleted;"); expect(styled).not.toContain("class B tjRecorded;");
});
});
describe("investigation diagram render reuse", () => {
it("shares an in-flight layout and rebinds SVG ids for each mounted copy", async () => {
const renderSvg = vi.fn(async (id: string) => ({ svg:
`<svg xmlns="http://www.w3.org/2000/svg" id="${id}"><defs><marker id="${id}-arrow"/></defs><path marker-end="url(#${id}-arrow)"/></svg>` }));
vi.spyOn(mermaid, "getMermaid").mockReturnValue({ initialize: vi.fn(), render: renderSvg });
const first = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" }).render("first-render", "flowchart TD\nA[核查]");
const second = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" }).render("second-render", "flowchart TD\nA[核查]");
const [original, reused] = await Promise.all([first, second]);
expect(renderSvg).toHaveBeenCalledTimes(1);
expect(original.svg).toContain('id="first-render"');
expect(reused.svg).toContain('id="second-render"');
expect(reused.svg).toContain("url(#second-render-arrow)");
expect(reused.svg).not.toContain("first-render");
await investigationDiagramPlugins.mermaid.getMermaid({ theme: "dark" }).render("dark-render", "flowchart TD\nA[核查]");
expect(renderSvg).toHaveBeenCalledTimes(2);
const configured = investigationDiagramPlugins.mermaid.getMermaid({ theme: "base" });
configured.initialize({ theme: "forest" });
await configured.render("configured-render", "flowchart TD\nA[核查]");
expect(renderSvg).toHaveBeenCalledTimes(3);
});
it("retries failed rendering and bounds retained layouts", async () => {
const renderSvg = vi.fn().mockRejectedValueOnce(new Error("render failed")).mockResolvedValue({
svg: '<svg xmlns="http://www.w3.org/2000/svg"/>'
});
vi.spyOn(mermaid, "getMermaid").mockReturnValue({ initialize: vi.fn(), render: renderSvg });
const renderer = investigationDiagramPlugins.mermaid.getMermaid();
await expect(renderer.render("first", "flowchart TD\nA[核查]")).rejects.toThrow("render failed");
await renderer.render("retry", "flowchart TD\nA[核查]");
expect(renderSvg).toHaveBeenCalledTimes(2);
for (let index = 0; index < 32; index++) await renderer.render(`next-${index}`, `flowchart TD\nN${index}[步骤]`);
await renderer.render("after-eviction", "flowchart TD\nA[核查]");
expect(renderSvg).toHaveBeenCalledTimes(35);
}); });
}); });
@@ -1,5 +1,6 @@
import { mermaid, type MermaidConfig } from "@streamdown/mermaid"; import { mermaid, type MermaidConfig } from "@streamdown/mermaid";
import type { InvestigationDiagram } from "../api/investigation-report"; import type { InvestigationDiagram } from "../api/investigation-report";
import { normalizeInvestigationNodeAnnotation } from "../api/investigation-node-state";
export const INVESTIGATION_DIAGRAM_FONT = export const INVESTIGATION_DIAGRAM_FONT =
'"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif'; '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif';
@@ -51,15 +52,41 @@ export const investigationDiagramConfig: MermaidConfig = {
` `
}; };
type DiagramResult = { svg: string };
type CachedDiagram = { id: string; result: Promise<DiagramResult> };
const diagramCaches = new WeakMap<object, Map<string, CachedDiagram>>();
/** Keep group captions beside crossing links, without moving nodes or changing edges. */ /** Keep group captions beside crossing links, without moving nodes or changing edges. */
export const investigationDiagramPlugins = { export const investigationDiagramPlugins = {
mermaid: { mermaid: {
...mermaid, ...mermaid,
getMermaid(config?: MermaidConfig) { getMermaid(config?: MermaidConfig) {
const renderer = mermaid.getMermaid(config); const renderer = mermaid.getMermaid(config);
let effectiveConfig = config;
const cache = diagramCaches.get(renderer) ?? new Map<string, CachedDiagram>();
diagramCaches.set(renderer, cache);
return { return {
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig), initialize: (nextConfig: MermaidConfig) => { effectiveConfig = nextConfig; renderer.initialize(nextConfig); },
async render(id: string, source: string) { async render(id: string, source: string) {
const key = JSON.stringify([effectiveConfig, source]);
const cached = cache.get(key);
if (cached) {
cache.delete(key);
cache.set(key, cached);
const result = await cached.result;
// SVG markers and CSS must belong to this mounted instance.
return { ...result, svg: result.svg.replaceAll(cached.id, id) };
}
const result = renderSvg(id, source, effectiveConfig);
const entry = { id, result };
cache.set(key, entry);
if (cache.size > 32) cache.delete(cache.keys().next().value!);
try { return await result; }
catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; }
}
};
async function renderSvg(id: string, source: string, renderConfig?: MermaidConfig) {
// Streamdown's two-argument renderer uses Mermaid's temporary #d{id} // Streamdown's two-argument renderer uses Mermaid's temporary #d{id}
// under body. It must remain measurable, but never enter page flow. // under body. It must remain measurable, but never enter page flow.
// Scope the rule to this render, including fullscreen and error paths. // Scope the rule to this render, including fullscreen and error paths.
@@ -91,7 +118,7 @@ export const investigationDiagramPlugins = {
if (!Number.isFinite(x) || !Number.isFinite(y)) return; if (!Number.isFinite(x) || !Number.isFinite(y)) return;
caption.setAttribute( caption.setAttribute(
"transform", "transform",
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})` `translate(${x + 18}, ${y + (renderConfig?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
); );
// Mermaid paints crossing edges after clusters; keep captions above those edges. // Mermaid paints crossing edges after clusters; keep captions above those edges.
const clusters = cluster.parentElement; const clusters = cluster.parentElement;
@@ -104,30 +131,26 @@ export const investigationDiagramPlugins = {
return result; return result;
} }
} }
};
} }
} }
}; };
export const investigationDiagramStates = { export const investigationDiagramStates = {
pending: { className: "tjPending", label: "尚未开始", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "0" }, reference: { className: "tjReference", label: "参考步骤", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" },
running: { className: "tjRunning", label: "正在执行", fill: "#E8F0FF", stroke: "#4263C7", color: "#284574", width: 2.5, dash: "0" }, recorded: { className: "tjRecorded", label: "已有结果", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" },
completed: { className: "tjCompleted", label: "已完成", fill: "#EDF7F4", stroke: "#6B9E8B", color: "#2D5E4E", width: 1.8, dash: "0" }, needs_input: { className: "tjNeedsInput", label: "待补充条件", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" },
waiting: { className: "tjWaiting", label: "等待依赖 / 外部结果", fill: "#FFFBEA", stroke: "#C8AC60", color: "#78531B", width: 1.8, dash: "6 3" }, failed: { className: "tjFailed", label: "尝试失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
waiting_human: { className: "tjWaitingHuman", label: "等待人工处理", fill: "#FFF6E8", stroke: "#C4A168", color: "#735018", width: 2, dash: "0" }, not_taken: { className: "tjNotTaken", label: "未采用路线", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" }
failed: { className: "tjFailed", label: "执行失败", fill: "#FEF2F2", stroke: "#D96C75", color: "#922C3E", width: 2, dash: "0" },
skipped: { className: "tjSkipped", label: "已跳过", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#64748B", width: 1.5, dash: "8 4" },
cancelled: { className: "tjCancelled", label: "已取消", fill: "#F1F5F9", stroke: "#94A3B8", color: "#64748B", width: 1.5, dash: "2 3" },
recorded_running: { className: "tjRecordedRunning", label: "记录时执行中", fill: "#F6F8FC", stroke: "#A5B5CC", color: "#475976", width: 1.5, dash: "6 3" },
unknown: { className: "tjUnknown", label: "未标注状态", fill: "#FFFFFF", stroke: "#CCD6E5", color: "#536784", width: 1.4, dash: "0" }
} as const; } as const;
export function investigationNodeState( const legacyClasses = {
node: InvestigationDiagram["node_refs"][number], tjPending: "reference", tjRunning: "reference", tjRecordedRunning: "reference", tjUnknown: "reference",
running = false tjCompleted: "recorded", tjWaiting: "needs_input", tjWaitingHuman: "needs_input",
): keyof typeof investigationDiagramStates { tjSkipped: "not_taken", tjCancelled: "not_taken"
if (node.status === "running" && !running) return "recorded_running"; } as const;
return node.status ?? "unknown";
export function investigationNodeState(node: InvestigationDiagram["node_refs"][number]): keyof typeof investigationDiagramStates {
return normalizeInvestigationNodeAnnotation(node.status);
} }
function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) { function nodeStyle(state: typeof investigationDiagramStates[keyof typeof investigationDiagramStates]) {
@@ -138,12 +161,12 @@ export const investigationDiagramNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1]; node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
/** Update state-only changes in place, preserving the reader's pan and zoom. */ /** Update state-only changes in place, preserving the reader's pan and zoom. */
export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram, running: boolean) { export function paintInvestigationDiagramStates(root: Element, diagram: InvestigationDiagram) {
const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node])); const refs = new Map(diagram.node_refs.map((node) => [node.node_id, node]));
const classes = Object.values(investigationDiagramStates).map((state) => state.className); const classes = [...Object.values(investigationDiagramStates).map((state) => state.className), ...Object.keys(legacyClasses)];
root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => { root.querySelectorAll<SVGGElement>("svg g.node").forEach((node) => {
const ref = refs.get(investigationDiagramNodeId(node) ?? ""); const ref = refs.get(investigationDiagramNodeId(node) ?? "");
const state = investigationDiagramStates[ref ? investigationNodeState(ref, running) : "unknown"]; const state = investigationDiagramStates[ref ? investigationNodeState(ref) : "reference"];
node.classList.remove(...classes); node.classList.remove(...classes);
node.classList.add(state.className); node.classList.add(state.className);
node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`); node.setAttribute("aria-label", `${ref?.label ?? node.textContent?.trim() ?? "节点"}:${state.label}`);
@@ -162,23 +185,22 @@ export function paintInvestigationDiagramStates(root: Element, diagram: Investig
}); });
} }
/** Color explicit recorded states; relationships and evidence never imply completion. */ /** Color saved reference annotations without deriving state from an execution run. */
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) { export function styleInvestigationDiagram(diagram: InvestigationDiagram) {
const assignments = diagram.node_refs.flatMap((node) => { const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const inSource = new RegExp(`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`, "m").test( const inSource = new RegExp(`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`, "m").test(
diagram.mermaid diagram.mermaid
); );
if (!inSource) return []; if (!inSource) return [];
const state = investigationDiagramStates[investigationNodeState(node, running)]; const state = investigationDiagramStates[investigationNodeState(node)];
return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`]; return [`class ${node.node_id} ${state.className};`, `style ${node.node_id} ${nodeStyle(state)};`];
}); });
// Keep role/reference classes neutral. Their presence conveys association, // Keep role/reference classes neutral. Their presence conveys association,
// while only node_refs.status supplies an execution fact. // and only explicit node annotations describe saved findings.
const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"] const roles = ["default", "tjAgent", "tjHuman", "tjDecision", "tjEvidence", "tjAction"]
.map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.unknown)};`); .map((name) => `classDef ${name} ${nodeStyle(investigationDiagramStates.reference)};`);
const states = Object.entries(investigationDiagramStates).map(([key, state]) => const states = Object.values(investigationDiagramStates).map((state) => `classDef ${state.className} ${nodeStyle(state)};`);
`classDef ${state.className} ${nodeStyle(key === "running" && !running ? investigationDiagramStates.recorded_running : state)};` const legacy = Object.entries(legacyClasses).map(([name, annotation]) => `classDef ${name} ${nodeStyle(investigationDiagramStates[annotation])};`);
); return [diagram.mermaid, ...roles, ...states, ...legacy, ...assignments].join("\n");
return [diagram.mermaid, ...roles, ...states, ...assignments].join("\n");
} }
@@ -10,6 +10,7 @@ import {
type PresentationPorts type PresentationPorts
} from "../map/presentation-command-executor"; } from "../map/presentation-command-executor";
import { presentationCommandLabel } from "./presentation-command-label"; import { presentationCommandLabel } from "./presentation-command-label";
import { presentationCommandCategory } from "./presentation-history-categories";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history"; import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
/** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */ /** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */
@@ -50,6 +51,9 @@ export async function hydrateInvestigationPresentationArchive({
execution_generation: event.execution_generation, execution_generation: event.execution_generation,
command: event.command command: event.command
}); });
const label = parsed.success
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效";
let message: string | undefined; let message: string | undefined;
try { try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。"); if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
@@ -73,12 +77,13 @@ export async function hydrateInvestigationPresentationArchive({
signal.throwIfAborted(); signal.throwIfAborted();
if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue; if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue;
const id = history.begin( const id = history.begin(
parsed.success label,
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效",
"live", "live",
`${task.title} · 第 ${generation} 轮`, `${task.title} · 第 ${generation} 轮`,
{ key, taskId: task.task_id, createdAt: Date.parse(event.occurred_at) } { key, taskId: task.task_id, executionGeneration: event.execution_generation, createdAt: Date.parse(event.occurred_at),
category: parsed.success ? presentationCommandCategory(parsed.data.command.action) : "results",
chartId: parsed.success && (parsed.data.command.action === "open_chart" || parsed.data.command.action === "close_chart")
? parsed.data.command.chart_id : undefined }
); );
history.finish(id, message ? "error" : "completed", message, staging.current); history.finish(id, message ? "error" : "completed", message, staging.current);
} }
@@ -99,6 +104,8 @@ export async function hydrateInvestigationPresentationArchive({
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, { const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key, key,
taskId: task.task_id, taskId: task.task_id,
executionGeneration: report.execution_generation,
category: "results",
createdAt: Date.parse(report.published_at) createdAt: Date.parse(report.published_at)
}); });
history.finish(id, "completed", undefined, current); history.finish(id, "completed", undefined, current);
@@ -0,0 +1,139 @@
import { expect, it } from "vitest";
import { mergePresentationHistory, mergeAdjacentPresentationHistory } from "./merge-presentation-history";
import { groupAdjacentPresentationHistory } from "./presentation-history-categories";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
const metadata = { taskId: "task-a", executionGeneration: 2 };
const chart = (id: string, value = 20) => ({ action: "open_chart" as const, chart_id: id, evidence_refs: ["reading"],
chart: { title: id, chart_type: "line" as const, x_data: ["08:00"], series: [{ name: "压力", data: [value] }] } });
const point = (device_id: string) => ({ type: "Feature" as const, properties: { device_id },
geometry: { type: "Point" as const, coordinates: [121.4, 31.1] } });
it("merges consecutive categories in order and prevents earlier charts leaking into a later group", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
history.update({ ...history.current, charts: [chart("A"), chart("B")] });
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
history.update({ ...history.current, scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
history.record("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
history.update({ ...history.current, charts: [chart("A"), chart("B"), chart("C")] });
history.record("打开图表 · C", "live", "调查", { ...metadata, category: "charts", chartId: "C" });
const groups = groupAdjacentPresentationHistory(history.entries);
expect(groups.map((group) => [group.label, group.entries.length])).toEqual([["图表", 2], ["SCADA 分析", 1], ["图表", 1]]);
const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
expect(merged.map((entry) => entry.id)).toEqual([-1, -3, -4]);
expect(merged.map((entry) => entry.snapshot.charts.map((item) => item.chart_id))).toEqual([["A", "B"], [], ["C"]]);
expect(merged[0].snapshot.scada).toBeNull();
expect(merged[1].snapshot.scada?.items).toHaveLength(1);
expect(merged[2].snapshot.scada).toBeNull();
});
it("groups the selected task's sequence while keeping preview and live results separate", () => {
const history = new PresentationViewHistory();
const record = (extra: Partial<typeof metadata> = {}, source: "live" | "preview" = "live") =>
history.record("打开图表 · A", source, "调查", { ...metadata, category: "charts", chartId: "A", ...extra });
history.update({ ...history.current, charts: [chart("A")] });
record();
record({ taskId: "task-b" });
record();
record({}, "preview");
record({ executionGeneration: 3 });
const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
expect(groupAdjacentPresentationHistory(history.entries)).toHaveLength(5);
expect(merged.map((entry) => entry.mergedStepIds)).toEqual([[1, 3], [5]]);
});
it("merges adjacent categories across historical rounds while preserving each original step", () => {
const history = new PresentationViewHistory();
for (const generation of [1, 7]) {
history.update({ ...emptyPresentationView(), charts: [chart(`G${generation}`)] });
history.record(`打开图表 · G${generation}`, "live", "调查", { ...metadata,
executionGeneration: generation, category: "charts", chartId: `G${generation}` });
}
const groups = groupAdjacentPresentationHistory(history.entries);
expect(groups).toHaveLength(1);
expect(groups[0].entries.map((entry) => entry.executionGeneration)).toEqual([1, 7]);
expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0].snapshot.charts.map((item) => item.chart_id)).toEqual(["G1", "G7"]);
});
it("does not discard pending or failed intervening categories before grouping", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
const failed = history.begin("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
history.finish(failed, "error", "not found");
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
expect(groupAdjacentPresentationHistory(history.entries).map((group) => group.label)).toEqual(["图表", "SCADA 分析", "图表", "地图操作"]);
expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查").map((entry) => entry.mergedStepIds)).toEqual([[1], [3]]);
});
it("keeps a selected first-level group frozen while its adjacent live run grows", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
const selected = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
history.selectMerged(selected);
history.update({ ...history.current, charts: [chart("A"), chart("B")] });
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
const next = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
expect(next.id).toBe(selected.id);
expect(next.mergedStepIds).toEqual([1, 2]);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
history.select(null);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
});
it("combines saved chart and SCADA results with the latest value per object and isolates the task and generation", () => {
const history = new PresentationViewHistory();
history.update({ ...emptyPresentationView(), charts: [chart("old-task")] });
history.record("other task", "live", "其他调查", { ...metadata, taskId: "task-b" });
history.update({ ...emptyPresentationView(), charts: [chart("old-round")] });
history.record("old round", "live", "调查", { ...metadata, executionGeneration: 1 });
history.update({ ...emptyPresentationView(), charts: [chart("A")],
highlights: { type: "FeatureCollection", features: [point("P1")] }, highlightLayers: ["scada"],
scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
const first = history.record("first", "live", "调查", metadata);
history.update({ ...emptyPresentationView(), charts: [chart("A", 25), chart("B")],
highlights: { type: "FeatureCollection", features: [point("P2")] }, highlightLayers: ["pipes"],
scada: { data: { type: "FeatureCollection", features: [point("P2")] },
items: [{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }] } });
const second = history.record("second", "live", "调查", metadata);
// Closing a chart removes it from current but leaves it available in the combined result.
history.update(emptyPresentationView());
history.record("close", "live", "调查", metadata);
history.update({ ...emptyPresentationView(), charts: [chart("failed")] });
history.finish(history.begin("failed", "live", "调查", metadata), "error");
history.record("preview", "preview", "调查", metadata);
const before = structuredClone(history.entries);
const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
expect(merged.mergedStepIds).toEqual([first, second, second + 1]);
expect(merged.snapshot.charts.map((item) => [item.chart_id, item.chart.series[0].data[0]])).toEqual([["A", 25], ["B", 20]]);
expect(merged.snapshot.scada?.items).toEqual([{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }]);
expect(merged.snapshot.highlights.features).toHaveLength(2);
expect(merged.snapshot.scada?.data.features).toHaveLength(2);
expect(merged.snapshot.highlightLayers).toEqual(["scada", "pipes"]);
expect(merged.mergedSummary).toBe("图表 2 张 · SCADA 标记 2 个 · 地图高亮 2 个");
expect(history.entries).toEqual(before);
});
it("pins the combined snapshot while live results arrive and preserves single-step playback", () => {
const history = new PresentationViewHistory();
history.update({ ...emptyPresentationView(), charts: [chart("A")] });
history.record("first", "live", "调查", metadata);
expect(mergePresentationHistory(history.entries, "task-a", 2, "调查")).toBeNull();
history.update({ ...emptyPresentationView(), charts: [chart("B")] });
history.record("second", "live", "调查", metadata);
const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
history.selectMerged(merged);
history.update({ ...emptyPresentationView(), charts: [chart("C")] });
history.record("third", "live", "调查", metadata);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
expect(history.selected?.mergedStepIds).toHaveLength(2);
history.startPlayback(history.entries);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
history.select(null);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["C"]);
expect(history.entries).toHaveLength(3);
});
@@ -0,0 +1,109 @@
import type { FeatureCollection } from "geojson";
import { emptyPresentationView, type PresentationHistoryEntry, type PresentationHistoryCategory } from "./presentation-view-history";
import { groupAdjacentPresentationHistory, presentationHistoryCategories, presentationHistoryCategory, presentationHistoryStepLabel } from "./presentation-history-categories";
function mergeFeatures(collections: FeatureCollection[]): FeatureCollection {
const features = new Map<string, FeatureCollection["features"][number]>();
for (const collection of collections) {
for (const feature of collection.features) {
// Geometry distinguishes equal numeric IDs from different model layers.
const key = JSON.stringify([feature.geometry, feature.id ?? null, feature.properties?.device_id ?? null]);
features.set(key, feature);
}
}
return { type: "FeatureCollection", features: [...features.values()] };
}
/** Compose captured results only. Never replay commands or mutate their saved snapshots. */
export function mergePresentationHistory(
entries: PresentationHistoryEntry[],
taskId: string,
executionGeneration: number,
taskTitle: string,
category?: PresentationHistoryCategory
): PresentationHistoryEntry | null {
const completed = entries.filter((entry) => entry.taskId === taskId &&
entry.executionGeneration === executionGeneration && entry.source === "live" && entry.status === "completed" &&
(!category || presentationHistoryCategory(entry) === category));
if (completed.length < 2) return null;
return mergeCompletedPresentations(completed, taskId, executionGeneration, taskTitle, category);
}
function mergeCompletedPresentations(
completed: PresentationHistoryEntry[], taskId: string, executionGeneration: number,
taskTitle: string, category?: PresentationHistoryCategory
): PresentationHistoryEntry {
const snapshot = emptyPresentationView();
const charts = new Map<string, typeof snapshot.charts[number]>();
const evidence = new Map<string, typeof snapshot.evidence[number]>();
const scada = new Map<string, NonNullable<typeof snapshot.scada>["items"][number]>();
for (const { snapshot: view } of completed) {
for (const chart of view.charts) charts.set(chart.chart_id, chart);
for (const item of view.evidence) evidence.set(item.evidence_id, item);
for (const item of view.scada?.items ?? []) scada.set(item.device_id, item);
if (view.blocks.length) snapshot.blocks = view.blocks;
if (view.records.length) snapshot.records = view.records;
if (view.overlay) snapshot.overlay = view.overlay;
if (view.camera) snapshot.camera = view.camera;
}
snapshot.charts = [...charts.values()];
snapshot.evidence = [...evidence.values()];
snapshot.highlights = mergeFeatures(completed.map((entry) => entry.snapshot.highlights));
snapshot.highlightLayers = [...new Set(completed.flatMap((entry) => entry.snapshot.highlightLayers))];
if (scada.size) snapshot.scada = {
data: mergeFeatures(completed.flatMap((entry) => entry.snapshot.scada ? [entry.snapshot.scada.data] : [])),
items: [...scada.values()]
};
if (category) {
if (category !== "charts") snapshot.charts = [];
if (category !== "scada") snapshot.scada = null;
if (category !== "map") { snapshot.highlights = { type: "FeatureCollection", features: [] }; snapshot.highlightLayers = []; }
if (category !== "overlay") snapshot.overlay = null;
if (category !== "results") { snapshot.blocks = []; snapshot.records = []; }
if (category === "charts" || category === "results") snapshot.camera = null;
}
const categoryIndex = presentationHistoryCategories.findIndex((item) => item.id === category);
const categoryLabel = presentationHistoryCategories[categoryIndex]?.label;
return {
id: category ? -(executionGeneration * 10 + categoryIndex + 1) : -executionGeneration,
taskId,
executionGeneration,
taskTitle,
category,
source: "live",
label: `${categoryLabel ? `${categoryLabel}合并` : "合并展示"} · 第 ${executionGeneration} 轮 · ${completed.length} 个步骤`,
status: "completed",
createdAt: completed.at(-1)!.createdAt,
mergedStepIds: completed.map((entry) => entry.id),
mergedSummary: [
snapshot.charts.length ? `图表 ${snapshot.charts.length} 张` : null,
snapshot.scada?.items.length ? `SCADA 标记 ${snapshot.scada.items.length} 个` : null,
snapshot.highlights.features.length ? `地图高亮 ${snapshot.highlights.features.length} 个` : null,
snapshot.overlay ? "覆盖层 1 个" : null,
snapshot.records.length ? `数据记录 ${snapshot.records.length} 条` : null,
snapshot.blocks.length ? `报告内容 ${snapshot.blocks.length} 项` : null,
snapshot.camera ? "地图视角" : null
].filter(Boolean).join(" · ") || `展示步骤 ${completed.length} 项`,
snapshot
};
}
export function mergeAdjacentPresentationHistory(
entries: PresentationHistoryEntry[], taskId: string, taskTitle: string
): PresentationHistoryEntry[] {
return groupAdjacentPresentationHistory(entries.filter((entry) => entry.taskId === taskId)).flatMap((group) => {
const first = group.entries[0];
if (first.taskId !== taskId || first.executionGeneration === undefined) return [];
const scoped = group.category === "charts" ? group.entries.map((entry) => {
const named = entry.snapshot.charts.filter((chart) => entry.chartId !== undefined
? chart.chart_id === entry.chartId : chart.chart.title === presentationHistoryStepLabel(entry));
return { ...entry, snapshot: { ...entry.snapshot,
charts: entry.chartId !== undefined || named.length ? named : entry.snapshot.charts } };
}) : group.entries;
const completed = scoped.filter((entry) => entry.source === "live" && entry.status === "completed");
if (!completed.length) return [];
const generation = completed.at(-1)!.executionGeneration ?? first.executionGeneration;
const merged = mergeCompletedPresentations(completed, taskId, generation, taskTitle, group.category);
return [{ ...merged, id: group.id, label: group.label }];
});
}
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import type { AnswerUiBlock } from "@/features/agent";
import { presentationChartBlocks } from "./presentation-chart-blocks";
it("collects report charts in document order with distinct identities and intact props", () => {
const chart = { type: "Chart", props: { title: "压力", source: "保存的证据", series: [] }, children: [] };
const block: AnswerUiBlock = { version: "answer-ui@1", block_id: "report", fallback_text: "报告", spec: {
root: "root", elements: {
root: { type: "Stack", props: {}, children: ["second", "first", "text"] },
first: chart, second: chart, orphan: chart,
text: { type: "Text", props: { text: "结论" }, children: [] }
}
} };
const results = presentationChartBlocks([block]);
expect(results.map((item) => item.block_id)).toEqual(["report:second", "report:first"]);
expect(results[0].spec).toEqual({ root: "second", elements: { second: chart } });
expect(results[0].spec.elements.second.props).toBe(chart.props);
expect(block.spec.root).toBe("root");
});
@@ -0,0 +1,23 @@
import type { AnswerUiBlock } from "@/features/agent";
/** Include only reachable Chart nodes, preserving report props and their source. */
export function presentationChartBlocks(blocks: AnswerUiBlock[]): AnswerUiBlock[] {
return blocks.flatMap((block) => {
const charts: AnswerUiBlock[] = [];
const visited = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
visited.add(id);
const element = block.spec.elements[id];
if (!element) return;
if (element.type === "Chart") charts.push({
...block,
block_id: `${block.block_id}:${id}`,
spec: { root: id, elements: { [id]: element } }
});
else element.children?.forEach(visit);
};
visit(block.spec.root);
return charts;
});
}
@@ -0,0 +1,67 @@
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { PresentationHistoryCategory, PresentationHistoryEntry } from "./presentation-view-history";
export const presentationHistoryCategories: Array<{ id: PresentationHistoryCategory; label: string }> = [
{ id: "charts", label: "图表" },
{ id: "scada", label: "SCADA 分析" },
{ id: "map", label: "地图操作" },
{ id: "overlay", label: "覆盖层" },
{ id: "results", label: "报告与数据" }
];
export type PresentationHistoryGroup = {
id: number;
category: PresentationHistoryCategory;
label: string;
entries: PresentationHistoryEntry[];
};
/** Group before excluding failed or pending results so intervening categories retain their position. */
export function groupAdjacentPresentationHistory(entries: PresentationHistoryEntry[]): PresentationHistoryGroup[] {
const groups: PresentationHistoryGroup[] = [];
for (const entry of entries) {
const category = presentationHistoryCategory(entry);
const previousGroup = groups.at(-1);
const previous = previousGroup?.entries.at(-1);
if (previous && previousGroup?.category === category && previous.taskId === entry.taskId &&
previous.source === entry.source) {
previousGroup.entries.push(entry);
} else {
groups.push({ id: -entry.id, category,
label: presentationHistoryCategories.find((item) => item.id === category)!.label, entries: [entry] });
}
}
return groups;
}
export function presentationCommandCategory(action: InvestigationPresentationCommand["action"]): PresentationHistoryCategory {
switch (action) {
case "open_chart": case "close_chart": return "charts";
case "render_scada_analysis": case "clear_scada_analysis": return "scada";
case "set_overlay": case "clear_overlay": return "overlay";
default: return "map";
}
}
/** Label fallback also classifies page-local snapshots created before this update. */
export function presentationHistoryCategory(entry: PresentationHistoryEntry): PresentationHistoryCategory {
if (entry.category) return entry.category;
if (/^(打开|关闭)图表/.test(entry.label)) return "charts";
if (/^(清除 )?SCADA\s*分析/.test(entry.label)) return "scada";
if (/^(显示|隐藏|清除|定位)覆盖层/.test(entry.label)) return "overlay";
if (/^地图|^调整视角|^清除高亮|^定位关联要素/.test(entry.label)) return "map";
return "results";
}
export function presentationHistoryStepLabel(entry: PresentationHistoryEntry): string {
const separator = entry.label.indexOf(" · ");
if (separator >= 0) {
const action = entry.label.slice(0, separator);
const detail = entry.label.slice(separator + 3);
if (action === "打开图表" || action === "显示覆盖层" || action === "展示结果") return detail;
if (action === "关闭图表") return `关闭 · ${detail}`;
if (action === "地图定位") return `定位 · ${detail}`;
if (action === "地图高亮") return `高亮 · ${detail}`;
}
return entry.label === "SCADA 分析" ? "分析结果" : entry.label;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history"; import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
import { mergeAdjacentPresentationHistory } from "./merge-presentation-history";
describe("presentation history", () => { describe("presentation history", () => {
it("updates a retried event in place and ignores completion from its superseded attempt", () => { it("updates a retried event in place and ignores completion from its superseded attempt", () => {
@@ -121,3 +122,54 @@ it("pauses playback on manual seeking and scope reset", () => {
expect(history.playing).toBe(false); expect(history.playing).toBe(false);
expect(history.playbackIds).toEqual([]); expect(history.playbackIds).toEqual([]);
}); });
it("plays and seeks frozen adjacent groups, retaining their merged snapshots across live arrivals and pause", () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("first", "live", "调查", { ...metadata, category: "charts" });
history.record("second", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
expect(history.startPlayback(groups)).toBe(true);
expect(history.selectedId).toBe(-1);
expect(history.selected).toBe(groups[0]);
expect(history.visible).toBe(groups[0].snapshot);
history.record("later map", "live", "调查", { ...metadata, category: "map" });
history.record("later chart", "live", "调查", { ...metadata, category: "charts" });
expect(history.playbackIds).toEqual(groups.map((entry) => entry.id));
expect(history.advancePlayback()).toBe(true);
expect(history.visible).toBe(groups[1].snapshot);
expect(history.selected?.mergedStepIds).toEqual([3]);
expect(history.advancePlayback()).toBe(false);
expect(history.seekPlayback(groups[0].id)).toBe(true);
expect(history.playing).toBe(false);
expect(history.visible).toBe(groups[0].snapshot);
expect(history.startPlayback(history.playbackEntries)).toBe(true);
expect(history.selectedId).toBe(-1);
expect(history.advancePlayback()).toBe(true);
expect(history.visible).toBe(groups[1].snapshot);
history.select(null);
expect(history.playbackEntries).toEqual([]);
expect(history.visible).toBe(history.current);
});
it("starts grouped playback at the parent of a selected original step and restarts after the final group", () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
const first = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
const child = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
history.select(child);
history.startPlayback(groups);
expect(history.selectedId).toBe(-first);
expect(history.selected?.mergedStepIds).toEqual([first, child]);
history.advancePlayback();
history.advancePlayback();
history.startPlayback(history.playbackEntries);
expect(history.selectedId).toBe(-first);
history.select(child);
expect(history.playbackEntries).toEqual([]);
expect(history.selected?.id).toBe(child);
});
@@ -28,6 +28,11 @@ export type PresentationHistoryEntry = {
id: number; id: number;
key?: string; key?: string;
taskId?: string; taskId?: string;
executionGeneration?: number;
mergedStepIds?: number[];
mergedSummary?: string;
category?: PresentationHistoryCategory;
chartId?: string;
label: string; label: string;
source: InvestigationPresentationActivity["source"]; source: InvestigationPresentationActivity["source"];
taskTitle: string | null; taskTitle: string | null;
@@ -36,12 +41,16 @@ export type PresentationHistoryEntry = {
message?: string; message?: string;
snapshot: PresentationView; snapshot: PresentationView;
}; };
export type PresentationHistoryCategory = "charts" | "scada" | "map" | "overlay" | "results";
export type PresentationHistoryControls = { export type PresentationHistoryControls = {
currentEntryId: number | null; currentEntryId: number | null;
entries: PresentationHistoryEntry[]; entries: PresentationHistoryEntry[];
selectedId: number | null; selectedId: number | null;
mergedEntry?: PresentationHistoryEntry | null;
mergedEntries?: PresentationHistoryEntry[];
onSelect: (id: number | null) => void; onSelect: (id: number | null) => void;
playback?: { playback?: {
entries?: PresentationHistoryEntry[];
playing: boolean; playing: boolean;
speed: number; speed: number;
onToggle: () => void; onToggle: () => void;
@@ -66,8 +75,13 @@ export class PresentationViewHistory {
entries: PresentationHistoryEntry[] = []; entries: PresentationHistoryEntry[] = [];
selectedId: number | null = null; selectedId: number | null = null;
playing = false; playing = false;
playbackIds: number[] = []; playbackEntries: PresentationHistoryEntry[] = [];
private sequence = 0; private sequence = 0;
private selectedMerged: PresentationHistoryEntry | null = null;
get playbackIds() {
return this.playbackEntries.map((entry) => entry.id);
}
get currentEntryId() { get currentEntryId() {
if (this.entries.at(-1)?.status === "running") return null; if (this.entries.at(-1)?.status === "running") return null;
@@ -86,7 +100,9 @@ export class PresentationViewHistory {
); );
} }
get selected() { get selected() {
return this.entries.find((entry) => entry.id === this.selectedId) ?? null; if (this.selectedId === this.selectedMerged?.id) return this.selectedMerged;
return this.playbackEntries.find((entry) => entry.id === this.selectedId)
?? this.entries.find((entry) => entry.id === this.selectedId) ?? null;
} }
get visible() { get visible() {
return this.selected?.snapshot ?? this.current; return this.selected?.snapshot ?? this.current;
@@ -98,7 +114,7 @@ export class PresentationViewHistory {
label: string, label: string,
source: PresentationHistoryEntry["source"], source: PresentationHistoryEntry["source"],
taskTitle: string | null, taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {} metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
) { ) {
const existing = metadata.key ? this.entries.find((entry) => entry.key === metadata.key) : null; const existing = metadata.key ? this.entries.find((entry) => entry.key === metadata.key) : null;
const id = existing?.id ?? ++this.sequence; const id = existing?.id ?? ++this.sequence;
@@ -133,7 +149,7 @@ export class PresentationViewHistory {
label: string, label: string,
source: PresentationHistoryEntry["source"], source: PresentationHistoryEntry["source"],
taskTitle: string | null, taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {} metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
) { ) {
const id = this.begin(label, source, taskTitle, metadata); const id = this.begin(label, source, taskTitle, metadata);
this.finish(id, "completed"); this.finish(id, "completed");
@@ -144,32 +160,54 @@ export class PresentationViewHistory {
if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running")) if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running"))
return false; return false;
this.selectedId = id; this.selectedId = id;
this.selectedMerged = null;
this.playbackEntries = [];
return true; return true;
} }
selectMerged(entry: PresentationHistoryEntry) {
this.pausePlayback();
this.selectedMerged = entry;
this.selectedId = entry.id;
this.playbackEntries = [];
}
startPlayback(entries: PresentationHistoryEntry[]) { startPlayback(entries: PresentationHistoryEntry[]) {
this.playbackIds = entries.filter((entry) => entry.status === "completed").map((entry) => entry.id); const completed = entries.filter((entry) => entry.status === "completed");
if (!this.playbackIds.length) return false; if (!completed.length) return false;
const index = this.playbackIds.indexOf(this.selectedId ?? -1); const index = completed.findIndex((entry) => entry.id === this.selectedId ||
if (index < 0 || index === this.playbackIds.length - 1) this.selectedId = this.playbackIds[0]; (this.selectedId !== null && entry.mergedStepIds?.includes(this.selectedId)));
this.seekPlayback(completed[index < 0 || index === completed.length - 1 ? 0 : index].id, completed);
this.playing = true; this.playing = true;
return true; return true;
} }
seekPlayback(id: number, entries = this.playbackEntries) {
const completed = entries.filter((entry) => entry.status === "completed");
const entry = completed.find((entry) => entry.id === id);
if (!entry) return false;
this.pausePlayback();
this.playbackEntries = completed;
this.selectedMerged = entry.mergedStepIds ? entry : null;
this.selectedId = entry.id;
return true;
}
advancePlayback() { advancePlayback() {
if (!this.playing) return false; if (!this.playing) return false;
const next = this.playbackIds[this.playbackIds.indexOf(this.selectedId ?? -1) + 1]; const index = this.playbackEntries.findIndex((entry) => entry.id === this.selectedId);
const next = this.playbackEntries[index + 1];
if (next === undefined) { if (next === undefined) {
this.pausePlayback(); this.pausePlayback();
return false; return false;
} }
this.selectedId = next; this.selectedMerged = next.mergedStepIds ? next : null;
this.selectedId = next.id;
return true; return true;
} }
pausePlayback() { this.playing = false; } pausePlayback() { this.playing = false; }
reset() { reset() {
this.pausePlayback(); this.pausePlayback();
this.playbackIds = []; this.playbackEntries = [];
this.current = emptyPresentationView(); this.current = emptyPresentationView();
this.entries = []; this.entries = [];
this.selectedId = null; this.selectedId = null;
this.selectedMerged = null;
} }
} }
@@ -0,0 +1,5 @@
import { createContext, useContext } from "react";
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
export const ChartWorkspaceContext = createContext<FloatingWindowDock | null>(null);
export const useChartWorkspace = () => useContext(ChartWorkspaceContext);
@@ -0,0 +1,17 @@
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import type { FloatingWindowDock } from "@/shared/ui/floating-window";
import { ChartWorkspaceContext } from "./chart-workspace-context";
export function ChartWorkspaceProvider({ children }: { children: ReactNode }) {
const target = useRef<HTMLDivElement>(null);
const [state, setState] = useState<{ activeId: string | null; view: FloatingWindowDock["view"] }>({ activeId: null, view: "map_split" });
const activate = useCallback((id: string) => {
setState((current) => current.activeId === id ? current : { activeId: id, view: "map_split" });
}, []);
const deactivate = useCallback((id?: string) => {
setState((current) => id && current.activeId !== id ? current : { ...current, activeId: null });
}, []);
const setView = useCallback((view: FloatingWindowDock["view"]) => setState((current) => ({ ...current, view })), []);
const value = useMemo(() => ({ ...state, target, activate, deactivate, setView }), [state, activate, deactivate, setView]);
return <ChartWorkspaceContext.Provider value={value}>{children}</ChartWorkspaceContext.Provider>;
}
@@ -9,6 +9,7 @@ export function WorkbenchContentSplit({
split, split,
showMap, showMap,
minPaneWidth, minPaneWidth,
minDocumentWidth = minPaneWidth,
onMapSizeChange onMapSizeChange
}: { }: {
map: ReactNode; map: ReactNode;
@@ -16,6 +17,7 @@ export function WorkbenchContentSplit({
split: boolean; split: boolean;
showMap: boolean; showMap: boolean;
minPaneWidth: number; minPaneWidth: number;
minDocumentWidth?: number;
onMapSizeChange: (percentage: number) => void; onMapSizeChange: (percentage: number) => void;
}) { }) {
const group = useGroupRef(); const group = useGroupRef();
@@ -24,7 +26,7 @@ export function WorkbenchContentSplit({
group.current?.setLayout( group.current?.setLayout(
split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 } split ? splitLayout.current : showMap ? { map: 100, document: 0 } : { map: 0, document: 100 }
); );
}, [group, split, showMap, minPaneWidth]); }, [group, split, showMap, minPaneWidth, minDocumentWidth]);
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
@@ -56,7 +58,7 @@ export function WorkbenchContentSplit({
<ResizablePanel <ResizablePanel
id="document" id="document"
defaultSize={showMap ? (split ? "50%" : "0%") : "100%"} defaultSize={showMap ? (split ? "50%" : "0%") : "100%"}
minSize={split ? minPaneWidth : showMap ? 0 : "100%"} minSize={split ? minDocumentWidth : showMap ? 0 : "100%"}
maxSize={!split && showMap ? 0 : "100%"} maxSize={!split && showMap ? 0 : "100%"}
className="relative h-full min-h-0 min-w-0" className="relative h-full min-h-0 min-w-0"
> >
@@ -1,7 +1,7 @@
import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context"; import { useReadingWorkspace, type ReadingDocument } from "@/shared/ui/reading-workspace-context";
import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react"; import { ArrowLeft, Focus, Map as MapIcon, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
@@ -10,7 +10,8 @@ import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational
import { AnalysisArtifactWorkspace } from "./analysis-document-view"; import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar"; import { ArtifactPeekBar } from "./artifact-peek-bar";
import { WorkbenchContentSplit } from "./workbench-content-split"; import { WorkbenchContentSplit } from "./workbench-content-split";
import { ARTIFACT_PANE_MIN_WIDTH } from "./workspace-model"; import type { FloatingWindowDock } from "@/shared/ui/floating-window";
import { ARTIFACT_PANE_MIN_WIDTH, CONDITION_PANE_MIN_WIDTH } from "./workspace-model";
import { import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME, ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
@@ -32,6 +33,14 @@ type WorkbenchMainFrameProps = {
surfaceMode: WorkbenchSurfaceMode; surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean; mapSplitAvailable: boolean;
readingSplitAvailable?: boolean; readingSplitAvailable?: boolean;
chartDock?: { window: FloatingWindowDock; splitAvailable: boolean; reserveAgentRail: boolean };
conditionDock?: {
active: boolean;
splitAvailable: boolean;
target: RefObject<HTMLDivElement | null>;
view: ArtifactRequestedView;
onSetView: (view: ArtifactRequestedView) => void;
};
onOpenPendingArtifact: () => void; onOpenPendingArtifact: () => void;
onDestroyPendingArtifact: () => void; onDestroyPendingArtifact: () => void;
onSetArtifactView: (view: ArtifactRequestedView) => void; onSetArtifactView: (view: ArtifactRequestedView) => void;
@@ -50,6 +59,8 @@ export function WorkbenchMainFrame({
surfaceMode: artifactSurfaceMode, surfaceMode: artifactSurfaceMode,
mapSplitAvailable, mapSplitAvailable,
readingSplitAvailable = false, readingSplitAvailable = false,
chartDock,
conditionDock,
onOpenPendingArtifact, onOpenPendingArtifact,
onDestroyPendingArtifact, onDestroyPendingArtifact,
onSetArtifactView, onSetArtifactView,
@@ -59,19 +70,30 @@ export function WorkbenchMainFrame({
const reading = useReadingWorkspace(); const reading = useReadingWorkspace();
const lastReading = useRef<ReadingDocument | null>(null); const lastReading = useRef<ReadingDocument | null>(null);
useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]); useEffect(() => { if (reading?.active) lastReading.current = reading.active; }, [reading?.active]);
const chartActive = Boolean(chartDock?.window.activeId && !reading?.active);
const dock = reading?.active ? null : chartActive && chartDock
? { ...chartDock.window, splitAvailable: chartDock.splitAvailable, onSetView: chartDock.window.setView }
: conditionDock?.active ? conditionDock : null;
const dockActive = Boolean(dock);
const surfaceMode = reading?.active const surfaceMode = reading?.active
? reading.view === "map_split" && !readingSplitAvailable ? reading.view === "map_split" && !readingSplitAvailable
? "focus" ? "focus"
: reading.view : reading.view
: dock
? dock.view === "map_split" && !dock.splitAvailable ? "focus" : dock.view
: artifactSurfaceMode; : artifactSurfaceMode;
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus"; const showMap = surfaceMode !== "focus";
const showArtifact = Boolean(!reading?.active && activeArtifact && surfaceMode !== "map_only"); const showArtifact = Boolean(!reading?.active && !dockActive && activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(activeArtifact && !reading?.active); const showArtifactViewControl = Boolean((activeArtifact || dockActive) && !reading?.active);
const mapOnly = surfaceMode === "map_only"; const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split"; const mapSplit = surfaceMode === "map_split";
const [mapPercentage, setMapPercentage] = useState(50); const [mapPercentage, setMapPercentage] = useState(50);
const viewControlLabel = mapOnly const splitAvailable = dock?.splitAvailable ?? mapSplitAvailable;
const viewControlLabel = dockActive
? mapOnly ? chartActive ? "返回图表管理" : "返回工况任务"
: mapSplit ? chartActive ? "专注图表" : "专注工况" : splitAvailable ? "地图对照" : "显示地图"
: mapOnly
? "返回分析成果" ? "返回分析成果"
: mapSplit : mapSplit
? "专注分析" ? "专注分析"
@@ -94,7 +116,7 @@ export function WorkbenchMainFrame({
<Dialog open={Boolean(reading?.active && surfaceMode === "focus")} <Dialog open={Boolean(reading?.active && surfaceMode === "focus")}
onOpenChange={(open) => { if (!open) reading?.close(); }}> onOpenChange={(open) => { if (!open) reading?.close(); }}>
<div <div
className="workbench-main-frame pointer-events-none absolute inset-0 min-h-0" className={cn("workbench-main-frame pointer-events-none absolute inset-0 min-h-0", chartActive && "max-h-dvh")}
data-testid="workbench-main-frame" data-testid="workbench-main-frame"
data-timeline-mode={timelineMode} data-timeline-mode={timelineMode}
> >
@@ -107,6 +129,7 @@ export function WorkbenchMainFrame({
split={mapSplit} split={mapSplit}
showMap={showMap} showMap={showMap}
minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH} minPaneWidth={reading?.active ? 360 : ARTIFACT_PANE_MIN_WIDTH}
minDocumentWidth={dockActive && !chartActive ? CONDITION_PANE_MIN_WIDTH : undefined}
onMapSizeChange={setMapPercentage} onMapSizeChange={setMapPercentage}
map={ map={
<section <section
@@ -124,6 +147,15 @@ export function WorkbenchMainFrame({
} }
document={ document={
<> <>
<div
ref={conditionDock?.target}
id="condition-workspace"
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0", (!dockActive || chartActive || mapOnly) && "hidden")}
/>
<div ref={chartDock?.window.target} id="chart-workspace"
className={cn("absolute inset-x-0 bottom-0 top-14 min-h-0 min-w-0",
chartActive && surfaceMode === "focus" && chartDock?.reserveAgentRail && "lg:pl-[84px]",
(!chartActive || mapOnly) && "hidden")} />
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? ( {activeArtifact ? (
<motion.section <motion.section
@@ -179,7 +211,7 @@ export function WorkbenchMainFrame({
</Button> </Button>
) : null} ) : null}
{pendingArtifact && !reading?.active ? ( {pendingArtifact && !reading?.active && !dockActive ? (
<ArtifactPeekBar <ArtifactPeekBar
artifact={pendingArtifact} artifact={pendingArtifact}
surfaceMode={surfaceMode} surfaceMode={surfaceMode}
@@ -205,9 +237,9 @@ export function WorkbenchMainFrame({
type="button" type="button"
aria-label={viewControlLabel} aria-label={viewControlLabel}
onClick={() => onClick={() =>
onSetArtifactView( (dock?.onSetView ?? onSetArtifactView)(
surfaceMode === "focus" surfaceMode === "focus"
? mapSplitAvailable ? splitAvailable
? "map_split" ? "map_split"
: "map_only" : "map_only"
: "focus" : "focus"
@@ -243,12 +275,12 @@ export function WorkbenchMainFrame({
</div> </div>
<motion.div <motion.div
className="pointer-events-auto absolute left-3 z-20 mx-auto min-h-0" className={cn("pointer-events-auto absolute left-3 z-20 mx-auto min-h-0", dockActive && !showMap && "hidden")}
initial={false} initial={false}
animate={{ animate={{
bottom: timelineBottom, bottom: timelineBottom,
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth, maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
right: 12 right: dockActive && mapSplit ? `calc(${100 - mapPercentage}% + 12px)` : 12
}} }}
transition={timelineTransition} transition={timelineTransition}
> >
@@ -2,6 +2,8 @@ import type { FeatureCollection, Geometry } from "geojson";
export const ARTIFACT_PANE_MIN_WIDTH = 600; export const ARTIFACT_PANE_MIN_WIDTH = 600;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1; export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH * 2 + 1;
export const CONDITION_PANE_MIN_WIDTH = 880;
export const CONDITION_MAP_SPLIT_MIN_WIDTH = ARTIFACT_PANE_MIN_WIDTH + CONDITION_PANE_MIN_WIDTH + 1;
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis"; export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived"; export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
+61
View File
@@ -0,0 +1,61 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DeferredRender } from "./deferred-render";
let sequence = 0;
const frames = new Map<number, FrameRequestCallback>();
const tick = () => act(() => {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(0));
});
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", undefined);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++sequence, callback); return sequence; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
});
afterEach(() => { cleanup(); frames.clear(); vi.unstubAllGlobals(); });
it("paints loading placeholders before mounting visible drawings one at a time", () => {
render(<><DeferredRender label="正在加载甲图"><p>甲图已就绪</p></DeferredRender>
<DeferredRender label="正在加载乙图"><p>乙图已就绪</p></DeferredRender></>);
expect(screen.getByRole("status", { name: "正在加载甲图" })).toHaveAttribute("aria-busy", "true");
tick();
expect(screen.queryByText("甲图已就绪")).toBeNull();
tick();
expect(screen.getByText("甲图已就绪")).toBeInTheDocument();
expect(screen.queryByText("乙图已就绪")).toBeNull();
tick(); tick();
expect(screen.getByText("乙图已就绪")).toBeInTheDocument();
expect(screen.queryByRole("status")).toBeNull();
});
it("cancels queued drawings when the task is removed", () => {
const Drawing = vi.fn(() => <p>已过期图表</p>);
const { unmount } = render(<DeferredRender label="加载中"><Drawing /></DeferredRender>);
tick();
unmount();
expect(frames.size).toBe(0);
tick(); tick();
expect(Drawing).not.toHaveBeenCalled();
expect(frames.size).toBe(0);
});
it("defers offscreen content until its own viewport intersection", () => {
let intersect!: IntersectionObserverCallback;
const disconnect = vi.fn();
vi.stubGlobal("IntersectionObserver", class {
constructor(callback: IntersectionObserverCallback) { intersect = callback; }
observe() {}
disconnect = disconnect;
});
render(<DeferredRender label="等待显示"><p>可见图表</p></DeferredRender>);
expect(frames.size).toBe(0);
act(() => intersect([{ isIntersecting: false }] as IntersectionObserverEntry[], {} as IntersectionObserver));
expect(frames.size).toBe(0);
act(() => intersect([{ isIntersecting: true }] as IntersectionObserverEntry[], {} as IntersectionObserver));
tick(); tick();
expect(screen.getByText("可见图表")).toBeInTheDocument();
expect(disconnect).toHaveBeenCalled();
});
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "./cn";
const pending: Array<() => void> = [];
let frame = 0;
function schedule() {
if (frame || !pending.length) return;
// Let the task heading and placeholder paint before mounting an expensive plot.
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
frame = 0;
pending.shift()?.();
schedule();
});
});
}
function enqueue(render: () => void) {
pending.push(render);
schedule();
return () => {
const index = pending.indexOf(render);
if (index !== -1) pending.splice(index, 1);
if (!pending.length) { cancelAnimationFrame(frame); frame = 0; }
};
}
export function LoadingSkeleton({ label, className }: { label: string; className?: string }) {
return <div role="status" aria-label={label} aria-busy="true"
className={cn("space-y-3 rounded-lg border border-border bg-card p-4", className)}>
<span className="sr-only">{label}</span>
<div aria-hidden className="space-y-3 motion-safe:animate-pulse">
<div className="h-3 w-2/5 rounded bg-muted" />
<div className="h-32 rounded bg-muted/60" />
<div className="flex gap-3"><div className="h-2 w-1/4 rounded bg-muted" /><div className="h-2 w-1/3 rounded bg-muted" /></div>
</div>
</div>;
}
/** Mount visible drawings one at a time; cancel queued work when their task leaves. */
export function DeferredRender({ children, label, className }: {
children: ReactNode;
label: string;
className?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancel: (() => void) | undefined;
const load = () => { cancel ??= enqueue(() => setReady(true)); };
if (typeof IntersectionObserver === "undefined") { load(); return () => cancel?.(); }
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer.disconnect(); }
}, { rootMargin: "200px" });
if (host.current) observer.observe(host.current);
return () => { observer.disconnect(); cancel?.(); };
}, []);
return <div ref={host} className={className}>{ready ? children : <LoadingSkeleton label={label} />}</div>;
}
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from "vitest";
import { floatingWindowEntries, floatingWindowOrder, raiseFloatingWindow, registerFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
afterEach(() => floatingWindowOrder().forEach(removeFloatingWindow));
it("keeps a stable snapshot, follows stacking order and removes window metadata", () => {
const notify = vi.fn();
const stop = subscribeFloatingWindows(notify);
const first = { id: "first", title: "压力图", focus: vi.fn(), close: vi.fn() };
registerFloatingWindow(first);
const snapshot = floatingWindowEntries();
expect(floatingWindowEntries()).toBe(snapshot);
registerFloatingWindow({ id: "second", title: "流量图", focus: vi.fn(), close: vi.fn() });
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["first", "second"]);
raiseFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second", "first"]);
removeFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second"]);
removeFloatingWindow("second");
expect(floatingWindowEntries()).toEqual([]);
expect(notify).toHaveBeenCalledTimes(5);
stop();
});
it("updates titles without moving windows and calls the latest actions without snapshot churn", () => {
const focus = vi.fn(), close = vi.fn();
registerFloatingWindow({ id: "chart", title: "旧标题", focus: vi.fn(), close: vi.fn() });
const initial = floatingWindowEntries();
registerFloatingWindow({ id: "chart", title: "旧标题", focus, close });
expect(floatingWindowEntries()).toBe(initial);
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
registerFloatingWindow({ id: "chart", title: "新标题", focus, close });
expect(floatingWindowEntries()[0].title).toBe("新标题");
expect(initial[0].title).toBe("旧标题");
expect(floatingWindowOrder()).toEqual(["chart"]);
removeFloatingWindow("chart");
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
+38 -3
View File
@@ -1,23 +1,58 @@
let order: string[] = []; let order: string[] = [];
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let host: HTMLDivElement | undefined; let host: HTMLDivElement | undefined;
export type FloatingWindowEntry = { id: string; title: string; focus: () => void; close: () => void };
const registered = new Map<string, {
entry: FloatingWindowEntry;
actions: Pick<FloatingWindowEntry, "focus" | "close">;
}>();
let entries: readonly FloatingWindowEntry[] = [];
function publishWindows() {
entries = order.flatMap((id) => {
const record = registered.get(id);
return record ? [record.entry] : [];
});
listeners.forEach((listener) => listener());
}
export const floatingWindowOrder = () => order; export const floatingWindowOrder = () => order;
/** Stable between changes, ordered from the back window to the foreground. */
export const floatingWindowEntries = () => entries;
export const subscribeFloatingWindows = (listener: () => void) => { export const subscribeFloatingWindows = (listener: () => void) => {
listeners.add(listener); listeners.add(listener);
return () => { listeners.delete(listener); }; return () => { listeners.delete(listener); };
}; };
export function registerFloatingWindow(entry: FloatingWindowEntry) {
const previous = registered.get(entry.id);
if (previous) {
// Callbacks can change without invalidating the subscribed presentation.
previous.actions.focus = entry.focus;
previous.actions.close = entry.close;
if (previous.entry.title === entry.title) return;
previous.entry = { ...previous.entry, title: entry.title };
} else {
const actions = { focus: entry.focus, close: entry.close };
registered.set(entry.id, { actions, entry: { id: entry.id, title: entry.title,
focus: () => { if (registered.get(entry.id)?.actions === actions) actions.focus(); },
close: () => { if (registered.get(entry.id)?.actions === actions) actions.close(); } } });
if (!order.includes(entry.id)) order = [...order, entry.id];
}
publishWindows();
}
export function raiseFloatingWindow(id: string) { export function raiseFloatingWindow(id: string) {
if (order.at(-1) === id) return; if (order.at(-1) === id) return;
order = [...order.filter((item) => item !== id), id]; order = [...order.filter((item) => item !== id), id];
listeners.forEach((listener) => listener()); publishWindows();
} }
export function removeFloatingWindow(id: string) { export function removeFloatingWindow(id: string) {
if (!order.includes(id)) return; if (!order.includes(id) && !registered.has(id)) return;
registered.delete(id);
order = order.filter((item) => item !== id); order = order.filter((item) => item !== id);
listeners.forEach((listener) => listener()); publishWindows();
} }
export function floatingWindowHost() { export function floatingWindowHost() {
@@ -0,0 +1,5 @@
/** Hosts can reserve a top navigation area without raising it above modal dialogs. */
export function floatingWindowTopInset() {
return Math.max(8, ...Array.from(document.querySelectorAll<HTMLElement>("[data-floating-window-inset-top]"))
.map((element) => element.getBoundingClientRect().bottom + 8));
}

Some files were not shown because too many files have changed in this diff Show More