Author SHA1 Message Date
jiang 5f7041ffae fix: recognize class-attached investigation diagram nodes
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
2026-11-02 15:23:34 +08:00
jiang c0e4231b83 feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
2026-10-30 17:30:19 +08:00
jiang ddd03dbff6 style: refine agent composer focus feedback 2026-10-30 17:09:13 +08:00
jiang 185bf31446 fix: preserve investigation execution history by round 2026-10-30 17:07:53 +08:00
jiang 1ee21d1c7f feat: polish intelligent answer interactions 2026-10-29 18:39:50 +08:00
jiang 9474788db6 fix: unify chat process and reply presentation 2026-10-29 17:54:34 +08:00
jiang 7621bf0f86 fix: synchronize task collaboration session titles
Apply creation titles and repaired history names to cached sessions.
Keep messages and reading state intact and preserve title edits received
while a collaboration request is in flight.

Validation: pnpm lint; pnpm typecheck; 604 unit tests; two browser regressions.
2026-10-29 16:55:31 +08:00
jiang 5b710d1247 feat: extend map targeting and overlay controls
Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries.

Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
2026-10-28 14:47:33 +08:00
jiang e57e0f099f fix: smooth persona hover transitions
Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes.

Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
2026-10-28 11:46:14 +08:00
jiang 4f0f34f460 feat: refine supply map layers and workbench branding 2026-10-27 17:56:38 +08:00
jiang 6983ba03f1 docs: record deployed API port 2026-10-23 16:12:26 +08:00
jiang 4010acd414 docs: clarify peripheral proxy upstream 2026-10-23 16:11:34 +08:00
jiang 535b800401 fix: proxy peripheral API through main server 2026-10-23 16:06:01 +08:00
303 changed files with 20508 additions and 6117 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
} }
+9 -3
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 天) |
@@ -65,7 +67,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。 Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。外围样板推荐配置 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`;线上 NPMplus 在现有前端 Proxy Host 中增加 `/peripheral/` Custom Location,转发到外围服务 `8011`。开发和预览模式提供同路径代理,默认上游是 `http://127.0.0.1:8011`,可用 `TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖。语音播放始终请求同源 `/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。
@@ -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 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+266 -13
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
@@ -9,22 +51,75 @@
## 可用内容与交互 ## 可用内容与交互
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、 `Stack`、`Section`、`Text`、`Result`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表; `MapLocation`、`FollowUpForm` 共十种组件。图表可切换折线、柱状和数据表;
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问; 表格可搜索和排序,图表可切换已加载区间、显隐序列并查看数据点详情;
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始, 证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。 由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
数据组件用于展示与本地浏览,不提供勾选、加入分析、分析内容篮或针对组件追问。`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 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
| 组件 | 交互与内容处理 |
| --- | --- |
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `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` 的块仍按明确引用兼容读取;没有位置标记的旧答复继续在正文后追加引用块,新发布不再依赖这一兼容路径。
同一最终块从生成切换到状态保存时保持组件实例,不因启用持久化而重挂载;尚未完成的编辑先留在内存,最终答复完成后读取远端并合并本地变更。过程块不访问最终状态接口,关闭或切换会话也不产生远端写入。
已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生 已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生
生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成; 生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成;
@@ -34,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
{ {
@@ -59,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 可对照地图,其余宽度在两者之间切换;原地图实例保留。
## 扩展组件 ## 扩展组件
@@ -94,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
``` ```
@@ -103,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
``` ```
@@ -134,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/正文锚点规则不变。
+53 -8
View File
@@ -26,7 +26,7 @@ Agent 相关弹窗、面板及菜单统一保持本地 shadcn/ui 设计,具体
| 人工参与 | 权限审批、问题回答和跳过、补充资料 | 对应回复内保留可操作表单,确认前不执行 | | 人工参与 | 权限审批、问题回答和跳过、补充资料 | 对应回复内保留可操作表单,确认前不执行 |
| 分析结果 | 指标、表格、折线和柱状图、证据 | 回复中的交互内容,可搜索、排序、选点、筛选序列及已加载区间 | | 分析结果 | 指标、表格、折线和柱状图、证据 | 回复中的交互内容,可搜索、排序、选点、筛选序列及已加载区间 |
| 展开阅读 | 聊天回答与正式报告进入主工作区 | 明确点击进入,保留唯一组件实例、筛选、表单草稿和阅读位置 | | 展开阅读 | 聊天回答与正式报告进入主工作区 | 明确点击进入,保留唯一组件实例、筛选、表单草稿和阅读位置 |
| 内容追问 | 指标、数据点、表格行、证据 | 带来源的可删除引用;正式报告另带版本和轮次;确认后发送 | | 数据浏览 | 指标、数据点、表格、证据 | 展示与本地查看;不提供行勾选、分析篮或组件追问 |
| 正式报告 | 当前结论、结论边界、证据、缺口、待办、审批与历史版本 | 固定事实区与可选交互分析模块组合 | | 正式报告 | 当前结论、结论边界、证据、缺口、待办、审批与历史版本 | 固定事实区与可选交互分析模块组合 |
| 地图联动 | 定位实际要素、显示定位结果 | 对应内容内的明确按钮 | | 地图联动 | 定位实际要素、显示定位结果 | 对应内容内的明确按钮 |
| 回复工具 | 复制正文及交互摘要、朗读和选段朗读 | 回复底部低强调操作及文字选择入口 | | 回复工具 | 复制正文及交互摘要、朗读和选段朗读 | 回复底部低强调操作及文字选择入口 |
@@ -34,6 +34,8 @@ Agent 相关弹窗、面板及菜单统一保持本地 shadcn/ui 设计,具体
普通用户的阅读路径为:确认当前项目,提出问题,阅读回答,核查来源,再按需要定位地图或补充资料。审批和队列在有实际内容时出现,历史与处理过程按需展开。 普通用户的阅读路径为:确认当前项目,提出问题,阅读回答,核查来源,再按需要定位地图或补充资料。审批和队列在有实际内容时出现,历史与处理过程按需展开。
任务协作以对应任务名称作为初始标题,随后由 Agent 根据任务名称与对话内容生成具体标题;生成失败或仅返回通用名称时保留现有标题。“任务协作”仅作为历史列表中的类型标记。旧的通用标题随历史分页和详情读取逐步补齐,保留手动重命名及原有顺序;标题更新不重载消息或打断当前对话。
运行中的输入区使用一个外框:顶部显示待处理消息,正文最多预览两行,溢出时提供“展开全文”;每条消息只保留正文、简短的处理方式和一个菜单。编辑、处理方式切换、关闭排队统一进入该菜单。底栏在生成期间用“引导 / 排队”替代暂时不能切换的模型选择,生成完成后恢复模型选择。右下角的同一个按钮在运行且输入为空时停止,有内容时发送追加消息,空闲时发送新消息。输入正文默认高 64px,随多行内容自动增高至视口高度的 24%,最多 160px;超出后在输入区内部滚动,清空后恢复 64px;队列单独限制滚动高度,工作台支持的窄屏和短窗口保留可见的输入操作。 运行中的输入区使用一个外框:顶部显示待处理消息,正文最多预览两行,溢出时提供“展开全文”;每条消息只保留正文、简短的处理方式和一个菜单。编辑、处理方式切换、关闭排队统一进入该菜单。底栏在生成期间用“引导 / 排队”替代暂时不能切换的模型选择,生成完成后恢复模型选择。右下角的同一个按钮在运行且输入为空时停止,有内容时发送追加消息,空闲时发送新消息。输入正文默认高 64px,随多行内容自动增高至视口高度的 24%,最多 160px;超出后在输入区内部滚动,清空后恢复 64px;队列单独限制滚动高度,工作台支持的窄屏和短窗口保留可见的输入操作。
## 1. 交互参考 ## 1. 交互参考
@@ -112,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 连续调宽添加动画。系统开启减少动态效果时直接切换位置。表单实例和草稿在布局变化时保留。
@@ -156,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 输入接口进入原生收件箱。没有宿主执行轮次时只保存输入,不自行启动模型,也不把消息当作提问表单的答案。读取续接状态时,服务端可以依据已消费的原生回答和已完成的原生输出恢复丢失的确认;仅有部分文字或原生状态不可用时保留未确认状态。若输入在入队前被明确告知当前轮次已结束,客户端刷新状态后作为新一轮发送;其他错误不自动重发。
证据类别明确区分查询记录、用户提供、分析推断和演练数据。地图定位的按钮结果以实际回调为准;资料表单提交后只表示发送消息,业务证据是否保存取决于后端实际结果。最终回复不等于后台调查任务完成。 证据类别明确区分查询记录、用户提供、分析推断和演练数据。地图定位的按钮结果以实际回调为准;资料表单提交后只表示发送消息,业务证据是否保存取决于后端实际结果。最终回复不等于后台调查任务完成。
@@ -182,15 +216,17 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
## 7. 交互规则与范围 ## 7. 交互规则与范围
数据组件按展示与本地浏览组织:短内容不附加目录;单个证据或补充表单不叠加分组标题与固定提示;来源已在证据卡中显示时,报告外层只列出其余依据。调查脉络节点保留详情、缩放和地图定位,不提供节点追问按钮。
| 内容 | 本轮实现 | 数据与操作边界 | | 内容 | 本轮实现 | 数据与操作边界 |
| --- | --- | --- | | --- | --- | --- |
| 指标 | 值、单位、来源,针对指标提问 | 不补造变化率、单位或精度 | | 指标 | 值、单位、来源 | 不补造变化率、单位或精度 |
| 表格 | 搜索、三态排序、行选择、引用所选行 | 只处理当前数据;更换数据集时重置行选择 | | 表格 | 搜索、三态排序 | 只处理当前数据;不提供勾选与引用操作 |
| 图表 | 折线 / 柱状 / 数据,序列显隐,加载区间,点击或键盘选点 | 最少保留一条序列;区间不触发查询或重算 | | 图表 | 折线 / 柱状 / 数据,序列显隐,加载区间,点击或键盘选点 | 最少保留一条序列;区间不触发查询或重算 |
| 证据 | 来源、类别、时间、编号,展开与类别筛选 | 正式报告使用已保存证据覆盖生成的摘要、来源和类别 | | 证据 | 来源、类别、时间、编号,展开与类别筛选 | 正式报告使用已保存证据覆盖生成的摘要、来源和类别 |
| 地图 | 显式定位,地图对照或切换 | 使用真实定位结果与失败反馈 | | 地图 | 显式定位,地图对照或切换 | 使用真实定位结果与失败反馈 |
| 表单 | 空白初始值、必填校验、提交中禁用、失败保留 | 聊天表单主动发送;报告表单先带入对话,确认后发送 | | 表单 | 空白初始值、必填校验、提交中禁用、失败保留 | 聊天表单主动发送;报告表单先带入对话,确认后发送 |
| 追问 | 输入区引用卡片、详情、移除 | 不自动发送;报告引用带选中的版本与轮次 | | 任务与报告对话 | 输入区引用卡片、详情、移除 | 保留专用任务、报告和补充表单入口;通用数据组件不产生引用 |
| 历史内容 | 固定报告事实可读,不支持的块显示摘要 | 降级不执行未知组件或事件 | | 历史内容 | 固定报告事实可读,不支持的块显示摘要 | 降级不执行未知组件或事件 |
本轮未包含参数重算、新数据查询控件、附件上传、导出、报告版本差异比较。后续扩展时需要独立定义参数校验、数据来源和执行反馈;不能把本地筛选当作重新分析。后台任务完成、业务审批和证据写入继续以各自服务端状态为准。 本轮未包含参数重算、新数据查询控件、附件上传、导出、报告版本差异比较。后续扩展时需要独立定义参数校验、数据来源和执行反馈;不能把本地筛选当作重新分析。后台任务完成、业务审批和证据写入继续以各自服务端状态为准。
@@ -202,3 +238,12 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
固定报告字段不变:阶段、结果、结论、边界、数据缺口、证据和建议仍独立校验。新字段通过原有报告 JSON 保存,跟随 revision 与 execution_generation 读取;请求幂等沿用原有机制。旧报告缺省为空,不因缺省字段而产生虚假的报告更新通知。无需数据库表或环境变量迁移。 固定报告字段不变:阶段、结果、结论、边界、数据缺口、证据和建议仍独立校验。新字段通过原有报告 JSON 保存,跟随 revision 与 execution_generation 读取;请求幂等沿用原有机制。旧报告缺省为空,不因缺省字段而产生虚假的报告更新通知。无需数据库表或环境变量迁移。
组件参数唯一来源为前端 `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 的“状态和提醒”进入对应任务详情,由关联的负责节点或动作卡承接人工审核。会话只保留任务引用、讨论和消息,不注入固定的任务状态、人工事实或业务审核表单;调查详情继续负责草稿编辑、批准、拒绝及执行回执。工具执行权限审批和用户问题仍按会话交互协议处理。
## 调查报告与自由对话的分工
后台调查的固定问答放在右侧调查报告的“补充回答”中,支持选项、自定义文字、提交状态和历史回答。任务通知打开该报告;提交直接保存到原任务并接续调查,无需创建或发送左侧聊天消息。数据缺口旁的“回答当前调查问题”定位到报告内待答表单。
左侧 Agent 保留自由讨论、追问和进一步分析。“Agent 协作”和“讨论此缺口”是明确的讨论入口;固定任务回答不经过这些入口转发。
+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。
+4 -4
View File
@@ -1,6 +1,6 @@
# Agent Persona 设计 # Agent Persona 设计
更新日期:2026-10-22。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。 更新日期:2026-10-28。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
## 1. 形象与材质 ## 1. 形象与材质
@@ -68,9 +68,9 @@
## 5. 悬停与点击 ## 5. 悬停与点击
悬停沿用原版三层反射的尺寸、渐变、亮度和淡入淡出。白色高光跟随鼠标所在位置:从左侧移到右侧时,高光随之移动;鼠标离开后恢复常态。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。 悬停沿用原版三层反射的尺寸、渐变和亮度。进入以约 150ms 渐显;白色高光以约 80ms 的跟随时间常数趋近鼠标位置,保留轻微惯性。鼠标移出后先停留 100ms,再以约 600ms 淡出,退场期间保留最后的跟随位置。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。
共享 `Persona` 保留 Rive 原生指针监听,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。 共享 `Persona` 保留 Rive 原生指针监听,经由 `persona-hover.ts` 平滑 CSS 坐标后,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。150ms / 600ms 渐变由 Rive 时间轴负责,进出切换以 100ms 状态混合衔接;所有时间参数集中在 [persona-hover-timing.json](../src/shared/ai-elements/persona-hover-timing.json)。100ms 停留内重新进入会取消退场,淡出过程中重新进入从当前坐标继续跟随;取消事件立即释放悬停,卸载时清理动画帧和计时器。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。
点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。 点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。
@@ -93,7 +93,7 @@
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) | | 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) | | 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。 材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束,仅调整时间轴与指针跟随节奏;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。 验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
+7 -5
View File
@@ -11,8 +11,9 @@ Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 | | [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 | | [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 | | [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 |
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 | | [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,879 字节资源 | | [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,929 字节资源 |
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 | | [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 | | [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 | | [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
@@ -64,13 +65,14 @@ JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。 - 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。 - 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。 - 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。其他输入、状态层和交互保持原定义。 - 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。
- 悬停保持原版反射几何、渐变与亮度,将 `hover_in` / `hover_out` 分别调整为 9 / 36 帧,即 150ms / 600ms,并使用原有贝塞尔插值器。退出时间轴持续保持节点 129 的指针约束,完全淡出后才释放;进出切换使用 100ms 混合。
生成结果包含 957 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。 生成结果包含 960 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
## 前端接入与生命周期 ## 前端接入与生命周期
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 向画布转交鼠标的进入、移动和离开坐标,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。 悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 与 `persona-hover.ts` 向画布转交鼠标的进入、移动和离开坐标;移动坐标以 80ms 时间常数平滑,移出延迟 100ms 后交给资源淡出,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。 `persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
@@ -88,4 +90,4 @@ Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.ri
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。 原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。 对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
+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 下均无横向溢出。
+35 -3
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` 的遮罩、边框、阴影与交互样式。
@@ -77,11 +95,25 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
状态使用 `status-tone-neutral/info/success/warning/danger` 与共享 `StatusBadge`、`StatusIcon`、`StatusDot`,同时提供文字或图标。业务状态、交互选中和数据系列颜色各自独立;`material-tone-agent` 只标记 Agent 解释或建议,原始监测值和人工资料继续使用普通阅读面。 状态使用 `status-tone-neutral/info/success/warning/danger` 与共享 `StatusBadge`、`StatusIcon`、`StatusDot`,同时提供文字或图标。业务状态、交互选中和数据系列颜色各自独立;`material-tone-agent` 只标记 Agent 解释或建议,原始监测值和人工资料继续使用普通阅读面。
地图中的管线一起显示,以管径对应的颜色和线宽体现差异。水库、泵站、水箱和水质监测点保持清晰;阀门、普通节点与常规 SCADA 使用随缩放连续变化的大小和透明度。密集设施的工程符号自动避让,底层保留轻量圆点以表达位置和支持点选。选中与异常分析标记单独突出,普通设施以连续变化为主;普通节点从 12 级、阀门从 13 级开始显示,图标、标注与命中范围同步限制。
Agent、调查展示和 Dev 本地预览的要素定位复用地图调试的选中样式,按原业务源保留线宽、边框、圆环半径、设备类型过滤和缩放规则。展示覆盖层从当前地图样式派生,独立管理高亮与清理范围;闪烁只改变强调透明度,结束后恢复静态样式。
管道透明度不随缩放变化,缩放渐变体现在管线与描边的宽度上,远看纤细、放大清晰;图层开关时仍使用透明度淡入淡出。
图层开关统一使用约 320ms 的透明度淡入淡出,覆盖管线、设施、监测点、GIS 原始数据和模拟结果。矢量瓦片可绘制后才开始淡入计时,避免加载期间耗尽过渡时间。淡入淡出只临时叠加透明度系数,结束后恢复图层原有样式;快速反向切换从当前进度继续。隐藏时立即关闭命中层与交互强调层,淡入结束后恢复;减少动态效果偏好下直接切换。管线用过滤器区分开关状态,透明度保持统一数值,避免逐帧重写属性表达式触发瓦片重算。
重要设施也使用连续缩放插值,但图标的最低透明度保持较高,衬底更轻,保证概览可辨识。按需开启的数值标签随缩放渐变字号与透明度;使用者设置的字号作为放大后的目标字号。
水质、压力和流量监测点统一从 11 级开始显示,图标、圆点、交互层和图层面板提示共用缩放起点。水质与压力监测点使用一致的圆点、衬底尺寸和图标屏幕像素宽度。图标缩放系数需结合原图尺寸换算,水质图标 128px、压力图标 96px,不直接使用相同系数。
GIS 原始图层与对应业务类型共用起始级别:GIS 管线与 DN100+ 台账从 8.5 级显示,流量仪从 11 级显示,消火栓、接水点、用户及非居民水表从 12 级显示,GIS 阀门与排水阀从 13 级显示。图层提示、图形和点击范围保持一致。
Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列为待迁移到 Acrylic 的差异。已有 Agent 专用配色、材质和控件 CSS 覆盖应在对应组件调整时收敛到本地 shadcn/ui 样式;是否完成以运行界面验收为准。 Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列为待迁移到 Acrylic 的差异。已有 Agent 专用配色、材质和控件 CSS 覆盖应在对应组件调整时收敛到本地 shadcn/ui 样式;是否完成以运行界面验收为准。
## 4. 材质与层级 ## 4. 材质与层级
本节的 Acrylic 材质规则适用于地图与其他工作台界面;Agent 弹窗与面板按前述 shadcn/ui 规则执行。Acrylic 通过半透明色调、背景模糊、混合层和细噪点建立层级,边缘高光与阴影保持克制。常驻、参与布局的业务区域使用实体底板;Mica 仅作为这一底板角色的设计参考,Web CSS 实体色不等同于原生系统 Mica。 本节的 Acrylic 材质规则适用于地图与其他工作台界面;Agent、Dev 测试、外围系统、调查与本地展示面板按前述 shadcn/ui 规则执行。Acrylic 通过半透明色调、背景模糊、混合层和细噪点建立层级,边缘高光与阴影保持克制。常驻、参与布局的业务区域使用实体底板;Mica 仅作为这一底板角色的设计参考,Web CSS 实体色不等同于原生系统 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

Binary file not shown.
+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

+28 -1
View File
@@ -12,6 +12,7 @@ const designUrl = new URL("../design/agent/obsidian-offline.json", import.meta.u
const defaultDesign = JSON.parse(await readFile(designUrl, "utf8")); const defaultDesign = JSON.parse(await readFile(designUrl, "utf8"));
const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url); const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url);
const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8")); const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8"));
const hoverTiming = JSON.parse(await readFile(new URL("../src/shared/ai-elements/persona-hover-timing.json", import.meta.url), "utf8"));
function uint(value) { function uint(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`); if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`);
@@ -172,14 +173,40 @@ export function buildOfflinePersona(source, offline = defaultDesign, material =
const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447); const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447);
let animation; let animation;
let machineLayer;
for (const object of objects) { for (const object of objects) {
if (object.type === 31) animation = valueOf(object, 55); if (object.type === 31) {
animation = valueOf(object, 55);
if (animation === "hover_in" || animation === "hover_out") {
setValue(object, 57, Math.round((animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs) * 60 / 1000));
}
}
// Change timing only, keeping the original reflection geometry and color.
if ((animation === "hover_in" || animation === "hover_out") && object.type === 30) {
const durationMs = animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs;
setValue(object, 67, Math.round(valueOf(object, 67, 0) * durationMs / 1000));
setValue(object, 68, 2);
setValue(object, 69, 261);
}
if (object.type === 57) machineLayer = valueOf(object, 138);
if (machineLayer === "hover" && object.type === 65 && valueOf(object, 152, 0) === 12) {
setValue(object, 158, hoverTiming.blendMs);
}
// These tracks only control opacity and scale. Keep the complete sphere. // These tracks only control opacity and scale. Keep the complete sphere.
if ((animation === "asleep" || animation === "wake") && object.type === 30) { if ((animation === "asleep" || animation === "wake") && object.type === 30) {
setValue(object, 70, 1); setValue(object, 70, 1);
} }
} }
const hoverOutEnd = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === "on_loop_1");
// Keep the pointer constraint active until the outgoing reflection is fully
// transparent. The original hover_off timeline releases it afterwards.
objects.splice(hoverOutEnd, 0,
{ type: 25, values: [[51, 129]] },
{ type: 26, values: [[53, 172]] },
{ type: 30, values: [[68, 1], [70, 1]] }
);
const loops = []; const loops = [];
for (let number = 1; number <= 3; number++) { for (let number = 1; number <= 3; number++) {
const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`); const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`);
+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;
};
}
+11 -5
View File
@@ -17,8 +17,14 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
}); });
await page.goto("/"); await page.goto("/");
await expect(page.getByText("供水管网智能调度系统")).toBeVisible(); await expect(page).toHaveTitle("供水管网智慧运维系统");
await expect(page.getByText("调度工作台")).toBeVisible(); 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.getByLabel("Agent 折叠栏")).toBeVisible(); await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0); await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
@@ -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");
}); });
@@ -0,0 +1,70 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AnswerUiView } from "./answer-ui-view";
import { parseAnswerUiBlock } from "./contract";
vi.mock("echarts-for-react", () => ({ default: () => <div /> }));
function block(type: string, props: Record<string, unknown>) {
const result = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "actions", fallback_text: "资料", spec: { root: "action", elements: { action: { type, props, children: [] } } } });
if (!result) throw new Error("invalid fixture");
return result;
}
const location = block("MapLocation", { label: "定位要素", source: "查询结果", layer: "pipes", feature_ids: ["P1", "P2"] });
const form = block("FollowUpForm", { title: "现场资料", description: "用户补充", fields: [{ name: "record", label: "记录", kind: "text", required: true }] });
describe("answer action states", () => {
it.each([
[{ located_ids: ["P1", "P2"], missing_ids: [] }, "success", "已定位 2 个"],
[{ located_ids: ["P1"], missing_ids: ["P2"] }, "partial", "另有 1 个未找到"],
[{ located_ids: [], missing_ids: ["P1", "P2"] }, "error", "未找到可定位"],
[undefined, "unconfirmed", "未返回定位结果"]
])("reports verified location outcomes %j", async (result, state, text) => {
render(<AnswerUiView block={location} onLocate={vi.fn().mockResolvedValue(result)} />);
fireEvent.click(screen.getByRole("button", { name: "定位要素" }));
await waitFor(() => expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("data-state", state));
expect(screen.getByRole("status")).toHaveTextContent(text);
});
it("explains unsupported location", () => {
render(<AnswerUiView block={location} />);
expect(screen.getByRole("button", { name: "定位要素" })).toBeDisabled();
expect(screen.getByText("当前视图不支持地图定位,可查看关联要素编号。")).toBeInTheDocument();
});
it("locks pending location and supports retry after rejection", async () => {
let reject!: (error: Error) => void;
const locate = vi.fn().mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })).mockResolvedValue({ located_ids: ["P1"], missing_ids: ["P2"] });
render(<AnswerUiView block={location} onLocate={locate} />);
fireEvent.click(screen.getByRole("button", { name: "定位要素" }));
expect(screen.getByRole("group", { name: "定位要素" })).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("button", { name: "正在定位…" })).toBeDisabled();
await act(async () => reject(new Error("网络失败")));
fireEvent.click(screen.getByRole("button", { name: "重试定位要素" }));
await waitFor(() => expect(locate).toHaveBeenCalledTimes(2));
});
it("associates required errors, retains failed drafts and clears success when editing", async () => {
let complete!: () => void;
const submit = vi.fn().mockRejectedValueOnce(new Error("发送失败")).mockImplementationOnce(() => new Promise<void>((resolve) => { complete = resolve; }));
render(<AnswerUiView block={form} onSubmit={submit} />);
const editor = screen.getByLabelText("记录(必填)");
const formElement = screen.getByRole("form", { name: "现场资料" });
fireEvent.submit(formElement);
expect(editor).toHaveAttribute("aria-invalid", "true");
expect(editor).toHaveAccessibleDescription("请填写记录。");
fireEvent.change(editor, { target: { value: "草稿" } });
fireEvent.submit(formElement);
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("发送失败"));
expect(editor).toHaveValue("草稿");
fireEvent.submit(formElement);
fireEvent.submit(formElement);
expect(submit).toHaveBeenCalledTimes(2);
expect(formElement).toHaveAttribute("aria-busy", "true");
await act(async () => complete());
expect(screen.getByRole("status")).toHaveTextContent("资料已发送");
fireEvent.change(editor, { target: { value: "更新后的草稿" } });
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.getByRole("group", { name: "现场资料" })).toHaveAttribute("data-state", "idle");
});
it("confirms draft transfer without claiming submission", async () => {
render(<AnswerUiView block={form} submitMode="draft" onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText("记录(必填)"), { target: { value: "草稿" } });
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("资料已带入对话,请确认后发送"));
});
});
@@ -0,0 +1,232 @@
import { useContext, useId, useRef, useState } from "react";
import { ArrowUpRight, CheckCircle2, FileCheck2, Info, LoaderCircle, MapPin, TriangleAlert } from "lucide-react";
import { useAnswerMotion } from "./answer-motion-context";
import { motion } from "motion/react";
import { agentEase } from "../components/agent-motion";
import { AnswerDisclosure, AnswerDisclosureChevron } from "./answer-motion";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { useAnswerNodeState } from "./answer-view-state-provider";
import { answerStatePointer, type AnswerFormState } from "./answer-view-state";
type ActionState = "idle" | "pending" | "success" | "partial" | "unconfirmed" | "error";
function ActionFeedback({ state, children }: { state: ActionState; children: string }) {
const shouldAnimate = useAnswerMotion();
const animate = shouldAnimate();
const Icon = state === "pending" ? LoaderCircle : state === "success" ? CheckCircle2 : state === "error" || state === "partial" ? TriangleAlert : Info;
return <motion.p key={state} initial={animate ? { opacity: 0, y: 3 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }} role={state === "idle" ? undefined : "status"} className="mt-1 flex items-start gap-1.5 text-xs leading-5 text-muted-foreground"><Icon size={14} className={`mt-0.5 shrink-0 ${state === "pending" && animate ? "motion-safe:animate-spin" : ""}`} aria-hidden />{children}</motion.p>;
}
export function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const runningRef = useRef(false);
const [showIds, setShowIds] = useState(false);
const [state, setState] = useState<ActionState>("idle");
const running = state === "pending";
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-location" role="group" aria-label={label} data-state={state} aria-busy={running}>
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
if (!onLocate || disabled || runningRef.current) return;
runningRef.current = true;
setState("pending");
setFeedback(null);
try {
const result = await onLocate(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
if (!output || !Array.isArray(output.located_ids)) {
setState("unconfirmed");
setFeedback("定位请求已处理,未返回定位结果,请查看地图确认。");
} else {
const located = output.located_ids.length;
const missing = output.missing_ids?.length ?? 0;
setState(!located ? "error" : missing ? "partial" : "success");
setFeedback(!located ? "未找到可定位的关联要素,请检查要素编号后重试。" : missing ? `已定位 ${located} 个,另有 ${missing} 个未找到` : `已定位 ${located} 个关联要素到地图`);
}
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "地图定位失败,请重试。");
} finally {
runningRef.current = false;
}
}}
>
{running ? "正在定位…" : state === "error" ? `重试${label}` : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{target.feature_ids.length} 个关联要素 · 依据:{source}</p>
<AnswerDisclosure open={showIds} onOpenChange={setShowIds} className="mt-1 text-[11px] leading-5 text-muted-foreground"
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>
</AnswerDisclosure>
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div>
</Card>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
export function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [{ values }, patch] = useAnswerNodeState<AnswerFormState>("FollowUpForm", { title, description, fields });
const submittingRef = useRef(false);
const [state, setState] = useState<ActionState>("idle");
const submitting = state === "pending";
const [invalidFields, setInvalidFields] = useState<string[]>([]);
const setValue = (name: string, value: string) => {
patch({ values: { ...values, [name]: value } }, [`values/${answerStatePointer(name)}`]);
setState("idle");
setFeedback(null);
setInvalidFields((current) => current.filter((field) => field !== name));
};
const [feedback, setFeedback] = useState<string | null>(null);
return (
<Card className="answer-ui-form" role="group" aria-label={title} data-state={state} aria-busy={submitting}>
<CardHeader className="gap-2 border-b border-border p-3.5">
<CardTitle className="flex items-center gap-2 text-sm leading-6 tracking-normal"><FileCheck2 size={14} className="shrink-0 text-muted-foreground" aria-hidden />{title}</CardTitle>
<CardDescription className="text-xs leading-5">{description}</CardDescription>
</CardHeader>
<CardContent className="p-3.5">
<form
className="space-y-3"
aria-label={title}
aria-busy={submitting}
noValidate
onSubmit={async (event) => {
event.preventDefault();
if (!onSubmit || disabled || submittingRef.current) return;
const missing = fields.filter((field) => field.required && !values[field.name]?.trim()).map((field) => field.name);
setInvalidFields(missing);
if (missing.length) {
setState("error");
setFeedback("请填写必填资料。");
return;
}
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setState("error");
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setState("error");
setFeedback("补充资料过长,请分次发送。");
return;
}
submittingRef.current = true;
setState("pending");
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setState("success");
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setState("error");
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
submittingRef.current = false;
}
}}
>
{fields.map((field) => (
<div key={field.name} role={field.kind === "datetime" ? "group" : undefined} aria-label={field.kind === "datetime" ? `${field.label}${field.required ? "(必填)" : ""}` : undefined} aria-invalid={invalidFields.includes(field.name) || undefined} aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}>
{field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValue(field.name, value)}
/>
) : (
<div key={field.name} className="space-y-1.5">
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
{field.label}
{field.required ? "(必填)" : ""}
</label>
{field.kind === "text" ? (
<Textarea
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
aria-required={field.required}
aria-invalid={invalidFields.includes(field.name) || undefined}
aria-describedby={invalidFields.includes(field.name) ? `${prefix}-${field.name}-error` : undefined}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValue(field.name, value);
}}
/>
)}
</div>
)}
{invalidFields.includes(field.name) ? <p id={`${prefix}-${field.name}-error`} className="mt-1 text-xs leading-5 text-destructive">请填写{field.label}。</p> : null}
</div>
))}
<Button
type="submit"
size="sm"
variant="default"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback || submitting || !onSubmit ? <ActionFeedback state={state}>{submitting ? submitMode === "draft" ? "正在带入对话…" : "正在发送补充资料…" : feedback ?? "当前视图无法提交资料。"}</ActionFeedback> : null}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
import { useRef, type RefObject } from "react";
import { ChartNoAxesCombined, ClipboardPenLine, Gauge, MapPin, BookOpenCheck, Table2, ListTree, ChevronDown, CircleCheck } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import type { AnswerUiBlock } from "./contract";
import { canExpandAnswer } from "./answer-presentation";
const contentKinds = [
{ type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" },
{ type: "Metric", label: "指标", icon: Gauge, selector: ".answer-ui-metric" },
{ type: "Chart", label: "趋势", icon: ChartNoAxesCombined, selector: ".answer-ui-chart" },
{ type: "Table", label: "表格", icon: Table2, selector: "[data-answer-table]" },
{ type: "Evidence", label: "依据", icon: BookOpenCheck, selector: ".answer-ui-evidence" },
{ type: "MapLocation", label: "地图", icon: MapPin, selector: ".answer-ui-location" },
{ type: "FollowUpForm", label: "补充资料", icon: ClipboardPenLine, selector: ".answer-ui-form" }
] as const;
/** Navigate the mounted answer so chart selections and unsent forms survive. */
export function AnswerContentNav({ block, contentRef }: {
block: AnswerUiBlock;
contentRef: RefObject<HTMLDivElement | null>;
}) {
const navigationTarget = useRef<HTMLElement | null>(null);
const elements = Object.values(block.spec.elements);
const items = contentKinds.map((kind) => ({
...kind, count: elements.filter((element) => element.type === kind.type).length
})).filter((item) => item.count > 0);
if (items.length < 2 || !canExpandAnswer([{ type: "answer-ui", block }])) return null;
return (
<nav aria-label="回答内容导航" className="answer-content-nav">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-9 gap-1.5 px-2 text-xs font-normal">
<ListTree size={14} aria-hidden />浏览回答<ChevronDown size={12} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" data-agent-theme="chat" className="min-w-48"
onCloseAutoFocus={(event) => {
const target = navigationTarget.current;
navigationTarget.current = null;
if (!target?.isConnected) return;
event.preventDefault();
target.tabIndex = -1;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "start", behavior: "instant" });
}}>
{items.map(({ type, label, icon: Icon, selector, count }) => (
<DropdownMenuItem key={type} className="min-h-10 gap-2 text-xs"
aria-label={`查看${label},${count}项`}
onSelect={() => {
const target = contentRef.current?.querySelector<HTMLElement>(selector) ?? null;
const section = target?.closest(".answer-task-support-section[data-state=closed]");
section?.querySelector<HTMLButtonElement>(":scope > button")?.click();
navigationTarget.current = target;
}}>
<Icon size={14} aria-hidden />{label}
<Badge variant="secondary" className="ml-auto min-w-4 justify-center rounded-sm border-0 px-1 py-0 text-[10px] tabular-nums">
{count}
</Badge>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="text-[11px] text-muted-foreground">
{items.map((item, index) => <span key={item.type} className="inline-block whitespace-nowrap">
{index > 0 ? <span aria-hidden> · </span> : null}{item.label}
</span>)}
</span>
</nav>
);
}
@@ -0,0 +1,4 @@
import { createContext, useContext } from "react";
export const AnswerMotionContext = createContext<() => boolean>(() => false);
export const useAnswerMotion = () => useContext(AnswerMotionContext);
@@ -0,0 +1,72 @@
import { forwardRef, useCallback, useLayoutEffect, useRef, useState, type ComponentProps, type ReactElement, type ReactNode } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { agentEase } from "../components/agent-motion";
import { AnswerMotionContext, useAnswerMotion } from "./answer-motion-context";
/** Initial rendering is instant; subsequent transitions follow pointer or keyboard modality. */
export function AnswerMotionScope({ children, ...props }: ComponentProps<"div">) {
const pointer = useRef(false);
const reducedMotion = useReducedMotion();
const shouldAnimate = useCallback(() => pointer.current && !reducedMotion, [reducedMotion]);
return <AnswerMotionContext.Provider value={shouldAnimate}>
<div {...props} onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => { pointer.current = false; }}
onClickCapture={(event) => { if (event.detail === 0) pointer.current = false; }}>{children}</div>
</AnswerMotionContext.Provider>;
}
/** Keep the same mounted editors; retiring content is immediately inert and hidden from AT. */
export const AnswerReveal = forwardRef<HTMLDivElement, ComponentProps<typeof motion.div> & { open: boolean }>(
function AnswerReveal({ open, children, ...props }, ref) {
const shouldAnimate = useAnswerMotion();
const reducedMotion = useReducedMotion();
const controls = useAnimationControls();
const previous = useRef(open);
const [retained, setRetained] = useState(open);
useLayoutEffect(() => {
const changed = previous.current !== open;
previous.current = open;
controls.stop();
if (!changed || !shouldAnimate() || reducedMotion) {
controls.set({ opacity: 1, y: 0 });
setRetained(open);
return;
}
let cancelled = false;
if (open) {
setRetained(true);
controls.set({ opacity: 0, y: -4 });
}
void controls.start(open ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
duration: 0.18, ease: agentEase
}).then(() => { if (!cancelled) setRetained(open); });
return () => { cancelled = true; controls.stop(); };
}, [controls, open, reducedMotion, shouldAnimate]);
return <motion.div {...props} ref={ref} data-answer-reveal data-open={open}
hidden={!open && !retained} inert={!open} aria-hidden={!open || undefined}
initial={false} animate={controls}>{children}</motion.div>;
}
);
export function AnswerDisclosure({ open, onOpenChange, trigger, children, contentClassName, ...props }: {
open: boolean; onOpenChange: (open: boolean) => void; trigger: ReactElement; children: ReactNode;
contentClassName?: string;
} & Omit<ComponentProps<typeof Collapsible>, "children" | "open" | "onOpenChange">) {
return <Collapsible {...props} open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>{trigger}</CollapsibleTrigger>
<CollapsibleContent forceMount asChild>
<AnswerReveal open={open} className={contentClassName}>{children}</AnswerReveal>
</CollapsibleContent>
</Collapsible>;
}
export function AnswerDisclosureChevron({ open }: { open: boolean }) {
const shouldAnimate = useAnswerMotion();
return <motion.span aria-hidden className="inline-flex shrink-0" initial={false}
animate={{ rotate: open ? 180 : 0 }} transition={{ duration: shouldAnimate() ? 0.18 : 0, ease: agentEase }}>
<ChevronDown size={14} />
</motion.span>;
}
@@ -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"> <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,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentChatMessage } from "../types"; import type { AgentChatMessage } from "../types";
import { getAnswerPresentation } from "./answer-presentation"; import { canExpandAnswer, getAnswerPresentation, getFinalAnswerTranscript } from "./answer-presentation";
import type { AnswerUiPart } from "./contract"; import type { AnswerUiPart } from "./contract";
const ui = (id: string): AnswerUiPart => ({ const ui = (id: string): AnswerUiPart => ({
@@ -28,6 +28,17 @@ const message: AgentChatMessage = {
answerParts: parts answerParts: parts
}; };
describe("final report and collapsible process", () => { describe("final report and collapsible process", () => {
it("keeps short warnings and location confirmations inline while retaining report readers", () => {
const note = ui("note");
if (note.type !== "answer-ui") throw new Error("invalid fixture");
note.block.spec.elements.text.props = { text: "当前工具不能确认高亮。", tone: "warning" };
expect(canExpandAnswer([{ type: "text", text: "已定位管道。" }, note])).toBe(false);
expect(canExpandAnswer([{ type: "text", text: "完整报告".repeat(300) }])).toBe(true);
for (const type of ["Table", "Chart", "FollowUpForm"]) {
note.block.spec.elements.text.type = type;
expect(canExpandAnswer([note])).toBe(true);
}
});
it("shows all incremental content while waiting for final_answer", () => { it("shows all incremental content while waiting for final_answer", () => {
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false }); expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
}); });
@@ -52,3 +63,68 @@ describe("final report and collapsible process", () => {
expect(view.process).toEqual(parts); expect(view.process).toEqual(parts);
}); });
}); });
function phasedUi(id: string, phase: "process" | "final"): AnswerUiPart {
const part = ui(id);
if (part.type === "answer-ui") {
part.block.phase = phase;
part.block.fallback_text = `摘要 ${id}`;
}
return part;
}
describe("final UI anchors", () => {
const chart = phasedUi("chart", "final");
const progress = phasedUi("progress", "process");
it("separates explicit process blocks from the report before a final answer arrives", () => {
const view = getAnswerPresentation({ ...message, answerParts: [parts[0], progress, chart, parts[3]] });
expect(view.report).toEqual([parts[0], chart, parts[3]]);
expect(view.process).toEqual([progress]);
expect(view.final).toBe(false);
});
const anchoredMessage = (content: string, uiBlockIds = ["chart", "progress"]): AgentChatMessage => ({
...message,
answerParts: [parts[0], progress, chart, parts[3]],
finalAnswer: { content, uiBlockIds }
});
it("places final UI between the original Markdown sections and mounts duplicates once", () => {
const content = "# 分析\n\n- 第一项\n\n[[ui:chart]]\n\n## 建议\n保留换行\n[[ui:chart]]\n";
const view = getAnswerPresentation(anchoredMessage(content));
expect(view.report).toEqual([
{ type: "text", text: "# 分析\n\n- 第一项\n\n" },
chart,
{ type: "text", text: "\n## 建议\n保留换行\n" }
]);
expect(view.process).toEqual([parts[0], progress, parts[3]]);
expect(getFinalAnswerTranscript(anchoredMessage(content))).toBe("# 分析\n\n- 第一项\n\n摘要 chart\n## 建议\n保留换行\n");
});
it("never includes process UI even when referenced and anchored", () => {
const value = anchoredMessage("结论\n[[ui:progress]]\n[[ui:missing]]\n[[ui:chart]]", ["progress", "missing"]);
const view = getAnswerPresentation(value);
expect(view.report.every((part) => part.type === "text")).toBe(true);
expect(view.process).toEqual([parts[0], progress, chart, parts[3]]);
const transcript = getFinalAnswerTranscript(value);
expect(transcript).not.toContain("progress");
expect(transcript).not.toContain("missing");
expect(transcript).not.toContain("chart");
expect(transcript).toContain("暂不可用");
});
it("preserves fenced, indented, and quoted markers as literal Markdown", () => {
const literal = "```md\n[[ui:chart]]\n```\n\n~~~\n[[ui:chart]]\n~~~\n\n [[ui:chart]]\n\n> [[ui:chart]]\n";
const view = getAnswerPresentation(anchoredMessage(literal, []));
expect(view.report).toEqual([{ type: "text", text: literal }]);
});
it("retains legacy references and appends final references without anchors", () => {
const view = getAnswerPresentation(anchoredMessage("报告"));
expect(view.report).toEqual([{ type: "text", text: "报告" }, chart]);
expect(getFinalAnswerTranscript(anchoredMessage("报告"))).toBe("报告\n\n摘要 chart");
});
it("does not expand a short answer because of marker identifiers", () => {
expect(canExpandAnswer([{ type: "text", text: `结论\n[[ui:${"x".repeat(1500)}]]` }])).toBe(false);
});
});
@@ -1,25 +1,103 @@
import type { AgentChatMessage } from "../types"; import type { AgentChatMessage } from "../types";
import type { AnswerUiPart } from "./contract"; import type { AnswerUiPart } from "./contract";
/** Short notes and map confirmations stay inline; dense data and reports can expand. */
export function canExpandAnswer(parts: AnswerUiPart[]): boolean {
let textLength = 0;
for (const part of parts) {
if (part.type === "text") {
textLength += part.text.replace(/\[\[ui:[^\]\r\n]*\]\]/g, "").length;
} else {
for (const node of Object.values(part.block.spec.elements)) {
if (["Table", "Chart", "FollowUpForm"].includes(node.type)) return true;
if (node.type === "Text" && typeof node.props.text === "string") textLength += node.props.text.length;
if (node.type === "Evidence" && typeof node.props.summary === "string") textLength += node.props.summary.length;
if (node.type === "Result" && typeof node.props.summary === "string") textLength += node.props.summary.length;
}
}
}
return textLength >= 1200;
}
/** Readable copy/TTS text for the same ordered parts shown in the answer. */
export function getAnswerPartsTranscript(parts: AnswerUiPart[]): string {
return parts.reduce((text, part) => {
const next = part.type === "text" ? part.text : part.block.fallback_text;
if (!text || !next) return text + next;
return text + (text.endsWith("\n") || next.startsWith("\n") ? "" : "\n\n") + next;
}, "");
}
/** Final report transcript excludes process blocks and resolves authorized anchors. */
export function getFinalAnswerTranscript(message: AgentChatMessage): string {
return getAnswerPartsTranscript(getAnswerPresentation(message).report);
}
function splitAnchors(content: string): Array<{ text: string } | { id: string }> {
const result: Array<{ text: string } | { id: string }> = [];
let buffer = "";
let fence: { character: string; length: number } | undefined;
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
const bare = line.replace(/\r?\n$/, "");
const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(bare);
if (fence) {
buffer += line;
if (delimiter && delimiter[1][0] === fence.character && delimiter[1].length >= fence.length && !delimiter[2].trim()) fence = undefined;
continue;
}
if (delimiter) {
fence = { character: delimiter[1][0], length: delimiter[1].length };
buffer += line;
continue;
}
const anchor = /^ {0,3}\[\[ui:([^\]\r\n]*)\]\][ \t]*$/.exec(bare);
if (!anchor) { buffer += line; continue; }
if (buffer.trim()) result.push({ text: buffer });
buffer = "";
result.push({ id: anchor[1] });
}
if (buffer.trim()) result.push({ text: buffer });
return result;
}
export function getAnswerPresentation(message: AgentChatMessage): { export function getAnswerPresentation(message: AgentChatMessage): {
report: AnswerUiPart[]; report: AnswerUiPart[];
process: AnswerUiPart[]; process: AnswerUiPart[];
final: boolean; final: boolean;
} { } {
const parts = const parts = message.answerParts ??
message.answerParts ??
(message.content ? [{ type: "text" as const, text: message.content }] : []); (message.content ? [{ type: "text" as const, text: message.content }] : []);
if (!message.finalAnswer) return { report: parts, process: [], final: false }; if (!message.finalAnswer) {
const selected = new Set(message.finalAnswer.uiBlockIds); const isProcess = (part: AnswerUiPart) => part.type === "answer-ui" && part.block.phase === "process";
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }]; return { report: parts.filter((part) => !isProcess(part)), process: parts.filter(isProcess), final: false };
for (const id of selected) {
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
if (part) report.push(part);
} }
const process = parts.filter((part) => const selected = new Set(message.finalAnswer.uiBlockIds);
part.type === "answer-ui" const available = new Map(parts.flatMap((part) =>
? !selected.has(part.block.block_id) part.type === "answer-ui" ? [[part.block.block_id, part] as const] : []));
: part.text.trim() !== message.finalAnswer!.content.trim() const mounted = new Set<string>();
); const anchored = new Set<string>();
const report: AnswerUiPart[] = [];
for (const segment of splitAnchors(message.finalAnswer.content)) {
if ("text" in segment) { report.push({ type: "text", text: segment.text }); continue; }
anchored.add(segment.id);
const part = available.get(segment.id);
if (!selected.has(segment.id) || !part || part.block.phase === "process") {
report.push({ type: "text", text: "交互内容暂不可用。\n" });
} else if (!mounted.has(segment.id)) {
mounted.add(segment.id);
report.push(part);
}
}
// Old reports carried references without positional anchors.
for (const id of selected) {
const part = available.get(id);
if (part && part.block.phase !== "process" && !mounted.has(id) && !anchored.has(id)) {
mounted.add(id);
report.push(part);
}
}
const process = parts.filter((part) => part.type === "answer-ui"
? !mounted.has(part.block.block_id)
: part.text.trim() !== message.finalAnswer!.content.trim());
return { report, process, final: true }; return { report, process, final: true };
} }
@@ -0,0 +1,29 @@
import { CircleCheck, CircleHelp, CircleX, TriangleAlert } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
const resultStatuses = {
success: { label: "成功", icon: CircleCheck, color: "text-[var(--status-success-foreground)]" },
partial: { label: "部分完成", icon: TriangleAlert, color: "text-[var(--status-warning-foreground)]" },
unconfirmed: { label: "未确认", icon: CircleHelp, color: "text-muted-foreground" },
error: { label: "失败", icon: CircleX, color: "text-destructive" }
};
/** A summary of recorded evidence, never an executable operation or approval. */
export function AnswerResult({ title, summary, status, source }: {
title: string;
summary: string;
status: keyof typeof resultStatuses;
source: string | null;
}) {
const { label, icon: Icon, color } = resultStatuses[status];
return <section data-answer-result data-result-status={status} role="group" aria-label={title}
className="min-w-0 rounded-lg border border-border bg-card px-3.5 py-3 text-card-foreground">
<div className="flex flex-wrap items-start gap-x-2 gap-y-1.5">
<Icon className={`mt-1 size-4 shrink-0 ${color}`} aria-hidden />
<h3 className="min-w-0 flex-1 text-sm font-medium leading-6">{title}</h3>
<Badge variant="outline" className="mt-0.5 shrink-0 px-1.5 py-0 text-[11px] font-normal shadow-none">{label}</Badge>
</div>
<p className="mt-2 whitespace-pre-wrap text-base leading-7">{summary}</p>
{source ? <p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">依据:{source}</p> : null}
</section>;
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { getAnswerTaskLayout } from "./answer-task-layout";
describe("answer task composition", () => {
const nodes: Spec["elements"] = {
root: { type: "Stack", props: { columns: "one" }, children: ["comparison", "data", "evidence", "mixed", "form"] },
comparison: { type: "Stack", props: { columns: "two" }, children: ["metric-a", "metric-b"] },
"metric-a": { type: "Metric", props: {} },
"metric-b": { type: "Metric", props: {} },
data: { type: "Section", props: { title: "趋势" }, children: ["chart", "table"] },
chart: { type: "Chart", props: {} },
table: { type: "Table", props: {} },
evidence: { type: "Evidence", props: {} },
mixed: { type: "Section", props: { title: "判断与依据" }, children: ["note", "basis"] },
note: { type: "Text", props: {} },
basis: { type: "Evidence", props: {} },
form: { type: "FollowUpForm", props: {} }
};
it("composes independent content while keeping authored comparisons and mixed sections together", () => {
expect(getAnswerTaskLayout({ root: "root", elements: nodes })).toEqual({
overview: ["comparison", "mixed"], analysis: ["data"], evidence: ["evidence"], followUp: ["form"]
});
});
it("preserves an explicitly authored comparison as the root", () => {
expect(getAnswerTaskLayout({ root: "comparison", elements: nodes })).toBeNull();
});
it("keeps explicit content roles and comparison intent instead of guessing from component types", () => {
const elements: Spec["elements"] = { ...nodes,
data: { ...nodes.data, props: { title: "读数依据", role: "evidence" } },
mixed: { ...nodes.mixed, props: { title: "处理结果", role: "result" } }
};
expect(getAnswerTaskLayout({ root: "root", elements })).toEqual({
overview: ["comparison", "mixed"], analysis: [], evidence: ["data", "evidence"], followUp: ["form"]
});
expect(getAnswerTaskLayout({ root: "root", elements: { ...elements,
root: { ...elements.root, props: { ...elements.root.props, intent: "comparison" } }
} })).toBeNull();
});
});
@@ -0,0 +1,35 @@
import type { Spec } from "@json-render/core";
export type AnswerTaskLayout = {
overview: string[];
analysis: string[];
evidence: string[];
followUp: string[];
};
type ContentRole = keyof AnswerTaskLayout;
/** Keep authored comparisons and mixed sections intact. Only compose independent roots. */
export function getAnswerTaskLayout(spec: Spec): AnswerTaskLayout | null {
const root = spec.elements[spec.root];
if (root.type !== "Stack" || root.props.columns !== "one" || root.props.intent === "comparison") return null;
const roleFor = (id: string): ContentRole => {
const node = spec.elements[id];
if (node.type === "Section") {
const roles: Record<string, ContentRole> = { result: "overview", analysis: "analysis", evidence: "evidence", follow_up: "followUp" };
const role = typeof node.props.role === "string" ? roles[node.props.role] : undefined;
if (role) return role;
}
if (node.type === "Evidence") return "evidence";
if (node.type === "FollowUpForm") return "followUp";
if (["Chart", "Table", "MapLocation"].includes(node.type)) return "analysis";
if (node.children?.length) {
const roles = new Set(node.children.map(roleFor));
if (roles.size === 1) return [...roles][0];
}
return "overview";
};
const groups: AnswerTaskLayout = { overview: [], analysis: [], evidence: [], followUp: [] };
for (const id of root.children ?? []) groups[roleFor(id)].push(id);
return groups;
}
@@ -9,7 +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;
evidenceFilter?: string; evidenceFilter?: string;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
}; };
@@ -1,24 +0,0 @@
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { useContext, useState } from "react";
import { MessageSquarePlus } from "lucide-react";
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>;
}
@@ -0,0 +1,105 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AnswerUiView } from "./answer-ui-view";
import { parseAnswerUiBlock } from "./contract";
import { normalizeAnswerViewState, type AnswerViewState } from "./answer-view-state";
vi.mock("echarts-for-react", () => ({ default: () => <div aria-label="测试图形" /> }));
function block(type: string, props: Record<string, unknown>, id = "node") {
const value = parseAnswerUiBlock({ version: "answer-ui@1", block_id: "state", fallback_text: "演练", spec: { root: id, elements: { [id]: { type, props, children: [] } } } });
if (!value) throw new Error("Invalid fixture");
return value;
}
const chart = (labels = ["一", "二"]) => block("Chart", { title: "压力", chart_type: "line", x_data: labels, series: [{ name: "入口", data: labels.map((_, index) => index) }], source: "测试" });
const form = (kind = "text") => block("FollowUpForm", { title: "补充", description: "主动填写", fields: [{ name: "record", kind, label: "现场记录", required: false }, { name: "other", kind: "text", label: "其他资料", required: false }] });
describe("controlled native answer state", () => {
it("keeps independent drafts for identically shaped nodes and never changes model props", () => {
const current = form();
const node = current.spec.elements.node;
current.spec = { root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["first", "second"] }, first: structuredClone(node), second: structuredClone(node) } };
const changed = vi.fn();
const original = JSON.stringify(current.spec);
render(<AnswerUiView block={current} onViewStateChange={changed} onSubmit={vi.fn()} />);
const inputs = screen.getAllByLabelText("现场记录");
fireEvent.change(inputs[0], { target: { value: "第一份草稿" } });
fireEvent.change(inputs[1], { target: { value: "第二份草稿" } });
expect(inputs[0]).toHaveValue("第一份草稿");
expect(inputs[1]).toHaveValue("第二份草稿");
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { first: { type: "FollowUpForm", values: { record: "第一份草稿" } }, second: { type: "FollowUpForm", values: { record: "第二份草稿" } } } }), ["/nodes/second/values/record"]);
expect(JSON.stringify(current.spec)).toBe(original);
});
it("records only the edited form field and restores it after remount", () => {
const current = form();
let saved: AnswerViewState = normalizeAnswerViewState(current.spec);
const changed = vi.fn((next: AnswerViewState) => { saved = next; });
const rendered = render(<AnswerUiView block={current} viewState={saved} onViewStateChange={changed} onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "尚未提交的草稿" } });
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交的草稿" } } } }), ["/nodes/node/values/record"]);
rendered.unmount();
render(<AnswerUiView block={current} viewState={saved} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录")).toHaveValue("尚未提交的草稿");
expect(screen.getByLabelText("其他资料")).toHaveValue("");
});
it("keeps chart data-tab search across data refresh without remounting", () => {
const current = chart();
const state = normalizeAnswerViewState(current.spec);
if (state.nodes.node.type === "Chart") state.nodes.node.view = "table";
const { rerender } = render(<AnswerUiView block={current} viewState={state} />);
const search = screen.getByRole("textbox", { name: "搜索压力" });
fireEvent.change(search, { target: { value: "一" } });
// A local owner can omit the external snapshot while keeping the same block instance.
rerender(<AnswerUiView block={chart(["一", "三"])} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search);
expect(search).toHaveValue("一");
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
});
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 changed = vi.fn();
const { rerender } = render(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />);
const restored = normalizeAnswerViewState(current.spec, { nodes: { node: { type: "Chart", view: "table", hidden: [1], range: [0, 1], point: 1, inspecting: true,
table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } });
rerender(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending");
expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument();
expect(changed).not.toHaveBeenCalled();
// A subsequent deliberate series change clears the derived table sort.
fireEvent.click(screen.getByRole("button", { name: "出口" }));
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
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", () => {
const { rerender } = render(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
const input = screen.getByLabelText("现场记录");
fireEvent.change(input, { target: { value: "未发送" } });
rerender(<AnswerUiView block={form()} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(input).toHaveValue("未发送");
rerender(<AnswerUiView block={form("number")} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录")).toHaveValue(null);
});
it("restores evidence expansion and reports ambiguous chart label migration", () => {
const evidence = block("Evidence", { title: "依据", summary: "记录内容", source: "测试", kind: "observed", observed_at: null, evidence_id: null });
const saved = normalizeAnswerViewState(evidence.spec, { nodes: { node: { type: "Evidence", open: true } } });
const { unmount } = render(<AnswerUiView block={evidence} viewState={saved} />);
expect(screen.getByText("记录内容")).toBeVisible();
unmount();
const original = chart();
const state = normalizeAnswerViewState(original.spec);
if (state.nodes.node.type === "Chart") { state.nodes.node.point = 1; state.nodes.node.inspecting = true; }
const mounted = render(<AnswerUiView block={original} viewState={state} />);
mounted.rerender(<AnswerUiView block={chart(["一", "二", "二"])} />);
expect(screen.getByRole("status")).toHaveTextContent("无法唯一对应,已清除选择");
expect(screen.queryByRole("region", { name: "数据点详情" })).not.toBeInTheDocument();
});
});
@@ -15,17 +15,157 @@ function block(type: string, props: Record<string, unknown>) {
if (!parsed) throw new Error("invalid test fixture"); if (!parsed) throw new Error("invalid test fixture");
return parsed; return parsed;
} }
function taskBlock(includeData = true) {
const children = ["metric", ...(includeData ? ["table"] : []), "evidence", "form"];
const fixture = {
version: "answer-ui@1", block_id: "task", fallback_text: "演练分析",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children },
metric: { type: "Metric", props: { label: "当前压力", value: "0.2130", unit: "MPa", source: "监测快照" }, children: [] },
...(includeData ? { table: { type: "Table", props: { title: "入口记录", columns: ["对象", "压力(MPa)"], rows: [["北区入口", "0.2130"], ["南区入口", "0.2870"]] }, children: [] } } : {}),
evidence: { type: "Evidence", props: { title: "读数依据", summary: "仅用于演练", source: "测试查询", kind: "demo", evidence_id: "DEMO-26", observed_at: null }, children: [] },
form: { type: "FollowUpForm", props: { title: "核验资料", description: "主动补充", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] }
} }
};
const parsed = parseAnswerUiBlock(fixture);
if (!parsed) throw new Error("invalid task fixture");
return parsed;
}
describe("local interactive answer renderer", () => { describe("local interactive answer renderer", () => {
it("offers a named metric quote action and retains the exact value, unit and source", async () => { 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 () => {
const user = userEvent.setup(); const user = userEvent.setup();
const ask = vi.fn(); const fixture = taskBlock();
fixture.spec.elements.root.props = { columns: "one", gap: "normal", intent: "response", density: "compact" };
const { rerender, container } = render(<AnswerUiView block={fixture} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "保留的现场草稿");
expect(container.querySelector("[data-answer-task-layout]")).toBeNull();
const report = { ...fixture, spec: { ...fixture.spec, elements: { ...fixture.spec.elements,
root: { ...fixture.spec.elements.root, props: { columns: "one", gap: "normal", intent: "report", density: "comfortable" } }
} } };
rerender(<AnswerUiView block={report} onSubmit={vi.fn()} />);
expect(container.querySelector("[data-answer-task-layout]")).not.toBeNull();
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("保留的现场草稿");
});
it("renders evidence-bound result states without treating historical results as live alerts", () => {
const fixture = (status: string) => block("Result", { title: "管道定位结果", summary: "已定位 3 根管道;高亮尚未确认。", status, source: "地图定位回执" });
const { rerender } = render(<AnswerUiView block={fixture("partial")} />);
const result = screen.getByRole("group", { name: "管道定位结果" });
expect(result).toHaveTextContent("部分完成");
expect(result).toHaveTextContent("依据:地图定位回执");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
for (const [status, label] of [["success", "成功"], ["unconfirmed", "未确认"], ["error", "失败"]]) {
rerender(<AnswerUiView block={fixture(status)} />);
expect(result).toHaveTextContent(label);
expect(result).toHaveAttribute("data-result-status", status);
}
});
it("uses the supplied note title without changing the warning meaning", () => {
render(<AnswerUiView block={block("Text", { text: "当前工具未提供高亮操作。", tone: "warning", title: "能力限制" })} />);
expect(screen.getByRole("note")).toHaveTextContent("能力限制");
expect(screen.getByRole("note")).not.toHaveTextContent("注意事项");
});
it("shows data and evidence without row selection, quoting or analysis controls", async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<AnswerUiView block={taskBlock()} onSubmit={submit} />);
expect(screen.getByRole("group", { name: "当前压力" })).toHaveTextContent("0.2130MPa");
expect(screen.getByRole("table")).toHaveTextContent("北区入口");
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
await waitFor(() => expect(screen.getByText("仅用于演练")).toBeVisible());
expect(screen.getByText("DEMO-26")).toBeVisible();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "分析内容篮" })).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled();
});
it("keeps the same form when more data arrives and support sections collapse", async () => {
const user = userEvent.setup();
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "未发送的现场草稿");
const grouped = taskBlock();
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(editor).toHaveValue("未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
expect(editor.closest("[data-answer-reveal]")).toHaveAttribute("inert");
await waitFor(() => expect(editor).not.toBeVisible());
await user.click(screen.getByRole("button", { name: "展开补充资料" }));
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
const updated = taskBlock();
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
rerender(<AnswerUiView block={updated} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿");
});
it("preserves long original cell values without requiring a selection or quote action", () => {
const fixture = taskBlock();
fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
render(<AnswerUiView block={fixture} />);
expect(screen.getAllByText("长".repeat(800))).toHaveLength(12);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
});
it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
const { rerender } = render(<AnswerUiView block={block("Text", {
text: "压力记录尚未经过现场核验。", tone: "warning",
})} />);
expect(screen.getByRole("note")).toHaveTextContent("注意事项");
expect(screen.getByRole("note")).toHaveTextContent("压力记录尚未经过现场核验。");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
rerender(<AnswerUiView block={block("Text", { text: "仅展示已加载的记录。", tone: "info" })} />);
expect(screen.getByRole("note")).toHaveTextContent("补充说明");
rerender(<AnswerUiView block={block("Text", { text: "普通回答", tone: "neutral" })} />);
expect(screen.queryByRole("note")).not.toBeInTheDocument();
expect(screen.getByText("普通回答")).toBeInTheDocument();
});
it("retains a metric's exact value, unit and source without action buttons", () => {
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();
@@ -72,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 () => {
+98 -238
View File
@@ -1,48 +1,52 @@
import { AskAboutContent } from "./answer-ui-interactions"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useId, useState } from "react"; import { useContext, useRef, useState, type ReactNode } from "react";
import { import {
ArrowUpRight, Info,
ChevronDown, TriangleAlert,
ClipboardList, ClipboardList,
Database, Database,
FileCheck2, FileCheck2,
FlaskConical, FlaskConical,
Lightbulb, Lightbulb,
MapPin,
MessageSquare MessageSquare
} from "lucide-react"; } from "lucide-react";
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 { Card, CardContent } from "@/shared/ui/card"; import { Card, CardContent } from "@/shared/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
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 { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context"; import { AnswerResult } from "./answer-result";
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 { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
const { registry } = defineRegistry(answerUiCatalog, { const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {}, actions: {},
components: { components: {
Stack: ({ props, children }) => ( Stack: ({ props, children }) => (
<div <div
className={cn( className={cn(
"answer-ui-stack", "answer-ui-stack",
props.columns === "two" && "answer-ui-stack-comparison", (props.columns === "two" || props.intent === "comparison") && "answer-ui-stack-comparison",
props.gap === "compact" && "answer-ui-stack-compact" props.gap === "compact" && "answer-ui-stack-compact"
)} )}
data-answer-intent={props.intent}
data-answer-density={props.density}
> >
{children} {children}
</div> </div>
), ),
Section: ({ props, children }) => ( Section: ({ props, children }) => (
<section className="answer-ui-section"> <section className="answer-ui-section" data-answer-role={props.role}>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ClipboardList size={15} aria-hidden="true" /> <ClipboardList size={15} aria-hidden="true" />
{props.title} {props.title}
@@ -53,22 +57,21 @@ const { registry } = defineRegistry(answerUiCatalog, {
<div className="mt-3 space-y-3">{children}</div> <div className="mt-3 space-y-3">{children}</div>
</section> </section>
), ),
Text: ({ props }) => ( Text: ({ props }) => props.tone === "neutral" ? (
<p <div className="space-y-1">
className={cn( {props.title ? <h4 className="text-sm font-medium leading-6 text-foreground">{props.title}</h4> : null}
"whitespace-pre-wrap text-sm leading-6", <p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{props.text}</p>
props.tone === "warning" </div>
? "text-[var(--status-warning-foreground)]" ) : (
: props.tone === "info" <Alert role="note" className="border-border bg-card px-3.5 py-3 text-card-foreground has-[>svg]:gap-x-2.5">
? "text-[var(--action-blue-hover)]" {props.tone === "warning" ? <TriangleAlert className="text-[var(--status-warning-foreground)]" aria-hidden /> : <Info className="text-muted-foreground" aria-hidden />}
: "text-[var(--ink)]" <AlertTitle className="text-[13px]">{props.title ?? (props.tone === "warning" ? "注意事项" : "补充说明")}</AlertTitle>
)} <AlertDescription className="whitespace-pre-wrap text-muted-foreground">{props.text}</AlertDescription>
> </Alert>
{props.text}
</p>
), ),
Result: ({ props }) => <AnswerResult {...props} />,
Metric: ({ props }) => ( Metric: ({ props }) => (
<Card className="answer-ui-metric"> <Card className="answer-ui-metric" role="group" aria-label={props.label}>
<CardContent className="answer-ui-metric-content p-3"> <CardContent className="answer-ui-metric-content p-3">
<p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p> <p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p>
<p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground"> <p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground">
@@ -80,47 +83,89 @@ const { registry } = 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}
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</CardContent> </CardContent>
</Card> </Card>
), ),
Table: ({ props }) => ( Table: ({ props }) => (
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} /> <section data-answer-table aria-label={props.title} className="min-w-0">
<InteractiveDataTable title={props.title} columns={props.columns} rows={props.rows} />
</section>
), ),
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />, Chart: ({ props }) => <InteractiveChart {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />, Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />, MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} /> FollowUpForm: ({ props }) => <FollowUpForm {...props} />
} }
}); });
export function AnswerUiView({ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} />
</AnswerViewStateProvider>;
}
function AnswerUiBody({
block, block,
disabled = false, disabled = false,
onLocate, onLocate,
onSubmit, onSubmit,
onAsk,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [filter, setFilter] = useState("all"); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const contentRef = useRef<HTMLDivElement>(null);
const taskLayout = getAnswerTaskLayout(block.spec);
const root = block.spec.elements[block.spec.root];
const density = root.type === "Stack" ? root.props.density : 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 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="筛选证据类别">
{["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}
</Button>)}
</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} />);
return ( return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, <InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : 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}>
<div className="answer-ui-root" data-answer-ui={block.block_id}> <AnswerContentNav block={block} contentRef={contentRef} />
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别"> <div ref={contentRef}>
{["all", ...kinds].map((kind) => <Button key={kind} variant="outline" size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}> <>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label} {taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
</Button>)} {taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
</div> : null} {taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
<JSONUIProvider registry={registry}> {renderNodes(taskLayout.analysis)}
<Renderer spec={block.spec} registry={registry} /> </section> : null}
</JSONUIProvider> {taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
</div> {taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</>
</div>
</AnswerMotionScope>
</InteractionContext.Provider> </InteractionContext.Provider>
); );
} }
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
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>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
const evidenceKinds = { const evidenceKinds = {
observed: { label: "查询记录", icon: Database }, observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare }, human_input: { label: "用户提供", icon: MessageSquare },
@@ -136,14 +181,15 @@ function EvidenceCard(props: {
observed_at: string | null; observed_at: string | null;
evidence_id: string | null; evidence_id: string | null;
}) { }) {
const [open, setOpen] = useState(false); const [{ open }, patch] = useAnswerNodeState<AnswerEvidenceState>("Evidence", props);
const setOpen = (open: boolean) => patch({ open });
const meta = evidenceKinds[props.kind]; const meta = evidenceKinds[props.kind];
const Icon = meta.icon; const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext); const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null; if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return ( return (
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence"> <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-ui-evidence" role="group" aria-label={props.title}
<CollapsibleTrigger asChild> contentClassName="px-3 pb-3 text-xs leading-6" trigger={
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
@@ -161,14 +207,8 @@ function EvidenceCard(props: {
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal"> <Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
{meta.label} {meta.label}
</Badge> </Badge>
<ChevronDown <AnswerDisclosureChevron open={open} />
size={14} </Button>}>
className={cn("shrink-0", open && "rotate-180")}
aria-hidden="true"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p> <p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
{props.observed_at || props.evidence_id ? ( {props.observed_at || props.evidence_id ? (
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground"> <dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
@@ -186,186 +226,6 @@ function EvidenceCard(props: {
) : null} ) : null}
</dl> </dl>
) : null} ) : null}
<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}` : ""}`} /> </AnswerDisclosure>
</CollapsibleContent>
</Collapsible>
);
}
function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const [running, setRunning] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<div className="answer-ui-location">
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as
{ located_ids?: string[]; missing_ids?: string[] } | undefined;
setFeedback(
output?.missing_ids?.length
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
: "已定位到地图"
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "地图定位失败");
} finally {
setRunning(false);
}
}}
>
{running ? "正在定位…" : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
{feedback ? (
<p role="status" className="mt-1 text-xs leading-5">
{feedback}
</p>
) : null}
</div>
</div>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [values, setValues] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<form
className="answer-ui-form space-y-3"
aria-label={title}
onSubmit={async (event) => {
event.preventDefault();
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
setFeedback("请填写必填资料。");
return;
}
if (!onSubmit || disabled || submitting) return;
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setFeedback("补充资料过长,请分次发送。");
return;
}
setSubmitting(true);
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
setSubmitting(false);
}
}}
>
<div className="flex items-start gap-2.5">
<span className="answer-ui-evidence-icon">
<FileCheck2 size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
</div>
</div>
{fields.map((field) =>
field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
/>
) : (
<div key={field.name} className="space-y-1.5">
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
{field.label}
{field.required ? "(必填)" : ""}
</label>
{field.kind === "text" ? (
<Textarea
id={`${prefix}-${field.name}`}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
)}
</div>
)
)}
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback ? (
<p role="status" className="text-xs leading-5">
{feedback}
</p>
) : null}
</form>
); );
} }
@@ -0,0 +1,92 @@
/* eslint-disable react-refresh/only-export-components -- Local rendering adapter and its state hooks share private contexts. */
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";
import { createStateStore, JSONUIProvider, useStateStore, type ComponentRegistry } from "@json-render/react";
import type { Spec } from "@json-render/core";
import {
answerStatePointer, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerNodeState, normalizeAnswerViewState,
type AnswerUiNodeState, type AnswerViewState, type AnswerViewStateChange
} from "./answer-view-state";
const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
}
export function withAnswerNodeIdentity(registry: ComponentRegistry): ComponentRegistry {
return Object.fromEntries(Object.entries(registry).map(([type, Component]) => [type, function AnswerNodeRenderer(props) {
const id = (props.element as typeof props.element & { __answerNodeId?: string }).__answerNodeId;
return <NodeContext.Provider value={id}><Component {...props} /></NodeContext.Provider>;
}]));
}
export function AnswerViewStateProvider({ spec, registry, viewState, onViewStateChange, children }: {
spec: Spec; registry: ComponentRegistry; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange; children: ReactNode;
}) {
const [store] = useState(() => createStateStore(normalizeAnswerViewState(spec, viewState)));
// Capture the full prior schema, including in-place updates from callers.
const [previous, setPrevious] = useState(() => JSON.stringify(spec));
const serialized = JSON.stringify(spec);
useLayoutEffect(() => {
const current = store.getSnapshot() as AnswerViewState;
const source = viewState ?? current;
const next = previous === serialized ? normalizeAnswerViewState(spec, source) : migrateAnswerViewState(JSON.parse(previous) as Spec, spec, source);
const dirty = answerViewStateDirtyPaths(source, next);
const previousSpec = JSON.parse(previous) as Spec;
const resetCharts = Object.entries(next.nodes).filter(([id, node]) => {
const old = source.nodes[id];
if (node.type !== "Chart" || old?.type !== "Chart" || previous === serialized) return false;
const before = previousSpec.elements[id], after = spec.elements[id];
if (before?.type !== "Chart" || JSON.stringify([before.props.x_data, before.props.series]) === JSON.stringify([after.props.x_data, after.props.series])) return false;
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((value) => value === label).length === 1 && labels.filter((value) => value === label).length === 1 ? labels.indexOf(label) : -1;
};
return relocate(old.point) < 0 || relocate(old.range[0]) < 0 || relocate(old.range[1]) < relocate(old.range[0]);
}).map(([id]) => id);
store.update({ "/nodes": next.nodes, "/block": next.block });
if (previous !== serialized) {
store.set("/notices", Object.fromEntries(resetCharts.map((id) => [id, "数据标签已变化,原选点或区间无法唯一对应,已清除选择。仍可查看当前数据。"])));
setPrevious(serialized);
}
if (dirty.length) onViewStateChange?.(next, dirty);
}, [store, previous, serialized, spec, viewState, onViewStateChange]);
const change: AnswerViewStateChange = (next, dirty) => {
store.update({ "/nodes": next.nodes, "/block": next.block });
onViewStateChange?.(next, dirty);
};
return <ChangeContext.Provider value={change}>
<JSONUIProvider registry={registry} store={store}>{children}</JSONUIProvider>
</ChangeContext.Provider>;
}
export function useAnswerNodeState<T extends AnswerUiNodeState>(type: T["type"], props: Record<string, unknown>) {
const id = useContext(NodeContext);
const store = useStateStore();
const onChange = useContext(ChangeContext);
// Derived controls (such as a chart's data table) have no schema node of their own.
const [local, setLocal] = useState(() => normalizeAnswerNodeState({ type, props, children: [] }) as T);
const owned = id && (store.get(`/nodes/${answerStatePointer(id)}`) as AnswerUiNodeState | undefined)?.type === type;
const state = (owned ? normalizeAnswerNodeState({ type, props, children: [] }, store.get(`/nodes/${answerStatePointer(id!)}`)) : local) as T;
const patch = (updates: Partial<Omit<T, "type">>, fields?: string[]) => {
const live = owned ? store.get(`/nodes/${answerStatePointer(id!)}`) as T : state;
const next = { ...live, ...updates } as T;
if (!owned || !onChange) { setLocal(next); return; }
const snapshot = store.getSnapshot() as AnswerViewState;
onChange({ nodes: { ...snapshot.nodes, [id!]: next }, block: snapshot.block },
(fields ?? Object.keys(updates)).map((field) => `/nodes/${answerStatePointer(id!)}/${field}`));
};
return [state, patch, id ? store.get(`/notices/${answerStatePointer(id)}`) as string | undefined : undefined] as const;
}
export function useAnswerBlockState() {
const store = useStateStore(), onChange = useContext(ChangeContext);
const block = store.get("/block") as AnswerViewState["block"];
return [block, (evidenceFilter: string) => {
const snapshot = store.getSnapshot() as AnswerViewState;
onChange?.({ nodes: snapshot.nodes, block: { evidenceFilter } }, ["/block/evidenceFilter"]);
}] as const;
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
function spec(type: string, props: Record<string, unknown>): Spec {
return { root: "node", elements: { node: { type, props, children: [] } } };
}
const chart = (labels = ["a", "b", "c"], names = ["入口", "出口"]) => spec("Chart", { chart_type: "line", x_data: labels, series: names.map((name) => ({ name, data: labels.map((_, index) => index) })) });
const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"]) => spec("Table", { rows, columns });
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => {
it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table();
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(undefined, ["读数", "对象"]), state).nodes.node).toMatchObject({ query: "a", sort: null, selection: [] });
expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state);
});
it("relocates chart ranges and points only by unique labels and resets hidden series after renaming", () => {
const before = chart();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Chart", view: "bar", hidden: [1], range: [1, 2], point: 2, inspecting: true, table: { query: "b", sort: { column: 1, descending: false }, selection: [0] } } } });
expect(migrateAnswerViewState(before, chart(["z", "a", "b", "c"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [1], range: [2, 3], point: 3, inspecting: true, table: { query: "b", selection: [] } });
expect(migrateAnswerViewState(before, chart(undefined, ["出口", "入口"]), state).nodes.node).toMatchObject({ view: "bar", hidden: [], table: { query: "b", sort: null, selection: [] } });
for (const labels of [["a", "b", "b"], ["a", "b"]]) expect(migrateAnswerViewState(before, chart(labels), state).nodes.node).toMatchObject({ range: [0, labels.length - 1], point: 0, inspecting: false });
});
it("preserves same-name same-kind drafts across equivalent streaming schemas and drops replaced fields", () => {
const before = form([{ name: "record", kind: "text" }, { name: "deleted", kind: "text" }]);
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "FollowUpForm", values: { record: "尚未提交", deleted: "旧值" } } } });
expect(migrateAnswerViewState(before, structuredClone(before), state)).toEqual(state);
expect(migrateAnswerViewState(before, form([{ name: "record", kind: "number" }, { name: "added", kind: "text" }]), state).nodes.node).toEqual({ type: "FollowUpForm", values: {} });
expect(migrateAnswerViewState(before, form(), state).nodes.node).toEqual({ type: "FollowUpForm", values: { record: "尚未提交" } });
});
it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => {
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" });
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", () => {
const current = form([{ name: "record", kind: "text" }, { name: "other", kind: "text" }]);
const before = normalizeAnswerViewState(current);
const after = normalizeAnswerViewState(current, { nodes: { node: { type: "FollowUpForm", values: { record: "草稿" } } } });
expect(answerViewStateDirtyPaths(before, after)).toEqual(["/nodes/node/values/record"]);
const chartState = normalizeAnswerViewState(chart());
const updated = structuredClone(chartState);
if (updated.nodes.node.type === "Chart") updated.nodes.node.table!.query = "a";
expect(answerViewStateDirtyPaths(chartState, updated)).toEqual(["/nodes/node/table/query"]);
});
});
@@ -0,0 +1,127 @@
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 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 AnswerEvidenceState = { type: "Evidence"; open: boolean };
export type AnswerUiNodeState = AnswerChartState | AnswerTableState | AnswerFormState | AnswerEvidenceState;
export type AnswerViewState = { nodes: Record<string, AnswerUiNodeState>; block: { evidenceFilter: string } };
export type AnswerViewStateChange = (next: AnswerViewState, dirtyPaths: string[]) => void;
export type AnswerViewStateRecord = AnswerViewState & {
version: "answer-view-state@1"; message_id: string; block_id: string; revision: number; schema_digest: string; updated_at: string | null;
};
type Node = Spec["elements"][string];
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const indices = (value: unknown, length: number) => Array.isArray(value) ? [...new Set(value.filter((index): index is number => Number.isInteger(index) && index >= 0 && index < length))].slice(0, 100) : [];
const index = (value: unknown, length: number, fallback = 0) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < length ? value : fallback;
export const answerStatePointer = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
/** Only the local component allowlist can contribute state. Model props stay unchanged. */
export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiNodeState | undefined {
const value = record(input);
const state = value.type === node.type ? value : {};
if (node.type === "Chart") {
const labels = node.props.x_data as string[];
const series = node.props.series as unknown[];
const proposed = Array.isArray(state.range) ? state.range : [];
const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
const { type: _type, ...tableState } = table;
chart.table = tableState;
return chart;
}
if (node.type === "Table") {
const columns = node.props.columns as unknown[];
const sort = record(state.sort);
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,
selection: [] };
}
if (node.type === "FollowUpForm") {
const values = record(state.values);
return { type: "FollowUpForm", values: Object.fromEntries((node.props.fields as Array<{ name: string }>).filter(({ name }) => typeof values[name] === "string").map(({ name }) => [name, String(values[name])])) };
}
if (node.type === "Evidence") return { type: "Evidence", open: state.open === true };
}
export function answerChartTableData(node: Node, state: AnswerChartState) {
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
const visible = series.filter((_, index) => !state.hidden.includes(index));
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
}
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
const state = record(input), nodes = record(state.nodes), block = record(state.block);
const kinds = Object.values(spec.elements).filter((node) => node.type === "Evidence").map((node) => node.props.kind);
return { nodes: Object.fromEntries(Object.entries(spec.elements).flatMap(([id, node]) => {
const value = normalizeAnswerNodeState(node, nodes[id]);
return value ? [[id, value]] : [];
})), block: { evidenceFilter: typeof block.evidenceFilter === "string" && kinds.includes(block.evidenceFilter) ? block.evidenceFilter : "all" } };
}
/** Migrate by explicit data identity, never by assuming old indices identify new data. */
export function migrateAnswerViewState(previous: Spec, next: Spec, input: AnswerViewState): AnswerViewState {
const state = normalizeAnswerViewState(next, input);
for (const [id, value] of Object.entries(state.nodes)) {
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 (value.type === "Table") {
if (!same(before.props.columns, after.props.columns)) value.sort = null;
}
if (value.type === "FollowUpForm") {
const fields = before.props.fields as Array<{ name: string; kind: string }>;
const nextFields = after.props.fields as Array<{ name: string; kind: string }>;
value.values = Object.fromEntries(Object.entries(value.values).filter(([name]) => fields.some((field) => field.name === name && nextFields.some((nextField) => nextField.name === name && nextField.kind === field.kind))));
}
if (value.type === "Chart" && old.type === "Chart") {
const names = (node: Node) => (node.props.series as Array<{ name: string }>).map(({ name }) => name);
if (!same(names(before), names(after))) value.hidden = [];
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
if (!same(previousLabels, labels) || !same(before.props.series, after.props.series)) {
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((item) => item === label).length === 1 && labels.filter((item) => item === label).length === 1 ? labels.indexOf(label) : -1;
};
const point = relocate(old.point), start = relocate(old.range[0]), end = relocate(old.range[1]);
if (point < 0 || start < 0 || end < start) {
value.point = 0; value.range = [0, labels.length - 1]; value.inspecting = false;
} else { value.point = point; value.range = [start, end]; }
}
if (value.table) {
const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value);
if (!same(oldData.columns, newData.columns)) value.table.sort = null;
}
}
}
return state;
}
export function answerViewStateDirtyPaths(before: AnswerViewState, after: AnswerViewState): string[] {
const paths: string[] = [];
for (const [id, value] of Object.entries(after.nodes)) {
const old = before.nodes[id];
if (old && old.type !== value.type) { paths.push(`/nodes/${answerStatePointer(id)}`); continue; }
for (const [field, next] of Object.entries(value)) {
if (field === "type") continue;
const prefix = `/nodes/${answerStatePointer(id)}/${field}`;
if (field === "values" && value.type === "FollowUpForm") {
const oldValues = old?.type === "FollowUpForm" ? old.values : {};
for (const name of new Set([...Object.keys(oldValues), ...Object.keys(value.values)])) if (oldValues[name] !== value.values[name]) paths.push(`${prefix}/${answerStatePointer(name)}`);
} else if (field === "table" && value.type === "Chart" && value.table) {
const oldTable = old?.type === "Chart" ? old.table : undefined;
for (const [key, item] of Object.entries(value.table)) if (!same(oldTable?.[key as keyof typeof oldTable], item)) paths.push(`${prefix}/${key}`);
} else if (!same(old && (old as unknown as Record<string, unknown>)[field], next)) paths.push(prefix);
}
}
if (before.block.evidenceFilter !== after.block.evidenceFilter) paths.push("/block/evidenceFilter");
for (const id of Object.keys(before.nodes)) if (!Object.hasOwn(after.nodes, id)) paths.push(`/nodes/${answerStatePointer(id)}`);
return paths;
}
+21 -5
View File
@@ -13,20 +13,36 @@ export const answerUiCatalog = defineCatalog(schema, {
components: { components: {
Stack: { Stack: {
props: z props: z
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) }) .object({
columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]),
intent: z.enum(["response", "comparison", "report"]).optional(),
density: z.enum(["compact", "comfortable"]).optional()
})
.strict(), .strict(),
slots: ["default"], slots: ["default"],
description: "组织内容;双列在窄面板自动改为单列。" description: "组织内容。intent 表达简短答复、并列比较或报告;density 表达疏密,不控制字体和颜色。comparison 与双列在窄面板自动改单列。"
}, },
Section: { Section: {
props: z.object({ title: shortText, description: text.nullable() }).strict(), props: z.object({
title: shortText, description: text.nullable(),
role: z.enum(["result", "analysis", "evidence", "follow_up"]).optional()
}).strict(),
slots: ["default"], slots: ["default"],
description: "带标题的资料分区,保留真实内容层级。" description: "带标题的资料分区。role 标记结果、分析、依据或补充资料;前端据此组织任务内容,保留分区内部的原有顺序。"
}, },
Text: { Text: {
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(), props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]), title: shortText.optional() }).strict(),
description: "简短说明、判断边界或资料缺口。" description: "简短说明、判断边界或资料缺口。"
}, },
Result: {
props: z.object({
title: shortText,
summary: text,
status: z.enum(["success", "partial", "unconfirmed", "error"]),
source: optionalText
}).strict(),
description: "概括已有工具结果:成功、部分完成、未确认或失败。source 标注真实依据;无依据用 unconfirmed。此组件不执行操作,不证明浏览器展示、业务写入或现场处置完成。"
},
Metric: { Metric: {
props: z props: z
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText }) .object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
@@ -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 [
+4 -1
View File
@@ -11,6 +11,7 @@ const payloadSchema = z
.object({ .object({
version: z.literal(ANSWER_UI_VERSION), version: z.literal(ANSWER_UI_VERSION),
block_id: key, block_id: key,
phase: z.enum(["process", "final"]).optional(),
fallback_text: z.string().min(1).max(4000), fallback_text: z.string().min(1).max(4000),
spec: specSchema spec: specSchema
}) })
@@ -20,18 +21,20 @@ const payloadSchema = z
export const storedAnswerUiBlockSchema = z.object({ export const storedAnswerUiBlockSchema = z.object({
version: z.string(), version: z.string(),
block_id: key, block_id: key,
phase: z.enum(["process", "final"]).optional(),
fallback_text: z.string().min(1).max(4000), fallback_text: z.string().min(1).max(4000),
spec: z.unknown() spec: z.unknown()
}); });
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 = {
version: typeof ANSWER_UI_VERSION; version: typeof ANSWER_UI_VERSION;
block_id: string; block_id: string;
phase?: "process" | "final";
fallback_text: string; fallback_text: string;
spec: Spec; spec: Spec;
}; };
@@ -1,13 +1,14 @@
import { useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import { BarChart3, ChevronDown, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { 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 { AskAboutContent } from "./answer-ui-interactions"; import { agentEase } from "../components/agent-motion";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
@@ -16,6 +17,9 @@ export type AnswerChartProps = {
series: Array<{ name: string; data: number[] }>; series: Array<{ name: string; data: number[] }>;
source: string; source: string;
}; };
import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerChartState } from "./answer-view-state";
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"]; const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
function PointSelect({ label, value, labels, onChange }: { function PointSelect({ label, value, labels, onChange }: {
label: string; value: number; labels: string[]; onChange: (value: number) => void; label: string; value: number; labels: string[]; onChange: (value: number) => void;
@@ -26,16 +30,45 @@ function PointSelect({ label, value, labels, onChange }: {
</Select>; </Select>;
} }
export function InteractiveChart(props: AnswerChartProps) { export function InteractiveChart(props: AnswerChartProps) {
const [view, setView] = useState<string>(props.chart_type); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const [hidden, setHidden] = useState<number[]>([]); const reducedMotion = useReducedMotion();
const [range, setRange] = useState<[number, number]>([0, props.x_data.length - 1]); const pointer = useRef(false);
const [point, setPoint] = useState(0); const plot = useRef<HTMLDivElement>(null);
const [animateInteraction, setAnimateInteraction] = useState(false);
const contentMotion = useAnimationControls();
const pendingTransition = useRef(false);
useLayoutEffect(() => {
// Start after the new ECharts view has mounted, so rendering cannot consume
// the transition before the browser gets a chance to paint it.
if (pendingTransition.current) {
pendingTransition.current = false;
void contentMotion.start({ opacity: 1, y: 0 }, { duration: 0.18, ease: agentEase });
} else contentMotion.set({ opacity: 1, y: 0 });
}, [contentMotion, view]);
const setView = (next: string) => {
if (next === view) return;
const animate = pointer.current && !reducedMotion;
setAnimateInteraction(animate);
contentMotion.stop();
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
pendingTransition.current = animate;
patch({ view: next as AnswerChartState["view"] });
};
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index)); const visible = props.series.filter((_, index) => !hidden.includes(index));
const labels = props.x_data.slice(range[0], range[1] + 1); const labels = props.x_data.slice(range[0], range[1] + 1);
const pointIndex = Math.min(range[1], Math.max(range[0], point)); const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => {
const 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) };
});
const option = (type: "line" | "bar") => ({ const option = (type: "line" | "bar") => ({
animation: false, animation: animateInteraction && !reducedMotion,
animationDuration: 180,
animationDurationUpdate: 180,
color: colors.filter((_, index) => !hidden.includes(index)), color: colors.filter((_, index) => !hidden.includes(index)),
aria: { enabled: true }, aria: { enabled: true },
tooltip: { trigger: "axis", renderMode: "richText" }, tooltip: { trigger: "axis", renderMode: "richText" },
@@ -45,14 +78,31 @@ export function InteractiveChart(props: AnswerChartProps) {
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1), series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } })) showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
}); });
return <Card className="answer-ui-chart overflow-hidden"> return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
<CardHeader className="space-y-1 px-3.5 pb-3 pt-3.5"> onPointerDownCapture={() => { pointer.current = true; }}
<CardTitle className="flex items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} />{props.title}</CardTitle> onKeyDownCapture={() => {
<CardDescription className="text-[11px]">{labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列</CardDescription> pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.title}</CardTitle>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0"> <CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView}> <Tabs value={view} onValueChange={setView}>
<TabsList aria-label={`${props.title}显示方式`} className="mb-3 grid w-full grid-cols-3"> <TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none">
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger> <TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger> <TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger> <TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
@@ -61,66 +111,85 @@ export function InteractiveChart(props: AnswerChartProps) {
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline" {props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
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={() => setHidden((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index])}> onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 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>
<Collapsible className="mb-2"> {!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1"> <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<CollapsibleTrigger asChild> <div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" aria-label="筛选数据范围" <PointSelect label="区间起点" value={range[0]} labels={props.x_data}
className="group h-8 gap-1.5 px-1.5 font-normal text-muted-foreground"> onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<SlidersHorizontal size={13} aria-hidden />数据范围 <span className="text-xs text-muted-foreground">至</span>
<ChevronDown size={13} aria-hidden className="group-data-[state=open]:rotate-180" /> <PointSelect label="区间终点" value={range[1]} labels={props.x_data}
</Button> onChange={(end) => setRange([Math.min(range[0], end), end])} />
</CollapsibleTrigger>
<span className="text-[11px] text-muted-foreground">
{fullRange ? "全部数据" : `${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}
</span>
</div> </div>
<CollapsibleContent className="space-y-2 rounded-lg border bg-muted/30 p-2.5"> <Button type="button" variant="ghost" size="sm" disabled={fullRange}
<div className="flex items-center gap-2"> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} </CollapsibleContent>
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> <motion.div data-chart-view={view} initial={false} animate={contentMotion}>
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
</Collapsible>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}> {(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
<div role="img" aria-label={props.title}> <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<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") {
setPoint(range[0] + event.dataIndex); patch({ point: range[0] + event.dataIndex, inspecting: true });
}
} }} /> } }} />
</div> </div>
</TabsContent>)} </TabsContent>)}
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}> <TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable key={JSON.stringify([hidden, range])} 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]))])}
source={props.source} /> state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent> </TabsContent>
</motion.div>
</Tabs> </Tabs>
<div className="rounded-lg bg-muted/40 p-2.5"> {view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="flex items-center gap-2"><span className="text-xs text-muted-foreground">查看数据点</span> <div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<PointSelect label="选择数据点" value={pointIndex - range[0]} labels={labels} onChange={(index) => setPoint(range[0] + index)} /> <span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div> </div>
<dl className="my-2 space-y-1.5 text-xs"> <div className="space-y-2">
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3"> {summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<dt className="min-w-0 text-muted-foreground">{series.name}</dt> <span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd> <span className="tabular-nums">{item.first} → {item.last}</span>
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
</div>)} </div>)}
</dl> </div>
<AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`} </div> : null}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} /> {view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
</div> initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-medium">数据点详情</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭数据点详情" onClick={() => { setInspecting(false); plot.current?.focus(); }}><X size={14} aria-hidden /></Button>
</div>
<div className="flex items-center gap-2"><span className="text-xs text-muted-foreground">时间或类别</span>
<PointSelect label="选择数据点" value={pointIndex - range[0]} labels={labels} onChange={(index) => setPoint(range[0] + index)} />
</div>
<dl className="my-2 space-y-1.5 text-xs">
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
</div>)}
</dl>
</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>来源:{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>
</p> </p>
</CardContent> </CardContent>
</Card>; </Card></Collapsible>;
} }
@@ -1,18 +1,22 @@
import { useMemo, useState } from "react"; 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 { AskAboutContent } from "./answer-ui-interactions";
export function InteractiveDataTable({ title, columns, rows, source }: { import { useAnswerNodeState } from "./answer-view-state-provider";
title: string; columns: string[]; rows: string[][]; source?: string; import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
}) { }) {
const [query, setQuery] = useState(""); const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const [sort, setSort] = useState<{ column: number; descending: boolean } | null>(null); const state = controlledState ?? localState;
const [selection, setSelection] = useState<number[]>([]); const patch = onStateChange ?? patchLocal;
const { query, sort } = state;
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()));
@@ -25,12 +29,10 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
}); });
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) => { setQuery(event.currentTarget.value); setSelection([]); }} /> 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}
@@ -38,33 +40,26 @@ export function InteractiveDataTable({ title, columns, rows, source }: {
<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"><span className="sr-only">选择行</span></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}排序`}
onClick={() => setSort((current) => current?.column === index onClick={() => patch({ sort: sort?.column === index
? current.descending ? null : { column: index, descending: true } ? sort.descending ? null : { column: index, descending: true }
: { column: index, descending: false })}> : { column: index, descending: false } })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />} {column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
</Button> </Button>
</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)}
onChange={(event) => setSelection((current) => event.target.checked ? [...current, id] : current.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>
{selected.length ? <div className="flex flex-wrap items-center justify-between gap-1">
<span className="text-xs text-muted-foreground">已选 {selected.length} 行</span>
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
</div> : <p className="text-[11px] text-muted-foreground">筛选与排序仅作用于已加载数据;勾选行可继续提问。</p>}
</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);
}
+96 -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()
@@ -138,6 +163,76 @@ describe("Agent API client sessions", () => {
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull(); await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
}); });
it("loads a task execution history through its exact task and run binding", async () => {
const fetchMock = vi.fn(async (..._args: Parameters<typeof fetch>) => Response.json({
id: "execution-session",
session_id: "execution-session",
title: "南桥管道爆管调查 · 执行记录",
purpose: "task_execution",
task_id: "50000000-0000-4000-8000-000000000001",
created_at: "2026-10-30T01:00:00Z",
updated_at: "2026-10-30T01:05:00Z",
messages: [{ id: "assistant", role: "assistant", parts: [{ type: "text", text: "调查完成" }] }],
is_streaming: false,
run_status: "completed"
}));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", {
getAccessToken: async () => "token",
getProjectId: async () => "project"
});
const loaded = await client.loadTaskExecutionSession(
"50000000-0000-4000-8000-000000000001",
"70000000-0000-4000-8000-000000000001",
"execution-session"
);
expect(loaded).toMatchObject({
sessionId: "execution-session",
title: "南桥管道爆管调查 · 执行记录",
purpose: "task_execution",
taskId: "50000000-0000-4000-8000-000000000001",
runStatus: "completed"
});
const url = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(url.pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session");
expect(url.searchParams.get("run_id")).toBe("70000000-0000-4000-8000-000000000001");
expect(url.searchParams.get("session_id")).toBe("execution-session");
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBe("project");
});
it("creates a debug branch bound to an exact task execution history", async () => {
const fetchMock = vi.fn(async (..._args: Parameters<typeof fetch>) => Response.json({
session_id: "debug-session",
title: "南桥管道爆管调查 · 调试",
purpose: "task_collaboration",
task_id: "50000000-0000-4000-8000-000000000001",
parent_session_id: "execution-session",
created: true
}, { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", {
getAccessToken: async () => "token",
getProjectId: async () => "project"
});
await expect(client.createTaskExecutionDebugSession(
"50000000-0000-4000-8000-000000000001",
"70000000-0000-4000-8000-000000000001",
"execution-session"
)).resolves.toMatchObject({ session_id: "debug-session", purpose: "task_collaboration" });
const [url, init] = fetchMock.mock.calls[0] ?? [];
expect(new URL(String(url)).pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session/debug");
expect(JSON.parse(String(init?.body))).toEqual({
run_id: "70000000-0000-4000-8000-000000000001",
session_id: "execution-session"
});
});
it("falls back when a candidate is not the agent service", async () => { it("falls back when a candidate is not the agent service", async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
+99 -9
View File
@@ -1,8 +1,10 @@
import { createInteractionRequestApi, type InteractionRequestApi } from "./interaction-requests";
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"; 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";
export type AgentEventTriggerContext = { export type AgentEventTriggerContext = {
@@ -19,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";
}; };
}; };
@@ -40,6 +42,10 @@ export type AgentModelsResponse = {
export type AgentInputDelivery = "steer" | "queue"; export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" }; export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
export class AgentResponseFinishedError extends Error {}
export class AgentSessionStreamError extends Error {
constructor(message: string, readonly status: number) { super(message); }
}
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string }; export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
@@ -115,6 +121,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update" | "todo_update"
| "permission_request" | "permission_request"
| "permission_response" | "permission_response"
| "interaction_request"
| "question_request" | "question_request"
| "question_response" | "question_response"
| "tool_call" | "tool_call"
@@ -148,7 +155,10 @@ export type AgentSessionStreamEvent =
data: Record<string, unknown>; data: Record<string, unknown>;
}; };
export type AgentApiClient = { export type AgentApiClient = InteractionRequestApi & {
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[]>;
@@ -167,6 +177,8 @@ export type AgentApiClient = {
moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>; moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>;
loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>; loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>; createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
createTaskExecutionDebugSession: (taskId: string, runId: string, sessionId: string) => Promise<AgentChatSession>;
loadTaskExecutionSession: (taskId: string, runId: string, sessionId: string, signal?: AbortSignal) => Promise<AgentLoadedChatSession | null>;
loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>; loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
streamSession: ( streamSession: (
sessionId: string, sessionId: string,
@@ -191,6 +203,7 @@ export type AgentApiClient = {
requestId: string, requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string } options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>; ) => Promise<void>;
getQuestionStatus: (sessionId: string, requestId: string) => Promise<{ status: "pending" | "answered" | "rejected" | "invalidated" }>;
replyQuestion: ( replyQuestion: (
requestId: string, requestId: string,
options: { sessionId: string; answers: string[][] } options: { sessionId: string; answers: string[][] }
@@ -239,8 +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(answerStateRequest),
async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json();
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("无效的会话文件地址");
@@ -255,10 +301,14 @@ export function createAgentApiClient(
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
}, },
async submitResponseInput(sessionId, text, delivery, id) { async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", { const response = await request("/responses", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } }) body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
}); });
const data = await response.json();
if (response.status === 409 && data?.code === "RESPONSE_FINISHED") throw new AgentResponseFinishedError("当前回复已结束");
if (!response.ok) throw new Error(getResponseErrorMessage(data, response.status));
return data as AgentResponseInput;
}, },
async listResponseInputs(sessionId) { async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`); const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
@@ -347,6 +397,30 @@ export function createAgentApiClient(
); );
}, },
async createTaskExecutionDebugSession(taskId, runId, sessionId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/execution-session/debug`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: runId, session_id: sessionId })
}
);
},
async loadTaskExecutionSession(taskId, runId, sessionId, signal) {
const query = new URLSearchParams({ run_id: runId, session_id: sessionId });
const response = await request(
`/task/${encodeURIComponent(taskId)}/execution-session?${query}`,
{ signal }
);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (response.status === 404) return null;
if (!response.ok) throw new Error(getResponseErrorMessage(data, response.status));
return toLoadedSession(data);
},
async getFrontendActionRegistry() { async getFrontendActionRegistry() {
return requestJson<unknown>("/frontend-action-registry"); return requestJson<unknown>("/frontend-action-registry");
}, },
@@ -384,7 +458,7 @@ export function createAgentApiClient(
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
const data = text ? JSON.parse(text) : null; const data = text ? JSON.parse(text) : null;
throw new Error(getResponseErrorMessage(data, response.status)); throw new AgentSessionStreamError(getResponseErrorMessage(data, response.status), response.status);
} }
await readSessionSseStream(response, options.onEvent); await readSessionSseStream(response, options.onEvent);
@@ -460,6 +534,11 @@ export function createAgentApiClient(
}); });
}, },
async getQuestionStatus(sessionId, requestId) {
const value = await requestJson<{ status: "pending" | "answered" | "rejected" | "invalidated" }>(`/session/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(requestId)}`, { cache: "no-store" });
if (!["pending", "answered", "rejected", "invalidated"].includes(value.status)) throw new Error("问题状态核对失败");
return value;
},
async replyQuestion(requestId, options) { async replyQuestion(requestId, options) {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, { await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST", method: "POST",
@@ -505,7 +584,8 @@ async function fetchWithFallback(
path: string, path: string,
init?: RequestInit, init?: RequestInit,
getAccessToken?: AccessTokenProvider, getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined> getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) { ) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown; let lastError: unknown;
@@ -520,7 +600,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) { for (const baseUrl of orderedBaseUrls) {
try { try {
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit); const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
if (response.ok) { if (response.ok) {
setActiveBaseUrl(baseUrl); setActiveBaseUrl(baseUrl);
return response; return response;
@@ -609,7 +689,7 @@ function normalizeTitle(value: unknown) {
} }
function readRunStatus(value: unknown): AgentRunStatus | undefined { function readRunStatus(value: unknown): AgentRunStatus | undefined {
return value === "running" || value === "completed" || value === "error" || value === "aborted" return value === "waiting_input" || value === "running" || value === "completed" || value === "error" || value === "aborted"
? value ? value
: undefined; : undefined;
} }
@@ -757,6 +837,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" || value === "execution_plan" ||
value === "permission_request" || value === "permission_request" ||
value === "permission_response" || value === "permission_response" ||
value === "interaction_request" ||
value === "question_request" || value === "question_request" ||
value === "question_response" || value === "question_response" ||
value === "tool_call" || value === "tool_call" ||
@@ -794,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";
}
@@ -0,0 +1,13 @@
import { expect, it, vi } from "vitest";
import { createInteractionRequestApi, interactionRequestSchema } from "./interaction-requests";
it("rejects malformed host requests and scopes escaped URLs with no-store reads", async () => {
expect(interactionRequestSchema.safeParse({ version: "agent-interaction-request@1", questions: [] }).success).toBe(false);
const request = vi.fn(async () => new Response(JSON.stringify({ items: [] }), { status: 200 }));
const api = createInteractionRequestApi(request);
await expect(api.listInteractionRequests("session/other")).resolves.toEqual({ items: [] });
expect(request).toHaveBeenCalledWith("/session/session%2Fother/interactions", { cache: "no-store" });
});
it("keeps denied status available to purge the scope even with an empty response", async () => {
const api = createInteractionRequestApi(async () => new Response(null, { status: 403 }));
await expect(api.listInteractionRequests("session")).rejects.toMatchObject({ status: 403 });
});
@@ -0,0 +1,51 @@
import { z } from "zod";
const id = z.string().min(1).max(128);
const revision = z.number().int().nonnegative();
const option = z.object({ id, label: z.string().max(200) }).strict();
export const interactionAnswerSchema = z.object({ question_id: id, selected_option_ids: z.array(id).max(12), custom_text: z.string().max(2000) }).strict();
export const interactionRequestSchema = z.object({
version: z.literal("agent-interaction-request@1"), interaction_id: id, message_id: id, turn_id: id, call_id: id,
questions: z.array(z.object({ question_id: id, title: z.string().max(200), body: z.string().max(2000), options: z.array(option).max(12), mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
status: z.enum(["pending", "answered", "skipped", "cancelled", "invalidated"]), revision, draft_revision: revision,
draft: z.array(interactionAnswerSchema).max(3), answers: z.array(interactionAnswerSchema.extend({ selected_options: z.array(option) }).strict()).optional(),
created_at: z.string(), resolved_at: z.string().optional(),
delivery: z.object({ native_message_id: id, revision, status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), error_code: z.string().optional(), response_id: z.string().optional() }).strict().optional(),
}).strict();
export type InteractionRequest = z.infer<typeof interactionRequestSchema>;
export const canReplaceInteractionRequest = (next: InteractionRequest, current: InteractionRequest) =>
(next.delivery?.revision ?? -1) >= (current.delivery?.revision ?? -1)
&& (next.revision > current.revision || (next.revision === current.revision && next.draft_revision >= current.draft_revision));
export function readInteractionReply(data: unknown, messageId: string): InteractionRequest | undefined {
const parsed = interactionRequestSchema.safeParse(data);
return parsed.success && ["answered", "skipped"].includes(parsed.data.status)
&& parsed.data.delivery?.native_message_id === messageId ? parsed.data : undefined;
}
export type InteractionAnswer = z.infer<typeof interactionAnswerSchema>;
export type InteractionWrite = { version: "agent-interaction-request@1"; request_id: string; expected_revision: number; answers?: InteractionAnswer[] };
export type InteractionDraftWrite = { version: "agent-interaction-request@1"; request_id: string; expected_draft_revision: number; answers: InteractionAnswer[] };
export class InteractionRequestApiError extends Error {
constructor(message: string, readonly status: number, readonly current?: InteractionRequest) { super(message); }
}
export function createInteractionRequestApi(request: (path: string, init?: RequestInit) => Promise<Response>) {
const path = (sessionId: string, interactionId?: string) => `/session/${encodeURIComponent(sessionId)}/interactions${interactionId ? `/${encodeURIComponent(interactionId)}` : ""}`;
const read = async (response: Response) => {
const value: unknown = await response.json().catch(() => {
throw new InteractionRequestApiError("提问状态暂不可用", response.status);
});
if (!response.ok) {
const error = z.object({ detail: z.string().optional(), current: z.unknown().optional() }).passthrough().safeParse(value);
const current = interactionRequestSchema.safeParse(error.success ? error.data.current : null);
throw new InteractionRequestApiError(error.success ? error.data.detail ?? "提问状态暂不可用" : "提问状态暂不可用", response.status, current.success ? current.data : undefined);
}
return value;
};
return {
async listInteractionRequests(sessionId: string) { return z.object({ items: z.array(interactionRequestSchema), next_cursor: z.string().optional() }).strict().parse(await read(await request(path(sessionId), { cache: "no-store" }))); },
async getInteractionRequest(sessionId: string, interactionId: string) { return interactionRequestSchema.parse(await read(await request(path(sessionId, interactionId), { cache: "no-store" }))); },
async writeInteractionRequest(sessionId: string, interactionId: string, action: "draft" | "reply" | "skip" | "cancel" | "resume", body: InteractionWrite | InteractionDraftWrite) {
return interactionRequestSchema.parse(await read(await request(`${path(sessionId, interactionId)}/${action}`, { method: action === "draft" ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })));
},
};
}
export type InteractionRequestApi = ReturnType<typeof createInteractionRequestApi>;
@@ -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,10 +1,11 @@
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai"; import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export class AgentStreamDisconnectedError extends Error {}
export function createResponseRequest(messages: UIMessage[], options: { export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[]; conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown; topicId?: string; interactionId?: string; triggerContext?: unknown; taskContext?: unknown; topicId?: string;
}) { }) {
const latest = [...messages].reverse().find((message) => message.role === "user"); const latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim(); const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
@@ -13,7 +14,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true, input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}), ...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}), ...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [], tjwater: { ...(options.interactionId ? { interaction_id: options.interactionId } : {}), ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}), ...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}), ...(options.taskContext ? { task_context: options.taskContext } : {}),
}, },
@@ -99,7 +100,7 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
buffer += decoder.decode(); buffer += decoder.decode();
flushBlocks(controller); flushBlocks(controller);
if (buffer.trim()) parse(buffer, controller); if (buffer.trim()) parse(buffer, controller);
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态"); if (!terminal) throw new AgentStreamDisconnectedError("Responses 流在完成前断开,正在恢复会话连接");
}, },
})); }));
} }
@@ -1,36 +1,49 @@
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { Check, Copy, LoaderCircle } from "lucide-react"; import { Check, Copy, LoaderCircle, CircleAlert } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { MessageAction, MessageActions } from "@/shared/ai-elements/message"; import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
import { getAnswerPartsTranscript } from "../answer-ui/answer-presentation";
import type { AnswerUiPart } from "../answer-ui/contract"; import type { AnswerUiPart } from "../answer-ui/contract";
export function AgentChatMessageHeader({ export function AgentChatMessageHeader({ running }: { running: boolean }) {
running, if (!running) return null;
interactive
}: {
running: boolean;
interactive: boolean;
}) {
if (!running && !interactive) return null;
return ( return (
<div className="agent-chat-message-header"> <div className="agent-chat-message-header justify-start text-left">
{running ? ( <span
<span role="status"
role="status" className="inline-flex items-center gap-1.5 text-sm leading-5 text-muted-foreground"
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground" >
> <LoaderCircle size={14} className="motion-safe:animate-spin" aria-hidden="true" />
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" /> 正在回复
正在回复 </span>
</span> </div>
) : interactive ? ( );
<Badge }
variant="secondary"
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none" export const AGENT_MESSAGE_ACTION_CLASS_NAME =
> "h-8 min-w-8 gap-1.5 px-2 text-sm font-normal text-muted-foreground shadow-none after:inset-0";
交互回答 export const AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME = "hidden @[320px]/agent-message-actions:inline";
</Badge>
) : null} export function AgentChatMessageToolbar({
speech,
copy,
children
}: {
speech?: ReactNode;
copy?: ReactNode;
children?: ReactNode;
}) {
return (
<div
data-agent-message-footer
className="@container/agent-message-actions mt-2 border-t border-border/70 pt-1"
>
<div data-agent-message-toolbar className="flex min-h-8 min-w-0 items-center gap-1">
{children}
<div className="ml-auto flex shrink-0 items-center gap-1">
{speech}
{copy}
</div>
</div>
</div> </div>
); );
} }
@@ -38,18 +51,16 @@ export function AgentChatMessageHeader({
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) { export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const text = parts const text = getAnswerPartsTranscript(parts);
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
.join("\n\n");
if (!text.trim()) return null; if (!text.trim()) return null;
return ( return (
<MessageActions className="agent-chat-message-actions"> <MessageActions className="agent-chat-message-actions">
<MessageAction <MessageAction
tooltip={copied ? "回复已复制" : "复制回复"} tooltip={error ? "复制失败,点击重试" : copied ? "回复已复制" : "复制回复"}
label={copied ? "回复已复制" : "复制回复"} label={copied ? "回复已复制" : "复制回复"}
size="sm" size="sm"
className="h-10 gap-1.5 px-2 text-[11px] font-normal shadow-none" className={AGENT_MESSAGE_ACTION_CLASS_NAME}
aria-label={copied ? "回复已复制" : "复制回复"} aria-label={copied ? "回复已复制" : "复制回复"}
onClick={async () => { onClick={async () => {
try { try {
@@ -62,10 +73,18 @@ export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
} }
}} }}
> >
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />} {error ? (
{copied ? "已复制" : "复制回复"} <CircleAlert size={16} className="text-destructive" aria-hidden="true" />
) : copied ? (
<Check size={16} aria-hidden="true" />
) : (
<Copy size={16} aria-hidden="true" />
)}
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>
{error ? "重试复制" : copied ? "已复制" : "复制"}
</span>
</MessageAction> </MessageAction>
<span role="status" className="text-[11px] text-muted-foreground"> <span role="status" className="sr-only">
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""} {error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
</span> </span>
</MessageActions> </MessageActions>
File diff suppressed because it is too large Load Diff
@@ -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,146 @@
import { CircleCheck, CircleHelp, LoaderCircle, RotateCw, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import type { InteractionAnswer } from "../api/interaction-requests";
import type { InteractionRequestEntry, InteractionRequestsController } from "../hooks/use-interaction-requests";
import { AgentUserMessageText } from "./agent-user-message-text";
const statuses = { pending: "等待回答", answered: "答案已保存", skipped: "已跳过", cancelled: "已取消", invalidated: "已失效" };
const deliveries = { queued: "等待继续", dispatching: "正在继续处理", delivered: "已继续处理", blocked: "继续失败,可以重试", cancelled: "已停止继续" };
export function AgentInteractionRequests({ controller, representedMessageIds }: {
controller: InteractionRequestsController;
representedMessageIds?: ReadonlySet<string>;
}) {
return <section aria-label="Agent 提问" className="space-y-3">
{controller.checking ? <p role="status" className="text-xs text-muted-foreground">正在核对提问状态,核对后可回答。</p> : null}
{controller.error ? <div role="alert" className="text-sm">{controller.error}<Button type="button" variant="ghost" size="sm" onClick={() => void controller.refresh()} aria-label="重新核对提问"><RotateCw size={14} aria-hidden />重新核对</Button></div> : null}
{controller.entries.filter(({ record }) => !(record.delivery && ["answered", "skipped"].includes(record.status)
&& representedMessageIds?.has(record.delivery.native_message_id))).map((entry) =>
<AgentInteractionRequestCard key={entry.record.interaction_id} entry={entry} checking={controller.checking}
onChange={(draft) => controller.update(entry.record.interaction_id, draft)} onAction={(action) => void controller.act(entry.record.interaction_id, action)} />)}
</section>;
}
/** Render the stored human answer, independent of the model-facing transcript. */
export function AgentInteractionReply({ entry, checking, onResume }: {
entry: InteractionRequestEntry;
checking: boolean;
onResume: () => void;
}) {
const { record } = entry;
const retryable = record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status);
return <div role="group" aria-label="已提交的回答" className="min-w-0 space-y-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleCheck size={14} className="shrink-0" aria-hidden />
<span>{record.status === "skipped" ? "已跳过" : "已回答"}</span>
</div>
{record.status === "skipped" ? <p className="text-sm leading-6">已跳过本组可选问题。</p> : <dl className="space-y-3">
{record.questions.map((question) => {
const answer = record.answers?.find((item) => item.question_id === question.question_id);
return <div key={question.question_id} className="min-w-0">
<dt className="mb-1 break-words text-xs leading-5 text-muted-foreground">{question.title}</dt>
<dd className="space-y-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{answer?.selected_options.map((option) => <div key={option.id} className="font-medium"><AgentUserMessageText content={option.label} /></div>)}
{answer?.custom_text ? <div>{answer.selected_options.length ? <span className="block text-xs text-muted-foreground">补充说明</span> : null}<AgentUserMessageText content={answer.custom_text} /></div> : null}
{!answer?.selected_options.length && !answer?.custom_text ? <p className="text-muted-foreground">未填写</p> : null}
</dd>
</div>;
})}
</dl>}
{entry.error ? <p role="alert" className="text-xs leading-5 text-destructive">{entry.error}</p> : null}
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
{record.delivery ? <div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
<span role="status" className="text-xs leading-5 text-muted-foreground">{checking ? "正在核对提问" : deliveries[record.delivery.status]}</span>
{retryable ? <Button type="button" variant="ghost" size="sm" disabled={checking || entry.busy} onClick={onResume}>
<RotateCw size={13} aria-hidden />重试继续
</Button> : null}
</div> : null}
</div>;
}
export function AgentInteractionRequestCard({ entry, checking, onChange, onAction }: {
entry: InteractionRequestEntry; checking: boolean; onChange: (draft: InteractionAnswer[]) => void;
onAction: (action: "reply" | "skip" | "cancel" | "resume") => void;
}) {
const { record } = entry;
if (["answered", "skipped"].includes(record.status)) return <div className="flex min-w-0 justify-end">
<div className="agent-panel-user-message min-w-0 max-w-[92%] rounded-2xl px-3 py-3">
<AgentInteractionReply entry={entry} checking={checking} onResume={() => onAction("resume")} />
</div>
</div>;
const pending = record.status === "pending";
const disabled = checking || entry.busy || !pending;
const values = entry.draft;
const answerFor = (id: string) => values.find((answer) => answer.question_id === id) ?? { question_id: id, selected_option_ids: [], custom_text: "" };
const change = (answer: InteractionAnswer) => onChange(record.questions.map((question) => question.question_id === answer.question_id ? answer : answerFor(question.question_id)));
const valid = record.questions.every((question) => !question.required || answerFor(question.question_id).selected_option_ids.length > 0 || (question.allow_custom && answerFor(question.question_id).custom_text.trim().length > 0));
const draftStatus = checking ? "正在核对提问" : entry.saving ? "正在保存草稿" : entry.dirty ? "草稿尚未保存"
: entry.draft.some((answer) => answer.selected_option_ids.length || answer.custom_text.trim()) ? "草稿已保存" : "提交后继续处理";
return <article aria-label="需要补充的信息" aria-busy={entry.busy || checking}
className="min-w-0 overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
<header className="flex min-h-10 items-center justify-between gap-3 px-4 pt-3 sm:px-5">
<div role="status" className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<CircleHelp size={15} className="shrink-0" aria-hidden />
<span>{checking ? "核对中" : statuses[record.status]}</span>
</div>
{pending ? <Button type="button" size="icon-sm" variant="ghost" aria-label="取消提问" title="取消提问"
disabled={disabled} onClick={() => onAction("cancel")}><X size={15} aria-hidden /></Button> : null}
</header>
<div className="space-y-5 px-4 pb-4 pt-2 sm:px-5 sm:pb-5">
{record.questions.map((question) => {
const answer = answerFor(question.question_id);
const selected = new Set(answer.selected_option_ids);
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0">
<legend className="mb-2 w-full text-sm font-semibold leading-6 text-foreground">
{question.title}
{pending ? <span className="ml-2 text-[11px] font-normal text-muted-foreground">
{question.required ? "必填" : "可选"}{question.options.length ? question.mode === "multiple" ? " · 可多选" : " · 单选" : ""}
</span> : null}
</legend>
{pending ? <>
<p className="mb-3 whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">{question.body}</p>
<div className="space-y-2">
{question.options.map((option) => <label key={option.id}
className={cn("flex min-h-11 min-w-0 cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm leading-6 has-[:disabled]:cursor-default",
selected.has(option.id) ? "border-primary/50 bg-primary/5" : "border-border bg-background has-[:enabled]:hover:bg-muted/40")}>
<span className="mt-0.5 inline-flex shrink-0">
{question.mode === "single" ? <Radio name={`${record.interaction_id}-${question.question_id}`}
checked={selected.has(option.id)} onChange={() => change({ ...answer, selected_option_ids: [option.id] })} />
: <Checkbox checked={selected.has(option.id)} onChange={(event) => change({ ...answer,
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id) })} />}
</span>
<span className="min-w-0 flex-1 break-words">{option.label}</span>
</label>)}
</div>
{question.allow_custom ? <label className="mt-3 block text-xs font-medium leading-5 text-foreground">
{question.options.length ? "自定义回答 / 补充说明" : "你的回答"}
<Textarea aria-label={`${question.title}:补充说明`} placeholder={question.options.length ? "可以直接填写回答,也可以补充已选内容" : "请输入你的回答"}
maxLength={2000} value={answer.custom_text} onChange={(event) => change({ ...answer, custom_text: event.target.value })}
className="mt-1.5 min-h-20 resize-y bg-background text-sm font-normal leading-6" />
</label> : null}
</> : <p className="text-sm leading-6 text-muted-foreground">未提交回答。</p>}
</fieldset>;
})}
{entry.error ? <p role="alert" className="text-sm leading-5 text-destructive">{entry.error}</p> : null}
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3 sm:px-5">
<p role="status" className="text-xs leading-5 text-muted-foreground">{pending ? draftStatus : "本次提问已结束"}</p>
<div className="ml-auto flex flex-wrap gap-2">
{pending ? <>
{record.questions.every((question) => !question.required) ? <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => onAction("skip")}>跳过</Button> : null}
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => onAction("reply")}>
{entry.busy ? <LoaderCircle size={14} className="animate-spin motion-reduce:animate-none" aria-hidden /> : null}提交回答
</Button>
</> : null}
</div>
</footer>
</article>;
}
@@ -1,3 +1,5 @@
import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
import { AgentToolProgress } from "./agent-tool-progress";
import { import {
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
@@ -40,7 +42,6 @@ import type {
AgentTodoUpdate AgentTodoUpdate
} from "../types"; } from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"]; type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"]; type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"]; type QuestionStatus = AgentQuestionRequest["status"];
@@ -53,22 +54,6 @@ type AgentStatusMeta = {
activity?: StatusActivity; activity?: StatusActivity;
}; };
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
running: {
label: "进行中",
tone: "info",
activity: "live"
},
completed: {
label: "已完成",
tone: "success"
},
error: {
label: "失败",
tone: "danger"
}
};
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = { const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
completed: { completed: {
label: "完成", label: "完成",
@@ -100,6 +85,8 @@ const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
}; };
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = { const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
checking: { label: "核对中", tone: "info" },
invalidated: { label: "已失效", tone: "neutral" },
answered: { label: "已回答", tone: "success" }, answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" }, rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" }, error: { label: "失败", tone: "danger" },
@@ -208,28 +195,21 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
} }
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false); const [userExpanded, setExpanded] = useState(false);
const expanded = !running || userExpanded;
const listId = useId(); const listId = useId();
const compact = running && !expanded; const visibleProgress = expanded ? progress : progress.slice(-STREAMING_PROGRESS_LIMIT);
const visibleProgress = expanded
? progress
: running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: [];
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress> <div className="min-w-0" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5"> {running ? <div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" /> <ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span> <span className="min-w-0 flex-1 text-sm font-medium text-foreground">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]"> <span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
{running ? ( <StatusDot tone="info" activity="live" aria-hidden="true" />
<StatusDot tone="info" activity="live" aria-hidden="true" /> <span>{expanded ? "全部" : "最近"}</span>
) : null}
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
<span className="font-mono tabular-nums text-slate-600"> <span className="font-mono tabular-nums text-slate-600">
{expanded || !running {expanded
? progress.length ? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} : Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
<span className="px-0.5 text-slate-300">/</span> <span className="px-0.5 text-slate-300">/</span>
@@ -254,14 +234,14 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
</div> </div> : null}
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? ( {visibleProgress.length ? (
<motion.ol <motion.ol
key={listMode} key="progress"
id={listId} id={listId}
aria-label={expanded ? "全部执行进度" : "最近执行进度"} aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 flex flex-col gap-1.5 overflow-hidden" className={cn("flex flex-col gap-3 overflow-hidden", running && "mt-2")}
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
@@ -272,63 +252,33 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
<motion.li <motion.li
key={item.id} key={item.id}
className={cn( className={cn(
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs", "items-start gap-2 text-sm",
compact && "h-12 overflow-hidden" item.phase === "tool" ? "block min-w-0" : "grid grid-cols-[24px_minmax(0,1fr)]"
)} )}
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }} exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }} transition={{ duration: 0.16, ease: agentEase }}
> >
<StatusDot {item.phase === "tool" ? <AgentToolProgress item={item} /> : <>
tone={progressStatusMeta[item.status].tone} <AgentProgressStatusIcon status={item.status} />
activity={progressStatusMeta[item.status].activity}
size="md"
className="mt-1"
/>
<span className="min-w-0"> <span className="min-w-0">
<span <span
className={cn( className="block break-words font-medium leading-6 text-foreground"
"block font-semibold text-slate-800",
compact ? "truncate" : "break-words"
)}
title={item.title} title={item.title}
> >
{item.title} {item.title}
</span> </span>
{item.detail ? ( {item.detail ? (
<span <span
className={cn( className="block break-words leading-5 text-muted-foreground"
"block leading-5 text-[var(--ink-muted)]",
compact ? "truncate" : "break-words"
)}
title={item.detail} title={item.detail}
> >
{item.detail} {item.detail}
</span> </span>
) : null} ) : null}
</span> </span>
<AnimatePresence initial={false} mode="wait"> </> }
<motion.span
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running"
? "text-[var(--action-blue-hover)]"
: [
`status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]"
]
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14 }}
>
{progressStatusMeta[item.status].label}
</motion.span>
</AnimatePresence>
</motion.li> </motion.li>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -0,0 +1,39 @@
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { hasPendingMessageInput } from "../presentation/message-process-groups";
import type { AgentChatMessage } from "../types";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
export function AgentMessageProcessContent({
messages,
qaOnly,
showProgress,
runningMessageId
}: {
messages: AgentChatMessage[];
qaOnly: boolean;
showProgress: boolean;
runningMessageId?: string;
}) {
const progress = messages.flatMap((message) => (message.progress ?? []).map((item) => ({
...item, id: JSON.stringify([message.id, item.id])
})));
return (
<>
{showProgress ? <AgentMessageProgress progress={progress} running={Boolean(runningMessageId)} /> : null}
{messages.map((message) => {
const { process } = getAnswerPresentation(message);
const running = message.id === runningMessageId;
const showDetails = !qaOnly && !running && !hasPendingMessageInput(message)
&& Boolean(message.todos?.todos.length || message.permissions?.length || message.questions?.length);
if (!process.length && !showDetails) return null;
return <div key={message.id} data-agent-process-message-id={message.id} className="space-y-3">
{process.length > 0 ? (
<AnswerPartsView parts={process} messageId={`${message.id}-process`} disabled streaming={running} streamDone={!running} />
) : null}
{showDetails ? <AgentMessageDetails message={message} /> : null}
</div>;
})}
</>
);
}
@@ -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,227 @@
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { ChevronDown } from "lucide-react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/shared/ui/button";
import { AGENT_MESSAGE_ACTION_CLASS_NAME } from "./agent-chat-message-chrome";
import { cn } from "@/shared/ui/cn";
import { agentEase } from "./agent-motion";
type Anchor = {
scroll: HTMLElement;
element: HTMLElement;
top: number;
release: () => void;
};
/** A reader action stops bottom-following before it changes the message's layout. */
export function AgentProcessDisclosure({
label,
children,
kind = "process",
toolbar,
live = false
}: {
label: ReactNode;
children: ReactNode;
kind?: "process" | "tool";
toolbar?: (trigger: ReactNode) => ReactNode;
live?: boolean;
}) {
const { scrollRef, stopScroll } = useStickToBottomContext();
const [userOpen, setOpen] = useState<boolean>();
const open = live || (userOpen ?? false);
const [animated, setAnimated] = useState(false);
const [phase, setPhase] = useState<"idle" | "opening" | "closing">("idle");
const requestedOpen = useRef(false);
const sequence = useRef(0);
const root = useRef<HTMLDetailsElement>(null);
const anchor = useRef<Anchor | null>(null);
const frame = useRef<number | undefined>(undefined);
const controls = useAnimationControls();
const reducedMotion = useReducedMotion();
const contentId = useId();
function releaseAnchor() {
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
anchor.current?.release();
anchor.current = null;
}
function captureAnchor() {
releaseAnchor();
const scroll = scrollRef.current;
const details = root.current;
if (!scroll || !details) return;
stopScroll();
const element = details.closest<HTMLElement>("[data-agent-message-id]") ?? details;
const previous = scroll.style.overflowAnchor;
scroll.style.overflowAnchor = "none";
const cancel = () => releaseAnchor();
for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
scroll.addEventListener(event, cancel, { passive: true });
anchor.current = {
scroll,
element,
top: element.getBoundingClientRect().top,
release: () => {
scroll.style.overflowAnchor = previous;
for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
scroll.removeEventListener(event, cancel);
}
};
}
function restoreAnchor() {
const value = anchor.current;
if (!value || !value.element.isConnected) return;
stopScroll();
const delta = value.element.getBoundingClientRect().top - value.top;
if (Math.abs(delta) > 0.5) value.scroll.scrollTop += delta;
}
async function toggle(event: MouseEvent<HTMLElement>) {
event.preventDefault();
const current = ++sequence.current;
const next = !(phase === "idle" ? open : requestedOpen.current);
requestedOpen.current = next;
controls.stop();
captureAnchor();
const immediate = Boolean(reducedMotion) || event.detail === 0;
setAnimated(!immediate);
if (next) {
controls.set(immediate ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 });
flushSync(() => {
setPhase(immediate ? "idle" : "opening");
setOpen(true);
});
restoreAnchor();
} else {
setPhase(immediate ? "idle" : "closing");
}
if (!immediate)
await controls.start(next ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
duration: 0.16,
ease: agentEase
});
if (sequence.current !== current) return;
if (!next) {
flushSync(() => setOpen(false));
controls.set({ opacity: 1, y: 0 });
}
restoreAnchor();
setPhase("idle");
frame.current = requestAnimationFrame(() => {
restoreAnchor();
releaseAnchor();
});
}
useEffect(
() => () => {
sequence.current += 1;
controls.stop();
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
anchor.current?.release();
anchor.current = null;
},
[controls]
);
const processIcon = (
<span data-process-toggle-icon className="inline-grid size-6 shrink-0 place-items-center">
<ChevronDown
aria-hidden
className={cn(
"size-4",
animated && "transition-transform duration-150 motion-reduce:transition-none",
open && phase !== "closing" && "rotate-180"
)}
/>
</span>
);
return (
<>
{!live && toolbar?.(
<Button
type="button"
variant="ghost"
size="sm"
className={cn(AGENT_MESSAGE_ACTION_CLASS_NAME, "gap-2 rounded-lg px-3")}
aria-expanded={open}
aria-controls={contentId}
data-process-toggle
onClick={(event) => {
void toggle(event);
}}
>
{processIcon}
<span data-process-toggle-label>{label}</span>
</Button>
)}
<details
ref={root}
open={open}
hidden={!live && toolbar && !open ? true : undefined}
data-agent-process={kind === "process" && !live ? "" : undefined}
data-agent-live-process={live ? "" : undefined}
data-agent-tool={kind === "tool" ? "" : undefined}
data-disclosure-motion={phase}
className={cn(
"min-w-0 overflow-hidden",
kind === "process" &&
(toolbar
? "mt-2 rounded-xl border border-border bg-card"
: "rounded-xl border border-border bg-card")
)}
>
<summary
onClick={(event) => {
void toggle(event);
}}
aria-controls={contentId}
aria-hidden={toolbar ? true : undefined}
tabIndex={toolbar ? -1 : undefined}
className={cn(
toolbar
? "hidden"
: "flex min-h-10 cursor-pointer list-none gap-2 text-xs font-medium leading-5 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden",
!toolbar &&
(kind === "process" ? "items-center px-3 py-2" : "items-start rounded-md py-1"),
kind === "process" && open && "border-b border-border"
)}
>
{!toolbar ? (
<>
{kind === "process" && !toolbar ? processIcon : null}
<span className="min-w-0 flex-1">{label}</span>
{kind === "tool" ? (
<span data-tool-chevron className="inline-grid size-6 shrink-0 place-items-center">
<ChevronDown
aria-hidden
className={cn(
"size-4 text-muted-foreground",
animated && "transition-transform duration-150 motion-reduce:transition-none",
open && phase !== "closing" ? "rotate-0" : "-rotate-90"
)}
/>
</span>
) : null}
</>
) : null}
</summary>
<motion.div
id={contentId}
initial={false}
animate={controls}
data-agent-disclosure-content
className={kind === "process" ? "space-y-3 p-3" : "pb-2 pl-8"}
>
{children}
</motion.div>
</details>
</>
);
}
@@ -0,0 +1,31 @@
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import type { AgentChatProgress } from "../types";
const states = {
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
error: { label: "失败", tone: "danger", Icon: XCircle },
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
} satisfies Record<
AgentChatProgress["status"],
{ label: string; tone: StatusTone; Icon: typeof CheckCircle2 }
>;
/** Ordinary steps and tool calls share the same status glyph, size and tone. */
export function AgentProgressStatusIcon({ status }: { status: AgentChatProgress["status"] }) {
const { label, tone, Icon } = states[status];
return (
<StatusIcon
data-progress-status={status}
role="img"
aria-label={label}
tone={tone}
activity={status === "running" ? "loading" : "static"}
shape="circle"
size="sm"
className="border-0"
>
<Icon className="size-3.5" aria-hidden="true" />
</StatusIcon>
);
}
@@ -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>
);
}
@@ -0,0 +1,26 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { AgentToolProgress } from "./agent-tool-progress";
vi.mock("use-stick-to-bottom", () => ({ useStickToBottomContext: () => ({ scrollRef: { current: null }, stopScroll: vi.fn() }) }));
afterEach(cleanup);
it("keeps full structured parameters collapsed until the tool row opens", () => {
const address = "完整地址".repeat(70);
const { container } = render(<AgentToolProgress item={{ id: "tool", phase: "tool", status: "completed", title: "已完成", tool: { name: "mcp_tjwater_geocode", input: { address, radius: 500 } }, detail: "legacy secret summary" }} />);
const details = container.querySelector("details")!;
expect(details.open).toBe(false);
expect(screen.getByText("mcp_tjwater_geocode")).toBeTruthy();
expect(container.querySelector("pre")?.textContent).toContain(address);
expect(screen.queryByText("legacy secret summary")).toBeNull();
fireEvent.click(container.querySelector("summary")!);
expect(details.open).toBe(true);
});
it("retains historical details and removes the old status text from the tool label", () => {
const { container } = render(<AgentToolProgress item={{ id: "old", phase: "tool", status: "running", title: "正在调用 mcp_tjwater_query_nearby_elements", detail: "历史参数:radius=500" }} />);
expect(screen.getByText("mcp_tjwater_query_nearby_elements")).toBeTruthy();
expect(screen.getByText("历史参数:radius=500")).toBeTruthy();
expect(container.querySelector("details")?.open).toBe(false);
});
@@ -0,0 +1,63 @@
import { Wrench } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
import type { AgentChatProgress } from "../types";
import { AgentProcessDisclosure } from "./agent-process-disclosure";
export function AgentToolProgress({
item,
compact = false
}: {
item: AgentChatProgress;
compact?: boolean;
}) {
const name =
item.tool?.name ??
item.title
.replace(/^(?:正在调用|准备调用)\s*/, "")
.replace(
/\s*(?:调用失败|已执行完成|已完成|正在执行|正在调用|调用中|已进入待执行状态|等待执行|执行失败)\s*$/,
""
);
const input = item.tool?.input;
return (
<AgentProcessDisclosure
kind="tool"
label={
<span className={cn("flex min-w-0 items-start gap-2", compact && "text-xs")}>
<AgentProgressStatusIcon status={item.status} />
<span
data-tool-symbol
className="inline-flex h-6 w-4 shrink-0 items-center justify-center"
>
<Wrench aria-hidden="true" className="size-4 text-muted-foreground" />
</span>
<span data-tool-name className="min-w-0 break-all font-medium leading-6">
{name || "工具调用"}
</span>
</span>
}
>
{input ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">调用参数</p>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted/30 p-3 text-xs leading-relaxed">
{JSON.stringify(input, null, 2)}
</pre>
{item.status === "error" && item.detail ? (
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-destructive">
{item.detail}
</p>
) : null}
</div>
) : null}
{!input && item.detail ? (
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
{item.detail}
</p>
) : !input ? (
<p className="text-xs text-muted-foreground">无附加参数</p>
) : null}
</AgentProcessDisclosure>
);
}
@@ -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>
@@ -0,0 +1,5 @@
import { copySelectedParagraph } from "@/shared/ai-elements/paragraph-copy";
export function AgentUserMessageText({ content }: { content: string }) {
return <p className="whitespace-pre-wrap" onCopy={(event) => copySelectedParagraph(event, event.currentTarget)}>{content}</p>;
}
@@ -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,103 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useAnimationControls, useReducedMotion } from "motion/react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import type { AgentChatMessage } from "../types";
import { agentEase } from "../components/agent-motion";
type ReadingAnchor = { element: HTMLElement; text: string; top: number };
const readable = "p, h1, h2, h3, li, summary";
/** A final report is readable before transport cleanup finishes. Handoff once. */
export function useAgentAnswerTransition(message: AgentChatMessage, running: boolean) {
const ready = Boolean(message.finalAnswer) || !running;
const [committed, setCommitted] = useState(ready);
const [phase, setPhase] = useState(ready ? "settled" : "streaming");
const previous = useRef(message);
const root = useRef<HTMLDivElement>(null);
const anchor = useRef<{ scroll: HTMLElement; reading?: ReadingAnchor; top: number; release: () => void } | null>(null);
const controls = useAnimationControls();
const reducedMotion = useReducedMotion();
const { scrollRef, stopScroll, state } = useStickToBottomContext();
useLayoutEffect(() => { if (!ready) previous.current = message; }, [message, ready]);
useLayoutEffect(() => {
if (!ready && committed) {
setCommitted(false);
setPhase("streaming");
controls.set({ opacity: 1 });
return;
}
if (!ready || committed) return;
let disposed = false;
const scroll = scrollRef.current;
const element = root.current;
if (scroll && element) {
anchor.current?.release();
anchor.current = null;
const viewport = scroll.getBoundingClientRect();
// Use the same near-bottom state as the conversation's follow button.
const following = state.isAtBottom || state.isNearBottom;
const reading = !following ? [...scroll.querySelectorAll<HTMLElement>(readable)].find((node) => {
const bounds = node.getBoundingClientRect();
return bounds.height > 0 && bounds.bottom > viewport.top + 12 && bounds.top < viewport.bottom;
}) : undefined;
stopScroll();
const overflowAnchor = scroll.style.overflowAnchor;
scroll.style.overflowAnchor = "none";
const cancel = () => { anchor.current?.release(); anchor.current = null; };
const events = ["wheel", "pointerdown", "touchmove", "keydown"];
for (const event of events) scroll.addEventListener(event, cancel, { passive: true });
anchor.current = { scroll,
reading: reading ? { element: reading, text: reading.textContent ?? "", top: reading.getBoundingClientRect().top } : undefined,
top: Math.max(viewport.top + 12, element.getBoundingClientRect().top),
release: () => {
scroll.style.overflowAnchor = overflowAnchor;
for (const event of events) scroll.removeEventListener(event, cancel);
},
};
}
const commit = () => { if (!disposed) { setCommitted(true); setPhase(reducedMotion ? "settled" : "entering"); } };
if (reducedMotion) commit();
else {
setPhase("leaving");
void controls.start({ opacity: 0 }, { duration: 0.08, ease: agentEase }).then(commit);
}
return () => { disposed = true; controls.stop(); };
}, [committed, controls, ready, reducedMotion, scrollRef, state, stopScroll]);
useLayoutEffect(() => {
if (!committed) return;
let disposed = false;
let frame: number | undefined;
const restore = () => {
const current = anchor.current;
if (!current || !root.current) return;
let target: HTMLElement | null = root.current.querySelector("[data-answer-parts]") ?? root.current;
const reading = current.reading;
if (reading) {
target = reading.element.isConnected && reading.element.textContent === reading.text && reading.element.getBoundingClientRect().height > 0 ? reading.element
: [...current.scroll.querySelectorAll<HTMLElement>(readable)].find((node) => node.textContent === reading.text && node.getBoundingClientRect().height > 0) ?? target;
}
if (target) current.scroll.scrollTop += target.getBoundingClientRect().top - (reading?.top ?? current.top);
};
restore();
frame = requestAnimationFrame(restore);
const finish = () => {
if (disposed) return;
restore();
anchor.current?.release();
anchor.current = null;
setPhase("settled");
};
if (reducedMotion) {
controls.set({ opacity: 1 });
frame = requestAnimationFrame(() => { restore(); frame = requestAnimationFrame(finish); });
}
else void controls.start({ opacity: 1 }, { duration: 0.12, ease: agentEase }).then(finish);
return () => { disposed = true; if (frame !== undefined) cancelAnimationFrame(frame); controls.stop(); };
}, [committed, controls, reducedMotion]);
useLayoutEffect(() => () => { anchor.current?.release(); anchor.current = null; }, []);
return { message: ready && !committed ? previous.current : message, completed: committed, phase, root, controls };
}
@@ -0,0 +1,51 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useAgentConnection } from "./use-agent-connection";
afterEach(() => vi.useRealTimers());
it("rechecks an offline service, probes on focus and clears work on unmount", async () => {
vi.useFakeTimers();
const getHealth = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
const client = { getHealth };
const { result, unmount } = renderHook(() => useAgentConnection(client, "project-a"));
await act(async () => {});
expect(result.current.connectionStatus).toBe("offline");
await act(async () => vi.advanceTimersByTimeAsync(1_000));
expect(result.current.connectionStatus).toBe("online");
expect(result.current.recoveryVersion).toBe(1);
await act(async () => window.dispatchEvent(new Event("focus")));
expect(getHealth).toHaveBeenCalledTimes(3);
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it("discards a late result from a previous client and scope", async () => {
vi.useFakeTimers();
let finish!: () => void;
let oldSignal!: AbortSignal;
const oldClient = { getHealth: vi.fn((signal: AbortSignal) => { oldSignal = signal; return new Promise<void>((resolve) => { finish = resolve; }); }) };
const nextClient = { getHealth: vi.fn(async () => { throw new Error("next service offline"); }) };
const { result, rerender, unmount } = renderHook(({ client, scope }) => useAgentConnection(client, scope), { initialProps: { client: oldClient, scope: "old" } });
await act(async () => {});
rerender({ client: nextClient, scope: "next" });
await act(async () => {});
expect(oldSignal.aborted).toBe(true);
await act(async () => finish());
expect(result.current.connectionStatus).toBe("offline");
unmount();
});
it("aborts a stalled probe and recovers on the next retry", async () => {
vi.useFakeTimers();
const getHealth = vi.fn().mockImplementationOnce((signal: AbortSignal) => new Promise<void>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("timeout", "AbortError")), { once: true });
})).mockResolvedValue(undefined);
const client = { getHealth };
const { result, unmount } = renderHook(() => useAgentConnection(client, "project"));
await act(async () => vi.advanceTimersByTimeAsync(5_000));
expect(result.current.connectionStatus).toBe("offline");
await act(async () => vi.advanceTimersByTimeAsync(1_000));
expect(result.current.connectionStatus).toBe("online");
unmount();
});
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentApiClient } from "../api/client";
import type { AgentConnectionStatus } from "../types";
/** Service readiness is independent of a conversation's business result. */
export function useAgentConnection(client: Pick<AgentApiClient, "getHealth">, scope: string) {
const [snapshot, setSnapshot] = useState({ client, scope, status: "connecting" as AgentConnectionStatus, recoveryVersion: 0 });
const check = useRef<() => void>(() => {});
const recheck = useCallback(() => check.current(), []);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let failures = 0;
let status: AgentConnectionStatus = "connecting";
let recoveryVersion = 0;
const publish = (next: AgentConnectionStatus) => {
if (disposed) return;
if (next === "online" && status === "offline") recoveryVersion++;
status = next;
setSnapshot((previous) => previous.client === client && previous.scope === scope && previous.status === status && previous.recoveryVersion === recoveryVersion
? previous : { client, scope, status, recoveryVersion });
};
const probe = async () => {
if (disposed || controller) return;
clearTimeout(timer);
if (!navigator.onLine) { publish("offline"); return; }
const pending = new AbortController();
controller = pending;
timeout = setTimeout(() => pending.abort(), 5_000);
try {
await client.getHealth(pending.signal);
if (disposed || controller !== pending) return;
failures = 0;
publish("online");
} catch {
if (disposed || controller !== pending) return;
failures++;
publish("offline");
} finally {
if (controller === pending) {
clearTimeout(timeout);
controller = undefined;
if (!disposed) timer = setTimeout(() => void probe(), failures ? Math.min(1_000 * 2 ** Math.min(failures - 1, 4), 15_000) : 15_000);
}
}
};
const reconnect = () => { void probe(); };
const visible = () => { if (document.visibilityState === "visible") reconnect(); };
const offline = () => {
clearTimeout(timer);
clearTimeout(timeout);
const pending = controller;
controller = undefined;
pending?.abort();
publish("offline");
};
check.current = reconnect;
publish("connecting");
reconnect();
window.addEventListener("focus", reconnect);
window.addEventListener("online", reconnect);
window.addEventListener("offline", offline);
document.addEventListener("visibilitychange", visible);
return () => {
disposed = true;
clearTimeout(timer);
clearTimeout(timeout);
controller?.abort();
check.current = () => {};
window.removeEventListener("focus", reconnect);
window.removeEventListener("online", reconnect);
window.removeEventListener("offline", offline);
document.removeEventListener("visibilitychange", visible);
};
}, [client, scope]);
return { connectionStatus: snapshot.client === client && snapshot.scope === scope ? snapshot.status : "connecting" as const,
recoveryVersion: snapshot.client === client && snapshot.scope === scope ? snapshot.recoveryVersion : 0, recheck };
}
@@ -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;
@@ -0,0 +1,138 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import type { InteractionRequest, InteractionRequestApi } from "../api/interaction-requests";
import { useInteractionRequests } from "./use-interaction-requests";
const record = (status: InteractionRequest["status"] = "pending", revision = 0): InteractionRequest => ({ version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "确认范围", body: "选择范围", mode: "single", options: [{ id: "all", label: "全部" }], required: true, allow_custom: true }], status, revision, draft_revision: 0, draft: [], created_at: "now" });
it("debounces draft CAS and never continues on open; late pending reads cannot revive accepted answers", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
let readResolve!: (value: { items: InteractionRequest[] }) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record("answered", 1)), writeInteractionRequest: vi.fn(async (_session, _id, action) => action === "draft" ? { ...record(), draft_revision: 1 } : { ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" as const } }) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; }
try {
await act(async () => root.render(<Probe />));
expect(resume).not.toHaveBeenCalled();
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "" }]));
await act(async () => vi.advanceTimersByTimeAsync(499));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
await act(async () => vi.advanceTimersByTimeAsync(1));
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ expected_draft_revision: 0 }));
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { readResolve = resolve; }));
let refresh!: Promise<void>;
await act(async () => { refresh = current.refresh(); });
// Finish checking then submit. A stale read dispatched before submit must not win afterward.
await act(async () => { readResolve({ items: [record()] }); await refresh; });
await act(async () => current.act("i", "reply"));
expect(current.entries[0].record.status).toBe("answered");
expect(resume).toHaveBeenCalledWith("first", "i");
await act(async () => current.refresh());
expect(current.entries[0].record.status).toBe("answered");
await act(async () => root.render(<Probe session="second" />));
expect(current.entries[0].record.status).toBe("pending");
await act(async () => root.render(<Probe />));
expect(current.entries[0].record.status).toBe("answered");
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
});
it("binds a delayed submission to its original session and keeps the next session unchanged", async () => {
const root = createRoot(document.createElement("div"));
let resolveReply!: (value: InteractionRequest) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }]));
let submission!: Promise<void>;
await act(async () => { submission = current.act("i", "reply"); });
await act(async () => root.render(<Probe session="second" />));
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
expect(resume).toHaveBeenCalledWith("first", "i");
expect(current.entries[0].record.status).toBe("pending");
expect(current.entries[0].draft).toEqual([]);
await act(async () => root.render(<Probe session="first" />));
expect(current.entries[0].record.status).toBe("answered");
} finally { await act(async () => root.unmount()); }
});
it("keeps delivery revisions monotonic and ignores superseded reads", async () => {
const root = createRoot(document.createElement("div"));
const delivered = { ...record("answered", 1), delivery: { native_message_id: "native", revision: 2, status: "delivered" as const } };
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [delivered] })), getInteractionRequest: vi.fn(async () => delivered), writeInteractionRequest: vi.fn(async () => delivered) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; }
try {
await act(async () => root.render(<Probe />));
vi.mocked(api.listInteractionRequests).mockResolvedValueOnce({ items: [{ ...delivered, delivery: { ...delivered.delivery, revision: 1, status: "queued" } }] });
await act(async () => current.refresh());
expect(current.entries[0].record.delivery?.status).toBe("delivered");
let first!: (value: { items: InteractionRequest[] }) => void;
let second!: (value: { items: InteractionRequest[] }) => void;
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockImplementationOnce(() => new Promise((resolve) => { second = resolve; }));
let a!: Promise<void>, b!: Promise<void>;
await act(async () => { a = current.refresh(); b = current.refresh(); });
await act(async () => { first({ items: [delivered] }); await a; });
expect(current.checking).toBe(true);
await act(async () => { second({ items: [delivered] }); await b; });
expect(current.checking).toBe(false);
} finally { await act(async () => root.unmount()); }
});
it("purges revoked snapshots and prevents pending reads and old callbacks from restoring them", async () => {
const { InteractionRequestApiError } = await import("../api/interaction-requests");
const root = createRoot(document.createElement("div"));
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => record()) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; }
try {
await act(async () => root.render(<Probe />));
const staleAction = current.act;
vi.mocked(api.listInteractionRequests).mockRejectedValueOnce(new InteractionRequestApiError("无权访问", 403));
await act(async () => current.refresh());
expect(current.entries).toEqual([]);
await act(async () => staleAction("i", "reply"));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
const next = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
await act(async () => root.render(<Probe client={next} />));
expect(current.entries).toEqual([]);
await act(async () => staleAction("i", "reply"));
expect(resume).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it("flushes the old session draft on switch without waiting for the debounce", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => ({ ...record(), draft_revision: 1 })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }]));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
await act(async () => root.render(<Probe session="second" />));
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ answers: [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }] }));
expect(current.entries[0].draft).toEqual([]);
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
});
it("drops an in-flight accepted reply after the API identity changes", async () => {
const root = createRoot(document.createElement("div"));
let resolveReply!: (value: InteractionRequest) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; }
try {
await act(async () => root.render(<Probe client={api} />));
let submission!: Promise<void>;
await act(async () => { submission = current.act("i", "reply"); });
await act(async () => root.render(<Probe client={other} />));
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
expect(current.entries).toEqual([]);
expect(resume).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { InteractionRequestApiError, canReplaceInteractionRequest, type InteractionAnswer, type InteractionRequest, type InteractionRequestApi } from "../api/interaction-requests";
const version = "agent-interaction-request@1" as const;
const requestId = () => crypto.randomUUID();
type Entry = { record: InteractionRequest; draft: InteractionAnswer[]; dirty: boolean; busy: boolean; saving?: boolean; error?: string; conflict?: InteractionAnswer[]; submission?: { id: string; answers: InteractionAnswer[] }; draftWrite?: { id: string; answers: InteractionAnswer[]; revision: number } };
export type InteractionRequestEntry = Entry;
const denied = (error: unknown) => error instanceof Error && "status" in error && typeof error.status === "number" && [401, 403, 404].includes(error.status);
export function useInteractionRequests(api: InteractionRequestApi, scope: string, sessionId: string | null, onContinue: (sessionId: string, interactionId: string) => Promise<void>) {
const identity = useRef({ api, scope, generation: 0, revoked: false });
const cache = useRef(new Map<string, Map<string, Entry>>());
const checked = useRef(new Set<string>());
const sequences = useRef(new Map<string, number>());
if (identity.current.api !== api || identity.current.scope !== scope) {
identity.current = { api, scope, generation: identity.current.generation + 1, revoked: false };
cache.current.clear(); checked.current.clear(); sequences.current.clear();
}
const generation = identity.current.generation;
const valid = useCallback(() => identity.current.generation === generation && !identity.current.revoked, [generation]);
const key = `${scope}:${generation}:${sessionId ?? ""}`;
const active = useRef(key); active.current = key;
const [tick, render] = useState(0);
const [checkingKey, setCheckingKey] = useState<string>();
const [loadError, setLoadError] = useState<{ key: string; message: string }>();
const notify = useCallback(() => render((value) => value + 1), []);
const entries = useMemo(() => {
const existing = cache.current.get(key) ?? new Map<string, Entry>();
cache.current.set(key, existing);
return existing;
}, [key]);
const purge = useCallback((error: unknown) => {
if (!valid() || !denied(error)) return false;
cache.current.forEach((items) => items.clear());
checked.current.clear(); sequences.current.clear();
identity.current.generation += 1;
identity.current.revoked = true;
setLoadError({ key, message: error instanceof Error ? error.message : "提问访问权限已失效" });
notify(); return true;
}, [key, notify, valid]);
const refresh = useCallback(async () => {
if (!sessionId || !valid()) return;
const target = entries;
const sequence = (sequences.current.get(key) ?? 0) + 1;
sequences.current.set(key, sequence);
const latest = () => valid() && sequences.current.get(key) === sequence;
setCheckingKey(key); checked.current.delete(key);
try {
const result = await api.listInteractionRequests(sessionId);
if (!latest()) return;
for (const record of result.items) {
const previous = target.get(record.interaction_id);
if (previous && !canReplaceInteractionRequest(record, previous.record)) continue;
const next: Entry = { ...previous, record, draft: previous?.dirty ? previous.draft : record.draft, dirty: previous?.dirty ?? false, busy: previous?.busy ?? false,
...(record.status !== "pending" && previous?.dirty ? { conflict: previous.draft } : {}) };
target.set(record.interaction_id, previous ? Object.assign(previous, next) : next);
}
checked.current.add(key);
if (active.current === key) setLoadError(undefined);
} catch (error) {
if (latest() && !purge(error) && active.current === key) setLoadError({ key, message: error instanceof Error ? error.message : "提问核对失败" });
} finally { if (latest() && active.current === key) { setCheckingKey(undefined); notify(); } }
}, [api, entries, key, sessionId, notify, valid, purge]);
useEffect(() => { void refresh(); }, [refresh]);
const update = (interactionId: string, draft: InteractionAnswer[]) => {
const entry = entries.get(interactionId);
if (!valid() || !entry || entry.record.status !== "pending" || entry.busy) return;
entry.draft = draft; entry.dirty = true; entry.error = undefined; entry.submission = undefined; notify();
};
const saveDrafts = useCallback(() => {
if (!sessionId || !valid() || !checked.current.has(key)) return;
for (const entry of entries.values()) {
if (!entry.dirty || entry.busy || entry.saving || entry.error || entry.record.status !== "pending") continue;
entry.saving = true;
const write = entry.draftWrite ?? { id: requestId(), answers: entry.draft, revision: entry.record.draft_revision };
entry.draftWrite = write;
void api.writeInteractionRequest(sessionId, entry.record.interaction_id, "draft", { version, request_id: write.id, expected_draft_revision: write.revision, answers: write.answers }).then((record) => {
if (!valid() || !canReplaceInteractionRequest(record, entry.record)) return;
entry.record = record; entry.draftWrite = undefined;
if (entry.draft === write.answers) entry.dirty = false;
}).catch((error: unknown) => {
if (!valid() || purge(error)) return;
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) {
entry.record = error.current; entry.draftWrite = undefined;
if (error.current.status !== "pending") entry.conflict = write.answers;
}
entry.error = error instanceof Error ? error.message : "草稿未保存";
}).finally(() => { entry.saving = false; if (valid() && active.current === key) notify(); });
}
}, [api, entries, key, notify, purge, sessionId, valid]);
useEffect(() => {
const timer = window.setTimeout(saveDrafts, 500);
return () => window.clearTimeout(timer);
}, [saveDrafts, tick]);
// Flush the owning session before replacing its subscription. Identity changes invalidate this closure.
useEffect(() => () => saveDrafts(), [saveDrafts]);
const act = async (interactionId: string, action: "reply" | "skip" | "cancel" | "resume") => {
if (!sessionId || !valid() || !checked.current.has(key)) return;
const entry = entries.get(interactionId);
if (!entry || entry.busy || checkingKey === key) return;
entry.busy = true; entry.error = undefined; notify();
const submission = entry.submission ?? { id: requestId(), answers: structuredClone(entry.draft) };
if (action === "reply") entry.submission = submission;
try {
const record = await api.writeInteractionRequest(sessionId, interactionId, action, { version, request_id: action === "reply" ? submission.id : requestId(), expected_revision: action === "resume" ? entry.record.delivery?.revision ?? 0 : entry.record.revision, ...(action === "reply" ? { answers: submission.answers } : {}) });
if (!valid()) return;
if (canReplaceInteractionRequest(record, entry.record)) entry.record = record;
entry.dirty = false;
if (action !== "cancel" && record.delivery && ["queued", "blocked", "dispatching"].includes(record.delivery.status)) await onContinue(sessionId, interactionId);
} catch (error) {
if (!valid() || purge(error)) return;
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) { entry.record = error.current; entry.conflict = submission.answers; }
entry.error = error instanceof Error ? error.message : "操作失败,请重试";
try {
const record = await api.getInteractionRequest(sessionId, interactionId);
if (valid() && canReplaceInteractionRequest(record, entry.record)) entry.record = record;
} catch (readError) { purge(readError); }
} finally { entry.busy = false; if (valid() && active.current === key) notify(); }
};
const list = [...entries.values()];
const checking = Boolean(sessionId) && (!checked.current.has(key) || checkingKey === key);
return { entries: list, checking, error: loadError && (loadError.key === key || identity.current.revoked) ? loadError.message : undefined, refresh, update, act,
awaitingInput: list.some(({ record }) => record.status === "pending" || (record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status))) };
}
export type InteractionRequestsController = ReturnType<typeof useInteractionRequests>;
+13 -2
View File
@@ -10,7 +10,8 @@ export type {
AgentWorkspaceContextItem AgentWorkspaceContextItem
} from "./components/agent-command-panel"; } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona"; export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client"; export { createAgentApiClient, AgentResponseFinishedError, AgentSessionStreamError } from "./api/client";
export { useAgentConnection } from "./hooks/use-agent-connection";
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox"; export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages"; export type { AgentMailboxMessage } from "./api/agent-messages";
export type { export type {
@@ -96,11 +97,21 @@ export { parseFrontendChart } from "./frontend-action/chart";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view"; export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport"; export { ResponsesChatTransport, createResponseRequest, AgentStreamDisconnectedError } from "./api/responses-transport";
export { AnswerUiView } from "./answer-ui/answer-ui-view"; export { AnswerUiView } from "./answer-ui/answer-ui-view";
export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract"; export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract";
export { getFinalAnswerTranscript } from "./answer-ui/answer-presentation";
export type { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract"; export type { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract";
export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context"; export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context";
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 { AgentInteractionRequests } from "./components/agent-interaction-request-card";
export { readInteractionReply } from "./api/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,83 @@
import { describe, expect, it } from "vitest";
import type { InteractionRequest } from "../api/interaction-requests";
import type { AgentChatMessage } from "../types";
import { groupMessageProcesses } from "./message-process-groups";
const assistant = (id: string, content = ""): AgentChatMessage => ({
id, role: "assistant", content,
progress: [{ id: "query", phase: "tool", status: "completed", title: id }]
});
const user = (id: string): AgentChatMessage => ({ id, role: "user", content: id });
const request = (source: string, reply: string, extra: Partial<InteractionRequest> = {}): InteractionRequest => ({
version: "agent-interaction-request@1", interaction_id: `question-${source}`, message_id: source,
turn_id: `turn-${source}`, call_id: `call-${source}`, status: "answered", revision: 1, draft_revision: 0,
questions: [{ question_id: "scope", title: "范围", body: "核查范围?", mode: "single", required: true, allow_custom: true, options: [] }],
draft: [], created_at: "2026-10-28T10:00:00Z", delivery: { native_message_id: reply, revision: 1, status: "delivered" },
...extra
});
describe("process groups across clarification replies", () => {
const source = assistant("question");
const reply = user("reply");
const result = assistant("answer_reply", "结果正文");
const messages = [user("original"), source, reply, result];
it("moves the source process to the related continuation without editing stored messages or progress ids", () => {
const original = structuredClone(messages);
const groups = groupMessageProcesses(messages, [request(source.id, reply.id)]);
expect(groups.get(source.id)).toEqual([]);
expect(groups.get(result.id)).toEqual([source, result]);
expect(groups.get(result.id)?.map((message) => message.progress?.[0].id)).toEqual(["query", "query"]);
expect(messages).toEqual(original);
});
it("groups an existing result even when delivery is blocked and there is no final_answer", () => {
const record = request(source.id, reply.id, { delivery: { native_message_id: reply.id, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" } });
expect(groupMessageProcesses(messages, [record]).get(result.id)).toEqual([source, result]);
expect(record.delivery?.status).toBe("blocked");
});
it("keeps processes in place while waiting, cancelled, missing a source page or missing a continuation", () => {
for (const extra of [{ status: "pending" }, { status: "cancelled" }, { delivery: undefined },
{ delivery: { native_message_id: reply.id, revision: 2, status: "cancelled" } }] satisfies Partial<InteractionRequest>[]) {
expect(groupMessageProcesses(messages, [request(source.id, reply.id, extra)]).get(source.id)).toEqual([source]);
}
expect(groupMessageProcesses(messages.slice(0, -1), [request(source.id, reply.id)]).get(source.id)).toEqual([source]);
expect(groupMessageProcesses(messages.slice(2), [request(source.id, reply.id)]).get(result.id)).toEqual([result]);
});
it("does not merge across an unrelated user request on either side of the saved reply", () => {
for (const list of [
[source, user("other-request"), reply, result],
[source, reply, user("other-request"), result],
[reply, source, result]
]) {
const groups = groupMessageProcesses(list, [request(source.id, reply.id)]);
expect(groups.get(source.id)).toEqual([source]);
expect(groups.get(result.id)).toEqual([result]);
}
});
it("combines successive clarifications in order and ignores duplicate records", () => {
const reply2 = user("reply2");
const final = assistant("answer_reply2", "最终结果");
const first = request(source.id, reply.id);
const second = request(result.id, reply2.id);
const groups = groupMessageProcesses([...messages, reply2, final], [second, first, first]);
expect(groups.get(source.id)).toEqual([]);
expect(groups.get(result.id)).toEqual([]);
expect(groups.get(final.id)).toEqual([source, result, final]);
});
it("retains actionable questions and permissions at their original message", () => {
const pendingQuestion: AgentChatMessage = { ...source, questions: [{
requestId: "pending", sessionId: "s", questions: [], createdAt: 0, status: "checking"
}] };
const pendingPermission: AgentChatMessage = { ...source, permissions: [{
requestId: "pending", sessionId: "s", permission: "simulate", patterns: [], always: [], createdAt: 0, status: "pending"
}] };
for (const pending of [pendingQuestion, pendingPermission]) {
expect(groupMessageProcesses([pending, reply, result], [request(source.id, reply.id)]).get(source.id)).toEqual([pending]);
}
});
});
@@ -0,0 +1,36 @@
import type { InteractionRequest } from "../api/interaction-requests";
import type { AgentChatMessage } from "../types";
export function hasPendingMessageInput(message: AgentChatMessage): boolean {
return Boolean(
message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) ||
message.questions?.some((item) => ["checking", "pending", "submitting", "error"].includes(item.status))
);
}
/** Group presentation only; preserve persisted message identities and per-message step ids. */
export function groupMessageProcesses(
messages: readonly AgentChatMessage[],
requests: readonly InteractionRequest[]
): Map<string, AgentChatMessage[]> {
const positions = new Map(messages.map((message, index) => [message.id, index]));
const groups = new Map(messages.filter((message) => message.role === "assistant").map((message) => [message.id, [message]]));
const ordered = [...requests].sort((a, b) => (positions.get(a.message_id) ?? -1) - (positions.get(b.message_id) ?? -1));
for (const record of ordered) {
if (!["answered", "skipped"].includes(record.status) || !record.delivery || record.delivery.status === "cancelled") continue;
const sourceIndex = positions.get(record.message_id);
const replyIndex = positions.get(record.delivery.native_message_id);
if (sourceIndex === undefined || replyIndex === undefined || sourceIndex >= replyIndex) continue;
const source = messages[sourceIndex];
const reply = messages[replyIndex];
const target = messages[replyIndex + 1];
if (source.role !== "assistant" || reply.role !== "user" || target?.role !== "assistant" || hasPendingMessageInput(source)) continue;
// A saved reply can arrive late; do not pull a process across another user request.
if (messages.slice(sourceIndex + 1, replyIndex).some((message) => message.role === "user")) continue;
const previous = groups.get(source.id);
if (!previous?.length) continue;
groups.set(target.id, [...previous, ...(groups.get(target.id) ?? [])]);
groups.set(source.id, []);
}
return groups;
}
+9
View File
@@ -31,6 +31,15 @@ describe("agent session state", () => {
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 }); expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
}); });
it("retains full tool inputs across stream updates and persisted history", () => {
const input = { address: "完整地址".repeat(70), point: { longitude: 121.5, latitude: 31.2 }, radius: 500 };
const first = upsertProgress(undefined, { id: "tool", phase: "tool", tool: { name: "mcp_tjwater_geocode", call_id: "native-call", input } });
const next = upsertProgress(first, { id: "tool", phase: "tool", status: "completed" });
expect(next[0].tool).toEqual({ name: "mcp_tjwater_geocode", callId: "native-call", input });
expect(upsertProgress(undefined, next[0])[0].tool).toEqual(next[0].tool);
expect(upsertProgress(undefined, { tool: { name: 3, input: [] } })[0].tool).toBeUndefined();
});
it("normalizes todo updates", () => { it("normalizes todo updates", () => {
expect( expect(
toTodoUpdate({ toTodoUpdate({
+13
View File
@@ -29,6 +29,7 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
status, status,
title: readString(payload.title) ?? "正在处理", title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail), detail: readString(payload.detail),
tool: readProgressTool(payload.tool) ?? existing?.tool,
startedAt, startedAt,
endedAt, endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined, elapsedMs: status === "running" ? elapsedMs : undefined,
@@ -373,3 +374,15 @@ function readNumber(value: unknown) {
function readStringArray(value: unknown) { function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
} }
export function readProgressTool(value: unknown): AgentChatProgress["tool"] {
const tool = asRecord(value);
const name = readString(tool.name);
if (!name) return undefined;
return {
name,
callId: readString(tool.call_id) ?? readString(tool.callId),
input: tool.input && typeof tool.input === "object" && !Array.isArray(tool.input)
? tool.input as Record<string, unknown> : undefined
};
}
@@ -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) };
}

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