fix(workbench): stabilize investigation presentation rendering

Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
This commit is contained in:
2026-11-11 19:10:41 +08:00
parent 0ae0a41e61
commit a617aa95e9
35 changed files with 1268 additions and 715 deletions
@@ -44,7 +44,7 @@ Agent 类型检查及 16 项相关测试通过;隔离 OpenCode 工具发现验
## 2026-10-21 Rive 资源内离线水纹
解析原版 775 个运行时对象,JSON 重新编码与原文件逐字节一致。保留原图形和在线动画,新增三段离线循环,并修正 asleep / wake 的透明度与缩放关键帧;同源资源为 `public/agent/obsidian-water-v1.riv`。实现与再生成说明见 [Agent 动画资源](../agent-persona-resource.md)。
解析原版 775 个运行时对象,JSON 重新编码与原文件逐字节一致。保留原图形和在线动画,新增三段离线循环,并修正 asleep / wake 的透明度与缩放关键帧;同源资源为 `public/agent/obsidian-water-v1.riv`。实现与再生成说明见 [Agent 动画资源](../agent-persona-design.md#8-资源生成与维护)。
验证通过:生成一致性检查、lint、类型检查、547 项单测、构建、`git diff --check`。10 项相关浏览器用例分批通过,包含真实 WebGL2 在线状态采样逐像素对照、原版离线消失与修改版离线持续运动、初始离线、反复恢复、工作台单实例、加载衔接、按压无反馈、资源失败、减少动态效果、键盘及 320/375px。首次运行有 1 项动画就绪等待超时;未修改运行代码,带 trace 复测通过,首次超时根因未确认。
@@ -0,0 +1,377 @@
# Intelligent UI 交互状态补齐设计
> 历史记录:2026 年 10 月交互状态原始提案,其中分析篮、行选择和组件追问已取消,当前范围见 [交互状态](../agent-interaction-state-design.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
> 2026-10-30 产品决定:数据组件仅用于展示与本地浏览,已移除表格勾选、分析内容篮及组件追问;专用地图定位和补充资料表单保留。本文分析篮、行选择和引用操作的方案属于历史设计,不再作为当前实施目标。阅读位置、表单草稿及宿主提问的独立设计仍可参考,当前行为以 [本地交互式回答](../agent-answer-ui.md) 为准。
历史设计状态:2026-10-27 设计稿;2026-10-28 第 11 节宿主提问已接入工作区代码,其余补齐项仍待实现。当前代码进度见 [本地交互式回答](../agent-answer-ui.md)。本设计补齐阅读位置、分析篮和正式报告的私人交互状态,不改变模型可生成的 `answer-ui@1` 组件协议。第 11 节单独处理 question 的执行生命周期。
## 1. 目标与方案
用户刷新后,再次打开同一回答或同一版报告,可以继续阅读、筛选和填写资料。恢复只改变当前用户的界面,不发送消息、不提交表单、不调用业务工具、不定位地图,也不自动打开窗口。
推荐保留已实现的聊天控件状态接口,新增阅读与分析篮的补充记录;正式报告的状态由 Server 按当前读者保存。三种记录独立比较版本,避免滚动保存与表单输入、选行互相冲突。前端继续复用受控 store 和现有保存协调器,只由来源适配器决定请求 Agent 还是 Server。
最小方案是仅增加会话内存缓存,但刷新后仍会丢失,不能满足目标。把全部状态塞入原接口会破坏旧客户端的严格校验,也会让分析篮挤占控件的 32 KiB 限额,因此采用补充记录。正式报告不能借用 `source_session_id` 保存:它指向发布报告的执行会话,报告读者未必拥有这个会话。
新增服务、账号、密钥和环境变量均为零;正式报告需要在已有项目 BizDB 增加一张私人状态表。预计涉及三个仓库、超过 8 个实现文件,按第 10 节拆成可独立交付的两批。
```text
聊天最终回答 -> Agent 来源适配器 -> 已授权会话 / 控件记录 + 补充记录
^
|
读取/编辑 -> 前端状态协调器 -> 受控组件、阅读位置、分析篮
|
v
指定版本报告 -> Workbench 来源适配器 -> Server 报告权限 / 当前读者状态
用户主动“带入对话” -> 现有输入区 -> 用户确认发送 -> 既有 Agent 流程
```
基础能力沿用 [json-render 的运行时状态支持](https://json-render.dev/docs/data-binding) 和 [React 的外部 store 订阅](https://react.dev/reference/react/useSyncExternalStore)。阅读容器尺寸变化使用 [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 观测;这些机制不替代宿主的权限、持久化和版本校验。实现以仓库当前安装版本为准,不因本设计升级依赖。
## 2. 当前依据与状态分工
| 当前实现 | 补齐方式 |
| --- | --- |
| `answer-view-state@1` 保存图表、表格、表单、证据状态 | 保留接口、32 KiB 限额和迁移规则;复用其保存/恢复机制 |
| `ExpandableReading` 用 `useId()` 标识实例;阅读偏移放在 DOM dataset | 注入业务来源身份;只保存可序列化的阅读位置,DOM、host、focus 回调留在内存 |
| Markdown 表格的行内和放大视图是两份 DOM,图示可移动同一个 host | 按阅读面分别记录位置,不假设搬动 DOM 等于持久化 |
| `AnswerUiView` 的分析篮只存 `{title,text}`,意图由 Tray 的 `useState` 保存 | 增加来源、选择描述和摘要;篮与意图一起受控保存 |
| `InvestigationReportBlocks` 接收报告快照,并用任务证据覆盖 Evidence 展示 | 按报告版本及当前读者加载状态;两端核对实际渲染内容摘要 |
| 正式报告属于项目任务,读取使用 `operational_task.view` | 私人状态沿用报告读取权限,不要求读者拥有发布会话,不写回正式报告 |
| 记录 | 持久化内容 | 保持临时的内容 |
| --- | --- | --- |
| controls,按块 | 现有节点控件状态和证据类别筛选 | 请求中的 loading/error、悬停、菜单和下拉框开关 |
| reader,按文档 | 阅读面的位置、锚点、局部偏移和内容摘要 | 是否打开弹窗、当前焦点、地图相机、窗口尺寸、DOM 引用 |
| basket,按块 | 有序选择项、来源描述、选取时文本快照、分析意图 | 正在带入对话、临时提示、浏览器文本选区 |
说明:分析篮仍以一个回答块为边界,本次不扩展为跨消息、跨报告的全局收藏。证据大分区、范围工具栏的折叠开关沿用当前内存行为;它们不决定所选数据。Mermaid 平移/缩放也只在当前实例内保留,重新打开时按可用空间适配,不保存会随图形重新布局失效的像素变换。
## 3. 身份与版本
| 来源 | 文档身份 | 块身份 | 权威校验 |
| --- | --- | --- | --- |
| 聊天 | 当前主体、个人/项目范围、sessionId、assistantMessageId | 文档身份 + blockId | Agent 校验会话归属、已保存的助手消息及最终回答引用;旧版没有 final 的记录沿用既有兼容规则 |
| 正式报告 | 当前 user_id、project_id、task_id、report_revision | 文档身份 + blockId | Server 校验当前成员身份、`operational_task.view` 和指定报告是否存在 |
`nodeId` 来自验证后的 `spec.elements` 键;不得使用标题、数组当前序号、React `useId()` 或 DOM selector 作为业务身份。客户端不能指定状态所有者,主体和项目必须来自服务端认证上下文。
报告的 `revision` 在任务内唯一,`execution_generation` 由服务端从报告推导并回传,不作为另一个客户端命名空间。历史 `null` 保留为 `null`,不能替换为 0 或任务当前轮次。文档与块的 `state_revision` 是私人交互版本,不能与报告 revision 混用。
打开任务仍默认查看当前报告,不自动恢复为上次选中的历史版本。用户主动选择某个版本后才恢复该版本的状态。新报告使用新的身份和空状态,不自动复制旧版表单或分析篮;旧版记录保留供用户回看。放大窗口保留旧版内容时,状态写入也绑定当时的旧版,直到用户点击“查看最新内容”。
最容易失效的前提是“相同 blockId 就是相同数据”。本设计用内容摘要、节点类型和选择描述复核,无法唯一匹配时保留可核对的草稿并清理无效选择,不将旧数组下标套到新数据。
## 4. 记录与接口
### 4.1 保留现有接口,新增补充接口
| 所属服务 | 接口 | 状态范围 |
| --- | --- | --- |
| Agent,已有 | `GET/PUT /api/v1/session/:sessionId/ui-state` | 聊天 controls,保持原 `answer-view-state@1` 协议 |
| Agent,拟新增 | `GET/PUT /api/v1/session/:sessionId/interaction-state` | 聊天 reader 或 basket;以 message_id、kind 和 basket 所需的 block_id 定位 |
| Server,拟新增 | `GET/PUT /api/v1/operational-tasks/:taskId/reports/:reportRevision/interaction-state` | 正式报告 controls、reader 或 basket;controls/basket 需要 block_id |
GET 的 kind 只有 `reader`、`basket`,Server 额外允许 `controls`。reader 属于整个文档,不接受 block_id;其内部阅读面可以引用块。GET 无记录返回对应空 payload、state_revision 0 和当前内容摘要,不写入空记录。未知 kind、无效字段或不存在的块返回 400/404,不隐式建立来源。
GET 选择器放在 query,PUT 放在 body;Agent 两种方法均要求 message_id,Server 报告身份只取 URL。PUT 另要求 `version="ui-interaction-state@1"`。两端错误在前端适配器内统一为 code、message、current:Agent 沿用顶层 code/detail,Server 沿用现有 detail envelope,不让通用组件处理两套响应形状。所有响应使用 private/no-store。
新接口的记录版本为 `ui-interaction-state@1`。字段固定如下;不接受任意对象透传。
| 字段 | 规则 |
| --- | --- |
| `source` | 只出现在响应中,返回服务端确认的聊天或报告身份;不返回用户 token、宿主路径 |
| `kind`、`block_id` | 标识记录种类及块;reader 的 block_id 不存在 |
| `state_revision` | 每个记录独立的非负整数,只出现在响应中 |
| `source_digest` | 服务端规范化内容的 SHA-256;PUT 原样带回,用于拒绝过期内容上的保存 |
| `payload` | 由 kind 选择严格 schema,字段见第 5、6 节;report controls 使用已有 nodes/block 的允许字段与迁移规则 |
| `updated_at` | 服务端写入时间;空记录为 null,不用客户端时间决定覆盖顺序 |
| `item_status` | 仅 basket 响应包含,按 item_id 返回当前有效性与原因;不接受客户端写入 |
| `expected_revision`、`request_id` | 只用于 PUT;版本比较和最近请求重试沿用现有规则 |
选择器对应的身份摘要、version、revision 和请求标识均受限长与整数范围校验。source_digest 为 64 位小写十六进制;request_id 为 1 至 128 字符;message_id 为 1 至 200 字符;block_id 沿用原协议。state_revision/expected_revision 使用 JavaScript 安全整数范围,禁止负数或溢出。
`source_digest` 的材料:聊天 reader 为已发布报告文字与引用块,basket 为所在块的实际 spec;报告 reader 为指定版本的报告正文及其可见证据投影,controls/basket 为对应块的实际渲染 spec。规范化 JSON 对对象键排序,数组顺序不变,摘要算法跨语言使用同一组固定测试样例。摘要是过期检查依据,不是客户端提交文字的真实性证明。
### 4.2 正式报告的 Evidence 投影
报告原 spec 与实际显示的 Evidence 可能不同:当前实现从任务证据补充摘要、来源、时间、类别,找不到证据时显示提示文本。Server 与前端必须采用相同投影规则,控件验证及摘要计算以投影后的 spec 为准。
Server 状态读取先按现有报告/证据访问规则取得来源,计算投影及 source_digest;前端对已经加载的报告与证据执行同一规则并计算摘要。两者不一致时,不把旧状态应用到当前节点:对指定报告和任务证据做一次有界刷新,再核对摘要;仍不一致则显示“内容已更新,交互状态暂未恢复”,允许用户重试,禁止无限刷新。无需在状态响应中再复制整份报告或原始图表数据。
PUT 在事务中重读报告和证据,投影变化返回 409 `UI_INTERACTION_SOURCE_CHANGED`,并返回当前记录和摘要。前端先更新来源,再迁移合法控件;无权访问的证据不以篮子快照重新显示。此投影规则需要前后端共用测试样例,不能各自猜测字段映射。
### 4.3 存储与容量
聊天补充记录放在宿主 `chat/answer-interaction-state.json`,加入 `SessionWorkspaceStore` 允许列表,复用同一队列、所有权校验、删除标记及原子写入。已有 controls 文件保持不变,旧客户端读写不会抹掉新状态。新文件每个 document reader、每个 block basket 独立版本,文件总量最多 1 MiB,包含回执;与已有 controls 文件合计最多 2 MiB/会话。
正式报告新增项目 BizDB 表 `workbench.report_user_view_states`,每个 `(project_id,user_id,task_id,report_revision)` 一行,JSONB document 内分别保存 controls、reader、baskets 和最近回执。每个子记录有独立 state_revision;行锁只用于版本比较、容量检查和原子替换,不把滚动和表单合并成一个逻辑版本。报告外键 `(project_id,task_id,report_revision)` 使用物理删除级联;user_id 来自 `ProjectContext`,不建立跨数据库外键。
首次写入采用插入空行且忽略唯一键冲突,再锁定同一私人状态行,避免两个 revision 0 请求同时成功。事务失败回滚空行和状态修改;报告删除由外键与事务约束阻止迟到写入复活。状态表不是报告事实表,保存私人状态只要求报告读权限,不借此授予任务写入或审批权限。
拟新增迁移 `next-tjwater-server/resources/sql/026_report_user_view_states.sql`,沿用项目 BizDB 显式迁移流程。当前最高编号为 025,实施时若编号已占用,顺延编号。接口不执行建表;未迁移的项目返回安全的 503,报告仍可阅读,草稿留在当前内存。
| 项目 | 限额 |
| --- | --- |
| controls payload | 32 KiB UTF-8/块,沿用现有节点数量和字段限制 |
| reader payload | 8 KiB UTF-8/文档,最多 16 个阅读面 |
| basket payload | 64 KiB UTF-8/块,最多 8 项;正文组合不超过现有 8000 个 UTF-16 代码单元;分析意图最多 800 个 UTF-16 代码单元 |
| 正式报告私人状态行 | 1 MiB,计入所有子记录和回执 |
| 补充记录重试回执 | 每个聊天补充文件或报告私人状态行,最近 32 条且最多 128 KiB;过期请求仍受旧 expected_revision 阻止重放 |
大小检查包括标识与元数据的序列化体积,不截断表单或选择内容来勉强保存。超限返回 413,保留当前草稿,提示先减少分析篮内容或清除不再需要的记录。业务限制 8000/800 由三端按相同 UTF-16 计数规则校验,不能用 Python 字符数直接替代。发送消息时仍检查问题、意图、引用及来源说明的完整文本是否超过现有 10000 限额。
不按每次滚动追加历史;当前快照覆盖、回执有界。记录数量随实际访问的用户/报告组合增长,使用上述复合主键直接读写,不扫描全部用户状态;本版本不增加自动 TTL,避免无提示删除草稿。
## 5. 阅读位置
reader payload 为 `positions` 和 `recent_reader_keys`,前者按宿主生成的 readerKey 保存位置,后者是最近使用顺序。两者最多 16 项且键集合一致;位置键、锚点 id 最多 200 个字符,不接受 DOM selector 或脚本。每项位置字段如下。
| 字段 | 含义 |
| --- | --- |
| `content_digest` | 该阅读面实际内容的摘要;用于判断是否可复用整体滚动比例 |
| `anchor` | 可空的 `{kind,id,occurrence}`;kind 为 section、node、markdown,id 来自稳定业务键或规范化 Markdown 块摘要 |
| `anchor_offset_ratio` | 锚点内部偏移,有限数值,范围 0 到 1 |
| `scroll_y_ratio`、`scroll_x_ratio` | 容器可滚动距离的比例,范围 0 到 1;无可滚动距离时为 0 |
readerKey 只由宿主注册:`workspace`、`report-result`、原生 `blockId/nodeId` 对应阅读面,以及 Markdown 内容块摘要派生的表格/图示阅读面。纯文本最终回答也可建立 document reader,无须伪造 answer-ui 块。标题或“第 n 个表”不是跨内容更新的稳定键;相同内容重复出现时 occurrence 只在摘要相同的文档内使用。
这里只保存版本明确的内容。当前调查进展、实时任务时间线、审批卡和报告版本选择器仍属于实时任务界面,不绑定到某个报告 revision 的阅读状态。用户输入、审批焦点和地图位置均不通过 reader 恢复。
恢复流程:
1. 用户主动打开对应阅读面,绑定不可变的 source 身份并订阅状态。尚未加载历史消息/报告时不为了恢复位置自动翻完全部历史,也不自动切换会话或报告版本。
2. 内容挂载、控件恢复和必要字体准备完成后,用连续两帧相同尺寸判断可定位;最多等待 1500ms。`ResizeObserver` 仅用于这次定位,不能持续把用户拉回旧位置。
3. 优先定位仍唯一存在的锚点,再恢复锚点内部偏移。没有锚点但 content_digest 相同,则使用整体滚动比例;内容已变化且锚点无法对应,回到本阅读面起点,提示“内容已变化,原阅读位置无法恢复”。
4. 比例始终按当前实际滚动范围计算并夹紧;窄屏和宽屏互切不能直接复用旧像素 scrollTop。Markdown 行内与放大表格使用不同 readerKey,横向位置也分别记录。
5. 每次主动打开最多执行一次自动定位。等待期间出现用户滚轮、触摸、指针按下、滚动键、文本选择、编辑或内容导航,则取消自动定位;迟到 GET 只进入缓存,不移动当前视口。需要回到上次位置时由用户点击入口。
阅读保存使用 1000ms 防抖,连续滚动每 5 秒最多提交一次;关闭阅读面或切换来源时保存最后位置。位置未变化不发送;程序恢复产生的 scroll 事件不标记为用户编辑,不反向触发保存循环。最多 16 个位置,超出时只淘汰最久未使用的阅读位置,保留当前阅读面;分析篮和表单不参与此淘汰。
reader 冲突不弹出阻断窗口:保留当前视口,接收远端的新 revision 供下一次真实用户滚动保存,显示轻量“阅读位置未同步”状态;不自动覆盖远端,也不因远端更新跳动当前页面。下次用户操作只更新对应位置,合并远端其他阅读面的位置和最近使用顺序,不能用整个旧 positions 覆盖它们。网络失败可随下一次用户滚动重试或由用户手动重试。
## 6. 分析篮与表单草稿
basket payload 为同一份 `{items,intent}`。items 是有序数组,新增、移除、清空和意图编辑由一份受控状态管理;不拆成八个各自保存的项目。
| 选择项字段 | 规则 |
| --- | --- |
| `item_id` | 本地生成的稳定 UUID,保存与重排时保持;不用于权限证明 |
| `node_id`、`selection_kind` | 必须对应来源中的真实节点;首批 kind 为 metric、table_rows、chart_point、chart_range |
| `selector` | table_rows 为原始行索引及数据摘要;chart 为点/区间索引、对应标签、可见序列索引与名称;metric 不带行索引 |
| `source_digest` | 选取时所在块的有效内容摘要,由已读取来源取得 |
| `title`、`snapshot_text` | 选取时的纯文本显示快照,title 最多 200 个 UTF-16 代码单元;正文计入组合总限额 |
服务端校验节点类型、索引上限和选择描述,并校验来源摘要;快照文本是用户引用内容,不提升为事实证据,不执行其中指令。它是“不复制原始数据”原则的有界例外,只保存用户明确选取的文本,不保存整份时序数据。正式报告的 evidence_refs 及报告身份由宿主从块与报告补充,不信任客户端伪造来源。
去重依据是节点、选择描述和选取时内容摘要;不同节点即使文本相同也可分别选取。每个选项保留当时的文本,不随筛选、排序或数据更新悄悄重算。服务器计算恢复状态为 valid、stale 或 unavailable,客户端不能提交该标记:
- 内容仍一致为 valid,正常使用。
- 节点仍存在但数据/结构摘要变化为 stale,显示“内容已变化,此项为先前快照”;保留快照供核对,整篮“带入对话”暂不可用,须移除该项或从当前内容重新选择。不会自动迁移篮中行索引。
- 来源节点或证据已不可用时为 unavailable,停止返回相关快照文本,只保留可移除的占位;不得通过篮子绕过当前来源访问规则。整个文档失去权限时按第 8 节处理。
新增选择只接受当前 source_digest;已保存的 stale 项可以原样保留或移除,不能借保存意图修改它的快照/来源。兼容校验先按 item_id 对比已存项,再检查新增项。无法自动迁移时不将 stale 项冒充当前数据。
GET 响应另有 `item_status`,按 item_id 返回状态及简短原因,不混入可写 payload。unavailable 项以相同 item_id 和来源标识返回,title/snapshot_text 使用固定不可用提示;PUT 只能保留这个经过屏蔽的占位或删除它。幂等重试返回回执前也要重新检查当前来源可用性并屏蔽不可用文本,不能直接回放旧的含文本响应。回执确认原写入版本,不构成允许再次显示旧快照的授权。
items 与 intent 分别记录 dirty 路径;清空动作原子设置 `items=[]`、`intent=""`,即使值为空也保留 dirty 标记,迟到 GET 不得把旧项目重新并入。清空保存期间又新增内容时,旧回执只能确认它发送时对应的修改序号,新内容继续保留并按新 revision 保存。
“带入对话”只复制捕获的篮内容和意图到现有输入区,成功后仍保留篮子。异步回调同时捕获 source、目标 session 和当前作用域代次;用户已切换项目/会话时,旧回调不得填入新的输入区。实际发送继续由用户确认,失败不清空篮或表单。
表单草稿沿用现有控件存储。普通聊天发送成功后,只清除与该次发送快照仍相同的字段;发送期间新输入的字段保留。正式报告的表单默认带入对话,未获得实际发送确认前不清草稿。恢复只填充字段,不调用提交回调,不把报告表单写入正式证据。
## 7. 保存与冲突规则
共同流程为读取、合并本地编辑、待保存、保存中、已保存;异常分为未保存、冲突、来源变化和失去访问权限。展示不要求网络先成功,写入必须等已知 revision 和来源摘要。控件及分析篮继续使用 500ms 防抖,读取失败只保留一次首次编辑触发的补读,其余由显式重试触发,不轮询。
| 情况 | 处理 |
| --- | --- |
| GET 比用户编辑晚到 | 按 dirty 字段合并;items 是一个整体,不做数组 union;清空和删除也不能被恢复覆盖 |
| PUT 尚未返回又发生编辑 | 保留更新的 changeSeq;回执只清除已确认的旧 dirty 字段,再保存后续版本 |
| 响应丢失,无法判断写入是否成功 | 先以原 request_id 和原始 payload 重试;确认后再发后续编辑 |
| 409 `UI_INTERACTION_STATE_CONFLICT` | controls/basket 保留本地草稿,停止自动重试;允许“保留本地并重新保存”或确认后“加载远端” |
| 409 `UI_INTERACTION_SOURCE_CHANGED` | 先有界刷新来源并兼容迁移;不可直接拿新摘要覆盖旧索引;stale 篮项目按第 6 节处理 |
| 同 request_id 携带不同 payload | 409 `UI_INTERACTION_REQUEST_CONFLICT`;视为客户端错误,保留草稿,不能自动换 ID 继续重放 |
| 413 或服务不可用 | 保留内存草稿,说明未保存;不切片、截断或循环提交 |
| 服务端确认目标回答正在生成或等待落盘 | 返回 503 `UI_INTERACTION_SOURCE_NOT_READY`,保留草稿;首次用户编辑可触发一次补读,之后显式重试,不等同于来源被删除;仅会话存在不能证明回答正在落盘,不存在的来源仍返回 404 |
| 临时 401 | 沿用现有认证刷新机制;不能因此扩大权限或换用其他主体保存 |
来源摘要冲突必须先解决来源变化,再决定是否覆盖交互版本;“保留本地”不是绕过数据校验。不同浏览器不增加 WebSocket 或后台同步:打开来源时读取,保存时发现冲突。自动读取到较新状态也不能改动用户正在操作的视口或表单。
状态提示按记录聚合:“已保存”表示本界面当前脏记录全部已确认;控件已保存而篮子未保存时明确标出分析篮。纯阅读位置同步异常只在阅读面显示轻量状态,不让每次滚动占据对话通知区。
“加载远端”的确认绑定当前记录和确认时的 changeSeq;确认后又出现新的本地输入,旧确认结果不得覆盖它,应保留新输入并重新呈现冲突选择。
## 8. 清理、权限与删除
- 切换会话时,未失败的待保存写入仍使用捕获的旧 source;新来源创建独立订阅。组件卸载不立即丢掉同一认证作用域内的草稿。
- 登出、切换主体或项目时,取消未发送计时器、移除旧订阅并清理对应缓存。已发出的请求不能撤销既成写入,但回执不得进入新的作用域。
- 401 经认证处理确认失效、403 或来源不存在的 404,关闭来源恢复通道,清除该来源缓存中的正文快照和草稿,不在新用户界面显示;不能将这些响应当普通离线错误长期保留敏感快照。后续重新获得访问权限时重新授权读取。
- 显式“清空”使用带 expected_revision 的空 payload 写入,并增加 state_revision;保留空记录和近期回执,不删成 revision 0,防止迟到保存使已清内容复活。表单、篮和 reader 各自清空,不连带删除业务报告。
- 聊天删除沿用宿主删除标记与队列,清除两个状态文件;聊天分叉不复制私人控件、篮和阅读位置。
- 正式报告物理删除时由外键级联删除私人状态。任务取消、源撤回或进入新轮次不等于报告删除;只要旧报告仍按现有规则可读,其私人状态继续按旧 revision 保留。
- 每次 GET/PUT 重新检查报告权限。成员撤权对后续请求生效;已经通过鉴权的在途请求可能按当时权限完成,不承诺跨 metadata 与 BizDB 事务的即时强撤回。错误响应不能返回其他用户状态或证据快照。
## 9. 验收矩阵
| 场景 | 必须满足的结果 |
| --- | --- |
| 刷新后主动打开同一回答/报告 | 恢复控件、篮意图和阅读位置;不自动打开弹窗、定位地图或发送消息 |
| 桌面与 320px 窄屏互换 | 锚点/比例按新布局恢复,表格横纵滚动均有效,无越界和全页横向溢出 |
| 阅读恢复晚到、字体/图表延迟渲染 | 用户未操作时最多定位一次;用户已滚动或导航后不抢位置;1500ms 后不无限等待 |
| 同一 Markdown 内有重复标题/表格 | 同内容版本可按 occurrence 恢复;内容改变后匹配不唯一只清该位置 |
| 篮清空后 GET 或旧 PUT 回来 | 已清项目不复活,清空之后的新编辑不丢失 |
| 数据换序、重复标签、列变化或 Evidence 消失 | 控件执行明确迁移;篮快照标为 stale/unavailable,不静默换成别的数据 |
| 报告当前版变成下一版 | 旧阅读窗仍向旧版保存;用户选择新版本才加载其独立状态 |
| 两位有报告权限的用户 | 可分别保存同一报告,不能互读或互相冲突;均不要求拥有 source_session_id |
| 同一用户两窗口修改 | 控件/篮出现明确冲突;阅读位置不跳动;原请求重试不增加重复版本 |
| 状态保存中切换用户、项目或来源 | 旧响应和带入对话回调不污染新作用域;失去权限不返回缓存快照 |
| 32/64 KiB 边界、emoji/CJK、文件或报告行满额 | 字节和 UTF-16 限制一致,失败不截断、不丢当前草稿 |
| 会话删除、报告删除、空记录清除竞态 | 不复活删除对象,不把空记录误当从未保存 |
| Server 项目未迁移或接口回退 | 保持可读和当前内存交互,明确未保存;旧版聊天 controls 接口继续工作 |
| 回答正在落盘与来源已删除 | 前者保留草稿并有界重试,后者清理来源缓存;不能将普通 404 无限转成等待 |
| reduced motion、键盘打开和关闭 | 定位不增加动画;恢复不抢焦点,关闭回到触发按钮 |
单元测试覆盖状态归一化、dirty/tombstone、字节预算和迁移;浏览器使用有意延迟、响应丢失和冲突的模拟接口验证行为;Server/Agent 聚焦测试验证真实存储事务、权限和删除。最后以隔离 Agent、已授权项目和真实报告完成保存读回,明确区分模拟接口与真实服务结果。
## 10. 实施顺序与文件交接
第一批补齐聊天 reader/basket,可独立上线,不等待报告表迁移。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| frontend | `src/features/agent/session-state/`、`src/features/agent/api/` | 增加补充记录 schema 与来源适配器,复用现有队列/dirty/retry;保持旧 controls 接口 |
| frontend | `answer-ui/answer-selection.ts`、`answer-selection-tray.tsx`、`answer-ui-interactions.tsx`、`answer-ui-view.tsx` | 选择描述、稳定项目身份、受控 items/intent、快照有效性及带入对话保护 |
| frontend | `src/shared/ui/reading-workspace-context.ts`、`reading-workspace.tsx`、`src/shared/ai-elements/markdown-reading.tsx` | 只接收通用 source key 和位置读写回调;锚点、容器测量与恢复取消,不导入业务模块 |
| Agent | `src/sessions/workspace-store.ts`;拟新增 `src/sessions/answer-interaction-state-store.ts`、`src/routes/answer-interaction-state-routes.ts`;`src/routes/chat.ts`、`src/server.ts` | 受控补充文件与新接口、来源校验、CAS、容量和删除 |
第二批接入正式报告,复用第一批组件和补充记录协议。
| 仓库 | 修改位置 | 工作 |
| --- | --- | --- |
| Server | 拟新增 `resources/sql/026_report_user_view_states.sql` | 项目 BizDB 私人状态表及报告删除外键 |
| Server | 拟新增 `app/domain/schemas/report_view_state.py`、`app/services/report_view_states.py`、`app/infra/db/postgresql/report_view_states.py`;`app/api/v1/endpoints/operational_tasks.py` | 状态模型、Evidence 投影、读者权限、行锁、独立子记录版本和错误契约 |
| frontend | `src/features/workbench/api/operational-task-client.ts`;拟新增 `api/report-view-state-client.ts` | 读取指定报告版本、通过现有项目请求头访问私人状态接口,解析 Server 错误 envelope |
| frontend | `components/investigation-report-blocks.tsx`、`investigation-task-report.tsx`、`investigation-expand-card.tsx` | 显式报告身份、冻结阅读版本对应的状态绑定、投影摘要核对和只读者状态接线 |
Workbench 通过 Agent 公共 API 注入保存适配器,Agent 组件不能反向导入 Workbench;shared 阅读组件只处理通用锚点与滚动,不接触 token、taskId 或业务请求。
验证命令沿用各仓库 AGENTS:前端 `pnpm lint`、`pnpm typecheck`、`pnpm test`,新增 `tests/browser/agent-interaction-state.e2e.ts` 并回归既有阅读/状态用例;Agent `bun run check`,新增 `tests/sessions/answerInteractionState.test.ts`、`tests/routes/answerInteractionStateRoutes.test.ts`;Server 新增 `tests/unit/test_report_view_states.py`、`tests/api/test_report_view_states.py`,覆盖本节矩阵的存储与权限分支。新测试文件在实现时创建,本设计不将它们标为已存在或已通过。
发布顺序:聊天先 Agent 补充接口、后前端;报告先对目标项目备份并应用迁移,再发布 Server 和前端。迁移必须包含可重复执行与旧报告外键校验,不能对未授权项目批量执行。回退前端保留两类状态文件与报告状态表;回退 Server 关闭新接口即可,不需要删除数据。每批完成后记录实际提交、迁移项目、服务版本和读回结果,不以本地测试代替发布验收。
## 11. 宿主管理提问与回复
2026-10-28 实施记录:已接入宿主存储、工具桥与门禁、问题卡、草稿保存、Responses 续跑及历史原生 question 核对。当前实现通过独立列表恢复问题,明确重试继续;尚未将全部历史请求嵌入会话详情的分页投影。已使用隔离原生实例验证固定输入 ID 去重与重启持久化,未调用付费模型验证实际工具选择和完整推理续跑。下述契约保留为设计与验收依据;未提交、未部署。
原生验证发现:已回答或取消的 form 在重启后可能返回不存在。因此兼容入口优先保留宿主已知终态,未知且原生缺失的历史卡片显示失效;不能仅凭 form 缺失推断用户已经回答。新协议不依赖原生 form 保持等待状态。
本次验证:Agent 静态检查及 67 项聚焦测试通过,追加边界修改后对应 13 项回归通过;前端 lint、typecheck、125 个文件共 655 项测试及生产构建通过。桌面和 320px 浏览器场景通过,一次重复运行在打开面板前超时,独立重跑通过。构建使用 Node 24.13.0,低于 package.json 要求的 24.22.1,发布前还需在规定 Node 版本复验。隔离原生检查不调用模型,不替代真实授权项目完整推理验收。
### 11.1 决策与当前证据
用户允许在必要时自行设计提问机制,不依赖 OpenCode 内置 question。推荐新增受控工具 `ask_user`:模型提出问题,Agent 宿主保存请求并结束当前模型轮次;用户提交答案后,宿主在原会话启动一次有身份绑定的续跑。等待人的时间不占用一个原生 question Promise,问题是否可回答也不由前端卡片判断。
这不是替换推理引擎。继续使用 OpenCode 执行模型和既有工具,但提问协议、草稿、答案、取消及投递记录由宿主管理。正式报告 FollowUpForm 仍是补充资料入口,不自动变成阻塞提问;Shell/MCP 权限审批继续走原有审批通道,不由 ask_user 代替。
当前代码依据:
- 前端 `QuestionRequestCard` 的选项草稿在组件 `useState` 中;切换后重建组件会重新初始化草稿。
- Agent 的 `chatInteractionRoutes.ts` 要求内存 `activeRuns` 和 `pendingQuestions` 存在;`runtime/opencode.ts` 随后读取并回复原生 form。历史卡片存在不能证明原生请求仍有效。
- 当前切换会话不直接终止旧运行,尚未复现用户此前的错误,因此不能将历史故障确定归因为切换动作。
- `ResponseInputTracker` 本身是内存 Map,不能直接当作持久化队列。可复用的是 runtime 中固定消息 ID、inbox 与 message 读回的投递核对,以及 `chatStream.ts` 在工具完成后中断并等待 idle 的已有轮次结束机制。
最小替代方案是补原生 form 状态核对与选项缓存,改动较少,但恢复仍受原生请求生命周期约束。选择宿主方案是为了独立保存等待状态,不再为每次运行状态丢失拼接恢复逻辑。官方支持[自定义工具及独立工具名称](https://opencode.ai/docs/custom-tools/);本站示例 API 与当前仓库 OpenCode 2 插件 API 不完全相同,实施使用本地 `.opencode/tool.ts` 和插件注册方式,不照搬官网导入路径。
### 11.2 请求与草稿分离
首批仅用于 `ordinary_chat` 和 `task_collaboration`,一个会话最多一个未解决的提问组,每组最多 3 个问题。后台 `task_execution` 不等待浏览器,继续使用缺失资料报告及人工证据流程。工具和浏览器都不能指定所有者、项目或目标续跑会话。
新增宿主记录协议 `agent-interaction-request@1`,身份为已授权 actor/project/session + 宿主生成的 interaction_id。绑定原始消息、宿主 turn_id、工具 call_id;用 session/turn/call 唯一键去重创建,原生 form_id 不参与新协议。
| 记录 | 字段和规则 |
| --- | --- |
| 提问定义 | 不可变的 questions;每题包含稳定 question_id、标题、正文、选项 id/label、single/multiple、allow_custom、required;每题最多 12 个选项 |
| 请求状态 | revision、pending/answered/skipped/cancelled/invalidated、创建时间、解决时间;已回答答案不可覆盖,修正需发新消息 |
| 草稿 | 独立 draft_revision、各题 selected_option_ids/custom_text;请求待回答时可保存;迟到草稿回执不能恢复终态请求 |
| 用户答案 | 按 question_id 提交选项 ID 和自定义文字;宿主按原始定义补齐标签,拒绝不存在的选项、重复 ID 和非法多选 |
| 续跑记录 | 稳定 native_message_id、queued/dispatching/delivered/blocked/cancelled、错误码和关联 response_id;与答案在同一次原子写入中建立 |
所有 schema 严格校验。标识最多 128 字符;标题、问题正文、选项标签分别最多 200、2000、200 个 UTF-16 代码单元;每题自定义输入最多 2000,问题或答案的完整文本各最多 10000。定义最多 32 KiB,答案和草稿各最多 16 KiB UTF-8。默认选中不是已提交,也不能触发自动回复。
新增 `chat/interaction-requests.json`,复用宿主所有权、原子替换、串行更新及删除标记,最多 2 MiB、1000 个请求,先到限额者生效;连同第 4 节两类状态文件,交互文件预算最多 4 MiB/会话。已解决记录保留用于重试和审计,不自动淘汰未回答项;满额拒绝新提问并返回普通文字说明,不截断已有答案。首次实现沿用单宿主进程拥有会话的部署边界,不能把进程内队列宣传为跨实例锁。
### 11.3 提问、等待与续跑
```text
模型 ask_user -> 可信工具桥 -> 宿主原子保存请求 -> 问题卡片
|
本轮结束,等待回答
|
用户提交 -> 宿主保存答案和投递记录 -> 既有运行协调器 -> OpenCode 原会话续跑
^ |
+--- 按固定消息 ID 核对回执 ---+
```
1. 工具桥复用现有内部桥认证及运行会话上下文,session/turn/call 从可信上下文获得,模型参数只包含问题定义。严格注册工具 schema,拒绝模型传入身份、URL、命令或恢复回调。首次发布前确认没有其他正在执行的工具;同组并行调用时拒绝创建提问并要求在结果收齐后重试,不能把已开始的业务动作说成已撤销。
2. 先原子保存 pending 请求,再发布含 interaction_id/revision 的事件。保存失败不显示可提交卡片。工具立即返回“已登记,等待用户”的结果,不挂起 Promise;宿主在原生工具结果完成后结束该轮并等待 idle,记录专门的 `waiting_input` 结束原因,不显示为执行失败或最终报告完成。
3. 从登记成功到模型 idle,插件和宿主都拒绝该轮新工具调用,避免模型尚未收到答案就继续依赖答案执行。已有 final_answer 结束机制可复用,但 ask_user 的结束原因、落盘及重连投影需要单独实现;仅增加一句“请等待”提示词不满足验收。创建后崩溃时,宿主从持久记录重建等待状态并核对旧轮次,不继续旧的工具链。
4. 浏览器显式提交答案,宿主比较请求 revision、验证答案并原子写入 answered 和 queued 投递记录,返回已保存回执。原轮次尚未 idle 时只排队,不并发启动第二轮。提交不能依赖 `activeRuns` 必须仍有旧对象。
5. 抽取并复用既有 `/responses` 的会话启动、事件订阅、鉴权上下文、消息持久化和模型选择,建立单一运行协调入口,不从路由外直接裸调用 runtime.prompt。新轮次采用宿主固定 native_message_id,通过已有原生 prompt 的 id 能力投递结构化问题与答案,标注为用户回答,不伪装成原 question 工具返回值。
6. 投递前先按固定 ID 查原生 inbox/message:已入队则接续观察,已消费则补记 delivered,仅确认不存在才投递。网络失败保留 dispatching 并核对,不生成新 ID。已消费后生成失败只允许继续该轮结果处理,不能再次注入同一答案;不承诺跨系统恰好执行一次业务动作。
7. 宿主重启不会丢掉已保存答案。恢复在下一次已授权的会话打开、回复或用户重试时触发,先恢复事件订阅,再核对投递与原生运行状态;不持久保存 Bearer,也不在无有效用户上下文时自动发起推理。OpenCode 会话不可恢复时标记 blocked,允许复制答案,但不能悄悄创建新会话、重放整段业务操作或伪造工具结果。
本方案最脆弱的前提是固定消息 ID 的核对能够区分未投递与已消费,以及提问后能形成可靠的轮次结束边界。上线门槛是用当前原生版本验证这两个行为;若无法确认,保持“答案已保存,继续失败”并停止自动重试,不回退到不受控的重复执行。当前设计没有执行这些原生故障注入验证。
### 11.4 接口与 UI 行为
接口均拟新增在 Agent 的 `/api/v1/session/:sessionId/interactions` 下,沿用会话鉴权;不建立 Server 表或引入新服务。内部创建桥为 `/internal/chat-interactions`,不对浏览器开放创建权限。会话详情增加未解决请求和当前消息页涉及的请求投影;独立列表使用稳定游标,默认 20、最多 50 条,不为恢复卡片拉取全部历史。
| 方法与相对路径 | 行为 |
| --- | --- |
| GET 根路径;GET `/:interactionId` | 返回权威请求、当前草稿和续跑状态;以 interaction_id 去重,读取不自动回答 |
| PUT `/:interactionId/draft` | 带 expected_draft_revision、request_id 和草稿;500ms 防抖,终态拒绝写入;与正式提交使用不同版本 |
| POST `/:interactionId/reply` | 带 expected_revision、request_id 和完整答案;同请求同内容返回原回执,不重复建续跑记录 |
| POST `/:interactionId/skip` | 所有问题均非必填时允许;原子记录 skipped 并安排一次明确的跳过输入,不编造默认答案 |
| POST `/:interactionId/cancel` | 仅 pending 可取消;关闭提问且不续跑,用户可以改发普通消息 |
| POST `/:interactionId/resume` | 只核对并继续原投递记录;不修改答案、不创建新的投递 ID |
写接口均使用严格 version、expected_revision 和 request_id 校验,draft 使用自己的 revision;resume 比较续跑记录 revision。400 表示定义或答案非法,409 区分版本冲突、请求已解决与幂等键内容不一致,413 表示容量不足,503 表示服务暂不可用。回复已落盘但模型暂不可用时返回已保存状态及 queued/blocked,不把答案当作提交失败让用户反复重发。GET 使用 private/no-store,权限失效与删除沿用第 8 节。
前端复用 shadcn 的 RadioGroup、Checkbox、Textarea、Button、状态提示和现有卡片布局,独立封装宿主提问卡,不新增任意代码渲染。可恢复的状态如下。
| 状态 | 界面和操作 |
| --- | --- |
| 核对中 | 显示问题与草稿,提交暂不可用;不能凭历史 pending 直接放行 |
| 待回答 | 支持单选、多选、自定义输入,草稿保存提示;必填项不提供跳过 |
| 提交中 | 禁止双击;保留完整输入;响应丢失按同请求 ID 核对 |
| 已回答,等待继续 | 只读显示已确认答案和续跑状态;重试只操作续跑记录 |
| 已回答并继续 | 显示答案摘要和后续回复;不再展示提交按钮 |
| 已跳过、已取消、已失效 | 展示对应原因,不显示可点击的过期选项;不统一称为“状态丢失” |
切换对话只切换订阅,旧回执绑定旧 session/interaction,不改变新对话;切回与刷新时先读权威状态,再合并当前作用域未保存的草稿。打开页面不自动提交、弹窗、聚焦或滚动。两窗口提交不同答案只有一个成功,另一窗口显示服务端已接受答案;保留冲突输入供复制,不覆盖已经提交的答案。
有 pending 提问时,普通输入区提示先回答或取消;服务端也拒绝绕过等待直接启动新一轮。已提交答案的续跑排队时同样阻止抢占,避免问答顺序错乱。“停止”会显式取消仍 pending 的提问;已经提交的答案仍保留,未投递记录可停止,已投递的运行沿用既有停止语义,不伪造未执行状态。停止与投递通过同一会话协调入口串行化。
### 11.5 兼容、实施与验收
新请求使用独立类型和 ID,不把历史原生 question 卡片直接改成宿主 pending。旧请求读取时核对原生状态:仍待回答才保留旧回复入口,已回复/取消则同步终态,明确不存在时显示已失效;503 时显示暂不可核对,不能误判失效。用户可以复制旧问题另起交互,但不会自动替旧工具伪造回答。
前端通过 `host-question@1` 能力声明支持新卡片,宿主在每轮启动时固定能力快照。有能力的新轮次注册 ask_user 并禁用内置 question;旧客户端不生成其无法操作的新卡片。既有原生请求的回复路由保留。灰度按新轮次边界生效,不热替换正在等待的工具。旧客户端打开新记录至少能看到文本和已解决状态,待回答请求提示使用支持该能力的客户端,不能静默降级后绕过等待。
该项可在 reader/basket 和正式报告持久化之前独立交付。预计涉及 Agent 与 frontend 两个仓库、超过 8 个文件;新增服务、数据库、账号、环境变量均为零,内部桥复用现有服务凭据。
| 位置 | 交付内容 |
| --- | --- |
| Agent `.opencode/tools/ask_user.ts`、`.opencode/plugins/tjwater-tools.ts`、工具与 instruction 策略 | 新工具、严格 schema、轮次能力判断和等待期工具约束;前者为拟新增文件 |
| Agent 拟新增 `src/sessions/interaction-request-store.ts`、`src/routes/interaction-request-routes.ts`、`src/routes/chat-interaction-bridge.ts` | 单文件事务、草稿版本、原子答案/投递记录、内部创建与用户接口 |
| Agent `src/sessions/workspace-store.ts`、`src/routes/chatStreamRoute.ts`、`chatStream.ts`、`chatSessionRoutes.ts`、`src/runtime/opencode.ts` | 文件允许列表、提问轮次结束、共享运行入口、重连核对、稳定消息 ID、删除及旧请求兼容 |
| frontend `src/features/agent/api/`、拟新增 `components/agent-interaction-request-card.tsx`、`src/features/workbench/hooks/agent-session-runtime.ts`、`use-workbench-agent.ts` | 协议解析、受控提问卡、草稿保存、请求状态核对与会话隔离 |
验收必须包括:A 会话提问后切到 B 再切回提交;刷新恢复草稿;两窗口同时提交;清空草稿后的迟到 GET;提交成功但回执丢失;原生已消费但宿主未记回执时崩溃;宿主重启后授权恢复;原生会话丢失;提问与另一工具并行;提问后模型继续调用工具;停止与续跑竞争;旧客户端及旧 question;权限失效和会话删除。前端覆盖键盘操作、320px 窄屏、焦点及 reduced motion。
实现时运行前端 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和拟新增浏览器 `tests/browser/agent-question-lifecycle.e2e.ts`;Agent 运行 `bun run check`,为状态存储、路由及原生续跑新增聚焦测试。浏览器模拟通过后,还需隔离原生运行验证固定 ID 与故障恢复,不能把模拟 Map 的幂等行为当作 OpenCode 的保证。
发布先增加服务端读写、文本投影与旧请求核对,再上线客户端,最后在支持能力的新轮次启用 ask_user。回退时先停止创建新请求,保留新协议的读取、提交和续跑兼容接口,直到待回答及待投递请求被解决或用户取消;不能直接删除接口把已发布问题遗留成死卡片。记录和答案始终保留,不因回退重置 revision。
@@ -0,0 +1,17 @@
# 回答 UI 的 2026 年 10 月验证记录
> 历史快照,仅保留当时的验证范围,不证明当前工作区或线上服务已通过同样检查。当前协议见 [本地交互式回答](../agent-answer-ui.md)。
2026-10-30 展示范围调整已通过 lint、typecheck、146 个文件共 838 项单元测试,以及 25 项浏览器回归(含复跑,使用模拟接口)。浏览器覆盖桌面与窄屏、内联与展开表格、图表动效、会话状态恢复、地图定位和补充表单;不代表真实项目联调或部署完成。
2026-10-30 分区、导航与来源精简已通过 lint、typecheck、842 项单元测试和 20 项浏览器回归(含复跑,使用模拟接口);另覆盖节点追问移除后的详情与地图定位。
以下为 2026-10-26 的历史验证记录,不代表后续工作区变更的验证结果:
| 验证层 | 结果 | 覆盖边界 |
| --- | --- | --- |
| 前端静态检查与单元测试 | lint、typecheck 通过;121 个文件、631 项测试通过 | 当前原生 UI、状态管理和既有前端回归 |
| Agent 检查与聚焦测试 | `bun run check` 通过;33 项测试通过 | 状态存储、权限、版本冲突、重试和会话生命周期 |
| UI 浏览器验收 | 10 项通过 | 当时的交互状态、引用追问、Markdown 阅读,使用模拟接口;引用追问现已移除 |
| 窗口鼠标行为回归 | 4 项通过 | 外围与 Dev 的窗口内外事件、地图拖动与缩放;修复前已复现遮挡 |
| 前端构建 | 通过 | SPA、生产 CSS 校验和 TTS adapter 构建;不等于部署完成 |
+117
View File
@@ -0,0 +1,117 @@
# 非地图面板整理
> 历史记录:2026 年 9 月的面板整理过程,当前规范见 [样式设计指南](../style-design-guide.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。
## 视觉与交互
沿用现有浅色阅读面、Noto Sans SC 中文字体、蓝色操作强调和状态徽标。优先修复可读性、对齐和窄屏裁切,不引入第二套主题、字体或组件库。
- `FieldSelect` 复用现有 Radix Select:统一标签、40px 最小高度、长选项换行、禁用态、键盘选择和弹层层级。清空受控值后回到占位提示,不触发额外业务动作。
- `Checkbox` 保留原生输入与标签语义,统一勾选、焦点和禁用样式。反馈确认仍须用户主动勾选。
- `Disclosure` 保留原生 details/summary 语义,统一展开箭头、焦点和点击区域。
- 调查目标和外围历史回放的日期字段单列展示,避免窄面板内日期与时分被裁切。两处均已使用共享自定义日期时间弹窗,保留各自本地时间/UTC 的业务语义。
- 手机端 Agent 欢迎区改为紧凑横排,避免被底部输入区遮挡。
- 桌面成果专注视图为 Agent 折叠栏预留空间,标题不再被覆盖,仍可重新展开助手。
- 成果图表显式使用页面的中文字体栈。此前 ECharts 画布仍以 `11px sans-serif` 绘制中文图例,在测试环境显示方框;浏览器画布探针已验证修复前失败、修复后在 1280/375px 通过,截图可见完整“调度下限”。源码只有这一处 ReactECharts 入口。
## 检查覆盖
| 面板 | 本次检查 |
| --- | --- |
| 调查任务、生命周期、运行与历史结果 | 表单、确认区、折叠内容、长标识、320/375/1280px |
| 已保存比较结论、Evidence 查看 | 四分支、加载/空/错误态、只读重试、键盘下拉、320/375/1280px |
| 外围样板、模拟数据、历史回放 | 下拉、日期、状态徽标、任务历史、375px |
| 反馈关联、人工复核 | 确认勾选、展开内容、禁用与只读边界、窄屏 |
| Agent 对话与历史 | 桌面抽屉、320/375px 欢迎区与滚动容器边界 |
| 工况列表与详情 | 375px 抽屉布局 |
| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 |
| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 |
实际要素详情使用地图上的 `FeaturePopover`;未接入运行入口的 `FeatureInsightPanel` 已在冗余清理中移除。
## 验证与限制
最终代码检查:`pnpm typecheck`、`pnpm lint`、全量 68 文件 / 350 项 Vitest、`pnpm build` 通过。生产构建仍提示部分 chunk 超过 500kB,本次未扩展打包优化范围。调查/反馈/外围/比较/生命周期的 29 项与非地图布局的 5 项专项浏览器用例通过,比较区新增的弹层不透明度和背景色检查另行重跑 6 项通过。非地图布局用例同时覆盖开发面板键盘选项和成果图表画布字体。
浏览器用例使用显式模拟接口,不代表真实账号、Agent 和 PG 的全链路验收。主要回归在 `tests/browser/non-map-panels-layout.e2e.ts`、`run-comparison.e2e.ts` 及既有调查、反馈、外围与生命周期测试中;截图保存在忽略的 `test-results/non-map-panels/`、`test-results/run-comparison/` 等目录,不作为发布资源。
本次也运行了完整浏览器套件,当次为 66 通过、33 失败,不能宣称全量通过。已确认的失败包括旧用例依赖 Agent 初始展开或已不存在的“工作台导航”、旧截图基线,以及新移动用例当时选择器不唯一。后者已改为语义化 `role=log` 并在专项回归中通过;其他旧用例与真实地图依赖仍需单独梳理,不通过修改产品默认行为或批量更新截图掩盖失败。
运行内容来自现有接口,无需手动替换界面占位数据。人工验收时请在桌面全宽与 375px 下检查长任务名称、日期输入、下拉键盘操作和成果标题。
## 后续交互回归
继续核查旧失败用例时发现并修复了两处真实缺陷:
- 窄桌面展开工况详情会自动收起 Agent;此前聊天草稿存在被卸载的面板内部,重新展开时丢失。现在由稳定的工作台 hook 持有草稿,桌面和手机面板共享受控值,新建会话仍清空。此状态只在当前页面内保留,不是刷新后的持久化草稿,也没有新增存储层。面板仍可卸载,避免为了保留文字让隐藏的语音控件继续活动。
- 新增调查/外围入口后,1536px 顶栏的异常处置与调查按钮发生重叠。现在保留按钮宽度、收紧组间距,仅在 1800px 以上展开次要按钮文字;较窄桌面省略模型只读提示,完整操作名称仍通过无障碍标签提供。手机标题显示短称“供水”,而不是省略号。
`workspace-main-frame.e2e.ts` 现在验证默认收起、主动展开后的相邻布局、收起恢复、成果 split/focus 切换,并检查 320/375/1024/1280/1440/1536/1600/1800/1920px 顶栏按钮不重叠、标题不截断。顶栏几何断言修复前失败,修复后通过。截图位于 `test-results/workspace-regression/`。
`agent-voice-hydration.e2e.ts` 与 `agent-panel-resize.e2e.ts` 改为先通过真实按钮打开助手,再检查语音、几何和草稿状态;不再假定 Agent 初始展开或折叠后保留旧版 72px 导航宽度。工况自动收起场景使用其实际适用的 1280px 宽度,保留了未发送草稿不丢失的断言。布局测试按实时几何等待入场动画完成,不固定动画中途的坐标,也没有加入固定 sleep。
该轮完整浏览器套件为 79 通过、21 失败,共 100 项。仍有旧导航/默认展开假设、截图基线与动画时机相关失败,不能宣称全量通过,也未批量更新基线。
## 调查任务日期时间补充
根因是原先只统一了 `datetime-local` 输入框外观,其日历和时间弹窗仍由浏览器绘制。调查任务的开始、结束时窗已替换为共享 `DateTimeField`:沿用浅色面板、蓝色选中态和现有 Radix 弹层/下拉,不引入新依赖。
- 日历、月份、年份、小时和分钟均在应用内绘制,窄屏弹窗限制宽高并允许纵向滚动。
- 点击确认才更新值,取消或 Esc 放弃草稿并将焦点返回字段;日历支持方向键和 PageUp/PageDown。
- 年份编辑保持输入焦点,支持闰年和真实月份天数;无效源值不会被静默截断后保存。
- 已关闭任务不可编辑。组件仍输出本地 `YYYY-MM-DDTHH:mm`,原有目标保存逻辑负责转换 UTC,不改变业务时区语义。
- `date-time-field.test.tsx` 覆盖组件边界。
调查任务两个日期字段均已替换;同类的外围历史回放已在后续批次统一,见下节。
补充后的验证:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项单元测试和 `pnpm build` 全部通过;日期时间、调查面板、非地图布局、运行控制及任务生命周期的 20 项浏览器回归通过。生产构建仍有既有大 chunk 提示。上述浏览器检查使用模拟接口,不替代真实系统验收。
日期回归用例做过红绿验证:临时将调查时窗重新接回原生 `datetime-local`,确认/提交用例因缺少自定义字段入口失败;恢复 `DateTimeField` 后重新运行全部 3 项日期用例通过。代码未提交。
按用户要求,每轮 Playwright 结束后检查其浏览器和临时 Vite 服务是否退出,不关闭用户已有的 5173 开发服务。
## 外围历史回放日期补充
外围面板的 `HistoryTime` 同样仍由浏览器绘制日期时间弹窗,已删除该包装并复用 `DateTimeField`。保留“开始(UTC)/结束(UTC)”标签和原来的 UTC 查询拼接,不套用调查任务的本地时间转换。扫描 `src/` 已无原生 date/time/datetime-local 输入入口。
`peripheral-sample-panel.e2e.ts` 新增实际交互断言:修改起止日期时间后只有点击“读取历史读数”才发送 GET;浏览器为 Asia/Shanghai 时,08:15 仍按 08:15Z 查询;取消不改值、不发查询,也没有任何外围写请求。320/375/1280px 弹窗边界与无横向溢出通过,截图位于忽略的 `test-results/peripheral-datetime/`。
红绿验证:替换前该用例因缺少自定义日期入口失败,替换后外围面板、调查日期时间、反馈导入共 14 项浏览器测试通过。类型检查、Lint、69 文件 / 357 项单元测试与生产构建通过,构建仍提示既有大 chunk。仍使用模拟接口,不代表真实外围服务验收;完整浏览器套件的失败不因本轮专项通过而视为消除。
## 问答与流式测试入口修订
重新运行确认旧问答/流式用例默认寻找尚未展开的 Agent 输入框;显式展开后还暴露了授权项目模拟缺失。请求失败探针记录 `/api/v1/projects` 连接拒绝,Agent 客户端因此未进入回复与模型查询。现在使用明确的测试运行配置和已有项目/SCADA mock,不改产品授权流程;临时日志已移除。
流式用例另外仍断言旧浮动简报和浮动输入区。当前 `desktop-dock` 分支将简报和输入区放入正常布局,已改为验证简报在对话区上方、输入框在滚动区域下方、分析中操作禁用及区域不重叠;进度窗口几何采样、计划卡片、结束滚动、保持展开、选中文本朗读等原断言仍保留。问答回复的精确请求号与 payload 断言未放宽。两个文件共 3 项重跑通过,其中问答桌面和流式用例此前失败。
本轮最终完整浏览器套件:103 项中 85 通过、18 失败,耗时 2.7 分钟。失败分布为 `app.e2e.ts` 4 项、`agent-acrylic.e2e.ts` 4 项、开发面板/地图通知/移动转桌面/窄桌面工况/地图相机稳定性各 1 项、视觉基线 5 项。不得将全部失败归因于旧断言,仍需逐项核查实际行为。此前工况动画时机失败在本轮未复现,未作修复,仍作为时序风险保留;问答与流式是本轮明确修订后通过的两项。完整结果与失败上下文位于忽略的 `test-results/non-map-followup-full/`。
## 历史菜单、Esc 分层与全量回归收尾
继续核查上述 18 个失败后,确认并修复了以下真实行为问题:
- 手机 Agent 内的 Radix 模型菜单和权限模式菜单已处理 Esc,并将事件标记为 `defaultPrevented`,但外层 `MobileWorkbenchSheet` 的 window 监听仍无条件关闭抽屉。外层现在只处理未被内层消费的 Esc。模型和权限菜单各自关闭后,焦点返回原触发按钮;没有内层菜单时,Esc 仍关闭抽屉。
- Dev Panel 有相同的事件分层缺陷。`FieldSelect` 打开时第一次 Esc 只关闭选择菜单并返回焦点,第二次 Esc 才关闭开发面板。
- 手机端已打开 Agent 时跨越 1024px 进入桌面,原逻辑先清空移动抽屉状态,导致用户打开 Agent 的意图丢失。现在先将移动 Agent 状态迁移为桌面展开状态,再清空移动抽屉。
- Dev Panel 打开时,地图工具栏仍在面板下面渲染。现在开发面板显示期间不挂载桌面地图工具栏与其工具面板。
- 1024px 下展开工况任务会自动折叠 Agent,但 880px 工况面板与 72px 折叠栏重叠 4px。布局变量现显式记录折叠栏宽度,工况面板在该宽度下收敛到 852px,二者保留 24px 间距。
同时修正了三类失真的回归断言。地图容器变宽或变窄时,MapLibre 的 `resize()` 会发出 `movestart`,但经纬度、缩放、方位角和俯仰并未改变;相机稳定性用例现在直接比较这五项参数。地图通知位置测试改用确定会生成通知的“操作参考”入口。工况材质测试等待入场动画结束后再读取 transform 和 will-change,不再采样动画中间帧。焦点、几何和业务请求断言均保留。
320px 与 375px 的模型菜单截图位于 `test-results/agent-history-ui/320-model-menu.png` 和 `375-model-menu.png`,长标题重命名截图位于同一目录。模型名称、说明和菜单边界完整,权限模式、模型、语音和发送控件没有重叠。五张过期的工作台视觉基线经逐张检查后更新,覆盖桌面浅色/卫星底图、1920px Agent 收起状态、手机启动器和手机 Agent 半屏;手机工况卫星图无需更新。视觉用例更新后再次无更新参数运行,6/6 通过。
最终验证结果:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项 Vitest、`pnpm build` 全部通过。构建继续报告既有的 500kB 以上 chunk 提示,CSS 校验和 TTS bundle 通过。完整 Playwright 套件为 104/104 通过,耗时 2.4 分钟,结果位于忽略的 `test-results/non-map-followup-final-green/`。这些浏览器用例仍使用模拟接口,不替代真实账号、Agent、PG 和外围服务的全链路验收。
## 新增业务组件统一设计
2026-09-18 对调查任务与外围样板的新组件完成统一重构。现行规则见 [样式设计指南](../style-design-guide.md),视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。外围工单统一按系统数据处理。
- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。
- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。
本轮最终检查为 `pnpm typecheck`、`pnpm lint`、76 文件 / 395 项 Vitest 和 `pnpm build` 通过。新增组件相关的 17 文件 / 118 项单测通过,核心业务面板 Playwright 为 31/31 通过。完整浏览器套件共 110 项,并发运行 108 项通过;2 项在等待 Toast 退出时超过 15 秒,随后单进程精确复跑 2/2 通过。截图位于忽略的 `test-results/task-lifecycle/`、`run-control/`、`run-comparison/`、`peripheral-datetime/` 和 `/tmp/feedback-*.png`。
+93
View File
@@ -0,0 +1,93 @@
# Rive Persona 内存问题排查记录
> 历史记录:保留 Rive 内存故障与修复依据,当前指针通路、缓存与离线行为见 [Persona 设计](../agent-persona-design.md)。测试数量、失败列表与未提交状态只对应原记录时间,不代表当前回归结果。
日期:2026-07-21
## 背景
`next-webgis` 中的 Obsidian Persona 使用 Rive WebGL2 渲染。迁移到
`next-tjwater-frontend` 的 Vite、React 运行环境后,页面加载 Persona 时出现浏览器
`Out of Memory`,同时需要保留原有羽化灰黑球体的视觉效果。
本次排障验证使用 `@rive-app/react-webgl2@4.29.3`,依赖声明保留 4.x 兼容版本范围。
资源仍使用:
```text
https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv
```
## 已证实的触发条件
在同一 Chromium 环境下进行了对照测试。以下内存值是排障时的单次观测范围,
用于判断增长趋势,不应视为跨设备性能基准。
| 场景 | 结果 |
| ------------------------------------------------- | ----------------------------------------------- |
| Vite、WebGL2、绑定 Rive View Model | 渲染进程快速增长到约 4.6 GB,随后失去响应或崩溃 |
| Vite、阻止 `.riv` 加载 | 约 29 MB 至 61 MB,保持稳定 |
| Vite、Canvas、不绑定 View Model | 内存稳定,但渲染边缘和质感不符合原设计 |
| Vite、Canvas、调用 `bindViewModelInstance` | 再次出现持续增长 |
| Vite、WebGL2 4.29.3、调用 `bindViewModelInstance` | 再次出现持续增长 |
| Vite、WebGL2 4.29.3、不绑定 View Model | 约 71 MB 至 116 MB,最终约 78 MB |
| `next-webgis` 原实现 | 约 165 MB 至 221 MB,未出现持续增长 |
可以确认:在迁移后的 Vite 应用中,`bindViewModelInstance` 是泄漏的稳定触发点。
`useViewModelInstance` 内部也会执行该绑定,因此仅替换颜色 hook 不能解决问题。
不能仅凭现有证据断言缺陷完全属于 Vite、React 或 Rive 某一方。`next-webgis` 的同一
资源和完整绑定能够稳定运行,说明这是新运行环境与 Rive View Model 绑定生命周期的
兼容问题。若要定位到 Rive 内部对象,需要进一步制作最小复现并进行运行时级堆分析。
## 为什么不是 TypeScript 7
TypeScript 在该项目中只通过 `tsc --noEmit` 做静态检查。浏览器实际执行的是 Vite 和
SWC 转换后的 JavaScript。泄漏可以通过是否调用 Rive View Model 绑定稳定开关,因此
与 TypeScript 类型检查器无关。
## 最终修改
1. 保留 Rive WebGL2、Obsidian 资源和 `default` 状态机。
2. 移除 `useViewModel`、`useViewModelInstance`、`useViewModelInstanceColor` 及所有
`bindViewModelInstance` 调用。
3. 使用 CSS `brightness` 和 `contrast` 保持浅色界面中的灰黑视觉,不再监听并不存在的
应用深色主题。
4. Persona 仅在元素进入视口且页面可见时挂载,避免桌面和移动响应式节点同时初始化。
5. 首次初始化延迟到下一帧,使 React Strict Mode 的探测挂载可以在创建 Rive 实例前取消。
6. 保留懒加载和加载失败占位,但移除实验阶段的多皮肤、重复状态推导和无用事件透传。
7. 移除 Persona 环境变量开关,动画由组件生命周期直接管理。
## 回归保护
- `src/shared/ai-elements/persona.test.tsx` 验证加载的是 Obsidian 和 `default` 状态机,
同时保证不会调用 `bindViewModelInstance`。
- `src/app/app.e2e.ts` 验证页面只发出一次 `.riv` 请求。
- 桌面 1440 x 900 和移动端 375 x 812 均验证无横向溢出。
验证命令:
```bash
pnpm typecheck
pnpm lint
pnpm test
pnpm test:browser
pnpm build
```
## 后续修改约束
- 在独立最小复现和持续内存采样通过前,不要重新引入 Rive View Model 绑定。
- 不要用 CSS 隐藏两个已挂载的 Persona,响应式副本必须通过可见性挂载保持单实例。
- 不要移除 Strict Mode 延迟初始化,除非重新验证开发和生产生命周期。
- 更新 Rive 版本时,至少验证单次 `.riv` 请求、状态机切换、页面切换后的实例清理和
30 秒以上的浏览器进程内存趋势。
## 2026-10-21 原版恢复后的现状
Agent 入口使用基于原版 Obsidian 定制的冰水蓝资源。继续保留 Strict Mode 延迟初始化、按可见性挂载和禁止 View Model 绑定;配色直接写入资源,不应用旧版的 brightness / contrast 滤镜。`persona-resource.ts` 在页面内存与浏览器 Cache Storage 中缓存动画字节,按内容版本更新,不共享 Rive 实例或 View Model。
业务离线由本地 `obsidian-water-v1.riv` 的 `asleep` 状态播放内部缓慢水纹并关闭外围光圈,在线/离线切换保留同一实例。减少动态效果、加载和失败时显示从当前资源渲染的 `obsidian-water-idle.png` 或无光圈的 `obsidian-water-offline.png`。隐藏和减少动态效果会卸载实例,不通过 CSS 同时隐藏多个仍运行的 Persona。资源改造见 [Agent 动画资源说明](../agent-persona-design.md#8-资源生成与维护)。`hover` 输入由外层交互控制,Rive 自带的 DOM 监听关闭,避免和原生入口按钮及触摸操作重复响应。
回归验证已覆盖原始字节缓存、失败后的重试、无 View Model 绑定、原生状态及 hover 切换、真实 canvas 动画和桌面/手机单实例。
独立原版 / 接入版预览进行了 35 秒浏览器渲染进程 RSS 观察:约 193.3 MiB 至 177.2 MiB,未观察到持续增长。该结果仅覆盖本地短时对照预览;本轮没有重新引入 View Model 绑定,也未更新 Rive 版本。