feat: expand intelligent investigation workspace

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c40c163392
223 changed files with 13155 additions and 5304 deletions
+1 -1
View File
@@ -16,5 +16,5 @@ TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS=8
TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_DEV_PANEL=false
TJWATER_ENABLE_MSW=false TJWATER_ENABLE_MSW=false
# Optional server-only Edge TTS voice. Never exposed through runtime-config.js. # Optional default Mandarin voice. Users can choose another voice in the picker.
EDGE_TTS_VOICE=zh-CN-XiaoxiaoNeural EDGE_TTS_VOICE=zh-CN-XiaoxiaoNeural
+2 -1
View File
@@ -8,7 +8,8 @@
@immutableAssets path /assets/* @immutableAssets path /assets/*
header @immutableAssets Cache-Control "public, max-age=31536000, immutable" header @immutableAssets Cache-Control "public, max-age=31536000, immutable"
handle /api/tts/edge { @edgeTts path /api/tts/edge /api/tts/edge/voices
handle @edgeTts {
reverse_proxy 127.0.0.1:8790 reverse_proxy 127.0.0.1:8790
} }
+8 -2
View File
@@ -15,6 +15,8 @@ Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以
- 经过 Schema 校验的 UI Envelope 和前端动作执行 - 经过 Schema 校验的 UI Envelope 和前端动作执行
- 运行时配置注入、MSW 本地模拟和开发面板 - 运行时配置注入、MSW 本地模拟和开发面板
Dev 的“调查测试”提供统一 command 格式的案例、JSONL 校验与本地预览,无需先选择调查;选中调查后还可检查实时地图、图表和报告界面块。操作说明与验证边界见 [调查展示 Dev 测试](docs/investigation-dev-tools.md)。
## 开发环境 ## 开发环境
需要安装 Node.js、Corepack 和 pnpm。项目声明的包管理器版本为 pnpm 11.8.0。 需要安装 Node.js、Corepack 和 pnpm。项目声明的包管理器版本为 pnpm 11.8.0。
@@ -51,7 +53,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| `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` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
| `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、带时区) |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | | `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
@@ -67,6 +69,10 @@ Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 S
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。外围样板推荐配置 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`;线上 NPMplus 在现有前端 Proxy Host 中增加 `/peripheral` Custom Location,保留该前缀并转发到 `http://192.168.1.114:8001`,Forward Path 留空。容器内部仍监听 `8000`,宿主机使用 `8001` 避免与已有服务冲突。开发和预览模式提供同路径代理,默认使用 `TJWATER_SERVER_API_BASE_URL`。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。外围样板推荐配置 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`;线上 NPMplus 在现有前端 Proxy Host 中增加 `/peripheral` Custom Location,保留该前缀并转发到 `http://192.168.1.114:8001`,Forward Path 留空。容器内部仍监听 `8000`,宿主机使用 `8001` 避免与已有服务冲突。开发和预览模式提供同路径代理,默认使用 `TJWATER_SERVER_API_BASE_URL`。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
回复底部的“音色”入口从 `/api/tts/edge/voices` 加载 Edge TTS 当前提供的全部普通话音色,仅接受 `zh-CN`,排除其他语言、粤语、台湾普通话和地方方言。使用本地 shadcn/ui 的 Popover、Command 和 Button 组合,支持搜索和键盘选择。目录在服务端缓存一小时,界面复用 SWR 内存缓存,不随菜单打开重复请求;HTTP 请求和响应使用 `no-store`,避免版本更新后复用格式不兼容的浏览器磁盘缓存。选择保存在当前浏览器,下一次播放生效;同一次分段朗读固定使用开始时的音色。选择“默认音色”时沿用服务端的普通话配置,显式选择的音色必须通过服务端目录校验。目录加载失败可重试,也可继续使用默认音色。开发服务需要重启以加载语音适配器变更,生产环境需重新构建镜像。
真实语音目录与浏览器播放可通过 `TJWATER_TEST_LIVE_TTS=true PLAYWRIGHT_PORT=5397 pnpm test:browser tests/browser/agent-speech-playback.e2e.ts --reporter=line --workers=1` 验证;默认测试使用音色目录和音频模拟,不依赖微软在线服务。
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。 Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
Agent 运行时仍可发送消息,底栏可选择“引导”(steer,默认)或“排队”(queue)。右下角只有一个操作位置:运行中输入为空时停止,有内容时发送追加消息;空闲时发送新消息。待处理消息并入输入组件顶部,每条展示正文预览、处理方式和消息菜单;长内容可展开全文,菜单可切换处理方式、退回编辑或关闭排队。编辑需服务端确认撤回,保留已有草稿并等待手动重发。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。 Agent 运行时仍可发送消息,底栏可选择“引导”(steer,默认)或“排队”(queue)。右下角只有一个操作位置:运行中输入为空时停止,有内容时发送追加消息;空闲时发送新消息。待处理消息并入输入组件顶部,每条展示正文预览、处理方式和消息菜单;长内容可展开全文,菜单可切换处理方式、退回编辑或关闭排队。编辑需服务端确认撤回,保留已有草稿并等待手动重发。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
@@ -130,6 +136,6 @@ src/
调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。 调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。
“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围样板面板可对 synthetic 测试工单和测试数据执行 CRUD,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围样板说明](../next-tjwater-server/docs/peripheral-sample.md)。 “分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围系统面板管理业务工单,新建工单来源为 `system_data`;显式演练数据保留 `synthetic` 标记,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围系统说明](../next-tjwater-server/docs/peripheral-sample.md)。
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。 生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+253 -15
View File
@@ -2,6 +2,48 @@
统一样式见 [样式设计指南](style-design-guide.md),聊天布局与报告交互见 [Agent 交互与报告设计](agent-chat-ui-design.md)。本文保留协议、组件参数与业务交互约定。 统一样式见 [样式设计指南](style-design-guide.md),聊天布局与报告交互见 [Agent 交互与报告设计](agent-chat-ui-design.md)。本文保留协议、组件参数与业务交互约定。
## 实施进度(2026-10-30 更新)
原生组件版的主要界面与交互状态保存已实现,已完成本地自动化验证;真实授权项目联调和发布尚待完成。下表的“已实现”指当前工作区代码,不代表运行服务已同步更新。相关改动仍未提交、未部署。
| 能力 | 状态 | 已有范围与剩余边界 |
| --- | --- | --- |
| Agent 内容组织 | 已实现 | shadcn/ui 原生组件、内容分区、导航、指标与证据展示;沿用现有对话和工具执行通道 |
| 任务语义与结果表达 | 2026-10-28 已接入工作区 | `Result`、回答用途、分区角色、疏密及具体说明标题;Agent 选择表达语义,宿主统一视觉与动作边界,真实模型联调待完成 |
| 表格与图表 | 已实现 | 搜索、排序、折线/柱状/数据表切换、区间选择和序列显隐;只处理已加载数据 |
| 报告与 Markdown 阅读 | 已实现 | 表格完整内容可横纵滚动;报告与 Mermaid 有界放大、暗色遮罩、图标按钮、焦点恢复及减少动态效果适配 |
| 定位与资料补充 | 已实现 | 保留专用地图定位和补充资料表单;数据组件不提供勾选、分析篮或组件追问 |
| 交互状态持久化(A) | 核心已实现 | 图表、表格、表单与证据状态按会话保存;支持刷新恢复、兼容迁移、失败重试和显式冲突处理 |
| 阅读位置持久化 | 设计待实施 | reader 记录尚未保存到宿主;分析篮已按产品决定移除,旧 basket 设计不再作为实施目标 |
| 提问与回答生命周期 | 2026-10-28 已接入工作区 | 自有 ask_user、独立草稿、答案持久化与固定 ID 续跑;历史 question 权威核对。真实模型完整链路及发布未验收 |
| 压力历史探索(B) | 设计提案 | `HistoryExplorer` 和新时间窗分页查询尚未实现,不能将本地筛选当成重新查询 |
| 自由代码运行时(C) | 未接入 | 仍采用受控原生组件;独立 iframe、生成代码运行及其通信协议需要另行架构评审 |
| 外围与 Dev 窗口交互 | 已修复 | 窗口外地图可点击、拖动和滚轮缩放;窗口内部操作不穿透地图 |
对应根目录研究文档的四个阶段:阶段一的标准组件路线已实现;阶段三的原生组件状态管理已提前落地;阶段四已接入部分地图、报告和追问流程;阶段二的自由代码运行时尚未接入。本文后续 A/B/C 是实施包名称,与研究文档的阶段编号不同。
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 构建;不等于部署完成 |
当前实现入口为 [原生回答组件](../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 阶段“无数据库迁移”的交付结论。后续追加的宿主提问实现状态单列如下。
同日补充的宿主提问方案已于 2026-10-28 接入工作区,见上述文档第 11 节:以自有 ask_user 管理等待、草稿、答案与续跑,同时核对历史原生 question 的可用状态。独立完成此项不代表 reader 或正式报告状态已实施;此前历史故障的具体触发条件仍未取得现场响应。
## 接入方式
聊天保留 AI SDK `UIMessage`、AI Elements 与现有 Agent 后端,新增 聊天保留 AI SDK `UIMessage`、AI Elements 与现有 Agent 后端,新增
`@json-render/core` / `@json-render/react` 的本地注册组件。 `@json-render/core` / `@json-render/react` 的本地注册组件。
组件代码随前端构建发布,无 ChatKit iframe、domain key 或 Vercel/OpenAI 组件代码随前端构建发布,无 ChatKit iframe、domain key 或 Vercel/OpenAI
@@ -11,11 +53,32 @@
`Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、 `Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表; `MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表;
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问; 表格可搜索和排序,图表可切换已加载区间、显隐序列并查看数据点详情;
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始, 证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。 由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与选行在视图切换后保留。 数据组件用于展示与本地浏览,不提供勾选、加入分析、分析内容篮或针对组件追问。`MapLocation` 的定位与 `FollowUpForm` 的补充资料提交保留各自入口。
短回答不显示内容导航;含表格、图表、补充表单或达到长内容标准、且包含至少两种可导航组件的回答才提供目录。单个证据或表单使用组件自身标题,多个独立条目才增加分组折叠;不叠加固定的分区说明。
### 按任务表达内容
Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、响应式布局和可执行动作。新增字段均为 `answer-ui@1` 的兼容扩展,旧记录保持可读;Agent、Server 与前端需同步交付生成目录。
| 组件 | 可配置语义 | 前端行为 |
| --- | --- | --- |
| `Result` | `title`、`summary`、`status`、`source` | 用小面积状态图标和文字区分 `success`、`partial`、`unconfirmed`、`error`;保留摘要及依据,不自动执行操作 |
| `Stack` | 可选 `intent=response/comparison/report`、`density=compact/comfortable` | 指定简短回答、并列比较或报告;比较内容按可用宽度切换双列/单列;密度只调整内容间距 |
| `Section` | 可选 `role=result/analysis/evidence/follow_up` | 将独立分区放入结果、分析、依据或补充资料区域,保留分区内部的顺序 |
| `Text` | 可选 `title`,原有 `tone` 不变 | 使用具体说明标题,例如“能力限制”,避免所有说明都叫“注意事项” |
`Stack` 原有 `columns`、`gap` 与其他组件原有必填参数不变。未给新语义时沿用既有布局;显式 `intent=comparison` 保留作者编排,不再拆成分析与依据区域。`density` 不改变字体、控件大小和触屏命中区域,也不折叠或截断原始数据。颜色、CSS、脚本及任意动作不属于模型可配置参数。
已有执行结果适合 `Result`,批量对象适合表格,真实时间序列适合图表,后续定位适合 `MapLocation`,可选资料补充适合 `FollowUpForm`。模型只能以已有工具结果或明确事实表达成功;未确认的执行或现场状态应保持 `unconfirmed`。演练记录成功与实际业务处置分别说明,`Result` 状态本身不构成执行凭据。正文保留关键结论与边界,辅助组件补充数据和依据,避免重复整段文字。
短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。
其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
@@ -23,23 +86,40 @@
| --- | --- | | --- | --- |
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | | `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | | `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 保留原始数值、单位与来源,仅加入分析的按钮图标过渡 | | `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;选行操作栏进入退出使用统一过渡;空结果不叠加选行提示 | | `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | | `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | | `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | | `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
| 报告辅助分区与分析内容篮 | 分区、内容篮及所选引用详情统一展开收起;保留表单与分析目标草稿 | | 报告辅助分区 | 分区使用统一展开收起;保留补充表单草稿 |
定位与资料提交的即时状态由用户操作及宿主回调驱动,不读取模型声明的成功状态。定位区分处理中、成功、部分缺失、零结果失败与回执未确认;失败可重试,不支持定位时说明原因。表单提交中阻止重复发送,失败保留草稿;修改字段后清除上一版发送反馈,必填错误与对应字段关联。即时反馈随组件实例存在,刷新后不恢复为本地操作成功;已实现的表单草稿、筛选与图表状态继续使用会话保存通道。
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。 Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同 消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
`block_id` 更新原块;下一轮不会修改上一轮回答。 `block_id` 更新原块,块内稳定的元素 ID 标识同一图表、表格和表单;下一轮不会修改上一轮回答。
这是按内容块逐步出现的界面,不是逐 token 编译 JSX 或每个 JSON 属性都立即显示。 这是按内容块逐步出现的界面,不是逐 token 编译 JSX 或每个 JSON 属性都立即显示。
结构化消息与普通聊天共用持久化、历史与断线恢复通道。 结构化消息与普通聊天共用持久化、历史与断线恢复通道。
生成期间展开显示过程;`final_answer` 到达后,仅展开最后报告的完整文字与 新发布的块使用 `phase: "process" | "final"`,省略时按 `process` 发布。同一轮相同 `block_id` 的阶段不可改变;需要最终界面时使用独立 ID 发布 `phase: "final"`。过程块始终留在处理过程,即使误列最终引用也不能进入最终答复或读取、写入最终交互状态。
`ui_block_ids` 明确引用的已发布组件,其余文字、界面与工具轨迹默认折叠在
“查看处理过程”中。报告组件按引用顺序排列,同一组件不会同时作为报告和过程 `final_answer.answer` 中用独立一行的 `[[ui:block_id]]` 固定最终组件的位置,`ui_block_ids` 是允许引用的块 ID 列表,必须与标记去重后的顺序一致。例如:
挂载,避免重复表单。未被报告引用的过程界面只供回看。
```text
已完成本次记录核对,结果如下。
[[ui:pressure-summary]]
下面解释异常时间和后续核验建议。
[[ui:pressure-trend]]
```
两个示例 ID 都必须先以 `phase: "final"` 发布,再调用最终答复工具。代码围栏、缩进代码和引用中的标记保持字面文本,不执行引用。重复有效标记只挂载一次;不存在、未授权或过程块的标记显示内容不可用,不泄露过程摘要。
生成期间按原始顺序显示内容;`final_answer` 到达后,正文按标记位置穿插最终组件,其余文字、界面与工具轨迹折叠在“查看处理过程”中。完成态只保留这一层过程折叠,展开后直接列出步骤及状态图标,不再嵌套执行进度卡片、计数和折叠按钮。工具调用按独立行展示状态图标、工具图标及名称,具体参数默认收起。过程与工具参数展开前停止底部自动跟随,以当前消息位置作为锚点;鼠标操作使用短时淡入与位移动画,键盘与减少动态效果模式即时完成。外层箭头与步骤状态使用相同的 24px 图标占位;工具名换行时,状态、工具图标和右侧箭头对齐标题首行,参数说明沿用正文缩进。复制和语音使用相同的最终顺序,以组件摘要替代标记,并排除过程资料。旧历史中没有 `phase` 的块仍按明确引用兼容读取;没有位置标记的旧答复继续在正文后追加引用块,新发布不再依赖这一兼容路径。
同一最终块从生成切换到状态保存时保持组件实例,不因启用持久化而重挂载;尚未完成的编辑先留在内存,最终答复完成后读取远端并合并本地变更。过程块不访问最终状态接口,关闭或切换会话也不产生远端写入。
已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生 已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生
生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成; 生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成;
@@ -49,7 +129,9 @@ Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之
## 正式报告与主工作区 ## 正式报告与主工作区
正式报告可携带 `ui_blocks?: Array<{ block: AnswerUiBlock; evidence_refs: string[] }>`,最多 8 块,总 UTF-8 JSON 不超过 400000 字节。每块绑定报告中的已有证据,Server 验证任务归属;事实字段与审批不由 UI 块替代。组件树与聊天共用 `answer-ui@1`。例如: 正式报告可携带 `ui_blocks?: Array<{ block: AnswerUiBlock; evidence_refs: string[] }>`,最多 8 块,总 UTF-8 JSON 不超过 400000 字节。每块绑定报告中的已有证据,Server 验证任务归属;事实字段与审批不由 UI 块替代。组件树与聊天共用 `answer-ui@1`。
正式报告块只接受 `phase: "final"` 或未标阶段的旧块,拒绝 `process`;读取旧记录时继续省略阶段字段,不将其改写为 `null`。例如:
```json ```json
{ {
@@ -74,9 +156,9 @@ Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之
此片段仅展示新增字段,发布时仍需完整报告与真实存在的证据编号。保存沿用 `agent-artifacts`、报告 JSON、幂等键和 revision;读取沿用报告列表、指定版本、任务详情及 Agent context,不新增写入端点或数据库表。 此片段仅展示新增字段,发布时仍需完整报告与真实存在的证据编号。保存沿用 `agent-artifacts`、报告 JSON、幂等键和 revision;读取沿用报告列表、指定版本、任务详情及 Agent context,不新增写入端点或数据库表。
`InvestigationReportBlocks` 将正式报告接入 `AnswerUiView`,证据组件使用保存记录中的摘要、来源、时间与类别。未知版本或无法解析的块展示 fallback,报告固定事实仍可读。报告追问携带 `report_revision`、`reportGeneration` 和引用文本;表单先带入会话,确认后才发送。 `InvestigationReportBlocks` 将正式报告接入 `AnswerUiView`,证据组件使用保存记录中的摘要、来源、时间与类别;块外只补充尚未在证据卡中展示的引用依据,表格和降级内容仍保留来源。未知版本或无法解析的块展示 fallback,报告固定事实仍可读。补充表单带入会话时携带 `report_revision`、`reportGeneration` 和资料文本,确认后才发送;不提供通用组件追问入口。
`ExpandableReading` 将同一个 React 内容实例移至主工作区,保留图表视图、搜索、行选择、表单草稿。关闭恢复入口焦点,重开恢复阅读位置。主工作区宽度至少 960px 可对照地图,其余宽度在两者之间切换;原地图实例保留。 `ExpandableReading` 将同一个 React 内容实例移至主工作区,保留图表视图、搜索、排序、表单草稿。关闭恢复入口焦点,重开恢复阅读位置。主工作区宽度至少 960px 可对照地图,其余宽度在两者之间切换;原地图实例保留。
## 扩展组件 ## 扩展组件
@@ -109,8 +191,9 @@ node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-cat
```bash ```bash
pnpm lint pnpm lint
pnpm typecheck pnpm typecheck
pnpm test src/features/agent/answer-ui src/features/workbench/hooks/agent-session-message-state.test.ts pnpm test
pnpm test:browser tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-start-page.e2e.ts tests/browser/agent-report-workspace.e2e.ts pnpm test:browser tests/browser/agent-answer-state.e2e.ts tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-intelligent-workspace.e2e.ts --workers=1
pnpm test:browser tests/browser/floating-panel-pointer.e2e.ts tests/browser/peripheral-sample-panel.e2e.ts --grep 'panel keeps outside|项目分析成果打开时|外围面板只读取' --workers=1
pnpm build pnpm build
``` ```
@@ -118,6 +201,7 @@ Agent 仓库:
```bash ```bash
bun run check bun run check
bun test tests/sessions/answerViewState.test.ts tests/routes/answerViewStateRoutes.test.ts tests/sessions/workspaceStore.test.ts tests/routes/chatSession.test.ts
bun test tests/chat/answer-stream-projection.test.ts tests/chat/answer-ui.test.ts tests/routes/chatStream.test.ts tests/routes/taskCollaboration.test.ts tests/runtime/opencode.test.ts bun test tests/chat/answer-stream-projection.test.ts tests/chat/answer-ui.test.ts tests/routes/chatStream.test.ts tests/routes/taskCollaboration.test.ts tests/runtime/opencode.test.ts
bun run tests/integration/answer-ui-smoke.ts bun run tests/integration/answer-ui-smoke.ts
``` ```
@@ -149,3 +233,157 @@ Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓
运行的开发服务将旧临时目录的 React 预构建写回测试缓存。 运行的开发服务将旧临时目录的 React 预构建写回测试缓存。
消息入口为 `POST /api/v1/responses`,采用 Responses 文本输入、Response 输出和标准 SSE 生命周期。前端 `ResponsesChatTransport` 将网络事件转换为已有 UIMessage 渲染数据;审批、凭据续期、地图动作及 answer-ui 走 `tjwater.*` 扩展事件,不将 AI SDK 的 UIMessage 流作为网络协议。会话、模型及动作回执等接口统一使用 `/api/v1` 前缀,原 `/agent/chat` 路径已移除;Agent 服务和现有会话数据保留。 消息入口为 `POST /api/v1/responses`,采用 Responses 文本输入、Response 输出和标准 SSE 生命周期。前端 `ResponsesChatTransport` 将网络事件转换为已有 UIMessage 渲染数据;审批、凭据续期、地图动作及 answer-ui 走 `tjwater.*` 扩展事件,不将 AI SDK 的 UIMessage 流作为网络协议。会话、模型及动作回执等接口统一使用 `/api/v1` 前缀,原 `/agent/chat` 路径已移除;Agent 服务和现有会话数据保留。
## 可持续分析工作区:实现与后续设计
状态:2026-10-30 更新。A 的控件状态保存已实现,阅读位置持久化待补齐;分析篮已移除;B 为尚未实现的受控查询方案;C 为独立架构扩展的接入条件。
### 目标与实施次序
以工作区根目录的 [Intelligent UI 研究文档](../../intelligent%20UI.md) 为方向,下一阶段让用户能够保留探索状态、在回答内查询新时间窗、联动地图,再带着明确的对象与范围继续分析。视觉规范仍只引用 [样式设计指南](style-design-guide.md)。
原生组件已具备宿主状态保存,下一步优先验证真实项目中的恢复流程,再推进受控数据查询。自由代码运行时在原生组件无法满足具体场景时单独评审。当前实现沿用既有 Agent 框架与存储,没有新增独立 UI Planner、新数据库或环境变量。
原共享架构 `agent_ui_architecture_without_sandbox.md` 明确排除生成代码执行。本提案的可实施范围仍是原生组件;研究文档中的 sandbox 路线列为独立架构扩展,不借此改变当前运行时约束。
### 已解决问题与剩余缺口
| 问题或约束 | 当前状态 |
| --- | --- |
| 按完整数据设置 React key 导致控件重建 | 已改为稳定节点身份和显式兼容迁移 |
| 控件状态仅在组件内存中,刷新后丢失 | 已接入受控 store 与宿主保存;阅读位置仍未持久化;分析篮已移除 |
| 流式消息写入可能覆盖交互状态 | 已独立保存到 `chat/answer-view-state.json`,复用宿主串行队列、身份及删除校验 |
| `answer-ui@1` 仅接受固定组件与 props | 保留此边界;新交互通过注册组件和受控回调扩展 |
| Server 已有历史查询服务、项目连接与 scada.view 校验 | 新数据查询可以复用原服务,无须让每次选区都调用模型 |
| 公开历史查询接口目前未暴露分页,而服务与 MCP 已支持 row_limit/row_offset | 交互探索需要增加有界的公开分页入口,不能把无限读取的风险留给浏览器 |
这些结论来自源码核对;本轮未请求真实项目数据、模型或生产服务。
### B 阶段目标工作区(尚未实现)
首个场景选择“压力历史分析”。Agent 获取真实监测对象和资料后选择原生组件,简单解释仍只显示文字。聊天流保留结论、判断边界与轻量内容;用户主动展开后,在原工作区内探索。
```text
对话:结论、判断边界、关键读数、展开入口
|
v
压力历史分析 来源与记录时间
对象选择 起止时间 [更新数据] 当前探索 / 原回答快照
趋势 / 数据 [筛选已加载数据]
选中对象的读数、单位、缺测说明 [定位地图]
依据与说明 [以此继续分析]
```
- `更新数据` 明确查询新时间窗;图内的范围选择、搜索与排序只处理已加载数据。参数改动先成为草稿,按按钮提交一次,不随每次键入发请求。
- 桌面继续复用现有地图对照工作区,手机使用同一个实例切换地图与内容。沿用 `Card`、`Tabs`、`Select`、`DateTimeField`、`Table`、`Alert` 和 `Button`;不在窄聊天列内增加固定多栏仪表盘。
- 地图联动以点击为触发,不因悬停、恢复历史或数据到达改变视角。所选对象由明确的图层与 feature ID 表示;名字和数组序号不能充当资产标识。
- `以此继续分析` 将对象、时间窗、来源类别与引用的回答版本带入输入区,由用户确认发送。Agent 重新核验所需数据,不把浏览器传入的数值自动当成服务端事实。
- 历史回答是生成时的快照。探索新时间窗不重写原结论;重新分析产生新的回复。正式报告版本与证据也保持不可变。
### A. 保存交互状态(核心已实现)
下述控件状态与 GET/PUT 接口已接入 Agent 对话中完成的最终回答。前端使用稳定节点标识、受控 store 和按登录范围持有的会话缓存;500ms 防抖保存,恢复期间按字段合并新编辑,保存失败可重试,冲突须明确选择保留本地或重载远端。正式报告的同实例展开保留内存交互,但尚未接入这套聊天状态持久化。
本阶段不扩展模型可生成的组件协议。宿主新增 `answer-view-state@1`,状态身份由当前登录主体、业务项目、sessionId、assistantMessageId、blockId、nodeId 共同确定;主体和项目只能从认证上下文取得。
前端在验证成功后,按 spec.elements 的键建立 nodeId。通过本地渲染适配注入状态句柄,不把 nodeId 注入字段或状态表达式开放给模型。使用 json-render 已有受控 store/StateProvider 接口,将控件改为受控状态,避免第二份组件本地状态与宿主状态竞争。库提供状态绑定机制,但允许持久化哪些字段由本地组件目录决定,见 [json-render 数据绑定](https://json-render.dev/docs/data-binding)。
| 内容 | 保存字段 | 新数据到达时的处理 |
| --- | --- | --- |
| 图表 | 视图、隐藏序列、区间、选中点、详情展开 | 保留视图;序列名与顺序一致才复用显隐;唯一时间标签可重定位选点;无法匹配时清除选择并提示 |
| 图表内数据表 | 搜索、排序 | 切换序列时清理排序;视图切换和状态恢复保留兼容的搜索与排序 |
| 表格 | 搜索、排序 | 搜索保留;列变化清除无效排序;不恢复历史选行 |
| 资料表单 | 字段草稿 | 同 nodeId、同字段名和类型才合并;删除或变更类型的字段不迁移,不自动提交 |
| 证据 | 类别筛选、展开项 | 保留仍存在的证据;不存在的筛选回到全部 |
为兼容已部署的 `answer-view-state@1` 严格校验,表格与图表数据表仍在传输时携带 `selection: []`;前端丢弃旧快照中的选行,不提供相关界面。
尚未持久化:阅读锚点与相对偏移。当前同实例展开/收起的内存保留,不等于刷新后的宿主恢复。
补齐方案见 [Intelligent UI 交互状态补齐设计](agent-interaction-state-design.md):保留现有 controls 接口,计划新增独立 reader 记录;旧 basket 部分已停止实施;正式报告按当前读者及报告版本保存到 Server,不使用发布报告的 Agent 会话充当读者身份。
状态记录附带完整 spec 摘要 `schema_digest`、单块 `revision` 与最后请求 ID。`schema_digest` 由服务端从会话里已保存的块计算;客户端摘要只用于检查过期。摘要变化后执行上表的兼容迁移,再写新版本。按数据整体设置 key 的逻辑改为稳定节点身份加显式兼容处理,不能只删除 key 而沿用越界索引。
已实现的 Agent 接口:
| 接口 | 输入与行为 |
| --- | --- |
| `GET /api/v1/session/:sessionId/ui-state?message_id=…&block_id=…` | 校验会话归属和块存在;返回对应状态、revision、schema_digest;未保存返回空状态及 revision 0 |
| `PUT /api/v1/session/:sessionId/ui-state` | 输入 message_id、block_id、schema_digest、expected_revision、request_id、nodes、block;只接受对应组件允许的状态字段 |
写入在同一串行队列内读取旧记录、检查 expected_revision、校验并原子替换。回执保留最近 32 条且总量不超过 128 KiB;窗口内相同 request_id 且内容一致返回原结果,相同 ID 携带不同内容拒绝。回执过期后,旧请求仍受版本检查保护。版本冲突返回 409 及当前版本,前端保留未保存草稿并提供重载或重新保存,不静默覆盖另一个窗口的状态。保存失败显示“未保存”,不声称刷新后可恢复。
初次恢复完成前允许阅读和编辑,但不以 revision 0 覆盖未知的已保存状态;记录本地变更字段,读取成功后按字段合并,用户刚输入的内容优先于迟到的恢复结果。失败时保留草稿并允许重试,重载远端状态必须明确提示会替换哪些本地改动。
持久化使用宿主 `chat/answer-view-state.json`,独立于 messages.json;文件已加入 SessionWorkspaceStore 的受控记录列表,读取消息、检查版本和原子写入均在宿主队列内执行。状态来自用户交互及其兼容迁移,不进入模型可写的 workspace/,不进入默认推理上下文。删除会话沿用宿主删除标记和串行清理,迟到写入不能复活记录;分叉会话不继承未提交表单。
当前限制:每块状态最多 32 KiB UTF-8,单会话状态文件最多 1 MiB;只保存控件字段及迁移依据,不复制原始时序读数、授权凭据或错误堆栈。服务端超限返回 413,前端单块预检超限时不发送;两者均保留当前内存草稿并提示未保存。前端 500ms 防抖,切换会话时对已完成初次读取的改动发起保存;退出时清空内存,不能依赖页面卸载请求保证保存成功。无须迁移旧 messages.json,旧会话首次保存时惰性创建状态记录。
### B. 压力历史探索(设计提案,未实现)
新增注册组件 `HistoryExplorer`,作为拥有固定业务语义的原生组件,不开放模型编写请求 URL、工具名或任意 action。其 props 为 title、targets、initial_start、initial_end、source;targets 只允许查询结果中明确关联的 junction ID 与 device ID,最多 5 组;source 首批只允许 scada_raw 或 scada_cleaned。时间为带时区 ISO 字符串,单次范围不超过 24 小时。缺少对象关联时继续展示原有 Chart 与数据缺口说明,不生成猜测绑定。
该组件仍通过 answer-ui@1 的目录扩展交付,保持 envelope 与快照格式不变;前端为参数唯一来源,导出 Agent、Server 的 props schema,并在三端增加相同的交叉校验。旧客户端遇到新组件显示 fallback_text,不能执行未知组件。Agent 和 Server 先支持校验,前端发布后再启用该组件的生成指令;旧客户端仍可阅读摘要。
前端 `agent` 模块只声明 `onQueryHistory` 回调与数据结果类型,`workbench` 实现身份、项目头、请求和地图控制器。不能从 agent 深入导入 workbench,也不在 Agent 服务里新建通用查询代理。
```text
已有 Agent Harness -> present_answer -> 现有校验与消息通道
|
v
原生回答 + 交互状态
|
用户显式操作
|
Workbench 适配器
/ \
地图控制器 Server 历史服务
|
查询结果回到当前探索视图
```
Server 拟新增 `POST /api/v1/timeseries/views/element-history/page`,复用 TimeseriesHistoryService.query 的 row_limit/row_offset 路径及当前项目数据库连接,保留 scada.view 授权。请求包含上述明确对象与时间窗,以及 limit、offset;首批只支持 observed。limit 默认 500、最大 1000,offset 为非负整数,最多 5 个对象、24 小时。返回 series、page.limit、page.offset、page.has_more、queried_at;不改变原无分页接口。
每次“更新数据”最多读取 5000 个点或 10 页,以先到者为准;超过预算保留已读部分并明确“时间窗未完整覆盖”,提示缩小范围。Server 每页有界,浏览器也限制累计体积;到达 2 MiB 停止继续加载。不能用前 100 点冒充完整 24 小时。分页是在有数据写入的系统中逐页读取,不承诺跨页一致快照;按对象、设备、来源、时间去重,保留查询时刻和查询参数,正式证据仍由后端报告流程形成。
新请求取消旧请求并提升 request generation;结果只有在项目、会话、节点和 generation 均匹配时可应用。超时或服务失败保留旧图,并同时显示旧图的实际时间窗与失败原因;无数据与请求失败分开表达。返回 null 的点显示缺测断线,不补零;按 source_unit 展示,转换需有明确单位规则;原始、清洗、实时模型和分析结果不能混为同一种来源。
刷新数据不修改消息快照。查询参数和控件状态可以保存,未固化的探索数据只留在当前会话内存;刷新页面后先显示原回答或待加载状态,用户点击“更新数据”重新取数。需要跨日保存的业务结论仍通过现有正式报告与证据通道发布。
### C. 自由代码运行时(未接入,需独立评审)
本轮只确定接入条件:出现标准组件难以表达、且确有使用价值的交互图解或模拟,再单独评审 sandbox 协议与运行环境。阀门工况计算必须复用实际仿真服务;可拖动示意图不等同于水力模型。
后续设计参考 [MCP Apps 的 AppBridge](https://github.com/modelcontextprotocol/ext-apps/blob/main/src/app-bridge.ts) 的初始化、能力协商、结果推送和 teardown,把动态程序当作独立实例管理。其工具转发能力不能原样映射为“允许调用全部供水工具”。iframe 本身也不能证明资源消耗或业务权限已受控。
接入前须完成:独立来源与 CSP/网络策略、消息来源与实例校验、显式能力列表、宿主中止与生命周期、版本替换及状态迁移、崩溃后的文本回退、真实浏览器资源预算验证。生产控制、业务写入和审批继续由既有服务执行。本阶段不新增 sandbox 入口、依赖或服务器,因此不会留下无法使用的占位 UI。
### 后续待办与验收
A 与 B 可独立交付,B 尚未实现不影响已有聊天控件的状态保存。建议按以下顺序推进,正式提交和发布按各仓库分别执行。
| 顺序 | 待办 | 完成标准 |
| --- | --- | --- |
| 1 | 已实现能力的真实联调与交付 | 在隔离 Agent 与已授权项目中验证保存读回、刷新恢复、会话隔离、报告/表单回传;补充真实模型组件选择验收,再整理提交与发布记录 |
| 2 | B 阶段压力历史探索 | 用户选择对象和时间窗后显式查询;分页有上限、缺测不补零、旧响应不覆盖新请求;定位地图并携带查询范围继续分析 |
| 3 | 实施已补齐的状态设计 | 优先独立交付宿主提问生命周期;聊天 reader 和正式报告私人状态另分两批,分别验证恢复、清理与版本隔离 |
| 4 | C 阶段架构评审 | 先确认原生组件无法满足的具体场景,再决定是否引入沙箱及其权限、通信、资源预算和生命周期 |
| 阶段 | 主要修改位置 | 必须验证 |
| --- | --- | --- |
| A 前端(已实现) | answer-ui/answer-ui-view.tsx、answer-parts-view.tsx、answer-view-state.ts、answer-view-state-provider.tsx;agent/session-state/;workbench/hooks/use-workbench-agent.ts;agent/api/client.ts | 状态注入、同块更新、无效选择清理、迟到恢复不覆盖新输入、会话隔离 |
| A Agent(已实现) | sessions/workspace-store.ts、sessions/answer-view-state-store.ts、routes/answer-view-state-routes.ts、routes/chat.ts | 身份和块归属、原子版本检查、幂等重试、冲突、字节限制、会话删除竞态 |
| B 前端 | answer-ui/catalog.ts、contract.ts、answer-ui-view.tsx;新增 history-explorer.tsx、workbench/api/element-history-client.ts;现有 workbench-agent-panels.tsx 的回调装配 | 参数校验、请求与本地筛选区分、取消和旧响应、缺测、单位、地图定位失败 |
| B Agent / Server | 导出的 answer-ui-catalog.json;三端 answer-ui 校验;Server timeseries/composite.py、timeseries_history.py 的分页请求/响应模型;Agent instruction.md | 旧消息降级、新组件约束一致、Server 分页限制、scada.view 和项目隔离 |
前端执行 `pnpm lint`、`pnpm typecheck`、`pnpm test`,并扩充 `tests/browser/agent-answer-ui.e2e.ts` 的 320/375/1440px 用例。A 在 Agent 执行 `bun run check` 及 `tests/sessions/workspaceStore.test.ts`、新增状态存储/路由测试、`tests/routes/chatSession.test.ts`。B 追加 Agent `tests/chat/answer-ui.test.ts`,Server `tests/unit/test_timeseries_history.py`、`test_timeseries_history_schema.py`、`test_report_ui.py` 与新增分页 API 授权测试。新增 schema 后运行 `pnpm export:answer-ui-contract`;发布前完成前端构建和真实授权项目的有界读查询验收。
验收场景必须包含:保存后刷新、两窗口编辑冲突、数据更新时仍在填写表单、重复序列标签、数据集换序、断网与 403、切换项目后旧响应返回、删除会话后迟到保存、未知组件回退、减少动态效果、键盘焦点与窄屏无溢出。保存成功必须有读回证据,不能仅以组件没报错作为通过。
最关键的前提是查询结果能明确关联监测设备与管网对象;现有数据若缺少关联,地图入口应显示不可定位并保留读数,不能使整个分析失效。真实模型选型与数据质量均需独立验收,组件测试不替代它们。
发布 A 时先部署 Agent 的可选状态接口,再部署前端;没有接口或保存失败时保持可读和内存交互,并显示未保存。回退保留新状态文件和已有消息,不清理用户资料。B 回退时停止生成 HistoryExplorer,旧客户端按已有 fallback 读取,保留原查询接口。上述两阶段均不需要新账号、密钥或环境变量。
### 地图与外部展示动作
`answer-ui@1` 继续描述可保留的回答内容;动作统一为 `{command: {action, ...字段}}`。Chat 的 `present_view` 使用 `frontend-action@2` 和 `frontend-action-result@2`,调查任务事件及 Dev JSONL 使用相同 command,由共用执行器处理。定位按钮也使用该执行器。十种动作和验证步骤见 [调查任务展示与 Dev 测试](investigation-dev-tools.md#统一展示动作)。动作是一次性过程副作用,不因 final_answer 引用 UI 或历史渲染而重放;block_id/phase/正文锚点规则不变。
+46 -15
View File
@@ -26,7 +26,7 @@ Agent 相关弹窗、面板及菜单统一保持本地 shadcn/ui 设计,具体
| 人工参与 | 权限审批、问题回答和跳过、补充资料 | 对应回复内保留可操作表单,确认前不执行 | | 人工参与 | 权限审批、问题回答和跳过、补充资料 | 对应回复内保留可操作表单,确认前不执行 |
| 分析结果 | 指标、表格、折线和柱状图、证据 | 回复中的交互内容,可搜索、排序、选点、筛选序列及已加载区间 | | 分析结果 | 指标、表格、折线和柱状图、证据 | 回复中的交互内容,可搜索、排序、选点、筛选序列及已加载区间 |
| 展开阅读 | 聊天回答与正式报告进入主工作区 | 明确点击进入,保留唯一组件实例、筛选、表单草稿和阅读位置 | | 展开阅读 | 聊天回答与正式报告进入主工作区 | 明确点击进入,保留唯一组件实例、筛选、表单草稿和阅读位置 |
| 内容追问 | 指标、数据点、表格行、证据 | 带来源的可删除引用;正式报告另带版本和轮次;确认后发送 | | 数据浏览 | 指标、数据点、表格、证据 | 展示与本地查看;不提供行勾选、分析篮或组件追问 |
| 正式报告 | 当前结论、结论边界、证据、缺口、待办、审批与历史版本 | 固定事实区与可选交互分析模块组合 | | 正式报告 | 当前结论、结论边界、证据、缺口、待办、审批与历史版本 | 固定事实区与可选交互分析模块组合 |
| 地图联动 | 定位实际要素、显示定位结果 | 对应内容内的明确按钮 | | 地图联动 | 定位实际要素、显示定位结果 | 对应内容内的明确按钮 |
| 回复工具 | 复制正文及交互摘要、朗读和选段朗读 | 回复底部低强调操作及文字选择入口 | | 回复工具 | 复制正文及交互摘要、朗读和选段朗读 | 回复底部低强调操作及文字选择入口 |
@@ -114,13 +114,13 @@ Agent 面板
└─ 输入区:引用 / 多行输入 / 权限、模型、语音、发送或停止 └─ 输入区:引用 / 多行输入 / 权限、模型、语音、发送或停止
``` ```
纯指标的双列 `Stack` 合并为紧凑摘要区,容器宽度达到 240px 即可并排;名称、原始数值、单位和来源完整保留,不用省略号截断。指标提问按钮放在名称旁,减少单独操作行。混合内容的双列仍使用 440px 断点。助手正文直接进入消息流,证据按需折叠,补充表单保留独立操作区;表面与间距统一引用样式设计指南。 纯指标的双列 `Stack` 合并为紧凑摘要区,容器宽度达到 240px 即可并排;名称、原始数值、单位和来源完整保留,不用省略号截断。指标仅展示名称、数值、单位和来源。混合内容的双列仍使用 440px 断点。助手正文直接进入消息流,证据按需折叠,补充表单保留独立操作区;表面与间距统一引用样式设计指南。
图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。 图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。
正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。 正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。
空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史或已有任务状态、审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。 空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。
输入文字区初始高度 64px,按内容增长至视口允许的高度。首次发送后同一个输入组件回到底部;新建空会话后恢复居中布局。使用 `motion/react` 的位置布局动画处理这两种状态,沿用项目的 180ms 缓动,不缩放输入文字,也不对 Resizable 连续调宽添加动画。系统开启减少动态效果时直接切换位置。表单实例和草稿在布局变化时保留。 输入文字区初始高度 64px,按内容增长至视口允许的高度。首次发送后同一个输入组件回到底部;新建空会话后恢复居中布局。使用 `motion/react` 的位置布局动画处理这两种状态,沿用项目的 180ms 缓动,不缩放输入文字,也不对 Resizable 连续调宽添加动画。系统开启减少动态效果时直接切换位置。表单实例和草稿在布局变化时保留。
@@ -158,13 +158,45 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
推荐依据使用独立的近期会话页,历史搜索和翻页不改变输入。缓存摘要按活动 ID、标题、内容、状态及来源计算,单纯心跳时间变化不触发推理。生成新推荐时保留上一组问题。 推荐依据使用独立的近期会话页,历史搜索和翻页不改变输入。缓存摘要按活动 ID、标题、内容、状态及来源计算,单纯心跳时间变化不触发推理。生成新推荐时保留上一组问题。
每个会话持有独立 Chat 实例、生成连接、草稿、引用、阅读位置和审批状态。切换历史或新建对话保留原会话后台生成;后台需要审批或回答时,通过历史入口提示返回处理。模型、项目头和凭据续期绑定原会话。后台浏览器动作返回 `SESSION_NOT_ACTIVE`,提示用户回到会话后重新请求;切换时取消原会话尚在执行的浏览器动作。恢复失败保持可重试的连接异常状态,不将断流视为任务完成。切换业务项目由工作台统一处理,会话、主题及缓存按业务项目隔离。 推荐失败后,面板保持打开时在错误缓存到期后重试,同一轮最多请求三次;服务从离线恢复时立即重试失败项,也提供“重试推荐”入口。成功缓存继续复用,重试不改变输入草稿,模型目录尚未加载时不发送空模型请求。
Agent 面板、历史和主题窗口及正文阅读窗口共用选区复制处理,仅移除浏览器附加的末尾段落换行。正文内部段落、代码缩进、表格制表符与用户输入的有效换行保留;输入框沿用原生复制。
每个会话持有独立 Chat 实例、生成连接、草稿、引用、阅读位置和审批状态。切换历史或新建对话保留原会话后台生成;后台需要审批或回答时,通过历史入口提示返回处理。模型、项目头和凭据续期绑定原会话。后台浏览器动作返回 `SESSION_NOT_ACTIVE`,提示用户回到会话后重新请求;切换时取消原会话尚在执行的浏览器动作。切换业务项目由工作台统一处理,会话、主题及缓存按业务项目隔离。
头部在线状态由 Agent 公共 `/health` 的就绪结果独立决定,单次业务失败、提问等待、主动停止和新建对话不直接改为离线或在线。在线时每 15 秒探测,失败后按 1、2、4、8、15 秒退避,单次超时 5 秒;浏览器恢复联网、窗口聚焦或页面重新可见时立即重检。服务恢复后刷新初始化信息和当前会话的提问、输入队列,保留草稿与阅读位置。
已知会话的 Responses 流意外断开后,改用该会话的只读订阅恢复;订阅中断同样按 1 至 15 秒退避重连,合并权威消息尾部并保留已加载历史。重连不重发问题、不重启模型、不重做工具操作,也不将断流视为任务完成。权限失效、会话不存在、主动停止和所属工作台销毁时终止重连;切换到其他对话仍由原会话持有后台恢复连接。
## 4. 内容与状态 ## 4. 内容与状态
### 任务驱动的回答表达
按 Intelligent UI 的声明式组件路线增强当前对话:Agent 使用既有 Harness 与 `present_answer` 决定文字、指标、趋势、对照表、证据及补充表单的组合,前端负责本地交互和视觉层次。简单回答保留文字;说明和判断边界使用本地 shadcn/ui `Alert` 的图标、标题与正文组合,静态历史内容使用 `note` 语义,避免恢复会话时反复播报警告。
包含两类及以上可导航组件,且含表格、图表、补充表单或达到长内容标准时,回答上方出现“浏览回答”菜单,按实际组件显示类别和数量。选中项目在当前内容实例内滚动并交接键盘焦点;Escape 关闭菜单后回到触发按钮。导览沿用 `DropdownMenu`、`Button`、`Badge`,不切换页面、不重建图表或表单。只有一种可导航组件或内容较短时不增加导览。导航入口与内容类别摘要在窄屏保持紧凑,长标签自然换行。
图表保留折线、柱状和数据页签;范围筛选收进标题右侧的控件,所选非完整区间仍明确显示。数据点详情按需展开,点击图上数据点自动展开对应读数;键盘用户通过“查看数据点”及选择器完成同等操作。收起详情保留所选点。当前界面交互均只影响已加载数据;新查询与仿真仍走原业务工具。
消息标题区明确助手身份和真实生成状态。指标保留原始精度、单位与来源;证据继续区分事实和推断;资料表单通过 `CardHeader`、`CardContent` 划分说明和输入,提交是明确的主操作,失败保留填写内容。颜色、字体与表面沿用样式设计指南,交互状态不冒充业务状态。
本次无需新增 UI 协议、环境变量或数据库迁移。自由代码沙箱、跨会话保存控件状态和参数驱动的新数据查询属于后续能力;当前表单、筛选与选择在聊天和展开工作区之间移动时保留,历史恢复仍从已保存的回答结构重新建立控件。
后续的状态保存、压力历史探索与沙箱接入条件见 [可持续分析工作区设计](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)。
生成中显示已经到达的文字、内容块和公开执行摘要;最终答复到达后,正文和它明确引用的内容留在主阅读流,其他记录进入处理过程。待回答问题、权限审批和失败状态保持可见,不能因最终文字到达而被隐藏。这里借鉴 Reasoning 的折叠行为,呈现的数据是服务端已发布的过程信息,不额外生成思考文本,也不根据耗时推算完成比例。 处理过程统一位于回复正文下方。生成中,文字与回答内容块在前,公开执行摘要、标记为过程的内容块和待办在后;最终答复到达或回复流结束后,过程收起为底部“查看处理过程”,入口位于语音播放左侧,没有 `final_answer` 标记的历史回复也遵循同一布局。最终答复明确引用的回答内容块留在主阅读流,其他记录进入处理过程。待回答问题、权限审批和失败状态在正文下方保持可见,不能因回复结束而被隐藏。这里借鉴 Reasoning 的折叠行为,呈现的数据是服务端已发布的过程信息,不额外生成思考文本,也不根据耗时推算完成比例。
最终答复到达后,正文、回复状态与语音/复制操作在同一次交接中切换,网络流的后续收尾不再改变这条回复的展示阶段。输入框仍根据真实执行状态接受引导或排队消息。过程复用生成时的容器和步骤实例,保留工具详情的展开状态;无论用户是否向上滚动或查看过工具详情,交接后默认收起过程并优先让结果正文可读;用户可通过“查看处理过程”重新展开。历史回放直接显示保存的结果,不重新播放交接。
补充问题打断后继续执行时,依据提问记录的 `message_id` 和补充回答的 `delivery.native_message_id` 将前后过程归到续接答复下方;连续补充时沿同一关联链顺序归组。没有正文的旧过程消息不再单独占位,原有正文和用户补充回答仍保留。尚未出现续接答复、历史分页缺少关联消息、仍有待回答问题或审批、关联之间出现新用户请求时,保留原位置。该归组仅影响展示,不改原始记录或消息标识。
生成中、补充后续接和历史恢复共用一个处理过程容器,边框、背景和内边距保持一致;各段执行记录合并为一份列表,不再嵌套另一张“执行进度”卡片。步骤显示身份包含来源消息,避免前后轮次的同名步骤互相覆盖;生成时统一控制整个过程的最近三项与展开全部,完成后由底部入口统一折叠。
已提交的回答以原生用户消息 ID 保存纯净文本和 `data-interaction_reply` 快照,首屏即可还原同一张“已回答”卡片并归组前后过程,不等待独立提问接口。该接口返回较新修订后更新投递状态和重试操作。旧历史由 Agent 按所属会话中已保存的投递 ID 补齐相同表示,不通过文本前缀猜测对应关系,也不改写历史文件。给模型的“用户提交的提问回复”协议前缀只用于推理输入。
提问等待和续接失败不禁用消息发送。生成中或存在未解决的提问/续接时,输入区提供“引导当前任务”和“排队处理”;消息通过既有 Responses 输入接口进入原生收件箱。没有宿主执行轮次时只保存输入,不自行启动模型,也不把消息当作提问表单的答案。读取续接状态时,服务端可以依据已消费的原生回答和已完成的原生输出恢复丢失的确认;仅有部分文字或原生状态不可用时保留未确认状态。若输入在入队前被明确告知当前轮次已结束,客户端刷新状态后作为新一轮发送;其他错误不自动重发。
证据类别明确区分查询记录、用户提供、分析推断和演练数据。地图定位的按钮结果以实际回调为准;资料表单提交后只表示发送消息,业务证据是否保存取决于后端实际结果。最终回复不等于后台调查任务完成。 证据类别明确区分查询记录、用户提供、分析推断和演练数据。地图定位的按钮结果以实际回调为准;资料表单提交后只表示发送消息,业务证据是否保存取决于后端实际结果。最终回复不等于后台调查任务完成。
@@ -184,15 +216,17 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
## 7. 交互规则与范围 ## 7. 交互规则与范围
数据组件按展示与本地浏览组织:短内容不附加目录;单个证据或补充表单不叠加分组标题与固定提示;来源已在证据卡中显示时,报告外层只列出其余依据。调查脉络节点保留详情、缩放和地图定位,不提供节点追问按钮。
| 内容 | 本轮实现 | 数据与操作边界 | | 内容 | 本轮实现 | 数据与操作边界 |
| --- | --- | --- | | --- | --- | --- |
| 指标 | 值、单位、来源,针对指标提问 | 不补造变化率、单位或精度 | | 指标 | 值、单位、来源 | 不补造变化率、单位或精度 |
| 表格 | 搜索、三态排序、行选择、引用所选行 | 只处理当前数据;更换数据集时重置行选择 | | 表格 | 搜索、三态排序 | 只处理当前数据;不提供勾选与引用操作 |
| 图表 | 折线 / 柱状 / 数据,序列显隐,加载区间,点击或键盘选点 | 最少保留一条序列;区间不触发查询或重算 | | 图表 | 折线 / 柱状 / 数据,序列显隐,加载区间,点击或键盘选点 | 最少保留一条序列;区间不触发查询或重算 |
| 证据 | 来源、类别、时间、编号,展开与类别筛选 | 正式报告使用已保存证据覆盖生成的摘要、来源和类别 | | 证据 | 来源、类别、时间、编号,展开与类别筛选 | 正式报告使用已保存证据覆盖生成的摘要、来源和类别 |
| 地图 | 显式定位,地图对照或切换 | 使用真实定位结果与失败反馈 | | 地图 | 显式定位,地图对照或切换 | 使用真实定位结果与失败反馈 |
| 表单 | 空白初始值、必填校验、提交中禁用、失败保留 | 聊天表单主动发送;报告表单先带入对话,确认后发送 | | 表单 | 空白初始值、必填校验、提交中禁用、失败保留 | 聊天表单主动发送;报告表单先带入对话,确认后发送 |
| 追问 | 输入区引用卡片、详情、移除 | 不自动发送;报告引用带选中的版本与轮次 | | 任务与报告对话 | 输入区引用卡片、详情、移除 | 保留专用任务、报告和补充表单入口;通用数据组件不产生引用 |
| 历史内容 | 固定报告事实可读,不支持的块显示摘要 | 降级不执行未知组件或事件 | | 历史内容 | 固定报告事实可读,不支持的块显示摘要 | 降级不执行未知组件或事件 |
本轮未包含参数重算、新数据查询控件、附件上传、导出、报告版本差异比较。后续扩展时需要独立定义参数校验、数据来源和执行反馈;不能把本地筛选当作重新分析。后台任务完成、业务审批和证据写入继续以各自服务端状态为准。 本轮未包含参数重算、新数据查询控件、附件上传、导出、报告版本差异比较。后续扩展时需要独立定义参数校验、数据来源和执行反馈;不能把本地筛选当作重新分析。后台任务完成、业务审批和证据写入继续以各自服务端状态为准。
@@ -205,14 +239,11 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
组件参数唯一来源为前端 `catalog.ts`,导出到 Agent 与 Server;跨字段约束在三端分别验证。Agent `publish_task_report` 使用正式报告通道,聊天 `present_answer` 使用聊天通道。实现与生成命令见 [本地交互式回答](agent-answer-ui.md)。 组件参数唯一来源为前端 `catalog.ts`,导出到 Agent 与 Server;跨字段约束在三端分别验证。Agent `publish_task_report` 使用正式报告通道,聊天 `present_answer` 使用聊天通道。实现与生成命令见 [本地交互式回答](agent-answer-ui.md)。
## 聊天过程与回答恢复 任务业务审核统一从 header 的“状态和提醒”进入对应任务详情,由关联的负责节点或动作卡承接人工审核。会话只保留任务引用、讨论和消息,不注入固定的任务状态、人工事实或业务审核表单;调查详情继续负责草稿编辑、批准、拒绝及执行回执。工具执行权限审批和用户问题仍按会话交互协议处理。
处理过程统一位于回复正文下方。生成中,文字与回答内容块在前,公开执行摘要、标记为过程的内容块和待办在后;最终答复到达或回复流结束后,过程收起为底部“查看处理过程”,入口位于语音播放左侧,没有 `final_answer` 标记的历史回复也遵循同一布局。最终答复明确引用的回答内容块留在主阅读流,其他记录进入处理过程。待回答问题、权限审批和失败状态在正文下方保持可见,不能因回复结束而被隐藏。这里借鉴 Reasoning 的折叠行为,呈现的数据是服务端已发布的过程信息,不额外生成思考文本,也不根据耗时推算完成比例。
最终答复到达后,正文、回复状态与语音/复制操作在同一次交接中切换,网络流的后续收尾不再改变这条回复的展示阶段。输入框仍根据真实执行状态接受引导或排队消息。过程复用生成时的容器和步骤实例,保留工具详情的展开状态;无论用户是否向上滚动或查看过工具详情,交接后默认收起过程并优先让结果正文可读;用户可通过“查看处理过程”重新展开。历史回放直接显示保存的结果,不重新播放交接。 ## 调查报告与自由对话的分工
补充问题打断后继续执行时,依据提问记录的 `message_id` 和补充回答的 `delivery.native_message_id` 将前后过程归到续接答复下方;连续补充时沿同一关联链顺序归组。没有正文的旧过程消息不再单独占位,原有正文和用户补充回答仍保留。尚未出现续接答复、历史分页缺少关联消息、仍有待回答问题或审批、关联之间出现新用户请求时,保留原位置。该归组仅影响展示,不改原始记录或消息标识。 后台调查的固定问答放在右侧调查报告的“补充回答”中,支持选项、自定义文字、提交状态和历史回答。任务通知打开该报告;提交直接保存到原任务并接续调查,无需创建或发送左侧聊天消息。数据缺口旁的“回答当前调查问题”定位到报告内待答表单。
生成中、补充后续接和历史恢复共用一个处理过程容器,边框、背景和内边距保持一致;各段执行记录合并为一份列表,不再嵌套另一张“执行进度”卡片。步骤显示身份包含来源消息,避免前后轮次的同名步骤互相覆盖;生成时统一控制整个过程的最近三项与展开全部,完成后由底部入口统一折叠。 左侧 Agent 保留自由讨论、追问和进一步分析。“Agent 协作”和“讨论此缺口”是明确的讨论入口;固定任务回答不经过这些入口转发。
已提交的回答以原生用户消息 ID 保存纯净文本和 `data-interaction_reply` 快照,首屏即可还原同一张“已回答”卡片并归组前后过程,不等待独立提问接口。该接口返回较新修订后更新投递状态和重试操作。旧历史由 Agent 按所属会话中已保存的投递 ID 补齐相同表示,不通过文本前缀猜测对应关系,也不改写历史文件。给模型的“用户提交的提问回复”协议前缀只用于推理输入。
+375
View File
@@ -0,0 +1,375 @@
# Intelligent UI 交互状态补齐设计
> 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。
+106
View File
@@ -0,0 +1,106 @@
# 调查任务展示与 Dev 测试
可直接打开顶部 Dev 入口载入、编辑、校验并执行本地测试案例,无需先选择调查或开启实时跟随。纯数据和界面块可独立预览,包含地图或图表展示指令时需等待地图就绪。验证实际调查联动时,在“工况任务 → 调查任务”选择一项任务,再打开 Dev。Dev 默认打开“调查测试”页签,保留当前选择,显示调查代次、实时图表数、已保存报告中的图表/表格/地图入口数,以及最近的展示动作。上一轮报告会明确标注,不当成本轮新成果。
Dev 分为“调查测试”和“地图调试”两个页签:前者按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程,后者保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。
## 当前识别链路
| 内容 | 来源和行为 |
| --- | --- |
| 实时地图、图表和覆盖层 | 从工作台事件流读取 `operational.presentation.requested`,核对所选 task、run 和 generation,验证命令后使用调查展示控制器执行 |
| 已保存的结构化界面 | 任务报告的 `ui_blocks` 使用既有 `answer-ui@1` 校验及 json-render 组件;未知或不合法的内容不作为可执行界面 |
| JSONL 数据 | Dev 中显式载入、校验后在展示面板预览;调查数据行仅展示,只有受控展示指令行会在点击“执行本地预览”后操作地图或图表 |
选中调查任务时,通过只读 `GET /api/v1/operational-tasks/{task_id}/presentation-events` 分页读取该任务保存的展示事件,并载入左下列表;同时载入本轮最新报告中已保存的有效界面块。已完成任务、关闭实时跟随时也可读取。历史报告仍在任务详情可读。开启跟随或重连时先建立固定事件游标,载入不超过该游标的历史,期间到达的新事件暂存,历史完成后再顺序消费,避免间隙或重复。“载入当前报告”加载该任务最新保存的报告界面块,供本地检查,不重新发布报告。
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
## 本地测试步骤
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
## 可切换测试案例
“JSONL 测试案例”选择框不依赖调查选择;地图就绪后即可切换并载入内容,显示该案例的预期结果。本地预览无需选择调查或开启跟随,表格、卡片、数据摘要、图表与范围统一在地图左下方的“本地预览展示”中显示,Dev 只保留输入、校验、原始数据检查与执行记录;“载入合成样例”重新生成当前案例,尚未选择时载入组合联动。修改输入会重置案例选择与校验状态,仍可继续手工测试。“载入当前报告”继续加载所选任务已保存的界面块。
| 案例 | 核对内容 |
| --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon |
| 投诉热力分布 | 三个具有非负权重的合成投诉点 |
| 候选管段对比 | 合成候选表格及两条 candidate 数据,不查询虚拟管段 |
| 隔离阀门清单 | 合成阀门表格及两条 valve 数据,不执行关阀 |
| 纯 JSONL 数据 | 只显示数据;内嵌 action 字段不作为动作执行 |
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
| 错误:不支持的动作 | 拒绝协议未支持的动作 |
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。
## JSONL 格式
以下每行都是独立对象。第一行只在点击执行后清理本地高亮;第二行预览合成说明;第三行仅为候选数据,不触发高亮、定位或计算。
```jsonl
{"record_type":"presentation","command":{"action":"clear_highlights"}}
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"dev-note","fallback_text":"本地合成格式示例","spec":{"root":"note","elements":{"note":{"type":"Text","props":{"text":"本地合成格式示例,不代表调查成果。","tone":"warning"},"children":[]}}}}}
{"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` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。
每行必须显式使用 `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`、`origin`、`source_evidence_refs`,其余字段按实际内容保留。`origin` 接受 system_data、synthetic、derived、human_input,仅说明本地数据来源,不构成 Server 证据认证。显示合成表格不等于运行过定位或完成现场处置。
## 验证范围
聚焦测试:
```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
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
```
浏览器用隔离 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 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。
动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema 和 Agent/Server JSON Schema;运行同一命令加 `--check` 检查一致性。Server 任务入口继续执行证据归属和快照校验。
## 已有案例与示例迁移
仓库内正向示例统一使用以下格式;测试输入中的旧格式仅用于明确的拒绝用例。
| 内容 | 当前格式 |
| --- | --- |
| 地图、图表和 SCADA 动作 | `{"record_type":"presentation","command":{"action":"动作名",…}}` |
| 图表动作 | `open_chart`,图表数据位于 `command.chart`,明确提供 `chart_id` 和 `evidence_refs` |
| 结构化界面 | `{"record_type":"ui_block","block":{"version":"answer-ui@1","block_id":"…","phase":"final",…},"evidence_refs":[]}` |
| 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 |
| Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 |
本轮迁移只覆盖仓库内的内置案例、夹具和文档示例,未修改历史会话、数据库或外部下载文件。没有增加运行时旧格式转换。
## 展示动作历史
调查实时展示按所选任务读取 Server 已保存的展示动作,各任务的历史在浏览器中分别保留、切换时按任务展示。对话展示和本地预览展示继续保留当前项目、本次页面会话内收到的动作。清除类动作不纳入历史。展示面板的“切换展示动作”以最新优先倒序排列,“当前(最新)”始终置顶,动作编号保留发生顺序。可选择任一已完成、未完成或取消的动作,也可用上一步/下一步逐步回看;进行中的动作显示状态但不可选择。“回到当前”恢复最新展示。 当前结果与最后一条已完成动作相同时,合并显示为“当前(最新)”,下拉和前后导航不再重复这一项。清理后当前结果与最后快照不同,最后一条仍作为历史结果可选。切换任务、切换案例、关闭图表和清除当前展示都会保留历史;切换项目或刷新页面后清空浏览器缓存,重新选择调查任务可再次读取 Server 保存的事件。不设最近若干条的截断。载入和回看不向 Server 发布事件,不重启调查、审批或现场操作;重复选择同一任务按任务编号和事件序号去重。
本地快照保存图表、界面块、数据、证据摘要、地图几何及镜头。Server 历史保留原发布指令和空间证据快照;只有要素编号的旧定位指令在首次载入时按当前项目读取可访问几何,不声称这些是历史时刻的资产位置。载入后回看使用本地快照,不重复查询。上一轮结果可作为历史查看,不混入本轮当前状态。单条过期或不合法指令显示失败记录,其他有效结果继续载入。历史导航直接渲染快照,不重新查询资产、执行原指令或发送调查请求。关闭图表、显示/隐藏及定位覆盖层、界面块关联要素定位等操作会产生记录;清除临时展示、清除地图展示,以及 `clear_highlights/clear_overlay/clear_scada_analysis` 指令只更新当前状态,不创建历史条目,已有结果快照仍保留;地图手动拖拽和证据展开属于查看操作,不逐帧记录。记录不是完整底图或外部实时数据的离线副本。
回看期间,新动作继续更新“当前”,面板保留正在查看的历史并提示后续动作数。“回到当前”恢复这些最新结果。历史画面禁止关闭图表、修改覆盖层和发起新的关联要素查询,仍可查看依据、检查表格并定位已有覆盖层。地图与界面使用已有过渡,并尊重减少动画设置。Dev 原始数据检查对应编辑器最近一次执行的输入,不随展示历史切换;历史结果及快照以展示面板为准。
历史接口按 `after`、`through` 游标和 `limit` 分页,返回 `task_id/event_cursor/items/next_after`;客户端固定首次授权游标,不截断为最近一页。Server 按项目、任务及展示事件类型读取现有事件日志,不需要新增数据表。新接口需与前端一起发布;测试覆盖的是当前源码的服务/仓储/API 单元检查与浏览器模拟 API 联动,未替代真实项目验收。
+2 -2
View File
@@ -27,7 +27,7 @@
| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 | | 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 |
| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 | | 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 |
`FeatureInsightPanel` 当前只有定义,没有运行入口,未伪造页面声称已完成运行检查。实际要素详情使用地图上的 `FeaturePopover`,不在本次非地图面板范围。 实际要素详情使用地图上的 `FeaturePopover`;未接入运行入口的 `FeatureInsightPanel` 已在冗余清理中移除。
## 验证与限制 ## 验证与限制
@@ -108,7 +108,7 @@
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。 - 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。 - 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;synthetic 数据、测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。 - 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。外围工单统一按系统数据处理。
- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。 - `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。
- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。 - 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。
+23 -5
View File
@@ -1,6 +1,6 @@
# 样式设计指南 # 样式设计指南
供水前端统一以本地 shadcn/ui 为组件基础,使用 Tailwind CSS、共享变体和语义变量。Agent 相关弹窗、面板与菜单保持 shadcn/ui 设计;地图控件与其他工作台浮层使用本文规定的 Acrylic 视觉。适用于工作台、地图控件、Agent、报告、表单和弹层。 供水前端统一以本地 shadcn/ui 为组件基础,使用 Tailwind CSS、共享变体和语义变量。Agent 相关弹窗、面板与菜单,以及 Dev 测试面板、外围系统面板、调查与本地预览展示面板保持 shadcn/ui 设计;其他地图控件与工作台浮层使用本文规定的 Acrylic 视觉。适用于工作台、地图控件、Agent、报告、表单和弹层。
本文是唯一的样式规范。业务交互见 [Agent 交互与报告设计](agent-chat-ui-design.md),状态含义见 [状态语言](status-language.md)。本文规定统一目标;历史局部配色和样式覆盖需在后续组件修改中逐步对齐,文档合并不代表运行样式已全部迁移。 本文是唯一的样式规范。业务交互见 [Agent 交互与报告设计](agent-chat-ui-design.md),状态含义见 [状态语言](status-language.md)。本文规定统一目标;历史局部配色和样式覆盖需在后续组件修改中逐步对齐,文档合并不代表运行样式已全部迁移。
@@ -16,6 +16,24 @@
### Agent 弹窗与面板 ### Agent 弹窗与面板
回答正文与注意事项、表格等结构块之间使用 20px 的布局间距。Markdown 复制容器使用 `display: contents` 时,不使用直接子元素 margin 实现这段间距,避免间距丢失。
回复生成期间只在正文左侧显示一处“正在回复”状态,状态组与正文左边缘对齐;不再在空正文中重复显示“正在生成”。
过程切换为正式结果时,先将旧内容淡出,再统一切换正文、过程归档与底部操作,最后淡入结果,整体约 200ms;只改变透明度,不对长内容高度做插值。切换期间保护阅读锚点,用户滚动或操作后停止自动位置补偿。系统要求减少动态效果时直接交接,仍保留阅读位置和工具展开状态。
面板头部使用紧凑字号:助手名称为 14px/20px,在线状态和主题选择为 12px/16px,保留原有按钮点击区域。对话中的用户消息、回答正文与输入文字为 16px,消息正文与输入行高为 28px;回复状态、依据轮次、输入引用、协作标签及操作文字为 14px,说明行高为 20px。引用标题通过字重区分,说明与标题间隔 4px。输入框顶部不重复显示等待回答的常驻提示,提问与继续状态由对应提问卡片呈现。
起始推荐问题及其加载、失败提示统一使用 14px/20px、常规字重,窄屏下允许完整换行。
输入框按上方引用、中间编辑、下方工具栏排列,外壳使用 22px 圆角和轻边框。任务引用使用无独立底色的紧凑行,引用图标为 14px,文字为 14px/20px;任务名称与轮次、报告版本放在同一文字流中,窄屏在完整短语之间换行。上下内边距各 4px,不在输入底栏重复显示“任务协作问答”标签。内容引用复用紧凑行,原文与分析方向按需展开。权限、模型和消息发送方式的下拉入口沿用 10px 圆角矩形,箭头不设独立圆形区域;悬停和打开时使用整行的淡底色,箭头保持中性颜色;整行保持可点击。语音与发送按钮继续使用圆形。
Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白色阅读背景,桌面、窄屏和浮动输入框复用同一规则。聚焦与未聚焦保持相同边框和阴影,不显示蓝色外圈;工具按钮保留键盘焦点提示。左侧始终显示权限模式与文字,任务协作会话同样保留该入口,运行期间按原规则禁用。右侧依次放模型、语音和发送操作;运行时模型位置切换为引导或排队方式,主按钮沿用原有停止与发送切换;停止使用柔和红底和深红图标,悬停时红底略深,发送保持蓝色。工具图标统一为 16px、2px 描边,下拉箭头统一为 12px 单箭头;语音使用声波图标,监听时保留动态声波反馈。发送使用左右对称的 20px 上箭头和 40px 圆形按钮,图标通过网格居中;禁用时使用浅底、灰色图标,启用时使用蓝底、白色图标。模型名称按输入框实际宽度显示。待处理消息队列位于编辑框上方,区别于尚未发送的草稿。
助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。
Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试与地图调试两个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 先展示最新值,再提供异常演练。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。
Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。 Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。
| 场景 | 组件与组合 | | 场景 | 组件与组合 |
@@ -23,7 +41,7 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
| 历史、主题表单、独立详情与确认 | `Dialog`、`DialogTitle`、`DialogDescription`,组合 `Input`、`Button` 和列表 | | 历史、主题表单、独立详情与确认 | `Dialog`、`DialogTitle`、`DialogDescription`,组合 `Input`、`Button` 和列表 |
| 对话、审批、问答与展开阅读面板 | 本地 shadcn/ui 面板布局,按需组合 `Card`、`Separator`、`Tabs`、`ResizablePanelGroup` 与 AI Elements | | 对话、审批、问答与展开阅读面板 | 本地 shadcn/ui 面板布局,按需组合 `Card`、`Separator`、`Tabs`、`ResizablePanelGroup` 与 AI Elements |
| 模型、主题与会话操作菜单 | `DropdownMenu` 及其菜单项、分组和分隔线 | | 模型、主题与会话操作菜单 | `DropdownMenu` 及其菜单项、分组和分隔线 |
| 辅助说明与状态 | `Tooltip`、`HoverCard`、`Badge` 及共享状态组件 | | 辅助说明与状态 | `Alert`、`Tooltip`、`HoverCard`、`Badge` 及共享状态组件 |
- 使用已配置的 shadcn/ui 主题变量:`bg-background`、`bg-card`、`bg-popover`、`text-foreground`、`text-muted-foreground`、`border-border`、`ring-ring` 等。按钮、输入和菜单项优先使用本地默认样式及 `variant`、`size`,不为 Agent 另建配色和控件外观。 - 使用已配置的 shadcn/ui 主题变量:`bg-background`、`bg-card`、`bg-popover`、`text-foreground`、`text-muted-foreground`、`border-border`、`ring-ring` 等。按钮、输入和菜单项优先使用本地默认样式及 `variant`、`size`,不为 Agent 另建配色和控件外观。
- 历史和主题弹窗使用 `bg-card`,与 Agent 主对话区保持白色实体表面,标题区和列表不另铺灰色底。通过 Portal 渲染的弹窗及菜单标记 `data-agent-theme="chat"`,复用主面板的现有主题变量,避免继承工作台银灰色底板;保留本地 `Dialog` 的遮罩、边框、阴影与交互样式。 - 历史和主题弹窗使用 `bg-card`,与 Agent 主对话区保持白色实体表面,标题区和列表不另铺灰色底。通过 Portal 渲染的弹窗及菜单标记 `data-agent-theme="chat"`,复用主面板的现有主题变量,避免继承工作台银灰色底板;保留本地 `Dialog` 的遮罩、边框、阴影与交互样式。
@@ -79,6 +97,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
地图中的管线一起显示,以管径对应的颜色和线宽体现差异。水库、泵站、水箱和水质监测点保持清晰;阀门、普通节点与常规 SCADA 使用随缩放连续变化的大小和透明度。密集设施的工程符号自动避让,底层保留轻量圆点以表达位置和支持点选。选中与异常分析标记单独突出,普通设施以连续变化为主;普通节点从 12 级、阀门从 13 级开始显示,图标、标注与命中范围同步限制。 地图中的管线一起显示,以管径对应的颜色和线宽体现差异。水库、泵站、水箱和水质监测点保持清晰;阀门、普通节点与常规 SCADA 使用随缩放连续变化的大小和透明度。密集设施的工程符号自动避让,底层保留轻量圆点以表达位置和支持点选。选中与异常分析标记单独突出,普通设施以连续变化为主;普通节点从 12 级、阀门从 13 级开始显示,图标、标注与命中范围同步限制。
Agent、调查展示和 Dev 本地预览的要素定位复用地图调试的选中样式,按原业务源保留线宽、边框、圆环半径、设备类型过滤和缩放规则。展示覆盖层从当前地图样式派生,独立管理高亮与清理范围;闪烁只改变强调透明度,结束后恢复静态样式。
管道透明度不随缩放变化,缩放渐变体现在管线与描边的宽度上,远看纤细、放大清晰;图层开关时仍使用透明度淡入淡出。 管道透明度不随缩放变化,缩放渐变体现在管线与描边的宽度上,远看纤细、放大清晰;图层开关时仍使用透明度淡入淡出。
图层开关统一使用约 320ms 的透明度淡入淡出,覆盖管线、设施、监测点、GIS 原始数据和模拟结果。矢量瓦片可绘制后才开始淡入计时,避免加载期间耗尽过渡时间。淡入淡出只临时叠加透明度系数,结束后恢复图层原有样式;快速反向切换从当前进度继续。隐藏时立即关闭命中层与交互强调层,淡入结束后恢复;减少动态效果偏好下直接切换。管线用过滤器区分开关状态,透明度保持统一数值,避免逐帧重写属性表达式触发瓦片重算。 图层开关统一使用约 320ms 的透明度淡入淡出,覆盖管线、设施、监测点、GIS 原始数据和模拟结果。矢量瓦片可绘制后才开始淡入计时,避免加载期间耗尽过渡时间。淡入淡出只临时叠加透明度系数,结束后恢复图层原有样式;快速反向切换从当前进度继续。隐藏时立即关闭命中层与交互强调层,淡入结束后恢复;减少动态效果偏好下直接切换。管线用过滤器区分开关状态,透明度保持统一数值,避免逐帧重写属性表达式触发瓦片重算。
@@ -93,7 +113,7 @@ Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列
## 4. 材质与层级 ## 4. 材质与层级
本节的 Acrylic 材质规则适用于地图与其他工作台界面;Agent 弹窗与面板按前述 shadcn/ui 规则执行。Acrylic 通过半透明色调、背景模糊、混合层和细噪点建立层级,边缘高光与阴影保持克制。常驻、参与布局的业务区域使用实体底板;Mica 仅作为这一底板角色的设计参考,Web CSS 实体色不等同于原生系统 Mica。 本节的 Acrylic 材质规则适用于地图与其他工作台界面;Agent、Dev 测试、外围系统、调查与本地展示面板按前述 shadcn/ui 规则执行。Acrylic 通过半透明色调、背景模糊、混合层和细噪点建立层级,边缘高光与阴影保持克制。常驻、参与布局的业务区域使用实体底板;Mica 仅作为这一底板角色的设计参考,Web CSS 实体色不等同于原生系统 Mica。
| 表面类 | 用途 | | 表面类 | 用途 |
|---|---| |---|---|
@@ -179,5 +199,3 @@ Agent 界面另检查所有弹窗、面板及其子菜单是否沿用本地 shad
- [shadcn/ui 组件原则](https://ui.shadcn.com/docs)、[主题变量](https://ui.shadcn.com/docs/theming)、[Vite 接入](https://ui.shadcn.com/docs/installation/vite)、[CLI 配置](https://ui.shadcn.com/docs/components-json)。 - [shadcn/ui 组件原则](https://ui.shadcn.com/docs)、[主题变量](https://ui.shadcn.com/docs/theming)、[Vite 接入](https://ui.shadcn.com/docs/installation/vite)、[CLI 配置](https://ui.shadcn.com/docs/components-json)。
- [Tailwind CSS 主题变量](https://tailwindcss.com/docs/theme)、[自定义样式与层叠](https://tailwindcss.com/docs/adding-custom-styles)。 - [Tailwind CSS 主题变量](https://tailwindcss.com/docs/theme)、[自定义样式与层叠](https://tailwindcss.com/docs/adding-custom-styles)。
- [Fluent Acrylic](https://learn.microsoft.com/en-us/windows/apps/design/style/acrylic)、[Mica](https://learn.microsoft.com/en-us/windows/apps/design/style/mica)。 - [Fluent Acrylic](https://learn.microsoft.com/en-us/windows/apps/design/style/acrylic)、[Mica](https://learn.microsoft.com/en-us/windows/apps/design/style/mica)。
聊天的生成、续接与刷新恢复共用一个过程容器和一份步骤列表。过程入口在回复底部靠左,语音和复制靠右。发送保持蓝色圆形按钮,停止使用柔和红底与深红图标,悬停时红底略深。
+2 -1
View File
@@ -3,13 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link <link
rel="preload" rel="preload"
as="fetch" as="fetch"
href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf" href="https://demotiles.maplibre.org/font/Noto%20Sans%20Regular/0-255.pbf"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<title>供水管网</title> <title>供水管网智慧运维系统</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -4
View File
@@ -26,11 +26,12 @@
"@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",
"@maplibre/maplibre-gl-style-spec": "26.4.4",
"@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18", "@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-hover-card": "^1.1.17", "@radix-ui/react-popover": "^1.3.1",
"@radix-ui/react-select": "^2.3.1", "@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10", "@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
@@ -48,18 +49,16 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0", "katex": "^0.17.0",
"keycloak-js": "26.2.4", "keycloak-js": "26.2.4",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"maplibre-gl": "^6.4.1", "maplibre-gl": "^6.4.1",
"motion": "^12.40.0", "motion": "^12.40.0",
"nanoid": "^5.1.14", "node-edge-tts": "1.2.12",
"pmtiles": "4.3.0", "pmtiles": "4.3.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-resizable-panels": "^4.14.7", "react-resizable-panels": "^4.14.7",
"shiki": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"swr": "^2.4.2", "swr": "^2.4.2",
+220 -153
View File
@@ -24,6 +24,9 @@ importers:
'@json-render/react': '@json-render/react':
specifier: ^0.21.0 specifier: ^0.21.0
version: 0.21.0(react@19.2.7)(zod@3.25.76) version: 0.21.0(react@19.2.7)(zod@3.25.76)
'@maplibre/maplibre-gl-style-spec':
specifier: 26.4.4
version: 26.4.4
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.2.14 specifier: ^1.2.14
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -36,9 +39,9 @@ importers:
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.18 specifier: ^2.1.18
version: 2.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-hover-card': '@radix-ui/react-popover':
specifier: ^1.1.17 specifier: ^1.3.1
version: 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -90,9 +93,6 @@ importers:
echarts-for-react: echarts-for-react:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.6(echarts@6.1.0)(react@19.2.7) version: 3.0.6(echarts@6.1.0)(react@19.2.7)
edge-tts-ts:
specifier: ^1.0.0
version: 1.0.0
katex: katex:
specifier: ^0.17.0 specifier: ^0.17.0
version: 0.17.0 version: 0.17.0
@@ -108,9 +108,9 @@ importers:
motion: motion:
specifier: ^12.40.0 specifier: ^12.40.0
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
nanoid: node-edge-tts:
specifier: ^5.1.14 specifier: 1.2.12
version: 5.1.16 version: 1.2.12
pmtiles: pmtiles:
specifier: 4.3.0 specifier: 4.3.0
version: 4.3.0 version: 4.3.0
@@ -123,9 +123,6 @@ importers:
react-resizable-panels: react-resizable-panels:
specifier: ^4.14.7 specifier: ^4.14.7
version: 4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
shiki:
specifier: ^4.2.0
version: 4.3.1
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -860,6 +857,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-arrow@1.1.17':
resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.17': '@radix-ui/react-collapsible@1.1.17':
resolution: {integrity: sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==} resolution: {integrity: sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==}
peerDependencies: peerDependencies:
@@ -979,6 +989,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-dismissable-layer@1.2.1':
resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.21': '@radix-ui/react-dropdown-menu@2.1.21':
resolution: {integrity: sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==} resolution: {integrity: sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==}
peerDependencies: peerDependencies:
@@ -1001,6 +1024,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-focus-guards@1.2.0':
resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.13': '@radix-ui/react-focus-scope@1.1.13':
resolution: {integrity: sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==} resolution: {integrity: sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==}
peerDependencies: peerDependencies:
@@ -1014,8 +1046,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-hover-card@1.1.20': '@radix-ui/react-focus-scope@1.3.1':
resolution: {integrity: sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==} resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@@ -1058,6 +1090,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popover@1.3.1':
resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.3.4': '@radix-ui/react-popper@1.3.4':
resolution: {integrity: sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==} resolution: {integrity: sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==}
peerDependencies: peerDependencies:
@@ -1071,6 +1116,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popper@1.4.0':
resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.14': '@radix-ui/react-portal@1.1.14':
resolution: {integrity: sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==} resolution: {integrity: sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==}
peerDependencies: peerDependencies:
@@ -1084,6 +1142,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-portal@1.2.0':
resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.11': '@radix-ui/react-presence@1.1.11':
resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==}
peerDependencies: peerDependencies:
@@ -1340,6 +1411,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-rect@1.1.4':
resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.2': '@radix-ui/react-use-size@1.1.2':
resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
peerDependencies: peerDependencies:
@@ -1349,6 +1429,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-size@1.1.5':
resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.8': '@radix-ui/react-visually-hidden@1.2.8':
resolution: {integrity: sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==} resolution: {integrity: sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==}
peerDependencies: peerDependencies:
@@ -1365,6 +1454,9 @@ packages:
'@radix-ui/rect@1.1.2': '@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@radix-ui/rect@1.1.3':
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
'@rive-app/react-webgl2@4.29.3': '@rive-app/react-webgl2@4.29.3':
resolution: {integrity: sha512-NotopuJ2FEM/th6t3kTN9aZB35xKeyqW68UXBy2ybPirysbLqn2yrGTP7IXoouG0MmRiGqFm+Ufbhk7LrkTc7g==} resolution: {integrity: sha512-NotopuJ2FEM/th6t3kTN9aZB35xKeyqW68UXBy2ybPirysbLqn2yrGTP7IXoouG0MmRiGqFm+Ufbhk7LrkTc7g==}
peerDependencies: peerDependencies:
@@ -1517,49 +1609,21 @@ packages:
'@shikijs/core@3.23.0': '@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@3.23.0': '@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@3.23.0': '@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@3.23.0': '@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@3.23.0': '@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@3.23.0': '@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -2275,10 +2339,6 @@ packages:
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@7.2.0: commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -2544,10 +2604,6 @@ packages:
echarts@6.1.0: echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
edge-tts-ts@1.0.0:
resolution: {integrity: sha512-327gpuN0VjvMsuvbizqabzqMiYpdHb0Slt8/hyf+ridtSkOGN68oogLpFnm8KznunbCnoo3DWMTAn7j6sd3WrA==}
hasBin: true
electron-to-chromium@1.5.393: electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
@@ -2962,11 +3018,6 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
ws: '*'
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@@ -3416,14 +3467,13 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-edge-tts@1.2.12:
resolution: {integrity: sha512-tvopsQ+XkxHGHM58GUVDM7iwzxVivjqSJb948xg2F0q9r+OWO7bSrJkHnJ0e7BPTxhPiTrxK+9zsl4CL5H5KbQ==}
hasBin: true
node-releases@2.0.51: node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3730,10 +3780,6 @@ packages:
shiki@3.23.0: shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
siginfo@2.0.0: siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -3750,9 +3796,6 @@ packages:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
sound-play@1.1.0:
resolution: {integrity: sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3982,10 +4025,6 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@13.0.2:
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
hasBin: true
uuid@14.0.1: uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true hasBin: true
@@ -4815,6 +4854,15 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-arrow@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-collapsible@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-collapsible@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.6 '@radix-ui/primitive': 1.1.6
@@ -4928,6 +4976,19 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.2.0
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-dropdown-menu@2.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-dropdown-menu@2.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.6 '@radix-ui/primitive': 1.1.6
@@ -4949,6 +5010,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-focus-guards@1.2.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-focus-scope@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-focus-scope@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
@@ -4960,17 +5027,11 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-hover-card@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
optionalDependencies: optionalDependencies:
@@ -5017,6 +5078,30 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-popover@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.2.0
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.5.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-popper@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-popper@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -5035,6 +5120,24 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-popper@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-size': 1.1.5(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/rect': 1.1.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-portal@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-portal@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -5045,6 +5148,16 @@ snapshots:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-portal@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-presence@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-presence@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
@@ -5291,6 +5404,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/rect': 1.1.3
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -5298,6 +5418,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
'@radix-ui/react-use-size@1.1.5(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-visually-hidden@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@radix-ui/react-visually-hidden@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -5309,6 +5436,8 @@ snapshots:
'@radix-ui/rect@1.1.2': {} '@radix-ui/rect@1.1.2': {}
'@radix-ui/rect@1.1.3': {}
'@rive-app/react-webgl2@4.29.3(react@19.2.7)': '@rive-app/react-webgl2@4.29.3(react@19.2.7)':
dependencies: dependencies:
'@rive-app/webgl2': 2.38.3 '@rive-app/webgl2': 2.38.3
@@ -5400,68 +5529,30 @@ snapshots:
'@types/hast': 3.0.5 '@types/hast': 3.0.5
hast-util-to-html: 9.0.5 hast-util-to-html: 9.0.5
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0': '@shikijs/engine-javascript@3.23.0':
dependencies: dependencies:
'@shikijs/types': 3.23.0 '@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6 oniguruma-to-es: 4.3.6
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@3.23.0': '@shikijs/engine-oniguruma@3.23.0':
dependencies: dependencies:
'@shikijs/types': 3.23.0 '@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.23.0': '@shikijs/langs@3.23.0':
dependencies: dependencies:
'@shikijs/types': 3.23.0 '@shikijs/types': 3.23.0
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/themes@3.23.0': '@shikijs/themes@3.23.0':
dependencies: dependencies:
'@shikijs/types': 3.23.0 '@shikijs/types': 3.23.0
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types@3.23.0': '@shikijs/types@3.23.0':
dependencies: dependencies:
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5 '@types/hast': 3.0.5
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
@@ -6097,8 +6188,6 @@ snapshots:
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
commander@14.0.3: {}
commander@7.2.0: {} commander@7.2.0: {}
commander@8.3.0: {} commander@8.3.0: {}
@@ -6379,17 +6468,6 @@ snapshots:
tslib: 2.3.0 tslib: 2.3.0
zrender: 6.1.0 zrender: 6.1.0
edge-tts-ts@1.0.0:
dependencies:
commander: 14.0.3
isomorphic-ws: 5.0.0(ws@8.21.1)
sound-play: 1.1.0
uuid: 13.0.2
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
electron-to-chromium@1.5.393: {} electron-to-chromium@1.5.393: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -6874,10 +6952,6 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isomorphic-ws@5.0.0(ws@8.21.1):
dependencies:
ws: 8.21.1
jiti@2.7.0: {} jiti@2.7.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -7564,10 +7638,18 @@ snapshots:
nanoid@3.3.16: {} nanoid@3.3.16: {}
nanoid@5.1.16: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
node-edge-tts@1.2.12:
dependencies:
https-proxy-agent: 7.0.6
ws: 8.21.1
yargs: 17.7.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
node-releases@2.0.51: {} node-releases@2.0.51: {}
nwsapi@2.2.24: {} nwsapi@2.2.24: {}
@@ -7922,17 +8004,6 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5 '@types/hast': 3.0.5
shiki@4.3.1:
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
siginfo@2.0.0: {} siginfo@2.0.0: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
@@ -7944,8 +8015,6 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
sound-play@1.1.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {} space-separated-tokens@2.0.2: {}
@@ -8194,8 +8263,6 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
uuid@13.0.2: {}
uuid@14.0.1: {} uuid@14.0.1: {}
vfile-location@5.0.3: vfile-location@5.0.3:
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#4263c7" />
<g
fill="none"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
transform="translate(8 8) scale(2)"
>
<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z" />
<path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+35
View File
@@ -0,0 +1,35 @@
import process from "node:process";
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath, URL } from "node:url";
import { zodToJsonSchema } from "zod-to-json-schema";
import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts";
const sourceUrl = new URL("../src/features/agent/presentation/command-schema.ts", import.meta.url);
const source = await readFile(sourceUrl, "utf8");
const schema =
JSON.stringify(zodToJsonSchema(viewCommandSchema, { $refStrategy: "none" }), null, 2) + "\n";
const targets = [
[
new URL("../../next-tjwater-agent/src/frontendAction/command.ts", import.meta.url),
"// Generated by next-tjwater-frontend/scripts/export-view-command-contract.mjs.\n" + source
],
[
new URL(
"../../next-tjwater-agent/src/frontendAction/view-command.schema.json",
import.meta.url
),
schema
],
[new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema]
];
for (const [url, content] of targets) {
if (process.argv.includes("--check")) {
if ((await readFile(url, "utf8")) !== content)
throw new Error(`View command contract differs: ${fileURLToPath(url)}`);
} else {
await writeFile(url, content);
}
}
process.stdout.write(
`View command contract ${process.argv.includes("--check") ? "checked" : "exported"}.\n`
);
+53
View File
@@ -7,6 +7,13 @@ import {
type EdgeTtsMiddlewareOptions, type EdgeTtsMiddlewareOptions,
type EdgeTtsSynthesizer type EdgeTtsSynthesizer
} from "./edge-tts-service"; } from "./edge-tts-service";
import type { SpeechVoice } from "../src/shared/tts/voices";
const voices: SpeechVoice[] = [
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN", gender: "Female" },
{ id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN", gender: "Male" },
{ id: "zh-CN-XiaoyiNeural", name: "Xiaoyi", locale: "zh-CN", gender: "Female" }
];
const servers: ReturnType<typeof createServer>[] = []; const servers: ReturnType<typeof createServer>[] = [];
@@ -39,6 +46,52 @@ function postSpeech(baseUrl: string, body: unknown, headers = true) {
} }
describe("Edge TTS adapter", () => { describe("Edge TTS adapter", () => {
it("falls back to Mandarin when a non-Mandarin default is configured", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ defaultVoice: "en-US-AriaNeural", synthesize });
expect((await postSpeech(baseUrl, { text: "测试" })).status).toBe(200);
expect(synthesize).toHaveBeenCalledWith("测试", "zh-CN-XiaoxiaoNeural");
});
it("returns all voices and the default, sharing the catalog with selection validation", async () => {
const listVoices = vi.fn(async () => voices);
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices, synthesize, defaultVoice: voices[0].id });
const response = await fetch(`${baseUrl}/api/tts/edge/voices`);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ voices, defaultVoice: voices[0].id });
expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(200);
expect(synthesize).toHaveBeenLastCalledWith("测试", voices[1].id);
expect((await postSpeech(baseUrl, { text: "test", voice: voices[2].id })).status).toBe(200);
expect(synthesize).toHaveBeenLastCalledWith("test", voices[2].id);
expect(listVoices).toHaveBeenCalledOnce();
});
it("rejects unknown and malformed voice choices before synthesis", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices: async () => voices, synthesize });
for (const voice of ["zh-CN-UnknownNeural", "en-US-AriaNeural", "zh-TW-HsiaoChenNeural", "zh-CN-liaoning-XiaobeiNeural", "<voice name='bad'>", null, 12, {}]) {
expect((await postSpeech(baseUrl, { text: "测试", voice })).status).toBe(400);
}
expect(synthesize).not.toHaveBeenCalled();
});
it("can use the default when the catalog is unavailable and retry a failed catalog load", async () => {
const listVoices = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(voices);
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices, synthesize });
expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(503);
expect((await postSpeech(baseUrl, { text: "默认" })).status).toBe(200);
expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(200);
expect(listVoices).toHaveBeenCalledTimes(2);
});
it("refuses a chosen voice when its availability cannot be verified", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices: async () => { throw new Error("offline"); }, synthesize });
expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(503);
expect(synthesize).not.toHaveBeenCalled();
});
it("returns synthesized MP3 audio with the server-owned voice", async () => { it("returns synthesized MP3 audio with the server-owned voice", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([73, 68, 51])); const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([73, 68, 51]));
const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" }); const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" });
+54 -17
View File
@@ -1,7 +1,10 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Communicate } from "edge-tts-ts"; import { EdgeTTS } from "node-edge-tts";
import { speechVoiceIdSchema, type SpeechVoice } from "../src/shared/tts/voices";
import { createEdgeTtsVoiceCatalog } from "./edge-tts-voices";
const EDGE_TTS_PATH = "/api/tts/edge"; const EDGE_TTS_PATH = "/api/tts/edge";
const EDGE_TTS_VOICES_PATH = `${EDGE_TTS_PATH}/voices`;
const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"; const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
const MAX_REQUEST_BYTES = 8 * 1024; const MAX_REQUEST_BYTES = 8 * 1024;
const MAX_TEXT_LENGTH = 520; const MAX_TEXT_LENGTH = 520;
@@ -32,6 +35,7 @@ export type EdgeTtsMiddlewareOptions = {
synthesize?: EdgeTtsSynthesizer; synthesize?: EdgeTtsSynthesizer;
timeoutMs?: number; timeoutMs?: number;
trustProxy?: boolean; trustProxy?: boolean;
listVoices?: () => Promise<SpeechVoice[]>;
}; };
function sendJson(response: ServerResponse, status: number, message: string) { function sendJson(response: ServerResponse, status: number, message: string) {
@@ -76,14 +80,15 @@ function getClientKey(request: IncomingMessage, trustProxy: boolean) {
} }
export async function synthesizeEdgeSpeech(text: string, voice: string): Promise<Uint8Array> { export async function synthesizeEdgeSpeech(text: string, voice: string): Promise<Uint8Array> {
const communicate = new Communicate(text, { voice }); const communicate = new EdgeTTS({ voice, timeout: DEFAULT_TIMEOUT_MS });
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
let byteLength = 0; let byteLength = 0;
for await (const chunk of communicate.stream()) { for await (const chunk of communicate.streamAudio(text)) {
if (chunk.type !== "audio") continue; if (!Buffer.isBuffer(chunk)) continue;
chunks.push(chunk.data); chunks.push(chunk);
byteLength += chunk.data.byteLength; byteLength += chunk.byteLength;
if (byteLength > MAX_AUDIO_BYTES) throw new Error("语音服务返回的音频过大");
} }
if (!byteLength) throw new Error("语音服务返回了空音频"); if (!byteLength) throw new Error("语音服务返回了空音频");
@@ -99,27 +104,32 @@ export async function synthesizeEdgeSpeech(text: string, voice: string): Promise
export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) { export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) {
const synthesize = options.synthesize ?? synthesizeEdgeSpeech; const synthesize = options.synthesize ?? synthesizeEdgeSpeech;
const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE; const configuredVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE;
const defaultVoice = speechVoiceIdSchema.safeParse(configuredVoice).success ? configuredVoice : DEFAULT_VOICE;
const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT; const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT;
const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS; const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const trustProxy = options.trustProxy ?? false; const trustProxy = options.trustProxy ?? false;
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
const listVoices = createEdgeTtsVoiceCatalog(options.listVoices, now);
const rateBuckets = new Map<string, RateBucket>(); const rateBuckets = new Map<string, RateBucket>();
let activeRequests = 0; let activeRequests = 0;
return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => { return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => {
if (request.url?.split("?", 1)[0] !== EDGE_TTS_PATH) { const path = request.url?.split("?", 1)[0];
const voicesRequest = path === EDGE_TTS_VOICES_PATH;
if (path !== EDGE_TTS_PATH && !voicesRequest) {
next?.(); next?.();
return; return;
} }
if (request.method !== "POST") { const method = voicesRequest ? "GET" : "POST";
response.setHeader("Allow", "POST"); if (request.method !== method) {
sendJson(response, 405, "仅支持 POST 请求"); response.setHeader("Allow", method);
sendJson(response, 405, `仅支持 ${method} 请求`);
return; return;
} }
if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { if (!voicesRequest && !request.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
sendJson(response, 415, "请求必须使用 application/json"); sendJson(response, 415, "请求必须使用 application/json");
return; return;
} }
@@ -143,6 +153,19 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
return; return;
} }
if (voicesRequest) {
try {
const voices = await listVoices();
response.statusCode = 200;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify({ voices, defaultVoice }));
} catch {
sendJson(response, 503, "音色列表暂时不可用,请稍后重试");
}
return;
}
let payload: EdgeTtsPayload; let payload: EdgeTtsPayload;
try { try {
payload = await readJson(request); payload = await readJson(request);
@@ -151,10 +174,6 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
return; return;
} }
if (Object.prototype.hasOwnProperty.call(payload, "voice")) {
sendJson(response, 400, "语音角色只能由服务端配置");
return;
}
const text = typeof payload.text === "string" ? payload.text.trim() : ""; const text = typeof payload.text === "string" ? payload.text.trim() : "";
if (!text) { if (!text) {
sendJson(response, 400, "缺少待朗读文本"); sendJson(response, 400, "缺少待朗读文本");
@@ -164,6 +183,24 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH} 字`); sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH} 字`);
return; return;
} }
let voice = defaultVoice;
if (Object.prototype.hasOwnProperty.call(payload, "voice")) {
const selectedVoice = speechVoiceIdSchema.safeParse(payload.voice);
if (!selectedVoice.success) {
sendJson(response, 400, "请选择有效音色");
return;
}
try {
if (!(await listVoices()).some((item) => item.id === selectedVoice.data)) {
sendJson(response, 400, "所选音色不可用,请重新选择");
return;
}
voice = selectedVoice.data;
} catch {
sendJson(response, 503, "无法确认音色是否可用,请稍后重试");
return;
}
}
if (activeRequests >= maxConcurrent) { if (activeRequests >= maxConcurrent) {
response.setHeader("Retry-After", "1"); response.setHeader("Retry-After", "1");
sendJson(response, 429, "语音服务繁忙,请稍后重试"); sendJson(response, 429, "语音服务繁忙,请稍后重试");
@@ -171,7 +208,7 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
} }
activeRequests += 1; activeRequests += 1;
const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice)); const synthesisTask = Promise.resolve().then(() => synthesize(text, voice));
void synthesisTask.finally(() => { void synthesisTask.finally(() => {
activeRequests -= 1; activeRequests -= 1;
}).catch(() => undefined); }).catch(() => undefined);
+42
View File
@@ -0,0 +1,42 @@
import { EdgeTTS } from "node-edge-tts";
import { afterEach, expect, it, vi } from "vitest";
import { synthesizeEdgeSpeech } from "./edge-tts-service";
afterEach(() => vi.restoreAllMocks());
it("collects streamed MP3 bytes with the configured voice and a bounded timeout", async () => {
const stream = vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* (this: EdgeTTS) {
expect(this).toMatchObject({ voice: "zh-CN-XiaoxiaoNeural", timeout: 30_000 });
yield Buffer.from([73, 68]);
yield { part: "测试", start: 0, end: 1 };
yield Buffer.from([51]);
});
expect(await synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural"))
.toEqual(Uint8Array.from([73, 68, 51]));
expect(stream).toHaveBeenCalledWith("测试");
});
it("rejects empty audio and propagates upstream failures", async () => {
const stream = vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* () {});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural")).rejects.toThrow("空音频");
stream.mockImplementation(async function* () {
yield { part: "测试", start: 0, end: 1 };
throw new Error("upstream unavailable");
});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural"))
.rejects.toThrow("upstream unavailable");
});
it("stops consuming an oversized audio stream", async () => {
const closed = vi.fn();
vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* () {
try {
yield Buffer.alloc(5 * 1024 * 1024 + 1);
throw new Error("must stop before consuming more audio");
} finally {
closed();
}
});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural")).rejects.toThrow("音频过大");
expect(closed).toHaveBeenCalledOnce();
});
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, expect, it, vi } from "vitest";
import { createEdgeTtsVoiceCatalog, fetchEdgeTtsVoices } from "./edge-tts-voices";
afterEach(() => vi.unstubAllGlobals());
it("deduplicates simultaneous catalog loads and refreshes after one hour", async () => {
let now = 0;
const load = vi.fn(async () => [{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN" as const, gender: "Female" as const }]);
const catalog = createEdgeTtsVoiceCatalog(load, () => now);
const [first, second] = await Promise.all([catalog(), catalog()]);
expect(first).toBe(second);
expect(await catalog()).toBe(first);
expect(load).toHaveBeenCalledOnce();
now = 3_600_001;
await catalog();
expect(load).toHaveBeenCalledTimes(2);
});
it("requests v2 authentication and only exposes validated mainland Mandarin voices", async () => {
const upstream = [
{ ShortName: "zh-CN-XiaoxiaoNeural", FriendlyName: "Xiaoxiao", Locale: "zh-CN", Gender: "Female" },
{ ShortName: "en-US-AriaNeural", FriendlyName: "Aria", Locale: "en-US", Gender: "Female" },
{ ShortName: "zh-HK-HiuMaanNeural", FriendlyName: "HiuMaan", Locale: "zh-HK", Gender: "Female" },
{ ShortName: "zh-TW-HsiaoChenNeural", FriendlyName: "HsiaoChen", Locale: "zh-TW", Gender: "Female" },
{ ShortName: "zh-CN-liaoning-XiaobeiNeural", FriendlyName: "Xiaobei", Locale: "zh-CN-liaoning", Gender: "Female" }
];
const fetchMock = vi.fn(async () => new Response(JSON.stringify(upstream)));
vi.stubGlobal("fetch", fetchMock);
expect(await fetchEdgeTtsVoices()).toEqual([{ id: upstream[0].ShortName, name: "Xiaoxiao", locale: "zh-CN", gender: "Female" }]);
const [url] = fetchMock.mock.calls[0] as unknown as [URL];
expect(url.searchParams.get("Sec-MS-GEC-Version")).toMatch(/^2-/);
expect(url.searchParams.get("Sec-MS-GEC")).toMatch(/^[0-9A-F]{16}-[0-9A-F]{64}$/);
fetchMock.mockImplementation(async () => new Response(JSON.stringify([{ ...upstream[0], ShortName: "bad<xml>" }])));
await expect(fetchEdgeTtsVoices()).rejects.toThrow();
});
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { generateSecMsGecToken_V2, SEC_MS_GEC_VERSION_V2, TRUSTED_CLIENT_TOKEN } from "node-edge-tts/dist/drm";
import { speechVoiceSchema, type SpeechVoice } from "../src/shared/tts/voices";
const upstreamVoicesSchema = z.array(z.object({
ShortName: z.string().min(1).max(120),
FriendlyName: z.string().min(1).max(200),
Locale: z.string().min(2).max(40),
Gender: z.enum(["Female", "Male"])
})).min(1).max(2_000);
export async function fetchEdgeTtsVoices(): Promise<SpeechVoice[]> {
const url = new URL("https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list");
url.searchParams.set("trustedclienttoken", TRUSTED_CLIENT_TOKEN);
url.searchParams.set("Sec-MS-GEC", generateSecMsGecToken_V2());
url.searchParams.set("Sec-MS-GEC-Version", SEC_MS_GEC_VERSION_V2);
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Edge TTS voice catalog returned ${response.status}`);
const voices = upstreamVoicesSchema.parse(await response.json()).filter((voice) => voice.Locale === "zh-CN");
if (!voices.length) throw new Error("Edge TTS returned no Mandarin voices");
return voices.map((voice) => speechVoiceSchema.parse({
id: voice.ShortName, name: voice.FriendlyName, locale: voice.Locale, gender: voice.Gender
}));
}
export function createEdgeTtsVoiceCatalog(
load: () => Promise<SpeechVoice[]> = fetchEdgeTtsVoices,
now: () => number = Date.now
) {
let cached: SpeechVoice[] | undefined;
let expiresAt = 0;
let pending: Promise<SpeechVoice[]> | undefined;
return () => {
if (cached && now() < expiresAt) return Promise.resolve(cached);
if (!pending) {
pending = Promise.resolve().then(load).then((voices) => {
cached = voices;
expiresAt = now() + 60 * 60 * 1_000;
return voices;
}).finally(() => { pending = undefined; });
}
return pending;
};
}
+9 -3
View File
@@ -17,6 +17,12 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
}); });
await page.goto("/"); await page.goto("/");
await expect(page).toHaveTitle("供水管网智慧运维系统");
const faviconHref = await page.locator('link[rel="icon"]').getAttribute("href");
expect(faviconHref).toBe("/favicon.svg");
const favicon = await page.request.get(faviconHref!);
expect(favicon.ok()).toBe(true);
expect(favicon.headers()["content-type"]).toContain("image/svg+xml");
await expect(page.getByText("供水管网智慧运维系统")).toBeVisible(); await expect(page.getByText("供水管网智慧运维系统")).toBeVisible();
await expect(page.getByText("运维工作台")).toBeVisible(); await expect(page.getByText("运维工作台")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible(); await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
@@ -92,9 +98,9 @@ test("preserves compact typography in header and agent controls", async ({ page
const suggestion = page.locator("[aria-label=推荐问题] button").first(); const suggestion = page.locator("[aria-label=推荐问题] button").first();
await expect(headerControl).toHaveCSS("font-weight", "600"); await expect(headerControl).toHaveCSS("font-weight", "600");
await expect(approvalControl).toHaveCSS("font-size", "12px"); await expect(approvalControl).toHaveCSS("font-size", "14px");
await expect(approvalControl).toHaveCSS("line-height", "16px"); await expect(approvalControl).toHaveCSS("line-height", "20px");
await expect(suggestion).toHaveCSS("font-size", "12px"); await expect(suggestion).toHaveCSS("font-size", "14px");
await expect(suggestion).toHaveCSS("font-weight", "400"); await expect(suggestion).toHaveCSS("font-weight", "400");
}); });
@@ -10,7 +10,6 @@ import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context"; import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { SelectForAnalysis } from "./answer-ui-interactions";
import { useAnswerNodeState } from "./answer-view-state-provider"; import { useAnswerNodeState } from "./answer-view-state-provider";
import { answerStatePointer, type AnswerFormState } from "./answer-view-state"; import { answerStatePointer, type AnswerFormState } from "./answer-view-state";
@@ -75,7 +74,6 @@ export function LocateButton({
trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}> trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}>
<p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p> <p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p>
</AnswerDisclosure> </AnswerDisclosure>
<SelectForAnalysis title={label} text={`关联图层:${target.layer}\n要素编号:${target.feature_ids.join("、")}\n依据:${source}`} />
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null} {feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div> </div>
</Card> </Card>
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import type { AnswerUiBlock } from "./contract"; import type { AnswerUiBlock } from "./contract";
import { canExpandAnswer } from "./answer-presentation";
const contentKinds = [ const contentKinds = [
{ type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" }, { type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" },
@@ -25,7 +26,7 @@ export function AnswerContentNav({ block, contentRef }: {
const items = contentKinds.map((kind) => ({ const items = contentKinds.map((kind) => ({
...kind, count: elements.filter((element) => element.type === kind.type).length ...kind, count: elements.filter((element) => element.type === kind.type).length
})).filter((item) => item.count > 0); })).filter((item) => item.count > 0);
if (items.length < 2) return null; if (items.length < 2 || !canExpandAnswer([{ type: "answer-ui", block }])) return null;
return ( return (
<nav aria-label="回答内容导航" className="answer-content-nav"> <nav aria-label="回答内容导航" className="answer-content-nav">
@@ -0,0 +1,67 @@
import { act, fireEvent, render, screen, cleanup } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state";
import { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AnswerViewPersistenceContext } from "../session-state/answer-view-persistence-context";
import type { AnswerUiBlock } from "./contract";
import { AnswerPartsView } from "./answer-parts-view";
vi.mock("../components/streaming-token-response", () => ({ StreamingTokenResponse: () => null }));
afterEach(() => { cleanup(); vi.useRealTimers(); });
const block: AnswerUiBlock = {
version: "answer-ui@1", block_id: "block", phase: "final", fallback_text: "资料",
spec: { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: {
title: "资料", description: "补充", fields: [{ name: "note", label: "现场记录", kind: "text", required: false }]
} } } }
};
const remote: AnswerViewStateRecord = {
version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 4,
schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: "远端" } } }, block: { evidenceFilter: "all" }
};
function fixture() {
let restore!: (value: AnswerViewStateRecord) => void;
const client = { getAnswerViewState: vi.fn(() => new Promise<AnswerViewStateRecord>((resolve) => { restore = resolve; })),
putAnswerViewState: vi.fn(async (_session: string, write: AnswerViewStateWrite) => ({ ...remote, revision: 5, nodes: write.nodes, block: write.block })) };
const scope = new AnswerViewStateScope("actor:project", client);
const view = (persist: boolean, value = block) => <AnswerViewPersistenceContext.Provider value={{ scope, sessionId: "session" }}>
<AnswerPartsView parts={[{ type: "answer-ui", block: value }]} messageId="message" disabled={false} streaming={!persist} streamDone={persist} persist={persist} />
</AnswerViewPersistenceContext.Provider>;
return { scope, client, view, restore: (record: AnswerViewStateRecord) => restore(record) };
}
it("keeps the same form DOM and selection when persistence starts, preserving edits against late restore", async () => {
vi.useFakeTimers();
const f = fixture();
const rendered = render(f.view(false));
const input = screen.getByLabelText("现场记录") as HTMLInputElement;
fireEvent.change(input, { target: { value: "本地草稿" } });
input.focus(); input.setSelectionRange(1, 3);
rendered.rerender(f.view(false, structuredClone(block)));
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
rendered.rerender(f.view(true, structuredClone(block)));
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(input.value).toBe("本地草稿");
expect(input.selectionStart).toBe(1);
expect(input.selectionEnd).toBe(3);
expect(f.client.getAnswerViewState).toHaveBeenCalledTimes(1);
await act(async () => f.restore(remote));
expect(input.value).toBe("本地草稿");
await act(async () => vi.advanceTimersByTimeAsync(500));
expect(f.client.putAnswerViewState.mock.calls[0][1]).toMatchObject({ expected_revision: 4, nodes: { form: { values: { note: "本地草稿" } } } });
rendered.unmount(); f.scope.dispose();
});
it("never reads or writes process blocks even when persistence is requested", async () => {
vi.useFakeTimers();
const f = fixture();
const rendered = render(f.view(true, { ...block, phase: "process" }));
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "过程草稿" } });
expect(screen.queryByText("正在恢复交互状态")).toBeNull();
await act(async () => vi.advanceTimersByTimeAsync(2000));
rendered.unmount();
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
expect(f.client.putAnswerViewState).not.toHaveBeenCalled();
f.scope.dispose();
});
@@ -1,7 +1,11 @@
import { useState, type ComponentProps } from "react";
import { X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { useAnswerViewPersistence, useAnswerViewState } from "../session-state/use-answer-view-state";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { StreamingTokenResponse } from "../components/streaming-token-response"; import { StreamingTokenResponse } from "../components/streaming-token-response";
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view"; import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
import type { AnswerUiPart } from "./contract"; import type { AnswerUiPart } from "./contract";
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
export function AnswerPartsView({ export function AnswerPartsView({
parts, parts,
@@ -9,30 +13,33 @@ export function AnswerPartsView({
disabled, disabled,
streaming, streaming,
streamDone, streamDone,
persist = false,
onLocate, onLocate,
onSubmit, onSubmit,
onAsk
}: { }: {
parts: AnswerUiPart[]; parts: AnswerUiPart[];
messageId: string; messageId: string;
disabled: boolean; disabled: boolean;
streaming: boolean; streaming: boolean;
streamDone: boolean; streamDone: boolean;
persist?: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
}) { }) {
const persistence = useAnswerViewPersistence();
return ( return (
<div className="space-y-4" data-answer-parts> <div className="flex min-w-0 flex-col gap-5" data-answer-parts>
{parts.map((part, index) => {parts.map((part, index) =>
part.type === "answer-ui" ? ( part.type === "answer-ui" ? (
<AnswerUiView <PersistableAnswerUiView
key={`ui-${part.block.block_id}`} key={`ui-${part.block.block_id}`}
block={part.block} block={part.block}
persistence={persistence}
active={persist && part.block.phase !== "process"}
messageId={messageId}
disabled={disabled} disabled={disabled}
onLocate={onLocate} onLocate={onLocate}
onSubmit={onSubmit} onSubmit={onSubmit}
onAsk={onAsk}
/> />
) : ( ) : (
<StreamingTokenResponse <StreamingTokenResponse
@@ -48,3 +55,37 @@ export function AnswerPartsView({
</div> </div>
); );
} }
type PersistableProps = ComponentProps<typeof AnswerUiView> & {
messageId: string;
active: boolean;
persistence: { scope: AnswerViewStateScope; sessionId: string } | null;
};
function PersistableAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps) {
if (!persistence) return <AnswerUiView {...props} />;
return <SavedAnswerUiView key={`${persistence.scope.scope}:${persistence.sessionId}:${messageId}:${props.block.block_id}`}
{...props} messageId={messageId} active={active} persistence={persistence} />;
}
function SavedAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps & { persistence: NonNullable<PersistableProps["persistence"]> }) {
const saved = useAnswerViewState(persistence.scope, persistence.sessionId, messageId, props.block.block_id, props.block.spec, active);
const [confirmReload, setConfirmReload] = useState(false);
const label = { loading: "正在恢复交互状态", saving: "正在保存", saved: "已保存", unsaved: "未保存", conflict: "保存冲突" }[saved.status];
return <div className="space-y-2" data-answer-state={active ? saved.status : undefined}>
<AnswerUiView {...props} viewState={saved.state} onViewStateChange={saved.update} />
{active ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span role="status">{label}</span>
{saved.error ? <span>{saved.error}</span> : null}
{saved.status === "unsaved" ? <Button size="sm" variant="ghost" onClick={saved.retry}>重试保存</Button> : null}
{saved.status === "conflict" && !confirmReload ? <>
<Button size="sm" variant="ghost" onClick={() => setConfirmReload(true)}>重新加载远端</Button>
<Button size="sm" variant="ghost" onClick={saved.resave}>保留本地并重新保存</Button>
</> : null}
{confirmReload ? <div role="alert" className="flex flex-wrap items-center gap-2">
<span>重新加载将替换本块的本地筛选与表单草稿。</span>
<Button size="sm" variant="outline" onClick={() => { setConfirmReload(false); saved.reload(); }}>确认替换本地草稿</Button>
<Button size="icon-sm" variant="ghost" aria-label="取消重新加载" onClick={() => setConfirmReload(false)}><X aria-hidden /></Button>
</div> : null}
</div> : null}
</div>;
}
@@ -1,59 +0,0 @@
import { useId, useState } from "react";
import { ArrowUpRight, Layers2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerReveal } from "./answer-motion";
import { getSelectionText } from "./answer-selection";
export function AnswerSelectionTray({ selections, disabled, onRemove, onClear, onAsk }: {
selections: AnswerUiSelection[];
disabled: boolean;
onRemove: (selection: AnswerUiSelection) => void;
onClear: () => void;
onAsk: (selection: AnswerUiSelection) => Promise<void> | void;
}) {
const id = useId();
const [intent, setIntent] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
return <AnswerReveal open={selections.length > 0}><section aria-label="分析内容篮" className="answer-selection-tray rounded-xl border border-border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="flex items-center gap-2 text-xs font-medium"><Layers2 size={15} aria-hidden />已选 {selections.length} 项分析内容</h4>
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs" onClick={onClear} disabled={running}>清空选择</Button>
</div>
<ul className="my-2 max-h-48 space-y-1.5 overflow-y-auto">
{selections.map((selection) => <li key={`${selection.title}\n${selection.text}`} className="flex items-start gap-2 rounded-md bg-muted/40 px-2.5 py-2">
<SelectionDetail selection={selection} />
<Button type="button" size="icon-sm" variant="ghost" className="shrink-0" aria-label={`移除${selection.title}`} disabled={running} onClick={() => onRemove(selection)}><X size={13} aria-hidden /></Button>
</li>)}
</ul>
<label htmlFor={id} className="text-xs font-medium">想继续分析什么?</label>
<Textarea id={id} className="mt-1.5 min-h-16 text-xs" value={intent} maxLength={800}
placeholder="例如:比较差异,并核查可能的原因" onChange={(event) => setIntent(event.currentTarget.value)} />
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] leading-5 text-muted-foreground">保留原始数值与来源,确认后再发送。</p>
<Button type="button" size="sm" disabled={disabled || running} onClick={async () => {
setRunning(true); setError(null);
try {
await onAsk({
title: `${selections.length} 项内容的联合分析`,
text: `${intent.trim() ? `分析目标:${intent.trim()}\n\n` : ""}${getSelectionText(selections)}`
});
} catch (cause) { setError(cause instanceof Error ? cause.message : "内容暂未带入对话,请重试。"); }
finally { setRunning(false); }
}}><ArrowUpRight size={14} aria-hidden />{running ? "正在处理…" : "带入对话"}</Button>
</div>
{error ? <p role="status" className="mt-2 text-xs text-destructive">{error}</p> : null}
</section></AnswerReveal>;
}
function SelectionDetail({ selection }: { selection: AnswerUiSelection }) {
const [open, setOpen] = useState(false);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="min-w-0 flex-1 text-xs leading-5"
trigger={<Button type="button" variant="ghost" className="h-auto min-h-9 w-full justify-between gap-2 whitespace-normal px-0 py-1 text-left text-xs">
<span className="min-w-0 break-words">{selection.title}</span><AnswerDisclosureChevron open={open} />
</Button>}>
<p className="mt-1 whitespace-pre-wrap break-words text-muted-foreground">{selection.text}</p>
</AnswerDisclosure>;
}
@@ -1,6 +0,0 @@
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
export const ANSWER_SELECTION_LIMIT = 8000;
export function getSelectionText(selections: AnswerUiSelection[]) {
return selections.map((selection, index) => `${index + 1}. ${selection.title}\n${selection.text}`).join("\n\n");
}
@@ -9,9 +9,6 @@ export type AnswerUiInteractions = {
disabled: boolean; disabled: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
onToggleSelection?: (selection: AnswerUiSelection) => void;
selections?: AnswerUiSelection[];
evidenceFilter?: string; evidenceFilter?: string;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
}; };
@@ -1,45 +0,0 @@
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { useContext, useState } from "react";
import { Check, MessageSquarePlus, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useAnswerMotion } from "./answer-motion-context";
import { agentEase } from "../components/agent-motion";
import { Button } from "@/shared/ui/button";
export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) {
const { onAsk, disabled } = useContext(AnswerInteractionContext);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!onAsk) return null;
return <div className={compact ? "answer-ui-metric-ask" : undefined}><Button type="button"
size={compact ? "icon-sm" : "sm"} variant="ghost"
className={compact ? "text-muted-foreground" : "h-9 gap-1.5 text-xs text-muted-foreground"}
aria-label={compact ? `针对${title}提问` : undefined}
title={compact ? `针对${title}提问` : undefined}
disabled={disabled || running} onClick={async () => {
setRunning(true); setError(null);
try { await onAsk({ title, text }); }
catch (error) { setError(error instanceof Error ? error.message : "引用暂未带入对话,请重试。"); }
finally { setRunning(false); }
}}>
<MessageSquarePlus size={14} aria-hidden />{compact ? null : "针对这部分提问"}
</Button>{error ? <p role="status" className="text-xs text-destructive">{error}</p> : null}</div>;
}
export function SelectForAnalysis({ title, text, compact = false, label }: AnswerUiSelection & { compact?: boolean; label?: string }) {
const shouldAnimate = useAnswerMotion();
const { disabled, onToggleSelection, selections = [] } = useContext(AnswerInteractionContext);
if (!onToggleSelection) return null;
const selected = selections.some((item) => item.title === title && item.text === text);
return <Button type="button" variant={selected ? "secondary" : "ghost"} size={compact ? "icon-sm" : "sm"}
className={compact ? "shrink-0 text-muted-foreground" : "h-9 gap-1.5 text-xs"}
disabled={disabled} aria-pressed={selected} aria-label={label ?? `将${title}加入分析`}
title={label ?? `将${title}加入分析`} onClick={() => onToggleSelection({ title, text })}>
<motion.span key={String(selected)} className="inline-flex" aria-hidden
initial={shouldAnimate() ? { opacity: 0, scale: 0.8, rotate: -30 } : false}
animate={{ opacity: 1, scale: 1, rotate: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
{selected ? <Check size={14} /> : <Plus size={14} />}
</motion.span>
{compact ? null : selected ? "已加入分析" : "加入分析"}
</Button>;
}
@@ -43,24 +43,22 @@ describe("controlled native answer state", () => {
expect(screen.getByLabelText("其他资料")).toHaveValue(""); expect(screen.getByLabelText("其他资料")).toHaveValue("");
}); });
it("keeps chart data-tab search across data refresh and clears row selection without remounting", () => { it("keeps chart data-tab search across data refresh without remounting", () => {
const current = chart(); const current = chart();
const state = normalizeAnswerViewState(current.spec); const state = normalizeAnswerViewState(current.spec);
if (state.nodes.node.type === "Chart") state.nodes.node.view = "table"; if (state.nodes.node.type === "Chart") state.nodes.node.view = "table";
const { rerender } = render(<AnswerUiView block={current} viewState={state} />); const { rerender } = render(<AnswerUiView block={current} viewState={state} />);
const search = screen.getByRole("textbox", { name: "搜索压力" }); const search = screen.getByRole("textbox", { name: "搜索压力" });
fireEvent.change(search, { target: { value: "一" } }); fireEvent.change(search, { target: { value: "一" } });
fireEvent.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).toBeChecked();
// A local owner can omit the external snapshot while keeping the same block instance. // A local owner can omit the external snapshot while keeping the same block instance.
rerender(<AnswerUiView block={chart(["一", "三"])} />); rerender(<AnswerUiView block={chart(["一", "三"])} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search); expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search);
expect(search).toHaveValue("一"); expect(search).toHaveValue("一");
expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).not.toBeChecked(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
}); });
it("restores chart data selections atomically when a delayed snapshot changes hidden series and range", () => { it("restores chart search and sort while discarding legacy row selection", () => {
const current = block("Chart", { title: "压力", chart_type: "line", x_data: ["08:00", "09:00", "10:00"], series: [{ name: "入口", data: [1, 2, 3] }, { name: "出口", data: [3, 4, 5] }], source: "测试" }); const current = block("Chart", { title: "压力", chart_type: "line", x_data: ["08:00", "09:00", "10:00"], series: [{ name: "入口", data: [1, 2, 3] }, { name: "出口", data: [3, 4, 5] }], source: "测试" });
const changed = vi.fn(); const changed = vi.fn();
const { rerender } = render(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />); const { rerender } = render(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />);
@@ -68,15 +66,15 @@ describe("controlled native answer state", () => {
table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } }); table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } });
rerender(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />); rerender(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00"); expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).toBeChecked(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending"); expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending");
expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument();
expect(changed).not.toHaveBeenCalled(); expect(changed).not.toHaveBeenCalled();
// A subsequent deliberate series change clears invalid derived-table indices. // A subsequent deliberate series change clears the derived table sort.
fireEvent.click(screen.getByRole("button", { name: "出口" })); fireEvent.click(screen.getByRole("button", { name: "出口" }));
expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).not.toBeChecked(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00"); expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort", "/nodes/node/table/selection"]); expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort"]);
}); });
it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => { it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => {
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view"; import { AnswerUiView } from "./answer-ui-view";
@@ -33,6 +33,29 @@ function taskBlock(includeData = true) {
return parsed; return parsed;
} }
describe("local interactive answer renderer", () => { describe("local interactive answer renderer", () => {
it("shows navigation for long answers while keeping short metric and evidence replies quiet", () => {
const fixture = taskBlock(false);
fixture.spec.elements.root.children = ["metric", "evidence"];
delete fixture.spec.elements.form;
const { rerender } = render(<AnswerUiView block={fixture} />);
expect(screen.getByRole("group", { name: "当前压力" })).toBeVisible();
expect(screen.queryByRole("navigation", { name: "回答内容导航" })).not.toBeInTheDocument();
const longer = structuredClone(fixture);
longer.spec.elements.evidence.props.summary = "待核对的监测资料。".repeat(150);
rerender(<AnswerUiView block={longer} />);
expect(screen.getByRole("navigation", { name: "回答内容导航" })).toBeVisible();
});
it("uses a single form and evidence card's own headings without fixed wrapper instructions", () => {
render(<AnswerUiView block={taskBlock()} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBeVisible();
expect(screen.getByRole("button", { name: /读数依据/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "收起补充资料" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "收起核查依据" })).not.toBeInTheDocument();
expect(screen.queryByText("直接探索已加载数据")).not.toBeInTheDocument();
expect(screen.queryByText("区分记录、推断与用户资料")).not.toBeInTheDocument();
});
it("adapts response and report composition without replacing a reader's form draft", async () => { it("adapts response and report composition without replacing a reader's form draft", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const fixture = taskBlock(); const fixture = taskBlock();
@@ -68,38 +91,33 @@ describe("local interactive answer renderer", () => {
expect(screen.getByRole("note")).toHaveTextContent("能力限制"); expect(screen.getByRole("note")).toHaveTextContent("能力限制");
expect(screen.getByRole("note")).not.toHaveTextContent("注意事项"); expect(screen.getByRole("note")).not.toHaveTextContent("注意事项");
}); });
it("combines metrics, selected rows and evidence into one deliberate quote without sending", async () => { it("shows data and evidence without row selection, quoting or analysis controls", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn(); const submit = vi.fn();
render(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={submit} />); render(<AnswerUiView block={taskBlock()} onSubmit={submit} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" })); expect(screen.getByRole("group", { name: "当前压力" })).toHaveTextContent("0.2130MPa");
await user.click(screen.getByRole("checkbox", { name: "选择第 1 行" })); expect(screen.getByRole("table")).toHaveTextContent("北区入口");
await user.click(screen.getByRole("button", { name: "将所选行加入分析" })); expect(screen.getAllByRole("columnheader")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ })); await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
await user.click(screen.getByRole("button", { name: "将读数依据加入分析" })); await waitFor(() => expect(screen.getByText("仅用于演练")).toBeVisible());
const tray = screen.getByRole("region", { name: "分析内容篮" }); expect(screen.getByText("DEMO-26")).toBeVisible();
expect(tray).toHaveTextContent("已选 3 项分析内容"); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(ask).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).not.toBeInTheDocument();
await user.type(within(tray).getByRole("textbox"), "比较差异并核查原因"); expect(screen.queryByRole("region", { name: "分析内容篮" })).not.toBeInTheDocument();
await user.click(within(tray).getByRole("button", { name: "带入对话" }));
expect(ask).toHaveBeenCalledWith({ title: "3 项内容的联合分析", text: expect.stringContaining("分析目标:比较差异并核查原因") });
const quote = ask.mock.calls[0][0].text;
for (const exact of ["0.2130MPa", "来源:监测快照", "对象:北区入口;压力(MPa):0.2130", "资料编号:DEMO-26", "引用内容块:task"]) expect(quote).toContain(exact);
expect(submit).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
await user.click(within(tray).getByRole("button", { name: "移除当前压力" }));
expect(tray).toHaveTextContent("已选 2 项分析内容");
expect(screen.getByRole("button", { name: "将当前压力加入分析" })).toHaveAttribute("aria-pressed", "false");
}); });
it("keeps the same form and selected content when more data arrives and support sections collapse", async () => { it("keeps the same form when more data arrives and support sections collapse", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const ask = vi.fn(); const { rerender } = render(<AnswerUiView block={taskBlock(false)} onSubmit={vi.fn()} />);
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onAsk={ask} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)"); const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "未发送的现场草稿"); await user.type(editor, "未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" })); const grouped = taskBlock();
rerender(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={vi.fn()} />); grouped.spec.elements.root.children = [...(grouped.spec.elements.root.children ?? []), "extra-form"];
grouped.spec.elements["extra-form"] = { ...grouped.spec.elements.form, props: {
title: "其他资料", description: "可选补充", fields: [{ name: "extra", label: "其他记录", kind: "text", required: false }]
} };
rerender(<AnswerUiView block={grouped} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor); expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("未发送的现场草稿"); expect(editor).toHaveValue("未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "收起补充资料" })); await user.click(screen.getByRole("button", { name: "收起补充资料" }));
@@ -108,25 +126,22 @@ describe("local interactive answer renderer", () => {
await user.click(screen.getByRole("button", { name: "展开补充资料" })); await user.click(screen.getByRole("button", { name: "展开补充资料" }));
await waitFor(() => expect(editor).toBeVisible()); await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿"); expect(editor).toHaveValue("未发送的现场草稿");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容"); await user.click(screen.getByRole("button", { name: "收起补充资料" }));
const updated = taskBlock(); const updated = taskBlock();
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]]; updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
rerender(<AnswerUiView block={updated} onAsk={ask} onSubmit={vi.fn()} />); rerender(<AnswerUiView block={updated} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor); expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿"); expect(editor).toHaveValue("未发送的现场草稿");
}); });
it("rejects oversized row quotes without truncating values or removing existing selections", async () => { it("preserves long original cell values without requiring a selection or quote action", () => {
const user = userEvent.setup();
const fixture = taskBlock(); const fixture = taskBlock();
fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]); fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
render(<AnswerUiView block={fixture} onAsk={vi.fn()} />); render(<AnswerUiView block={fixture} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" })); expect(screen.getAllByText("长".repeat(800))).toHaveLength(12);
await user.click(screen.getByRole("checkbox", { name: "选择入口记录的全部筛选结果" })); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "将所选行加入分析" })); expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("8000 字");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
expect(screen.getByRole("checkbox", { name: "选择第 12 行" })).toBeChecked();
}); });
it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => { it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
@@ -143,16 +158,14 @@ describe("local interactive answer renderer", () => {
expect(screen.queryByRole("note")).not.toBeInTheDocument(); expect(screen.queryByRole("note")).not.toBeInTheDocument();
expect(screen.getByText("普通回答")).toBeInTheDocument(); expect(screen.getByText("普通回答")).toBeInTheDocument();
}); });
it("offers a named metric quote action and retains the exact value, unit and source", async () => { it("retains a metric's exact value, unit and source without action buttons", () => {
const user = userEvent.setup();
const ask = vi.fn();
render(<AnswerUiView block={block("Metric", { render(<AnswerUiView block={block("Metric", {
label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录", label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录",
})} onAsk={ask} />); })} />);
await user.click(screen.getByRole("button", { name: "针对当前压力提问" })); const metric = screen.getByRole("group", { name: "当前压力" });
expect(ask).toHaveBeenCalledWith({ expect(metric).toHaveTextContent("0.3000MPa");
title: "当前压力", text: "当前压力:0.3000MPa\n来源:已加载记录\n引用内容块:test", expect(metric).toHaveTextContent("来源:已加载记录");
}); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => { it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -199,22 +212,21 @@ describe("local interactive answer renderer", () => {
feature_ids: ["DEMO-P001", "DEMO-P002"] feature_ids: ["DEMO-P001", "DEMO-P002"]
}); });
}); });
it("filters and sorts loaded rows, then quotes the selected original values without sending", async () => { it("filters and sorts loaded rows without selecting or sending content", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn(); const submit = vi.fn();
render(<AnswerUiView block={block("Table", { render(<AnswerUiView block={block("Table", {
title: "监测记录", columns: ["监测点", "压力"], title: "监测记录", columns: ["监测点", "压力"],
rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]] rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]]
})} onAsk={ask} onSubmit={submit} />); })} onSubmit={submit} />);
await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站"); await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站");
expect(screen.getByText("2 / 3 行")).toBeInTheDocument(); expect(screen.getByText("2 / 3 行")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "按压力排序" })); await user.click(screen.getByRole("button", { name: "按压力排序" }));
const rows = screen.getAllByRole("row"); const rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("南站二号"); expect(rows[1]).toHaveTextContent("南站二号");
await user.click(screen.getByRole("checkbox", { name: "选择第 3 行" })); expect(rows[1]).toHaveTextContent("0.26");
await user.click(screen.getByRole("button", { name: "针对这部分提问" })); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(ask).toHaveBeenCalledWith({ title: "监测记录", text: expect.stringContaining("监测点:南站二号;压力:0.26") }); expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
}); });
it("keeps form values after a failed submission", async () => { it("keeps form values after a failed submission", async () => {
+11 -45
View File
@@ -1,5 +1,4 @@
import { LocateButton, FollowUpForm } from "./answer-action-components"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { Renderer, defineRegistry } from "@json-render/react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react"; import { useContext, useRef, useState, type ReactNode } from "react";
import { import {
@@ -15,22 +14,20 @@ import {
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent } from "@/shared/ui/card";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion"; import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart"; import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table"; import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerResult } from "./answer-result"; import { AnswerResult } from "./answer-result";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context"; import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context"; export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog"; import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract"; import type { AnswerUiBlock } from "./contract";
import { AnswerContentNav } from "./answer-content-nav"; import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout"; import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerSelectionTray } from "./answer-selection-tray";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider"; import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state"; import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, { const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {}, actions: {},
@@ -86,10 +83,6 @@ const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
{props.source ? ( {props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p> <p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null} ) : null}
<div className="answer-ui-metric-actions flex items-center">
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</div>
</CardContent> </CardContent>
</Card> </Card>
), ),
@@ -119,43 +112,25 @@ function AnswerUiBody({
disabled = false, disabled = false,
onLocate, onLocate,
onSubmit, onSubmit,
onAsk,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState(); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
const [selectionError, setSelectionError] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const taskLayout = getAnswerTaskLayout(block.spec); const taskLayout = getAnswerTaskLayout(block.spec);
const root = block.spec.elements[block.spec.root]; const root = block.spec.elements[block.spec.root];
const density = root.type === "Stack" ? root.props.density : undefined; const density = root.type === "Stack" ? root.props.density : undefined;
const intent = root.type === "Stack" ? root.props.intent : undefined; const intent = root.type === "Stack" ? root.props.intent : undefined;
const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length)); const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
const ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))]; const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别"> const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}> {["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label} {kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
</Button>)} </Button>)}
</div> : null; </div> : null;
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, <InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
onAsk: ask, selections,
onToggleSelection: onAsk ? (selection) => {
setSelectionError(null);
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
if (alreadySelected) {
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
return;
}
const next = [...selections, selection];
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
return;
}
setSelections(next);
} : undefined }}>
<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="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} /> <AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}> <div ref={contentRef}>
@@ -163,35 +138,30 @@ function AnswerUiBody({
{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}
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容"> {taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
{renderNodes(taskLayout.analysis)} {renderNodes(taskLayout.analysis)}
</section> : null} </section> : null}
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4"> {taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
{taskLayout.evidence.length ? <TaskSupport enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}> {taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)} {evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null} </TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}> {taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)} {renderNodes(taskLayout.followUp)}
</TaskSupport> : null} </TaskSupport> : null}
</div> : null} </div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>} </div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</> </>
</div> </div>
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
</AnswerMotionScope> </AnswerMotionScope>
</InteractionContext.Provider> </InteractionContext.Provider>
); );
} }
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) { function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section" return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
contentClassName="answer-task-support-content space-y-2.5 pt-2" contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}> trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span> {icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
<AnswerDisclosureChevron open={open} /> <AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>; </Button>}>{children}</AnswerDisclosure>;
} }
@@ -256,10 +226,6 @@ function EvidenceCard(props: {
) : null} ) : null}
</dl> </dl>
) : null} ) : null}
<div className="mt-2 flex flex-wrap items-center gap-1">
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
</div>
</AnswerDisclosure> </AnswerDisclosure>
); );
} }
@@ -10,7 +10,7 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"])
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields }); const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => { describe("answer view state compatibility", () => {
it("clears row indices on reordered data, retains search, and clears sort when columns change", () => { it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table(); const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } }); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
expect(migrateAnswerViewState(before, table([["b", "2"], ["a", "1"]]), state).nodes.node).toEqual({ type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [] }); expect(migrateAnswerViewState(before, table([["b", "2"], ["a", "1"]]), state).nodes.node).toEqual({ type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [] });
@@ -37,7 +37,7 @@ describe("answer view state compatibility", () => {
it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => { it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => {
const current = table(); const current = table();
const result = normalizeAnswerViewState(current, { nodes: { node: { type: "Table", query: "查".repeat(2000), sort: { column: 99, descending: true }, selection: [-1, 0, 0, 2] }, unknown: { type: "Evidence", open: true } }, block: { evidenceFilter: "gone" }, action: "ignored" }); const result = normalizeAnswerViewState(current, { nodes: { node: { type: "Table", query: "查".repeat(2000), sort: { column: 99, descending: true }, selection: [-1, 0, 0, 2] }, unknown: { type: "Evidence", open: true } }, block: { evidenceFilter: "gone" }, action: "ignored" });
expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [0] } }, block: { evidenceFilter: "all" } }); expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [] } }, block: { evidenceFilter: "all" } });
}); });
it("tracks form and nested chart-table edits at field granularity", () => { it("tracks form and nested chart-table edits at field granularity", () => {
@@ -1,5 +1,6 @@
import type { Spec } from "@json-render/core"; import type { Spec } from "@json-render/core";
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] }; export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit<AnswerTableState, "type"> }; export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit<AnswerTableState, "type"> };
export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> }; export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> };
@@ -39,11 +40,10 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
} }
if (node.type === "Table") { if (node.type === "Table") {
const columns = node.props.columns as unknown[]; const columns = node.props.columns as unknown[];
const rows = node.props.rows as unknown[];
const sort = record(state.sort); const sort = record(state.sort);
return { type: "Table", query: typeof state.query === "string" ? state.query : "", return { type: "Table", query: typeof state.query === "string" ? state.query : "",
sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null, sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null,
selection: indices(state.selection, rows.length) }; selection: [] };
} }
if (node.type === "FollowUpForm") { if (node.type === "FollowUpForm") {
const values = record(state.values); const values = record(state.values);
@@ -74,8 +74,7 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
const before = previous.elements[id], after = next.elements[id], old = input.nodes[id]; const before = previous.elements[id], after = next.elements[id], old = input.nodes[id];
if (!before || before.type !== after.type || !old || old.type !== value.type) continue; if (!before || before.type !== after.type || !old || old.type !== value.type) continue;
if (value.type === "Table") { if (value.type === "Table") {
if (!same(before.props.rows, after.props.rows)) value.selection = []; if (!same(before.props.columns, after.props.columns)) value.sort = null;
if (!same(before.props.columns, after.props.columns)) { value.sort = null; value.selection = []; }
} }
if (value.type === "FollowUpForm") { if (value.type === "FollowUpForm") {
const fields = before.props.fields as Array<{ name: string; kind: string }>; const fields = before.props.fields as Array<{ name: string; kind: string }>;
@@ -98,7 +97,6 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
} }
if (value.table) { if (value.table) {
const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value); const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value);
if (!same(oldData.rows, newData.rows)) value.table.selection = [];
if (!same(oldData.columns, newData.columns)) value.table.sort = null; if (!same(oldData.columns, newData.columns)) value.table.sort = null;
} }
} }
@@ -11,6 +11,30 @@ const block = {
} }
}; };
describe("untrusted answer components", () => { describe("untrusted answer components", () => {
it("keeps explicit UI phases and rejects invalid phase values", () => {
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
expect(parseAnswerUiBlock({ ...block, phase: "final" })?.phase).toBe("final");
expect(parseAnswerUiBlock({ ...block, phase: "other" })).toBeNull();
expect(parseAnswerUiBlock({ ...block, phase: null })).toBeNull();
});
it("accepts controlled task semantics and result states without accepting arbitrary presentation code", () => {
const result = { type: "Result", props: { title: "定位结果", summary: "3 根管道已定位,高亮未确认。", status: "partial", source: "地图工具回执" }, children: [] };
const spec = {
root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "compact" }, children: ["section"] },
section: { type: "Section", props: { title: "本次结果", description: null, role: "result" }, children: ["result"] },
result
}
};
expect(parseAnswerUiBlock({ ...block, spec })).not.toBeNull();
for (const props of [
{ ...result.props, status: "running" },
{ ...result.props, css: "display:none" },
{ ...result.props, onClick: "fetch('/write')" }
]) expect(parseAnswerUiBlock({ ...block, spec: { ...spec, elements: { ...spec.elements, result: { ...result, props } } } })).toBeNull();
expect(parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: { ...block.spec.elements.note, props: { text: "高亮操作未提供。", tone: "warning", title: "能力限制" } } } } })).not.toBeNull();
});
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => { it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
expect(parseAnswerUiBlock(block)).not.toBeNull(); expect(parseAnswerUiBlock(block)).not.toBeNull();
for (const node of [ for (const node of [
+1 -1
View File
@@ -28,7 +28,7 @@ export const storedAnswerUiBlockSchema = z.object({
export const reportUiBlocksSchema = z.array(z.object({ export const reportUiBlocksSchema = z.array(z.object({
block: storedAnswerUiBlockSchema, block: storedAnswerUiBlockSchema,
evidence_refs: z.array(z.string().min(1).max(128)).min(1).max(100) evidence_refs: z.array(z.string().min(1).max(128)).min(1).max(100)
}).strict()).max(8); }).strict().refine((item) => item.block.phase !== "process", "过程界面不能发布为正式报告")).max(8);
export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number]; export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
export type AnswerUiBlock = { export type AnswerUiBlock = {
@@ -9,7 +9,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table"; import { InteractiveDataTable } from "./interactive-data-table";
import { agentEase } from "../components/agent-motion"; import { agentEase } from "../components/agent-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
@@ -55,7 +54,7 @@ export function InteractiveChart(props: AnswerChartProps) {
pendingTransition.current = animate; pendingTransition.current = animate;
patch({ view: next as AnswerChartState["view"] }); patch({ view: next as AnswerChartState["view"] });
}; };
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range", "table/selection"]); 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 visible = props.series.filter((_, index) => !hidden.includes(index));
@@ -66,8 +65,6 @@ export function InteractiveChart(props: AnswerChartProps) {
const values = series.data.slice(range[0], range[1] + 1); const values = series.data.slice(range[0], range[1] + 1);
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) }; return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
}); });
const rangeTitle = `${props.title} · ${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`;
const rangeText = `${labels.length} 个已加载数据点的本地统计\n${summaries.map((item) => `${item.name}:起点 ${item.first};终点 ${item.last};最小 ${item.min};最大 ${item.max}`).join("\n")}\n来源:${props.source}`;
const option = (type: "line" | "bar") => ({ const option = (type: "line" | "bar") => ({
animation: animateInteraction && !reducedMotion, animation: animateInteraction && !reducedMotion,
animationDuration: 180, animationDuration: 180,
@@ -115,7 +112,7 @@ export function InteractiveChart(props: AnswerChartProps) {
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)} className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !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], 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", "table/selection"]); }}> table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name} <span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)} </Button>)}
</div> </div>
@@ -145,7 +142,7 @@ export function InteractiveChart(props: AnswerChartProps) {
} }
if (event.key === "Escape") setInspecting(false); if (event.key === "Escape") setInspecting(false);
}}> }}>
<span className="sr-only">点击图中数据点查看详情并追问,也可使用回车和左右方向键。</span> <span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }} <ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => { onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") { if (event.componentType === "series" && typeof event.dataIndex === "number") {
@@ -157,15 +154,13 @@ export function InteractiveChart(props: AnswerChartProps) {
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}> <TabsContent value="table" 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) => String(series.data[range[0] + index]))])} rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
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}`))} />
source={props.source} />
</TabsContent> </TabsContent>
</motion.div> </motion.div>
</Tabs> </Tabs>
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要"> {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"> <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> <span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
<SelectForAnalysis compact label="将当前区间加入分析" title={rangeTitle} text={rangeText} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5"> {summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
@@ -191,12 +186,6 @@ export function InteractiveChart(props: AnswerChartProps) {
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd> <dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
</div>)} </div>)}
</dl> </dl>
<div className="flex flex-wrap items-center gap-1">
<SelectForAnalysis label="将当前数据点加入分析" title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
<AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
</div>
</motion.section> : null} </motion.section> : null}
<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>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span> <Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
@@ -2,27 +2,21 @@ import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowUpDown } 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 { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { AnswerReveal } from "./answer-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { useAnswerNodeState } from "./answer-view-state-provider"; import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state"; import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, source, state: controlledState, onStateChange }: { export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
title: string; columns: string[]; rows: string[][]; source?: string; title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">; state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void; onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
}) { }) {
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows }); const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const state = controlledState ?? localState; const state = controlledState ?? localState;
const patch = onStateChange ?? patchLocal; const patch = onStateChange ?? patchLocal;
// The owning schema node migrates data changes. A complete restored snapshot const { query, sort } = state;
// can change chart columns/range and restore a valid selection atomically.
const { query, selection, sort } = state;
const setSelection = (selection: number[]) => patch({ selection });
const filtered = useMemo(() => { const filtered = useMemo(() => {
const items = rows.map((cells, id) => ({ cells, id })) const items = rows.map((cells, id) => ({ cells, id }))
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); .filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
@@ -35,12 +29,10 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
}); });
return items; return items;
}, [rows, sort, query]); }, [rows, sort, query]);
const selected = filtered.filter(({ id }) => selection.includes(id));
const quote = selected.map(({ cells }) => cells.map((cell, index) => `${columns[index]}:${cell}`).join(";")).join("\n");
return <div className="min-w-0 space-y-2" data-interactive-table> return <div className="min-w-0 space-y-2" data-interactive-table>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs" <Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { patch({ query: event.currentTarget.value, selection: [] }); }} /> onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span> <span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
</div> </div>
<div role="region" aria-label={title} tabIndex={0} <div role="region" aria-label={title} tabIndex={0}
@@ -48,9 +40,6 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
<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>
<TableHead scope="col" className="w-10"><Checkbox aria-label={`选择${title}的全部筛选结果`}
checked={filtered.length > 0 && selected.length === Math.min(filtered.length, 100)} disabled={!filtered.length}
onChange={(event) => setSelection(event.target.checked ? filtered.slice(0, 100).map(({ id }) => id) : [])} /></TableHead>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap" {columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap"
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 size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`}
@@ -62,26 +51,15 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
</TableHead>)} </TableHead>)}
</TableRow></TableHeader> </TableRow></TableHeader>
<TableBody> <TableBody>
{filtered.map(({ cells, id }) => <TableRow key={id} data-state={selection.includes(id) ? "selected" : undefined}> {filtered.map(({ cells, id }) => <TableRow key={id}>
<TableCell className="w-10"><Checkbox aria-label={`选择第 ${id + 1} 行`} checked={selection.includes(id)} disabled={!selection.includes(id) && selection.length >= 100}
onChange={(event) => setSelection(event.target.checked ? [...selection, id] : selection.filter((value) => value !== id))} /></TableCell>
{cells.map((cell, index) => <TableCell key={index} {cells.map((cell, index) => <TableCell key={index}
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)} className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
</TableRow>)} </TableRow>)}
{!filtered.length ? <TableRow><TableCell colSpan={columns.length + 1} className="py-6 text-center text-xs text-muted-foreground"> {!filtered.length ? <TableRow><TableCell colSpan={columns.length} className="py-6 text-center text-xs text-muted-foreground">
{rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"} {rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
</TableCell></TableRow> : null} </TableCell></TableRow> : null}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<AnswerReveal open={selected.length > 0} className="flex flex-wrap items-center justify-between gap-1">
<span className="text-xs text-muted-foreground">已选 {selected.length} 行{filtered.length > 100 ? " · 每次最多选择前 100 行" : ""}</span>
<div className="flex flex-wrap gap-1">
<Button type="button" variant="ghost" size="sm" className="h-9 text-xs" onClick={() => setSelection([])}>取消选行</Button>
<SelectForAnalysis label="将所选行加入分析" title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
</div>
</AnswerReveal>
{!selected.length && filtered.length ? <p className="text-[11px] text-muted-foreground">勾选数据行可加入分析或继续提问。</p> : null}
</div>; </div>;
} }
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
import type { AnswerViewStateWrite } from "./answer-view-state";
const remote = { version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 2,
schema_digest: "digest", nodes: {}, block: { evidenceFilter: "all" }, updated_at: null };
const write: AnswerViewStateWrite = { message_id: "message", block_id: "block", expected_revision: 2, schema_digest: "digest", request_id: "intent", nodes: {}, block: { evidenceFilter: "all" } };
const token = (subject: string) => `header.${btoa(JSON.stringify({ iss: "https://identity.local", sub: subject }))}.signature`;
afterEach(() => vi.unstubAllGlobals());
describe("authorized answer view state API", () => {
it("keeps the message and block identity and uses the configured auth/project context", async () => {
const fetcher = vi.fn().mockImplementation(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher);
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token("alice"), getProjectId: async () => "project" });
await expect(api.getAnswerViewState("session/one", "message", "block")).resolves.toEqual(remote);
await api.putAnswerViewState("session/one", write);
expect(fetcher.mock.calls[0][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state?message_id=message&block_id=block");
expect(fetcher.mock.calls[1][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state");
expect(fetcher.mock.calls[1][1].body).toBe(JSON.stringify(write));
for (const [, init] of fetcher.mock.calls) {
expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${token("alice")}`);
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
}
});
it("retains structured conflict details for the explicit user choices", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ code: "ANSWER_VIEW_STATE_CONFLICT", detail: "new revision", current: remote }, { status: 409 })));
await expect(createAgentApiClient("https://agent.local").putAnswerViewState("session", write)).rejects.toMatchObject({
status: 409, code: "ANSWER_VIEW_STATE_CONFLICT", current: remote,
});
});
it("rejects writes if a mutable token provider changes subject in the same project", async () => {
const fetcher = vi.fn(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher);
let subject = "alice";
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject), getProjectId: async () => "same-project" });
await api.getAnswerViewState("session", "message", "block"); subject = "bob";
await expect(api.putAnswerViewState("session", write)).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects a late read if identity changes while the request is in flight", async () => {
let finish!: (response: Response) => void;
const fetcher = vi.fn(() => new Promise<Response>((resolve) => { finish = resolve; })); vi.stubGlobal("fetch", fetcher);
let subject = "alice";
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject) });
const read = api.getAnswerViewState("session", "message", "block");
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); subject = "bob"; finish(Response.json(remote));
await expect(read).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" });
});
it("rejects remote state containing unknown executable fields", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...remote, nodes: { x: { type: "Evidence", open: true, script: "bad" } } })));
await expect(createAgentApiClient("https://agent.local").getAnswerViewState("session", "message", "block")).rejects.toThrow();
});
});
@@ -0,0 +1,44 @@
import { z } from "zod";
import type { AnswerViewState } from "../answer-ui/answer-view-state";
const index = z.number().int().nonnegative();
const chartTable = z.object({ query: z.string(), sort: z.object({ column: index, descending: z.boolean() }).strict().nullable(), selection: z.array(index) }).strict();
const node = z.discriminatedUnion("type", [
z.object({ type: z.literal("Chart"), view: z.enum(["line", "bar", "table"]), hidden: z.array(index), range: z.tuple([index, index]), point: index, inspecting: z.boolean(), table: chartTable.optional() }).strict(),
z.object({ type: z.literal("Table"), query: z.string(), sort: z.object({ column: index, descending: z.boolean() }).strict().nullable(), selection: z.array(index) }).strict(),
z.object({ type: z.literal("FollowUpForm"), values: z.record(z.string()) }).strict(),
z.object({ type: z.literal("Evidence"), open: z.boolean() }).strict(),
]);
const record = z.object({
version: z.literal("answer-view-state@1"), message_id: z.string(), block_id: z.string(),
revision: index, schema_digest: z.string().min(1), nodes: z.record(node),
block: z.object({ evidenceFilter: z.string() }).strict(), updated_at: z.string().nullable(),
}).strict();
export type AnswerViewStateRecord = AnswerViewState & {
version: "answer-view-state@1"; message_id: string; block_id: string;
revision: number; schema_digest: string; updated_at: string | null;
};
export type AnswerViewStateWrite = AnswerViewState & {
message_id: string; block_id: string; schema_digest: string;
expected_revision: number; request_id: string;
};
export class AnswerViewStateApiError extends Error {
constructor(message: string, readonly status: number, readonly code?: string, readonly current?: AnswerViewStateRecord) {
super(message);
this.name = "AnswerViewStateApiError";
}
}
export function parseAnswerViewStateRecord(value: unknown): AnswerViewStateRecord {
return record.parse(value) as AnswerViewStateRecord;
}
export async function readAnswerViewStateResponse(response: Response): Promise<AnswerViewStateRecord> {
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string().optional(), detail: z.string().optional(), current: z.unknown().optional() }).passthrough().safeParse(data);
const current = error.success && error.data.current ? record.safeParse(error.data.current) : null;
throw new AnswerViewStateApiError(error.success ? error.data.detail ?? "交互状态未保存,请重试" : "交互状态暂不可用,请重试", response.status,
error.success ? error.data.code : undefined, current?.success ? current.data as AnswerViewStateRecord : undefined);
}
return parseAnswerViewStateRecord(data);
}
+26 -1
View File
@@ -1,7 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client"; import { AgentResponseFinishedError, createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => { describe("Agent prompt suggestions", () => {
it("probes public readiness without loading identity or project credentials", async () => {
const getAccessToken = vi.fn(async () => "private-token");
const getProjectId = vi.fn(async () => "project");
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true, ready: true }))
.mockResolvedValueOnce(Response.json({ ok: true, ready: false }));
vi.stubGlobal("fetch", fetcher);
const client = createAgentApiClient("https://agent.local/prefix", { getAccessToken, getProjectId });
const signal = new AbortController().signal;
await expect(client.getHealth(signal)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledWith("https://agent.local/prefix/health", { signal, cache: "no-store" });
expect(getAccessToken).not.toHaveBeenCalled();
expect(getProjectId).not.toHaveBeenCalled();
await expect(client.getHealth(signal)).rejects.toThrow("尚未就绪");
});
it("distinguishes a proven pre-admission idle response from ambiguous input conflicts", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(Response.json({ code: "RESPONSE_FINISHED", message: "finished" }, { status: 409 }))
.mockResolvedValueOnce(Response.json({ message: "unknown conflict" }, { status: 409 }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local");
await expect(client.submitResponseInput("s", "next", "queue", `msg_${"1".repeat(32)}`)).rejects.toBeInstanceOf(AgentResponseFinishedError);
const uncertain = client.submitResponseInput("s", "next", "queue", `msg_${"2".repeat(32)}`);
await expect(uncertain).rejects.not.toBeInstanceOf(AgentResponseFinishedError);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("uses owned topic endpoints and retains full-text hits from another topic", async () => { it("uses owned topic endpoints and retains full-text hits from another topic", async () => {
const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" }; const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" };
const fetchMock = vi.fn() const fetchMock = vi.fn()
+42 -3
View File
@@ -2,6 +2,7 @@ import { createInteractionRequestApi, type InteractionRequestApi } from "./inter
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages"; import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
import { AnswerViewStateApiError, readAnswerViewStateResponse, type AnswerViewStateRecord, type AnswerViewStateWrite } from "./answer-view-state";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted" | "waiting_input"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted" | "waiting_input";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration"; export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
@@ -20,8 +21,8 @@ export type AgentEventTriggerContext = {
summary: string; summary: string;
location?: string | null; location?: string | null;
priority: "low" | "normal" | "high" | "urgent"; priority: "low" | "normal" | "high" | "urgent";
scenario_code: "WQ-SECONDARY-SUPPLY-001"; scenario_code?: string | null;
origin: "synthetic"; origin: "system_data";
}; };
}; };
@@ -156,6 +157,8 @@ export type AgentSessionStreamEvent =
export type AgentApiClient = InteractionRequestApi & { export type AgentApiClient = InteractionRequestApi & {
getHealth: (signal: AbortSignal) => Promise<void>; getHealth: (signal: AbortSignal) => Promise<void>;
getAnswerViewState: (sessionId: string, messageId: string, blockId: string) => Promise<AnswerViewStateRecord>;
putAnswerViewState: (sessionId: string, state: AnswerViewStateWrite) => Promise<AnswerViewStateRecord>;
downloadFile: (path: string) => Promise<void>; downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>; submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>; listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
@@ -249,14 +252,41 @@ export function createAgentApiClient(
return data as T; return data as T;
}; };
let answerStateActor: string | undefined;
const checkedAnswerStateToken = options.getAccessToken ? async () => {
const token = await options.getAccessToken!();
const actor = answerStateTokenActor(token);
if (answerStateActor !== undefined && actor !== answerStateActor) {
throw new AnswerViewStateApiError("登录身份已变化,请刷新后继续。", 403, "ANSWER_VIEW_IDENTITY_CHANGED");
}
answerStateActor = actor;
return token;
} : undefined;
const answerStateRequest = async (path: string, init?: RequestInit) => {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
checkedAnswerStateToken, options.getProjectId);
// An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.();
return response;
};
return { return {
...createInteractionRequestApi(request), ...createInteractionRequestApi(answerStateRequest),
async getHealth(signal) { async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, ""); const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用"); if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json(); const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪"); if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
}, },
async getAnswerViewState(sessionId, messageId, blockId) {
const query = new URLSearchParams({ message_id: messageId, block_id: blockId });
return readAnswerViewStateResponse(await answerStateRequest(`/session/${encodeURIComponent(sessionId)}/ui-state?${query}`));
},
async putAnswerViewState(sessionId, state) {
return readAnswerViewStateResponse(await answerStateRequest(`/session/${encodeURIComponent(sessionId)}/ui-state`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state)
}));
},
async downloadFile(path) { async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path); const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址"); if (!match) throw new Error("无效的会话文件地址");
@@ -845,3 +875,12 @@ function readMessagePagination(value: unknown): AgentMessagePagination | undefin
if (!isRecord(value)) return undefined; if (!isRecord(value)) return undefined;
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null }; return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
} }
function answerStateTokenActor(token: string | null): string {
if (!token) return "development-context";
try {
const payload: unknown = JSON.parse(atob(token.split(".")[1].replaceAll("-", "+").replaceAll("_", "/")));
if (isRecord(payload) && typeof payload.sub === "string") return JSON.stringify([payload.iss, payload.sub]);
} catch { /* Non-JWT development credentials use the enclosing workbench owner. */ }
return "development-context";
}
@@ -55,3 +55,11 @@ describe("Responses transport", () => {
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" }); expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
}); });
}); });
it("binds a saved interaction continuation to the owning conversation", () => {
const request = createResponseRequest([{ id: "continuation", role: "user", parts: [{ type: "text", text: "继续处理已保存回答" }] }], {
conversation: "original-session", interactionId: "saved-interaction", capabilities: ["host-question@1"],
});
expect(request.conversation).toBe("original-session");
expect(request.tjwater).toMatchObject({ interaction_id: "saved-interaction", capabilities: ["host-question@1"] });
});
@@ -1,9 +1,17 @@
import type { ComponentProps, Dispatch, Ref, SetStateAction } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest, AgentStreamRenderState, AgentUiResult } from "../types";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card"; import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
import { canReplaceInteractionRequest } from "../api/interaction-requests"; import { canReplaceInteractionRequest } from "../api/interaction-requests";
import type { InteractionRequestsController } from "../hooks/use-interaction-requests"; import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view"; import { AnswerViewStateProvider } from "../session-state/answer-view-state-context";
import { AnswerPartsView } from "../answer-ui/answer-parts-view"; import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import { ExpandableReading } from "@/shared/ui/reading-workspace"; import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation"; import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
import { AgentProcessDisclosure } from "./agent-process-disclosure"; import { AgentProcessDisclosure } from "./agent-process-disclosure";
@@ -12,109 +20,35 @@ import { groupMessageProcesses, hasPendingMessageInput } from "../presentation/m
import { AgentUserMessageText } from "./agent-user-message-text"; import { AgentUserMessageText } from "./agent-user-message-text";
import { useAgentAnswerTransition } from "../hooks/use-agent-answer-transition"; import { useAgentAnswerTransition } from "../hooks/use-agent-answer-transition";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy"; import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import { import { ArrowUp, ChevronLeft, Eye, History, Plus, Quote, Square, X } from "lucide-react";
ArrowUp,
AudioLines,
CheckCircle2,
ChevronDown,
ChevronLeft,
Eye,
History,
LoaderCircle,
Pause,
Play,
Plus,
Quote,
Shield,
ShieldCheck,
Square,
Sparkles,
Zap,
Volume2,
X
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type Ref,
type ReactNode,
type SetStateAction
} from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom"; import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core"; import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent"; import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import { import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/shared/ai-elements/conversation";
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Message, MessageContent } from "@/shared/ai-elements/message"; import { Message, MessageContent } from "@/shared/ai-elements/message";
import { import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input";
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion"; import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator"; import { Separator } from "@/shared/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { StatusDot, type StatusTone } from "@/shared/ui/status"; import { StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client"; import { AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME, AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome"; import { agentLayoutTransition, agentPanelItemVariants, agentPanelListVariants } from "./agent-motion";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { AgentPersona } from "./agent-persona"; import { AgentPersona } from "./agent-persona";
import { AgentTopicControls } from "./agent-topic-controls"; import { AgentTopicControls } from "./agent-topic-controls";
import { AgentHistoryPanel } from "./agent-history-panel"; import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox"; import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs"; import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react";
import { AgentMessageDetails } from "./agent-message-details"; import { AgentMessageDetails } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer"; import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice"; import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import { import { stripSpeechMarkdown } from "../speech";
findSpeechSelectionStartOffset, import type { AgentConnectionStatus } from "../types";
stripSpeechMarkdown, import { VoiceInputButton, InputDeliverySelect, ApprovalModeSelect, AgentModelSelect } from "./agent-panel-controls";
type AgentSpeakOptions, import { AgentSpeechMessage, AgentMessageSpeechControls } from "./agent-message-speech";
type AgentSpeechState
} from "../speech";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet"; export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
@@ -125,7 +59,6 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning"; tone?: "neutral" | "info" | "warning";
}; };
type AgentCommandPanelProps = { type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation; presentation?: AgentCommandPanelPresentation;
collapsing?: boolean; collapsing?: boolean;
@@ -153,6 +86,7 @@ type AgentCommandPanelProps = {
promptSuggestionsUnavailable?: boolean; promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void; onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null; activeSessionId?: string | null;
answerStateScope?: AnswerViewStateScope;
qaOnly?: boolean; qaOnly?: boolean;
executionHistory?: boolean; executionHistory?: boolean;
statusLabel?: string; statusLabel?: string;
@@ -177,7 +111,6 @@ type AgentCommandPanelProps = {
approvalMode?: AgentApprovalMode; approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[]; uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController; interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>; messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[]; workspaceContext?: AgentWorkspaceContextItem[];
prompt: string; prompt: string;
@@ -221,6 +154,7 @@ export function AgentCommandPanel({
promptSuggestionsUnavailable = false, promptSuggestionsUnavailable = false,
onRetryPromptSuggestions, onRetryPromptSuggestions,
activeSessionId, activeSessionId,
answerStateScope,
qaOnly = false, qaOnly = false,
executionHistory = false, executionHistory = false,
connectionStatus = "connecting", connectionStatus = "connecting",
@@ -241,7 +175,6 @@ export function AgentCommandPanel({
approvalMode = "request", approvalMode = "request",
uiResults = [], uiResults = [],
interactionRequests, interactionRequests,
reviewPanel,
messageInbox, messageInbox,
prompt, prompt,
promptFocusRequest = 0, promptFocusRequest = 0,
@@ -278,7 +211,7 @@ export function AgentCommandPanel({
const floatingPresentation = desktopFloating; const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length); const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Task discussions keep their reference and composer layout before the first message. // Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !promptReference const centeredComposer = !hasConversation && !loadingSessionId && !promptReference
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error; && uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference; const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const trimmedPrompt = prompt.trim(); const trimmedPrompt = prompt.trim();
@@ -302,7 +235,15 @@ export function AgentCommandPanel({
resume: resumeSpeech, resume: resumeSpeech,
stop: stopSpeech, stop: stopSpeech,
isSupported: isSpeechSupported isSupported: isSpeechSupported
} = useAgentSpeechSynthesis(); } = useAgentSpeechSynthesis((message) => {
showMapNotice({
id: "agent-speech-playback-error",
tone: "error",
title: "语音播放失败",
message,
duration: 5000
});
});
const appendRecognitionResult = useCallback((text: string) => { const appendRecognitionResult = useCallback((text: string) => {
promptRestoreGenerationRef.current += 1; promptRestoreGenerationRef.current += 1;
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`); onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
@@ -396,6 +337,7 @@ export function AgentCommandPanel({
/> />
); );
return ( return (
<AnswerViewStateProvider scope={answerStateScope} sessionId={activeSessionId}>
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
{historyPanel} {historyPanel}
<motion.aside <motion.aside
@@ -567,16 +509,12 @@ export function AgentCommandPanel({
showProcess={!qaOnly || executionHistory} showProcess={!qaOnly || executionHistory}
onLocateAnswerFeatures={onLocateAnswerFeatures} onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt} onSubmitAnswerForm={onSubmitPrompt}
onAsk={(selection) => {
onContentReferenceChange?.(selection);
requestAnimationFrame(() => promptTextareaRef.current?.focus());
}}
/> />
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null} {!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests} {interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null} representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null} {messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
@@ -762,255 +700,7 @@ export function AgentCommandPanel({
</Agent> </Agent>
</motion.aside> </motion.aside>
</MotionConfig> </MotionConfig>
); </AnswerViewStateProvider>
}
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }
@@ -1167,101 +857,12 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
return null; return null;
} }
function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
const agentConnectionMetadata = { const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" }, connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" }, online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" } offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>; } satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}
type BackendMessageListProps = { type BackendMessageListProps = {
presentation: AgentCommandPanelPresentation; presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[]; messages: AgentChatMessage[];
@@ -1281,7 +882,6 @@ type BackendMessageListProps = {
showProcess: boolean; showProcess: boolean;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void; onSubmitAnswerForm: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
interactionRequests?: InteractionRequestsController; interactionRequests?: InteractionRequestsController;
}; };
@@ -1346,7 +946,6 @@ function BackendMessage({
showProcess, showProcess,
onLocateAnswerFeatures, onLocateAnswerFeatures,
onSubmitAnswerForm, onSubmitAnswerForm,
onAsk,
interactionRequests, interactionRequests,
}: BackendMessageListProps & { }: BackendMessageListProps & {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -1448,13 +1047,13 @@ function BackendMessage({
> >
<AnswerPartsView <AnswerPartsView
parts={answer.report} parts={answer.report}
persist={answer.final && completed}
messageId={message.id} messageId={message.id}
disabled={!completed} disabled={!completed}
streaming={!answer.final && !completed} streaming={!answer.final && !completed}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)} streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
onLocate={onLocateAnswerFeatures} onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm} onSubmit={onSubmitAnswerForm}
onAsk={onAsk}
/> />
</ExpandableReading> </ExpandableReading>
</AgentSpeechMessage> </AgentSpeechMessage>
@@ -1513,251 +1112,3 @@ function BackendMessage({
</motion.div> </motion.div>
); );
} }
type SpeechSelection = {
startOffset: number;
left: number;
top: number;
};
function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
isSupported,
onSpeak,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
isSupported: boolean;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(null);
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
const selectionAnchor = selection ?? lastSelectionRef.current;
if (selection) {
lastSelectionRef.current = selection;
}
const captureSelection = useCallback(() => {
if (!isSupported || disabled) {
setSelection(null);
return;
}
const browserSelection = window.getSelection();
const container = contentRef.current;
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
const range = browserSelection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
const rect = range.getBoundingClientRect();
if (startOffset === null || (!rect.width && !rect.height)) {
setSelection(null);
return;
}
setSelection({
startOffset,
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
top: Math.max(rect.top - 10, 48)
});
}, [disabled, isSupported, speechText]);
useEffect(() => {
setSelection(null);
}, [messageId, speechText]);
useEffect(() => {
if (!selection) return;
const close = () => setSelection(null);
const closeCollapsedSelection = () => {
if (window.getSelection()?.isCollapsed) close();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("selectionchange", closeCollapsedSelection);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("selectionchange", closeCollapsedSelection);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [selection]);
const speakFromSelection = () => {
if (!selection) return;
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
window.getSelection()?.removeAllRanges();
setSelection(null);
};
return (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
从这里开始朗读
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
</div>
);
}
function AgentMessageSpeechControls({
speechState,
onSpeak,
onPause,
onResume,
onStop
}: {
speechState: AgentSpeechState;
onSpeak: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={onSpeak}>
<Volume2 size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
</div>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
className={cn(
AGENT_MESSAGE_ACTION_CLASS_NAME,
"shrink-0 disabled:cursor-wait",
tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</Button>
);
}
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioG
import { Separator } from "@/shared/ui/separator"; import { Separator } from "@/shared/ui/separator";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { StatusBadge } from "@/shared/ui/status"; import { StatusBadge } from "@/shared/ui/status";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import type { AgentChatSessionSummary, AgentTopic } from "../api/client"; import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
type Props = { type Props = {
@@ -77,7 +78,7 @@ export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel,
}; };
let previousGroup = ""; let previousGroup = "";
return <Dialog open={open} onOpenChange={onOpenChange}> return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} data-agent-theme="chat" <DialogContent onCopy={copySelectedText} showCloseButton={false} data-agent-theme="chat"
overlayClassName="agent-history-overlay motion-reduce:animate-none" overlayClassName="agent-history-overlay motion-reduce:animate-none"
className="agent-history-dialog flex flex-col gap-0 overflow-hidden bg-card p-0 text-foreground motion-reduce:animate-none" className="agent-history-dialog flex flex-col gap-0 overflow-hidden bg-card p-0 text-foreground motion-reduce:animate-none"
style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined} style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined}
@@ -0,0 +1,261 @@
import type { ReactNode } from "react";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import { LoaderCircle, Pause, Play, Square, Volume2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME } from "./agent-chat-message-chrome";
import { AgentSpeechVoicePicker } from "./agent-speech-voice-picker";
import { findSpeechSelectionStartOffset, stripSpeechMarkdown } from "../speech";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
type SpeechSelection = {
startOffset: number;
left: number;
top: number;
};
export function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
isSupported,
onSpeak,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
isSupported: boolean;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(null);
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
const selectionAnchor = selection ?? lastSelectionRef.current;
if (selection) {
lastSelectionRef.current = selection;
}
const captureSelection = useCallback(() => {
if (!isSupported || disabled) {
setSelection(null);
return;
}
const browserSelection = window.getSelection();
const container = contentRef.current;
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
const range = browserSelection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
const rect = range.getBoundingClientRect();
if (startOffset === null || (!rect.width && !rect.height)) {
setSelection(null);
return;
}
setSelection({
startOffset,
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
top: Math.max(rect.top - 10, 48)
});
}, [disabled, isSupported, speechText]);
useEffect(() => {
setSelection(null);
}, [messageId, speechText]);
useEffect(() => {
if (!selection) return;
const close = () => setSelection(null);
const closeCollapsedSelection = () => {
if (window.getSelection()?.isCollapsed) close();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("selectionchange", closeCollapsedSelection);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("selectionchange", closeCollapsedSelection);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [selection]);
const speakFromSelection = () => {
if (!selection) return;
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
window.getSelection()?.removeAllRanges();
setSelection(null);
};
return (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
从这里开始朗读
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
</div>
);
}
export function AgentMessageSpeechControls({
speechState,
onSpeak,
onPause,
onResume,
onStop
}: {
speechState: AgentSpeechState;
onSpeak: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
<AgentSpeechVoicePicker />
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={onSpeak}>
<Volume2 size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
</div>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
className={cn(
AGENT_MESSAGE_ACTION_CLASS_NAME,
"shrink-0 disabled:cursor-wait",
tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</Button>
);
}
@@ -0,0 +1,346 @@
import type { AgentInputDelivery } from "../api/client";
import type { AgentApprovalMode, AgentModelOption } from "../types";
import { AudioLines, CheckCircle2, ChevronDown, Shield, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
export function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
export function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
export function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}
@@ -43,7 +43,7 @@ export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onE
if (!inputs.length) return null; if (!inputs.length) return null;
return ( return (
<section aria-label="待处理消息队列" className="order-first w-full min-w-0 border-b border-[var(--surface-divider)] bg-[var(--surface-reading)]"> <section aria-label="待处理消息队列" className="mb-2 w-full min-w-0 overflow-hidden rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-reading)]">
<p className="px-3 pt-2 text-[11px] leading-5 text-muted-foreground" aria-live="polite"> <p className="px-3 pt-2 text-[11px] leading-5 text-muted-foreground" aria-live="polite">
等待处理 <span className="ml-1 tabular-nums">{inputs.length}</span> 等待处理 <span className="ml-1 tabular-nums">{inputs.length}</span>
</p> </p>
@@ -0,0 +1,73 @@
import { useState } from "react";
import useSWR from "swr";
import { Check, ChevronDown, SlidersHorizontal } from "lucide-react";
import { fetchSpeechVoiceCatalog } from "../speech-voice-catalog";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/shared/ui/command";
import { getSpeechVoiceDescription, getSpeechVoiceName, setSelectedSpeechVoice, useSelectedSpeechVoice } from "../speech-voices";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME } from "./agent-chat-message-chrome";
export function AgentSpeechVoicePicker() {
const [open, setOpen] = useState(false);
const selected = useSelectedSpeechVoice();
const { data, error, isLoading, mutate } = useSWR("/api/tts/edge/voices", fetchSpeechVoiceCatalog, {
revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60 * 60 * 1_000,
shouldRetryOnError: false
});
const selectedVoice = data?.voices.find((voice) => voice.id === (selected || data.defaultVoice));
const defaultVoice = data?.voices.find((voice) => voice.id === data.defaultVoice);
const selectedName = selectedVoice ? getSpeechVoiceName(selectedVoice) : selected ? "已选音色" : "默认音色";
const choose = (id: string) => { setSelectedSpeechVoice(id); setOpen(false); };
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className={AGENT_MESSAGE_ACTION_CLASS_NAME}
aria-label={`选择朗读音色,当前${selectedName}`} title={`朗读音色:${selectedName}`}>
<SlidersHorizontal size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>音色</span>
<ChevronDown className="size-3" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent data-agent-theme="chat" align="end" side="top" collisionPadding={12} className="z-[90] w-80 overflow-hidden p-0" aria-label="选择朗读音色">
<div className="border-b px-3 py-2">
<p className="text-sm font-medium">普通话音色</p>
<p className="text-xs text-muted-foreground">{data ? `${data.voices.length} 种音色,选择后下次播放生效` : "选择后下次播放生效"}</p>
</div>
<Command loop>
<CommandInput placeholder="搜索普通话音色" aria-label="搜索音色" />
<CommandList className="max-h-[min(320px,calc(var(--radix-popover-content-available-height)-180px))] overscroll-contain">
{isLoading ? <p role="status" className="p-3 text-sm text-muted-foreground">正在加载音色…</p> : null}
{error ? <div role="alert" className="p-3 text-sm">
<p>音色列表加载失败</p>
{error instanceof Error ? <p className="mt-1 text-xs text-muted-foreground">{error.message}</p> : null}
<Button variant="link" size="sm" className="px-0" onClick={() => void mutate()}>重新加载音色</Button>
</div> : null}
<CommandEmpty>没有找到匹配的音色</CommandEmpty>
<CommandGroup>
<CommandItem value="default 默认音色" onSelect={() => choose("")}>
<span className="flex-1">默认音色{defaultVoice ? `(${getSpeechVoiceName(defaultVoice)})` : ""}</span>
{!selected ? <Check aria-hidden="true" /> : null}
</CommandItem>
</CommandGroup>
{selected && data && !selectedVoice ? <p role="status" className="px-3 py-2 text-xs text-muted-foreground">原选音色已不可用,请重新选择。</p> : null}
<CommandGroup>
{data?.voices.map((voice) => {
const name = getSpeechVoiceName(voice);
const description = getSpeechVoiceDescription(voice);
return <CommandItem key={voice.id} value={`${voice.id} ${name} ${description} ${voice.name}`} onSelect={() => choose(voice.id)}>
<span className="min-w-0 flex-1">
<span className="block break-words text-sm">{name}</span>
<span className="block break-words text-xs text-muted-foreground">{description}</span>
</span>
{selected === voice.id ? <Check aria-hidden="true" /> : null}
</CommandItem>;
})}
</CommandGroup>
</CommandList>
</Command>
<p className="border-t px-3 py-2 text-xs text-muted-foreground">音色偏好保存在当前浏览器。</p>
</PopoverContent>
</Popover>
);
}
@@ -3,6 +3,7 @@ import { useId, useRef, useState, type RefObject } from "react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import type { AgentTopic } from "../api/client"; import type { AgentTopic } from "../api/client";
type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic }; type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic };
@@ -48,7 +49,7 @@ export function AgentTopicDialog({ open, initialMode, onOpenChange, triggerRef,
const footerClass = "gap-2 px-6 pb-6 sm:gap-0"; const footerClass = "gap-2 px-6 pb-6 sm:gap-0";
return <Dialog open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}> return <Dialog open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}>
<DialogContent data-agent-theme="chat" overlayClassName="agent-history-overlay" showCloseButton={false} <DialogContent onCopy={copySelectedText} data-agent-theme="chat" overlayClassName="agent-history-overlay" showCloseButton={false}
className="agent-topic-dialog z-[111] flex max-h-[min(80dvh,40rem)] w-[calc(100vw-2rem)] max-w-md flex-col gap-0 overflow-hidden bg-card p-0" className="agent-topic-dialog z-[111] flex max-h-[min(80dvh,40rem)] w-[calc(100vw-2rem)] max-w-md flex-col gap-0 overflow-hidden bg-card p-0"
onOpenAutoFocus={(event) => { event.preventDefault(); (inputRef.current ?? newTopicRef.current)?.focus(); }} onOpenAutoFocus={(event) => { event.preventDefault(); (inputRef.current ?? newTopicRef.current)?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}> onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
@@ -85,7 +85,7 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
return ( return (
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}> <div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
<div className="mb-3 flex items-start justify-between gap-3"> <div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}>
{!embedded ? <div className="min-w-0"> {!embedded ? <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}> <p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"} {spec.title ?? "Agent 图表"}
@@ -95,11 +95,11 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""} {spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p> </p>
</div> : null} </div> : null}
<div className="flex shrink-0 flex-wrap justify-end gap-1.5"> <div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
{data.series.slice(0, 3).map((series, index) => ( {data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600"> <span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} /> <span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
{series.name} {embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
</span> </span>
))} ))}
</div> </div>
@@ -2,8 +2,11 @@ import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message"; import { MessageResponse } from "@/shared/ai-elements/message";
import { defaultRehypePlugins } from "streamdown"; import { defaultRehypePlugins } from "streamdown";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown"; import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
import { ReadableMarkdownDiagram, ReadableMarkdownTable } from "@/shared/ai-elements/markdown-reading";
const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin]; const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin];
const responseComponents = { ...artifactLinkComponents, table: ReadableMarkdownTable };
const responsePlugins = { renderers: [{ language: "mermaid", component: ReadableMarkdownDiagram }] };
type StreamingTokenResponseProps = { type StreamingTokenResponseProps = {
content: string; content: string;
@@ -32,7 +35,8 @@ export function StreamingTokenResponse({
mode="streaming" mode="streaming"
parseIncompleteMarkdown={true} parseIncompleteMarkdown={true}
rehypePlugins={responseRehypePlugins} rehypePlugins={responseRehypePlugins}
components={artifactLinkComponents} components={responseComponents}
plugins={responsePlugins}
> >
{content} {content}
</MessageResponse> </MessageResponse>
+3 -8
View File
@@ -1,11 +1,6 @@
import { z } from "zod"; import { safeChartInput } from "../presentation/command-schema";
export const frontendChartSchema = safeChartInput;
const chartSchema = z.object({
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
export const parseFrontendChart = (value: unknown) => { export const parseFrontendChart = (value: unknown) => {
const parsed = chartSchema.safeParse(value); const parsed = frontendChartSchema.safeParse(value);
return parsed.success ? parsed.data : null; return parsed.success ? parsed.data : null;
}; };
+81 -15
View File
@@ -7,13 +7,21 @@ import {
} from "."; } from ".";
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>; type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise<void>; type SubmitResult = (
sessionId: string,
actionId: string,
result: FrontendActionResult
) => Promise<void>;
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/; const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
export class FrontendActionExecutor { export class FrontendActionExecutor {
private readonly controllers = new Map<string, AbortController>(); private readonly controllers = new Map<string, AbortController>();
private queue: Promise<void> = Promise.resolve();
constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {} constructor(
private readonly execute: ExecuteAction,
private readonly submit: SubmitResult
) {}
async handle(value: unknown, activeSessionId: string | null): Promise<void> { async handle(value: unknown, activeSessionId: string | null): Promise<void> {
const request = parseFrontendActionRequest(value); const request = parseFrontendActionRequest(value);
@@ -24,32 +32,90 @@ export class FrontendActionExecutor {
return; return;
} }
if (this.controllers.has(request.actionId)) return; if (this.controllers.has(request.actionId)) return;
const result = await this.executeOnce(request); const controller = new AbortController();
storeActionResult(request.sessionId, result); this.controllers.set(request.actionId, controller);
await this.submit(request.sessionId, request.actionId, result); const pending = this.queue.then(async () => {
const result = await this.executeOnce(request, controller);
storeActionResult(request.sessionId, result);
await this.submit(request.sessionId, request.actionId, result);
});
this.queue = pending.catch(() => undefined);
return pending;
} }
cancelAll(): void { cancelAll(): void {
for (const controller of this.controllers.values()) controller.abort(); for (const controller of this.controllers.values()) controller.abort();
this.controllers.clear(); this.controllers.clear();
this.queue = Promise.resolve();
} }
private async executeOnce(request: FrontendActionRequest): Promise<FrontendActionResult> { private async executeOnce(
if (Date.now() >= request.expiresAt) return this.failure(request, "expired", "ACTION_EXPIRED", "frontend action expired before execution"); request: FrontendActionRequest,
const controller = new AbortController(); controller: AbortController
this.controllers.set(request.actionId, controller); ): Promise<FrontendActionResult> {
const remaining = request.expiresAt - Date.now();
const timer =
remaining > 0
? setTimeout(
() => controller.abort(new DOMException("Action expired", "TimeoutError")),
remaining
)
: undefined;
try { try {
return { version: "frontend-action-result@1", actionId: request.actionId, status: "succeeded", output: await this.execute(request, controller.signal), completedAt: Date.now() }; controller.signal.throwIfAborted();
if (remaining <= 0)
return this.failure(
request,
"expired",
"ACTION_EXPIRED",
"frontend action expired before execution"
);
const output = await this.execute(request, controller.signal);
controller.signal.throwIfAborted();
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status: "succeeded",
output,
completedAt: Date.now()
};
} catch (error) { } catch (error) {
const expired =
controller.signal.reason instanceof DOMException &&
controller.signal.reason.name === "TimeoutError";
const message = error instanceof Error ? error.message : "frontend action failed"; const message = error instanceof Error ? error.message : "frontend action failed";
const code = controller.signal.aborted ? "ACTION_CANCELLED" : ERROR_CODE_PATTERN.test(message) ? message : "ACTION_FAILED"; const code = expired
return this.failure(request, controller.signal.aborted ? "cancelled" : "failed", code, message); ? "ACTION_EXPIRED"
: controller.signal.aborted
? "ACTION_CANCELLED"
: ERROR_CODE_PATTERN.test(message)
? message
: "ACTION_FAILED";
return this.failure(
request,
expired ? "expired" : controller.signal.aborted ? "cancelled" : "failed",
code,
message
);
} finally { } finally {
this.controllers.delete(request.actionId); clearTimeout(timer);
if (this.controllers.get(request.actionId) === controller)
this.controllers.delete(request.actionId);
} }
} }
private failure(request: FrontendActionRequest, status: "failed" | "cancelled" | "expired", code: string, message: string): FrontendActionResult { private failure(
return { version: "frontend-action-result@1", actionId: request.actionId, status, error: { code, message }, completedAt: Date.now() }; request: FrontendActionRequest,
status: "failed" | "cancelled" | "expired",
code: string,
message: string
): FrontendActionResult {
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status,
error: { code, message },
completedAt: Date.now()
};
} }
} }
@@ -15,11 +15,11 @@ beforeEach(() => {
describe("SCADA frontend actions", () => { describe("SCADA frontend actions", () => {
it("accepts registered view actions and rejects unknown action names", () => { it("accepts registered view actions and rejects unknown action names", () => {
expect(parseFrontendActionRegistry({ expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1", schema_version: "frontend-action-registry@2",
actions: [ actions: [
{ id: "render_scada_analysis", version: "frontend-action@1" }, { id: "render_scada_analysis", version: "frontend-action@2" },
{ id: "clear_scada_analysis", version: "frontend-action@1" }, { id: "clear_scada_analysis", version: "frontend-action@2" },
{ id: "zoom_to_map", version: "frontend-action@1" } { id: "zoom_to_map", version: "frontend-action@2" }
] ]
})?.actions.map((action) => action.id)).toEqual([ })?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis", "render_scada_analysis",
@@ -49,6 +49,7 @@ describe("SCADA frontend actions", () => {
expect(execute).toHaveBeenCalledTimes(1); expect(execute).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledTimes(2); expect(submit).toHaveBeenCalledTimes(2);
expect(submit.mock.calls[0][2]).toMatchObject({ expect(submit.mock.calls[0][2]).toMatchObject({
version: "frontend-action-result@2",
status: "succeeded", status: "succeeded",
output: { rendered_ids: ["MP01"], fitted: true } output: { rendered_ids: ["MP01"], fitted: true }
}); });
@@ -58,13 +59,37 @@ describe("SCADA frontend actions", () => {
function createRequest(name: string) { function createRequest(name: string) {
return { return {
version: "frontend-action@1", version: "frontend-action@2",
actionId: "action-1", actionId: "action-1",
toolCallId: "call-1", toolCallId: "call-1",
sessionId: "session-1", sessionId: "session-1",
name, command: { action: name, items: [{ device_id: "MP01", level: "high" }] },
params: { items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(), issuedAt: Date.now(),
expiresAt: Date.now() + 15_000 expiresAt: Date.now() + 15_000
}; };
} }
describe("ordered v2 actions", () => {
it("rejects v1 envelopes, legacy name/params and malformed canonical commands", () => {
const request = createRequest("render_scada_analysis");
expect(parseFrontendActionRequest({ ...request, version: "frontend-action@1" })).toBeNull();
expect(parseFrontendActionRequest({ ...request, name: "render_scada_analysis", params: {} })).toBeNull();
expect(parseFrontendActionRequest({ ...request, command: { action: "zoom_to_map", center: [181, 31], zoom: 14 } })).toBeNull();
});
it("keeps commands in stream order and cancels queued work when a session changes", async () => {
let release!: () => void;
const first = new Promise<void>((resolve) => { release = resolve; });
const execute = vi.fn(async (request, signal) => { await first; signal.throwIfAborted(); return { action: request.command.action }; });
const submit = vi.fn(async () => {});
const executor = new FrontendActionExecutor(execute, submit);
const one = executor.handle(createRequest("render_scada_analysis"), "session-1");
const two = executor.handle({ ...createRequest("render_scada_analysis"), actionId: "action-2" }, "session-1");
await Promise.resolve();
expect(execute).toHaveBeenCalledTimes(1);
executor.cancelAll(); release(); await Promise.all([one, two]);
expect(execute).toHaveBeenCalledTimes(1);
expect(submit.mock.calls.map((call) => (call as unknown[])[2])).toEqual([
expect.objectContaining({ status: "cancelled" }), expect.objectContaining({ status: "cancelled" })
]);
});
});
+82 -11
View File
@@ -1,12 +1,83 @@
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const; import { z } from "zod";
import {
PRESENTATION_ACTIONS,
presentationCommandSchema,
type PresentationCommand
} from "../presentation/command";
export { parseFrontendChart } from "./chart"; export { parseFrontendChart } from "./chart";
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number]; export const FRONTEND_ACTION_NAMES = PRESENTATION_ACTIONS;
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> }; export type FrontendActionName = PresentationCommand["action"];
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number }; export type FrontendActionRegistry = {
export type FrontendActionResult = { version: "frontend-action-result@1"; actionId: string; status: "succeeded" | "failed" | "cancelled" | "expired"; output?: unknown; error?: { code: string; message: string }; completedAt: number }; schema_version: "frontend-action-registry@2";
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); actions: Array<{ id: FrontendActionName; version: "frontend-action@2" }>;
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter((item): item is { id: FrontendActionName; version: "frontend-action@1" } => isRecord(item) && FRONTEND_ACTION_NAMES.includes(item.id as FrontendActionName) && item.version === "frontend-action@1"); return { schema_version: "frontend-action-registry@1", actions }; } };
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null { if (!isRecord(value) || value.version !== "frontend-action@1" || typeof value.actionId !== "string" || typeof value.toolCallId !== "string" || typeof value.sessionId !== "string" || !FRONTEND_ACTION_NAMES.includes(value.name as FrontendActionName) || !isRecord(value.params) || typeof value.issuedAt !== "number" || typeof value.expiresAt !== "number") return null; return value as FrontendActionRequest; } const identifier = z.string().trim().min(1).max(128);
const storageKey = (sessionId: string) => `tjwater:frontend-actions:${sessionId}`; const requestSchema = z
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record<string, FrontendActionResult> : {}; } catch { return {}; } } .object({
export function storeActionResult(sessionId: string, result: FrontendActionResult) { const values = { ...readActionResults(sessionId), [result.actionId]: result }; const bounded = Object.fromEntries(Object.entries(values).sort(([, a], [, b]) => b.completedAt - a.completedAt).slice(0, 100)); sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded)); } version: z.literal("frontend-action@2"),
actionId: identifier,
toolCallId: identifier,
sessionId: identifier,
command: presentationCommandSchema,
issuedAt: z.number().finite().positive(),
expiresAt: z.number().finite().positive(),
risk: z.enum(["view", "reversible", "side_effect"]).optional(),
replayPolicy: z.enum(["never", "explicit"]).optional(),
fallbackText: z.string().max(2000).optional(),
replayOf: identifier.optional()
})
.strict()
.refine((value) => value.expiresAt > value.issuedAt);
export type FrontendActionRequest = z.infer<typeof requestSchema>;
export type FrontendActionResult = {
version: "frontend-action-result@2";
actionId: string;
status: "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
output?: unknown;
error?: { code: string; message: string };
completedAt: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const registrySchema = z
.object({
schema_version: z.literal("frontend-action-registry@2"),
actions: z.array(
z
.object({
id: z.enum(PRESENTATION_ACTIONS),
version: z.literal("frontend-action@2"),
risk: z.string().optional(),
timeoutMs: z.number().optional(),
supportedSurfaces: z.array(z.string()).optional()
})
.strict()
)
})
.strict();
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null {
const parsed = registrySchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null {
const parsed = requestSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
const storageKey = (sessionId: string) => `tjwater:frontend-actions:v2:${sessionId}`;
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> {
try {
const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}");
return isRecord(value) ? (value as Record<string, FrontendActionResult>) : {};
} catch {
return {};
}
}
export function storeActionResult(sessionId: string, result: FrontendActionResult) {
const values = { ...readActionResults(sessionId), [result.actionId]: result };
const bounded = Object.fromEntries(
Object.entries(values)
.sort(([, a], [, b]) => b.completedAt - a.completedAt)
.slice(0, 100)
);
sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded));
}
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useAgentSpeechSynthesis } from "./use-agent-voice";
import { setSelectedSpeechVoice } from "../speech-voices";
class MockAudio {
static instances: MockAudio[] = [];
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
play = vi.fn(async () => undefined);
pause = vi.fn();
load = vi.fn();
removeAttribute = vi.fn();
constructor() { MockAudio.instances.push(this); }
}
const fetchMock = vi.fn<typeof fetch>();
const audioResponse = () => new Response(new Blob(["audio"], { type: "audio/mpeg" }));
const errorResponse = () => new Response(JSON.stringify({ error: "语音生成失败" }), { status: 502 });
beforeEach(() => {
setSelectedSpeechVoice("");
MockAudio.instances = [];
fetchMock.mockReset();
vi.stubGlobal("Audio", MockAudio);
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:audio");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it("keeps one chosen voice for all chunks, then applies a changed choice to the next playback", async () => {
fetchMock.mockImplementation(async () => audioResponse());
setSelectedSpeechVoice("zh-CN-YunyangNeural");
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis());
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。${"丙".repeat(300)}。`));
setSelectedSpeechVoice("zh-CN-XiaoyiNeural");
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
expect(fetchMock.mock.calls.map(([, options]) => JSON.parse(String(options?.body)).voice))
.toEqual(["zh-CN-YunyangNeural", "zh-CN-YunyangNeural", "zh-CN-YunyangNeural"]);
await act(() => result.current.speak("new", "Hello"));
expect(JSON.parse(String(fetchMock.mock.lastCall?.[1]?.body)).voice).toBe("zh-CN-XiaoyiNeural");
unmount();
});
it("reports synthesis failure and allows a successful retry", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(errorResponse()).mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
expect(onError).toHaveBeenCalledWith("语音生成失败");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
expect(result.current.speechState).toBe("playing");
unmount();
});
it("handles failure of a later chunk without leaving playback loading", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(audioResponse()).mockImplementation(async () => errorResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。`));
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(onError).toHaveBeenCalledWith("语音生成失败"));
expect(result.current.speechState).toBe("idle");
expect(result.current.speakingMessageId).toBeNull();
unmount();
});
it("ignores a late failure belonging to a replaced playback", async () => {
const onError = vi.fn();
let failOld!: (reason: Error) => void;
fetchMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { failOld = reject; }))
.mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let oldPlayback!: Promise<void>;
act(() => { oldPlayback = result.current.speak("old", "旧回复"); });
await act(() => result.current.speak("new", "新回复"));
await act(async () => { failOld(new Error("old request failed")); await oldPlayback; });
expect(result.current.speechState).toBe("playing");
expect(result.current.speakingMessageId).toBe("new");
expect(onError).not.toHaveBeenCalled();
unmount();
});
it("reports media and resume failures and releases playback", async () => {
const onError = vi.fn();
fetchMock.mockImplementation(async () => audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
act(() => { MockAudio.instances[0].onerror?.(); });
expect(onError).toHaveBeenLastCalledWith("音频播放失败,请重试");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
act(() => result.current.pause());
MockAudio.instances[1].play.mockRejectedValueOnce(new DOMException("blocked", "NotAllowedError"));
await act(async () => result.current.resume());
expect(onError).toHaveBeenLastCalledWith("浏览器阻止了语音播放,请再次点击播放");
expect(result.current.speechState).toBe("idle");
unmount();
});
it("cancels a pending request silently when stopped", async () => {
const onError = vi.fn();
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
}));
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let playback!: Promise<void>;
act(() => { playback = result.current.speak("message", "你好"); });
await act(async () => { result.current.stop(); await playback; });
expect(onError).not.toHaveBeenCalled();
expect(result.current.speechState).toBe("idle");
unmount();
});
+36 -24
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { getSelectedSpeechVoice } from "../speech-voices";
import { import {
splitSpeechTextIntoChunks, splitSpeechTextIntoChunks,
type AgentSpeakOptions, type AgentSpeakOptions,
@@ -54,7 +55,11 @@ function getServerBrowserCapability() {
return false; return false;
} }
export function useAgentSpeechSynthesis() { export function useAgentSpeechSynthesis(onError?: (message: string) => void) {
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const [speechState, setSpeechState] = useState<AgentSpeechState>("idle"); const [speechState, setSpeechState] = useState<AgentSpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -64,6 +69,7 @@ export function useAgentSpeechSynthesis() {
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map()); const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map()); const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]); const chunksRef = useRef<string[]>([]);
const playbackVoiceRef = useRef("");
const currentChunkIndexRef = useRef(0); const currentChunkIndexRef = useRef(0);
const playbackTokenRef = useRef(0); const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null); const activeMessageIdRef = useRef<string | null>(null);
@@ -108,6 +114,19 @@ export function useAgentSpeechSynthesis() {
activeMessageIdRef.current = null; activeMessageIdRef.current = null;
}, [detachAudio]); }, [detachAudio]);
const handlePlaybackError = useCallback((error: unknown, playbackToken: number) => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
if (error instanceof DOMException && error.name === "AbortError") return;
const message = error instanceof DOMException && error.name === "NotAllowedError"
? "浏览器阻止了语音播放,请再次点击播放"
: error instanceof Error ? error.message : "音频播放失败,请重试";
onErrorRef.current?.(message);
}, [releaseAudio]);
const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => { const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => {
const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex); const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex);
if (cachedUrl) return Promise.resolve(cachedUrl); if (cachedUrl) return Promise.resolve(cachedUrl);
@@ -123,7 +142,7 @@ export function useAgentSpeechSynthesis() {
const promise = fetch("/api/tts/edge", { const promise = fetch("/api/tts/edge", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), body: JSON.stringify({ text, ...(playbackVoiceRef.current ? { voice: playbackVoiceRef.current } : {}) }),
signal: controller.signal signal: controller.signal
}) })
.then(async (response) => { .then(async (response) => {
@@ -144,7 +163,7 @@ export function useAgentSpeechSynthesis() {
}) })
.finally(() => { .finally(() => {
fetchControllersRef.current.delete(controller); fetchControllersRef.current.delete(controller);
chunkPromisesRef.current.delete(chunkIndex); if (playbackToken === playbackTokenRef.current) chunkPromisesRef.current.delete(chunkIndex);
}); });
chunkPromisesRef.current.set(chunkIndex, promise); chunkPromisesRef.current.set(chunkIndex, promise);
@@ -179,21 +198,18 @@ export function useAgentSpeechSynthesis() {
return; return;
} }
currentChunkIndexRef.current = nextChunkIndex; currentChunkIndexRef.current = nextChunkIndex;
void playChunkRef.current(nextChunkIndex, playbackToken); void playChunkRef.current(nextChunkIndex, playbackToken)
.catch((error: unknown) => handlePlaybackError(error, playbackToken));
}; };
audio.onerror = () => { audio.onerror = () => {
if (playbackToken !== playbackTokenRef.current) return; handlePlaybackError(new Error("音频播放失败,请重试"), playbackToken);
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
}; };
await audio.play(); await audio.play();
if (playbackToken !== playbackTokenRef.current) return; if (playbackToken !== playbackTokenRef.current) return;
setSpeechState("playing"); setSpeechState("playing");
prefetchChunk(chunkIndex + 1, playbackToken); prefetchChunk(chunkIndex + 1, playbackToken);
}, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]); }, [detachAudio, fetchChunkAudio, handlePlaybackError, prefetchChunk, releaseAudio]);
useEffect(() => { useEffect(() => {
playChunkRef.current = playChunk; playChunkRef.current = playChunk;
@@ -211,6 +227,7 @@ export function useAgentSpeechSynthesis() {
playbackTokenRef.current = playbackToken; playbackTokenRef.current = playbackToken;
releaseAudio(); releaseAudio();
chunksRef.current = chunks; chunksRef.current = chunks;
playbackVoiceRef.current = getSelectedSpeechVoice();
activeMessageIdRef.current = messageId; activeMessageIdRef.current = messageId;
setSpeakingMessageId(messageId); setSpeakingMessageId(messageId);
setSpeechState("loading"); setSpeechState("loading");
@@ -218,12 +235,9 @@ export function useAgentSpeechSynthesis() {
try { try {
await playChunk(0, playbackToken); await playChunk(0, playbackToken);
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return; handlePlaybackError(error, playbackToken);
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
} }
}, [isSupported, playChunk, releaseAudio]); }, [isSupported, playChunk, releaseAudio, handlePlaybackError]);
const pause = useCallback(() => { const pause = useCallback(() => {
if (!audioRef.current || speechState !== "playing") return; if (!audioRef.current || speechState !== "playing") return;
@@ -234,15 +248,13 @@ export function useAgentSpeechSynthesis() {
const resume = useCallback(() => { const resume = useCallback(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
setSpeakingMessageId(activeMessageIdRef.current); const playbackToken = playbackTokenRef.current;
setSpeechState("playing"); void audio.play().then(() => {
void audio.play().catch(() => { if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1; setSpeakingMessageId(activeMessageIdRef.current);
releaseAudio(); setSpeechState("playing");
setSpeechState("idle"); }).catch((error: unknown) => handlePlaybackError(error, playbackToken));
setSpeakingMessageId(null); }, [handlePlaybackError]);
});
}, [releaseAudio]);
const stop = useCallback(() => { const stop = useCallback(() => {
playbackTokenRef.current += 1; playbackTokenRef.current += 1;
+4
View File
@@ -107,7 +107,11 @@ export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-contex
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client"; export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
export type { AgentRunStatus } from "./api/client"; export type { AgentRunStatus } from "./api/client";
export { AnswerViewStateScope } from "./session-state/answer-view-state-scope";
export { useInteractionRequests } from "./hooks/use-interaction-requests"; export { useInteractionRequests } from "./hooks/use-interaction-requests";
export { AgentInteractionRequests } from "./components/agent-interaction-request-card"; export { AgentInteractionRequests } from "./components/agent-interaction-request-card";
export { readInteractionReply } from "./api/interaction-requests"; 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";
@@ -0,0 +1,212 @@
import { z } from "zod";
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const chartSchema = z
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z
.array(
z
.object({
name: z.string().max(128),
data: z.array(z.number().finite()).min(1).max(100)
})
.strict()
)
.min(1)
.max(8)
})
.strict()
.refine(
(value) => value.series.every((series) => series.data.length === value.x_data.length),
"series must align with x_data"
);
const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite())
.refine((value) => value.length <= 3);
const line = z.array(position).min(2).max(20000);
const ring = z
.array(position)
.min(4)
.max(20000)
.refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
const geometrySchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
z
.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) })
.strict(),
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
z
.object({
type: z.literal("MultiLineString"),
coordinates: z.array(line).min(1).max(20000)
})
.strict(),
z
.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) })
.strict(),
z
.object({
type: z.literal("MultiPolygon"),
coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000)
})
.strict()
])
.refine((geometry) => {
const count = (coordinates: unknown): number =>
!Array.isArray(coordinates)
? 0
: typeof coordinates[0] === "number"
? 1
: coordinates.reduce<number>((total, item) => total + count(item), 0);
return count(geometry.coordinates) <= 20000;
});
const presentationOverlayDataSchema = z
.object({
type: z.literal("FeatureCollection"),
features: z
.array(
z
.object({
type: z.literal("Feature"),
id: z.union([z.string(), z.number().finite()]).optional(),
properties: z.record(z.string(), z.unknown()).nullable(),
geometry: geometrySchema
})
.strict()
)
.min(1)
.max(1000)
})
.strict();
const supplyLayerSchema = z.enum([
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]);
const scadaItems = z
.array(
z
.object({
device_id: presentationIdentifierSchema,
level: z.enum(["high", "medium", "low", "unrated"])
})
.strict()
)
.min(1)
.max(100);
const overlaySchema = z
.object({
action: z.literal("set_overlay"),
overlay_id: presentationIdentifierSchema,
title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]),
evidence_id: presentationIdentifierSchema,
data: publication ? presentationOverlayDataSchema.optional() : presentationOverlayDataSchema
})
.strict();
const variants = [
z
.object({
action: z.literal("locate_features"),
layer: supplyLayerSchema,
feature_ids: z.array(presentationIdentifierSchema).min(1).max(100)
})
.strict(),
z
.object({
action: z.literal("zoom_to_map"),
center: z.tuple([
z.number().finite().min(-180).max(180),
z.number().finite().min(-90).max(90)
]),
zoom: z.number().finite().min(0).max(24)
})
.strict(),
z.object({ action: z.literal("render_scada_analysis"), items: scadaItems }).strict(),
z.object({ action: z.literal("clear_scada_analysis") }).strict(),
z
.object({
action: z.literal("highlight_features"),
layer: supplyLayerSchema,
feature_ids: z.array(presentationIdentifierSchema).min(1).max(100),
effect: z.enum(["steady", "pulse"])
})
.strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z
.object({
action: z.literal("open_chart"),
chart_id: presentationIdentifierSchema,
chart: chartSchema,
evidence_refs: z
.array(presentationIdentifierSchema)
.min(publication ? 1 : 0)
.max(100)
})
.strict(),
z.object({ action: z.literal("close_chart"), chart_id: presentationIdentifierSchema }).strict(),
overlaySchema,
z
.object({ action: z.literal("clear_overlay"), overlay_id: presentationIdentifierSchema })
.strict()
] as const;
const command = z.discriminatedUnion("action", [...variants]).superRefine((command, ctx) => {
if (
command.action === "render_scada_analysis" &&
new Set(command.items.map((item) => item.device_id)).size !== command.items.length
) {
ctx.addIssue({ code: "custom", message: "device_id values must be unique" });
}
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (
command.data?.features.some(
(feature) =>
feature.geometry.type !== "Point" ||
(feature.properties?.weight !== undefined &&
(typeof feature.properties.weight !== "number" ||
!Number.isFinite(feature.properties.weight) ||
feature.properties.weight < 0))
)
) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
return {
command,
variants,
safeChartInput: chartSchema,
supplyLayerSchema,
overlayDataSchema: presentationOverlayDataSchema
};
}
export const {
safeChartInput,
supplyLayerSchema,
overlayDataSchema,
variants: viewCommandVariants
} = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) =>
createViewSchemas(z, options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }>
| (Extract<Command, { action: "set_overlay" }> & { data: z.infer<typeof overlayDataSchema> });
export const viewCommandSchema = createViewCommandSchema({ publication: false }).refine(
(command): command is ViewCommand => command.action !== "set_overlay" || Boolean(command.data),
"resolved overlay data is required"
);
@@ -0,0 +1,16 @@
import { viewCommandSchema, overlayDataSchema } from "./command-schema";
export const presentationCommandSchema = viewCommandSchema;
export const presentationOverlayDataSchema = overlayDataSchema;
export type { ViewCommand as PresentationCommand } from "./command-schema";
export const PRESENTATION_ACTIONS = [
"locate_features",
"zoom_to_map",
"highlight_features",
"clear_highlights",
"open_chart",
"close_chart",
"set_overlay",
"clear_overlay",
"render_scada_analysis",
"clear_scada_analysis"
] as const;
@@ -0,0 +1,4 @@
import { createContext } from "react";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
export const AnswerViewPersistenceContext = createContext<{ scope: AnswerViewStateScope; sessionId: string } | null>(null);
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
import { AnswerViewPersistenceContext } from "./answer-view-persistence-context";
export function AnswerViewStateProvider({ scope, sessionId, children }: {
scope?: AnswerViewStateScope; sessionId?: string | null; children: ReactNode;
}) {
return <AnswerViewPersistenceContext.Provider value={scope && sessionId ? { scope, sessionId } : null}>{children}</AnswerViewPersistenceContext.Provider>;
}
@@ -0,0 +1,153 @@
import type { Spec } from "@json-render/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnswerViewStateApiError, type AnswerViewStateRecord, type AnswerViewStateWrite } from "../api/answer-view-state";
import { answerViewStateDirtyPaths, migrateAnswerViewState } from "../answer-ui/answer-view-state";
import { AnswerViewStateScope, mergeDirty } from "./answer-view-state-scope";
const spec: Spec = { root: "form", elements: { form: { type: "FollowUpForm", props: { title: "资料", fields: [
{ name: "name", label: "姓名", kind: "text" }, { name: "note", label: "说明", kind: "text" }
] }, children: [] } } };
const record = (revision = 1, name = "远端", note = "远端说明", message = "message"): AnswerViewStateRecord => ({
version: "answer-view-state@1", message_id: message, block_id: "block", revision, schema_digest: "digest", updated_at: "2026-10-26T00:00:00Z",
nodes: { form: { type: "FollowUpForm", values: { name, note } } }, block: { evidenceFilter: "all" },
});
function deferred<T>() { let resolve!: (value: T) => void; let reject!: (value: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
const settle = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
const client = () => ({ getAnswerViewState: vi.fn(async () => record()), putAnswerViewState: vi.fn(async (_session: string, state: AnswerViewStateWrite) => ({ ...record(state.expected_revision + 1), nodes: state.nodes, block: state.block })) });
function change(scope: AnswerViewStateScope, entry: ReturnType<AnswerViewStateScope["entry"]>, value: string) {
const state = structuredClone(entry.snapshot.state);
state.nodes.form = { type: "FollowUpForm", values: { ...(state.nodes.form.type === "FollowUpForm" ? state.nodes.form.values : {}), name: value } };
scope.update(entry, state, ["/nodes/form/values/name"]);
}
describe("answer view state persistence", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("merges a late restore by dirty field and never writes unknown revision zero", async () => {
const api = client(), read = deferred<AnswerViewStateRecord>();
api.getAnswerViewState.mockReturnValue(read.promise);
const scope = new AnswerViewStateScope("actor:project", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {});
change(scope, entry, "本地输入");
await vi.advanceTimersByTimeAsync(1000);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
read.resolve(record(8)); await settle();
expect(entry.snapshot.state.nodes.form).toEqual({ type: "FollowUpForm", values: { name: "本地输入", note: "远端说明" } });
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[0][1].expected_revision).toBe(8);
expect(entry.snapshot.status).toBe("saved");
scope.dispose();
});
it("flushes a known old session into its own key while isolating the same block in another message", async () => {
const api = client();
api.getAnswerViewState.mockImplementation(async (_session?: string, message?: string) => record(1, message, "note", message));
const scope = new AnswerViewStateScope("actor:project", api), first = scope.entry("first", "message", "block", spec);
const release = scope.subscribe(first, () => {}); await settle();
change(scope, first, "第一条草稿"); release();
const second = scope.entry("second", "another-message", "block", spec);
scope.subscribe(second, () => {}); await settle();
expect(api.putAnswerViewState.mock.calls[0][0]).toBe("first");
expect(api.putAnswerViewState.mock.calls[0][1].message_id).toBe("message");
expect(second.snapshot.state.nodes.form).toMatchObject({ values: { name: "another-message" } });
expect(scope.entry("first", "different-message", "block", spec)).not.toBe(first);
scope.dispose();
});
it("drops disposed project results and clears its cache", async () => {
const api = client(), read = deferred<AnswerViewStateRecord>(); api.getAnswerViewState.mockReturnValue(read.promise);
const oldScope = new AnswerViewStateScope("actor:old-project", api), old = oldScope.entry("session", "message", "block", spec);
const notify = vi.fn(); oldScope.subscribe(old, notify); oldScope.dispose();
const nextApi = client(), nextScope = new AnswerViewStateScope("actor:new-project", nextApi), next = nextScope.entry("session", "message", "block", spec);
nextScope.subscribe(next, () => {}); await settle();
read.resolve(record(7, "旧项目秘密")); await settle();
expect(notify).toHaveBeenCalledTimes(1);
expect(next.snapshot.state.nodes.form).toMatchObject({ values: { name: "远端" } });
expect(old.snapshot.state.nodes.form).toMatchObject({ values: {} });
nextScope.dispose();
});
it("keeps edits made during save and sends them using the acknowledged next revision", async () => {
const api = client(), write = deferred<AnswerViewStateRecord>(); api.putAnswerViewState.mockReturnValueOnce(write.promise);
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "第一版");
await vi.advanceTimersByTimeAsync(500); change(scope, entry, "保存中修改");
write.resolve(record(2, "第一版")); await settle();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "保存中修改" } });
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 2, nodes: { form: { values: { name: "保存中修改" } } } });
expect(entry.snapshot.status).toBe("saved"); scope.dispose();
});
it("retries an ambiguous network failure with exactly the same intent before saving newer edits", async () => {
const api = client(); api.putAnswerViewState.mockRejectedValueOnce(new TypeError("offline"));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "第一版"); await vi.advanceTimersByTimeAsync(500);
const payload = structuredClone(api.putAnswerViewState.mock.calls[0][1]);
change(scope, entry, "第二版"); await vi.advanceTimersByTimeAsync(2000);
expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
scope.retry(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toEqual(payload);
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[2][1].request_id).not.toBe(payload.request_id);
expect(api.putAnswerViewState.mock.calls[2][1].nodes.form).toMatchObject({ values: { name: "第二版" } });
scope.dispose();
});
it.each(["ANSWER_VIEW_STATE_CONFLICT", "ANSWER_VIEW_SCHEMA_CHANGED"])("keeps drafts on %s until explicit reload or resave", async (code) => {
const api = client(); api.putAnswerViewState.mockRejectedValueOnce(new AnswerViewStateApiError("conflict", 409, code, record(9, "别的窗口")));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "本地草稿"); await vi.advanceTimersByTimeAsync(500);
expect(entry.snapshot.status).toBe("conflict");
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "本地草稿" } });
await vi.advanceTimersByTimeAsync(5000); expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
if (code === "ANSWER_VIEW_STATE_CONFLICT") {
scope.reload(entry);
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "别的窗口" } });
expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
} else {
scope.resave(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 9, nodes: { form: { values: { name: "本地草稿" } } } });
}
scope.dispose();
});
it.each([403, 404, 503])("keeps memory drafts and bounds failed restore requests on HTTP %i", async (status) => {
const api = client(); api.getAnswerViewState.mockRejectedValue(new AnswerViewStateApiError("暂不可用", status));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "离线草稿"); await settle();
change(scope, entry, "继续编辑"); await vi.advanceTimersByTimeAsync(10_000);
expect(api.getAnswerViewState).toHaveBeenCalledTimes(2);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "继续编辑" } });
expect(entry.snapshot.status).toBe("unsaved"); scope.dispose();
});
it("uses the provider's schema migration exactly once and explicitly resaves at the conflict revision", async () => {
const before: Spec = { root: "table", elements: { table: { type: "Table", children: [], props: { columns: ["名称", "压力"], rows: [["甲", "1"], ["乙", "2"]] } } } };
const after: Spec = { root: "table", elements: { table: { type: "Table", children: [], props: { columns: ["名称"], rows: [["乙"], ["甲"]] } } } };
const original: AnswerViewStateRecord = { ...record(), nodes: { table: { type: "Table", query: "甲", sort: { column: 1, descending: false }, selection: [0] } } };
const migrated = migrateAnswerViewState(before, after, original);
const current: AnswerViewStateRecord = { ...original, ...migrated, revision: 4, schema_digest: "new-digest" };
const api = client(); api.getAnswerViewState.mockResolvedValue(original);
api.putAnswerViewState.mockRejectedValueOnce(new AnswerViewStateApiError("schema changed", 409, "ANSWER_VIEW_SCHEMA_CHANGED", current));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", before);
scope.subscribe(entry, () => {}); await settle();
scope.entry("session", "message", "block", after);
scope.update(entry, migrated, answerViewStateDirtyPaths(original, migrated));
await vi.advanceTimersByTimeAsync(500);
expect(entry.snapshot.status).toBe("conflict");
scope.resave(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 4, schema_digest: "new-digest",
nodes: { table: { query: "甲", sort: null, selection: [] } } });
expect(entry.snapshot.state.nodes.table).toMatchObject({ query: "甲", sort: null, selection: [] });
scope.dispose();
});
it("preserves oversized Unicode drafts and does not send a clipped payload", async () => {
const api = client(), scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); const text = "水".repeat(12_000); change(scope, entry, text);
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: text } });
expect(entry.snapshot.error).toContain("32 KiB"); scope.dispose();
});
it("merges escaped form fields and nested chart table paths without replacing siblings", () => {
const local = { nodes: { form: { type: "FollowUpForm" as const, values: { "a/b": "local", constructor: "safe-local" } } }, block: { evidenceFilter: "all" } };
const restored = { nodes: { form: { type: "FollowUpForm" as const, values: { "a/b": "remote", sibling: "keep", constructor: "safe-remote" } } }, block: { evidenceFilter: "all" } };
expect(mergeDirty(restored, local, ["/nodes/form/values/a~1b", "/nodes/form/values/constructor"]).nodes.form).toMatchObject({ values: { "a/b": "local", sibling: "keep", constructor: "safe-local" } });
});
});
@@ -0,0 +1,209 @@
import type { Spec } from "@json-render/core";
import type { AgentApiClient } from "../api/client";
import { AnswerViewStateApiError, type AnswerViewStateRecord, type AnswerViewStateWrite } from "../api/answer-view-state";
import { normalizeAnswerViewState, type AnswerViewState } from "../answer-ui/answer-view-state";
export type AnswerViewSaveStatus = "loading" | "saving" | "saved" | "unsaved" | "conflict";
export type AnswerViewSnapshot = {
state: AnswerViewState; status: AnswerViewSaveStatus; error?: string;
};
type PendingWrite = { payload: AnswerViewStateWrite; change: number };
export type AnswerViewEntry = {
readonly sessionId: string; readonly messageId: string; readonly blockId: string;
spec: Spec; snapshot: AnswerViewSnapshot; remote?: AnswerViewStateRecord;
dirty: Map<string, number>; change: number; listeners: Set<() => void>;
loading?: Promise<void>; saving?: Promise<void>; pending?: PendingWrite;
conflict?: AnswerViewStateRecord; timer?: ReturnType<typeof setTimeout>;
started: boolean; released: boolean;
};
// This cache belongs to one authenticated workbench/project, never to the module.
export class AnswerViewStateScope {
private entries = new Map<string, AnswerViewEntry>();
private disposed = false;
private invalidIdentity?: string;
private leases = 0;
constructor(readonly scope: string, private readonly client: Pick<AgentApiClient, "getAnswerViewState" | "putAnswerViewState">) {}
retain() { this.leases++; }
release() { this.leases--; queueMicrotask(() => { if (this.leases === 0) this.dispose(); }); }
dispose = () => {
this.disposed = true;
for (const entry of this.entries.values()) { clearTimeout(entry.timer); entry.listeners.clear(); }
this.entries.clear();
};
entry(sessionId: string, messageId: string, blockId: string, spec: Spec): AnswerViewEntry {
const key = JSON.stringify([this.scope, sessionId, messageId, blockId]);
let entry = this.entries.get(key);
if (!entry) {
entry = { sessionId, messageId, blockId, spec, snapshot: { state: normalizeAnswerViewState(spec), status: this.invalidIdentity ? "unsaved" : "loading", error: this.invalidIdentity },
dirty: new Map(), change: 0, listeners: new Set(), started: false, released: false };
this.entries.set(key, entry);
}
entry.spec = spec;
return entry;
}
subscribe(entry: AnswerViewEntry, listener: () => void, enabled = true) {
entry.listeners.add(listener);
entry.released = false;
if (enabled && !entry.started) { entry.started = true; void this.restore(entry); }
return () => {
entry.listeners.delete(listener);
if (!entry.listeners.size) {
entry.released = true;
clearTimeout(entry.timer);
// Preserve the captured entry and client when the active session changes.
if (entry.started && !entry.snapshot.error && !entry.conflict) void this.save(entry);
}
};
}
update(entry: AnswerViewEntry, state: AnswerViewState, paths: string[]) {
if (this.disposed || !paths.length) return;
entry.change++;
for (const path of paths) entry.dirty.set(path, entry.change);
this.publish(entry, { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, state), status: entry.conflict ? "conflict" : entry.snapshot.status === "unsaved" ? "unsaved" : entry.loading ? "loading" : entry.saving ? "saving" : "unsaved" });
if (!entry.started) return;
// One bounded re-read on the first user edit can recover a final-message save race.
if (!entry.remote && !entry.loading && entry.change === 1) { void this.restore(entry); return; }
if (entry.remote && !entry.pending && !entry.conflict && !entry.saving) this.schedule(entry);
}
retry(entry: AnswerViewEntry) {
if (this.disposed || !entry.started || entry.conflict) return;
if (!entry.remote) void this.restore(entry);
else void this.save(entry);
}
reload(entry: AnswerViewEntry) {
if (this.disposed) return;
// The UI explicitly confirms replacing local edits before calling this.
entry.dirty.clear(); entry.pending = undefined;
const current = entry.conflict;
entry.conflict = undefined;
if (current) {
entry.remote = current;
this.publish(entry, { state: normalizeAnswerViewState(entry.spec, current), status: "saved" });
} else void this.restore(entry);
}
resave(entry: AnswerViewEntry) {
if (this.disposed || !entry.conflict) return;
entry.remote = entry.conflict;
entry.conflict = undefined; entry.pending = undefined;
entry.snapshot = { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, entry.snapshot.state) };
// Explicit intent: keep every field in the visible local draft.
entry.change++;
for (const path of statePaths(entry.snapshot.state)) entry.dirty.set(path, entry.change);
void this.save(entry);
}
private publish(entry: AnswerViewEntry, snapshot: AnswerViewSnapshot) {
if (this.disposed) return;
entry.snapshot = snapshot;
entry.listeners.forEach((listener) => listener());
}
private fail(entry: AnswerViewEntry, error: unknown) {
if (this.disposed) return;
if (error instanceof AnswerViewStateApiError && error.code === "ANSWER_VIEW_IDENTITY_CHANGED") {
this.invalidIdentity = error.message;
for (const cached of this.entries.values()) {
cached.dirty.clear(); cached.pending = undefined; cached.remote = undefined; cached.conflict = undefined;
this.publish(cached, { state: normalizeAnswerViewState(cached.spec), status: "unsaved", error: error.message });
}
this.dispose();
} else this.publish(entry, { ...entry.snapshot, status: "unsaved", error: errorMessage(error) });
}
private schedule(entry: AnswerViewEntry) {
clearTimeout(entry.timer);
entry.timer = setTimeout(() => void this.save(entry), 500);
}
private async restore(entry: AnswerViewEntry) {
if (this.disposed || !entry.started || entry.loading || entry.saving) return;
this.publish(entry, { ...entry.snapshot, status: "loading", error: undefined });
const task = (async () => {
try {
const remote = await this.client.getAnswerViewState(entry.sessionId, entry.messageId, entry.blockId);
if (this.disposed) return;
checkIdentity(entry, remote);
entry.remote = remote;
const restored = normalizeAnswerViewState(entry.spec, remote);
const state = normalizeAnswerViewState(entry.spec, mergeDirty(restored, entry.snapshot.state, entry.dirty.keys()));
this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" });
} catch (error) {
this.fail(entry, error);
}
})();
entry.loading = task;
await task;
entry.loading = undefined;
if (!this.disposed && entry.remote && entry.dirty.size) {
if (entry.released) void this.save(entry); else this.schedule(entry);
}
}
private async save(entry: AnswerViewEntry) {
clearTimeout(entry.timer);
if (this.disposed || !entry.started || entry.loading || entry.saving || entry.conflict || !entry.remote || !entry.dirty.size) return;
const pending = entry.pending ?? {
payload: { message_id: entry.messageId, block_id: entry.blockId, schema_digest: entry.remote.schema_digest,
expected_revision: entry.remote.revision, request_id: crypto.randomUUID(),
nodes: structuredClone(entry.snapshot.state.nodes), block: { ...entry.snapshot.state.block } },
change: entry.change,
};
if (new TextEncoder().encode(JSON.stringify(pending.payload)).byteLength > 32 * 1024) {
this.publish(entry, { ...entry.snapshot, status: "unsaved", error: "交互状态超过 32 KiB,当前草稿仅保留在内存中。" });
return;
}
entry.pending = pending;
this.publish(entry, { ...entry.snapshot, status: "saving", error: undefined });
let succeeded = false;
const task = (async () => {
try {
const remote = await this.client.putAnswerViewState(entry.sessionId, pending.payload);
if (this.disposed) return;
checkIdentity(entry, remote);
entry.remote = remote; entry.pending = undefined;
for (const [path, change] of entry.dirty) if (change <= pending.change) entry.dirty.delete(path);
const state = normalizeAnswerViewState(entry.spec, mergeDirty(normalizeAnswerViewState(entry.spec, remote), entry.snapshot.state, entry.dirty.keys()));
this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" });
succeeded = true;
} catch (error) {
if (error instanceof AnswerViewStateApiError && error.status === 409 && error.current
&& error.current.message_id === entry.messageId && error.current.block_id === entry.blockId) {
entry.conflict = error.current;
this.publish(entry, { ...entry.snapshot, status: "conflict", error: error.code === "ANSWER_VIEW_SCHEMA_CHANGED"
? "回答内容已更新,当前草稿尚未保存。" : "另一个窗口已更新交互状态,当前草稿尚未保存。" });
} else this.fail(entry, error);
}
})();
entry.saving = task;
await task;
entry.saving = undefined;
if (!this.disposed && succeeded && entry.dirty.size) {
if (entry.released) void this.save(entry); else this.schedule(entry);
}
}
}
function checkIdentity(entry: AnswerViewEntry, remote: AnswerViewStateRecord) {
if (remote.message_id !== entry.messageId || remote.block_id !== entry.blockId) throw new Error("交互状态身份不匹配,请重试");
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "交互状态未保存,请重试";
}
function statePaths(state: AnswerViewState) {
return ["/block/evidenceFilter", ...Object.keys(state.nodes).map((key) => `/nodes/${key}`)];
}
export function mergeDirty(restored: AnswerViewState, local: AnswerViewState, paths: Iterable<string>): AnswerViewState {
const result = structuredClone(restored);
for (const path of paths) {
const keys = path.split("/").slice(1).map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
if (!keys.length || !["nodes", "block"].includes(keys[0])) continue;
let source: unknown = local;
let target = result as unknown as Record<string, unknown>;
for (const key of keys.slice(0, -1)) {
source = source && typeof source === "object" && Object.hasOwn(source, key) ? (source as Record<string, unknown>)[key] : undefined;
if (!Object.hasOwn(target, key) || !target[key] || typeof target[key] !== "object") {
Object.defineProperty(target, key, { value: {}, enumerable: true, configurable: true, writable: true });
}
target = target[key] as Record<string, unknown>;
}
const field = keys.at(-1)!;
const value = source && typeof source === "object" && Object.hasOwn(source, field) ? (source as Record<string, unknown>)[field] : undefined;
if (value === undefined) delete target[field]; else Object.defineProperty(target, field, { value: structuredClone(value), enumerable: true, configurable: true, writable: true });
}
return result;
}
@@ -0,0 +1,32 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import type { Spec } from "@json-render/core";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state";
import { AnswerViewStateScope } from "./answer-view-state-scope";
import { useAnswerViewState } from "./use-answer-view-state";
it("keeps rerenders debounced, flushes a switched session and retains drafts across component remounts", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
const spec: Spec = { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: { fields: [{ name: "note", kind: "text" }] } } } };
const remote = (session: string): AnswerViewStateRecord => ({ version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 0,
schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: session } } }, block: { evidenceFilter: "all" } });
const client = { getAnswerViewState: vi.fn(async (session: string) => remote(session)),
putAnswerViewState: vi.fn(async (session: string, state: AnswerViewStateWrite) => ({ ...remote(session), revision: state.expected_revision + 1, nodes: state.nodes })) };
const scope = new AnswerViewStateScope("actor:project", client);
let current!: ReturnType<typeof useAnswerViewState>;
function Probe({ session }: { session: string }) { current = useAnswerViewState(scope, session, "message", "block", spec); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update({ ...current.state, nodes: { form: { type: "FollowUpForm", values: { note: "draft" } } } }, ["/nodes/form/values/note"]));
await act(async () => root.render(<Probe session="first" />));
expect(client.putAnswerViewState).not.toHaveBeenCalled();
await act(async () => root.render(<Probe session="second" />));
expect(client.putAnswerViewState.mock.calls[0][0]).toBe("first");
expect(current.state.nodes.form).toMatchObject({ values: { note: "second" } });
await act(async () => root.render(<Probe session="first" />));
expect(current.state.nodes.form).toMatchObject({ values: { note: "draft" } });
expect(client.getAnswerViewState).toHaveBeenCalledTimes(2);
} finally { await act(async () => root.unmount()); scope.dispose(); vi.useRealTimers(); }
});
@@ -0,0 +1,16 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import type { Spec } from "@json-render/core";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
import { AnswerViewPersistenceContext } from "./answer-view-persistence-context";
export function useAnswerViewPersistence() { return useContext(AnswerViewPersistenceContext); }
export function useAnswerViewState(scope: AnswerViewStateScope, sessionId: string, messageId: string, blockId: string, spec: Spec, enabled = true) {
const entry = scope.entry(sessionId, messageId, blockId, spec);
const snapshot = useSyncExternalStore(
useCallback((listener: () => void) => scope.subscribe(entry, listener, enabled), [scope, entry, enabled]),
() => entry.snapshot,
() => entry.snapshot,
);
return { ...snapshot, update: scope.update.bind(scope, entry), retry: () => scope.retry(entry),
reload: () => scope.reload(entry), resave: () => scope.resave(entry) };
}
@@ -0,0 +1,18 @@
import { afterEach, expect, it, vi } from "vitest";
import { fetchSpeechVoiceCatalog } from "./speech-voice-catalog";
afterEach(() => vi.unstubAllGlobals());
it("preserves a service failure message for a useful retry explanation", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "音色列表暂时不可用,请稍后重试" }), { status: 503 })));
await expect(fetchSpeechVoiceCatalog("/api/tts/edge/voices")).rejects.toThrow("音色列表暂时不可用,请稍后重试");
});
it("explains an incompatible cached catalog or an HTML response without exposing parser details", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ voices: [], defaultVoice: "old" })))
.mockResolvedValueOnce(new Response("<!doctype html>"));
vi.stubGlobal("fetch", fetchMock);
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(fetchSpeechVoiceCatalog("/api/tts/edge/voices")).rejects.toThrow("音色列表格式不匹配,请刷新页面后重试");
}
});
@@ -0,0 +1,14 @@
import { speechVoiceCatalogSchema } from "@/shared/tts/voices";
export async function fetchSpeechVoiceCatalog(url: string) {
const response = await fetch(url, { cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = payload && typeof payload === "object" && "error" in payload
&& typeof payload.error === "string" ? payload.error : "音色服务暂时不可用,请稍后重试";
throw new Error(message);
}
const catalog = speechVoiceCatalogSchema.safeParse(payload);
if (!catalog.success) throw new Error("音色列表格式不匹配,请刷新页面后重试");
return catalog.data;
}
+49
View File
@@ -0,0 +1,49 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { getSelectedSpeechVoice, getSpeechVoiceDescription, getSpeechVoiceName, setSelectedSpeechVoice, SPEECH_VOICE_STORAGE_KEY, useSelectedSpeechVoice } from "./speech-voices";
beforeEach(() => setSelectedSpeechVoice(""));
it("shares a saved voice across selectors and restores it from browser storage", () => {
const first = renderHook(useSelectedSpeechVoice);
const second = renderHook(useSelectedSpeechVoice);
act(() => setSelectedSpeechVoice("zh-CN-YunyangNeural"));
expect(first.result.current).toBe("zh-CN-YunyangNeural");
expect(second.result.current).toBe("zh-CN-YunyangNeural");
expect(localStorage.getItem(SPEECH_VOICE_STORAGE_KEY)).toBe("zh-CN-YunyangNeural");
act(() => {
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "zh-CN-XiaoyiNeural");
window.dispatchEvent(new StorageEvent("storage", { key: SPEECH_VOICE_STORAGE_KEY }));
});
expect(first.result.current).toBe("zh-CN-XiaoyiNeural");
first.unmount(); second.unmount();
});
it("ignores malformed stored values and resets to the server default", () => {
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "unsafe<xml>");
expect(getSelectedSpeechVoice()).toBe("");
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "en-US-AriaNeural");
expect(getSelectedSpeechVoice()).toBe("");
setSelectedSpeechVoice("zh-CN-YunyangNeural");
setSelectedSpeechVoice("");
expect(getSelectedSpeechVoice()).toBe("");
expect(localStorage.getItem(SPEECH_VOICE_STORAGE_KEY)).toBeNull();
});
it("provides Chinese labels and gender descriptions for Mandarin voices", () => {
const voice = { id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN" as const, gender: "Male" as const };
expect(getSpeechVoiceName(voice)).toBe("云扬");
expect(getSpeechVoiceDescription(voice)).toContain("男声");
expect(getSpeechVoiceDescription(voice)).toContain("普通话");
});
it("keeps the choice for this page if browser storage cannot be written", () => {
const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota exceeded"); });
try {
setSelectedSpeechVoice("zh-CN-YunyangNeural");
expect(getSelectedSpeechVoice()).toBe("zh-CN-YunyangNeural");
} finally {
write.mockRestore();
setSelectedSpeechVoice("");
}
});
+52
View File
@@ -0,0 +1,52 @@
import { useSyncExternalStore } from "react";
import { speechVoiceIdSchema, type SpeechVoice } from "@/shared/tts/voices";
export const SPEECH_VOICE_STORAGE_KEY = "tjwater.agent.speech-voice";
const VOICE_CHANGE_EVENT = "tjwater:speech-voice-change";
let memoryVoice = "";
let useMemoryPreference = false;
export function getSelectedSpeechVoice(): string {
let value: string;
try { value = useMemoryPreference ? memoryVoice : localStorage.getItem(SPEECH_VOICE_STORAGE_KEY) ?? ""; }
catch { value = memoryVoice; }
return speechVoiceIdSchema.safeParse(value).success ? value : "";
}
export function setSelectedSpeechVoice(value: string) {
const voice = speechVoiceIdSchema.safeParse(value).success ? value : "";
memoryVoice = voice;
try {
if (voice) localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, voice);
else localStorage.removeItem(SPEECH_VOICE_STORAGE_KEY);
useMemoryPreference = false;
} catch { useMemoryPreference = true; }
window.dispatchEvent(new Event(VOICE_CHANGE_EVENT));
}
function subscribe(onChange: () => void) {
window.addEventListener(VOICE_CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(VOICE_CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
export function useSelectedSpeechVoice() {
return useSyncExternalStore(subscribe, getSelectedSpeechVoice, () => "");
}
const chineseNames: Record<string, string> = {
"zh-CN-XiaoxiaoNeural": "晓晓", "zh-CN-XiaoyiNeural": "晓伊",
"zh-CN-YunxiNeural": "云希", "zh-CN-YunyangNeural": "云扬",
"zh-CN-YunjianNeural": "云健", "zh-CN-YunxiaNeural": "云夏"
};
export function getSpeechVoiceName(voice: SpeechVoice) {
return chineseNames[voice.id] ?? voice.id.replace(`${voice.locale}-`, "").replace(/Neural$/, "");
}
export function getSpeechVoiceDescription(voice: SpeechVoice) {
return `普通话 · ${voice.gender === "Female" ? "女声" : "男声"}`;
}
+8
View File
@@ -18,6 +18,14 @@ describe("Agent 语音文本处理", () => {
expect(chunks.join("")).toContain("乙"); expect(chunks.join("")).toContain("乙");
}); });
it("短句后接长句时每段仍不超过接口的 520 字限制", () => {
const text = `${"甲".repeat(100)}。${"乙".repeat(450)}。`;
const chunks = splitSpeechTextIntoChunks(text);
expect(chunks).toHaveLength(2);
expect(chunks.every((chunk) => chunk.length <= 520)).toBe(true);
expect(chunks.join("")).toBe(text);
});
it("移除会干扰朗读的 Markdown 标记", () => { it("移除会干扰朗读的 Markdown 标记", () => {
expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情"); expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情");
}); });
+1 -6
View File
@@ -1,6 +1,5 @@
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim(); const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
const MAX_SPEECH_CHUNK_LENGTH = 520; const MAX_SPEECH_CHUNK_LENGTH = 520;
const MIN_SPEECH_CHUNK_LENGTH = 180;
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g; const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
export type AgentSpeechState = "idle" | "loading" | "playing" | "paused"; export type AgentSpeechState = "idle" | "loading" | "playing" | "paused";
@@ -93,11 +92,7 @@ export function splitSpeechTextIntoChunks(text: string): string[] {
} }
const candidate = currentChunk ? `${currentChunk}${segment}` : segment; const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
if ( if (currentChunk && candidate.length > MAX_SPEECH_CHUNK_LENGTH) {
currentChunk &&
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
) {
flush(); flush();
currentChunk = segment; currentChunk = segment;
continue; continue;
@@ -1,3 +1,4 @@
import { webMercatorToLngLat } from "@/features/map/core";
export type TrustedMapAction = export type TrustedMapAction =
| { | {
type: "locate_features"; type: "locate_features";
@@ -131,12 +132,7 @@ function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, num
return [x, y]; return [x, y];
} }
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
function stringValue(value: unknown) { function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined; return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -180,7 +176,6 @@ const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([ const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS ...SUPPLY_SOURCE_IDS
]); ]);
const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [ const LOCATE_ID_PARAM_KEYS = [
"feature_ids", "feature_ids",
+7
View File
@@ -0,0 +1,7 @@
const WEB_MERCATOR_RADIUS = 6378137;
export function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
+1
View File
@@ -64,3 +64,4 @@ export {
export { MapScaleLine } from "./components/scale-line"; export { MapScaleLine } from "./components/scale-line";
export { MapToolbar, type MapToolbarItem } from "./components/toolbar"; export { MapToolbar, type MapToolbarItem } from "./components/toolbar";
export { MapZoom } from "./components/zoom"; export { MapZoom } from "./components/zoom";
export { webMercatorToLngLat } from "./coordinates";
@@ -1,66 +1,77 @@
import { z } from "zod"; import { z } from "zod";
import type { FeatureCollection, Geometry } from "geojson"; import type { FeatureCollection, Geometry } from "geojson";
import { parseFrontendChart } from "@/features/agent"; import {
import { investigationMapTargetSchema } from "./investigation-report"; presentationCommandSchema,
presentationOverlayDataSchema,
type PresentationCommand
} from "@/features/agent";
const id = z.string().trim().min(1).max(128); export const investigationOverlayDataSchema = presentationOverlayDataSchema;
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null); export const investigationPresentationEventSchema = z
const position = z.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]).rest(z.number().finite()).refine((value) => value.length <= 3); .object({
const line = z.array(position).min(2).max(20000); task_id: z.string().uuid(),
const ring = z.array(position).min(4).max(20000).refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]); run_id: z.string().uuid(),
const geometrySchema = z.discriminatedUnion("type", [ execution_generation: z.number().int().positive(),
z.object({ type: z.literal("Point"), coordinates: position }).strict(), command: presentationCommandSchema.refine(
z.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) }).strict(), (command) => command.action !== "open_chart" || command.evidence_refs.length > 0,
z.object({ type: z.literal("LineString"), coordinates: line }).strict(), "task charts require evidence references"
z.object({ type: z.literal("MultiLineString"), coordinates: z.array(line).min(1).max(20000) }).strict(), )
z.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) }).strict(), })
z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000) }).strict() .strict();
]).refine((geometry) => { export const investigationPresentationCursorSchema = z
const count = (coordinates: unknown): number => !Array.isArray(coordinates) ? 0 .object({
: typeof coordinates[0] === "number" ? 1 : coordinates.reduce<number>((total, item) => total + count(item), 0); task_id: z.string().uuid(),
return count(geometry.coordinates) <= 20000; execution_generation: z.number().int().positive(),
}); active_run: z
export const investigationOverlayDataSchema = z.object({ .object({
type: z.literal("FeatureCollection"), run_id: z.string().uuid(),
features: z.array(z.object({ generation: z.number().int().positive(),
type: z.literal("Feature"), status: z.enum([
id: z.union([z.string(), z.number().finite()]).optional(), "queued",
properties: z.record(z.string(), z.unknown()).nullable(), "claimed",
geometry: geometrySchema "running",
}).strict()).min(1).max(1000) "succeeded",
}).strict(); "failed",
"timed_out",
const overlaySchema = z.object({ "cancelled"
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200), ])
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema })
}).strict(); .strict()
const commandSchema = z.discriminatedUnion("action", [ .nullable(),
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id, event_cursor: z.number().int().nonnegative()
feature_ids: z.array(id).min(1).max(100), effect: z.enum(["steady", "pulse"]) }).strict(), })
z.object({ action: z.literal("clear_highlights") }).strict(), .strict();
z.object({ action: z.literal("open_chart"), chart_id: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(), export type InvestigationPresentationCommand = PresentationCommand;
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
overlaySchema,
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
]).superRefine((command, ctx) => {
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (command.data.features.some((feature) => feature.geometry.type !== "Point"
|| (feature.properties?.weight !== undefined && (typeof feature.properties.weight !== "number" || !Number.isFinite(feature.properties.weight) || feature.properties.weight < 0)))) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
export const investigationPresentationEventSchema = z.object({
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
});
export const investigationPresentationCursorSchema = z.object({
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
event_cursor: z.number().int().nonnegative()
}).strict();
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>; export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>; export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean }; export type InvestigationOverlay = Extract<
InvestigationPresentationCommand,
{ action: "set_overlay" }
> & { visible: boolean };
export type InvestigationOverlayData = FeatureCollection<Geometry>; export type InvestigationOverlayData = FeatureCollection<Geometry>;
export const investigationPresentationHistoryPageSchema = z
.object({
task_id: z.string().uuid(),
event_cursor: z.number().int().nonnegative(),
items: z
.array(
z
.object({
event_seq: z.number().int().positive(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
occurred_at: z.string().datetime({ offset: true }),
command: z.unknown()
})
.strict()
)
.max(200),
next_after: z.number().int().positive().nullable()
})
.strict();
export type InvestigationPresentationHistoryEvent = z.infer<
typeof investigationPresentationHistoryPageSchema
>["items"][number] & { task_id: string };
export const presentationEventKey = (taskId: string, sequence: number) =>
`task:${taskId}:event:${sequence}`;
@@ -59,6 +59,15 @@ 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", () => {
const value = investigationReportSchema.parse({ ...base, diagram: {
title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]",
node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" },
{ node_id: "B", label: "人工复核", status: "waiting_human" },
{ node_id: "C", label: "下一步" }]
} });
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["waiting", "waiting_human", undefined]);
});
it.each([ it.each([
{ outcome: "other" }, { outcome: "other" },
{ stage: "interim" }, { stage: "interim" },
@@ -34,7 +34,7 @@ export const investigationDiagramSchema = 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_human", "failed", "skipped", "cancelled"]).nullish(), status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
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([])
@@ -1,9 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
fetchOperationalTaskEvidenceSnapshot, fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskPresentationHistory,
fetchOperationalTaskNotifications, fetchOperationalTaskNotifications,
fetchOperationalTaskRuns, fetchOperationalTaskRuns,
parseOperationalTask, parseOperationalTask,
parseOperationalTaskSummary,
parseOperationalTaskNotification parseOperationalTaskNotification
} from "./operational-task-client"; } from "./operational-task-client";
@@ -72,6 +74,36 @@ describe("operational task contract", () => {
expect(url.searchParams.get("offset")).toBe("0"); expect(url.searchParams.get("offset")).toBe("0");
}); });
it("accepts the Agent instance owner emitted for task runs", () => {
const {
latest_plan: _latestPlan,
latest_report: _latestReport,
evidence: _evidence,
actions: _actions,
permissions: _permissions,
...summary
} = task();
const run = {
run_id: id, task_id: id, generation: 2, execution_request_id: id,
status: "running", revision: 1, agent_session_id: "session",
agent_instance_id: "supply-agent-1", lease_expires_at: time, heartbeat_at: time,
started_at: time, finished_at: null, failure_code: null, failure_detail: null,
dispatch_attempts: 1, next_dispatch_at: time, created_at: time, updated_at: time
};
expect(parseOperationalTaskSummary({ ...summary, active_run: run }).active_run)
.toHaveProperty("agent_instance_id", "supply-agent-1");
});
it("accepts only system-data work order provenance", () => {
const snapshot = {
work_order_id: id, revision: 1, title: "投诉工单", summary: "待核查", status: "open",
priority: "normal", assignee: null, scheduled_at: null, work_order_type: "water_quality_complaint",
location: "供水小区", scenario_code: null, origin: "system_data", updated_at: time
};
expect(parseOperationalTask({ ...task(), work_order_snapshot: snapshot }).work_order_snapshot?.origin).toBe("system_data");
expect(() => parseOperationalTask({ ...task(), work_order_snapshot: { ...snapshot, origin: "synthetic" } })).toThrow();
});
it("accepts execution generation on both plan and report", () => { it("accepts execution generation on both plan and report", () => {
const parsed = parseOperationalTask(task()); const parsed = parseOperationalTask(task());
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2); expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
@@ -190,3 +222,22 @@ it("reads persisted MCP evidence without expanding task access-request scopes",
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow(); await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
}); });
it("reads all presentation pages at one boundary and rejects mismatched or stalled cursors", async () => {
const record = (event_seq: number) => ({ event_seq, run_id: id, execution_generation: 1, occurred_at: time, command: { action: "clear_highlights" } });
const fetch = vi.fn().mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(7)], next_after: 7 }))
.mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(20)], next_after: null }));
vi.stubGlobal("fetch", fetch);
expect((await fetchOperationalTaskPresentationHistory(id, 30)).map((item) => item.event_seq)).toEqual([7, 20]);
expect(fetch.mock.calls.map(([url]) => new URL(url).searchParams.get("after"))).toEqual(["0", "7"]);
expect(fetch.mock.calls.every(([url, init]) => new URL(url).searchParams.get("through") === "30" && !init.body)).toBe(true);
for (const page of [
{ task_id: id, event_cursor: 29, items: [], next_after: null },
{ task_id: id, event_cursor: 30, items: [], next_after: 7 },
{ task_id: "50000000-0000-4000-8000-000000000001", event_cursor: 30, items: [], next_after: null }
]) {
vi.stubGlobal("fetch", vi.fn(async () => Response.json(page)));
await expect(fetchOperationalTaskPresentationHistory(id, 30)).rejects.toThrow();
}
});
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report"; import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema } from "./investigation-presentation"; import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client"; import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -191,6 +191,7 @@ const taskPermissionsSchema = z.object({
can_execute_work_orders: z.boolean(), can_execute_work_orders: z.boolean(),
can_decide_commands: z.boolean(), can_decide_commands: z.boolean(),
can_execute_commands: z.boolean(), can_execute_commands: z.boolean(),
can_answer_questions: z.boolean().optional(),
can_decide_access: z.boolean().default(false) can_decide_access: z.boolean().default(false)
}).strict(); }).strict();
const externalWorkOrderActivitySchema = z.object({ const externalWorkOrderActivitySchema = z.object({
@@ -221,9 +222,25 @@ const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
scenario_code: z.string().min(1).nullable(), scenario_code: z.string().min(1).nullable(),
feedback_requirements: z.array(z.string().min(1)).default([]), feedback_requirements: z.array(z.string().min(1)).default([]),
activity: z.array(externalWorkOrderActivitySchema).default([]), activity: z.array(externalWorkOrderActivitySchema).default([]),
origin: z.literal("synthetic"), origin: z.literal("system_data"),
updated_at: workbenchDateTimeSchema updated_at: workbenchDateTimeSchema
}).strict()); }).strict());
const taskQuestionAnswerSchema = z.object({ question_id: z.string().min(1).max(128),
selected_option_ids: z.array(z.string().min(1).max(128)).max(12), custom_text: z.string().max(2000) }).strict();
const taskQuestionRequestSchema = z.object({
question_request_id: z.string().uuid(), task_id: z.string().uuid(), run_id: z.string().uuid(),
execution_generation: z.number().int().positive(), source_session_id: z.string(),
status: z.enum(["pending", "answered", "cancelled"]), version: z.number().int().positive(),
created_at: workbenchDateTimeSchema, answered_at: workbenchDateTimeSchema.nullable(), answered_by: z.string().nullable(),
questions: z.array(z.object({ question_id: z.string().min(1).max(128), title: z.string().min(1).max(200), body: z.string().min(1).max(2000),
options: z.array(z.object({ id: z.string().min(1).max(128), label: z.string().min(1).max(200) }).strict()).max(12),
mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
answers: z.array(taskQuestionAnswerSchema).max(3)
}).strict();
export type TaskQuestionRequest = z.infer<typeof taskQuestionRequestSchema>;
export type TaskQuestionAnswer = z.infer<typeof taskQuestionAnswerSchema>;
export type AnswerTaskQuestion = (questionId: string, version: number, answers: TaskQuestionAnswer[], requestId: string) => Promise<unknown>;
const operationalTaskSchema = taskSummarySchema.extend({ const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(), latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(), latest_report: reportRevisionSchema.nullable(),
@@ -231,6 +248,7 @@ const operationalTaskSchema = taskSummarySchema.extend({
evidence: z.array(evidenceSchema), evidence: z.array(evidenceSchema),
actions: z.array(actionSchema), actions: z.array(actionSchema),
access_requests: z.array(accessRequestSchema).default([]), access_requests: z.array(accessRequestSchema).default([]),
questions: z.array(taskQuestionRequestSchema).optional(),
permissions: taskPermissionsSchema, permissions: taskPermissionsSchema,
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null) work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
}).strict(); }).strict();
@@ -252,7 +270,7 @@ const notificationSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
description: z.string().min(1), description: z.string().min(1),
lifecycle_status: z.enum(["active", "resolved"]).default("active"), lifecycle_status: z.enum(["active", "resolved"]).default("active"),
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"), subject_type: z.enum(["task", "run", "access_request", "action", "question"]).default("task"),
subject_id: z.string().uuid().nullable().default(null), subject_id: z.string().uuid().nullable().default(null),
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(), report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
resolved_at: workbenchDateTimeSchema.nullable().default(null), resolved_at: workbenchDateTimeSchema.nullable().default(null),
@@ -451,6 +469,39 @@ export async function fetchOperationalTaskPresentationCursor(taskId: string, get
)); ));
} }
export async function fetchOperationalTaskPresentationHistory(
taskId: string,
through: number,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
) {
const items: InvestigationPresentationHistoryEvent[] = [];
let after = 0;
while (true) {
signal?.throwIfAborted();
const page = investigationPresentationHistoryPageSchema.parse(
await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-events?after=${after}&through=${through}&limit=100`,
getAccessToken,
{ signal }
)
);
if (page.task_id !== taskId || page.event_cursor !== through)
throw new Error("调查展示历史与所选任务或游标不一致");
let previous = after;
for (const item of page.items) {
if (item.event_seq <= previous || item.event_seq > through)
throw new Error("调查展示历史顺序不合法");
previous = item.event_seq;
items.push({ ...item, task_id: taskId });
}
if (page.next_after === null) return items;
if (page.next_after !== previous || previous <= after)
throw new Error("调查展示历史分页没有前进");
after = previous;
}
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) { export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) {
return operationalTaskSchema.parse(await operationalTaskRequest( return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks", "/operational-tasks",
@@ -572,3 +623,13 @@ export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evide
export function createOperationalRequestId() { export function createOperationalRequestId() {
return crypto.randomUUID(); return crypto.randomUUID();
} }
export async function answerOperationalTaskQuestion(taskId: string, questionId: string,
version: number, answers: TaskQuestionAnswer[], requestId: string, getAccessToken?: WorkbenchAccess) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/questions/${encodeURIComponent(questionId)}/answer`,
getAccessToken, { method: "POST", body: JSON.stringify({ request_id: requestId,
expected_version: version, answers: z.array(taskQuestionAnswerSchema).max(3).parse(answers) }) }
));
}
@@ -130,6 +130,35 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender }; return { ...props, rerender };
} }
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
run_id: "70000000-0000-4000-8000-000000000002", source_session_id: "s", execution_generation: taskBase.execution_generation,
status: "pending", version: 1, created_at: taskBase.created_at, answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "sampling", title: "是否有采样记录", body: "请补充采样信息", options: [], mode: "single", allow_custom: true, required: true }] }],
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段", impact: "待核验",
required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }] } }
};
const props = taskPanel(task);
const questions = screen.getByRole("region", { name: "调查提问" });
questions.scrollIntoView = vi.fn();
fireEvent.click(screen.getByRole("button", { name: "回答当前调查问题" }));
expect(questions).toHaveFocus();
expect(questions.scrollIntoView).toHaveBeenCalled();
expect(props.onContinue).not.toHaveBeenCalled();
});
it("labels free discussion separately when no fixed question is pending", () => {
const props = taskPanel({ ...taskBase, latest_report: { ...taskBase.latest_report!, report: {
...taskBase.latest_report!.report, data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段",
impact: "待核验", required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }]
} } });
expect(screen.queryByRole("button", { name: "补充事实" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "讨论此缺口" }));
expect(props.onContinue).toHaveBeenCalled();
});
it("references the selected report round instead of the latest task round, including legacy reports", () => { it("references the selected report round instead of the latest task round, including legacy reports", () => {
const task = { ...taskBase, execution_generation: 2 }; const task = { ...taskBase, execution_generation: 2 };
const { rerender, ...props } = taskPanel(task); const { rerender, ...props } = taskPanel(task);
@@ -191,7 +220,7 @@ it("opens the execution record bound to the selected report round", async () =>
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ })); fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible(); expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true })); fireEvent.click(screen.getByRole("button", { name: "执行记录" }));
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({ await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
executionGeneration: 1, executionGeneration: 1,
@@ -75,6 +75,7 @@ type AgentExecutionPlanPanelProps = {
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>; onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>; onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void; onRefresh: () => void;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>; onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void; onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null; focusActionId?: string | null;
@@ -94,11 +95,12 @@ export function AgentExecutionPlanPanel({
onLoadEvidenceSnapshot, onLoadEvidenceSnapshot,
onRefresh, onRefresh,
onLoadReports, onLoadReports,
onAnswerQuestion,
onReportViewed, onReportViewed,
focusActionId focusActionId
}: AgentExecutionPlanPanelProps) { }: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task} if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory} onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />} onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react"; import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context"; import type { WorkbenchAccess } from "../api/request-context";
@@ -148,7 +149,7 @@ export function AnalysisHistorySection({
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge> <StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
</span> </span>
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]"> <span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy} {item.runTypeLabel} · {formatLocalMonthDayTime(item.createdAt)} · {item.createdBy}
</span> </span>
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]"> <span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{item.hasResults ? "已有持久化结果" : "仅有运行记录"} {item.hasResults ? "已有持久化结果" : "仅有运行记录"}
@@ -187,15 +188,7 @@ function statusTone(status: string): StatusTone {
return "neutral"; return "neutral";
} }
function formatHistoryTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "分析成果历史请求失败"; return error instanceof Error ? error.message : "分析成果历史请求失败";
@@ -1,100 +0,0 @@
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
import {
getFeatureInsightMetrics,
getLocalizedFeatureProperties
} from "../utils/feature-properties";
import { FeaturePropertyList } from "./feature-property-list";
type FeatureInsightPanelProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
const metrics = useMemo(() => {
if (!feature) {
return [];
}
return getFeatureInsightMetrics(feature.layer, feature.properties);
}, [feature]);
const propertyEntries = useMemo(() => {
if (!feature) {
return [];
}
return getLocalizedFeatureProperties(feature.properties);
}, [feature]);
return (
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-[var(--surface-divider)]/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
<h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"}
</h2>
</div>
<button
type="button"
aria-label="关闭详情"
title="关闭详情"
onClick={onClose}
className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-sm text-slate-600">
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
</p>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{feature ? (
<>
<div className="grid grid-cols-2 gap-2">
{metrics.map(({ label, value }) => (
<div key={label} className="surface-reading rounded border p-3">
<p className="text-xs text-[var(--ink-muted)]">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
</div>
))}
</div>
<section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
<TriangleAlert size={16} aria-hidden="true" />
Agent 解释
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
</p>
</section>
<section className="mt-4">
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
<div className="mt-2">
<FeaturePropertyList entries={propertyEntries} />
</div>
</section>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
<Target size={22} aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
</p>
</div>
)}
</div>
</aside>
);
}
@@ -1,24 +0,0 @@
type IconButtonProps = {
label: string;
children: React.ReactNode;
onClick?: () => void;
active?: boolean;
};
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,123 @@
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { createInvestigationDevCase } from "../utils/investigation-dev-cases";
import { InvestigationDevTools } from "./investigation-dev-tools";
afterEach(cleanup);
describe("investigation Dev case selection", () => {
it("previews a loaded case in one click without an investigation or separate validation step", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const cases = screen.getByRole("combobox", { name: "JSONL 测试案例" });
expect(cases).toBeEnabled();
await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01"
);
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
"候选管段对比"
);
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("previews data without a map but waits before executing map commands", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady={false}
charts={0}
overlay={null}
activity={[]}
onSample={vi.fn()}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent("地图尚未就绪");
expect(onPreview).toHaveBeenCalledTimes(1);
});
it.each(["invalid_json", "invalid_command", "invalid_chart"] as const)(
"validates %s on execute and never runs a partial batch",
async (id) => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent(
id === "invalid_json" ? "第 2 行" : "第 1 行"
);
expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
}
);
});
@@ -0,0 +1,410 @@
import { useId, useMemo, useRef, useState } from "react";
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import {
investigationDevCases,
type InvestigationDevCaseId
} from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationActionLabels,
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview,
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
task: OperationalTask | null;
mapReady: boolean;
charts: number;
overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
};
export function InvestigationDevTools({
task,
mapReady,
charts,
overlay,
activity,
onSample,
onPreview,
onClear
}: InvestigationDevToolsProps) {
const [open, setOpen] = useState(true);
const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false);
const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
setInput(value);
setCaseId(nextCaseId);
setChecked(null);
setPreview(null);
setMessage(null);
setError(null);
setSelectedRecord(0);
}
function clearLoadedPreview() {
if (!previewActive.current) return;
onClear();
previewActive.current = false;
}
function loadCase(nextCaseId: InvestigationDevCaseId) {
if (running || !mapReady) return;
clearLoadedPreview();
replaceInput(onSample(nextCaseId), nextCaseId);
}
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
function validate() {
try {
const parsed = parseInvestigationDevJsonl(input);
setChecked(parsed);
setError(null);
setMessage(
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
return parsed;
} catch (cause) {
setChecked(null);
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
setMessage(null);
return null;
}
}
async function run() {
if (running || !input.trim()) return;
const parsed = validate();
if (!parsed) return;
if (parsed.commands.length > 0 && !mapReady) {
setError("地图尚未就绪,请稍后再执行本地预览。");
setMessage(null);
return;
}
setRunning(true);
setError(null);
setPreview(parsed);
previewActive.current = true;
try {
await onPreview(parsed, selectedCase?.label ?? "JSONL");
setMessage(
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError"))
setError(cause instanceof Error ? cause.message : "本地展示失败。");
} finally {
setRunning(false);
}
}
function download() {
if (!checked) return;
const url = URL.createObjectURL(
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
anchor.click();
URL.revokeObjectURL(url);
}
return (
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
<Button
type="button"
variant="ghost"
className="h-8 px-0 text-xs"
aria-expanded={open}
aria-controls="investigation-dev-lab"
onClick={() => setOpen(!open)}
>
<FlaskConical size={14} />
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
</Button>
</div>
<p className="break-words text-sm font-semibold text-foreground">
{task ? task.title : "尚未选择调查"}
</p>
<p className="text-muted-foreground">
{task
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
</p>
{task?.latest_report ? (
<p className="text-muted-foreground">
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
{task.latest_report.execution_generation ?? "未知"} 轮
{task.latest_report.execution_generation !== task.execution_generation
? ",上一轮内容"
: ""}
</p>
) : null}
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
{summary.invalid ? (
<p className="text-[var(--status-warning-foreground)]">
{summary.invalid} 个报告界面块校验未通过。
</p>
) : null}
{task && !task.latest_report ? (
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
) : null}
</div>
{open ? (
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
</p>
<FieldSelect
label="JSONL 测试案例"
placeholder="选择案例,立即载入 JSONL"
value={caseId ?? ""}
onValueChange={(value) => {
const nextCase = investigationDevCases.find((item) => item.id === value);
if (nextCase) loadCase(nextCase.id);
}}
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
disabled={!mapReady || running}
contentClassName="workbench-shadcn-surface"
/>
{!mapReady ? (
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
) : null}
{selectedCase ? (
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
预期:{selectedCase.expected}
</p>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。
</p>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!mapReady || running}
onClick={() => loadCase(caseId ?? "combined")}
>
载入合成样例
</Button>
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!task || !summary.blocks || running}
onClick={() => {
if (task) {
clearLoadedPreview();
replaceInput(investigationReportJsonl(task));
}
}}
>
载入当前报告
</Button>
</div>
<div className="space-y-1.5 border-t border-border pt-4">
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
<label htmlFor={inputId} className="text-xs font-medium">
调查展示 JSONL
</label>
<Textarea
id={inputId}
value={input}
disabled={running}
onChange={(event) => replaceInput(event.target.value)}
rows={5}
maxLength={200_000}
spellCheck={false}
className="min-h-28 resize-y font-mono text-[11px] leading-5"
placeholder="每行一条展示指令、界面块或带来源的调查数据"
/>
</div>
<Disclosure
label="JSONL 格式说明"
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
>
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
presentation 执行受控展示,ui_block 预览界面;candidate
等调查数据仅展示,不自动操作地图。
</p>
</Disclosure>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!input.trim() || running}
onClick={validate}
>
<Check size={14} />
校验 JSONL
</Button>
<Button
type="button"
className="text-xs"
disabled={!input.trim() || running}
onClick={() => void run()}
>
<Play size={14} />
{running ? "正在预览" : "执行本地预览"}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
</p>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">03 检查结果</h3>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
disabled={!checked || running}
onClick={download}
>
<Download size={14} />
下载测试 JSONL
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
onClick={() => {
onClear();
previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。");
setError(null);
}}
>
<RotateCcw size={14} />
清除临时展示
</Button>
</div>
</div>
{message ? (
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
{message}
</p>
) : null}
{error ? (
<p
role="alert"
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
>
{error}
</p>
) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div>
) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react"; import { useEffect, useLayoutEffect, 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";
@@ -9,6 +9,10 @@ import {
INVESTIGATION_DIAGRAM_FONT, INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig, investigationDiagramConfig,
investigationDiagramPlugins, investigationDiagramPlugins,
investigationDiagramStates,
investigationDiagramNodeId as getNodeId,
investigationNodeState,
paintInvestigationDiagramStates,
styleInvestigationDiagram styleInvestigationDiagram
} from "../utils/investigation-diagram-style"; } from "../utils/investigation-diagram-style";
@@ -41,33 +45,42 @@ function DiagramUnavailable() {
); );
} }
const getNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
export function InvestigationDiagramView({ export function InvestigationDiagramView({
diagram, diagram,
renderDetails, renderDetails,
onAsk,
running = false, running = false,
onLocate onLocate
}: { }: {
diagram: InvestigationDiagram; diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode; renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
running?: boolean; 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 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)))];
if (!displayedStates.length) displayedStates.push("unknown");
const [fontsReady, setFontsReady] = useState( const [fontsReady, setFontsReady] = useState(
() => typeof document === "undefined" || !document.fonts () => typeof document === "undefined" || !document.fonts
); );
useLayoutEffect(() => {
const root = diagramRoot.current;
if (!root) return;
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
paint();
// Mermaid renders asynchronously. Reapply the latest snapshot when it
// replaces the SVG; observing children avoids reacting to our own styles.
const observer = new MutationObserver(paint);
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, [diagram, running]);
useEffect(() => { useEffect(() => {
if (!document.fonts) return; if (!document.fonts) return;
let active = true; let active = true;
void document.fonts void document.fonts
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid) .load(`500 18px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.catch(() => undefined) .catch(() => undefined)
.then(() => { .then(() => {
if (active) setFontsReady(true); if (active) setFontsReady(true);
@@ -82,6 +95,7 @@ export function InvestigationDiagramView({
核查方向与判断分支。可放大查看,点击节点阅读关联依据。 核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p> </p>
<div <div
ref={diagramRoot}
className="investigation-diagram mt-3" className="investigation-diagram mt-3"
onPointerDownCapture={(event) => { onPointerDownCapture={(event) => {
const node = (event.target as Element).closest?.(".node"); const node = (event.target as Element).closest?.(".node");
@@ -104,6 +118,7 @@ export function InvestigationDiagramView({
> >
{fontsReady ? ( {fontsReady ? (
<MessageResponse <MessageResponse
key={diagram.mermaid}
icons={diagramControlIcons} icons={diagramControlIcons}
plugins={investigationDiagramPlugins} plugins={investigationDiagramPlugins}
mermaid={{ mermaid={{
@@ -118,36 +133,25 @@ export function InvestigationDiagramView({
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p> <p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
)} )}
</div> </div>
{running && diagram.node_refs.some((node) => node.status === "running") ? ( <div
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p> className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
) : null} aria-label="图示状态说明"
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? ( >
<div {displayedStates.map((key) => {
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]" const state = investigationDiagramStates[key];
aria-label="图示颜色说明" return (
> <span key={key} className="inline-flex items-center gap-1.5">
{diagram.node_refs.some(
(node) => node.evidence_refs.length && !node.action_refs.length
) ? (
<span className="inline-flex items-center gap-1.5">
<span <span
aria-hidden aria-hidden
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]" className="h-2.5 w-3.5 rounded-sm border"
style={{ backgroundColor: state.fill, borderColor: state.stroke,
borderStyle: state.dash === "0" ? "solid" : "dashed" }}
/> />
引用证据 {state.label}
</span> </span>
) : null} );
{diagram.node_refs.some((node) => node.action_refs.length) ? ( })}
<span className="inline-flex items-center gap-1.5"> </div>
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
/>
关联业务动作
</span>
) : null}
</div>
) : null}
{diagram.node_refs.length > 0 ? ( {diagram.node_refs.length > 0 ? (
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5"> <div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
{diagram.node_refs.map((node) => ( {diagram.node_refs.map((node) => (
@@ -161,6 +165,7 @@ export function InvestigationDiagramView({
} }
> >
{node.label} {node.label}
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""} {node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
</button> </button>
))} ))}
@@ -172,16 +177,9 @@ 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>
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : { <p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
}[selected.status]}</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}
{onAsk ? (
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
围绕此节点提问
</Button>
) : null}
</div> </div>
) : null} ) : null}
</> </>
@@ -0,0 +1,64 @@
import { useRef, useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InvestigationExpandCard } from "./investigation-expand-card";
describe("investigation card reading", () => {
it("freezes the reading revision until the reader requests the latest content, then restores focus", async () => {
const user = userEvent.setup();
const { rerender } = render(<InvestigationExpandCard title="当前结果" preview={<p>结论一预览</p>} revision="1"><p>结论一正文</p></InvestigationExpandCard>);
const trigger = screen.getByRole("button", { name: "展开当前结果" });
await user.click(trigger);
const dialog = screen.getByRole("dialog", { name: "当前结果" });
expect(document.body.querySelector(".investigation-detail-backdrop")).toBeInTheDocument();
rerender(<InvestigationExpandCard title="当前结果" preview={<p>结论二预览</p>} revision="2"><p>结论二正文</p></InvestigationExpandCard>);
expect(dialog).toHaveTextContent("结论一正文");
expect(dialog).not.toHaveTextContent("结论二正文");
expect(within(dialog).getByRole("status")).toHaveTextContent("当前保留正在阅读的版本");
await user.click(within(dialog).getByRole("button", { name: "查看最新内容" }));
expect(dialog).toHaveTextContent("结论二正文");
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("moves the same live control into the reader and retains a visible inert preview", async () => {
const user = userEvent.setup();
function Controls() {
const [count, setCount] = useState(0);
return <button type="button" onClick={() => setCount((value) => value + 1)}>缩放 {count}</button>;
}
render(<InvestigationExpandCard title="调查脉络" preview={null} reuseContent wide><Controls /></InvestigationExpandCard>);
const control = screen.getByRole("button", { name: "缩放 0" });
await user.click(control);
await user.click(screen.getByRole("button", { name: "展开调查脉络" }));
const dialog = screen.getByRole("dialog", { name: "调查脉络" });
expect(within(dialog).getByRole("button", { name: "缩放 1" })).toBe(control);
const snapshot = document.querySelector("[data-reading-preview]")!;
expect(snapshot).toBeVisible();
expect(snapshot).toHaveAttribute("aria-hidden", "true");
expect(snapshot).toHaveAttribute("inert");
await user.click(control);
await user.click(within(dialog).getByRole("button", { name: "关闭详情" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: "缩放 2" })).toBe(control);
expect(document.querySelector("[data-reading-preview]")).not.toBeInTheDocument();
});
it("allows a follow-up to own focus when it explicitly closes without restoring the trigger", async () => {
const user = userEvent.setup();
function Fixture() {
const target = useRef<HTMLInputElement>(null);
return <><label>继续输入<input ref={target} /></label>
<InvestigationExpandCard title="主要依据" preview={<p>依据预览</p>}>
{(close) => <button type="button" onClick={() => { close(false); requestAnimationFrame(() => target.current?.focus()); }}>继续核查</button>}
</InvestigationExpandCard></>;
}
render(<Fixture />);
await user.click(screen.getByRole("button", { name: "展开主要依据" }));
await user.click(screen.getByRole("button", { name: "继续核查" }));
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
});
});
@@ -1,9 +1,10 @@
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react"; import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import * as Dialog from "@radix-ui/react-dialog"; import { motion, useReducedMotion } from "motion/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Maximize2, X } from "lucide-react"; import { Maximize2, X } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
import { createReadingPreview } from "@/shared/ui/reading-preview";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */ /** Report-specific progressive disclosure. The source stays mounted while reading. */
@@ -27,7 +28,6 @@ export function InvestigationExpandCard({
reuseContent?: boolean; reuseContent?: boolean;
headingAccessory?: ReactNode; headingAccessory?: ReactNode;
}) { }) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(true); const restoreFocusRef = useRef(true);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -35,6 +35,7 @@ export function InvestigationExpandCard({
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null); const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null); const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
const previewSlot = useRef<HTMLDivElement | null>(null); const previewSlot = useRef<HTMLDivElement | null>(null);
const snapshot = useRef<HTMLElement | null>(null);
const attachPreview = useCallback((slot: HTMLDivElement | null) => { const attachPreview = useCallback((slot: HTMLDivElement | null) => {
previewSlot.current = slot; previewSlot.current = slot;
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost); if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
@@ -43,24 +44,22 @@ export function InvestigationExpandCard({
const preview = previewSlot.current; const preview = previewSlot.current;
if (!preview || !contentHost) return; if (!preview || !contentHost) return;
if (slot) { if (slot) {
// Reserve only the vacated inline space while expanded. The graph itself snapshot.current?.remove();
// keeps its natural height, including after content updates or resizing. snapshot.current = createReadingPreview(contentHost);
// Use the layout height, not a bounding box scaled by the card animation. preview.append(snapshot.current);
preview.style.height = getComputedStyle(preview).height;
slot.appendChild(contentHost); slot.appendChild(contentHost);
} else { } else {
snapshot.current?.remove();
snapshot.current = null;
preview.appendChild(contentHost); preview.appendChild(contentHost);
preview.style.removeProperty("height");
} }
}, [contentHost]); }, [contentHost]);
const reduced = useReducedMotion(); const reduced = useReducedMotion();
const duration = reduced || keyboard ? 0 : 0.24;
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); }; const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
const content = reading?.content ?? children; const content = reading?.content ?? children;
return ( return (
<Dialog.Root open={open} onOpenChange={(next) => { <Dialog open={open} onOpenChange={(next) => {
setOpen(next); setOpen(next);
if (next) { if (next) {
restoreFocusRef.current = true; restoreFocusRef.current = true;
@@ -68,69 +67,52 @@ export function InvestigationExpandCard({
onOpen?.(); onOpen?.();
} }
}}> }}>
<motion.section <section
ref={contentRef} ref={contentRef}
aria-label={label} aria-label={label}
className={cn("investigation-report-section investigation-expand-card", className)} className={cn("investigation-report-section investigation-expand-card", className)}
layoutId={reduced || keyboard ? undefined : id}
layoutDependency={open}
transition={transition}
style={{ borderRadius: 12 }}
> >
<div className="investigation-card-heading"> <div className="investigation-card-heading">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3> <h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
{headingAccessory} {headingAccessory}
</div> </div>
<Dialog.Trigger asChild> <DialogTrigger asChild>
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled} <Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}> title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
<Maximize2 size={15} aria-hidden /> <Maximize2 size={15} aria-hidden />
</Button> </Button>
</Dialog.Trigger> </DialogTrigger>
</div> </div>
{/* Contain child margins so the occupied and vacated slots measure alike. */} {/* Contain child margins so the occupied and vacated slots measure alike. */}
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview} {contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
</motion.section> </section>
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null} {contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
<Dialog.Portal forceMount> <DialogContent
<AnimatePresence initial={false}> className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
{open ? ( overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
<Dialog.Overlay forceMount asChild key="backdrop"> data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
<motion.div className="investigation-detail-backdrop" onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} /> onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
</Dialog.Overlay> <header className="investigation-detail-heading border-b border-border">
) : null} <DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
{open ? ( <DialogClose asChild>
<div className="investigation-detail-positioner" key="detail"> <Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
<Dialog.Content forceMount asChild aria-describedby={undefined} </DialogClose>
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }} </header>
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}> <div className="investigation-detail-scroll">
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")} <motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open} transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} {contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
transition={transition} style={{ borderRadius: 12 }}> </motion.div>
<header className="investigation-detail-heading"> </div>
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title> {reading && revision !== undefined && reading.revision !== revision ? (
<Dialog.Close asChild> <div className="investigation-report-update" role="status">
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button> <span>已有更新,当前保留正在阅读的版本。</span>
</Dialog.Close> <Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</header> </div>
<div className="investigation-detail-scroll"> ) : null}
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content} </DialogContent>
</div> </Dialog>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</motion.section>
</Dialog.Content>
</div>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
); );
} }
@@ -0,0 +1,99 @@
import { forwardRef, useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useIsPresent } from "motion/react";
import { ChevronDown, FileText } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
// Forward the element ref so AnimatePresence can remove a card without jumping its siblings.
export const PresentationItem = forwardRef<
HTMLDivElement,
{ immediate: boolean; children: ReactNode }
>(function PresentationItem({ immediate, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
layout={immediate ? false : "position"}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
>
{children}
</motion.div>
);
});
export const PresentationViewport = forwardRef<
HTMLDivElement,
{ immediate: boolean; id: string; children: ReactNode }
>(function PresentationViewport({ immediate, id, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
id={present ? id : undefined}
data-investigation-content={present ? "" : undefined}
className="relative min-h-0 space-y-3 overflow-y-auto overscroll-contain p-3 [scrollbar-gutter:stable]"
>
{children}
</motion.div>
);
});
export function PresentationReferences({
entries,
immediate
}: {
entries: Array<{ id: string; text: string }>;
immediate: boolean;
}) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div className="mt-3 border-t border-border pt-1">
<Button
type="button"
variant="ghost"
className="h-10 w-full justify-between gap-2 px-0 text-xs font-normal text-muted-foreground"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
<span className="inline-flex items-center gap-1.5">
<FileText size={13} aria-hidden />
查看依据<span className="tabular-nums">{entries.length}</span>
</span>
<motion.span
className="inline-flex"
animate={{ rotate: open ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={13} aria-hidden />
</motion.span>
</Button>
<AnimatePresence initial={false}>
{open ? (
<motion.ul
key="references"
id={id}
{...presentationFade(immediate)}
className="space-y-2 pb-2 pt-1 text-xs leading-5 text-muted-foreground"
>
{entries.map((entry) => (
<li key={entry.id} className="break-words [overflow-wrap:anywhere]">
{entry.text}
</li>
))}
</motion.ul>
) : null}
</AnimatePresence>
</div>
);
}
@@ -1,55 +1,354 @@
import { useState } from "react"; import { useId, useState } from "react";
import { Eye, EyeOff, LocateFixed, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AgentChartView } from "@/features/agent"; import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure"; import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import {
PresentationItem,
PresentationViewport,
PresentationReferences
} from "./investigation-presentation-motion";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
import type { OperationalTask } from "../api/operational-task-client"; 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 { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = { type Props = {
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null; blocks?: AnswerUiBlock[];
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null; records?: Array<Record<string, unknown>>;
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>; evidence?: Array<{ evidence_id: string; summary: string }>;
onResumeCamera: () => void; onCloseChart: (id: string) => void; history?: PresentationHistoryControls;
onToggleOverlay: () => void; onFitOverlay: () => void; onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
scope?: string;
embedded?: boolean;
task: OperationalTask | null;
localPreview?: boolean;
charts: InvestigationChart[];
overlay: InvestigationOverlay | null;
cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error";
error: string | null;
width: number;
relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void;
onCloseChart: (id: string) => void;
onToggleOverlay: () => void;
onFitOverlay: () => void;
onToggleLayer: (id: string, visible: boolean) => void; onToggleLayer: (id: string, visible: boolean) => void;
}; };
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers, const statusLabels = {
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) { connecting: "连接中",
live: "实时跟随",
history: "已载入历史",
disconnected: "连接中断",
error: "部分未完成"
};
export function InvestigationPresentationPanel({
scope = "investigation",
embedded = false,
task,
blocks = [],
records = [],
evidence,
history,
onLocateTargets,
localPreview = false,
charts,
overlay,
cameraPaused,
status,
error,
width,
relatedLayers,
onResumeCamera,
onCloseChart,
onToggleOverlay,
onFitOverlay,
onToggleLayer
}: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2"> const [keyboardAction, setKeyboardAction] = useState(false);
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)} const reducedMotion = useReducedMotion();
</Disclosure>; const immediate = Boolean(reducedMotion) || keyboardAction;
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel" const contentId = useId();
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs" const reviewing = history?.selectedId != null;
style={{ width, maxWidth: "calc(100% - 24px)" }}> const selected = history?.entries.find((entry) => entry.id === history.selectedId);
<div className="flex flex-wrap items-center justify-between gap-2"> const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
<p className="text-sm font-semibold">调查实时展示</p> const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button> const references = (ids: string[]) =>
</div> ids.length ? (
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p> <PresentationReferences
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null} immediate={immediate}
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null} entries={ids.map((id) => ({
{!collapsed ? <> id,
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1"> text:
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)} (evidence ?? task?.evidence)?.find((item) => item.evidence_id === id)?.summary ??
</div> : null} (localPreview && id === "dev:synthetic"
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3"> ? "合成测试数据,未保存为调查证据。"
<p className="text-sm font-medium">{overlay.title}</p> : `关联资料:${id}`)
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p> }))}
<div className="mt-2 flex flex-wrap gap-2"> />
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button> ) : null;
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
return (
<motion.aside
layout={immediate ? false : "size"}
{...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)}
aria-label={title}
data-testid="investigation-presentation-panel"
data-presentation-scope={scope}
data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
>
<motion.header
layout={immediate ? false : "position"}
transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing
? "历史回看"
: scope === "chat"
? "对话联动"
: localPreview
? "本地测试"
: statusLabels[status]}
</Badge>
<span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span>
{reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground">
后续 {pending} 个动作
</span>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)}
>
<motion.span
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
</div> </div>
{references([overlay.evidence_id])} <p
</section> : null} className={
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3"> history?.entries.length
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div> ? "sr-only"
<AgentChartView chart={item.chart} /> : "mt-1.5 text-[11px] leading-4 text-muted-foreground"
{references(item.evidence_refs)} }
</section>)} >
</> : null} {selected
</aside>; ? `${selected.taskTitle ? `${selected.taskTitle} · ` : ""}${new Date(selected.createdAt).toLocaleTimeString("zh-CN", { hour12: false })} · ${selected.label}${pending ? ` · 后续 ${pending} 个动作` : ""}`
: scope === "chat"
? "展示当前对话的地图操作与结果"
: localPreview
? "仅当前浏览器,不写入调查任务"
: status === "history"
? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
<PresentationViewport
key={history?.selectedId ?? "current"}
immediate={immediate}
id={contentId}
>
{selected?.message ? (
<p role="status" className="text-xs leading-5 text-muted-foreground">
{selected.message}
</p>
) : null}
{!reviewing && !localPreview && error ? (
<p
role="status"
className="rounded-md border border-border px-2.5 py-2 text-xs leading-5 text-muted-foreground"
>
{error}
</p>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{cameraPaused ? (
<PresentationItem key="camera" immediate={immediate}>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-xs">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Pause size={13} aria-hidden />
镜头跟随已暂停
</span>
<Button type="button" size="sm" variant="outline" onClick={onResumeCamera}>
{localPreview || scope === "chat" ? "恢复自动定位" : "恢复跟随"}
</Button>
</div>
</PresentationItem>
) : null}
</AnimatePresence>
{relatedLayers.length ? (
<section aria-label="调查相关图层" className="space-y-2">
<h3 className="text-[11px] font-medium text-muted-foreground">相关图层</h3>
<div className="flex flex-wrap gap-2">
{relatedLayers.map((layer) => (
<Button
key={layer.id}
type="button"
variant="outline"
size="sm"
className="h-auto min-h-8 whitespace-normal text-left"
disabled={reviewing}
aria-pressed={layer.visible}
onClick={() => onToggleLayer(layer.id, !layer.visible)}
>
{layer.visible ? (
<Eye size={14} aria-hidden />
) : (
<EyeOff size={14} aria-hidden />
)}
{layer.label}
</Button>
))}
</div>
</section>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
<CardHeader className="space-y-1.5 p-3 pb-2">
<div className="flex items-start gap-2">
<MapPinned
size={15}
className="mt-0.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<h3 className="min-w-0 break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{overlay.title}
</h3>
</div>
<p className="text-[11px] leading-5 text-muted-foreground">
{localPreview
? "本地空间预览,范围以输入 JSONL 为准"
: overlay.mode === "heatmap"
? "记录集中度,颜色不表示已确认影响范围"
: "调查范围与关联空间资料"}
</p>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={reviewing}
onClick={onToggleOverlay}
>
{overlay.visible ? (
<EyeOff size={14} aria-hidden />
) : (
<Eye size={14} aria-hidden />
)}
{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}
</Button>
<Button type="button" size="sm" variant="outline" onClick={onFitOverlay}>
<LocateFixed size={14} aria-hidden />
定位覆盖层
</Button>
</div>
{references([overlay.evidence_id])}
</CardContent>
</Card>
</PresentationItem>
) : null}
{charts.map((item) => (
<PresentationItem key={item.chart_id} immediate={immediate}>
<Card
data-investigation-chart={item.chart_id}
role="group"
aria-label={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]">
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem>
))}
</AnimatePresence>
<PresentationBusinessResults
blocks={blocks}
records={records}
reviewing={reviewing}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
!overlay &&
!blocks.length &&
!records.length &&
!relatedLayers.length ? (
<p className="text-xs leading-5 text-muted-foreground">
当前没有展示结果,可切换历史动作回看地图与结果。
</p>
) : null}
</PresentationViewport>
) : null}
</AnimatePresence>
</motion.aside>
);
} }
@@ -0,0 +1,50 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { OperationalTask, TaskQuestionRequest } from "../api/operational-task-client";
import { InvestigationQuestions } from "./investigation-questions";
const question: TaskQuestionRequest = {
question_request_id: "70000000-0000-4000-8000-000000000001", task_id: "70000000-0000-4000-8000-000000000002",
run_id: "70000000-0000-4000-8000-000000000003", source_session_id: "s", execution_generation: 1,
status: "pending", version: 1, created_at: "2026-10-30T01:00:00Z", answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }]
};
const task = { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
permissions: { can_answer_questions: true } } as OperationalTask;
describe("investigation questions", () => {
it("requires an answer, retains input after failure and retries the same request id", async () => {
const onAnswer = vi.fn().mockRejectedValueOnce(new Error("网络失败")).mockResolvedValueOnce({});
render(<InvestigationQuestions task={task} onAnswer={onAnswer} />);
const submit = screen.getByRole("button", { name: "提交回答并继续调查" });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "目前未知" }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "请保留为未知" } });
fireEvent.click(submit);
expect(await screen.findByRole("alert")).toHaveTextContent("网络失败");
expect(screen.getByRole("textbox")).toHaveValue("请保留为未知");
fireEvent.click(submit);
await waitFor(() => expect(onAnswer).toHaveBeenCalledTimes(2));
expect(onAnswer.mock.calls[0]).toEqual(onAnswer.mock.calls[1]);
expect(onAnswer.mock.calls[0].slice(0, 3)).toEqual([question.question_request_id, 1,
[{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "请保留为未知" }]]);
expect(await screen.findByText(/回答已保存,调查已排队继续/)).toBeVisible();
});
it.each([
{ ...task, permissions: { ...task.permissions, can_answer_questions: false } },
{ ...task, active_run: { status: "running" } },
{ ...task, execution_generation: 2 }
])("does not answer without permission or for active/stale runs", (value) => {
render(<InvestigationQuestions task={value as OperationalTask} onAnswer={vi.fn()} />);
expect(screen.getByRole("radio")).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});
it("shows stored option labels and text after reload without another answer action", () => {
render(<InvestigationQuestions task={{ ...task, questions: [{ ...question, status: "answered", version: 2,
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "没有记录" }] }] }} />);
expect(screen.getByText("目前未知;没有记录")).toBeVisible();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import { useRef, useState, type Ref } from "react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
}) {
const questions = task.questions ?? [];
if (!questions.length) return null;
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
request={request} onAnswer={onAnswer}
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
</section>;
}
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
}) {
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
question_id: q.question_id, selected_option_ids: [], custom_text: ""
})));
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const attempt = useRef<{ body: string; id: string } | null>(null);
const pending = request.status === "pending" && !submitted;
const disabled = !canAnswer || !onAnswer || busy || !pending;
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|| (q.allow_custom && answers[index].custom_text.trim()));
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
const submit = async () => {
if (disabled || !valid) return;
const body = JSON.stringify(answers);
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
setBusy(true); setError(null);
try {
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
setSubmitted(true);
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
finally { setBusy(false); }
};
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
{request.questions.map((question, index) => {
const answer = answers[index];
const saved = request.answers.find((item) => item.question_id === question.question_id);
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
{pending ? <>
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
})} />}
<span className="min-w-0 break-words">{option.label}</span>
</label>)}
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
</fieldset>;
})}
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
{pending ? <div className="space-y-2">
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
</div> : null}
</article>;
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { InvestigationReportBlocks } from "./investigation-report-blocks"; import { InvestigationReportBlocks } from "./investigation-report-blocks";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client"; import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
@@ -22,20 +22,16 @@ const snapshot: OperationalTaskReport = {
}; };
describe("formal report interactive content", () => { describe("formal report interactive content", () => {
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => { it("shows saved evidence facts without quoting or dispatching work", async () => {
const onContinue = vi.fn(); const onContinue = vi.fn();
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />); render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
fireEvent.click(screen.getByRole("button", { name: /监测依据/ })); fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible(); await waitFor(() => expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible());
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument(); expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" })); expect(screen.queryByText("引用依据:保存的监测来源")).not.toBeInTheDocument();
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({ expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
context: { report_revision: 3 }, reportGeneration: 2, expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据", expect(onContinue).not.toHaveBeenCalled();
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
}));
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
}); });
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => { it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
const next = structuredClone(snapshot); const next = structuredClone(snapshot);
@@ -44,7 +40,28 @@ describe("formal report interactive content", () => {
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />); const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("分析待核验。")).toBeVisible(); expect(screen.getByText("分析待核验。")).toBeVisible();
expect(container.querySelector("iframe")).toBeNull(); expect(container.querySelector("iframe")).toBeNull();
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
}); });
it("keeps references not already represented by an evidence card", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].evidence_refs.push("other-evidence");
const expandedTask = { evidence: [...task.evidence, { ...task.evidence[0], evidence_id: "other-evidence", source_label: "另一条监测来源" }] };
render(<InvestigationReportBlocks task={expandedTask} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("保存的监测来源")).toBeVisible();
expect(screen.getByText("引用依据:另一条监测来源")).toBeVisible();
expect(screen.queryByText("引用依据:保存的监测来源、另一条监测来源")).not.toBeInTheDocument();
});
it("keeps evidence references for tables without evidence cards", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "table", elements: { table: {
type: "Table", children: [], props: { title: "监测记录", columns: ["对象"], rows: [["北区"]] },
} } };
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByRole("table")).toHaveTextContent("北区");
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
});
it("turns report form input into a versioned conversation draft", async () => { it("turns report form input into a versioned conversation draft", async () => {
const next = structuredClone(snapshot); const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: { next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
@@ -58,5 +75,9 @@ describe("formal report interactive content", () => {
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible(); expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置"); expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 }); expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
expect(onContinue.mock.calls[0][0].reportGeneration).toBe(2);
expect(onContinue.mock.calls[0][0].reference.title).toBe("演练调查报告 · 报告第 3 版 · 补充资料");
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
}); });
}); });
@@ -24,7 +24,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
return <section className="investigation-report-section space-y-4" aria-label="交互分析"> return <section className="investigation-report-section space-y-4" aria-label="交互分析">
<div> <div>
<h3 className="text-sm font-semibold">数据与分析</h3> <h3 className="text-sm font-semibold">数据与分析</h3>
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p> <p className="mt-1 text-xs text-muted-foreground">查看报告中的数据与依据;定位和补充资料使用各自入口。</p>
</div> </div>
{blocks.map((item) => { {blocks.map((item) => {
const parsed = parseAnswerUiBlock(item.block); const parsed = parseAnswerUiBlock(item.block);
@@ -49,14 +49,17 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }]; } } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
})) }))
} } : null; } } : null;
const shownEvidence = new Set(block ? Object.values(block.spec.elements)
.filter((node) => node.type === "Evidence").map((node) => node.props.evidence_id) : []);
const remainingRefs = item.evidence_refs.filter((id) => !shownEvidence.has(id));
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2"> return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft" {block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })} onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} /> onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>} : <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
<p className="text-[11px] leading-5 text-muted-foreground"> {remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"} 引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> </p> : null}
</div>; </div>;
})} })}
</section>; </section>;
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react"; import { History, LoaderCircle, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure"; import { Disclosure } from "@/shared/ui/disclosure";
@@ -34,6 +35,7 @@ type Props = {
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void; onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
renderAction: (action: OperationalTaskAction) => ReactNode; renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode; renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>; onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void; onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null; focusActionId?: string | null;
@@ -82,6 +84,12 @@ function investigationTimeline(
detail: snapshot.report.current_conclusion detail: snapshot.report.current_conclusion
}); });
} }
task.questions?.forEach((question) => {
items.push({ id: `question:${question.question_request_id}`, occurredAt: question.created_at,
title: "调查提出补充问题", detail: question.questions.map((item) => item.title).join(";") });
if (question.answered_at) items.push({ id: `answer:${question.question_request_id}`, occurredAt: question.answered_at,
title: "已收到调查回答", detail: "回答已保存为用户补充信息,调查接续处理。" });
});
task.actions.forEach((action) => { task.actions.forEach((action) => {
const title = actionTitle(action); const title = actionTitle(action);
items.push({ items.push({
@@ -133,10 +141,14 @@ export function InvestigationTaskReport({
renderAction, renderAction,
renderEvidence, renderEvidence,
onLoadReports, onLoadReports,
onAnswerQuestion,
onReportViewed, onReportViewed,
focusActionId, focusActionId,
onLocate onLocate
}: Props) { }: Props) {
const questionsRef = useRef<HTMLElement>(null);
const hasPendingQuestions = task.questions?.some((request) => request.status === "pending"
&& request.execution_generation === task.execution_generation) ?? false;
// Null follows live task content. Only a deliberate history selection freezes a report. // Null follows live task content. Only a deliberate history selection freezes a report.
const [selection, setSelection] = useState<ReportSelection | null>(null); const [selection, setSelection] = useState<ReportSelection | null>(null);
const [history, setHistory] = useState<OperationalTaskReport[]>([]); const [history, setHistory] = useState<OperationalTaskReport[]>([]);
@@ -313,6 +325,8 @@ export function InvestigationTaskReport({
</div> </div>
</header> </header>
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
<InvestigationReportNavigation reportRef={reportRef} sections={sections} /> <InvestigationReportNavigation reportRef={reportRef} sections={sections} />
{latestAvailable ? ( {latestAvailable ? (
@@ -489,14 +503,19 @@ export function InvestigationTaskReport({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => {
discuss( if (hasPendingQuestions) {
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`, questionsRef.current?.scrollIntoView({ block: "start" });
{ gap_id: gap.gap_id } questionsRef.current?.focus({ preventScroll: true });
) } else {
} discuss(
`请帮我讨论“${formatInvestigationText(gap.missing_data)}”这个缺口的查询范围和补齐途径。`,
{ gap_id: gap.gap_id }
);
}
}}
> >
补充事实 {hasPendingQuestions ? "回答当前调查问题" : "讨论此缺口"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -548,12 +567,6 @@ export function InvestigationTaskReport({
) : null} ) : null}
</> </>
)} )}
onAsk={(node) =>
discuss(
`请解释调查脉络中的“${node.label}”及其关联依据。`,
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
)
}
/> />
) : null} ) : null}
@@ -50,6 +50,24 @@ describe("MapDevPanel", () => {
expect(onFollowChange).toHaveBeenCalledWith(false); expect(onFollowChange).toHaveBeenCalledWith(false);
}); });
it("keeps map inputs when switching between investigation and map tools", async () => {
const user = userEvent.setup();
const commands = createCommands();
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
detailFeature={null} onClose={vi.fn()} onInvestigationFollowChange={vi.fn()} />);
expect(screen.getByRole("tab", { name: "调查测试" })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("button", { name: "定位并高亮" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
const featureId = screen.getByLabelText("要素 ID(可多个)");
await user.clear(featureId);
await user.type(featureId, "DEMO-PIPE-03");
await user.click(screen.getByRole("tab", { name: "调查测试" }));
expect(screen.queryByRole("button", { name: "重置地图调试效果" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("DEMO-PIPE-03");
expect(commands.resetAll).not.toHaveBeenCalled();
});
it("updates a pipe with its signed velocity", async () => { it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const commands = createCommands(); const commands = createCommands();
@@ -6,25 +6,27 @@ import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select"; import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { import {
LAYER_GROUP_GEOMETRIES, LAYER_GROUP_GEOMETRIES,
type LayerGroupId, type LayerGroupId,
type LayerGroupStylePatch type LayerGroupStylePatch
} from "../map/layer-group-style"; } from "../map/layer-group-style";
import { MAP_STYLE_TOKENS } from "../map/map-colors"; import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { getNumericFeatureProperties } from "../map/value-label"; import { getNumericFeatureProperties } from "../map/value-label";
import type { import type {
FeatureBatchTarget, FeatureBatchTarget,
WorkbenchMapCommands, WorkbenchMapCommands,
WorkbenchMapControllerState WorkbenchMapControllerState
} from "../map/workbench-map-controller"; } from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS, AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG, SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId type AvailableWaterNetworkSourceId
} from "../map/sources"; } from "../map/sources";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
type MapDevPanelProps = { type MapDevPanelProps = {
commands: WorkbenchMapCommands; commands: WorkbenchMapCommands;
@@ -34,6 +36,7 @@ type MapDevPanelProps = {
onClose: () => void; onClose: () => void;
investigationFollow?: boolean; investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void; onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps;
}; };
const GROUP_LABELS = Object.fromEntries( const GROUP_LABELS = Object.fromEntries(
@@ -109,8 +112,10 @@ export function MapDevPanel({
flowDataSource = "mock", flowDataSource = "mock",
onClose, onClose,
investigationFollow = true, investigationFollow = true,
onInvestigationFollowChange onInvestigationFollowChange,
investigationTools
}: MapDevPanelProps) { }: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes"); const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681"); const [featureId, setFeatureId] = useState("440681");
const [property, setProperty] = useState("diameter"); const [property, setProperty] = useState("diameter");
@@ -241,17 +246,17 @@ export function MapDevPanel({
return ( return (
<aside <aside
aria-label="地图开发面板" aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex" className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel" data-testid="map-dev-panel"
> >
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3"> <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div> <div>
<h2 className="text-sm font-semibold text-foreground"> <h2 className="text-sm font-semibold text-foreground">
地图调试{" "} 开发测试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span> <span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2> </h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]"> <p className="mt-1 text-[11px] leading-4 text-muted-foreground">
按功能展开 · 设置仅本次页面会话生效 调查联动验证与地图工具 · 仅当前页面生效
</p> </p>
</div> </div>
<Button <Button
@@ -265,429 +270,436 @@ export function MapDevPanel({
</Button> </Button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
{controllerState.errorCode ? ( <div className="shrink-0 border-b border-border px-4 py-2">
<div <TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
role="alert" <TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]" <TabsTrigger value="map">地图调试</TabsTrigger>
> </TabsList>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode} </div>
</div> <TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
) : null} {onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4">
{onInvestigationFollowChange ? ( <div className="space-y-2">
<PanelSection <ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
title="调查联动" <p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<FieldSelect
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div> </div>
<RangeField {investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
label="透明度" </section>
min={0} ) : null}
max={1} </TabsContent>
step={0.05} <TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
value={Number(draft.opacity)} {controllerState.errorCode ? (
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} <div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/> />
<RangeField <Field label="要素 ID(可多个)">
label="半径倍率" <Textarea
min={0.5} value={featureId}
max={3} onChange={(event) => setFeatureId(event.target.value)}
step={0.1} rows={3}
value={Number(draft.radiusMultiplier)} placeholder="例如:440681, 440682&#10;440683"
onChange={(value) => aria-describedby="map-dev-feature-ids-help"
setDraft((current) => ({ ...current, radiusMultiplier: value })) aria-invalid={invalidIds}
} className="resize-y text-xs"
/> />
<RangeField </Field>
label="描边倍率" <p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
min={0.5} 用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
max={3} </p>
step={0.1} {invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
value={Number(draft.strokeMultiplier)} </div>
onChange={(value) => <Button
setDraft((current) => ({ ...current, strokeMultiplier: value })) type="button"
} disabled={!detailFeature}
/> onClick={useSelectedFeature}
{groupId !== "junctions" ? ( className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField <RangeField
label="图标倍率" label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5} min={0.5}
max={3} max={3}
step={0.1} step={0.1}
value={Number(draft.iconMultiplier)} value={Number(draft.widthMultiplier)}
onChange={(value) => onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value })) setDraft((current) => ({ ...current, widthMultiplier: value }))
} }
/> />
) : null} <FieldSelect
</> contentClassName="workbench-shadcn-surface"
)} label="线型"
<div className="grid grid-cols-2 gap-2"> value={String(draft.dash)}
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs"> onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
应用图层样式 options={[
</Button> { value: "original", label: "原始" },
<Button { value: "solid", label: "实线" },
type="button" { value: "dashed", label: "虚线" }
onClick={() => commands.resetLayerGroupStyle(groupId)} ]}
className="w-full text-xs" />
variant="outline" </>
> ) : (
重置本组 <>
</Button> <div className="grid grid-cols-2 gap-2">
</div> <ColorField
{controllerState.styledGroups.includes(groupId) ? ( label="填充"
<StatusText>已覆盖当前图层组样式</StatusText> value={String(draft.fillColor)}
) : null} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
</PanelSection> />
<ColorField
<PanelSection label="描边"
title="数值标注" value={String(draft.strokeColor)}
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。" onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
status={ />
hasActiveLabels </div>
? `${Object.keys(controllerState.valueLabels).length} 组已标注` <RangeField
: undefined label="透明度"
} min={0}
> max={1}
<FieldSelect step={0.05}
label="标注图层组" value={Number(draft.opacity)}
value={groupId} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)} />
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))} <RangeField
/> label="半径倍率"
<div className="grid grid-cols-2 gap-2"> min={0.5}
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} /> max={3}
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} /> step={0.1}
</div> value={Number(draft.radiusMultiplier)}
{showValueLabels ? ( onChange={(value) =>
<> setDraft((current) => ({ ...current, radiusMultiplier: value }))
<FieldSelect }
label="数值属性" />
value={property} <RangeField
onValueChange={setProperty} label="描边倍率"
options={LABEL_PRESETS[groupId].options} min={0.5}
/> max={3}
<div className="grid grid-cols-2 gap-2"> step={0.1}
<Field label="精度 0-3"> value={Number(draft.strokeMultiplier)}
<Input onChange={(value) =>
type="number" setDraft((current) => ({ ...current, strokeMultiplier: value }))
min={0} }
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3} max={3}
value={precision} step={0.1}
onChange={(event) => setPrecision(Number(event.target.value))} value={Number(draft.iconMultiplier)}
className={inputClassName} onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/> />
</Field> ) : null}
<Field label="单位"> </>
<Input )}
maxLength={16} <div className="grid grid-cols-2 gap-2">
value={unit} <Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
onChange={(event) => setUnit(event.target.value)} 应用图层样式
className={inputClassName} </Button>
/> <Button
</Field> type="button"
</div> onClick={() => commands.resetLayerGroupStyle(groupId)}
</> className="w-full text-xs"
) : null} variant="outline"
<RangeField >
label="字号" 重置本组
min={10} </Button>
max={18} </div>
step={1} {controllerState.styledGroups.includes(groupId) ? (
value={labelSize} <StatusText>已覆盖当前图层组样式</StatusText>
onChange={setLabelSize} ) : null}
/> </PanelSection>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3"> <PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</TabsContent>
</Tabs>
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
<Button <Button
type="button" type="button"
onClick={() => commands.resetAll()} onClick={() => commands.resetAll()}
@@ -718,12 +730,12 @@ function PanelSection({
return ( return (
<Disclosure <Disclosure
initiallyOpen={initiallyOpen} initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground" className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
label={ label={
<span className="flex min-w-0 items-center justify-between gap-2"> <span className="flex min-w-0 items-center justify-between gap-2">
<span className="font-semibold">{title}</span> <span className="font-semibold">{title}</span>
{status ? ( {status ? (
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]"> <span className="shrink-0 text-[11px] font-normal text-muted-foreground">
{status} {status}
</span> </span>
) : null} ) : null}
@@ -731,7 +743,7 @@ function PanelSection({
} }
> >
<div className="space-y-3 px-2 pb-3 pt-1"> <div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p> <p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
{children} {children}
</div> </div>
</Disclosure> </Disclosure>
@@ -741,7 +753,7 @@ function PanelSection({
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<label className="block min-w-0"> <label className="block min-w-0">
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span> <span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children} {children}
</label> </label>
); );
@@ -757,7 +769,7 @@ function ToggleField({
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
}) { }) {
return ( return (
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground"> <label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
<Checkbox <Checkbox
checked={checked} checked={checked}
onChange={(event) => onChange(event.target.checked)} onChange={(event) => onChange(event.target.checked)}
@@ -778,7 +790,7 @@ function ColorField({
}) { }) {
return ( return (
<Field label={label}> <Field label={label}>
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2"> <div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
<input <input
type="color" type="color"
value={value} value={value}
@@ -786,7 +798,7 @@ function ColorField({
aria-label={label} aria-label={label}
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]" className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/> />
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span> <span className="text-xs font-medium tabular-nums text-muted-foreground">{value}</span>
</div> </div>
</Field> </Field>
); );
@@ -835,4 +847,4 @@ function StatusText({ children, tone = "success" }: { children: React.ReactNode;
} }
const inputClassName = const inputClassName =
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors"; "h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";

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