feat: add project-aware agent workspace

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 9c4d4b764b
191 changed files with 12373 additions and 3556 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ TJWATER_GEOSERVER_WORKSPACE=tjwater_next
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
TJWATER_AGENT_URL=http://127.0.0.1:8787
TJWATER_WORKBENCH_RUNTIME_MODE=auto
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo
TJWATER_PERIPHERAL_SAMPLE_DEMO_START=2026-09-17T00:00:00Z
TJWATER_PERIPHERAL_SAMPLE_DEMO_END=2026-09-17T12:00:00Z
-1
View File
@@ -5,7 +5,6 @@ dist-server
coverage
playwright-report
test-results
/public/city-model
.env
.env.*
!.env.example
+8 -2
View File
@@ -26,8 +26,9 @@ execution belong in `next-tjwater-agent`.
- `src/mocks/` owns MSW handlers and fixtures.
- `src/test/` owns shared Vitest setup and repository-wide invariant tests.
- `tests/browser/` owns product-level Playwright regressions.
- `public/` contains static supply-network and SCADA assets plus the
runtime-config placeholder.
- `public/` contains static supply-network, SCADA, bundled city-model release
assets, and the runtime-config placeholder. Keep the city-model manifest,
attribution, and referenced runtime archive content-hash aligned.
- `server/` contains the same-origin Edge TTS adapter used by Vite and the
production container.
@@ -67,6 +68,11 @@ or barrels for private implementation files.
## Component and Module Design
- Follow [the style design guide](docs/style-design-guide.md) for UI changes:
preserve the local shadcn/ui design for all Agent dialogs, panels and menus.
Use Tailwind CSS with shared semantic tokens; apply Acrylic styling to map
and other workbench surfaces as specified by the guide. Keep feature
documents focused on business behavior.
- Use TypeScript and React function components. Export explicit types at
feature and adapter boundaries.
- Use two-space indentation and kebab-case filenames. Use PascalCase for
+9 -3
View File
@@ -4,6 +4,8 @@ Next TJWater 是供水管网 WebGIS Agent 工作台的前端项目。界面以
项目使用 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图与流向效果基于 MapLibre GL,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
样式规范统一见 [样式设计指南](docs/style-design-guide.md),Agent 功能与报告交互见 [交互设计](docs/agent-chat-ui-design.md),形象、配色与状态动画见 [Persona 设计](docs/agent-persona-design.md)。
## 主要功能
- 供水管网地图、业务图层和设备状态展示
@@ -49,7 +51,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| `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_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | `/peripheral` | 外围系统模拟服务地址;生产推荐使用同源代理路径,留空时不发起连接 |
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) |
| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) |
@@ -63,11 +65,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 只配置实际使用的前端地址。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/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,转发到外围服务 `8011`。开发和预览模式提供同路径代理,默认上游是 `http://127.0.0.1:8011`,可用 `TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
Agent 运行时仍可发送消息,默认“引导当前任务”(steer),也可选择“排队处理”(queue);停止按钮独立保留。尚未处理的消息显示在输入框上方,可以逐条切换发送方式。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
Agent 运行时仍可发送消息,底栏可选择“引导”(steer,默认)或“排队”(queue)。右下角只有一个操作位置:运行中输入为空时停止,有内容时发送追加消息;空闲时发送新消息。待处理消息并入输入组件顶部,每条展示正文预览、处理方式和消息菜单;长内容可展开全文,菜单可切换处理方式、退回编辑或关闭排队。编辑需服务端确认撤回,保留已有草稿并等待手动重发。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
## 常用命令
@@ -86,6 +88,10 @@ pnpm test:browser # 运行 Playwright 浏览器测试
## Docker
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时会根据环境变量生成 `/runtime-config.js`,同时启动 Edge TTS 适配器。
奉贤三维建筑资源随仓库中的 `public/city-model/` 一起进入镜像:浏览器先读取
`/city-model/manifest.json`,再通过 HTTP Range 按需读取
`/city-model/buildings.pmtiles`。清单中的 `tiles.buildings.sha256` 必须与归档内容一致,
客户端以该摘要隔离 IndexedDB 持久缓存版本。
```bash
docker build -t next-tjwater .
+30
View File
@@ -0,0 +1,30 @@
{
"name": "冰水蓝",
"bodyColor": "#DCEFFA",
"bodyOpacity": {
"rest": 0.94,
"active": 0.98
},
"colors": [
{
"name": "水体",
"from": "#121014",
"to": "#299FDF"
},
{
"name": "白色高光",
"from": "#ffffff",
"to": "#FFFFFF"
},
{
"name": "同色相暗部",
"from": "#000000",
"to": "#1482BC"
},
{
"name": "水纹",
"from": "#888888",
"to": "#4C9ECC"
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "静水待机",
"fps": 24,
"amplitude": 0.25,
"transitionMs": 600,
"layers": [
{
"name": "内层反射与下侧柔光",
"opacity": [
{ "objectId": 97, "name": "下侧柔光", "rest": 0.56, "crest": 0.68 },
{ "objectId": 190, "name": "内层光圈", "rest": 0, "crest": 0 }
]
},
{
"name": "中层反射与内部柔光",
"opacity": [
{ "objectId": 105, "name": "内部柔光", "rest": 0.48, "crest": 0.62 },
{ "objectId": 199, "name": "中层光圈", "rest": 0, "crest": 0 }
]
},
{
"name": "外层反射与上侧柔光",
"opacity": [
{ "objectId": 101, "name": "上侧柔光", "rest": 0.60, "crest": 0.72 },
{ "objectId": 208, "name": "外层光圈", "rest": 0, "crest": 0 }
]
}
]
}
File diff suppressed because it is too large Load Diff
-224
View File
@@ -1,224 +0,0 @@
# 亚克力设计类别与组件使用清单
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
## 总览
当前材质体系分为 3 组,共 13 个语义类:
| 分组 | 数量 | 类别 | 是否使用背景模糊 |
|---|---:|---|---|
| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 |
| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 |
| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 |
只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。
## 1. 外层亚克力与临时玻璃
这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。
### `acrylic-navigation`
- 用途:全局导航栏。
- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。
- 特征:保留底部分隔和下投影,不使用顶部高光。
- 当前组件:
- `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。
### `acrylic-panel`
- 用途:承载持续显示或需要集中阅读的主要浮动面板。
- 模糊:使用面板级模糊,20px 至 24px。
- 当前组件:
- `AgentCommandPanel`,Agent 命令面板外壳。
- `AgentCollapsedRail`,Agent 收起状态侧栏。
- `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。
- `LayerControl`、`ControlPanel`、`Legend`、`BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。
- `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。
- `FeatureInsightPanel`,要素洞察面板。
- `MapDevPanel`,地图开发调试面板。
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。
- 直接或间接涉及文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/map/core/components/map-control-styles.ts`
- `src/features/map/core/components/notice.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/feature-popover.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/map-dev-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control`
- 用途:始终悬浮在地图上的紧凑型独立控件。
- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。
- 当前组件:
- `Toolbar`,地图主工具栏。
- `DrawToolbar`,绘制工具栏。
- `ZoomControl`,缩放控件。
- `ScaleLine`,比例尺。
- `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`src/features/map/core/components/map-control-styles.ts`。
### `glass-transient`
- 用途:短时出现、需要突出当前任务状态的玻璃浮层。
- 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件:
- `AgentTaskTicker` 的活动任务卡片。
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
## 2. 内部实体表面
这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。
### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
- 后续可能增加的对象目录、调度队列等常驻侧栏。
- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`。
### `surface-control`
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
- 当前组件范围:
- Agent 收起侧栏条目、输入操作栏和命令面板快捷入口。
- 图层、量测和通用控制面板中的交互控件。
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
- 任务浮条的非活动卡片。
- 要素洞察面板的内部按钮。
- Header 下拉菜单的内部控制区。
- 通用 `IconButton`。
- 主要文件:
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/map/core/components/control-panel.tsx`
- `src/features/map/core/components/layer-control.tsx`
- `src/features/map/core/components/measure-panel.tsx`
- `src/features/workbench/components/agent-task-ticker.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well`
- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。
- 当前组件:
- `LayerPanel`、`LayerControl` 的未激活条目。
- `MeasurePanel`、`DrawToolbar`、`AnnotationPanel` 的内部区域。
- `ControlPanel`、`Legend`、`BaseLayersControl` 的分组与空白区域。
- Header 下拉菜单的普通菜单项。
- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。
### `surface-reading`
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
- 当前组件范围:
- Agent 消息详情、UI Envelope 内容和命令面板空状态。
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/feature-property-list.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面
语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel`、`surface-control` 或 `surface-reading` 来建立界面层级。
| 类名 | 含义 | 当前使用位置 |
|---|---|---|
| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 |
| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 |
| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 |
| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 |
| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 |
## 未使用样式的建议落点
### `surface-dock`
建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用:
- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。
- `ScheduledConditionFeed` 或 `ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。
- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。
如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`。
### `material-tone-danger`
建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括:
- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。
- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。
- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`。
- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。
不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。
### `material-tone-agent`
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。
## 共享类映射
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
| 共享常量 | 实际材质类 | 典型用途 |
|---|---|---|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 |
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 |
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 |
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 |
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 |
## 自适应与无障碍规则
- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。
- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。
- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。
- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。
- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。
## 使用约束
1. 一个浮动区域只允许最外层使用 `acrylic-*` 或 `glass-transient`。
2. 外层亚克力内部使用 `surface-control`、`surface-well` 或 `surface-reading`,不要叠加第二层模糊。
3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。
4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。
5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。
6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。
## 当前待清理项
- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。
- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。
- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。
- Agent 面板仍有少量局部类直接使用 `--surface-control`、`--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。
+42 -8
View File
@@ -1,5 +1,7 @@
# 本地交互式回答
统一样式见 [样式设计指南](style-design-guide.md),聊天布局与报告交互见 [Agent 交互与报告设计](agent-chat-ui-design.md)。本文保留协议、组件参数与业务交互约定。
聊天保留 AI SDK `UIMessage`、AI Elements 与现有 Agent 后端,新增
`@json-render/core` / `@json-render/react` 的本地注册组件。
组件代码随前端构建发布,无 ChatKit iframe、domain key 或 Vercel/OpenAI
@@ -9,7 +11,8 @@
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表;
证据可折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问;
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
@@ -29,13 +32,44 @@ Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之
后台调查执行继续走任务报告与任务展示通道,聊天界面块不构成业务完成或浏览器回执。
## 正式报告与主工作区
正式报告可携带 `ui_blocks?: Array<{ block: AnswerUiBlock; evidence_refs: string[] }>`,最多 8 块,总 UTF-8 JSON 不超过 400000 字节。每块绑定报告中的已有证据,Server 验证任务归属;事实字段与审批不由 UI 块替代。组件树与聊天共用 `answer-ui@1`。例如:
```json
{
"ui_blocks": [{
"evidence_refs": ["saved-evidence-id"],
"block": {
"version": "answer-ui@1",
"block_id": "pressure-summary",
"fallback_text": "已保存记录中的压力为 0.30 MPa。",
"spec": {
"root": "pressure",
"elements": {
"pressure": { "type": "Metric", "children": [], "props": {
"label": "压力", "value": "0.30", "unit": "MPa", "source": "已保存监测记录"
} }
}
}
}
}]
}
```
此片段仅展示新增字段,发布时仍需完整报告与真实存在的证据编号。保存沿用 `agent-artifacts`、报告 JSON、幂等键和 revision;读取沿用报告列表、指定版本、任务详情及 Agent context,不新增写入端点或数据库表。
`InvestigationReportBlocks` 将正式报告接入 `AnswerUiView`,证据组件使用保存记录中的摘要、来源、时间与类别。未知版本或无法解析的块展示 fallback,报告固定事实仍可读。报告追问携带 `report_revision`、`reportGeneration` 和引用文本;表单先带入会话,确认后才发送。
`ExpandableReading` 将同一个 React 内容实例移至主工作区,保留图表视图、搜索、行选择、表单草稿。关闭恢复入口焦点,重开恢复阅读位置。主工作区宽度至少 960px 可对照地图,其余宽度在两者之间切换;原地图实例保留。
## 扩展组件
1. 在 `src/features/agent/answer-ui/catalog.ts` 增加严格的 Zod 参数定义、用途描述与必要的数据上限。
2. 在 `answer-ui-view.tsx` 注册 React 组件。需要工作台交互时通过 Agent 公共 API 的回调接入,保持依赖方向。
3. 从本仓库运行 `pnpm export:answer-ui-contract`,更新相邻 Agent 仓库的
`src/chat/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随两边一起交付。
4. 如新组件存在跨字段约束,同时更新两端的协议校验并加边界和交互测试。
`src/chat/answer-ui-catalog.json` 和 Server 的 `contracts/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随三端一起交付。
4. 如新组件存在跨字段约束,同时更新三端的协议校验并加边界和交互测试。
导出脚本接受可选目标文件:
@@ -50,8 +84,8 @@ node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-cat
## 校验边界
两端拒绝未知组件、额外属性、任意脚本/事件定义、缺失引用、循环、重复或游离节点。
每块最多 48 节点、深度 12、约 100 KB,图表必须对齐且数值有限;表格每行与列数
三端拒绝未知组件、额外属性、任意脚本/事件定义、缺失引用、循环、重复或游离节点。
每块最多 48 节点、深度 12、完整序列化结果不超过 100000 个 JavaScript UTF-16 代码单元,图表必须对齐且数值有限;表格每行与列数
一致,表单字段名唯一。组件只消费明确的 props,没有动态代码、网络 URL 或任意动作 DSL。
无法解析的历史内容显示该块的 `fallback_text`。
@@ -61,7 +95,7 @@ node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-cat
pnpm lint
pnpm typecheck
pnpm test src/features/agent/answer-ui src/features/workbench/hooks/agent-session-message-state.test.ts
pnpm test:browser tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-start-page.e2e.ts
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 build
```
@@ -77,8 +111,8 @@ bun run tests/integration/answer-ui-smoke.ts
事件验证组件、更新、历史恢复、键盘展开证据、表单提交和 320/375px 布局,不能当作
真实模型已正确选择组件或现场资料已核验的证明。
前端与 Agent 需一起更新,Agent 进程需重新加载原生插件。无需新增环境变量、
domain key 或数据库迁移。当前开发实例通过现有监听机制重新加载;既有聊天与调查工作进程保留。
前端、Agent 与 Server 需同步更新;Agent 进程需重新加载原生插件,Server 需加载新报告 schema。无需新增环境变量、
domain key 或数据库迁移。本轮不重启既有服务;浏览器演练和本地测试不代表运行中的服务已完成同步。
## 真实模型验收(显式启用)
+204
View File
@@ -0,0 +1,204 @@
# Agent 交互与报告设计
本文维护供水前端 Agent 对话、正式调查报告和展开阅读的功能、布局与业务交互。组件基础、配色、材质、排版和无障碍样式统一遵循 [样式设计指南](style-design-guide.md),阶段验证结果见 [实现记录](history/agent-chat-ui-implementation-notes.md)。
Agent 相关弹窗、面板及菜单统一保持本地 shadcn/ui 设计,具体外观规则由样式设计指南维护。本文规定的历史全屏、展开阅读、审批和主题管理等布局均遵循该规则。
## Intelligent UI 的目标
本文中的 Intelligent UI 参考 [ChatGPT Intelligent UI](https://help.openai.com/en/articles/20001598-intelligent-ui-in-chatgpt):回答根据内容选择交互呈现,用户在阅读中探索数据、核查来源并继续提问。Vercel AI Elements 和 shadcn/ui 提供组件与布局参考,功能通过本项目本地组件实现。
当前落地为“左侧对话 + 主工作区”。聊天回答与正式调查报告共用指标、图表、表格、证据、定位和资料表单。复杂内容由用户点击展开到主工作区;调查状态、结论边界、证据、审批和版本仍由 Server 保存的事实驱动。模型只在注册组件中选择呈现方式。
## 功能梳理
以当前组件、回调和消息契约核对功能,再决定它们出现的位置,不继承原有界面分区。
| 功能组 | 已有能力 | 新布局中的位置 |
| --- | --- | --- |
| 会话管理 | 新建、历史加载、重命名、删除、折叠和关闭 | 顶部会话栏,历史通过独立窗口按需打开 |
| 上下文 | 会话标题、主题选择及管理 | 主题收于助手导航区;会话标题独占下方宽度,最多两行 |
| 输入 | 多行文字、引用报告或任务、语音输入、草稿 | 底部统一输入区,空会话使用独立欢迎状态 |
| 模型与权限 | 模型选择、审批模式 | 输入组件底栏,保留当前选项反馈 |
| 运行控制 | 停止、运行中引导、排队消息、投递方式和未消费消息撤回编辑 | 输入组件顶部的紧凑队列与底栏发送方式 |
| 消息阅读 | 流式文字、Markdown、历史恢复、滚动跟随 | 主阅读流,用户消息与助手回复分层 |
| 过程与状态 | 执行进度、工具结果、待办、错误、连接状态 | 回复内的过程区域及顶部真实连接状态 |
| 人工参与 | 权限审批、问题回答和跳过、补充资料 | 对应回复内保留可操作表单,确认前不执行 |
| 分析结果 | 指标、表格、折线和柱状图、证据 | 回复中的交互内容,可搜索、排序、选点、筛选序列及已加载区间 |
| 展开阅读 | 聊天回答与正式报告进入主工作区 | 明确点击进入,保留唯一组件实例、筛选、表单草稿和阅读位置 |
| 内容追问 | 指标、数据点、表格行、证据 | 带来源的可删除引用;正式报告另带版本和轮次;确认后发送 |
| 正式报告 | 当前结论、结论边界、证据、缺口、待办、审批与历史版本 | 固定事实区与可选交互分析模块组合 |
| 地图联动 | 定位实际要素、显示定位结果 | 对应内容内的明确按钮 |
| 回复工具 | 复制正文及交互摘要、朗读和选段朗读 | 回复底部低强调操作及文字选择入口 |
| 任务协作 | 任务引用、报告轮次、协作问答模式 | 输入引用区和回复依据,保留协作语义 |
普通用户的阅读路径为:确认当前项目,提出问题,阅读回答,核查来源,再按需要定位地图或补充资料。审批和队列在有实际内容时出现,历史与处理过程按需展开。
运行中的输入区使用一个外框:顶部显示待处理消息,正文最多预览两行,溢出时提供“展开全文”;每条消息只保留正文、简短的处理方式和一个菜单。编辑、处理方式切换、关闭排队统一进入该菜单。底栏在生成期间用“引导 / 排队”替代暂时不能切换的模型选择,生成完成后恢复模型选择。右下角的同一个按钮在运行且输入为空时停止,有内容时发送追加消息,空闲时发送新消息。输入正文默认高 64px,随多行内容自动增高至视口高度的 24%,最多 160px;超出后在输入区内部滚动,清空后恢复 64px;队列单独限制滚动高度,工作台支持的窄屏和短窗口保留可见的输入操作。
## 1. 交互参考
[AI Elements](https://elements.ai-sdk.dev/) 基于 shadcn/ui,提供组合式的 AI 应用组件。它将对话容器、消息、输入、过程、来源和工具状态拆成独立组件,适合在同一条消息中呈现文字与交互内容。官方当前提示组件正在迁入 shadcn/ui;本项目维护本地源码,参考交互方式,按需要适配组件。本文不要求整体替换现有依赖。
| 官方参考 | 采用的设计方式 | 本项目对应内容 |
| --- | --- | --- |
| [Chatbot 示例](https://elements.ai-sdk.dev/examples/chatbot) | 以消息阅读为主体,输入区独立,辅助操作低强调 | 左侧会话布局、空会话入口和回复操作 |
| [Conversation](https://elements.ai-sdk.dev/components/conversation) | 自动跟随消息,离开底部后提供返回入口 | 保留本地滚动管理与“滚动到最新消息” |
| [Message](https://elements.ai-sdk.dev/components/message) | 用户与助手分层,正文、流式 Markdown、操作栏组合 | 用户气泡、开放式助手正文、复制和朗读 |
| [Prompt Input](https://elements.ai-sdk.dev/components/prompt-input) | 输入、模型、工具和发送组成同一个输入组件 | 统一输入面、权限模式、模型、语音、停止与发送 |
| [Reasoning](https://elements.ai-sdk.dev/components/reasoning) | 生成过程可展开,完成后收起 | 已有执行摘要与“查看处理过程” |
| [Tool](https://elements.ai-sdk.dev/components/tool) | 工具状态与输入、输出按需展开 | 真实执行进度、审批与用户问题 |
| [Sources](https://elements.ai-sdk.dev/components/sources) | 来源单独呈现,详情按需展开 | 来源名称、证据类别、记录时间及资料编号 |
| [shadcn/ui Tabs](https://ui.shadcn.com/docs/components/radix/tabs) | 同一内容的视图用键盘可操作的页签切换 | 图表折线、柱状、原始数据切换 |
用户消息与助手回复分层组织,图表、证据与资料输入按内容用途组合。交互内容表达已有数据和可执行动作,不用虚构指标或装饰性进度制造智能感。
## 2. 组件规则
| 区域 | 组件组合 | 展示与行为 |
| --- | --- | --- |
| 消息列表 | AI Elements `Conversation`、`Message`、`MessageContent` | 用户右对齐,助手使用完整阅读宽度 |
| 助手标识 | 共享 `AgentPersona`、shadcn `Badge` | 角色、真实回复状态及交互内容标记 |
| 回复操作 | AI Elements `MessageActions`、`MessageAction` | 复制正文与结构化摘要,保留朗读能力 |
| 指标 | shadcn `Card`、`CardContent` | 名称、值、单位、来源;同组指标使用紧凑对照条,提问入口采用有名称的图标按钮 |
| 图表 | `Card`、`Tabs`、`Collapsible`、现有图表渲染器 | 折线、柱状、数据三种视图;范围筛选按需展开,收起时仍显示已选范围 |
| 表格 | 本地 shadcn/ui `Table` 组件组合 | 列头、标题、数值、键盘可达的滚动容器 |
| 证据 | `Collapsible`、`Button`、`Badge` | 收起时可读来源与类别,展开显示完整摘要和编号 |
| 地图定位 | `Button`、位置图标 | 由用户点击触发,显示依据和实际定位结果 |
| 补充资料 | `Input`、`Textarea`、`DateTimeField`、`Button` | 从空值开始,明确必填和提交状态 |
| 历史对话 | `Dialog`、`Input`、会话列表 | 跨主题标题及正文搜索、时间分组、游标分页与会话操作 |
| 模型与主题 | `DropdownMenu` | 模型选择、主题归类与管理;数据权限跟随当前业务项目 |
| 报告目录 | `nav`、`Button` | 展开阅读时固定于阅读区顶部,仅显示实际章节;支持键盘跳转和滚动位置提示 |
| 工作区分栏 | `ResizablePanelGroup`、`ResizablePanel`、`ResizableHandle` | Agent 与主工作区、地图与报告均可拖拽;分隔线可通过 Tab 聚焦,方向键及 Home / End 调整宽度 |
尺寸、圆角和交互状态按样式设计指南选用共享变体。尚未接入的附件、网页搜索和回答重试不展示可点击占位按钮。
## 3. 布局
```text
桌面
┌───────────────────────┬──────────────────────────────────────┐
│ Agent 对话 │ 主工作区 │
│ 会话 / 上下文 │ 默认地图 │
│ 消息、交互摘要 │ 点击展开 → 报告与分析 │
│ 待答问题与权限审批 │ 宽度足够 → 地图对照 / 专注阅读 │
│ 队列摘要 / 引用 / 输入│ 宽度不足 → 查看地图 / 返回报告 │
└───────────────────────┴──────────────────────────────────────┘
```
主工作区可用宽度至少 960px 时开放地图与报告双列;较窄窗口在地图与报告之间切换。地图实例保留,不重新初始化。手机只显示当前阅读面;展开时保留原抽屉内容实例,收起后恢复。
2026-10-21 分栏实现更新:采用 [shadcn/ui Resizable](https://ui.shadcn.com/docs/components/radix/resizable),通过本地 [resizable.tsx](../src/shared/ui/resizable.tsx) 封装 `react-resizable-panels` v4。外层直接承载 Agent 和主工作区,替代手写 Pointer Events 拖拽;内层直接承载地图和报告,替代固定的双列 Grid。
Agent 保留 400px 最小宽度与按窗口计算的最大宽度,上限 640px。收起、临时缩窄窗口及恢复桌面后沿用本次页面内的用户选择。地图与正式报告默认等分,每栏至少 360px;已有分析成果沿用每栏至少 600px 的限制。切换专注阅读后再次分栏,恢复上次调整的比例。手机隐藏分隔线,继续使用抽屉和地图 / 报告切换。尺寸记忆保存在当前页面内,不写入持久化存储。
两级分栏的容器保持挂载,地图 canvas 跟随实际容器尺寸更新;报告内容继续通过单实例移动保留草稿和阅读位置。手柄使用浅灰细线与可见拖拽标记,键盘焦点使用主题蓝色轮廓;连续调宽不附加布局过渡动画。
展开操作移动同一个内容实例,避免两份表单、重复提交和草稿分歧;关闭或 Escape 后回到原入口并恢复键盘焦点。阅读位置随文档保留。报告版本改变时切换到该版本自己的内容状态。会话或报告卸载时关闭对应工作区。
```text
Agent 面板
├─ 会话栏:助手标识 / 连接状态 / 新建与历史 / 折叠或关闭
├─ 上下文条:会话标题 / 主题
├─ 可滚动消息区
│ ├─ 用户问题
│ └─ 助手回复
│ ├─ 角色和真实生成状态
│ ├─ 正文与指标、趋势、表格
│ ├─ 证据、地图入口、资料表单
│ ├─ 可展开的处理过程
│ └─ 复制与朗读
└─ 输入区:引用 / 多行输入 / 权限、模型、语音、发送或停止
```
纯指标的双列 `Stack` 合并为紧凑摘要区,容器宽度达到 240px 即可并排;名称、原始数值、单位和来源完整保留,不用省略号截断。指标提问按钮放在名称旁,减少单独操作行。混合内容的双列仍使用 440px 断点。助手正文直接进入消息流,证据按需折叠,补充表单保留独立操作区;表面与间距统一引用样式设计指南。
图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。
正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。
空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史或已有任务状态、审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。
输入文字区初始高度 64px,按内容增长至视口允许的高度。首次发送后同一个输入组件回到底部;新建空会话后恢复居中布局。使用 `motion/react` 的位置布局动画处理这两种状态,沿用项目的 180ms 缓动,不缩放输入文字,也不对 Resizable 连续调宽添加动画。系统开启减少动态效果时直接切换位置。表单实例和草稿在布局变化时保留。
推荐由现有接口生成,排列在首次输入框下方,使用低强调的圆角按钮,窄屏自动换行显示完整问题,实际来源仍可通过 hover 或键盘焦点提示查看。点击发送完整原始问题。加载或生成失败时显示简短状态,也不影响普通输入。材质与边界复用共享样式,输入底栏保留现有操作。
Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv),通过本地定制的 `obsidian-water-v1.riv` 使用已确认的 02「冰水蓝」材质并增加离线水纹,以高遮蔽度的浅冰蓝底色和白色高光保留柔和羽化、流动反射与不规则边缘水纹。完整视觉与交互规格见 [Persona 设计](agent-persona-design.md)。默认形象与悬浮入口使用 64 × 64px,画布填满点击范围;入口距地图左侧、顶部导航下沿均为 12px,由同一层内边距控制。桌面及手机面板标题栏中的形象也使用 64px,手机地图入口使用 40px。三处通过同一个 `AgentPersona` 组件显示,保留原版资源自身的羽化留白。悬浮形象不附加状态文案、状态点、箭头、卡片或按钮底圈。完整业务状态继续通过按钮无障碍名称与原生悬停提示提供,点击或 Enter / Space 展开对话。
标题区分为导航与标题两层。导航行高 80px:左侧为 64px persona,其右侧首行并列助手名称与连接状态,状态位于名称右侧;下方间隔 6px 放置主题下拉,使用文件夹图标与名称,不再另列“主题”标签。连接状态保留共享状态点和文字,主题按钮使用本地 `Button` 的 `outline` / `sm` 变体,高 32px,使用文件夹和 Lucide 上下切换箭头。右侧集中放置新建、历史及折叠或关闭按钮,命中范围均为 40px。导航与会话标题之间只保留一条本地 `Separator`,使用 1px、`bg-border/40` 的低对比度分界线。默认空白会话不渲染“新对话”占位标题,也不保留空标题行。有实际会话时,标题区四周留白 12px,只显示当前会话标题,独占宽度、最多两行,不再增加下边框。长主题名在导航剩余空间内收缩,完整名称通过提示与可访问描述保留;320px 手机宽度下不挤压三个操作按钮。
在线动画由 Rive WebGL2 播放原资源的 `default` 状态机。沿用原生 `thinking`、`listening`、`speaking` 和 `hover` 控制项,状态过渡及水纹细节由资源自身完成。原版公开组件可见 [AI Elements Persona 源码](https://github.com/vercel/ai-elements/blob/main/packages/elements/src/persona.tsx);图形制作数据位于运行资源中,可编辑设计文件是 `.rev`,见 [作者的定制说明](https://elements.surge.studio/docs/customization)。已移除之前自行生成球缘曲线、光带和局部高光的实现,也移除改写原版明暗的 CSS 滤镜。配色直接写入 Rive 材质,暗部保持蓝色倾向,高光使用白色。
离线通过同一 Rive 资源中的 `asleep` 状态呈现“静水待机”:关闭外围三层光圈,保留完整蓝色球体及内部柔光的低速、小幅度流动。在线与离线切换保持同一个动画实例,通过资源内的状态混合衔接,恢复连接后回到在线光圈与节奏。参数和图层说明统一维护在 [Agent 动画资源说明](agent-persona-resource.md)。首次加载、加载失败和系统开启减少动态效果时显示从当前资源渲染的在线或离线静态帧,离线静态帧同样不带外围光圈;兜底形象与按钮功能持续可用。
鼠标悬停沿用原生 `hover` 动画和光圈尺寸;进入、移动、离开坐标通过共享组件交给 Rive 原生监听,使白色高光跟随鼠标局部移动,避免只开 hover 却使反射固定在中心。移出后恢复常态。球体组件移除 Motion,取消按压缩放、弹簧回弹和明暗变化;触摸按下不触发额外动画。点击、触摸轻点及 Enter / Space 继续展开对话,离线时同样可用。系统开启减少动态效果时显示静态帧,不初始化 Rive。强制颜色模式显示高对比圆形轮廓,保留键盘操作。球体动画与面板布局独立,不对 Resizable 连续调宽增加动画。
渲染生命周期继续遵守 [Rive 内存问题记录](rive-persona-incident.md):延迟一帧初始化,元素离开视口或页面隐藏时卸载渲染实例,不绑定 View Model;仅缓存不可变的 `.riv` 字节,展开、折叠和不同入口复用一次成功下载。失败下载不永久缓存,后续挂载可重试。动态组件准备完成前保留本地静态帧,原生进入动画在显示切换前完成。静态帧与动画通过 180ms CSS 透明度渐变衔接,结束后才隐藏静态帧,避免首次加载瞬间切换造成的跳动;画布的位置与尺寸始终固定。本轮没有新增依赖,也没有恢复已知存在内存问题的颜色绑定。
### 离线动画的资源改造
已解析原版的图形、属性、关键帧和状态机,保存为可读的运行时 JSON,并验证可以逐字节恢复原始 `.riv`。修改后的资源保留 5 个原生状态输入,在原有 20 段动画后增加 3 段离线循环,修正 `asleep` / `wake` 中隐藏和缩小球体的关键帧。修改、再生成及保真验证见 [Agent 动画资源说明](agent-persona-resource.md)。这份 JSON 是运行时数据,不是完整恢复的 `.rev` 编辑器工程。
### 历史窗口、缓存与后台会话
“主题”参考 [ChatGPT Projects 的会话组织方式](https://help.openai.com/en/articles/10169521-projects-in-chatgpt),作为会话组织单元,用于归集相关事件和问题;与决定数据权限的业务项目分开。Agent 的主题下拉参考 shadcn [TeamSwitcher 模板](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/blocks/sidebar-07/components/team-switcher.tsx)的起始对齐、触发器宽度与最小菜单宽度,配合本地 `DropdownMenuRadioGroup` 表示主题单选。菜单标注“切换主题”,主题列表独立滚动,新建与管理操作保留在底部;支持未分类、主题选择、新建和管理;原无处理逻辑的“新建项目”入口移除。Agent 默认访问当前工作台业务项目的数据,移除聊天面板中的“个人/项目数据”切换入口及前端个人会话分支。数据访问权限由项目身份和服务端授权决定,后续按角色限制可访问范围,主题选择不改变数据权限;业务项目切换仍从工作台入口进入。选择主题开启该主题的新对话,当前会话草稿和后台生成继续保留;新建对话沿用当前主题。主题名可修改,删除主题时会话保留为未分类;历史行操作可将已有会话移入其他主题或移出为未分类。主题列表内存缓存 5 分钟,登录和业务项目隔离,并发复用。旧会话默认未分类,不移动现有业务数据。本轮主题用于组织记录,不自动合并其他会话上下文、共享指令或附件。
主题新建、重命名、管理与删除确认使用标准 `DialogHeader`、`DialogDescription`、`Input` 和 `DialogFooter`。表单显示名称标签、80 字符限制、错误与取消/提交按钮;管理页使用可滚动的平面列表,每行保留重命名与删除入口,底部提供新建与完成。重命名和删除进入有明确标题的独立步骤,删除前确认会话保留规则。打开表单聚焦名称输入,打开管理页聚焦新建入口;失败保留输入,关闭后恢复主题按钮焦点。主题增删改先完成共享列表读取,再更新服务端与缓存,防止旧列表请求晚返回覆盖刚创建或修改的主题。
历史按钮打开独立模态窗口,桌面最大宽度 760px、高度不超过视口的 80%;小于 640px 时使用全屏窗口,保留安全区和独立滚动。窗口包含当前业务项目、标题及正文搜索、主题筛选、时间分组、会话状态和关闭入口。标题最多两行,重命名和删除进入每行操作菜单,删除需明确确认。选择会话成功后关闭窗口,失败时保留当前聊天与列表并显示错误。关闭、Escape 及取消操作恢复触发按钮焦点;模态期间底层地图和通知不覆盖窗口。
会话列表每页 30 条,标题及可见消息正文搜索在服务端覆盖当前用户及当前业务项目下的全部主题及未分类记录,结果显示所属主题和正文命中摘要,输入防抖 250ms,按更新时间及 ID 排序;时间按浏览器本地时区分为今天、昨天、近 7 天和更早。窗口浏览期间保持条目顺序,运行状态原位更新;有新的排序结果时由用户点击更新。关闭窗口保留已加载页和搜索缓存。历史统一包含普通对话和手动任务协作,不展示后台执行会话。主题筛选独立于搜索词,默认“全部主题”,不随当前对话主题隐式收窄。
正文首次读取最近 40 条,向上滚动或点击“加载更早消息”继续读取。插入旧页保持当前可见消息的位置。用户向上阅读后停止自动跟随,点击返回最新消息恢复跟随。恢复流也使用最近消息页,客户端按稳定消息 ID 合并旧页和实时尾部,断线区间缺口通过分页补齐。服务端仍保留完整历史,续聊只发送本轮输入;UI 分页不截断原生会话和保存记录。当前磁盘存储仍整份读取 JSON,分页主要减少网络传输和浏览器渲染。
缓存属于当前登录生命周期,按业务项目隔离并跨工作台重挂载复用;退出登录后释放。历史列表和已读正文缓存 60 秒,授权项目列表 5 分钟,推荐问题 10 分钟,推荐生成失败缓存 30 秒。有效期内打开窗口和面板不重复请求。过期刷新保留已有内容,并发消费者共用请求,单个消费者关闭不取消共享请求。主动刷新及真实数据变更按需更新缓存,项目进入操作仍核验访问上下文。
推荐依据使用独立的近期会话页,历史搜索和翻页不改变输入。缓存摘要按活动 ID、标题、内容、状态及来源计算,单纯心跳时间变化不触发推理。生成新推荐时保留上一组问题。
每个会话持有独立 Chat 实例、生成连接、草稿、引用、阅读位置和审批状态。切换历史或新建对话保留原会话后台生成;后台需要审批或回答时,通过历史入口提示返回处理。模型、项目头和凭据续期绑定原会话。后台浏览器动作返回 `SESSION_NOT_ACTIVE`,提示用户回到会话后重新请求;切换时取消原会话尚在执行的浏览器动作。恢复失败保持可重试的连接异常状态,不将断流视为任务完成。切换业务项目由工作台统一处理,会话、主题及缓存按业务项目隔离。
## 4. 内容与状态
`answer-ui@1` 仍由严格参数校验与本地注册组件驱动。已有的 `Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、`MapLocation`、`FollowUpForm` 使用新外观。聊天继续使用现有 Responses 事件;正式报告增加可选 `ui_blocks` 字段,复用相同的内容块协议。实现契约见 [本地交互式回答](agent-answer-ui.md)。
生成中显示已经到达的文字、内容块和公开执行摘要;最终答复到达后,正文和它明确引用的内容留在主阅读流,其他记录进入处理过程。待回答问题、权限审批和失败状态保持可见,不能因最终文字到达而被隐藏。这里借鉴 Reasoning 的折叠行为,呈现的数据是服务端已发布的过程信息,不额外生成思考文本,也不根据耗时推算完成比例。
证据类别明确区分查询记录、用户提供、分析推断和演练数据。地图定位的按钮结果以实际回调为准;资料表单提交后只表示发送消息,业务证据是否保存取决于后端实际结果。最终回复不等于后台调查任务完成。
## 5. 实现边界
保留 Vite 和 React SPA。通用 AI 展示组件位于 `src/shared/ai-elements/`,基础组件位于 `src/shared/ui/`;消息业务组合位于 `src/features/agent/components/`,结构化回答位于 `src/features/agent/answer-ui/`。
不引入 Next.js 路由、服务端组件或第二套聊天接口。地图控制器、会话存储和审批流程沿用已有实现。报告内容扩展同时进入 Agent 校验、Server 校验、现有报告 JSON 存储和 OpenAPI。组件按需加入本地源码,避免批量覆盖已定制的共享组件。AI Elements 负责展示组合,现有 Responses transport 负责消息传输,两者的职责分开。
## 6. 响应式与业务验收
桌面验证 1280px 及以上窗口,并检查面板宽度 400px、640px 的真实布局;手机验证 320px、375px。手机 Agent 默认打开全高抽屉,输入区进入正常布局,阅读内容不藏在悬浮输入框下面;抽屉仍可手动调低查看地图。混合内容在手机上使用单列,短指标按容器宽度保留双列。输入、发送、模型菜单、证据展开和滚动返回均需可操作。
本轮聚焦验收包括图表页签与方向键、证据键盘展开、历史恢复、表单提交、复制回复、流式结束和面板缩放。检查文字与背景对比度、减少动态效果和强制颜色模式。运行 lint、类型检查、相关单测、聚焦浏览器测试;依赖变化额外运行构建。浏览器演练数据必须标明来源,截图不代表真实业务核验。
当前文档描述本轮目标与实现约定,实际通过的检查和预览截图以交付记录为准。
## 7. 交互规则与范围
| 内容 | 本轮实现 | 数据与操作边界 |
| --- | --- | --- |
| 指标 | 值、单位、来源,针对指标提问 | 不补造变化率、单位或精度 |
| 表格 | 搜索、三态排序、行选择、引用所选行 | 只处理当前数据;更换数据集时重置行选择 |
| 图表 | 折线 / 柱状 / 数据,序列显隐,加载区间,点击或键盘选点 | 最少保留一条序列;区间不触发查询或重算 |
| 证据 | 来源、类别、时间、编号,展开与类别筛选 | 正式报告使用已保存证据覆盖生成的摘要、来源和类别 |
| 地图 | 显式定位,地图对照或切换 | 使用真实定位结果与失败反馈 |
| 表单 | 空白初始值、必填校验、提交中禁用、失败保留 | 聊天表单主动发送;报告表单先带入对话,确认后发送 |
| 追问 | 输入区引用卡片、详情、移除 | 不自动发送;报告引用带选中的版本与轮次 |
| 历史内容 | 固定报告事实可读,不支持的块显示摘要 | 降级不执行未知组件或事件 |
本轮未包含参数重算、新数据查询控件、附件上传、导出、报告版本差异比较。后续扩展时需要独立定义参数校验、数据来源和执行反馈;不能把本地筛选当作重新分析。后台任务完成、业务审批和证据写入继续以各自服务端状态为准。
## 8. 正式报告契约
`InvestigationReport.ui_blocks` 可省略;每项包含 `block: AnswerUiBlock` 和 `evidence_refs: string[]`。最多 8 块,总 UTF-8 JSON 不超过 400000 字节;单块完整序列化结果不超过 100000 个 JavaScript UTF-16 代码单元,最多 48 节点、深度 12。每块绑定至少一条报告证据,`Evidence` 节点只能引用该块的依据。Server 发布时再检查这些证据确属当前任务。
固定报告字段不变:阶段、结果、结论、边界、数据缺口、证据和建议仍独立校验。新字段通过原有报告 JSON 保存,跟随 revision 与 execution_generation 读取;请求幂等沿用原有机制。旧报告缺省为空,不因缺省字段而产生虚假的报告更新通知。无需数据库表或环境变量迁移。
组件参数唯一来源为前端 `catalog.ts`,导出到 Agent 与 Server;跨字段约束在三端分别验证。Agent `publish_task_report` 使用正式报告通道,聊天 `present_answer` 使用聊天通道。实现与生成命令见 [本地交互式回答](agent-answer-ui.md)。
+100
View File
@@ -0,0 +1,100 @@
# Agent Persona 设计
更新日期:2026-10-22。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
## 1. 形象与材质
采用 Obsidian 原生 Rive 水纹球,保留柔和羽化、流动反射、不规则边缘水纹和状态机。配色采用已选定的 02「冰水蓝」:浅色水体承载白色高光,暗部保持蓝色,外围水纹提供地图上的辨识度。主体底色具有较高遮蔽度,减少地图道路穿过球体的现象。
| 角色 | 当前值 | 用途 |
| --- | --- | --- |
| 球面底色 | `#DCEFFA` | 浅冰蓝底层,独立于流动反射 |
| 底色不透明度 | 待机 94%,活动 98% | 保持主体清楚,避免背景纹理干扰 |
| 水体反射 | `#299FDF` | 提供蓝色层次与流动感 |
| 同色相暗部 | `#1482BC` | 保留体积,避免灰黑混色 |
| 高光 | `#FFFFFF` | 保留清晰、干净的反射 |
| 外围水纹 | `#4C9ECC` | 提高浅色地图上的辨识度 |
颜色直接写入 Rive 资源,不使用 CSS 色相、亮度或对比度滤镜。通用 UI 仍引用共享语义颜色;上表仅定义动画资源本身的材质。参数源为 [obsidian-material.json](../design/agent/obsidian-material.json)。
以下为当前资源的实际静态帧,用于辨认在线与离线外观;运动行为以第 4 节为准。
| 在线待机 | 离线静水待机 |
| --- | --- |
| <img src="../public/agent/obsidian-water-idle.png" width="64" height="64" alt="冰水蓝在线 Persona,有外围水纹"> | <img src="../public/agent/obsidian-water-offline.png" width="64" height="64" alt="冰水蓝离线 Persona,无外围光圈"> |
## 2. 尺寸与定位
| 使用位置 | 画布尺寸 | 布局要求 |
| --- | --- | --- |
| `AgentPersona` 默认 | 64 × 64px | 固定尺寸,不随状态变化 |
| 桌面收起入口 | 64 × 64px | 画布填满按钮命中范围 |
| 桌面与手机面板标题栏 | 64 × 64px | 与名称、状态共同形成身份区 |
| 手机地图入口 | 40 × 40px | 使用同一资源,适应地图工具栏 |
桌面收起入口距地图左侧 **12px**,距顶部导航下沿 **12px**。两个方向由同一定位层的内边距控制;当前导航高 56px,因此入口左上角为视口中的 `(12px, 68px)`。这里的顶部距离以导航下沿为起点。
尺寸指整个 Rive 画布,包含资源固有的羽化留白。放大时保留画板比例,不裁掉外围水纹。悬浮入口仅显示 Persona;状态通过按钮名称与悬停提示提供,避免增加卡片、状态点、箭头或底圈。
## 3. Agent 标题区
身份行高 **80px**,左右内边距 12px、上下 8px。左侧为 64px Persona,旁边上下排列名称“供水助手”和连接状态;右侧集中排列对话操作。
| 内容 | 规格与行为 |
| --- | --- |
| 名称 | 14px、半粗,与现有标题排版一致 |
| 连接状态 | 12px 辅助文字配共享 `StatusDot`;显示实际的“连接中 / 在线 / 离线” |
| 操作 | 新建对话、历史、折叠;手机末项为关闭,每项命中范围 40 × 40px |
| 上下文行 | 高 36px,保留会话标题与项目或个人范围切换 |
身份行与上下文行保持连续的浅色表面,在标题区底部使用分隔线。历史展开、键盘导航和关闭后焦点恢复沿用现有行为。320px 手机宽度下,名称、状态和三个操作均应完整显示且互不重叠。
## 4. 动画与状态
动画由 Rive WebGL2 的同一个 `default` 状态机驱动,业务层提供真实状态。在线水纹持续流动;离线保持完整球体,通过节奏与光圈变化表达连接不可用。
| 状态 | 呈现 |
| --- | --- |
| `idle` | 原生待机流动与外围水纹 |
| `thinking` | 原生处理状态动画 |
| `listening` | 原生等待输入动画,具体含义由业务映射决定,不单凭形象表示麦克风录音 |
| `speaking` | 原生输出状态动画 |
| `asleep` | 内部柔光低速、小幅流动,外围三层光圈透明度为 0 |
| `hover` | 鼠标附近的局部白色反射随指针移动,与当前业务状态叠加 |
离线设计称为“静水待机”。在线与离线以 **600ms** 混合切换,可见实例保持挂载;离线球体持续运动,恢复在线后恢复外围光圈。离线悬停也不重新打开外围光圈。
离线三层循环周期为 **15s / 19.25s / 31.375s**,位移与呼吸振幅为原来的 25%,完整旋转保持循环闭合。时间轴速率由 60 调至 24,约为原速 40%;此值不代表屏幕渲染帧率上限。详细图层参数见 [obsidian-offline.json](../design/agent/obsidian-offline.json)。
## 5. 悬停与点击
悬停沿用原版三层反射的尺寸、渐变、亮度和淡入淡出。白色高光跟随鼠标所在位置:从左侧移到右侧时,高光随之移动;鼠标离开后恢复常态。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。
共享 `Persona` 保留 Rive 原生指针监听,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。
点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。
## 6. 加载、降级与缓存
首次加载显示当前资源导出的静态帧。Rive 加载后先等待 600ms 完成原生进入动画,再以 **180ms CSS 透明度渐变**衔接,尺寸和位置保持固定。在线、离线分别使用相应的本地 PNG,避免银灰占位与蓝色动画之间的突变。
系统开启“减少动态效果”时显示静态帧,不初始化 Rive;资源失败时保留相应静态形象与按钮功能。强制颜色模式显示高对比轮廓,保留焦点和键盘操作。元素离开视口或页面隐藏时卸载渲染实例,可见后恢复。
客户端按页面内存、Cache Storage、网络的顺序取得资源。仅共享不可变 `.riv` 字节,每个可见入口独立创建动画实例;不共享实例或绑定 View Model。资源重建时同步生成 SHA-256 版本,URL 带版本参数,使旧缓存自然更新。缓存范围与校验逻辑见 [动画资源说明](agent-persona-resource.md)。
## 7. 实现与维护
| 入口 | 文件 |
| --- | --- |
| 业务形象与降级 | [AgentPersona](../src/features/agent/components/agent-persona.tsx) |
| 原生状态机接入 | [Persona](../src/shared/ai-elements/persona.tsx) |
| 桌面悬浮按钮 | [AgentCollapsedRail](../src/features/agent/components/agent-collapsed-rail.tsx) |
| 标题区 | [AgentCommandPanel](../src/features/agent/components/agent-command-panel.tsx) |
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
资源级检查使用固定时间步对照原版与修改版:在线状态保持相同材质下的逐像素一致;悬停检查左右移动时局部高光跟随指针,移出后完全恢复。工作台检查鼠标坐标实际到达悬浮入口和标题形象的 Rive 监听器,不能仅断言 hover 开关变化。离线检查完整球体、持续变化和光圈关闭。运行命令见 [资源说明](agent-persona-resource.md),各次实际验证结果记录在 [实施记录](history/agent-chat-ui-implementation-notes.md)。
+91
View File
@@ -0,0 +1,91 @@
# Agent 动画资源与离线水纹
Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比选定的 02「冰水蓝」配色,并在 Rive 文件内部增加“静水待机”离线设计。在线保留外围水纹光圈;离线关闭外围三层光圈,仅保留球体内部的缓慢流动。在线、离线和交互使用同一个 `default` 状态机。
当前视觉规格、尺寸、标题区与交互约定统一见 [Persona 设计](agent-persona-design.md)。本文维护资源结构、生成过程与缓存实现。
## 文件与再生成
| 文件 | 用途 |
| --- | --- |
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,879 字节资源 |
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
在前端仓库运行:
```bash
node scripts/build-agent-persona.mjs
node scripts/render-agent-persona-stills.mjs
node scripts/build-agent-persona.mjs --check
# 将原版还原到一个尚不存在的文件,应用资源不会被替换
node scripts/build-agent-persona.mjs --restore-original /tmp/obsidian-original.riv
pnpm test:browser tests/browser/agent-persona-resource.e2e.ts tests/browser/agent-orb.e2e.ts --workers=1
```
JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验证原始数据能逐字节恢复,并检查生成结果与应用资源一致。基线 SHA-256 为 `c6ae7d08f5787a560e38d3453ba155c28250acf2db75e6acf2b0d1a0cd6017db`。替换上游基线时需重新解析和验证,不应只修改散列值跳过检查。
配色编辑 `obsidian-material.json`,其中 `bodyColor` 只控制球面底色,`bodyOpacity` 控制它在待机/活动时的遮蔽度,其他蓝色反射沿用 `colors` 映射;离线动画编辑 `obsidian-offline.json`;重新生成 `.riv` 和静态帧后刷新前端。静态帧脚本使用已安装的 Playwright、esbuild 与 Rive WASM,无需访问外部动画服务。`fps` 控制离线时间轴速度,`amplitude` 控制位移与缩放的变化幅度,`transitionMs` 控制在线/离线的混合时长。各层 `opacity` 中的 `rest` 和 `crest` 是相对原始透明度的比例,分别对应循环开始/结束与中点。数值需满足 `0 ≤ rest ≤ crest ≤ 1`;保留循环端点一致,避免接缝。外围三个光圈均固定为 `0`。
`--restore-original` 使用只创建模式,目标文件已存在时会报错,避免覆盖已有编辑。原版还原文件与基线逐字节相同,包含原版离线消失的行为;前端继续加载带离线设计的应用资源。
## 冰水蓝与离线设计
材质采用经原生 Rive 预览选定的 02 方案:水体 `#299FDF`、同色相暗部 `#1482BC`、白色高光 `#FFFFFF`、外围水纹 `#4C9ECC`。外围水纹从初选时的 `#B0E3FF` 加深,以提高浅色地图上的辨识度;光圈粗细、渐变 alpha 和运动保持原定义。球面底色独立设置为 `#DCEFFA`,待机/活动时的不透明度为 94%/98%,同步调整对应的活动状态关键帧。浅色且高遮蔽度的底层减少地图道路穿过球体的现象,蓝色反射仍由原来的运动图层呈现;暗部沿用蓝色调,避免叠加灰黑阴影。渐变位置、羽化、裁剪、运动曲线及其他节点的原有 alpha 保留,不添加 CSS 滤镜或 View Model 绑定。
此前 A「水务蓝」使用灰蓝主体、浅蓝高光与 78%/90% 的底色不透明度,在白底实际动画中显得发灰、浑浊,已被本方案替换。后续调整以同一背景下的真实动画及 40/64px 入口效果为准,不以色卡单独判断材质。
离线以“水面逐渐平静”表达连接不可用:外围三层光圈在 600ms 混合期间消退至完全不可见,内部柔光降低并保持缓慢流动。球体整体透明度和缩放保持完整;悬停不会重新打开离线光圈。恢复在线时重新显现光圈与在线节奏。业务状态仍由入口按钮的可访问名称和提示提供。
| 层 | 柔光强度 | 反射强度 | 循环周期 |
| --- | --- | --- | --- |
| 内层与下侧柔光 | 56%–68% | 0,关闭 | 15 秒 |
| 中层与内部柔光 | 48%–62% | 0,关闭 | 19.25 秒 |
| 外层与上侧柔光 | 60%–72% | 0,关闭 | 31.375 秒 |
上表是设计参数当前值的解释。内部柔光以原版贝塞尔缓动从 `rest` 升到 `crest` 再回落,周期错开,避免同步闪烁;光圈透明度保持为零。40/64px 入口共用同一资源,状态切换不改变布局尺寸。
参数中的图层名称是根据运行时结构补充的语义别名,不是恢复出的作者原始命名。`objectId` 是画板组件编号,不能用 JSON 对象数组的索引替代:绑定记录不占组件编号。
## 解析结果与修改
原版有 1 个 `Obsidian` 画板、20 段动画、13 个状态机层和 5 个布尔输入。`asleep` 动画在第 45 帧将节点 4 的透明度从 1 降至 0,并将节点 3、190、199、208 的缩放降至 0.6–0.8,因此离线时球体消失。
修改版保留原有图形、渐变结构、羽化、裁剪和在线运动,改变以下部分:
- 根据已选定的配色映射 RGB,并独立设置底色填充节点 189 的颜色、节点 186 的静止及活动透明度;原始 JSON 不改动,仍可完整还原银灰原版 `.riv`。
- `asleep` / `wake` 的透明度与缩放关键帧保持 1,避免隐藏或缩小球体。
- 在原有 20 段动画后追加 `offline_loop_1`、`offline_loop_2`、`offline_loop_3`,保持原有动画编号。
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。其他输入、状态层和交互保持原定义。
生成结果包含 957 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
## 前端接入与生命周期
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 向画布转交鼠标的进入、移动和离开坐标,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
读取顺序为页面内存、当前版本的持久缓存、网络。下载及缓存命中均检查字节数和 `RIVE` 文件头;支持 Web Crypto 时进一步验证 SHA-256。损坏缓存重新下载,错误页或不匹配的资源不写入缓存。新版本成功保存后,只删除本缓存中该资源路径的旧版本,不清理其他应用数据。重新生成动画时同步更新版本清单,`--check` 同时验证资源与清单。
持久缓存不可用、被禁用或空间不足时仍可下载并在当前页面复用。Cache Storage 需要安全上下文,生产环境使用 HTTPS,本机 localhost 开发可用;浏览器清理站点数据也可能移除缓存,见 [Cache API 说明](https://developer.mozilla.org/en-US/docs/Web/API/Cache)。缓存测试覆盖真实页面刷新,并阻断资源网络路径验证动画仍能播放。
Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.riv`,Rive WASM、应用代码与其他资源沿用各自的加载策略;不构成整站离线支持。浏览器完全断网且应用或运行时尚未加载时,仍可能无法播放动画。
在线与离线切换不卸载可见的 Rive 实例,由状态机处理过渡。静态帧仅用于首次加载、资源失败、减少动态效果及未挂载动画的情况:在线使用 `obsidian-water-idle.png`,离线使用无外围光圈的 `obsidian-water-offline.png`。两张 192px PNG 均由当前 Rive 材质直接渲染,URL 携带资源版本;首次加载维持 180ms 淡入,不再出现银灰占位图。保留可见性卸载、Strict Mode 延迟初始化和原版悬停;没有重新加入 Motion 按压效果或 View Model 绑定。历史约束见 [Rive 内存记录](rive-persona-incident.md)。
## 验证边界与来源
运行时 JSON 能恢复当前 `.riv`,但不等于作者的 `.rev` 编辑器工程;导出时省略的编辑信息没有凭空恢复。原作者提供可编辑 `.rev` 的说明见 [资源定制文档](https://elements.surge.studio/docs/customization),格式差异见 [Rive 备份说明](https://rive.app/docs/editor/exporting/exporting-for-backup)。
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
-96
View File
@@ -1,96 +0,0 @@
# 新增业务组件设计规范
本规范覆盖调查任务、运行控制、人工复核、外围样板、日期时间与共享表单组件。地图仍是主工作区,侧栏负责定位当前任务、显示状态、给出下一步操作和按需展开证据。
## 1. 视觉主题
方向为宝石蓝操作、银灰亚克力、少量香槟重点的浅色专业调度台。界面保持高信息密度,使用稳定的标题、状态、操作、证据顺序。沿用既有字体、布局、圆角和 acrylic 材质,以透底、色调和轻微背景色差区分导航、面板、分组与阅读区。
## 2. 色彩角色
采用同色相色阶与低彩度互补色规则。宝石蓝 `#4263C7` 转换为 OKLCH 后约为 `L=0.529317, C=0.160186, h=267.059148°`;中性材质和文字保持该色相,香槟辅助色使用 `(h + 180°) mod 360°`。色阶先计算后转换为 sRGB HEX,均处于 sRGB 色域。透明度不参与色板生成。
| 角色 | L | C | h | HEX |
|---|---:|---:|---:|---|
| 宝石蓝主色 | 0.529317 | 0.160186 | 267.06° | `#4263C7` |
| 银霜材质 | 0.935 | 0.012 | 267.06° | `#E6E9F2` |
| 冷瓷阅读面 | 0.975 | 0.005 | 267.06° | `#F5F7FA` |
| 香槟辅助色 | 0.820 | 0.065 | 87.06° | `#D6C294` |
| 深墨文字 | 0.280 | 0.025 | 267.06° | `#232935` |
完整色板见 [宝石蓝配色](gem-blue-palette.svg)。
- `--surface-navigation`:宝石蓝 8% 与银霜在 OKLab 空间混合的导航色调。浅色混色使用 OKLab,避免低彩度颜色在色相插值时偏色。
- `--surface-panel`:银霜色调;`--surface-well` 为其半透明次级面。
- `--surface-subtle`:`L=0.950, C=0.010, h=267.06°`,得到 `#EBEEF5`,供桌面 dock、分组和普通控件使用。
- `--surface-reading`:冷瓷正文、表单和报告阅读面,保持较高遮蔽度。
- `--action-selection-soft`:`L=0.920, C=0.035, h=267.06°`,得到 `#DAE5FD`,文字使用深宝石蓝。
- `--action-blue`:主操作 `#4263C7`;hover 将 L 降低 0.055、C 乘 0.95,得到 `#3653B0`;pressed 将 L 降低 0.105、C 乘 0.90,得到 `#2B469C`。
- `--surface-border` / `--surface-divider`:保持同色相,分别使用 `(L=0.815, C=0.015)` 和 `(L=0.885, C=0.012)`,得到银灰边界 `#BEC2CD` / `#D5D9E1`。
- `--action-focus-ring`:深宝石蓝焦点环;`--ink-muted`:`L=0.450, C=0.030, h=267.06°`,得到辅助文字 `#4E5566`。
- `cyan-700`:外围样板入口及其来源标识。
- `emerald`:已完成、已通过。
- `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。
- `rose`:失败、冲突或破坏性操作。
宝石蓝用于工作台操作和选中状态,银灰用于工作台材质层级。Agent 对话区使用独立的低饱和雾蓝灰层次:工作面 `#E8ECF3`、阅读面 `#F8F9FC`、用户消息 `#DDE4F1`,主操作使用岩蓝 `#465F91`。岩蓝与工作台品牌色保持同一视觉语言,同时降低亮度和饱和度,避免输入区显得刺眼。这组颜色按阅读层级和对比度选择,不要求遵循固定的相似色公式,也不扩散到地图和顶部工作台。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
## 3. 排版规则
沿用 Noto Sans SC。侧栏标题使用 14px,业务区标题使用 13px,正文和表单标签使用 12px,辅助信息使用 11px。编号、修订号、时间和数量使用等宽数字。标签左对齐,数值列右对齐。
## 4. 组件规则
- `WorkbenchSection` 统一业务区标题、说明、状态和操作,不在内部再次堆叠同级标题卡。
- `WorkbenchNotice` 使用左侧状态线传达信息、警告与错误,避免大面积色块。
- `WorkbenchEmptyState` 只说明当前为空和下一步,不占用过高空间。
- 表单控件高度为 40px,按钮使用现有 8px 或 12px 圆角体系。
- 主操作每个业务区最多一个。其余操作使用 outline 或 ghost。
## 5. 布局原则
每个业务区按“定位、状态、下一步、证据”排列。侧栏内容使用 12px 至 14px 的紧凑间距。复杂原始对象、请求参数、历史和测试资源按需展开。移动端保持单列,不用横向滚动承载业务字段。
地图背景延伸到顶部导航背后,地图控件和分析正文仍预留 56px 导航高度。独立浮层使用完整亚克力材质,阅读面和控件内部的填色不额外叠加模糊;任务堆叠保留完整圆角和每层 7px 的错位。时间轴容器不足 640px 时省略统计摘要,优先保证日期、图例和操作按钮互不重叠。
## 6. 层级与深度
保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 使用雾蓝灰实体底板,导航、历史和底部操作区保留同色调的局部亚克力;正文和文字输入区使用冷白实体阅读面,岩蓝只用于主操作、焦点和交互反馈。文字输入与权限、模型、语音和发送操作栏分开承担材质,聚焦边框仅围绕文字输入区。对话内容直接进入消息流,不在顶部重复展示分析摘要。每个功能区只保留一层模糊,历史展开内容与导航共用材质层。
桌面普通空会话使用浅雾蓝灰工作面和冷白文字输入区,居中输入组件距面板两侧至少 12px,最大宽度 600px,文字输入区初始高度 160px、字号 16px;多行输入的高度按可用视口限制,保持操作栏和推荐问题可见。模型根据当前用户与项目的近期任务、告警、分析成果和普通会话生成最多三条问题,在输入组件下方独立显示来源与时间,点击直接发送;首次开始对话时,同一个表单以 280ms 位移和尺寸过渡回到底部,随后使用 48px 的紧凑文字区和 40px 操作控件,减少动态效果偏好下则立即切换。移动端保留紧凑输入布局,生成的问题仍使用独立悬浮胶囊。推荐异步加载,不阻塞输入;打开空会话、切换模型或新建空会话时触发,普通重渲染和宽度变化不重新生成,模型失败时不以固定问题冒充生成结果。
| 材质 | 浅色底图 alpha | 影像底图 alpha |
|---|---:|---:|
| 导航 | 0.64 | 0.80 |
| 浮动业务面板 | 0.56 | 0.72 |
| 地图控件 | 0.74 | 0.86 |
| Agent 移动抽屉和历史 | 0.82 | 0.88 |
| Agent 浮动输入区 | 0.86 | 0.90 |
| 正文阅读面 | 0.96 | 0.985 |
分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
## 7. 使用约束
- 必须优先显示当前状态和可执行的下一步。
- 调查计划由 Agent 动态发布。默认呈现本次选中的路线和当前待办,未选路线及旧修订按需展开;条件、并行和审核状态只读取明确的结构化字段。
- 必须明确 synthetic、只读、结果未知和未配置状态。
- 必须保留完整请求号、对象修订和证据来源的可读入口。
- 不用颜色代替文字状态。
- 不在一个业务区内放置多个同等醒目的主按钮。
- 不用大块空卡显示一句空状态。
- 不引入第二套字体、主题或 CSS 方案。
## 8. 响应式与无障碍
支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。
表单字段使用共享 `.form-field` 规则:聚焦时只改变原有 1px 边框的颜色,不叠加外环或浏览器默认轮廓。组合输入由最外层字段容器承担焦点边界,内部输入保持无边框;无边框的亚克力输入区使用 1px 内描边,保持材质与布局不变。无效字段保留错误色,按钮与选择控件保留单独的键盘焦点提示。全局焦点样式仅作为基础层兜底,不能覆盖组件自身规则。
底图选择卡片的选中态使用 1px 主题边框和勾选标记,不附加常驻外环。键盘聚焦使用共享的 1px 内描边;鼠标选中、聚焦与按压均保持卡片尺寸及半透明表面不变。图层面板、独立底图面板与浮动底图控件使用同一规则。
普通文字与实际背景对比度至少 4.5:1,焦点和必要的非文字状态标记至少 3:1。透明区域按合成背景计算。不支持模糊或降低透明度时保留不透明的银霜、冷瓷层级;强制颜色模式使用系统颜色。主题由现有 Tailwind 与 CSS 变量统一提供,不新增切换入口。
## 9. 交互提示
任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。
@@ -0,0 +1,87 @@
# Agent 聊天与报告实现记录
本页归档原设计文档中的阶段记录,仅反映各次验证范围,不作为当前样式或验收结论。现行样式见 [样式设计指南](../style-design-guide.md),交互与报告规则见 [Agent 交互与报告设计](../agent-chat-ui-design.md)。
2026-10-20 本地验证:前端 lint、类型检查、514 项单测和构建通过;23 项聚焦浏览器用例通过,涵盖聊天、历史、流式稳定、表单、图表、展开阅读、报告版本、320/375px、键盘与强制颜色。最终引用来源调整另通过组件测试,并重新运行 5 项聊天及报告工作区浏览器用例。
Agent 类型检查及 16 项相关测试通过;隔离 OpenCode 工具发现验证通过,未调用模型推理。Server 的报告 schema、发布幂等、证据归属、历史读取和报告 API 共 76 项聚焦测试通过;OpenAPI 检查通过,Agent 类型与副本同步,三端组件参数目录一致。
另有原有 API 测试 `test_approved_task_source_uses_server_delegation` 失败:测试期待 `delegated=True`,当前接口返回 False;使用 HEAD 中的原版测试复现相同结果。该问题未混入本轮 UI 修改。
浏览器截图使用明确标注的演练数据。此次没有重启既有 Agent/Server、部署、调用真实模型生成报告或连接真实数据库验收;正式运行前需要同步三端版本并验证实际报告生成与落库。构建仍有大 chunk 提示。参数重算、附件、导出与版本差异比较属于后续范围。
同日后续细化:合并指标摘要区、收起图表范围筛选、对齐数据点读数,并增加正式报告章节导航。当前结果的状态标签与标题并排;当前最终结论去除重复的“调查结论”标题,历史、阶段性判断和证据不足的说明继续显示。
320px 浏览器实测,聊天展开视图的指标区从 310px 降为 118px;趋势图距阅读区顶部从 596px 降为 396px。测量使用同一演练数据,图表仍保留 220px 高度,文本内容与数值未裁剪。检查并更新聊天内嵌、聊天展开、报告内嵌、报告展开共 4 类视图(checked 4 / changed 4 / left 0)。
本次增量验证:lint、类型检查、515 项单测、18 项聚焦浏览器用例及 `git diff --check` 通过。浏览器覆盖 320/375px 与桌面、范围筛选和重置、章节键盘跳转与吸顶、滚动章节提示、草稿保留、版本引用、原有审批内容和强制颜色。改动限于前端展示与导航,没有新增依赖或更改接口;本次未重跑构建和 Agent/Server 测试。
2026-10-21 Resizable 分栏验证:新增 `react-resizable-panels@4.14.7`,移除手写 Agent 拖拽 Hook。`pnpm lint`、`pnpm typecheck`、`pnpm test`(100 个文件、515 项测试)、`pnpm build` 和 `git diff --check` 通过;构建仍有原有大 chunk 提示。25 项聚焦浏览器用例通过,覆盖 Agent 与地图/报告两级拖拽、键盘方向键及 Home / End、最小/最大宽度、折叠恢复、320/375px 与桌面往返、报告比例记忆、表单草稿及地图 canvas 连续绘制。运行命令:`pnpm exec playwright test tests/browser/agent-panel-resize.e2e.ts tests/browser/agent-answer-ui.e2e.ts tests/browser/agent-report-workspace.e2e.ts tests/browser/workspace-main-frame.e2e.ts tests/browser/workbench-resizable.e2e.ts --workers=1`。
键盘调宽回调可能早于 DOM 宽度更新,Agent 的尺寸记忆使用回调中的新比例计算,避免记录上一帧宽度;回归用例已先复现失败再验证通过。两处分栏回调均已核对,内层直接保存布局比例,不读取旧 DOM 尺寸。浏览器预览采用演练数据,本轮仅改动前端,未提交、部署或进行真实业务验收。
2026-10-21 首次提问与 Motion 增量验证:lint、类型检查、519 项单测(100 个文件)及 `git diff --check` 通过。10 项相关浏览器用例分批通过,覆盖居中提问、400/640px 调宽、首次发送与新建对话、表单实例保留、Motion 逐帧位移、减少动态效果、320/375px 与短屏输入、悬浮入口及历史富内容恢复。并行任务期间发生过整例超时,动画用例随后单独复核通过;截图辅助逻辑已修复通知退出导致的定位漂移。验证使用演练数据,未新增依赖,本轮未重跑构建或后台测试,未提交或部署。
2026-10-21 悬浮入口白色材质验证:lint、类型检查、519 项单测及 2 项聚焦浏览器用例通过,覆盖桌面折叠恢复、键盘展开、减少透明度降级和 375px 窄屏。检查了实际渲染截图;本轮只调整入口材质,未新增依赖。
2026-10-21 悬浮入口精简验证:入口改为 56 × 56px 圆形按钮,移除附加状态文案、状态点、箭头和详情提示卡片。lint、类型检查、519 项单测及 `git diff --check` 通过。桌面 1024/1440px 尺寸、键盘展开、减少透明度和 375px 手机共 2 项浏览器用例分批通过;桌面首次验证在展开后的布局断言超时,未改业务代码单独复测通过。截图采用浏览器演练环境。
2026-10-21 水纹球与交互验证:形象改为本地 CSS 与 Motion 绘制的水纹球,加入指针跟随、按压形变和触点水波;离线保留完整球体与操作反馈。lint、类型检查、519 项单测及 9 项聚焦浏览器用例通过,覆盖状态切换、离线可见与展开、鼠标移出复位、手机触摸及取消、减少动态效果、强制颜色、键盘、桌面尺寸和 320/375px。此前 3 项首次加载等待超时,已将连接状态首次就绪的等待上限调整为 30 秒,交互断言保持原有超时。检查了实际工作台与交互预览截图,使用演练数据,未新增依赖、提交或部署。
同日高光跟随修正:固定球体位置与边缘反光,改为在固定高光层内移动渐变中心,并缩小、柔化亮斑。720px 预览视口内,48px 球体右下角悬停时,高光层相对球面的偏移从横向 5.34px、纵向 5.34px 降为 0;32/40/48px 的左上、右下悬停均保持贴合。悬浮入口、标题栏、手机入口共检查 3 处、更新 3 处、遗留 0 处(checked 3 / changed 3 / left 0)。lint、类型检查、519 项单测、6 项水纹球浏览器用例和 `git diff --check` 通过;触摸取消、按压水波、离线可见与互动、键盘及减少动态效果保持有效。
同日球缘涟漪补充:实际渲染并对照 Obsidian 原资源的就绪、待回复、思考、输出和悬停状态,增加三层错峰向外传播的球缘水波,触碰改为三道渐弱涟漪;移除球内固定同心纹理,保留柔和流光与已修正的局部高光。检查 32/40/48px 及离线预览,三处共享入口同步更新。lint、类型检查、519 项单测、6 项浏览器用例和 `git diff --check` 通过;浏览器逐帧确认波纹半径实际增大,并检查离线与减少动态效果停止循环、鼠标及手机操作。仅变更前端形象与设计说明,未引入远程资源依赖或新增包。
2026-10-21 Motion 曲线细化:三层球缘改为闭合贝塞尔带状轮廓,内部改为两道持续形变的宽幅明暗光带;状态切换保留相位,悬停只扰动靠近指针的球缘,保留局部高光和三道按压水波。增加几何单测验证循环连续、曲线边界、状态差异及局部扰动。lint、类型检查、526 项单测(101 个文件)及 6 项水纹球浏览器用例通过,覆盖实际路径变化、视口外暂停恢复、处理状态、离线可见、鼠标与触摸取消、减少动态效果、强制颜色和键盘展开。原版 / 上一版 / 新版动态对照页已检查五种状态和 32/40/48px;高光层与球面最大偏差小于 0.03px,多个实例的 SVG ID 无重复,375px 预览无横向溢出。对照页为本地预览产物;工作台仅使用本地 Motion 实现,没有新增包或更改业务状态映射。`git diff --check` 通过,本轮未提交或部署。
2026-10-21 Obsidian 原版恢复:根据源码和实际资源核对,改回 Rive WebGL2 原动画,保留原版羽化、反射、水纹和 hover 状态。删除自绘 Motion 几何实现及对应几何单测;Motion 用于外层按压。离线、减少动态效果及加载失败显示从原动画导出的静态帧。三个入口共检查 3 处、更新 3 处、遗留 0 处(checked 3 / changed 3 / left 0)。lint、类型检查、523 项单测(101 个文件)、构建及 7 项聚焦浏览器用例通过,覆盖真实原版画面变化、单次资源下载、移出视口释放、状态切换、鼠标与触摸取消、离线、资源失败、键盘和强制颜色。构建保留已有的大 chunk 提示。本轮未新增包、提交或部署。
原版 / 接入版动态对照已检查五种状态和 32/40/48px,375px 预览无横向溢出;在线保留原版动画,离线切为原版静态帧。独立对照预览的 35 秒浏览器渲染进程 RSS 从约 193.3 MiB 回落至 177.2 MiB,未观察到持续增长;这是单次短时预览观察,不代表长时间运行或真实业务负载验收。`git diff --check` 通过。
2026-10-21 取消球体按压反馈:`AgentPersona` 移除 Motion,取消缩放、回弹、变暗及触摸触发 hover,保留原版鼠标悬停、水纹和状态动画。减少动态效果改用系统媒体查询监听;离线仍显示原版静态帧。lint、类型检查、523 项单测、7 项球体浏览器用例及 `git diff --check` 通过,覆盖桌面点击、手机触摸、离线、键盘和强制颜色。另在组件预览中验证减少动态效果的实时切换,以及按下前后静态图片一致;未新增依赖或重跑构建。
2026-10-21 首次加载衔接与尺寸修正:逐帧复现静态帧到 Rive 的透明度从 0 直接变为 1,球体位置和尺寸保持不变,确认跳动来自画面硬切。改为 180ms CSS 渐变,结束后隐藏占位;新浏览器用例在修改前失败、修改后通过。悬浮画布从 48px 放大至 56px,桌面标题栏从 40px 放大至 48px,手机入口从 32px 放大至 40px,三个入口均复核。lint、类型检查、523 项单测及 8 项聚焦浏览器用例分批通过,检查了 320/375px 实际画面、加载衔接、原版动画、按压无反馈、离线、触摸和键盘。未新增依赖、提交或部署。
## 2026-10-21 Rive 资源内离线水纹
解析原版 775 个运行时对象,JSON 重新编码与原文件逐字节一致。保留原图形和在线动画,新增三段离线循环,并修正 asleep / wake 的透明度与缩放关键帧;同源资源为 `public/agent/obsidian-water-v1.riv`。实现与再生成说明见 [Agent 动画资源](../agent-persona-resource.md)。
验证通过:生成一致性检查、lint、类型检查、547 项单测、构建、`git diff --check`。10 项相关浏览器用例分批通过,包含真实 WebGL2 在线状态采样逐像素对照、原版离线消失与修改版离线持续运动、初始离线、反复恢复、工作台单实例、加载衔接、按压无反馈、资源失败、减少动态效果、键盘及 320/375px。首次运行有 1 项动画就绪等待超时;未修改运行代码,带 trace 复测通过,首次超时根因未确认。
独立预览检查 5 种状态及 40/48/56px,离线仍为实时 canvas;30 秒渲染进程 RSS 约 209 MiB 至 176 MiB,未见持续增长。这是本地短时观察,不替代长时间运行验证。构建保留原有大 chunk 提示,本轮未新增依赖、提交或部署。
## 2026-10-21 静水待机与客户端资源缓存
在资源内为六个柔光/反射节点增加离线强度循环,保留原版整体轮廓与在线画面,在线/离线混合时长调整为 600ms。可编辑参数独立保存为 `design/agent/obsidian-offline.json`;生成脚本增加原始 `.riv` 还原命令,实测输出 SHA-256 与原版一致。浏览器对照覆盖在线采样逐像素一致、离线持续变化、正常恢复与快速反复切换后的原版光照恢复。
`.riv` 增加页面内存与 Cache Storage 两级缓存,使用生成脚本同步输出的内容 SHA-256 作为版本。下载和缓存读取验证内容,损坏缓存重新获取,写入新版本后仅清理该动画的旧条目;缓存不可用、权限或配额失败不阻止网络资源播放。真实工作台刷新后阻断动画文件的网络路径,仍能加载并持续播放,下载次数保持为一次。缓存仅覆盖动画资源,不代表应用和 Rive WASM 的完整离线支持。
最终验证通过:lint、类型检查、554 项单测、11 项聚焦浏览器用例、构建、资源与缓存清单再生成一致性、生产资源与打包版本核对、文档链接及 `git diff --check`。实际预览检查 5 种状态及 40/48/56px,浏览器用例覆盖桌面和 320/375px。构建仍有已有大 chunk 提示;未新增依赖、提交或部署。
## 2026-10-21 冰水蓝与光圈辨识度
初版 A「水务蓝」在实际动画中出现灰蓝混色、底色过厚的观感问题。将三个新配色放入原生 Rive 动画,在白底、五种状态与 40/48/56px 下对比后,采用用户选定的 02「冰水蓝」:水体 `#299FDF`、暗部 `#1482BC`、白色高光,底色不透明度为 12%/32%。根据工作台截图反馈,外围水纹进一步从 `#B0E3FF` 加深为 `#4C9ECC`,提升浅色地图上的轮廓辨识度;光圈粗细、渐变 alpha、羽化与运动曲线保持原定义。
离线仍关闭三层外围光圈,保留内部缓慢水纹。在线、离线兜底 PNG 从实际资源重新渲染,内容版本与客户端缓存清单同步更新;仅加深光圈前后,离线 PNG 逐字节一致。悬浮入口、标题栏和手机入口共享资源,检查 3 处、更新 3 处、遗留 0 处(checked 3 / changed 3 / left 0)。
验证通过:lint、类型检查、12 项相关单测及 12 项聚焦浏览器用例分批通过。初次资源对照的深蓝显色阈值不适用于新选定的浅色方案,已调整白底合成后的蓝色检查,并保留原版几何及在线动画的逐像素对照。光圈加深后,重新通过 7 项聚焦浏览器用例,覆盖原版运动、离线无光圈及悬停、恢复在线、缓存刷新、工作台入口和 320/375px。最终构建、资源再生成一致性、生产副本与缓存版本、文档链接及 `git diff --check` 通过;构建保留已有大 chunk 提示。此次未新增依赖、提交或部署。
## 2026-10-21 64px 入口、主体遮蔽与标题区重排
默认 persona、悬浮入口和面板标题区形象统一为 64px,手机地图入口保留 40px。悬浮入口通过同一层 12px 内边距定位,替换原来左侧 12px、导航下方 20px 的独立偏移;1440px 和 1024px 视口实测入口为 64 × 64px,距左侧和顶部导航下沿均为 12px。三个组件调用位置均检查,修改两处呈现尺寸,遗留零处(checked 3 / changed 2 / left 0)。
根据地图道路穿透球体的截图,为球面底色增加独立 `bodyColor`,设为浅冰蓝 `#DCEFFA`,待机/活动不透明度提高至 94%/98%。反射层继续使用选定的冰水蓝配色,外围光圈颜色及离线关闭规则保持。真实 Rive 前后对比检查了带线条背景,中心区域的运行时 alpha 采样不低于 90%;静态帧与缓存版本重新生成,原版几何与在线运动对照仍通过。
标题区身份行重排为 64px persona、上下排列的名称与连接状态,以及右侧三个 40px 操作按钮。上下文行继续提供会话标题与项目切换;两个区域共用白色背景,仅在底部保留分隔线。400px、640px 桌面面板和 320px 手机截图已检查,名称与操作按钮无重叠。历史展开、重命名取消、键盘焦点恢复、新建、折叠和手机关闭入口保留。
最终验证通过:lint、类型检查、556 项单测(102 个文件)、构建和 17 项相关浏览器用例分批通过。浏览器覆盖资源及主体遮蔽、离线、缓存、加载衔接、布局与标题区、320/375px、短屏输入,以及首次发送与新建会话的居中恢复。生产资源、PNG 副本、缓存版本清单、文档链接和 `git diff --check` 通过。构建保留已有大 chunk 提示;未新增依赖、提交或部署。
## 2026-10-22 Persona 设计归档与悬停坐标修复
当前视觉规格保存为 [Persona 设计](../agent-persona-design.md),记录冰水蓝材质、主体遮蔽、64px 默认尺寸、12px 等距定位、标题区、状态动画、交互、降级与缓存边界,并从 README、交互设计和资源说明关联。
悬停中央大片变白的原因是共享 `Persona` 禁用了 Rive 指针监听,仅手动设置 `hover`,导致原生反射的指针目标始终位于中心。恢复原生监听并通过 Pointer Events 转交主指针的进入、移动、离开与取消;Rive 完成坐标转换和原生约束更新。触摸、按压和触摸生成的鼠标事件不进入动画通路。撤销此前的 80% 光圈缩放,应用 `.riv` 与此次调整前逐字节一致。
三个调用位置均复核(checked 3 / changed 3 via shared component / left 0):桌面悬浮入口、面板标题区、手机地图入口。新增工作台回归在修复前收到空坐标并失败,修复后通过,覆盖悬浮入口和标题区的左右移动。资源级对照验证高光重心跟随左右指针移动,且画面与相同材质的原版逐像素一致;鼠标移出后完全恢复。实际组件的 64px 与 160px 放大画面已检查,手机 320/375px、离线、键盘、减少动态效果、失败降级和无按压反馈由相关浏览器用例覆盖。
lint、构建内类型检查、558 项单测(103 个文件)及构建通过;14 项相关浏览器用例分批通过。首次综合运行中,加载衔接的 5 秒前置等待和多次挂载缓存检查的整例 30 秒时限超时,未改代码或断言,单独复测两项均通过。资源还原与再生成校验、本文涉及的文档链接和 `git diff --check` 通过。构建仍有已有大 chunk 提示;未新增依赖、提交或部署。
+1 -1
View File
@@ -104,7 +104,7 @@
## 新增业务组件统一设计
2026-09-18 对调查任务与外围样板的新组件完成统一重构。完整规则见 `docs/component-design-system.md`,视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
2026-09-18 对调查任务与外围样板的新组件完成统一重构。现行规则见 [样式设计指南](style-design-guide.md),视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
+54
View File
@@ -0,0 +1,54 @@
# 工作台项目切换
2026-10-21:新增用户菜单内的项目切换,首批核对 `tjwater_v2` 与 `fengyang_v2`。
## 启动与加载状态
身份认证和授权项目加载共用启动界面:左对齐平台标识与标题,中性浅色表面和宝石蓝状态标识。认证阶段显示“检查登录状态”;项目阶段依次显示“读取授权项目”和“准备项目环境”。项目列表返回后才将第一步标为完成,并显示实际选中的授权项目名称;项目配置与访问上下文返回前不挂载工作台,不用计时器或百分比模拟进度。
加载中提供图形反馈和文字说明,系统开启减少动态效果时停止旋转。失败后移除加载状态,展示错误原因和单一重试操作;项目重试回到读取列表阶段,认证重试重新连接。桌面与手机使用同一组件,长项目名和错误信息自然换行。外观与组件选择遵循 [样式设计指南](style-design-guide.md)。
## 用户入口与选择规则
打开右上角用户菜单,点击“切换项目”进入弹窗。弹窗恢复中性乳白亚克力表面、小型平台标识、左对齐标题、当前项目说明和通栏“进入项目”按钮的布局。弹窗与下拉浮层使用 82% 中性白色亚克力底色,沿用模糊、噪点和边缘高光;遮罩保留轻微压暗与 3px 背景模糊。降低透明度或不支持背景模糊时回退为不透明白色。选择框与禁用按钮使用中性白色表面和低对比灰色边框;主按钮和选中态恢复工作台宝石蓝。
共享 Select 下拉列表显示项目名称与当前使用标记,选择区补充可用状态,后端返回角色时显示中文角色。首次打开和重新打开弹窗时,焦点显式落在“项目环境”选择框,不自动展开列表;Tab 与 Shift+Tab 保留正常焦点顺序。点击主按钮后才提交切换;选择相同项目时主按钮禁用。关闭按钮或 Escape 关闭弹窗并恢复用户菜单按钮焦点。下拉列表只列出后端授权列表中的活动项目;弹窗及下拉浮层覆盖地图工具栏,适配 1440、375 和 320px 视口。
动效使用 `motion/react`:遮罩淡入淡出,弹窗以 8px 位移进入并保留退出动画,项目列表以 4px 位移展开;键盘操作和系统“减少动态效果”设置下即时呈现。列表关闭后直接恢复选择框焦点并保留蓝色焦点边框;弹窗容器不显示浏览器默认 outline,避免选择项变化时焦点临时回落导致黑边。下拉外层只负责裁切,实际滚动统一由 Radix Viewport 管理,避免 4px 入场位移触发外层滚动条;长列表仍支持滚动与键盘定位。
浏览器保存最近一次成功选择的项目 ID。刷新后先重新加载授权列表,只有仍在列表中的 ID 才会恢复;否则使用部署默认工作空间对应的授权项目,再退到列表中的首个项目。项目查询失败显示重试入口,不凭本地存储获得权限。
切换前查询目标项目元数据和访问上下文,两者均通过项目 ID 核对后才提交选择。失败时保留原工作台、弹窗和目标选择,并显示错误提示;请求期间禁用选择与关闭操作。切换只改变浏览器正在查看的项目,不取消服务端工况任务。
## 数据与生命周期
`project-workbench.tsx` 持有唯一选中项目。它将 `WorkbenchRequestContext` 传入工作台,其中包含项目元数据、认证令牌提供器以及已取得的展示信息。现有 API 参数名 `getAccessToken` 为兼容保留,类型 `WorkbenchAccess` 同时接受旧认证提供器和显式项目上下文;主应用始终传显式上下文。
地图瓦片、WFS 定位与可用图层探测读取当前项目的 `gs_workspace`。SCADA、实时流量、运行态、事件流、任务、成果、GIS 属性与 Agent 项目会话读取同一个项目 ID。业务请求继续由后端通过 Bearer 与 `X-Project-Id` 校验权限。
项目 ID 作为工作台 SWR 边界的 React key;切换时新建业务缓存并重新挂载工作台,清理地图选择、报告、绘图和旧业务订阅,当前 Agent 视图进入新项目的空会话。登录级资源位于该边界之外,保留按归属隔离的历史、推荐、项目列表缓存和后台 Agent 会话;原会话生成、草稿及阅读位置继续由其会话实例持有。实现依据 [React 状态重置](https://react.dev/learn/preserving-and-resetting-state) 与 [SWR 缓存提供器](https://swr.vercel.app/docs/advanced/cache)。地图销毁后,绘图及要素交互不再访问旧样式;异步设施与 SCADA 图片注册受地图生命周期 AbortSignal 约束。
## 凤阳的数据边界
凤阳使用已有的 `fengyang_v2` 工作空间及元数据地图范围。固定奉贤演示工况、场景与空间覆盖不会在凤阳兜底显示。奉贤原始 GIS 图层不复制,图层入口根据当前工作空间中的实际数据探测。
地址定位支持文字搜索及地图取点反编码,两者复用当前项目的配置与服务端 Key。取点结果保留点击坐标,关闭工具或切换项目取消未完成查询。地名搜索读取 Metadata DB 的 `projects.map_config.place_search`,按当前项目的 `enabled`、`provider`、`admin_code` 和 `region` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。
## 验证
```bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm test:browser tests/browser/project-switching.e2e.ts tests/browser/project-presentation-backend.e2e.ts --workers=2 --reporter=line
```
浏览器覆盖用户菜单切换、地图工作空间、业务与 Agent 请求项目、元数据视角、刷新恢复、切回原项目及目标访问失败;同时检查切换期间无页面异常。浏览器接口使用隔离测试数据,不代表生产身份登录验收。
数据库补齐方法与备份记录见 Server 仓库的 `docs/workbench-database-rollout.md`。
## 项目列表缓存
授权项目列表按当前登录的凭据提供器隔离,内存缓存 5 分钟,分页结果完整合并后缓存;并发读取复用同一请求,单个消费者退出只取消自身等待。窗口直接展示已加载项目,有效期内反复打开不会重新查询。目标项目进入操作继续核验其元数据及访问上下文,复用这次已验证的展示信息,避免工作台挂载后再次读取相同上下文。缓存不替代业务接口授权,退出登录清理本次登录缓存。
+10
View File
@@ -79,3 +79,13 @@ pnpm build
- 不要移除 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-resource.md)。`hover` 输入由外层交互控制,Rive 自带的 DOM 监听关闭,避免和原生入口按钮及触摸操作重复响应。
回归验证已覆盖原始字节缓存、失败后的重试、无 View Model 绑定、原生状态及 hover 切换、真实 canvas 动画和桌面/手机单实例。
独立原版 / 接入版预览进行了 35 秒浏览器渲染进程 RSS 观察:约 193.3 MiB 至 177.2 MiB,未观察到持续增长。该结果仅覆盖本地短时对照预览;本轮没有重新引入 View Model 绑定,也未更新 Rive 版本。
+169
View File
@@ -0,0 +1,169 @@
# 样式设计指南
供水前端统一以本地 shadcn/ui 为组件基础,使用 Tailwind CSS、共享变体和语义变量。Agent 相关弹窗、面板与菜单保持 shadcn/ui 设计;地图控件与其他工作台浮层使用本文规定的 Acrylic 视觉。适用于工作台、地图控件、Agent、报告、表单和弹层。
本文是唯一的样式规范。业务交互见 [Agent 交互与报告设计](agent-chat-ui-design.md),状态含义见 [状态语言](status-language.md)。本文规定统一目标;历史局部配色和样式覆盖需在后续组件修改中逐步对齐,文档合并不代表运行样式已全部迁移。
## 1. 组件基础
- 优先复用 [src/shared/ui](../src/shared/ui/) 的本地组件;缺少能力时,按需引入与现有 Radix 技术栈匹配的 shadcn/ui 源码,再做样式适配。
- shadcn/ui 提供组件结构与组合方式,Radix 提供相应的基础交互。定制外观时保留语义、键盘操作、焦点管理、禁用和加载行为。
- 业务模块组合共享组件,负责数据、权限和动作;共享组件保持通用。AI Elements 用于聊天展示组合,与 Agent 的 shadcn/ui 组件使用同一套主题变量。
- 不批量覆盖已有组件,不引入第二套 UI 或样式体系。使用 CLI 前先核对 `components.json`;配置继续指向 Vite、TypeScript、Tailwind 4、`@/shared/ui` 和 `@/shared/ui/cn`,关闭 React Server Components。
- 界面图标统一从 `lucide-react` 按名称引入,使用其默认 2px 描边,不复制图标 SVG 路径或用字符替代操作图标。常规控件图标为 16px,紧凑说明可用 12px 至 14px;图标尺寸与按钮命中区域分别设置。图标按钮提供中文可访问名称,装饰性图标使用 `aria-hidden`。
- 界面动画统一使用 Motion,React 动画组件与 Hook 从 `motion/react` 引入,覆盖入场、退出、位置与布局变化及交互过渡。尊重 `prefers-reduced-motion`,减少动态效果时关闭非必要动画;连续拖拽调宽不附加布局动画。
- 保留本地 shadcn/ui 组件自带的基础动效,避免重复叠加。`AgentPersona` 等已有 Rive 专用资源动画继续按其功能文档维护,Motion 不替代资源内部的状态机与动画。
### Agent 弹窗与面板
Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。
| 场景 | 组件与组合 |
|---|---|
| 历史、主题表单、独立详情与确认 | `Dialog`、`DialogTitle`、`DialogDescription`,组合 `Input`、`Button` 和列表 |
| 对话、审批、问答与展开阅读面板 | 本地 shadcn/ui 面板布局,按需组合 `Card`、`Separator`、`Tabs`、`ResizablePanelGroup` 与 AI Elements |
| 模型、主题与会话操作菜单 | `DropdownMenu` 及其菜单项、分组和分隔线 |
| 辅助说明与状态 | `Tooltip`、`HoverCard`、`Badge` 及共享状态组件 |
- 使用已配置的 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` 的遮罩、边框、阴影与交互样式。
- Agent 表面不套用 `acrylic-panel`、`acrylic-panel-white`、`acrylic-control`、`glass-transient` 等地图材质类,也不以 `surface-dock`、`surface-reading` 等材质类替换 shadcn/ui 面板背景。不叠加毛玻璃、噪点、渐变高光、发光边缘或额外阴影。
- 业务适配可调整宽高、最大高度、滚动、分栏、层级和响应式布局;手机全屏历史与抽屉布局仍沿用相同的组件设计。保留组件的标题、说明、关闭、焦点、键盘和禁用行为,避免用全局 CSS 重绘 Agent 内的按钮、输入、菜单或弹窗。
- 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。
- Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。
## 2. Tailwind 定制方式
样式统一进入 [src/styles.css](../src/styles.css)。Tailwind 工具类负责布局、间距、尺寸、排版、响应式及交互状态;共享变量负责颜色和材质参数,共享变体负责重复的组件外观。
1. 先选组件,再选语义表面,最后调整布局与状态。常用差异通过组件的 `variant`、`size` 表达;局部差异通过 `className` 与 [cn](../src/shared/ui/cn.ts) 合并。
2. 使用 `bg-background`、`bg-card`、`bg-popover`、`text-foreground`、`border-border` 和 `ring-ring` 等语义工具类。`@theme inline` 将它们映射到项目变量;背景与前景成对维护。
3. 地图与工作台 Acrylic 的混合层、噪点和回退集中封装为材质类,组件内不重复拼装颜色、模糊和阴影配方,也不把整个控件设置为半透明而降低文字对比度。
4. 需要允许工具类覆盖的复合样式放入 `@layer components`;需要支持 Tailwind 变体的自定义工具使用 `@utility`。修改现有未分层规则前,先核对层叠关系及回退,不直接搬移整段样式。
5. 避免同一属性的冲突类;`cn` 处理已知 Tailwind 类的合并,不负责解决任意 CSS 的层叠。自定义材质类不应依赖 `className` 的书写先后来决定优先级,也不靠持续增加选择器权重或 `!important` 定制外观。
6. 显式颜色仅用于主题定义、图表与地图 API、资源固有颜色和必要的动态值;业务 JSX 优先引用语义变量。
```tsx
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
// 独立地图浮层;放入已有 Acrylic 外壳时改用内部内容表面。
<Card className="acrylic-panel w-full max-w-sm">
<CardHeader className="p-3">
<CardTitle className="text-sm">分析结果</CardTitle>
</CardHeader>
<CardContent className="surface-reading space-y-3 p-3">
<p className="text-sm text-foreground">展示已有结论与证据。</p>
<Button variant="outline" size="sm">查看依据</Button>
</CardContent>
</Card>
```
## 3. 颜色与状态
地图与工作台 Acrylic 界面的视觉方向为宝石蓝操作、银灰外壳、冷瓷阅读面和少量香槟提示。完整色阶见 [宝石蓝配色](gem-blue-palette.svg)。下表为其材质基线,实际使用通过变量引用;Agent 弹窗与面板沿用已配置的 shadcn/ui 主题变量,不从下表另建局部色板。
| 角色 | 语义变量 | 基线 |
|---|---|---|
| 主操作 | `--action-blue` | `#4263C7` |
| 主操作 hover / 按压 | `--action-blue-hover` / `--action-blue-pressed` | `#3653B0` / `#2B469C` |
| 外壳色调 | `--surface-panel` | 银霜 `#E6E9F2` |
| 常驻底板、普通控件 | `--surface-dock` / `--surface-subtle` | `#EBEEF5` |
| 正文与表单 | `--surface-reading` | 冷瓷 `#F5F7FA`,按阅读场景保持高遮蔽度 |
| 选中背景 | `--action-selection-soft` | `#DAE5FD` |
| 正文 / 辅助文字 | `--ink` / `--ink-muted` | `#232935` / `#4E5566` |
| 边框 / 分隔线 | `--surface-border` / `--surface-divider` | `#BEC2CD` / `#D5D9E1` |
| 警告与确认提示 | `--status-warning-*` | 香槟色系,小面积使用 |
状态使用 `status-tone-neutral/info/success/warning/danger` 与共享 `StatusBadge`、`StatusIcon`、`StatusDot`,同时提供文字或图标。业务状态、交互选中和数据系列颜色各自独立;`material-tone-agent` 只标记 Agent 解释或建议,原始监测值和人工资料继续使用普通阅读面。
Agent 使用 shadcn/ui 的实体表面和现有主题色,不将这种外观列为待迁移到 Acrylic 的差异。已有 Agent 专用配色、材质和控件 CSS 覆盖应在对应组件调整时收敛到本地 shadcn/ui 样式;是否完成以运行界面验收为准。
## 4. 材质与层级
本节的 Acrylic 材质规则适用于地图与其他工作台界面;Agent 弹窗与面板按前述 shadcn/ui 规则执行。Acrylic 通过半透明色调、背景模糊、混合层和细噪点建立层级,边缘高光与阴影保持克制。常驻、参与布局的业务区域使用实体底板;Mica 仅作为这一底板角色的设计参考,Web CSS 实体色不等同于原生系统 Mica。
| 表面类 | 用途 |
|---|---|
| `acrylic-navigation` | 透出地图背景的顶部导航 |
| `acrylic-panel` | 图层、图例、工况、要素详情和菜单等独立地图浮层 |
| `acrylic-panel-white` | 与 `acrylic-panel` 配合的白色变体,如项目切换对话框 |
| `acrylic-control` | 工具栏、缩放、比例尺和底图入口 |
| `glass-transient` | 活动任务浮条和地图临时通知 |
| `surface-dock` | 非 Agent 的常驻业务面板、分析成果及业务报告外壳与标题区 |
| `surface-control` | 内部按钮、筛选器和紧凑操作区 |
| `surface-well` | 低密度分组、空白支撑区和内部凹槽 |
| `surface-reading` | 消息、报告、表格、属性、证据和表单 |
一个浮动区域只由最外层承担背景模糊,内部使用内容表面。同一区域的工具面板相互替换,避免叠加 Acrylic。常驻底板可以承载独立临时浮层,标题栏按背景关系选择材质。
地图纹理可影响外壳空白区,不能穿透密集文字。透明度、模糊和阴影由全局变量及 `data-basemap-tone` 适配;具体参数以 CSS 为准,不在多份文档重复维护固定数值。入场动画与定位容器需保证背景采样正常,结束后不保留不必要的合成提示。
减少透明度偏好或不支持 `backdrop-filter` 时,切换高遮蔽度背景并关闭模糊;强制颜色模式使用系统色,关闭纹理与阴影。项目已有浅色主题是当前基线,暗色类名不代表已完成暗色验收。
地图组件继续复用 [map-control-styles.ts](../src/features/map/core/components/map-control-styles.ts):
| 共享常量 | 材质类 |
|---|---|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` |
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` |
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` |
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` |
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` |
## 5. 排版、密度与布局
沿用 Noto Sans SC,中文正文自然换行,不额外拉开字距。编号、时间和数值使用等宽数字,标签左对齐,数值列右对齐。
| 场景 | 排版约定 |
|---|---|
| 侧栏标题、业务分组标题 | 分别为 14px、13px |
| 紧凑正文与字段标签、辅助信息 | 分别为 12px、11px |
| Agent 消息正文 | 14px,行高 24px |
| 欢迎提示与展开报告标题 | 20px,行高 28px |
| 指标值、单位 | 分别为 24px、12px,数值使用等宽数字 |
业务区间距为 12px 至 14px,Agent 消息之间保留 24px。地图与业务面板的普通控件和内部区域沿用 8px、12px 圆角,独立大面板使用共享 16px 圆角;具体值复用组件变体和地图圆角常量。Agent 弹窗、面板和表单控件沿用本地 shadcn/ui 的默认圆角、间距及尺寸变体。
地图与业务表单的主要按钮目标高度为 40px;基础 `Input` 当前默认 36px,这些业务场景通过 `h-10` 或适配后的字段组件统一。Agent 控件优先沿用 shadcn/ui 默认尺寸和 `size` 变体。紧凑控件保留至少 40px 的互不重叠命中区域,可通过周边留白适配触屏。
地图保持主工作区。浮层、导航、时间轴和正文预留各自安全区,地图背景可延伸到 56px 顶部导航背后。业务内容按“定位、状态、下一步、证据”排列,原始对象与历史按需展开;移动端单列,表格只在自己的容器内横向滚动。
Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续调宽不增加布局过渡。报告展开保留同一组件实例、草稿、筛选与阅读位置。具体尺寸、切换条件和任务交互由 [Agent 交互与报告设计](agent-chat-ui-design.md) 维护。
## 6. 组件与交互规则
| 用途 | 优先组件 |
|---|---|
| 操作 | `Button`,复用 `default`、`outline`、`secondary`、`ghost`、`link` 和 `destructive` 变体 |
| 输入与选择 | `Input`、`Textarea`、`InputGroup`、`FieldSelect`、`Select`、`Checkbox`、`Radio`、`DateTimeField` |
| 分组、状态、数据 | `Card`、`Separator`、共享状态组件、`Table`、`Tabs` |
| 证据与过程 | `Disclosure`、`Accordion`、`Collapsible` |
| 菜单、弹层、解释 | `DropdownMenu`、`Dialog`、`Tooltip`、`HoverCard` |
| 可调工作区 | `ResizablePanelGroup`、`ResizablePanel`、`ResizableHandle` |
`WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
状态必须区分无数据、加载中、失败、无权限、未配置与结果未知;保留完整证据、修订及请求号的可读入口。演练数据明确标记,前端展示不能暗示已经完成业务执行或写入。
## 7. 验证与维护
文档修改检查引用路径、示例中的本地组件与属性、命令及 `git diff --check`。运行代码变更执行 `pnpm lint`、`pnpm typecheck`、`pnpm test`;可见 UI 变化增加聚焦的 `pnpm test:browser`,依赖、构建或运行集成变化增加 `pnpm build`。
界面验收覆盖 1280px 及以上桌面和 320px、375px 窄屏,检查长内容、按钮与命中区域、弹层边界、Tab、方向键、Esc、焦点返回及减少动态效果。普通文字与实际合成背景对比度至少 4.5:1,焦点与必要非文字状态至少 3:1,并验证强制颜色、减少透明度与模糊不可用时的回退。
Agent 界面另检查所有弹窗、面板及其子菜单是否沿用本地 shadcn/ui 设计,是否存在地图材质类、额外模糊和阴影、独立 Agent 配色或重绘基础控件的 CSS。尺寸和层级适配不能改变这些组件的视觉规范。
设计规则只在本文维护;业务协议与动作留在功能文档,历史测试结果留在交付记录,不作为当前全量验收结论。
## 官方参考
- [shadcn/ui 组件原则](https://ui.shadcn.com/docs)、[主题变量](https://ui.shadcn.com/docs/theming)、[Vite 接入](https://ui.shadcn.com/docs/installation/vite)、[CLI 配置](https://ui.shadcn.com/docs/components-json)。
- [Tailwind CSS 主题变量](https://tailwindcss.com/docs/theme)、[自定义样式与层叠](https://tailwindcss.com/docs/adding-custom-styles)。
- [Fluent Acrylic](https://learn.microsoft.com/en-us/windows/apps/design/style/acrylic)、[Mica](https://learn.microsoft.com/en-us/windows/apps/design/style/mica)。
+2
View File
@@ -34,6 +34,7 @@
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.2.1",
"@radix-ui/react-tooltip": "^1.2.10",
"@rive-app/react-webgl2": "^4.29.3",
"@streamdown/cjk": "^1.0.3",
@@ -57,6 +58,7 @@
"pmtiles": "4.3.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.14.7",
"shiki": "^4.2.0",
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
+310
View File
@@ -48,6 +48,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-tabs':
specifier: ^1.2.1
version: 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-tooltip':
specifier: ^1.2.10
version: 1.2.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)
@@ -117,6 +120,9 @@ importers:
react-dom:
specifier: ^19.2.7
version: 19.2.7(react@19.2.7)
react-resizable-panels:
specifier: ^4.14.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
@@ -825,6 +831,9 @@ packages:
'@radix-ui/primitive@1.1.6':
resolution: {integrity: sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==}
'@radix-ui/primitive@1.2.0':
resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==}
'@radix-ui/react-accordion@1.2.17':
resolution: {integrity: sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==}
peerDependencies:
@@ -877,6 +886,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.2.0':
resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==}
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-compose-refs@1.1.3':
resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
peerDependencies:
@@ -886,6 +908,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-compose-refs@1.1.5':
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.2.0':
resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==}
peerDependencies:
@@ -895,6 +926,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.3.0':
resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.20':
resolution: {integrity: sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==}
peerDependencies:
@@ -917,6 +957,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-direction@1.2.0':
resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.16':
resolution: {integrity: sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==}
peerDependencies:
@@ -987,6 +1036,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-id@1.1.4':
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-menu@2.1.21':
resolution: {integrity: sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==}
peerDependencies:
@@ -1026,6 +1084,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.11':
resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==}
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.8':
resolution: {integrity: sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==}
peerDependencies:
@@ -1052,6 +1123,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.2.0':
resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==}
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-roving-focus@1.1.16':
resolution: {integrity: sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==}
peerDependencies:
@@ -1065,6 +1149,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.2.1':
resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==}
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-select@2.3.4':
resolution: {integrity: sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==}
peerDependencies:
@@ -1100,6 +1197,28 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.5.0':
resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tabs@1.2.1':
resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==}
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-tooltip@1.2.13':
resolution: {integrity: sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==}
peerDependencies:
@@ -1122,6 +1241,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.5':
resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==}
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-controllable-state@1.2.4':
resolution: {integrity: sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==}
peerDependencies:
@@ -1131,6 +1259,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.3.0':
resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==}
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-effect-event@0.0.3':
resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
peerDependencies:
@@ -1140,6 +1277,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.5':
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
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-is-hydrated@0.1.1':
resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
peerDependencies:
@@ -1149,6 +1295,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-is-hydrated@0.1.3':
resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==}
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-layout-effect@1.1.2':
resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
peerDependencies:
@@ -1158,6 +1313,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.4':
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
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-previous@1.1.2':
resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
peerDependencies:
@@ -3417,6 +3581,12 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.14.7:
resolution: {integrity: sha512-UP8lccy1yGA8p8c6UvPaMDnqYePv/CWOv3R6rTAJ9f+UmuaeiTFDJlvcp5rFu5GJcQ1ofCmkjbzp0HceJXZRbw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4617,6 +4787,8 @@ snapshots:
'@radix-ui/primitive@1.1.6': {}
'@radix-ui/primitive@1.2.0': {}
'@radix-ui/react-accordion@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)':
dependencies:
'@radix-ui/primitive': 1.1.6
@@ -4671,18 +4843,43 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-collection@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-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-slot': 1.5.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)
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-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-context@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-context@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dialog@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)':
dependencies:
'@radix-ui/primitive': 1.1.6
@@ -4712,6 +4909,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-direction@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-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)':
dependencies:
'@radix-ui/primitive': 1.1.6
@@ -4781,6 +4984,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-id@1.1.4(@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-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:
'@radix-ui/primitive': 1.1.6
@@ -4835,6 +5045,15 @@ snapshots:
'@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)':
dependencies:
'@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.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:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -4853,6 +5072,15 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@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)':
dependencies:
'@radix-ui/react-slot': 1.5.0(@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-roving-focus@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)':
dependencies:
'@radix-ui/primitive': 1.1.6
@@ -4872,6 +5100,25 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-roving-focus@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-collection': 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-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-direction': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.4(@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-controllable-state': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-is-hydrated': 0.1.3(@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)
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-select@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)':
dependencies:
'@radix-ui/number': 1.1.2
@@ -4918,6 +5165,29 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-slot@1.5.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-tabs@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-context': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(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-roving-focus': 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-use-controllable-state': 1.3.0(@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-tooltip@1.2.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:
'@radix-ui/primitive': 1.1.6
@@ -4945,6 +5215,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-callback-ref@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-use-controllable-state@1.2.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.6
@@ -4954,6 +5231,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.2.0
'@radix-ui/react-use-effect-event': 0.0.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)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -4961,18 +5247,37 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-effect-event@0.0.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-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -7407,6 +7712,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
react-resizable-panels@4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
dependencies:
get-nonce: 1.0.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# 数据来源与许可
- 建筑和行政边界:© OpenStreetMap contributors,ODbL 1.0。https://www.openstreetmap.org/copyright
- 补充建筑:Overture Maps Foundation,2026-08-19.0 版本建筑主题,2026-09-22 下载。主题许可 ODbL。https://docs.overturemaps.org/attribution/
- 补充建筑的主要上游:Qian Shi 等,A First High-quality Vector Data of Buildings in East Asian Countries Based on a Comprehensive Large-scale Mapping Framework,2023,CC BY 4.0。https://doi.org/10.5281/zenodo.8174931
- 本项目对公开建筑做了奉贤行政区裁剪、重叠过滤和高度估算。原始来源记录保存在本地数据快照,不能把估算高度解释为测量值。
- 供水数据:用户提供的 tjwater_v2.gis 已发布物化视图,只读快照,不属于公开 OSM 数据。公开建筑许可不授权向外发布用户供水数据。
- 简化设备构件:本项目程序生成,外观和尺寸为示意,不代表具体厂家型号。
建筑参考与私人供水图层分开存储、独立切片。
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
{
"schema_version": 1,
"name": "奉贤供水数字城市场景",
"crs": "EPSG:3857",
"geojson_crs": "EPSG:4326",
"bbox": [
121.3467333,
30.6803212,
121.8495682,
31.0182303
],
"building_count": 154085,
"pipe_count": 90805,
"building_sources": {
"OpenStreetMap": 3617,
"Overture": 150468
},
"asset_count": 63968,
"generated_at": "2026-09-22T09:09:36.585868+00:00",
"notice": "建筑高度多为估算;管道为地表专题展示,埋深未知。2020 年资料存在头桥覆盖缺口。",
"attribution": "© OpenStreetMap contributors · Overture Maps Foundation · 用户提供的供水 GIS",
"license_url": "https://www.openstreetmap.org/copyright",
"tiles": {
"buildings": {
"minzoom": 12,
"maxzoom": 16,
"layer": "buildings",
"sha256": "e00ebbc98d1cf86a18147317e53813b24a9975915c38d4778ca42df1bf5f3ad8"
},
"pipes": {
"minzoom": 10,
"maxzoom": 17,
"layer": "pipes"
},
"assets": {
"minzoom": 12,
"maxzoom": 17,
"layer": "assets"
},
"pipe-solids": {
"minzoom": 16,
"maxzoom": 18,
"layer": "pipe_solids"
}
},
"models": {
"hydrant": "models/hydrant.glb",
"valve": "models/valve.glb",
"control_valve": "models/valve.glb",
"flow_meter": "models/flow-meter.glb"
}
}
+279
View File
@@ -0,0 +1,279 @@
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { argv, stdout } from "node:process";
import { pathToFileURL, URL } from "node:url";
import { parseArgs } from "node:util";
const sourceUrl = new URL("../design/agent/obsidian-runtime.json", import.meta.url);
const outputUrl = new URL("../public/agent/obsidian-water-v1.riv", import.meta.url);
const manifestUrl = new URL("../src/shared/ai-elements/persona-resource-manifest.json", import.meta.url);
const designUrl = new URL("../design/agent/obsidian-offline.json", import.meta.url);
const defaultDesign = JSON.parse(await readFile(designUrl, "utf8"));
const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url);
const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8"));
function uint(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`);
const bytes = [];
do {
const next = value % 128;
value = Math.floor(value / 128);
bytes.push(next | (value > 0 ? 128 : 0));
} while (value > 0);
return Buffer.from(bytes);
}
export function encodeSource(source, objects = source.objects) {
const parts = [Buffer.from(source.headerHex, "hex")];
for (const object of objects) {
parts.push(uint(object.type));
for (const [key, value] of object.values) {
parts.push(uint(key));
const type = source.properties[key]?.type;
if (type === "UInt") parts.push(uint(value));
else if (type === "Float" || type === "Color") {
const bytes = Buffer.alloc(4);
if (type === "Float") bytes.writeFloatLE(value);
else bytes.writeUInt32LE(value);
parts.push(bytes);
} else if (type === "String") {
const bytes = typeof value === "string" ? Buffer.from(value) : Buffer.from(value.hex, "hex");
parts.push(uint(bytes.length), bytes);
} else throw new Error(`Unknown Rive property: ${key}`);
}
parts.push(uint(0));
}
return Buffer.concat(parts);
}
const clone = (value) => JSON.parse(JSON.stringify(value));
const valueOf = (object, key, fallback) => object.values.find(([id]) => id === key)?.[1] ?? fallback;
function setValue(object, key, value) {
const property = object.values.find(([id]) => id === key);
if (property) property[1] = value;
else object.values.push([key, value]);
}
// Rive type/property keys are recorded alongside their names in the source JSON.
function transition(stateId, asleep, duration) {
return [
{ type: 65, values: [[151, stateId], [158, duration]] },
{ type: 71, values: [[155, 1], [156, asleep ? 0 : 1]] }
];
}
function restoreOriginal(source) {
const original = encodeSource(source);
if (createHash("sha256").update(original).digest("hex") !== source.sourceSha256) {
throw new Error("The decoded baseline no longer reproduces the original Obsidian file.");
}
return original;
}
function materialObjects(source, material) {
const colors = new Map();
for (const { from, to } of material.colors) {
if (!/^#[\da-f]{6}$/i.test(from) || !/^#[\da-f]{6}$/i.test(to)) {
throw new Error("Material colors must use six-digit hex RGB.");
}
colors.set(parseInt(from.slice(1), 16), parseInt(to.slice(1), 16));
}
const objects = clone(source.objects);
for (const object of objects) {
for (const property of object.values) {
if (source.properties[property[0]]?.type !== "Color") continue;
const color = colors.get(property[1] & 0xffffff);
// Keep alpha, gradient positions, feathering and all geometry intact.
if (color !== undefined) property[1] = ((property[1] & 0xff000000) | color) >>> 0;
}
}
const artboard = objects.findIndex((object) => object.type === 1);
const components = objects.slice(artboard).filter((object) => object.type !== 447);
// The backing color is independent of the moving blue reflections. A pale,
// opaque backing keeps map features from showing through the sphere.
if (material.bodyColor !== undefined) {
if (!/^#[\da-f]{6}$/i.test(material.bodyColor) || components[189]?.type !== 18) {
throw new Error("Invalid body color or missing original body fill.");
}
const alpha = valueOf(components[189], 37) & 0xff000000;
setValue(components[189], 37, (alpha | parseInt(material.bodyColor.slice(1), 16)) >>> 0);
}
// Keep the configured base opacity and its active-state keyframes in sync.
if (material.bodyOpacity) {
const { rest, active } = material.bodyOpacity;
if (![rest, active].every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) {
throw new Error("Body opacity must be between zero and one.");
}
if (components[186]?.type !== 2) throw new Error("Missing original body node.");
setValue(components[186], 18, rest);
let objectId, propertyKey;
for (const object of objects) {
if (object.type === 25) objectId = valueOf(object, 51);
if (object.type === 26) propertyKey = valueOf(object, 53);
if (object.type === 30 && objectId === 186 && propertyKey === 18) {
const value = valueOf(object, 70, 0);
if (Math.abs(value - 0.1) < 0.000001) setValue(object, 70, rest);
else if (Math.abs(value - 0.3) < 0.000001) setValue(object, 70, active);
else throw new Error("Unexpected body opacity keyframe.");
}
}
}
return objects;
}
// A recolored baseline allows visual checks of geometry and online timelines.
export function buildMaterialPersona(source, material = defaultMaterial) {
restoreOriginal(source);
return encodeSource(source, materialObjects(source, material));
}
function opacityTrack(objectId, values, duration) {
return [
{ type: 25, values: [[51, objectId]] },
{ type: 26, values: [[53, 18]] },
...values.map((value, index) => ({
type: 30,
values: [[67, Math.round(duration * index / (values.length - 1))], [68, 2], [69, 261], [70, value]]
}))
];
}
function validateDesign(offline) {
if (!Number.isInteger(offline.fps) || offline.fps < 1 || offline.fps > 60 ||
!Number.isFinite(offline.amplitude) || offline.amplitude < 0 || offline.amplitude > 1 ||
!Number.isInteger(offline.transitionMs) || offline.transitionMs < 1 || offline.transitionMs > 5000 ||
!Array.isArray(offline.layers) || offline.layers.length !== 3) {
throw new Error("Invalid offline timing, amplitude or layer count.");
}
const ids = new Set();
for (const layer of offline.layers) {
if (!Array.isArray(layer.opacity)) throw new Error("Missing offline opacity tracks.");
for (const { objectId, rest, crest } of layer.opacity) {
if (!Number.isInteger(objectId) || ids.has(objectId) ||
!Number.isFinite(rest) || !Number.isFinite(crest) || rest < 0 || rest > crest || crest > 1) {
throw new Error(`Invalid or repeated opacity track: ${objectId}`);
}
ids.add(objectId);
}
}
}
export function buildOfflinePersona(source, offline = defaultDesign, material = defaultMaterial) {
restoreOriginal(source);
validateDesign(offline);
const objects = materialObjects(source, material);
const animations = objects.filter((object) => object.type === 31);
if (animations.length !== 20) throw new Error("Unexpected original animation count.");
// Component IDs start at the artboard; binding records do not consume IDs.
const artboard = objects.findIndex((object) => object.type === 1);
const firstAnimation = objects.findIndex((object) => object.type === 31);
const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447);
let animation;
for (const object of objects) {
if (object.type === 31) animation = valueOf(object, 55);
// These tracks only control opacity and scale. Keep the complete sphere.
if ((animation === "asleep" || animation === "wake") && object.type === 30) {
setValue(object, 70, 1);
}
}
const loops = [];
for (let number = 1; number <= 3; number++) {
const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`);
const end = objects.findIndex((object, index) => index > start && (object.type === 31 || object.type === 53));
if (start < 0 || end < 0) throw new Error(`Missing original water loop ${number}.`);
const loop = clone(objects.slice(start, end));
setValue(loop[0], 55, `offline_loop_${number}`);
setValue(loop[0], 56, offline.fps);
let propertyKey;
let baseline;
for (const object of loop.slice(1)) {
if (object.type === 26) {
propertyKey = valueOf(object, 53);
baseline = undefined;
}
// Full rotations must close at 2π; reduce only displacement and breathing.
if (object.type === 30 && propertyKey !== 15) {
const value = valueOf(object, 70, 0);
baseline ??= value;
setValue(object, 70, baseline + (value - baseline) * offline.amplitude);
}
}
const duration = valueOf(loop[0], 57);
const onlineTracks = [];
for (const { objectId, rest, crest } of offline.layers[number - 1].opacity) {
const component = components[objectId];
if (!component || ![2, 3].includes(component.type)) throw new Error(`Invalid light node: ${objectId}`);
const opacity = valueOf(component, 18, 1);
loop.push(...opacityTrack(objectId, [opacity * rest, opacity * crest, opacity * rest], duration));
// Both sides key opacity so reconnecting restores the original material.
onlineTracks.push(...opacityTrack(objectId, [opacity, opacity], duration));
}
objects.splice(end, 0, ...onlineTracks);
loops.push(...loop);
}
const machine = objects.findIndex((object) => object.type === 53);
// Append timelines so all original animation IDs remain unchanged.
objects.splice(machine, 0, ...loops);
const result = [];
let layer;
let changedLayers = 0;
for (const object of objects) {
if (object.type === 57) {
if (/^loop[123]$/.test(layer)) {
const number = Number(layer.at(-1));
result.push({ type: 61, values: [[149, 19 + number]] });
result.push(...transition(number === 1 ? 3 : 0, false, offline.transitionMs));
changedLayers++;
}
layer = valueOf(object, 138);
}
result.push(object);
if (/^loop[123]$/.test(layer) && object.type === 61) {
result.push(...transition(4, true, offline.transitionMs));
}
}
if (changedLayers !== 3) throw new Error("The original loop layers have changed.");
// These standard properties were omitted from the original because of defaults.
const schema = { ...source, properties: {
...source.properties,
56: { name: "fps", type: "UInt" },
158: { name: "duration", type: "UInt" }
} };
return encodeSource(schema, result);
}
if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
const { values } = parseArgs({ options: {
check: { type: "boolean" },
"restore-original": { type: "string" }
} });
const source = JSON.parse(await readFile(sourceUrl, "utf8"));
if (values["restore-original"]) {
if (values.check) throw new Error("Choose --check or --restore-original, not both.");
// Never overwrite an existing resource when extracting the original.
await writeFile(values["restore-original"], restoreOriginal(source), { flag: "wx" });
stdout.write(`Restored original Obsidian to ${values["restore-original"]}.\n`);
} else {
const bytes = buildOfflinePersona(source);
const manifest = JSON.stringify({
path: "/agent/obsidian-water-v1.riv",
sha256: createHash("sha256").update(bytes).digest("hex"),
byteLength: bytes.length
}, null, 2) + "\n";
if (values.check) {
if (!bytes.equals(await readFile(outputUrl))) throw new Error("Agent animation is stale; rebuild it.");
if (manifest !== await readFile(manifestUrl, "utf8")) throw new Error("Agent cache version is stale; rebuild it.");
stdout.write("Verified original round trip and reproducible Agent animation.\n");
} else {
await writeFile(outputUrl, bytes);
await writeFile(manifestUrl, manifest);
stdout.write(`Built Agent animation (${bytes.length} bytes).\n`);
}
}
}
+10 -3
View File
@@ -4,7 +4,12 @@ import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
const target = resolve(process.argv[2] ?? "../next-tjwater-agent/src/chat/answer-ui-catalog.json");
const targets = process.argv[2]
? [resolve(process.argv[2])]
: [
resolve("../next-tjwater-agent/src/chat/answer-ui-catalog.json"),
resolve("../next-tjwater-server/contracts/answer-ui-catalog.json")
];
const contract = {
version: ANSWER_UI_VERSION,
description:
@@ -18,5 +23,7 @@ const contract = {
])
)
};
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
for (const target of targets) {
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
}
+75
View File
@@ -0,0 +1,75 @@
import { Buffer } from "node:buffer";
import { readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { cwd, stdout } from "node:process";
import { URL } from "node:url";
import { chromium } from "@playwright/test";
import { build } from "esbuild";
const require = createRequire(import.meta.url);
const riveRequire = createRequire(require.resolve("@rive-app/react-webgl2"));
const wasm = await readFile(riveRequire.resolve("@rive-app/webgl2/rive.wasm"));
const resource = await readFile(new URL("../public/agent/obsidian-water-v1.riv", import.meta.url));
const harness = `import { RuntimeLoader } from '@rive-app/react-webgl2';
RuntimeLoader.setWasmUrl('/runtime.wasm');
window.stills = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(async runtime => {
const bytes = new Uint8Array(await (await fetch('/resource.riv')).arrayBuffer());
const file = await runtime.load(bytes, undefined, false);
const canvas = document.querySelector('canvas');
const renderer = runtime.makeRenderer(canvas, true);
const images = {};
try {
for (const state of ['idle', 'asleep']) {
const artboard = file.defaultArtboard();
const machine = new runtime.StateMachineInstance(artboard.stateMachineByName('default'), artboard);
try {
for (let i = 0; i < machine.inputCount(); i++) {
const input = machine.input(i);
input.asBool().value = input.name === state;
}
for (let frame = 0; frame < (state === 'idle' ? 90 : 180); frame++) {
machine.advance(1 / 60);
artboard.advance(1 / 60);
}
renderer.clear();
renderer.save();
renderer.align(runtime.Fit.contain, runtime.Alignment.center,
{ minX: 0, minY: 0, maxX: 192, maxY: 192 }, artboard.bounds);
artboard.draw(renderer);
renderer.restore();
renderer.flush();
runtime.resolveAnimationFrame();
images[state] = canvas.toDataURL('image/png');
} finally { machine.delete(); artboard.delete(); }
}
return images;
} finally { renderer.delete(); file.delete(); }
});`;
const bundled = await build({
stdin: { contents: harness, resolveDir: cwd() },
bundle: true, write: false, format: "iife"
});
const browser = await chromium.launch({ args: ["--enable-unsafe-swiftshader"] });
try {
const page = await browser.newPage();
await page.route("http://persona-render.local/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith(".wasm")) return route.fulfill({ body: wasm, contentType: "application/wasm" });
if (path.endsWith(".riv")) return route.fulfill({ body: resource });
if (path.endsWith(".js")) return route.fulfill({ body: bundled.outputFiles[0].text, contentType: "application/javascript" });
return route.fulfill({
contentType: "text/html",
body: '<canvas width="192" height="192"></canvas><script src="/render.js"></script>'
});
});
await page.goto("http://persona-render.local/");
const images = await page.evaluate("window.stills");
for (const [state, image] of Object.entries(images)) {
const name = state === "asleep" ? "offline" : state;
const path = new URL(`../public/agent/obsidian-water-${name}.png`, import.meta.url);
await writeFile(path, Buffer.from(image.split(",")[1], "base64"));
}
stdout.write("Rendered current Rive material into online and ring-free offline fallback images.\n");
} finally {
await browser.close();
}
+2 -2
View File
@@ -95,7 +95,7 @@ test("preserves compact typography in header and agent controls", async ({ page
await expect(approvalControl).toHaveCSS("font-size", "12px");
await expect(approvalControl).toHaveCSS("line-height", "16px");
await expect(suggestion).toHaveCSS("font-size", "12px");
await expect(suggestion).toHaveCSS("font-weight", "600");
await expect(suggestion).toHaveCSS("font-weight", "400");
});
test("lets interactive utilities override surface materials", async ({ page }) => {
@@ -138,7 +138,7 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
};
});
expect(navigationMaterial.backdropFilter).toBe("none");
expect(navigationMaterial.backgroundColor).toBe(await readThemeColor(navigation, "--agent-dock-surface"));
expect(navigationMaterial.backgroundColor).toBe(await readThemeColor(navigation, "--surface-navigation"));
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { MapToaster } from "@/features/map/core";
import { MapWorkbenchPage } from "@/features/workbench";
import { ProjectWorkbench } from "@/features/workbench";
import type { Authentication } from "@/shared/auth/keycloak-auth";
import { AppProviders } from "./providers";
export function App({ authentication }: { authentication: Authentication }) {
return (
<AppProviders>
<MapWorkbenchPage
<ProjectWorkbench
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
+12 -21
View File
@@ -1,26 +1,17 @@
import { Droplets, RefreshCw } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Droplet } from "lucide-react";
import { StartupScreen } from "@/shared/ui/startup-screen";
export function AuthStartupScreen({ error }: { error?: boolean }) {
return (
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
<Droplets size={22} aria-hidden="true" />
</span>
<h1 className="mt-4 text-lg font-semibold">
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
</p>
{error ? (
<Button className="mt-5" onClick={() => window.location.reload()}>
<RefreshCw size={15} aria-hidden="true" />
重新连接
</Button>
) : null}
</section>
</main>
<StartupScreen
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
title={error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
description={error ? "连接恢复后,可以重新尝试登录。" : "正在确认登录状态,完成后自动继续。"}
progressLabel="身份认证进度"
steps={[{ id: "identity", label: "检查登录状态", detail: "连接认证服务,确认登录状态", state: "loading" }]}
error={error ? "暂时无法连接登录服务,请检查网络连接后重试。" : null}
retryLabel="重新连接"
onRetry={() => window.location.reload()}
/>
);
}
@@ -1,6 +1,7 @@
import { StreamingTokenResponse } from "../components/streaming-token-response";
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
import type { AnswerUiPart } from "./contract";
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
export function AnswerPartsView({
parts,
@@ -9,7 +10,8 @@ export function AnswerPartsView({
streaming,
streamDone,
onLocate,
onSubmit
onSubmit,
onAsk
}: {
parts: AnswerUiPart[];
messageId: string;
@@ -18,6 +20,7 @@ export function AnswerPartsView({
streamDone: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
}) {
return (
<div className="space-y-4">
@@ -29,6 +32,7 @@ export function AnswerPartsView({
disabled={disabled}
onLocate={onLocate}
onSubmit={onSubmit}
onAsk={onAsk}
/>
) : (
<StreamingTokenResponse
@@ -0,0 +1,16 @@
import { createContext } from "react";
export type AnswerUiSelection = { title: string; text: string };
export type AnswerUiMapTarget = {
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
feature_ids: string[];
};
export type AnswerUiInteractions = {
disabled: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
evidenceFilter?: string;
submitMode?: "send" | "draft";
};
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -0,0 +1,24 @@
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>;
}
@@ -1,7 +1,9 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view";
import { parseAnswerUiBlock } from "./contract";
vi.mock("echarts-for-react", () => ({ default: () => <div aria-label="测试图形" /> }));
function block(type: string, props: Record<string, unknown>) {
const parsed = parseAnswerUiBlock({
@@ -14,7 +16,19 @@ function block(type: string, props: Record<string, unknown>) {
return parsed;
}
describe("local interactive answer renderer", () => {
it("renders through json-render and lets readers inspect exact chart data", () => {
it("offers a named metric quote action and retains the exact value, unit and source", async () => {
const user = userEvent.setup();
const ask = vi.fn();
render(<AnswerUiView block={block("Metric", {
label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录",
})} onAsk={ask} />);
await user.click(screen.getByRole("button", { name: "针对当前压力提问" }));
expect(ask).toHaveBeenCalledWith({
title: "当前压力", text: "当前压力:0.3000MPa\n来源:已加载记录\n引用内容块:test",
});
});
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
const user = userEvent.setup();
render(
<AnswerUiView
block={block("Chart", {
@@ -26,10 +40,14 @@ describe("local interactive answer renderer", () => {
})}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^数据$/ }));
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.3");
expect(screen.getByRole("table")).toHaveTextContent("09:00");
expect(screen.getByRole("button", { name: /^数据$/ })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveFocus();
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("runs map location only after a user click and reports missing IDs", async () => {
const locate = vi
@@ -54,6 +72,35 @@ describe("local interactive answer renderer", () => {
feature_ids: ["DEMO-P001", "DEMO-P002"]
});
});
it("filters and sorts loaded rows, then quotes the selected original values without sending", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn();
render(<AnswerUiView block={block("Table", {
title: "监测记录", columns: ["监测点", "压力"],
rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]]
})} onAsk={ask} onSubmit={submit} />);
await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站");
expect(screen.getByText("2 / 3 行")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "按压力排序" }));
const rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("南站二号");
await user.click(screen.getByRole("checkbox", { name: "选择第 3 行" }));
await user.click(screen.getByRole("button", { name: "针对这部分提问" }));
expect(ask).toHaveBeenCalledWith({ title: "监测记录", text: expect.stringContaining("监测点:南站二号;压力:0.26") });
expect(submit).not.toHaveBeenCalled();
});
it("keeps form values after a failed submission", async () => {
const user = userEvent.setup();
render(<AnswerUiView block={block("FollowUpForm", {
title: "现场事实", description: "用户填写",
fields: [{ name: "detail", label: "记录", kind: "text", required: true }]
})} onSubmit={async () => { throw new Error("暂未发送"); }} />);
await user.type(screen.getByLabelText("记录(必填)"), "北站现场记录");
await user.click(screen.getByRole("button", { name: "发送补充资料" }));
expect(await screen.findByRole("status")).toHaveTextContent("暂未发送");
expect(screen.getByLabelText("记录(必填)")).toHaveValue("北站现场记录");
});
it("starts blank, requires input and sends one user message without claiming evidence is saved", async () => {
let complete!: () => void;
const submit = vi.fn(
+171 -179
View File
@@ -1,26 +1,32 @@
import { AskAboutContent } from "./answer-ui-interactions";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
import { createContext, useContext, useId, useState } from "react";
import { useContext, useId, useState } from "react";
import {
ArrowUpRight,
ChevronDown,
ClipboardList,
Database,
FileCheck2,
FlaskConical,
Lightbulb,
MapPin,
MessageSquare
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { Badge } from "@/shared/ui/badge";
import { Card, CardContent } from "@/shared/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
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 { AgentChartView } from "../components/agent-ui-envelope-renderer";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
export type AnswerUiMapTarget = {
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
feature_ids: string[];
};
type Interactions = {
disabled: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
};
const InteractionContext = createContext<Interactions>({ disabled: true });
const { registry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
@@ -37,9 +43,12 @@ const { registry } = defineRegistry(answerUiCatalog, {
),
Section: ({ props, children }) => (
<section className="answer-ui-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">{props.title}</h3>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ClipboardList size={15} aria-hidden="true" />
{props.title}
</h3>
{props.description ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{props.description}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{props.description}</p>
) : null}
<div className="mt-3 space-y-3">{children}</div>
</section>
@@ -59,43 +68,27 @@ const { registry } = defineRegistry(answerUiCatalog, {
</p>
),
Metric: ({ props }) => (
<div className="min-w-0 py-2">
<p className="text-xs text-[var(--ink-muted)]">{props.label}</p>
<p className="mt-1 break-words text-xl font-semibold tabular-nums text-[var(--ink)]">
{props.value}
{props.unit ? <span className="ml-1 text-xs font-normal">{props.unit}</span> : null}
</p>
{props.source ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">来源:{props.source}</p>
) : null}
</div>
<Card className="answer-ui-metric">
<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-value break-words text-2xl font-semibold tabular-nums text-foreground">
{props.value}
{props.unit ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">{props.unit}</span>
) : null}
</p>
{props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null}
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</CardContent>
</Card>
),
Table: ({ props }) => (
<DataTable title={props.title} columns={props.columns} rows={props.rows} />
),
Chart: ({ props }) => <AnswerChart {...props} />,
Evidence: ({ props }) => (
<Disclosure label={props.title} className="answer-ui-evidence">
<div className="space-y-2 py-2 text-xs leading-5">
<p className="text-[var(--ink-muted)]">
{
(
{
observed: "查询记录",
human_input: "用户提供",
inference: "分析推断",
demo: "演练数据"
} as const
)[props.kind]
}{" "}
· {props.source}
</p>
<p className="whitespace-pre-wrap">{props.summary}</p>
{props.observed_at ? <p>记录时间:{props.observed_at}</p> : null}
{props.evidence_id ? <p>资料编号:{props.evidence_id}</p> : null}
</div>
</Disclosure>
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} />
),
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />,
Evidence: ({ props }) => <EvidenceCard {...props} />,
MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
}
@@ -105,11 +98,21 @@ export function AnswerUiView({
block,
disabled = false,
onLocate,
onSubmit
}: { block: AnswerUiBlock } & Partial<Interactions>) {
onSubmit,
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [filter, setFilter] = useState("all");
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit }}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
<div className="answer-ui-root" data-answer-ui={block.block_id}>
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["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}
</Button>)}
</div> : null}
<JSONUIProvider registry={registry}>
<Renderer spec={block.spec} registry={registry} />
</JSONUIProvider>
@@ -118,97 +121,74 @@ export function AnswerUiView({
);
}
function DataTable({
title,
columns,
rows
}: {
title: string;
columns: string[];
rows: string[][];
}) {
return (
<div className="min-w-0">
<div
role="region"
aria-label={title}
tabIndex={0}
className="overflow-x-auto rounded-lg border border-[var(--surface-divider)]"
>
<table className="w-full border-collapse text-left text-xs">
<caption className="px-3 py-2 text-left font-medium text-[var(--ink)]">{title}</caption>
<thead className="bg-[var(--surface-subtle)]">
<tr>
{columns.map((column, index) => (
<th key={index} className="whitespace-nowrap px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-t border-[var(--surface-divider)]">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="min-w-24 px-3 py-2 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
type ChartProps = {
title: string;
chart_type: "line" | "bar";
x_data: string[];
series: Array<{ name: string; data: number[] }>;
source: string;
const evidenceKinds = {
observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare },
inference: { label: "分析推断", icon: Lightbulb },
demo: { label: "演练数据", icon: FlaskConical }
};
function AnswerChart(props: ChartProps) {
const [mode, setMode] = useState<"line" | "bar" | "table">(props.chart_type);
function EvidenceCard(props: {
title: string;
summary: string;
source: string;
kind: keyof typeof evidenceKinds;
observed_at: string | null;
evidence_id: string | null;
}) {
const [open, setOpen] = useState(false);
const meta = evidenceKinds[props.kind];
const Icon = meta.icon;
const { evidenceFilter } = useContext(InteractionContext);
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
return (
<div className="min-w-0">
<div role="group" aria-label={`${props.title}显示方式`} className="mb-2 flex flex-wrap gap-1">
{(
[
["line", "折线"],
["bar", "柱状"],
["table", "数据"]
] as const
).map(([value, label]) => (
<Button
key={value}
size="sm"
variant="ghost"
aria-pressed={mode === value}
onClick={() => setMode(value)}
>
{label}
</Button>
))}
</div>
{mode === "table" ? (
<DataTable
title={props.title}
columns={["时间或类别", ...props.series.map((series) => series.name)]}
rows={props.x_data.map((label, index) => [
label,
...props.series.map((series) => String(series.data[index]))
])}
/>
) : (
<AgentChartView chart={{ ...props, chart_type: mode }} />
)}
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-[var(--ink-muted)]">
来源:{props.source}
</p>
</div>
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
type="button"
className="h-auto min-h-12 w-full justify-start gap-2.5 whitespace-normal rounded-xl px-3 py-3 text-left shadow-none"
>
<span className="answer-ui-evidence-icon">
<Icon size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">{props.title}</span>
<span className="mt-0.5 block break-words text-[11px] font-normal text-muted-foreground">
{props.source}
</span>
</span>
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
{meta.label}
</Badge>
<ChevronDown
size={14}
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>
{props.observed_at || props.evidence_id ? (
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
{props.observed_at ? (
<div>
<dt className="inline">记录时间:</dt>
<dd className="inline tabular-nums">{props.observed_at}</dd>
</div>
) : null}
{props.evidence_id ? (
<div>
<dt className="inline">资料编号:</dt>
<dd className="inline break-all font-mono">{props.evidence_id}</dd>
</div>
) : null}
</dl>
) : 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}` : ""}`} />
</CollapsibleContent>
</Collapsible>
);
}
@@ -221,37 +201,43 @@ function LocateButton({
const [running, setRunning] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<div>
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
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}
</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 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>
);
}
@@ -271,7 +257,7 @@ function FollowUpForm({
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit } = useContext(InteractionContext);
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [values, setValues] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
@@ -307,7 +293,7 @@ function FollowUpForm({
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setFeedback("资料已发送,请查看 Agent 回复。");
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
@@ -315,9 +301,14 @@ function FollowUpForm({
}
}}
>
<div>
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
<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" ? (
@@ -347,6 +338,7 @@ function FollowUpForm({
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
type="number"
value={values[field.name] ?? ""}
@@ -367,7 +359,7 @@ function FollowUpForm({
variant="outline"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? "正在发送…" : "发送补充资料"}
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback ? (
<p role="status" className="text-xs leading-5">
+13
View File
@@ -16,6 +16,19 @@ const payloadSchema = z
})
.strict();
// Stored reports remain readable if one presentation component is no longer supported.
export const storedAnswerUiBlockSchema = z.object({
version: z.string(),
block_id: key,
fallback_text: z.string().min(1).max(4000),
spec: z.unknown()
});
export const reportUiBlocksSchema = z.array(z.object({
block: storedAnswerUiBlockSchema,
evidence_refs: z.array(z.string().min(1).max(128)).min(1).max(100)
}).strict()).max(8);
export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
export type AnswerUiBlock = {
version: typeof ANSWER_UI_VERSION;
block_id: string;
@@ -0,0 +1,126 @@
import { useState } from "react";
import ReactECharts from "echarts-for-react";
import { BarChart3, ChevronDown, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table";
import { AskAboutContent } from "./answer-ui-interactions";
export type AnswerChartProps = {
title: string;
chart_type: "line" | "bar";
x_data: string[];
series: Array<{ name: string; data: number[] }>;
source: string;
};
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
function PointSelect({ label, value, labels, onChange }: {
label: string; value: number; labels: string[]; onChange: (value: number) => void;
}) {
return <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}>
<SelectTrigger aria-label={label} className="h-9 min-w-0 flex-1 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>;
}
export function InteractiveChart(props: AnswerChartProps) {
const [view, setView] = useState<string>(props.chart_type);
const [hidden, setHidden] = useState<number[]>([]);
const [range, setRange] = useState<[number, number]>([0, props.x_data.length - 1]);
const [point, setPoint] = useState(0);
const visible = props.series.filter((_, index) => !hidden.includes(index));
const labels = props.x_data.slice(range[0], range[1] + 1);
const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const option = (type: "line" | "bar") => ({
animation: false,
color: colors.filter((_, index) => !hidden.includes(index)),
aria: { enabled: true },
tooltip: { trigger: "axis", renderMode: "richText" },
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true },
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } },
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } },
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
});
return <Card className="answer-ui-chart overflow-hidden">
<CardHeader className="space-y-1 px-3.5 pb-3 pt-3.5">
<CardTitle className="flex items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} />{props.title}</CardTitle>
<CardDescription className="text-[11px]">{labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={`${props.title}显示方式`} className="mb-3 grid w-full grid-cols-3">
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列">
{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)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => setHidden((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index])}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)}
</div>
<Collapsible className="mb-2">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label="筛选数据范围"
className="group h-8 gap-1.5 px-1.5 font-normal text-muted-foreground">
<SlidersHorizontal size={13} aria-hidden />数据范围
<ChevronDown size={13} aria-hidden className="group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<span className="text-[11px] text-muted-foreground">
{fullRange ? "全部数据" : `${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}
</span>
</div>
<CollapsibleContent className="space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<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}>
<div role="img" aria-label={props.title}>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number")
setPoint(range[0] + event.dataIndex);
} }} />
</div>
</TabsContent>)}
<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)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
source={props.source} />
</TabsContent>
</Tabs>
<div className="rounded-lg bg-muted/40 p-2.5">
<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>
<AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
</div>
<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>
</p>
</CardContent>
</Card>;
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
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 }: {
title: string; columns: string[]; rows: string[][]; source?: string;
}) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<{ column: number; descending: boolean } | null>(null);
const [selection, setSelection] = useState<number[]>([]);
const filtered = useMemo(() => {
const items = rows.map((cells, id) => ({ cells, id }))
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
if (sort) items.sort((a, b) => {
const aValue = a.cells[sort.column], bValue = b.cells[sort.column];
const compared = /^[-+]?\d+(\.\d+)?%?$/.test(aValue) && /^[-+]?\d+(\.\d+)?%?$/.test(bValue)
? Number.parseFloat(aValue) - Number.parseFloat(bValue)
: aValue.localeCompare(bValue, "zh-CN", { numeric: true });
return sort.descending ? -compared : compared;
});
return items;
}, [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>
<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"
onChange={(event) => { setQuery(event.currentTarget.value); setSelection([]); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
</div>
<div role="region" aria-label={title} tabIndex={0}
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
<Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption>
<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"
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}排序`}
onClick={() => setSort((current) => current?.column === index
? current.descending ? null : { column: index, descending: true }
: { column: index, descending: false })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
</Button>
</TableHead>)}
</TableRow></TableHeader>
<TableBody>
{filtered.map(({ cells, id }) => <TableRow key={id} data-state={selection.includes(id) ? "selected" : undefined}>
<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}
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
</TableRow>)}
{!filtered.length ? <TableRow><TableCell colSpan={columns.length + 1} className="py-6 text-center text-xs text-muted-foreground">
{rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
</TableCell></TableRow> : null}
</TableBody>
</Table>
</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>;
}
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe("authenticated session exports", () => {
it("fetches only Agent session files with identity and project headers", async () => {
const fetchMock = vi.fn(async () => new Response("ZIP", { headers: { "Content-Type": "application/zip" } }));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("URL", class extends URL {
static createObjectURL = vi.fn(() => "blob:fixture");
static revokeObjectURL = vi.fn();
});
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
const client = createAgentApiClient("https://agent.test", { getAccessToken: async () => "FIXTURE", getProjectId: async () => "project-a" });
const path = "/api/v1/session/session-a/files/mcp-11111111-1111-1111-1111-111111111111.zip";
await client.downloadFile(path);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`https://agent.test${path}`);
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer FIXTURE");
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project-a");
expect(init.redirect).toBe("error");
expect(click).toHaveBeenCalledOnce();
});
it("rejects arbitrary URLs and paths before requesting any credential", async () => {
const getAccessToken = vi.fn();
const client = createAgentApiClient("https://agent.test", { getAccessToken });
for (const path of ["https://foreign.test/file.zip", "/api/v1/admin/files/a.zip", "/api/v1/session/s/files/secret.env", "/api/v1/session/s/files/../owner.json"]) {
await expect(client.downloadFile(path)).rejects.toThrow("无效");
}
expect(getAccessToken).not.toHaveBeenCalled();
});
});
+34 -3
View File
@@ -2,16 +2,45 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
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 fetchMock = vi.fn()
.mockResolvedValueOnce(Response.json({ topics: [topic] }))
.mockResolvedValueOnce(Response.json({ sessions: [{ id: "session-b", title: "另一段对话", topic_id: "topic-b", topic_name: "检修事件", search_snippet: "正文中的南桥阀门", purpose: "task_collaboration" }], has_more: false }))
.mockResolvedValueOnce(Response.json({ session_id: "session-b", topic_id: "topic-a" }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project-a" });
expect(await client.listTopics()).toEqual([topic]);
const result = await client.listSessionPage({ q: "南桥阀门", includeCollaboration: true });
expect(result.sessions[0]).toMatchObject({ title: "另一段对话", topicId: "topic-b", topicName: "检修事件", searchSnippet: "正文中的南桥阀门", purpose: "task_collaboration" });
const query = new URL(String(fetchMock.mock.calls[1]?.[0])).searchParams;
expect(query.get("q")).toBe("南桥阀门");
expect(query.has("topic_id")).toBe(false);
expect(query.get("include_collaboration")).toBe("true");
await client.moveSessionToTopic("session-b", "topic-a");
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ topic_id: "topic-a" });
await client.deleteTopic("topic-a");
expect(String(fetchMock.mock.calls[3]?.[0])).toContain("/topics/topic-a");
for (const [, init] of fetchMock.mock.calls) {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-a");
}
});
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }))
.mockResolvedValueOnce(Response.json({ ...input, status: "cancelled" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
await expect(client.cancelResponseInput("session-1", input.id)).resolves.toEqual({ ...input, status: "cancelled" });
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
for (const [, request] of fetchMock.mock.calls) {
@@ -83,7 +112,8 @@ describe("Agent API client sessions", () => {
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
},
{
id: "old",
@@ -96,7 +126,8 @@ describe("Agent API client sessions", () => {
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
}
]);
});
+85 -8
View File
@@ -39,12 +39,15 @@ export type AgentModelsResponse = {
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
export type AgentChatSession = {
id?: string;
session_id: string;
title?: string;
topic_id?: string | null;
status?: string;
run_status?: AgentRunStatus;
is_streaming?: boolean;
@@ -64,12 +67,16 @@ export type AgentChatSessionSummary = {
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
id: string;
title: string;
topicId?: string | null;
topicName?: string;
searchSnippet?: string;
createdAt: number;
updatedAt: number;
status?: string;
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
needsInput?: boolean;
purpose: AgentSessionPurpose;
taskId?: string;
};
@@ -89,10 +96,15 @@ export type AgentPromptSuggestion = {
activityId: string | null;
};
export type AgentMessagePagination = { hasMore: boolean; beforeCursor: string | null };
export type AgentSessionPage = { sessions: AgentChatSessionSummary[]; hasMore: boolean; nextCursor: string | null };
export type AgentHistoryMessagePage = { messages: unknown[]; messagePage: AgentMessagePagination };
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
messages: unknown[];
messagePage?: AgentMessagePagination;
};
export type AgentSessionStreamDataEventType =
@@ -124,6 +136,7 @@ export type AgentSessionStreamEvent =
| {
type: "state";
executionPlans?: unknown[];
messagePage?: AgentMessagePagination;
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -136,20 +149,30 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
cancelResponseInput: (sessionId: string, id: string) => Promise<AgentResponseInput>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext; topicId?: string }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
listSessionPage: (options?: { q?: string; cursor?: string; limit?: number; purpose?: AgentSessionPurpose; topicId?: string; includeCollaboration?: boolean; signal?: AbortSignal }) => Promise<AgentSessionPage>;
listTopics: () => Promise<AgentTopic[]>;
createTopic: (name: string) => Promise<AgentTopic>;
renameTopic: (id: string, name: string) => Promise<AgentTopic>;
deleteTopic: (id: string) => Promise<void>;
moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>;
loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
messageLimit?: number;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
@@ -218,6 +241,19 @@ export function createAgentApiClient(
};
return {
async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址");
const response = await request(match[1], { redirect: "error" });
if (!response.ok) throw new Error("文件不可用或当前账号无权下载");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = path.split("/").at(-1) ?? "export.zip";
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
},
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -233,6 +269,11 @@ export function createAgentApiClient(
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
});
},
async cancelResponseInput(sessionId, id) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
method: "DELETE"
});
},
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
@@ -260,7 +301,7 @@ export function createAgentApiClient(
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger_context: options?.triggerContext })
body: JSON.stringify({ trigger_context: options?.triggerContext, topic_id: options?.topicId })
});
},
@@ -274,6 +315,31 @@ export function createAgentApiClient(
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async listSessionPage(options) {
const params = new URLSearchParams({ purpose: options?.purpose ?? "ordinary_chat", limit: String(options?.limit ?? 30) });
if (options?.q) params.set("q", options.q);
if (options?.topicId) params.set("topic_id", options.topicId);
if (options?.includeCollaboration) params.set("include_collaboration", "true");
if (options?.cursor) params.set("cursor", options.cursor);
const payload = await requestJson<{ sessions?: unknown[]; has_more?: boolean; next_cursor?: string | null }>(
`/sessions?${params}`, { signal: options?.signal });
return { sessions: (payload.sessions ?? []).map(toSessionSummary).filter(isPresent),
hasMore: payload.has_more === true, nextCursor: typeof payload.next_cursor === "string" ? payload.next_cursor : null };
},
async listTopics() { return (await requestJson<{ topics: AgentTopic[] }>("/topics")).topics; },
async createTopic(name) { return requestJson<AgentTopic>("/topics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
async renameTopic(id, name) { return requestJson<AgentTopic>(`/topics/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
async deleteTopic(id) { await requestJson(`/topics/${encodeURIComponent(id)}`, { method: "DELETE" }); },
async moveSessionToTopic(sessionId, topicId) { await requestJson(`/session/${encodeURIComponent(sessionId)}/topic`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topic_id: topicId }),
}); },
async loadOlderMessages(sessionId, before, signal) {
const query = new URLSearchParams({ limit: "40", before });
const payload = await requestJson<{ messages?: unknown[]; message_page?: unknown }>(
`/session/${encodeURIComponent(sessionId)}/messages?${query}`, { signal });
return { messages: payload.messages ?? [], messagePage: readMessagePagination(payload.message_page) ?? { hasMore: false, beforeCursor: null } };
},
async createTaskCollaborationSession(taskId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
@@ -293,8 +359,9 @@ export function createAgentApiClient(
});
},
async loadSession(sessionId) {
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
async loadSession(sessionId, options) {
const query = options?.messageLimit ? `?message_limit=${options.messageLimit}` : "";
const response = await request(`/session/${encodeURIComponent(sessionId)}${query}`, { signal: options?.signal });
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -310,7 +377,7 @@ export function createAgentApiClient(
async streamSession(sessionId, options) {
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
`/session/${encodeURIComponent(sessionId)}/stream${options.messageLimit ? `?message_limit=${options.messageLimit}` : ""}`,
{ signal: options.signal }
);
@@ -564,6 +631,9 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
return {
id,
title: normalizeTitle(value.title),
topicId: typeof value.topic_id === "string" ? value.topic_id : null,
topicName: typeof value.topic_name === "string" ? value.topic_name : undefined,
searchSnippet: typeof value.search_snippet === "string" ? value.search_snippet : undefined,
createdAt: toMillis(value.created_at),
updatedAt: toMillis(value.updated_at),
status: typeof value.status === "string" ? value.status : undefined,
@@ -591,7 +661,8 @@ function toLoadedSession(value: unknown): AgentLoadedChatSession {
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
isTitleManuallyEdited:
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
messages: Array.isArray(value.messages) ? value.messages : []
messages: Array.isArray(value.messages) ? value.messages : [],
messagePage: readMessagePagination(value.message_page)
};
}
@@ -657,6 +728,7 @@ function emitSessionSseEvent(
onEvent({
type: "state",
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
messagePage: readMessagePagination(data.message_page),
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
@@ -717,3 +789,8 @@ function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChat
return right.id.localeCompare(left.id);
}
function readMessagePagination(value: unknown): AgentMessagePagination | undefined {
if (!isRecord(value)) return undefined;
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
}
@@ -32,7 +32,7 @@ describe("Responses transport", () => {
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
tjwater: { approval_mode: "always", capabilities: [] },
tjwater: { client_message_id: "new", approval_mode: "always", capabilities: [] },
});
});
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
@@ -4,7 +4,7 @@ const record = (value: unknown): value is Record<string, unknown> => typeof valu
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown;
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
}) {
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();
@@ -13,7 +13,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
tjwater: { ...(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.taskContext ? { task_context: options.taskContext } : {}),
},
@@ -44,6 +44,9 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
if (value.type === "response.created") {
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
responseId = value.response.id;
if (record(value.response.conversation) && typeof value.response.conversation.id === "string") {
controller.enqueue({ type: "data-session_created", data: { session_id: value.response.conversation.id }, transient: true });
}
return;
}
if (!responseId) throw new Error("Responses 流缺少创建事件");
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Check, Copy, LoaderCircle } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
import type { AnswerUiPart } from "../answer-ui/contract";
export function AgentChatMessageHeader({
running,
interactive
}: {
running: boolean;
interactive: boolean;
}) {
if (!running && !interactive) return null;
return (
<div className="agent-chat-message-header">
{running ? (
<span
role="status"
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground"
>
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" />
正在回复
</span>
) : interactive ? (
<Badge
variant="secondary"
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none"
>
交互回答
</Badge>
) : null}
</div>
);
}
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
const text = parts
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
.join("\n\n");
if (!text.trim()) return null;
return (
<MessageActions className="agent-chat-message-actions">
<MessageAction
tooltip={copied ? "回复已复制" : "复制回复"}
label={copied ? "回复已复制" : "复制回复"}
size="sm"
className="h-10 gap-1.5 px-2 text-[11px] font-normal shadow-none"
aria-label={copied ? "回复已复制" : "复制回复"}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setError(false);
} catch {
setCopied(false);
setError(true);
}
}}
>
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
{copied ? "已复制" : "复制回复"}
</MessageAction>
<span role="status" className="text-[11px] text-muted-foreground">
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
</span>
</MessageActions>
);
}
@@ -1,7 +1,8 @@
import { ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import { AgentPersona } from "./agent-persona";
import { agentLayoutTransition } from "./agent-motion";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
@@ -15,26 +16,27 @@ export function AgentCollapsedRail({
onExpand
}: AgentCollapsedRailProps) {
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
const prefersReducedMotion = useReducedMotion();
return (
<aside
<motion.aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-control pointer-events-auto w-[72px] rounded-2xl border p-1.5"
)}
initial={prefersReducedMotion ? false : { opacity: 0, x: -5, scale: 0.94 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : agentLayoutTransition}
style={{ transformOrigin: "left top" }}
className="agent-collapsed-rail pointer-events-auto size-[var(--workbench-agent-rail-width,64px)] rounded-full"
>
<button
<Button
variant="ghost"
type="button"
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item group relative flex h-14 w-full items-center justify-center rounded-xl border border-transparent bg-transparent hover:bg-[var(--action-selection-soft)]"
className="flex h-full w-full items-center justify-center rounded-full border-0 bg-transparent p-0"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
</span>
</button>
</aside>
<AgentPersona className="h-full w-full" state={personaState} />
</Button>
</motion.aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,333 +1,196 @@
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Check, ChevronDown, FolderInput, MoreHorizontal, Pencil, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { Separator } from "@/shared/ui/separator";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
import { StatusBadge } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
type AgentHistoryPanelProps = {
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: RefObject<HTMLButtonElement | null>;
scopeLabel: string;
activeSessionId?: string | null;
loading: boolean;
loadingSessionId: string | null;
sessions: AgentChatSessionSummary[];
topics?: AgentTopic[];
topicId?: string;
onTopicChange?: (id: string | undefined) => void;
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
query?: string;
onQueryChange?: (value: string) => void;
hasMore?: boolean;
loadingMore?: boolean;
error?: string;
pendingUpdate?: boolean;
onLoadMore?: () => Promise<unknown> | void;
onRefresh?: () => Promise<void> | void;
onSelectSession: (sessionId: string) => void;
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteSession?: (sessionId: string) => Promise<void> | void;
};
export function AgentHistoryPanel({
activeSessionId,
loading,
loadingSessionId,
sessions,
onRefresh,
onSelectSession,
onRenameSession,
onDeleteSession
}: AgentHistoryPanelProps) {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
const beginRename = (session: AgentChatSessionSummary) => {
setEditingSessionId(session.id);
setDraftTitle(session.title);
export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel, activeSessionId, loading, loadingSessionId,
sessions, query = "", onQueryChange, hasMore, loadingMore, error, pendingUpdate, onLoadMore, onRefresh,
topics = [], topicId, onTopicChange, onMoveSessionToTopic, onSelectSession, onRenameSession, onDeleteSession }: Props) {
const [search, setSearch] = useState(query);
const [movingId, setMovingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [viewport, setViewport] = useState<{ height: number; top: number } | null>(null);
useEffect(() => {
if (!open) return;
const visual = window.visualViewport;
const update = () => {
const next = { height: visual?.height ?? window.innerHeight, top: visual?.offsetTop ?? 0 };
setViewport((current) => current?.height === next.height && current.top === next.top ? current : next);
};
update();
visual?.addEventListener("resize", update);
visual?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visual?.removeEventListener("resize", update);
visual?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (!open || search === query) return;
const timer = setTimeout(() => onQueryChange?.(search.trim()), 250);
return () => clearTimeout(timer);
}, [onQueryChange, open, query, search]);
const perform = async (id: string, action: () => Promise<void> | void) => {
setBusy(id); setActionError(null);
try { await action(); setEditingId(null); setDeletingId(null); }
catch (reason) { setActionError(reason instanceof Error ? reason.message : "操作失败,请重试"); }
finally { setBusy(null); }
};
const cancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const saveRename = (session: AgentChatSessionSummary) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle || normalizedTitle === session.title) {
cancelRename();
return;
}
setSavingSessionId(session.id);
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
.then(cancelRename)
.finally(() => setSavingSessionId(null));
};
const confirmDelete = (sessionId: string) => {
setDeletingSessionId(sessionId);
void Promise.resolve(onDeleteSession?.(sessionId))
.then(() => setConfirmingDeleteSessionId(null))
.finally(() => setDeletingSessionId(null));
};
return (
<div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950">历史记录</p>
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
let previousGroup = "";
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} data-agent-theme="chat"
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"
style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined}
onOpenAutoFocus={(event) => { event.preventDefault(); searchRef.current?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
<div data-agent-history-header className="shrink-0 space-y-4 p-4 sm:p-6 sm:pb-4">
<div className="flex items-start justify-between gap-4">
<DialogHeader className="min-w-0 text-left">
<DialogTitle>历史对话</DialogTitle>
<DialogDescription className="break-words">{scopeLabel}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" aria-label="刷新 Agent 历史记录" loading={loadingMore}
onClick={() => void perform("refresh", () => onRefresh?.())}><RefreshCw aria-hidden="true" /></Button>
<Button variant="ghost" size="icon-sm" aria-label="关闭历史对话" onClick={() => onOpenChange(false)}><X aria-hidden="true" /></Button>
</div>
<Button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
size="icon"
variant="ghost"
disabled={loading && sessions.length === 0}
loading={loading && sessions.length > 0}
loadingLabel="正在刷新 Agent 历史记录"
onClick={() => void Promise.resolve(onRefresh?.())}
>
<RefreshCw size={15} aria-hidden="true" />
</Button>
</div>
<motion.div
className="max-h-56 space-y-1 overflow-y-auto pr-1"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
正在加载
</motion.div>
) : sessions.length > 0 ? (
sessions.map((session) => {
const active = session.id === activeSessionId;
const itemLoading = loadingSessionId === session.id;
const editing = editingSessionId === session.id;
const saving = savingSessionId === session.id;
const deleting = deletingSessionId === session.id;
const confirmingDelete = confirmingDeleteSessionId === session.id;
return (
<motion.div
key={session.id}
layout
className={cn(
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing
? "agent-history-item-active border border-[var(--surface-border)]"
: "border border-transparent"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div
key="confirm-delete"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<span
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
title={session.title}
>
删除“{session.title}”?
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
删除后无法从历史记录中恢复
</span>
</motion.div>
) : editing ? (
<motion.form
key="rename"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onSubmit={(event) => {
event.preventDefault();
saveRename(session);
}}
>
<Input
autoFocus
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
cancelRename();
}
}}
/>
</motion.form>
) : (
<motion.button
key="session-summary"
type="button"
className="agent-history-session-trigger min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span
className="block truncate text-xs font-semibold text-slate-900"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
{formatSessionUpdatedAt(session.updatedAt)}
</span>
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-2">
{session.runStatus === "running" || session.isStreaming ? (
<StatusDot tone="info" activity="live" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2
size={13}
className="animate-spin text-[var(--action-blue-hover)]"
aria-hidden="true"
/>
) : null}
{active && !editing && !confirmingDelete ? (
<StatusBadge tone="info">当前</StatusBadge>
) : null}
{confirmingDelete ? (
<>
<Button
type="button"
variant="destructive"
size="sm"
loading={deleting}
loadingLabel="正在删除对话"
onClick={() => confirmDelete(session.id)}
>
确认
</Button>
<Button
type="button"
aria-label="取消删除"
title="取消删除"
size="icon-sm"
variant="ghost"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</Button>
</>
) : editing ? (
<>
<Button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
size="icon-sm"
loading={saving}
loadingLabel="正在保存对话主题"
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</Button>
<Button
type="button"
aria-label="取消重命名"
title="取消重命名"
size="icon-sm"
variant="ghost"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</Button>
</>
) : (
<>
<Button
type="button"
aria-label="重命名对话"
title="重命名对话"
size="icon-sm"
variant="ghost"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</Button>
<Button
type="button"
aria-label="删除对话"
title="删除对话"
size="icon-sm"
variant="ghost"
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
setConfirmingDeleteSessionId(session.id);
}}
>
<Trash2 size={14} aria-hidden="true" />
</Button>
</>
)}
</span>
</motion.div>
);
})
) : (
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
暂无历史记录
</motion.div>
)}
</AnimatePresence>
</motion.div>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input ref={searchRef} type="search" maxLength={200} aria-label="搜索标题和对话内容" placeholder="搜索标题或对话内容"
value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" />
</div>
{onTopicChange || pendingUpdate ? <div className="flex flex-wrap items-center justify-between gap-2">
{onTopicChange ? <DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline" size="sm" aria-label="筛选历史主题" className="max-w-full">
<FolderInput aria-hidden="true" /><span className="truncate">{topicId === "unclassified" ? "未分类" : topics.find((topic) => topic.id === topicId)?.name ?? "全部主题"}</span><ChevronDown aria-hidden="true" />
</Button></DropdownMenuTrigger><DropdownMenuContent data-agent-theme="chat" align="start" className="z-[112] max-h-64 overflow-y-auto">
<DropdownMenuRadioGroup value={topicId ?? "all"} onValueChange={(id) => onTopicChange(id === "all" ? undefined : id)}>
<DropdownMenuRadioItem value="all">全部主题</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>{topic.name}</DropdownMenuRadioItem>)}
</DropdownMenuRadioGroup>
</DropdownMenuContent></DropdownMenu> : null}
{pendingUpdate ? <Button variant="link" className="text-xs" onClick={() => void perform("refresh", () => onRefresh?.())}>
历史记录有更新,点击刷新
</Button> : null}
</div> : null}
{error || actionError ? <p role="alert" className="text-sm text-destructive">{actionError ?? error}</p> : null}
</div>
</div>
);
<Separator />
<div role="region" aria-label="Agent 历史记录" aria-busy={loading || undefined}
className="agent-history-list min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-4 sm:px-4">
{loading && !sessions.length ? <p role="status" className="px-3 py-8 text-center text-sm text-muted-foreground">正在加载历史对话…</p>
: !sessions.length ? <p className="px-3 py-8 text-center text-sm text-muted-foreground">{error ? "历史记录暂不可用,请刷新重试" : query ? "没有找到匹配的会话" : "暂无历史记录"}</p> : null}
{sessions.map((session) => {
const group = historyGroup(session.updatedAt);
const showGroup = group !== previousGroup;
previousGroup = group;
const active = session.id === activeSessionId;
const editing = session.id === editingId;
const deleting = session.id === deletingId;
const itemBusy = busy === session.id || loadingSessionId === session.id;
return <div key={session.id}>
{showGroup ? <h3 className="px-3 pb-2 pt-4 text-xs font-medium text-muted-foreground">{group}</h3> : null}
<div className={cn("agent-history-item flex items-center gap-3 rounded-md p-3", active ? "agent-history-item-active bg-accent" : "hover:bg-accent/50")}>
{editing ? <form className="flex min-w-0 flex-1 flex-wrap items-center gap-2" onSubmit={(event) => {
event.preventDefault(); if (title.trim()) void perform(session.id, () => onRenameSession?.(session.id, title.trim()));
}} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditingId(null); } }}>
<Input autoFocus aria-label="会话标题" value={title} disabled={itemBusy} onChange={(event) => setTitle(event.target.value)} className="min-w-0 flex-1" />
<Button type="submit" size="icon" aria-label="保存对话标题" loading={itemBusy} disabled={!title.trim()}><Check aria-hidden="true" /></Button>
<Button type="button" size="icon" variant="ghost" aria-label="取消重命名" onClick={() => setEditingId(null)}><X aria-hidden="true" /></Button>
</form> : movingId === session.id ? <div className="min-w-0 flex-1 space-y-2">
<p className="text-xs font-medium text-muted-foreground">移入主题</p>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{[{ id: "", name: "未分类" }, ...topics].map((topic) => <Button key={topic.id} variant="ghost" disabled={Boolean(busy)} className="h-auto min-h-9 justify-start whitespace-normal break-words text-left" onClick={() => void perform(session.id, async () => {
await onMoveSessionToTopic?.(session.id, topic.id || null); setMovingId(null);
})}>{topic.name}</Button>)}
</div><Button size="sm" variant="ghost" onClick={() => setMovingId(null)}>取消移动</Button>
</div> : deleting ? <div className="flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2">
<div className="min-w-0 flex-1"><p className="break-words text-sm">删除“{session.title}”?</p><p className="mt-1 text-xs text-muted-foreground">删除后无法从历史记录中恢复</p></div>
<Button variant="destructive" loading={itemBusy} onClick={() => void perform(session.id, () => onDeleteSession?.(session.id))}>确认</Button>
<Button variant="ghost" size="icon" aria-label="取消删除" onClick={() => setDeletingId(null)}><X aria-hidden="true" /></Button>
</div> : <>
<button type="button" className="agent-history-session-trigger min-w-0 flex-1 rounded-sm text-left outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-current={active ? "true" : undefined}
disabled={itemBusy || Boolean(loadingSessionId)} onClick={() => onSelectSession(session.id)}>
<span className="line-clamp-2 break-words text-sm font-medium leading-6 text-foreground" title={session.title}>{session.title}</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">{topics.find((topic) => topic.id === session.topicId)?.name ?? session.topicName ?? "未分类"}</span>
{session.searchSnippet ? <span className="mt-1 line-clamp-2 break-words text-xs leading-5 text-muted-foreground">{session.searchSnippet}</span> : null}
<span className="mt-1 block text-xs tabular-nums text-muted-foreground">{formatUpdatedAt(session.updatedAt)}{itemBusy ? " · 正在打开" : ""}</span>
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
{session.purpose === "task_collaboration" ? <StatusBadge tone="neutral">任务协作</StatusBadge> : null}
{active ? <StatusBadge tone="info">当前</StatusBadge> : null}
{session.needsInput ? <StatusBadge tone="warning">需要确认</StatusBadge> : null}
{session.isStreaming || session.runStatus === "running" ? <StatusBadge tone="info">处理中</StatusBadge> : null}
{session.runStatus === "error" ? <StatusBadge tone="danger">处理失败</StatusBadge> : null}
</span>
<DropdownMenu><DropdownMenuTrigger asChild><Button size="icon" variant="ghost" disabled={Boolean(loadingSessionId)} aria-label={`会话操作:${session.title}`}><MoreHorizontal aria-hidden="true" /></Button></DropdownMenuTrigger>
<DropdownMenuContent data-agent-theme="chat" align="end" className="z-[112]">
<DropdownMenuItem disabled={!onRenameSession} onSelect={() => { setEditingId(session.id); setDeletingId(null); setTitle(session.title); }}><Pencil aria-hidden="true" />重命名对话</DropdownMenuItem>
<DropdownMenuItem disabled={!onMoveSessionToTopic} onSelect={() => { setMovingId(session.id); setEditingId(null); setDeletingId(null); }}><FolderInput aria-hidden="true" />移入主题</DropdownMenuItem>
<DropdownMenuItem disabled={!onDeleteSession} onSelect={() => { setDeletingId(session.id); setEditingId(null); }}><Trash2 aria-hidden="true" />删除对话</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>}
</div>
</div>;
})}
{hasMore ? <div className="px-3 pt-4"><Button className="w-full" variant="outline" loading={loadingMore} onClick={() => void onLoadMore?.()}>加载更多</Button></div> : null}
</div>
</DialogContent>
</Dialog>;
}
function formatSessionUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(value);
function historyGroup(value: number) {
const now = new Date();
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const date = new Date(value);
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime();
const week = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime();
return start >= day ? "今天" : start === yesterday ? "昨天" : start >= week ? "近 7 天" : "更早";
}
function formatUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(value);
}
@@ -27,8 +27,10 @@ export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMo
<span>{messageStatusLabels[message.status]}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
<details className="text-[var(--ink-muted)]"><summary className="cursor-pointer">消息详情</summary>
<p className="break-all">任务编号:<span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all">运行编号:<span className="select-text font-mono">{message.run_id}</span></p>
</details>
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
</article>)}
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { LoaderCircle, MoreHorizontal, Pencil, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
type AgentPendingInputsProps = {
presentation: AgentCommandPanelPresentation;
inputs: AgentResponseInput[];
onDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
onEdit?: (id: string) => Promise<void> | void;
onCancel?: (id: string) => Promise<void> | void;
};
const menuItemClassName = "min-h-10 rounded-lg border border-transparent px-2.5 py-2 text-xs 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)]";
export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onEdit, onCancel }: AgentPendingInputsProps) {
const [busyId, setBusyId] = useState<string | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const latestId = inputs.at(-1)?.id;
useEffect(() => {
const list = listRef.current;
if (list) list.scrollTop = list.scrollHeight;
}, [latestId]);
const performAction = (id: string, action: () => Promise<void> | void) => {
setBusyId(id);
void Promise.resolve().then(action).catch(() => {}).finally(() => setBusyId(null));
};
if (!inputs.length) return null;
return (
<section aria-label="待处理消息队列" className="order-first w-full min-w-0 border-b border-[var(--surface-divider)] bg-[var(--surface-reading)]">
<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>
</p>
<ul ref={listRef} aria-label="待处理消息" className="max-h-[min(12rem,28dvh)] divide-y divide-[var(--surface-divider)] overflow-y-auto overscroll-contain px-2 pb-1">
{inputs.map((input) => (
<li key={input.id} aria-busy={busyId === input.id} className="flex min-w-0 items-start gap-1 py-1">
<PendingInputText text={input.text} />
<span className="shrink-0 py-2 text-[11px] leading-6 text-muted-foreground" title={input.delivery === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}>
{input.delivery === "queue" ? "排队" : "引导"}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={`消息操作:${input.text}`}
disabled={Boolean(busyId)} className="agent-panel-icon-button size-10 shrink-0 active:scale-95 motion-reduce:transform-none">
{busyId === input.id
? <LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden />
: <MoreHorizontal size={16} aria-hidden />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6} collisionPadding={12}
className="agent-panel-overlay w-48 rounded-xl p-1.5">
<DropdownMenuItem disabled={!onEdit} className={menuItemClassName}
onSelect={() => performAction(input.id, () => onEdit?.(input.id))}>
<Pencil size={14} aria-hidden />编辑消息
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 py-1 text-[11px] font-normal text-muted-foreground">处理方式</DropdownMenuLabel>
<DropdownMenuRadioGroup aria-label="消息处理方式" value={input.delivery} onValueChange={(delivery) => {
if (onDeliveryChange && (delivery === "steer" || delivery === "queue")) {
void Promise.resolve().then(() => onDeliveryChange(input.id, delivery)).catch(() => {});
}
}}>
<DropdownMenuRadioItem value="steer" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>引导当前任务</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="queue" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>排队处理</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!onCancel} className={menuItemClassName}
onSelect={() => performAction(input.id, () => onCancel?.(input.id))}>
<X size={14} aria-hidden />关闭排队
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</section>
);
}
function PendingInputText({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const textRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const node = textRef.current;
if (!node) return;
const measure = () => {
setOverflows(node.scrollHeight > Number.parseFloat(getComputedStyle(node).lineHeight) * 2 + 1);
};
const observer = new ResizeObserver(measure);
observer.observe(node);
measure();
return () => observer.disconnect();
}, [text]);
useEffect(() => {
if (expanded) textRef.current?.parentElement?.scrollIntoView({ block: "nearest" });
}, [expanded]);
return (
<div className="min-w-0 flex-1 px-1 py-2">
<p ref={textRef} className={cn("text-sm leading-6 text-foreground [overflow-wrap:anywhere]", expanded ? "whitespace-pre-wrap" : "whitespace-normal line-clamp-2")}>
{text}
</p>
{overflows ? <button type="button" aria-expanded={expanded} onClick={() => setExpanded((current) => !current)}
className="relative mt-1 rounded-sm text-[11px] leading-5 text-muted-foreground underline underline-offset-4 after:absolute after:-inset-y-2.5 after:inset-x-0 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:opacity-70">
{expanded ? "收起全文" : "展开全文"}
</button> : null}
</div>
);
}
+80 -29
View File
@@ -1,18 +1,37 @@
import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import asset from "@/shared/ai-elements/persona-resource-manifest.json";
import { cn } from "@/shared/ui/cn";
const LazyPersona = lazy(async () => {
const module = await import("@/shared/ai-elements/persona");
return { default: module.Persona };
});
const Persona = lazy(() =>
import("@/shared/ai-elements/persona").then((module) => ({ default: module.Persona }))
);
const stillSources = {
idle: "/agent/obsidian-water-idle.png",
asleep: "/agent/obsidian-water-offline.png"
};
type AgentPersonaProps = {
className?: string;
state?: PersonaState;
};
function usePrefersReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reducedMotion;
}
function useElementVisibility() {
const containerRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
@@ -20,60 +39,92 @@ function useElementVisibility() {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let intersectsViewport = false;
const updateVisibility = () => {
const updateVisibility = () =>
setVisible(intersectsViewport && document.visibilityState === "visible");
};
const observer = new IntersectionObserver(([entry]) => {
intersectsViewport = Boolean(entry?.isIntersecting);
updateVisibility();
});
observer.observe(container);
document.addEventListener("visibilitychange", updateVisibility);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", updateVisibility);
};
}, []);
return { containerRef, visible };
}
function PersonaFallback() {
function PersonaStill({ state }: { state: PersonaState }) {
const source = stillSources[state === "asleep" ? "asleep" : "idle"];
return <img className="agent-orb-still" src={`${source}?v=${asset.sha256}`} alt="" draggable={false} />;
}
function LivePersona({ state }: { state: PersonaState }) {
const [loaded, setLoaded] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const onLoad = useCallback(() => setLoaded(true), []);
const onLoadError = useCallback(() => setFailed(true), []);
useEffect(() => {
if (!loaded) return;
// Keep the matching still visible while Rive completes its entrance animation.
const timer = window.setTimeout(() => setReady(true), 600);
return () => window.clearTimeout(timer);
}, [loaded]);
return (
<span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
<span className="agent-orb-live" data-ready={ready && !failed} data-failed={failed}>
<PersonaStill state={state} />
{!failed ? (
<Suspense fallback={null}>
<Persona
className="agent-orb-canvas"
state={state}
onLoad={onLoad}
onLoadError={onLoadError}
/>
</Suspense>
) : null}
</span>
);
}
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
const { containerRef, visible } = useElementVisibility();
const [failed, setFailed] = useState(false);
const handleLoadError = useCallback(() => setFailed(true), []);
const reducedMotion = usePrefersReducedMotion();
const [hovered, setHovered] = useState(false);
const live = visible && !reducedMotion;
const shouldRenderAnimation = !failed && visible;
useEffect(() => {
if (!live) setHovered(false);
}, [live]);
return (
<span
ref={containerRef}
aria-label="Agent 状态"
className={cn("relative block shrink-0", className)}
className={cn("relative block h-16 w-16 shrink-0", className)}
>
{shouldRenderAnimation ? (
<Suspense fallback={<PersonaFallback />}>
<LazyPersona
className="h-full w-full scale-110"
onLoadError={handleLoadError}
state={state}
/>
</Suspense>
) : (
<PersonaFallback />
)}
<span
className="agent-orb"
data-agent-orb={state}
data-animated={live}
data-hovered={hovered && live}
aria-hidden="true"
onPointerEnter={(event) => {
if (event.isPrimary && event.pointerType !== "touch") setHovered(true);
}}
onPointerLeave={() => setHovered(false)}
onPointerCancel={() => setHovered(false)}
>
<span className="agent-orb-response">
{live ? <LivePersona state={state} /> : <PersonaStill state={state} />}
<span className="agent-orb-contrast" />
</span>
</span>
</span>
);
}
@@ -0,0 +1,55 @@
import { ChevronsUpDown, Folder, FolderPlus, Settings2 } from "lucide-react";
import { useRef, useState } from "react";
import type { AgentTopic } from "../api/client";
import { Button } from "@/shared/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { AgentTopicDialog } from "./agent-topic-dialog";
export type AgentTopicControlsProps = {
topics: AgentTopic[];
topicId?: string | null;
loading?: boolean;
error?: string;
onOpen: () => Promise<unknown>;
onSelect: (id: string | null) => void;
onCreate: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
export function AgentTopicControls({ topics, topicId, loading, error, onOpen, onSelect, onCreate, onRename, onDelete }: AgentTopicControlsProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"create" | "manage">("create");
const [dialogKey, setDialogKey] = useState(0);
const current = topics.find((topic) => topic.id === topicId);
const begin = (next: "create" | "manage") => {
setMode(next);
setDialogKey((key) => key + 1);
setOpen(true);
};
return <>
<DropdownMenu onOpenChange={(next) => { if (next) void onOpen().catch(() => {}); }}>
<DropdownMenuTrigger asChild><Button ref={triggerRef} variant="outline" size="sm" aria-label="选择主题" aria-description={current?.name ?? "未分类"} title={`主题:${current?.name ?? "未分类"}`} className="min-w-0 max-w-full justify-start gap-1.5 px-1.5 text-xs font-medium text-foreground">
<Folder aria-hidden="true" className="text-muted-foreground" /><span className="truncate">{current?.name ?? "未分类"}</span><ChevronsUpDown aria-hidden="true" className="ml-auto text-muted-foreground" />
</Button></DropdownMenuTrigger>
<DropdownMenuContent data-agent-theme="chat" align="start" side="bottom" className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56 max-w-[min(20rem,calc(100vw-1rem))]">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">切换主题</DropdownMenuLabel>
<DropdownMenuRadioGroup value={current?.id ?? "unclassified"} onValueChange={(id) => onSelect(id === "unclassified" ? null : id)} className="max-h-[min(45dvh,15rem)] overflow-y-auto">
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>
<span className="min-w-0 line-clamp-2 break-words" title={topic.name}>{topic.name}</span>
</DropdownMenuRadioItem>)}
</DropdownMenuRadioGroup>
{loading ? <p role="status" className="px-2 py-2 text-xs text-muted-foreground">正在加载主题…</p> : null}
{error ? <p role="alert" className="px-2 py-2 text-xs text-destructive">{error}</p> : null}
<DropdownMenuSeparator className="bg-border/50" />
<DropdownMenuItem onSelect={() => begin("create")}><FolderPlus size={15} />新建主题</DropdownMenuItem>
<DropdownMenuItem onSelect={() => begin("manage")}><Settings2 size={15} />管理主题</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AgentTopicDialog key={dialogKey} open={open} initialMode={mode} onOpenChange={setOpen} triggerRef={triggerRef}
topics={topics} onCreate={onCreate} onRename={onRename} onDelete={onDelete} />
</>;
}
@@ -0,0 +1,108 @@
import { Folder, FolderPlus, Pencil, Trash2, X } from "lucide-react";
import { useId, useRef, useState, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import type { AgentTopic } from "../api/client";
type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic };
type Props = {
open: boolean;
initialMode: "create" | "manage";
onOpenChange: (open: boolean) => void;
triggerRef: RefObject<HTMLButtonElement | null>;
topics: AgentTopic[];
onCreate: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
export function AgentTopicDialog({ open, initialMode, onOpenChange, triggerRef, topics, onCreate, onRename, onDelete }: Props) {
const [view, setView] = useState<TopicView>({ kind: initialMode === "create" ? "create" : "list" });
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const fieldId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const newTopicRef = useRef<HTMLButtonElement>(null);
const isForm = view.kind === "create" || view.kind === "rename";
const title = view.kind === "create" ? "新建主题" : view.kind === "rename" ? "重命名主题" : view.kind === "delete" ? "删除主题" : "管理主题";
const description = view.kind === "create" ? "将相关事件、问题和对话整理到同一主题。"
: view.kind === "rename" ? "修改名称不会影响主题中的现有会话。"
: view.kind === "delete" ? "仅删除分类,其中的会话会保留在未分类中。"
: `当前项目共有 ${topics.length} 个主题,用于整理相关事件与问题。`;
const changeView = (next: TopicView) => {
setError(undefined);
setName(next.kind === "rename" ? next.topic.name : "");
setView(next);
};
const perform = async (action: () => Promise<unknown>, done: () => void) => {
setBusy(true);
setError(undefined);
try { await action(); done(); }
catch (reason) { setError(reason instanceof Error ? reason.message : "主题操作失败,请重试"); }
finally { setBusy(false); }
};
const cancel = () => initialMode === "create" ? onOpenChange(false) : changeView({ kind: "list" });
const footerClass = "gap-2 px-6 pb-6 sm:gap-0";
return <Dialog open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}>
<DialogContent 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"
onOpenAutoFocus={(event) => { event.preventDefault(); (inputRef.current ?? newTopicRef.current)?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
<DialogHeader className="p-6 pb-4 pr-14 text-left">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Button variant="ghost" size="icon-sm" className="absolute right-4 top-4" aria-label="关闭主题窗口" disabled={busy} onClick={() => onOpenChange(false)}>
<X aria-hidden="true" />
</Button>
{isForm ? <form className="flex min-h-0 flex-col" onSubmit={(event) => {
event.preventDefault();
if (!name.trim() || busy) return;
void perform(() => view.kind === "rename" ? onRename(view.topic.id, name.trim()) : onCreate(name.trim()),
() => view.kind === "create" ? onOpenChange(false) : changeView({ kind: "list" }));
}}>
<div className="min-h-0 space-y-2 overflow-y-auto px-6 pb-6">
<label htmlFor={fieldId} className="text-sm font-medium">主题名称</label>
<Input ref={inputRef} id={fieldId} autoFocus maxLength={80} value={name} disabled={busy}
aria-describedby={`${fieldId}-hint`} onChange={(event) => setName(event.target.value)} placeholder="例如:城南压力异常排查" />
<p id={`${fieldId}-hint`} className="text-xs text-muted-foreground">名称最多 80 个字符。</p>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter className={footerClass}>
<Button type="button" variant="outline" disabled={busy} onClick={cancel}>取消</Button>
<Button type="submit" loading={busy} disabled={!name.trim()}>{view.kind === "rename" ? "保存主题" : "创建主题"}</Button>
</DialogFooter>
</form> : view.kind === "delete" ? <>
<div className="min-h-0 space-y-3 overflow-y-auto px-6 pb-6">
<p className="break-words text-sm">确认删除“{view.topic.name}”?</p>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter className={footerClass}>
<Button type="button" autoFocus variant="outline" disabled={busy} onClick={() => changeView({ kind: "list" })}>取消</Button>
<Button variant="destructive" loading={busy} onClick={() => void perform(() => onDelete(view.topic.id), () => changeView({ kind: "list" }))}>确认删除主题</Button>
</DialogFooter>
</> : <>
<div role="list" aria-label="主题列表" className="min-h-0 overflow-y-auto px-6 pb-4">
{topics.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">还没有主题,可以新建一个来整理对话。</p> : topics.map((topic) => (
<div role="listitem" key={topic.id} className="flex items-center gap-3 border-b border-border/40 py-3 last:border-0">
<Folder aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 break-words text-sm font-medium">{topic.name}</span>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" aria-label={`重命名主题:${topic.name}`} onClick={() => changeView({ kind: "rename", topic })}><Pencil aria-hidden="true" /></Button>
<Button variant="ghost" size="icon-sm" aria-label={`删除主题:${topic.name}`} onClick={() => changeView({ kind: "delete", topic })}><Trash2 aria-hidden="true" /></Button>
</div>
</div>
))}
</div>
<DialogFooter className={footerClass}>
<Button ref={newTopicRef} autoFocus variant="outline" onClick={() => changeView({ kind: "create" })}><FolderPlus aria-hidden="true" />新建主题</Button>
<Button onClick={() => onOpenChange(false)}>完成</Button>
</DialogFooter>
</>}
</DialogContent>
</Dialog>;
}
@@ -71,11 +71,11 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
);
}
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100);
@@ -84,9 +84,9 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
}
return (
<div className="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="min-w-0">
{!embedded ? <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
</p>
@@ -94,7 +94,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
{spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div>
</div> : null}
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
@@ -0,0 +1,20 @@
import { createElement, type ComponentType, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it } from "vitest";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
it("keeps external link safety and rejects forged custom export links", () => {
const Renderer = artifactLinkComponents["agent-export-link"] as ComponentType<{ href: string; children: ReactNode }>;
const path = "/api/v1/session/s/files/mcp-11111111-1111-1111-1111-111111111111.zip";
const render = (href: string) => renderToStaticMarkup(createElement(Renderer, { href, children: "下载" }));
expect(render(path)).toContain(`<a href="${path}"`);
for (const href of ["https://foreign.test/x", "javascript:alert(1)", "/api/v1/admin/secrets"]) {
expect(render(href)).toBe("<span>下载</span>");
}
const root = { type: "root", children: [
{ type: "element", tagName: "a", properties: { href: path } },
{ type: "element", tagName: "a", properties: { href: "https://example.com" } },
] };
artifactLinksPlugin()(root);
expect(root.children.map((child) => child.tagName)).toEqual(["agent-export-link", "a"]);
});
@@ -0,0 +1,32 @@
import type { Components } from "streamdown";
import type { ReactNode } from "react";
type MarkdownNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: MarkdownNode[];
};
const artifactPath = /^\/api\/v1\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx)$/;
// Only verified-format first-party download paths use the authenticated click
// handler. All other anchors retain Streamdown's built-in link safety dialog.
export function artifactLinksPlugin() {
return (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.tagName === "a" && typeof node.properties?.href === "string" && artifactPath.test(node.properties.href)) {
node.tagName = "agent-export-link";
}
node.children?.forEach(visit);
};
visit(tree);
};
}
export const artifactLinkComponents: Components = {
"agent-export-link": ({ href, children }) => {
if (typeof href !== "string" || !artifactPath.test(href)) return <span>{children as ReactNode}</span>;
return <a href={href} className="wrap-anywhere font-medium text-primary underline">{children as ReactNode}</a>;
}
};
@@ -1,5 +1,9 @@
import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message";
import { defaultRehypePlugins } from "streamdown";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin];
type StreamingTokenResponseProps = {
content: string;
@@ -27,6 +31,8 @@ export function StreamingTokenResponse({
isAnimating={isAnimating}
mode="streaming"
parseIncompleteMarkdown={true}
rehypePlugins={responseRehypePlugins}
components={artifactLinkComponents}
>
{content}
</MessageResponse>
+8 -3
View File
@@ -7,7 +7,6 @@ export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
AgentCommandPanelPresentation,
AgentConversationProjectOption,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
@@ -20,6 +19,7 @@ export type {
AgentInputDelivery,
AgentResponseInput,
AgentChatSessionSummary,
AgentTopic,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
@@ -94,8 +94,13 @@ export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data
export { parseFrontendChart } from "./frontend-action/chart";
export { parseAnswerUiBlock } from "./answer-ui/contract";
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
export { AnswerUiView } from "./answer-ui/answer-ui-view";
export { parseAnswerUiBlock, reportUiBlocksSchema } 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 { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
export type { AgentRunStatus } from "./api/client";
@@ -1,5 +1,6 @@
import { getWorkbenchAccessToken } from "./request-context";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
@@ -48,13 +49,13 @@ export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
elementId: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
@@ -82,13 +83,13 @@ export async function fetchSourceGisFeatureProperties(
layerKey: string,
sourceFid: number,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
@@ -74,3 +74,12 @@ describe("investigation report read contract", () => {
expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow();
});
});
it("retains fallback text from a future component while reading the fixed report facts", () => {
const value = investigationReportSchema.parse({ ...base, ui_blocks: [{ evidence_refs: ["query-1"], block: {
version: "answer-ui@future", block_id: "future", fallback_text: "旧版客户端仍可阅读。", spec: { future: true },
} }] });
expect(value.current_conclusion).toBe(base.current_conclusion);
expect(value.ui_blocks?.[0].block.fallback_text).toBe("旧版客户端仍可阅读。");
expect(investigationReportSchema.parse(base).ui_blocks).toBeUndefined();
});
@@ -1,11 +1,13 @@
import { z } from "zod";
import type { AgentTaskDiscussionContext } from "@/features/agent";
import type { AgentTaskDiscussionContext, AnswerUiSelection } from "@/features/agent";
import { reportUiBlocksSchema } from "@/features/agent";
export type InvestigationDiscussion = {
prompt?: string;
context?: AgentTaskDiscussionContext;
// Display metadata from the selected report, never the task's current round.
reportGeneration?: number | null;
reference?: AnswerUiSelection;
};
const text = z.string().trim().min(1);
@@ -74,7 +76,8 @@ export const investigationReportSchema = z
outcome: z.enum(["concluded", "insufficient_data"]).nullish(),
stop_reason: text.nullish(),
data_gaps: z.array(dataGapSchema).nullish(),
diagram: investigationDiagramSchema.nullish()
diagram: investigationDiagramSchema.nullish(),
ui_blocks: reportUiBlocksSchema.optional()
})
.strict()
.superRefine((report, ctx) => {
@@ -1,7 +1,7 @@
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema } from "./investigation-presentation";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -316,6 +316,39 @@ const taskDataSnapshotSchema = z.object({
"run_burst_location",
"analyze_valve_isolation",
"run_leakage_identification",
"get_mutation_status",
"query_nearby_elements",
"list_regions",
"get_region",
"get_region_nodes",
"get_node_connections",
"query_network_extent",
"list_major_nodes",
"get_model_configuration",
"list_model_configurations",
"search_project_places",
"reverse_geocode",
"get_project_info",
"export_project_model",
"get_sensor_placement_candidate",
"export_sensor_placement",
"list_task_schedules",
"get_task_run_history",
"calculate_demand_allocation",
"run_burst_simulation",
"edit_model_element",
"edit_model_configuration",
"clean_scada_readings",
"write_scada_readings",
"delete_scada_readings",
"save_simulation_scenario",
"create_sensor_placement",
"update_sensor_placement",
"create_operational_task",
"create_task_schedule",
"update_task_schedule",
"retry_operational_task",
"cancel_task_run",
]),
source_ref: z.string().min(1),
queried_at: workbenchDateTimeSchema,
@@ -366,7 +399,7 @@ export type CreateOperationalTaskInput = {
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
@@ -376,7 +409,7 @@ async function operationalTaskRequest(path: string, getAccessToken?: AccessToken
return payload;
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}`,
getAccessToken,
@@ -384,19 +417,19 @@ export async function fetchOperationalTask(taskId: string, getAccessToken?: Acce
));
}
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) {
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: WorkbenchAccess) {
return reportPageSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken,
));
}
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
return investigationPresentationCursorSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal }
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
getAccessToken,
@@ -409,7 +442,7 @@ export async function decideOperationalTaskAction(taskId: string, actionId: stri
expected_version: number;
decision: OperationalTaskDecision;
comment?: string;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
getAccessToken,
@@ -420,7 +453,7 @@ export async function decideOperationalTaskAction(taskId: string, actionId: stri
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
getAccessToken,
@@ -430,7 +463,7 @@ export async function executeOperationalTaskAction(taskId: string, actionId: str
export async function updateOperationalWorkOrderDraft(taskId: string, actionId: string, input: {
request_id: string; expected_version: number; payload: Record<string, unknown>;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/draft`,
getAccessToken, { method: "PATCH", body: JSON.stringify(input) }
@@ -439,7 +472,7 @@ export async function updateOperationalWorkOrderDraft(taskId: string, actionId:
export async function confirmOperationalWorkOrderDispatch(taskId: string, actionId: string, input: {
request_id: string; expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/confirm-dispatch`,
getAccessToken, { method: "POST", body: JSON.stringify(input) }
@@ -449,14 +482,14 @@ export async function confirmOperationalWorkOrderDispatch(taskId: string, action
export async function decideOperationalTaskAccess(taskId: string, accessRequestId: string, input: {
request_id: string; expected_version: number; decision: "approved" | "rejected";
approved_scopes: string[]; comment?: string;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return accessRequestSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/access-requests/${encodeURIComponent(accessRequestId)}/decision`,
getAccessToken, { method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
export async function fetchOperationalTaskNotifications(getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
return notificationPageSchema.parse(await operationalTaskRequest(
"/operational-task-notifications?unread_only=false&limit=100&offset=0",
getAccessToken,
@@ -464,7 +497,7 @@ export async function fetchOperationalTaskNotifications(getAccessToken?: AccessT
)).items;
}
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: WorkbenchAccess) {
return notificationSchema.parse(await operationalTaskRequest(
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
getAccessToken,
@@ -472,7 +505,7 @@ export async function acknowledgeOperationalTaskNotification(notificationId: str
));
}
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
export async function fetchOperationalTaskSchedules(getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
return schedulePageSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
@@ -488,7 +521,7 @@ export async function createOperationalTaskSchedule(input: {
interval_seconds: number;
next_run_at: string;
priority: "normal" | "urgent";
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return scheduleSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
@@ -499,7 +532,7 @@ export async function createOperationalTaskSchedule(input: {
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
}, getAccessToken?: WorkbenchAccess) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
getAccessToken,
@@ -507,7 +540,7 @@ export async function reconcileOperationalTaskAction(taskId: string, actionId: s
));
}
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: WorkbenchAccess) {
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
getAccessToken
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { searchPlaces } from "./place-search-client";
import { reverseGeocodePoint, searchPlaces } from "./place-search-client";
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
afterEach(() => vi.restoreAllMocks());
describe("Fengxian place search client", () => {
describe("project place search client", () => {
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
@@ -22,7 +22,7 @@ describe("Fengxian place search client", () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
});
it("accepts at most ten Fengxian candidates", async () => {
it("accepts at most ten candidates", async () => {
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
@@ -39,7 +39,21 @@ describe("Fengxian place search client", () => {
{ status: "0", location: { lon: "121", lat: "31" } }
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
await expect(searchPlaces("南桥路", "project-1", undefined, undefined, { enabled: true, provider: "tianditu", admin_code: "310120", region: "奉贤区" })).rejects.toThrow("未获得有效的地名搜索结果");
});
it("accepts candidates in different districts within a configured city", async () => {
const cityResults = { ...results, region: "上海市", items: [item, { ...item, id: "place-2", district: "青浦区" }], total: 2 };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(cityResults));
expect(await searchPlaces("路", "project-1", undefined, undefined, {
enabled: true, provider: "tianditu", admin_code: "310000", region: "上海市"
})).toEqual(cityResults);
});
it("accepts a configured project outside Fengxian", async () => {
const value = { ...results, region: "凤阳县", items: [{ ...item, name: "府城镇", district: "凤阳县" }] };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(value));
expect(await searchPlaces("府城镇", "fengyang", undefined, undefined, {
enabled: true, provider: "tianditu", admin_code: "341126", region: "凤阳县"
})).toEqual(value);
});
it("does not fetch blank keywords", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
@@ -59,3 +73,29 @@ describe("Fengxian place search client", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("reverse geocoding point client", () => {
it("sends coordinates with project and authentication headers", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(item));
expect(await reverseGeocodePoint(item.longitude, item.latitude, "project-1", async () => "token")).toEqual(item);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain("/api/v1/reverse-geocoding-requests");
expect(JSON.parse(init?.body as string)).toEqual({ longitude: item.longitude, latitude: item.latitude });
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-1");
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
});
it.each([[181, 31], [121, 91], [NaN, 31], [121, Infinity]])("rejects invalid input %s, %s", async (longitude, latitude) => {
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(reverseGeocodePoint(longitude, latitude, "project-1")).rejects.toThrow("坐标无效");
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects a result that changes the clicked point", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...item, longitude: item.longitude + 0.01 }));
await expect(reverseGeocodePoint(item.longitude, item.latitude, "project-1")).rejects.toThrow("未获得有效");
});
it("preserves an out of project scope message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "取点位置不在当前项目的地名搜索范围内(凤阳县)" }, { status: 422 }));
await expect(reverseGeocodePoint(117, 32, "project-1")).rejects.toThrow("凤阳县");
});
});
@@ -1,7 +1,8 @@
import { getWorkbenchAccessToken } from "./request-context";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -9,11 +10,11 @@ const placeSchema = z.object({
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90),
category: z.string().nullable(), district: z.literal("奉贤区")
category: z.string().nullable(), district: z.string().trim().min(1)
});
const resultsSchema = z.object({
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
truncated: z.boolean().default(false), region: z.literal("奉贤区")
truncated: z.boolean().default(false), region: z.string().trim().min(1)
});
export type PlaceSearchItem = z.infer<typeof placeSchema>;
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
@@ -21,33 +22,70 @@ export type PlaceSearchResults = z.infer<typeof resultsSchema>;
export async function searchPlaces(
keyword: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
configuration?: PlaceSearchConfiguration | null
): Promise<PlaceSearchResults> {
const query = keyword.trim();
if (!query) throw new Error("请输入需要搜索的地址或地名");
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify({ keyword: query }),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
const payload = await requestPlaceData("place-search-requests", { keyword: query }, projectId, getAccessToken, signal);
const parsed = resultsSchema.safeParse(payload);
if (!parsed.success
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|| parsed.data.total !== parsed.data.items.length) {
|| parsed.data.total !== parsed.data.items.length
|| (configuration && (parsed.data.region !== configuration.region
|| (!configuration.admin_code.endsWith("00") && parsed.data.items.some((item) => item.district !== configuration.region))))) {
throw new Error("未获得有效的地名搜索结果,请稍后重试");
}
return parsed.data;
}
export async function reverseGeocodePoint(
longitude: number,
latitude: number,
projectId: string | null,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
configuration?: PlaceSearchConfiguration | null
): Promise<PlaceSearchItem> {
const coordinates = z.object({
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90)
}).safeParse({ longitude, latitude });
if (!coordinates.success) throw new Error("地图取点坐标无效");
const payload = await requestPlaceData("reverse-geocoding-requests", coordinates.data, projectId, getAccessToken, signal);
const parsed = placeSchema.safeParse(payload);
if (!parsed.success || parsed.data.longitude !== longitude || parsed.data.latitude !== latitude
|| (configuration && !configuration.admin_code.endsWith("00") && parsed.data.district !== configuration.region)) {
throw new Error("未获得有效的地图取点结果,请重新取点");
}
return parsed.data;
}
async function requestPlaceData(
path: string,
body: Record<string, string | number>,
projectId: string | null,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<unknown> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify(body),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
return payload;
}
@@ -1,11 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchProjectPresentation,
parsePlaceSearchConfiguration,
fetchWorkbenchProjects,
clearWorkbenchProjectCache,
findOwnedWorkbenchProject,
parseProjectMapExtent
} from "./project-client";
describe("workbench project client", () => {
it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }));
vi.stubGlobal("fetch", fetch);
const provider = async () => "owner-token";
await Promise.all([fetchWorkbenchProjects(provider), fetchWorkbenchProjects(provider)]);
for (let i = 0; i < 10; i++) await fetchWorkbenchProjects(provider);
expect(fetch).toHaveBeenCalledTimes(1);
await fetchWorkbenchProjects(provider, undefined, true);
expect(fetch).toHaveBeenCalledTimes(2);
clock.mockReturnValue(300_101);
await fetchWorkbenchProjects(provider);
expect(fetch).toHaveBeenCalledTimes(3);
clearWorkbenchProjectCache(provider);
await fetchWorkbenchProjects(provider);
expect(fetch).toHaveBeenCalledTimes(4);
});
it("uses the explicitly selected project for each request without rediscovering the demo project", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn(async () => "token");
const project = { project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" };
const scoped = await findOwnedWorkbenchProject({ project, getAccessToken });
const other = await findOwnedWorkbenchProject({ project: { ...project, project_id: "demo" }, getAccessToken });
expect(scoped).toEqual({ project, accessToken: "token" });
expect(other.project.project_id).toBe("demo");
expect(fetchMock).not.toHaveBeenCalled();
});
it("loads all authorized project pages and excludes inactive projects", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [
{ project_id: "old", gs_workspace: "old", status: "inactive" },
{ project_id: "demo", gs_workspace: "tjwater_next", status: "active" }
], total: 3, limit: 1000, offset: 0 })))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [
{ project_id: "fengyang", gs_workspace: "fengyang_v2", status: "active" }
], total: 3, limit: 1000, offset: 2 })));
vi.stubGlobal("fetch", fetchMock);
expect((await fetchWorkbenchProjects()).map((project) => project.project_id)).toEqual(["demo", "fengyang"]);
expect(String(fetchMock.mock.calls[1][0])).toContain("offset=2");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
@@ -158,3 +204,16 @@ describe("workbench project client", () => {
});
});
});
describe("project place search configuration", () => {
it("reads configured regions and preserves unrelated map configuration", () => {
const config = { enabled: true, provider: "tianditu", admin_code: "341126", region: "凤阳县" };
expect(parsePlaceSearchConfiguration({ legend: "existing", place_search: config })).toEqual(config);
});
it.each([undefined, {}, { place_search: null }, { place_search: { enabled: false } },
{ place_search: { enabled: true, provider: "tianditu", admin_code: "invalid", region: "凤阳县" } }
])("disables absent or invalid configuration: %j", (config) => {
expect(parsePlaceSearchConfiguration(config)).toBeNull();
});
});
+66 -4
View File
@@ -1,8 +1,10 @@
import { ResourceCache } from "@/shared/cache/resource-cache";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
const projectSchema = z.object({
project_id: z.string().min(1),
@@ -22,12 +24,27 @@ const projectPageSchema = z.object({
export type WorkbenchProject = z.infer<typeof projectSchema>;
const placeSearchConfigurationSchema = z.object({
enabled: z.boolean(),
provider: z.literal("tianditu"),
admin_code: z.string().regex(/^(?:156)?[0-9]{6}$/),
region: z.string().trim().min(1)
});
export type PlaceSearchConfiguration = z.infer<typeof placeSearchConfigurationSchema>;
export function parsePlaceSearchConfiguration(mapConfig: unknown): PlaceSearchConfiguration | null {
if (!mapConfig || typeof mapConfig !== "object" || !("place_search" in mapConfig)) return null;
const parsed = placeSearchConfigurationSchema.safeParse(mapConfig.place_search);
return parsed.success && parsed.data.enabled ? parsed.data : null;
}
const projectMetadataSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1),
code: z.string().trim().min(1),
gs_workspace: z.string().trim().min(1),
map_extent: z.unknown().nullable().optional(),
map_config: z.unknown().optional(),
status: z.string(),
project_role: z.string().trim().min(1)
});
@@ -64,13 +81,14 @@ export type WorkbenchProjectPresentation = {
systemRole: string | null;
isSystemAdmin: boolean;
networkView: NetworkViewParams | null;
placeSearch: PlaceSearchConfiguration | null;
metadataAvailable: boolean;
accessContextAvailable: boolean;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const PROJECT_CACHE_TTL_MS = 5 * 60_000;
const projectContextPromises = new WeakMap<AccessTokenProvider, {
const projectContextPromises = new WeakMap<WorkbenchAccess, {
promise: Promise<WorkbenchProject>;
expiresAt: number;
}>();
@@ -86,11 +104,13 @@ function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal)
}
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const accessToken = await getWorkbenchAccessToken(getAccessToken);
signal?.throwIfAborted();
const scopedProject = getWorkbenchProject(getAccessToken);
if (scopedProject) return { project: scopedProject, accessToken };
const existing = getAccessToken
? projectContextPromises.get(getAccessToken)
: undefined;
@@ -123,7 +143,7 @@ export async function findOwnedWorkbenchProject(
}
export async function fetchWorkbenchProjectPresentation(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchProjectPresentation> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
@@ -147,11 +167,53 @@ export async function fetchWorkbenchProjectPresentation(
systemRole: accessContext?.system_role ?? null,
isSystemAdmin: accessContext?.is_system_admin ?? false,
networkView: parseProjectMapExtent(metadata?.map_extent),
placeSearch: parsePlaceSearchConfiguration(metadata?.map_config),
metadataAvailable: metadata !== null,
accessContextAvailable: accessContext !== null
};
}
const projectListCaches = new WeakMap<AccessTokenProvider, ResourceCache>();
export function clearWorkbenchProjectCache(provider?: AccessTokenProvider) {
if (provider) { projectListCaches.delete(provider); projectContextPromises.delete(provider); }
}
export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal, force = false) {
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
let cache = projectListCaches.get(getAccessToken);
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
PROJECT_CACHE_TTL_MS, { force });
if (!signal) return pending;
signal.throwIfAborted();
return new Promise<WorkbenchProject[]>((resolve, reject) => {
const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abort, { once: true });
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
const token = await getAccessToken?.();
signal?.throwIfAborted();
const projects: WorkbenchProject[] = [];
let offset = 0;
while (true) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
headers: createWorkbenchHeaders(token), signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const page = projectPageSchema.parse(payload);
projects.push(...page.items.filter((project) => project.status === "active"));
offset += page.items.length;
if (offset >= page.total) break;
if (!page.items.length) throw new Error("项目列表分页未返回后续数据");
}
return projects;
}
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
let bbox: z.infer<typeof bboxSchema> | null = null;
if (value && typeof value === "object" && "bbox" in value) {
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -20,7 +20,7 @@ export type RealtimePipeFlowSnapshot = {
};
export async function fetchRealtimePipeFlows(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
now = new Date()
): Promise<RealtimePipeFlowSnapshot> {
@@ -0,0 +1,23 @@
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchProject, WorkbenchProjectPresentation } from "./project-client";
export type WorkbenchRequestContext = {
project: WorkbenchProject;
getAccessToken?: AccessTokenProvider;
presentation?: WorkbenchProjectPresentation;
};
// Unscoped callers are retained for isolated tools and existing integrations.
export type WorkbenchAccess = WorkbenchRequestContext | AccessTokenProvider;
export function getWorkbenchTokenProvider(access?: WorkbenchAccess): AccessTokenProvider | undefined {
return typeof access === "function" ? access : access?.getAccessToken;
}
export function getWorkbenchAccessToken(access?: WorkbenchAccess) {
return getWorkbenchTokenProvider(access)?.();
}
export function getWorkbenchProject(access?: WorkbenchAccess) {
return typeof access === "object" ? access.project : undefined;
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchScadaDeviceCollection, fetchScadaSnapshot } from "./scada-client";
import { fetchScadaDeviceCollection, fetchScadaSnapshot, ScadaSystemClient } from "./scada-client";
const device = {
device_id: "SCADA-1",
@@ -119,4 +119,47 @@ describe("SCADA API client", () => {
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("reads the bounded latest-value projection and reuses the resolved project", async () => {
const latestPayload = {
items: [{
device_id: "SCADA-1",
device_type: "pressure",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: "2026-10-22T01:02:00Z",
monitored_value: 0.31,
cleaned_value: 0.3
}],
total: 1,
limit: 500,
offset: 0,
observed_count: 1,
latest_observed_at: "2026-10-22T01:02:00Z"
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(latestPayload), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(latestPayload), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("access-token");
const client = new ScadaSystemClient(getAccessToken);
await expect(client.getLatestReadings()).resolves.toEqual(latestPayload);
await expect(client.getLatestReadings()).resolves.toEqual(latestPayload);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0"
);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("X-Project-Id")).toBe("project-1");
expect(getAccessToken).toHaveBeenCalledTimes(3);
});
});
+59 -3
View File
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -36,7 +36,29 @@ const scadaReadingSchema = z.object({
const scadaReadingsSchema = z.array(scadaReadingSchema);
const latestScadaReadingSchema = z.object({
device_id: z.string().trim().min(1),
device_type: z.string().trim().min(1),
measurement_unit: z.string(),
transmission_mode: z.string(),
transmission_frequency: z.string(),
observed_at: z.string().datetime({ offset: true }).nullable(),
monitored_value: z.number().finite().nullable(),
cleaned_value: z.number().finite().nullable()
});
const latestScadaReadingsSchema = z.object({
items: z.array(latestScadaReadingSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().min(1).max(500),
offset: z.number().int().nonnegative(),
observed_count: z.number().int().nonnegative(),
latest_observed_at: z.string().datetime({ offset: true }).nullable()
});
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
export type LatestScadaReading = z.infer<typeof latestScadaReadingSchema>;
export type LatestScadaReadings = z.infer<typeof latestScadaReadingsSchema>;
export type ScadaSnapshot = {
collection: FeatureCollection<Point, ScadaDevice>;
@@ -49,15 +71,49 @@ const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100;
export class ScadaSystemClient {
private projectIdPromise: Promise<string> | null = null;
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
async getLatestReadings(signal?: AbortSignal): Promise<LatestScadaReadings> {
const [projectId, accessToken] = await Promise.all([
this.resolveProjectId(),
getWorkbenchAccessToken(this.getAccessToken)
]);
return latestScadaReadingsSchema.parse(
await requestJson(
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0",
accessToken,
projectId,
signal
)
);
}
private resolveProjectId() {
// Project identity is stable for this panel instance. Do not bind the shared
// promise to an individual refresh signal because React StrictMode cancels
// and immediately restarts effects during development.
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken)
.then(({ project }) => project.project_id)
.catch((error) => {
this.projectIdPromise = null;
throw error;
});
return this.projectIdPromise;
}
}
export async function fetchScadaDeviceCollection(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<FeatureCollection<Point, ScadaDevice>> {
return (await fetchScadaSnapshot(getAccessToken, signal)).collection;
}
export async function fetchScadaSnapshot(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
now = new Date()
): Promise<ScadaSnapshot> {
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -47,7 +47,7 @@ export async function fetchWorkbenchAnalysisHistory(
options: {
limit?: number;
offset?: number;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
signal?: AbortSignal;
} = {}
): Promise<WorkbenchAnalysisHistoryPage> {
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time";
import type {
@@ -254,7 +254,7 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
export async function fetchWorkbenchAnalysisRun(
runId: string,
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim();
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import {
parseOperationalTaskNotification,
@@ -60,7 +60,7 @@ export type WorkbenchSyncSnapshot = {
};
export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
@@ -82,7 +82,7 @@ export async function fetchWorkbenchSyncSnapshot(
export async function consumeWorkbenchEvents(input: {
after: number;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
onEvents: (events: WorkbenchEvent[]) => void;
onOpen?: () => void;
onReset: () => Promise<void>;
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
@@ -22,7 +22,7 @@ export function AnalysisHistorySection({
getAccessToken,
onOpenArtifact
}: {
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
onOpenArtifact: (artifact: AnalysisArtifact) => void;
}) {
const [items, setItems] = useState<WorkbenchAnalysisHistoryItem[]>([]);
@@ -1,5 +1,5 @@
import { BarChart3, X } from "lucide-react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import { Button } from "@/shared/ui/button";
import type { AnalysisArtifact } from "../workspace/workspace-model";
import { AnalysisHistorySection } from "./analysis-history-section";
@@ -12,7 +12,7 @@ export function AnalysisResultsPanel({
}: {
open: boolean;
onClose: () => void;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
onOpenArtifact: (artifact: AnalysisArtifact) => void;
}) {
if (!open) return null;
@@ -1,7 +1,7 @@
import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -36,7 +36,7 @@ import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = {
feature: DetailFeature | null;
projectId: string | null;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
onClose: () => void;
};
@@ -1,4 +1,4 @@
import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -31,8 +31,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
const candidateListRef = useRef<HTMLUListElement | null>(null);
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
const { address, result, results, pending, error, enabled } = geocoding;
const mapResult = geocoding.origin === "map";
const selectionCollapsed = Boolean(result && !geocoding.choosing);
const hideSelectionHint = Boolean(results?.items.length && enabled && !error);
const hideSelectionHint = Boolean(results?.items.length && enabled && !error && !geocoding.picking);
useLayoutEffect(() => {
if (selectionCollapsed || !result) return;
const list = candidateListRef.current;
@@ -42,12 +43,13 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
const row = selected.getBoundingClientRect();
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
}, [selectionCollapsed, result, results]);
const status = error
?? (pending ? "正在搜索地名…"
const status = !geocoding.configured ? "当前项目暂未配置地名搜索" : error
?? (pending ? (mapResult ? "正在查询取点位置…" : "正在搜索地名…")
: !enabled ? "等待地图加载后即可定位"
: geocoding.picking ? "点击地图查询地名,按 Esc 结束取点"
: result ? "已在地图标出所选位置"
: results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字"
: "搜索奉贤区内地址或地名,支持回车查询");
: results ? results.items.length ? "选择一个候选位置进行定位" : `${geocoding.region}内未找到匹配位置,请细化关键字`
: `搜索${geocoding.region}内地址或地名,支持回车查询`);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
@@ -79,12 +81,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
aria-label="搜索地名"
className="shrink-0 gap-1.5 px-3 text-xs"
disabled={!enabled || !address.trim()}
loading={pending}
loading={pending && !mapResult}
loadingLabel="正在搜索地名"
>
<Search aria-hidden="true" />查询
</Button>
</div>
<Button type="button" variant="secondary" className="w-full text-xs"
aria-label="从地图取点查找地名" aria-pressed={geocoding.picking} disabled={!enabled}
onClick={geocoding.togglePicking}>
<Crosshair aria-hidden="true" />{geocoding.picking ? "结束取点" : "地图取点"}
</Button>
<div
id={statusId}
role={error ? "alert" : "status"}
@@ -97,9 +104,9 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
</form>
{results && results.items.length > 0 ? (
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
<div className="flex h-10 shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]">
<span className="text-xs font-semibold">{selectionCollapsed ? "已选位置" : "选择位置"}</span>
{selectionCollapsed ? (
<div className={cn("flex shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]", mapResult ? "h-6" : "h-10")}>
<span className="text-xs font-semibold">{selectionCollapsed ? mapResult ? "取点结果" : "已选位置" : "选择位置"}</span>
{selectionCollapsed && !mapResult ? (
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
onClick={(event) => {
geocoding.changeSelection();
@@ -111,7 +118,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
}}>
更换位置<ChevronDown aria-hidden="true" />
</Button>
) : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
) : mapResult ? <span>地图位置</span> : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
</div>
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => (
@@ -132,8 +139,8 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
{result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className="space-y-3">
<div className="flex min-h-16 items-center gap-3 border-b border-[var(--surface-divider)] px-1 py-3 text-xs">
<div className={mapResult ? "space-y-2" : "space-y-3"}>
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
<PlaceSummary item={result} annotation={result.category ?? result.district} />
</div>
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
@@ -55,7 +55,7 @@ export function InvestigationDiagramView({
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
running?: boolean;
onLocate?: (targets: InvestigationMapTarget[]) => void;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
@@ -9,7 +9,7 @@ import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */
export function InvestigationExpandCard({
title, label = title, preview, children, wide = false, onOpen, contentRef,
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false, headingAccessory
}: {
title: string;
label?: string;
@@ -25,6 +25,7 @@ export function InvestigationExpandCard({
disabled?: boolean;
/** Move one live content host between slots, preserving expensive renderers. */
reuseContent?: boolean;
headingAccessory?: ReactNode;
}) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -77,7 +78,10 @@ export function InvestigationExpandCard({
style={{ borderRadius: 12 }}
>
<div className="investigation-card-heading">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
{headingAccessory}
</div>
<Dialog.Trigger asChild>
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
const now = "2026-10-20T08:00:00Z";
const task: Pick<OperationalTask, "evidence"> = { evidence: [{
evidence_id: "saved-evidence", kind: "synthetic", summary: "保存的演练压力,不代表现场事实。",
source_label: "保存的监测来源", observed_at: now, recorded_at: now, recorded_by: "agent", source_ref: null, has_snapshot: false,
}] };
const snapshot: OperationalTaskReport = {
revision: 3, execution_generation: 2, source_session_id: "session", published_at: now, published_by: "agent",
report: { title: "演练调查报告", current_conclusion: "待核验", conclusion_boundary: "演练数据",
evidence_refs: ["saved-evidence"], recommendations: [], pending_items: [], ui_blocks: [{
evidence_refs: ["saved-evidence"], block: { version: "answer-ui@1", block_id: "analysis", fallback_text: "分析待核验。",
spec: { root: "evidence", elements: { evidence: { type: "Evidence", children: [], props: {
title: "监测依据", summary: "模型填写的错误摘要", source: "模型填写的错误来源", kind: "observed",
observed_at: null, evidence_id: "saved-evidence",
} } } },
},
}] },
};
describe("formal report interactive content", () => {
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
const onContinue = vi.fn();
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
context: { report_revision: 3 }, reportGeneration: 2,
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
}));
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
});
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.version = "answer-ui@future";
next.report.ui_blocks![0].block.spec = { type: "iframe", src: "https://example.invalid" };
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("分析待核验。")).toBeVisible();
expect(container.querySelector("iframe")).toBeNull();
});
it("turns report form input into a versioned conversation draft", async () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
type: "FollowUpForm", children: [], props: { title: "补充资料", description: "填写核验位置",
fields: [{ name: "site", label: "位置", kind: "text", required: true }] },
} } };
const onContinue = vi.fn();
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={onContinue} />);
fireEvent.change(screen.getByLabelText("位置(必填)"), { target: { value: "演练位置" } });
fireEvent.click(screen.getByRole("button", { name: "带入对话" }));
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
});
});
@@ -0,0 +1,63 @@
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
task: Pick<OperationalTask, "evidence">;
snapshot: OperationalTaskReport;
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
}) {
const blocks = snapshot.report.ui_blocks ?? [];
if (!blocks.length) return null;
const ask = async (selection: AnswerUiSelection) => {
const result = await onContinue({
reference: {
title: `${snapshot.report.title} · 报告第 ${snapshot.revision} 版 · ${selection.title}`,
text: `${selection.text}\n报告第 ${snapshot.revision} 版${snapshot.execution_generation ? ` · 第 ${snapshot.execution_generation} 轮` : ""}`
},
context: { report_revision: snapshot.revision },
reportGeneration: snapshot.execution_generation
});
if (result === false) throw new Error("引用暂未带入对话,请重试。");
};
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
<div>
<h3 className="text-sm font-semibold">数据与分析</h3>
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
</div>
{blocks.map((item) => {
const parsed = parseAnswerUiBlock(item.block);
const evidence = task.evidence.filter((entry) => item.evidence_refs.includes(entry.evidence_id));
const quote = (selection: AnswerUiSelection) => ask({
...selection,
text: `${selection.text}\n引用依据:${item.evidence_refs.map((id) => {
const record = evidence.find((entry) => entry.evidence_id === id);
return record ? `${record.source_label}(${id})` : `${id}(记录当前不可用)`;
}).join("、")}`
});
// Evidence facts are supplied by the saved task record, not generated presentation props.
const block: AnswerUiBlock | null = parsed ? { ...parsed, spec: {
...parsed.spec,
elements: Object.fromEntries(Object.entries(parsed.spec.elements).map(([id, node]) => {
if (node.type !== "Evidence") return [id, node];
const record = evidence.find((entry) => entry.evidence_id === node.props.evidence_id);
return [id, record ? { ...node, props: {
...node.props, summary: record.summary, source: record.source_label,
observed_at: record.observed_at,
kind: record.kind === "synthetic" ? "demo" : record.kind === "human_input" ? "human_input" : "observed"
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
}))
} } : null;
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
<p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
</p>
</div>;
})}
</section>;
}
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context";
const labels = {
任务报告: "结论",
交互分析: "分析",
当前调查进展: "调查进展",
当前需处理: "待办",
处置进展: "处置",
数据缺口: "缺口",
调查脉络: "脉络",
主要依据: "依据",
任务时间线: "时间线",
报告版本: "版本",
};
export type InvestigationReportSection = keyof typeof labels;
export function InvestigationReportNavigation({ reportRef, sections }: {
reportRef: RefObject<HTMLElement | null>;
sections: InvestigationReportSection[];
}) {
const navigation = useRef<HTMLElement>(null);
const lastJump = useRef<{ section: string; scrollTop: number } | null>(null);
const reading = useReadingWorkspace();
const [current, setCurrent] = useState<string>("任务报告");
const sectionKey = sections.join("|");
useEffect(() => {
const report = reportRef.current;
const nav = navigation.current;
const scroll = report?.closest<HTMLElement>("[data-reading-scroll]");
if (!report || !nav || !scroll) return;
const targets = sectionKey.split("|").flatMap((label) => {
const element = report.querySelector<HTMLElement>(`section[aria-label="${label}"]`);
return element ? [{ label, element }] : [];
});
let frame = 0;
const update = () => {
// Near the document end an anchor cannot always reach the top. Keep the
// chosen chapter current until the reader actually moves away from it.
if (lastJump.current && Math.abs(lastJump.current.scrollTop - scroll.scrollTop) < 1
&& targets.some(({ label }) => label === lastJump.current?.section)) {
setCurrent(lastJump.current.section);
return;
}
lastJump.current = null;
const edge = scroll.getBoundingClientRect().top + nav.offsetHeight + 20;
const active = [...targets].reverse().find(({ element }) => element.getBoundingClientRect().top <= edge);
setCurrent(active?.label ?? targets[0]?.label ?? "");
};
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
scroll.addEventListener("scroll", schedule, { passive: true });
const observer = new ResizeObserver(schedule);
observer.observe(report);
observer.observe(scroll);
schedule();
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
scroll.removeEventListener("scroll", schedule);
};
}, [reportRef, sectionKey, reading?.active, reading?.view]);
// Keep the active chapter reachable when the navigation overflows on a phone.
useEffect(() => {
const nav = navigation.current;
const button = nav?.querySelector<HTMLElement>('[aria-current="location"]');
if (!nav || !button) return;
const bounds = nav.getBoundingClientRect();
const target = button.getBoundingClientRect();
if (target.left < bounds.left) nav.scrollLeft -= bounds.left - target.left + 4;
else if (target.right > bounds.right) nav.scrollLeft += target.right - bounds.right + 4;
}, [current]);
return <nav ref={navigation} className="investigation-report-navigation" aria-label="报告章节">
{sections.map((section) => <Button key={section} type="button" variant="ghost" size="sm"
aria-current={current === section ? "location" : undefined}
className="shrink-0 font-normal"
onClick={() => {
const target = reportRef.current?.querySelector<HTMLElement>(`section[aria-label="${section}"]`);
if (!target) return;
target.tabIndex = -1;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "start", behavior: "instant" });
const scroll = target.closest<HTMLElement>("[data-reading-scroll]");
if (scroll) lastJump.current = { section, scrollTop: scroll.scrollTop };
setCurrent(section);
}}>
{labels[section]}
</Button>)}
</nav>;
}
@@ -21,17 +21,20 @@ import {
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import { InvestigationReportNavigation, type InvestigationReportSection } from "./investigation-report-navigation";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
onLocate?: (targets: InvestigationMapTarget[]) => void;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
};
const roundLabel = (snapshot: OperationalTaskReport) =>
@@ -167,6 +170,17 @@ export function InvestigationTaskReport({
const timeline = investigationTimeline(task, snapshot);
const reportRef = useReportUpdateMotion(task.task_id,
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
const sections: InvestigationReportSection[] = [
"任务报告",
...(report?.ui_blocks?.length ? ["交互分析" as const] : []),
...(live ? ["当前调查进展" as const] : []),
...(pendingActions.length ? ["当前需处理" as const] : []),
...(completedActions.length ? ["处置进展" as const] : []),
...(report?.data_gaps?.length ? ["数据缺口" as const] : []),
...(diagram ? ["调查脉络" as const] : []),
"主要依据", "任务时间线",
...(onLoadReports ? ["报告版本" as const] : []),
];
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
@@ -209,6 +223,8 @@ export function InvestigationTaskReport({
};
return (
<ExpandableReading title={snapshot?.report.title ?? task.title}
summary={<p>{snapshot?.report.current_conclusion ?? task.summary}</p>}>
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
@@ -244,6 +260,8 @@ export function InvestigationTaskReport({
</Button>
</header>
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
@@ -254,9 +272,8 @@ export function InvestigationTaskReport({
) : null}
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
<div className="flex flex-wrap items-center justify-between gap-2">
{report ? (
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`}
headingAccessory={report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
? "证据不足"
@@ -265,9 +282,10 @@ export function InvestigationTaskReport({
: "结果未记录"}
</StatusBadge>
) : null}
</div>
preview={<>
{report && snapshot ? (
<>
{previousRound || olderRevision || insufficient || report.stage !== "final" || report.outcome !== "concluded" ? (
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
{previousRound
? task.status === "queued" || task.status === "running"
@@ -283,11 +301,10 @@ export function InvestigationTaskReport({
: "无法形成结论 · 正在结束本轮"
: report.stage === "interim"
? "阶段性判断"
: report.outcome === "concluded"
? "调查结论"
: "历史报告内容"}
: "历史报告内容"}
</p>
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
) : null}
<p className="investigation-conclusion-preview mt-2 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{formatInvestigationText(report.conclusion_boundary)}
</p>
@@ -318,6 +335,8 @@ export function InvestigationTaskReport({
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
</InvestigationExpandCard>
{snapshot ? <InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} onLocate={onLocate} /> : null}
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
@@ -622,6 +641,7 @@ export function InvestigationTaskReport({
任务更新于 {dateLabel(task.updated_at)}
</p>
</article>
</ExpandableReading>
);
}
@@ -21,6 +21,7 @@ type MobileWorkbenchSheetProps = {
onClose: () => void;
children: ReactNode;
className?: string;
suspended?: boolean;
};
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
@@ -31,7 +32,8 @@ export function MobileWorkbenchSheet({
onSnapChange,
onClose,
children,
className
className,
suspended = false
}: MobileWorkbenchSheetProps) {
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
const draggedRef = useRef(false);
@@ -52,6 +54,7 @@ export function MobileWorkbenchSheet({
}, []);
useEffect(() => {
if (suspended) return;
const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) {
onClose();
@@ -60,7 +63,7 @@ export function MobileWorkbenchSheet({
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
}, [onClose, suspended]);
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
pointerStartRef.current = {
@@ -0,0 +1,29 @@
import { Droplet } from "lucide-react";
import { StartupScreen } from "@/shared/ui/startup-screen";
export type ProjectStartupState = { phase: "projects" } | { phase: "context"; projectName: string };
export function ProjectStartupScreen({ state, error, onRetry }: {
state: ProjectStartupState;
error: string | null;
onRetry: () => void;
}) {
const readingProjects = state.phase === "projects";
return (
<StartupScreen
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
progressLabel="项目加载进度"
steps={[
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
detail: readingProjects ? "获取当前账号可访问的项目列表" : "已获取可访问的项目" },
{ id: "context", label: "准备项目环境", state: readingProjects ? "waiting" : "loading",
detail: state.phase === "context" ? `${state.projectName}\n正在读取项目配置与访问权限` : "等待项目列表返回" }
]}
error={error}
onRetry={onRetry}
/>
);
}
@@ -0,0 +1,100 @@
import { useId, useRef, useState } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { motion, useIsPresent, useReducedMotion } from "motion/react";
import { Droplet, RefreshCw, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogDescription, DialogPortal, DialogTitle } from "@/shared/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { WorkbenchProjectSelection } from "./workbench-top-bar-menus";
const roleLabels: Record<string, string> = { member: "项目成员", admin: "项目管理员", viewer: "只读成员", owner: "项目负责人" };
export function ProjectSwitchDialog({ selection, keyboardOpened, onClose, onRestoreFocus }: {
selection: WorkbenchProjectSelection;
keyboardOpened: boolean;
onClose: () => void;
onRestoreFocus: () => void;
}) {
const [projectId, setProjectId] = useState(selection.selectedProjectId);
const [attemptedProjectId, setAttemptedProjectId] = useState<string | null>(null);
const [keyboard, setKeyboard] = useState(keyboardOpened);
const present = useIsPresent();
const reducedMotion = useReducedMotion();
const labelId = useId();
const projectTriggerRef = useRef<HTMLButtonElement>(null);
const pending = selection.pendingProjectId !== null;
const current = selection.projects.find((project) => project.project_id === selection.selectedProjectId);
const selected = selection.projects.find((project) => project.project_id === projectId);
const error = attemptedProjectId === projectId || attemptedProjectId === null ? selection.error : null;
const transition = { duration: reducedMotion || keyboard ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] as const };
return (
<Dialog open={present} onOpenChange={(open) => { if (!open && !pending) onClose(); }}>
<DialogPortal forceMount>
<DialogPrimitive.Overlay forceMount asChild>
<motion.div className="fixed inset-0 z-[100] bg-slate-950/10 backdrop-blur-[3px]"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
</DialogPrimitive.Overlay>
<DialogPrimitive.Content forceMount asChild
onOpenAutoFocus={(event) => { event.preventDefault(); projectTriggerRef.current?.focus({ preventScroll: true }); }}
onEscapeKeyDown={() => setKeyboard(true)}
onCloseAutoFocus={(event) => { event.preventDefault(); onRestoreFocus(); }}>
<motion.section
className="acrylic-panel acrylic-panel-white fixed left-1/2 top-1/2 z-[101] max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-[420px] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-border)] p-6 text-slate-900 shadow-[var(--shadow-panel)] outline-0 sm:p-7"
initial={reducedMotion || keyboard ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reducedMotion || keyboard ? 0 : 4 }} transition={transition}
onKeyDownCapture={() => setKeyboard(true)} onPointerDownCapture={() => setKeyboard(false)}>
{selection.onRefresh ? <Button className="absolute right-14 top-3" variant="ghost" size="icon-sm"
aria-label="刷新授权项目" loading={selection.refreshing} onClick={selection.onRefresh}><RefreshCw size={16} /></Button> : null}
<Button className="absolute right-3 top-3" variant="ghost" size="icon-sm" aria-label="关闭项目切换" disabled={pending} onClick={onClose}>
<X size={18} aria-hidden="true" />
</Button>
<header>
<div className="flex items-center gap-2 pr-7 text-xs font-medium text-slate-500">
<Droplet className="h-5 w-5 shrink-0 fill-[var(--action-blue)] text-[var(--action-blue)]" strokeWidth={1.5} aria-hidden="true" />
<span><span className="font-semibold text-slate-700">TJWater</span><span className="mx-2 text-slate-300" aria-hidden="true">/</span>智慧水务平台</span>
</div>
<DialogTitle className="mt-6 text-[22px] font-semibold leading-8 tracking-normal">切换项目</DialogTitle>
<DialogDescription className="mt-1.5 break-words text-xs leading-5 text-slate-500">当前项目:{current?.name ?? current?.code ?? "当前项目"}</DialogDescription>
</header>
<form className="mt-6" onSubmit={(event) => {
event.preventDefault();
if (pending || projectId === selection.selectedProjectId) return;
setAttemptedProjectId(projectId);
selection.onSelectProject(projectId);
}}>
<div>
<label id={labelId} className="mb-2 block text-xs font-medium leading-4 text-slate-600">项目环境</label>
<Select value={projectId} disabled={pending} onValueChange={(value) => { setProjectId(value); setAttemptedProjectId(null); }}>
<SelectTrigger ref={projectTriggerRef} aria-labelledby={labelId} aria-describedby={error ? `${labelId}-error` : undefined} aria-invalid={!!error}
className="h-auto min-h-20 gap-3 whitespace-normal rounded-xl border-[var(--surface-divider)] bg-[var(--surface-reading)] px-4 py-3.5 text-left shadow-none transition-colors hover:bg-[var(--surface-control)] data-[state=open]:border-[var(--action-blue)] motion-reduce:transition-none [&>span]:line-clamp-none [&>svg]:shrink-0">
<SelectValue>
<span className="block min-w-0 space-y-1.5">
<span className="block break-words text-base font-medium leading-6">{selected?.name ?? selected?.code ?? "请选择项目"}</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-slate-500">
<span className="inline-flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full bg-emerald-600" aria-hidden="true" />可用</span>
{selected?.project_role ? <span>{roleLabels[selected.project_role] ?? selected.project_role}</span> : null}
</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent position="popper" sideOffset={8} animateContent={!keyboard}
className="acrylic-panel acrylic-panel-white z-[102] max-w-[calc(100vw-2rem)] rounded-xl border-[var(--surface-border)] shadow-[var(--shadow-panel)]">
{selection.projects.map((project) => (
<SelectItem key={project.project_id} value={project.project_id} className="min-h-16 rounded-lg py-2.5 text-sm [&>span:last-child]:min-w-0 [&>span:last-child]:whitespace-normal">
<span className="block break-words font-medium">{project.name ?? project.code ?? project.gs_workspace}</span>
{project.project_id === selection.selectedProjectId ? <span className="mt-1 block text-xs leading-5 text-slate-500">正在使用</span> : project.project_role ? <span className="mt-1 block text-xs leading-5 text-slate-500">{roleLabels[project.project_role] ?? project.project_role}</span> : null}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? <p id={`${labelId}-error`} role="alert" className="mt-2 text-xs leading-5 text-rose-700">{error}</p> : null}
<Button type="submit" className="mt-6 h-11 w-full disabled:border-[var(--surface-divider)] disabled:bg-[var(--surface-control)] disabled:text-[var(--ink-muted)] disabled:opacity-100 disabled:shadow-none" disabled={projectId === selection.selectedProjectId} loading={pending} loadingLabel="正在进入项目">进入项目</Button>
</form>
</motion.section>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}
@@ -109,11 +109,11 @@ type ExecutionStep = {
};
type ConditionDetailPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
condition: ScheduledConditionItem;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => void;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -94,7 +94,7 @@ type ScheduledConditionFeedProps = {
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => Promise<unknown> | void;
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
onRefresh?: () => void | Promise<void>;
};
@@ -369,14 +369,15 @@ export function ScheduledConditionFeed({
if (condition.operationalTask) {
onExpandAgent();
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id, discussion))
.catch((error) => showMapNotice({
if (!onOpenTaskCollaboration) return false;
return Promise.resolve(onOpenTaskCollaboration(condition.operationalTask.task_id, discussion))
.then(() => true)
.catch((error) => { showMapNotice({
id: "operational-task-collaboration",
tone: "error",
title: "Agent 协作打开失败",
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
}));
return;
}); return false; });
}
onExpandAgent();
@@ -1103,6 +1104,7 @@ function ConditionTimelineRow({
return (
<button
type="button"
data-condition-id={condition.id}
aria-pressed={selected}
onClick={onSelect}
className={cn(
@@ -232,7 +232,7 @@ export function ToolbarPanel({
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="地址定位" description="奉贤区 · 天地图地名搜索" icon={Icon}
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
@@ -1,10 +1,18 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import { useLayoutEffect, type ComponentProps } from "react";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
import { useEffect, useRef, type ComponentProps, type ReactNode } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/shared/ui/resizable";
import {
clampAgentPanelWidth,
getAgentPanelMaxWidth,
getWorkbenchViewportLayout,
WORKBENCH_LAYOUT
} from "../layout/workbench-layout";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
type WorkbenchAgentPanelsProps = {
children: ReactNode;
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
@@ -18,6 +26,7 @@ type WorkbenchAgentPanelsProps = {
};
export function WorkbenchAgentPanels({
children,
panelProps,
panelOpen,
panelCollapsing,
@@ -29,52 +38,100 @@ export function WorkbenchAgentPanels({
onExpandPanel,
onWidthCommit
}: WorkbenchAgentPanelsProps) {
const { handlePointerDown, panelRef, resizing, width } = useAgentPanelResize(
viewportWidth,
conditionExpanded
);
const panelRef = usePanelRef();
const groupElement = useRef<HTMLDivElement>(null);
const preferredWidth = useRef<number | null>(null);
const desktop = viewportWidth >= WORKBENCH_LAYOUT.desktopMinWidth;
const expanded = desktop && panelOpen;
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
useLayoutEffect(() => {
onWidthCommit?.(width);
}, [width, onWidthCommit]);
// Restore pixels after the group's ResizeObserver has measured the new
// viewport; a resize against its previous measurement would restore a ratio.
useEffect(() => {
const element = groupElement.current;
if (!element) return;
let frame = 0;
const restore = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const target = expanded
? clampAgentPanelWidth(
preferredWidth.current ?? defaultWidth,
element.clientWidth,
conditionExpanded
)
: 0;
panelRef.current?.resize(target);
});
};
const observer = new ResizeObserver(restore);
observer.observe(element);
restore();
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [expanded, defaultWidth, viewportWidth, conditionExpanded, panelRef]);
return (
<div
ref={panelRef}
className={`pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block${resizing ? " select-none" : ""}`}
style={{ width: panelOpen ? width : 0 }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
{!panelCollapsing ? (
<div
role="separator"
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
data-resizing={resizing}
onPointerDown={handlePointerDown}
className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none"
>
<span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-[var(--action-blue-hover)]/70 group-data-[resizing=true]:bg-[var(--action-blue)]/80 motion-reduce:transition-none" />
<span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--action-blue)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-[var(--action-blue)] group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" />
</div>
) : null}
</>
) : (
<div className="absolute left-3 top-5">
<div className="pointer-events-none absolute inset-0">
<ResizablePanelGroup
id="workbench-columns"
elementRef={groupElement}
orientation="horizontal"
disabled={!expanded || panelCollapsing}
resizeTargetMinimumSize={{ fine: 12, coarse: 24 }}
onLayoutChanged={(layout, { isUserInteraction }) => {
if (isUserInteraction && expanded) {
// Keyboard callbacks precede the panel's DOM update. Use the new
// layout rather than getSize().inPixels, which still reads the old width.
const groupWidth = groupElement.current?.clientWidth;
if (groupWidth)
preferredWidth.current = Math.round((layout["agent-column"] * groupWidth) / 100);
}
}}
>
<ResizablePanel
id="agent-column"
panelRef={panelRef}
defaultSize={expanded ? defaultWidth : 0}
minSize={expanded ? WORKBENCH_LAYOUT.minAgentWidth : 0}
maxSize={expanded ? getAgentPanelMaxWidth(viewportWidth, conditionExpanded) : 0}
groupResizeBehavior="preserve-pixel-size"
onResize={({ inPixels }) => {
if (expanded && inPixels > 0) onWidthCommit?.(Math.round(inPixels));
}}
>
<div className="pointer-events-auto hidden h-full min-h-0 pt-14 lg:block">
{panelOpen ? (
<AgentCommandPanel
{...panelProps}
presentation="desktop-dock"
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
) : null}
</div>
</ResizablePanel>
<ResizableHandle
withHandle
aria-label="调整 Agent 面板宽度"
disabled={!expanded || panelCollapsing}
className={`pointer-events-auto z-50 h-[calc(100%-3.5rem)] w-0 self-end ${expanded ? "" : "hidden"}`}
/>
<ResizablePanel id="main-column" minSize={0} className="relative h-full min-h-0">
{children}
</ResizablePanel>
</ResizablePanelGroup>
{!panelOpen ? (
<div className="pointer-events-none absolute left-0 top-14 z-50 hidden p-3 lg:block">
<AgentCollapsedRail
personaState={personaState}
statusLabel={statusLabel}
onExpand={onExpandPanel}
/>
</div>
)}
) : null}
</div>
);
}
@@ -1,5 +1,6 @@
import {
Activity,
ArrowRightLeft,
Bell,
Bot,
CheckCircle2,
@@ -17,7 +18,9 @@ import {
UserRound,
type LucideIcon
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { AnimatePresence } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -39,6 +42,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchProject } from "../api/project-client";
import type { WorkbenchAlert, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
@@ -259,10 +263,22 @@ export function AlertMenu({
);
}
export type WorkbenchProjectSelection = {
projects: WorkbenchProject[];
selectedProjectId: string;
pendingProjectId: string | null;
error: string | null;
onSelectProject: (projectId: string) => void;
onOpen?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
};
export function UserMenu({
open,
onOpenChange,
user,
projectSelection,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
@@ -272,16 +288,23 @@ export function UserMenu({
open: boolean;
onOpenChange: (open: boolean) => void;
user: WorkbenchUser;
projectSelection?: WorkbenchProjectSelection;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
}) {
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectDialogKeyboard, setProjectDialogKeyboard] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<>
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
className={cn(
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
@@ -297,8 +320,18 @@ export function UserMenu({
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
<DropdownMenuContent align="end" className={cn("z-[90] w-72", menuSurfaceClassName)}
onKeyDownCapture={() => setProjectDialogKeyboard(true)}
onPointerDownCapture={() => setProjectDialogKeyboard(false)}
onCloseAutoFocus={(event) => { if (projectDialogOpen) event.preventDefault(); }}>
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
{projectSelection ? (
<>
<MenuAction icon={ArrowRightLeft} label="切换项目" description="选择要进入的供水项目"
onSelect={() => { onOpenChange(false); setProjectDialogOpen(true); projectSelection.onOpen?.(); }} />
<MenuSeparator />
</>
) : null}
<MenuSectionLabel>会话控制</MenuSectionLabel>
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
@@ -319,6 +352,12 @@ export function UserMenu({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AnimatePresence>
{projectDialogOpen && projectSelection ? <ProjectSwitchDialog selection={projectSelection} keyboardOpened={projectDialogKeyboard}
onClose={() => setProjectDialogOpen(false)}
onRestoreFocus={() => triggerRef.current?.focus()} /> : null}
</AnimatePresence>
</>
);
}
@@ -20,7 +20,7 @@ import type {
WorkbenchUser
} from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu } from "./workbench-top-bar-menus";
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
import {
compactHeaderTextClassName,
headerControlButtonClassName,
@@ -34,6 +34,7 @@ export type WorkbenchTopBarProps = {
alerts: WorkbenchAlert[];
alertDataSource: WorkbenchAlertDataSource;
user: WorkbenchUser;
projectSelection?: WorkbenchProjectSelection;
conditionFeedVisible: boolean;
timelineVisible: boolean;
taskTickerAvailable: boolean;
@@ -71,6 +72,7 @@ export function WorkbenchTopBar({
alerts,
alertDataSource,
user,
projectSelection,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
@@ -184,14 +186,14 @@ export function WorkbenchTopBar({
</button>
<button
type="button"
aria-label="外围样板管理"
aria-label="外围系统"
aria-pressed={peripheralSamplePanelOpen}
title="外围样板管理"
title="外围系统"
onClick={onTogglePeripheralSamplePanel}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}><RadioTower size={14} aria-hidden="true" /></span>
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围样板</span>
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围系统</span>
</button>
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
@@ -250,6 +252,7 @@ export function WorkbenchTopBar({
open={openMenu === "user"}
onOpenChange={createMenuOpenChange("user")}
user={user}
projectSelection={projectSelection}
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
@@ -19,6 +19,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
session_created: Record<string, unknown>;
agent_message: Record<string, unknown>;
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatTransport, UIMessageChunk } from "ai";
import type { AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it("keeps the original response streaming after selecting another conversation", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const transport: ChatTransport<AgentUiMessage> = {
sendMessages: async () => new ReadableStream({ start(stream) { controller = stream; } }),
reconnectToStream: async () => null,
};
const client = { refreshCredential: vi.fn(async () => {}), submitFrontendActionResult: vi.fn(async () => {}) } as unknown as AgentApiClient;
const first = createAgentRuntime(resources, "project:a", client, transport);
const second = createAgentRuntime(resources, "project:a", client, transport);
resources.activeAgent = first;
first.sessionId = "server-a";
const response = first.chat.sendMessage({ text: "核对城南压力" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer-a" });
controller.enqueue({ type: "text-start", id: "text-a" });
controller.enqueue({ type: "text-delta", id: "text-a", delta: "第一段" });
await vi.waitFor(() => expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段" })])));
resources.activeAgent = second;
controller.enqueue({ type: "data-progress", data: { session_id: "another-session", title: "迟到的其他会话事件" } });
expect(runtimeRunning(first)).toBe(true);
controller.enqueue({ type: "data-credential_refresh_required", data: { request_id: "refresh-a", session_id: "server-a" } });
controller.enqueue({ type: "text-delta", id: "text-a", delta: ",后台完成" });
controller.enqueue({ type: "text-end", id: "text-a" });
controller.enqueue({ type: "finish", finishReason: "stop" });
controller.close();
await response;
expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段,后台完成" })]));
expect(second.chat.messages).toEqual([]);
expect(first.sessionId).toBe("server-a");
expect(client.refreshCredential).toHaveBeenCalledWith("server-a", "refresh-a");
resources.dispose();
});
it("merges overlapping pages and a newer authoritative tail without duplicates", () => {
expect(mergeHistoryMessages([message("a"), message("b", "old")], [message("b", "new"), message("c")]))
.toEqual([message("a"), message("b", "new"), message("c")]);
expect(mergeHistoryMessages([message("c"), message("d")], [message("a"), message("b"), message("c", "new")]))
.toEqual([message("a"), message("b"), message("c", "new"), message("d")]);
});
it("routes background browser actions to a scoped refusal and keeps old message pages on resume", async () => {
const resources = new WorkbenchSessionResources();
const submit = vi.fn(async () => {});
const client = { submitFrontendActionResult: submit,
streamSession: vi.fn(async (_id, options) => {
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
} });
options.onEvent({ type: "done", sessionId: "a", data: {} });
}),
} as unknown as AgentApiClient;
const transport = {} as ChatTransport<AgentUiMessage>;
const runtime = createAgentRuntime(resources, "project:a", client, transport, {
id: "a", sessionId: "a", title: "压力复核", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat",
isTitleManuallyEdited: false, messages: [message("a"), message("b", "old")],
});
resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
}));
resources.dispose();
});
});
@@ -0,0 +1,203 @@
import { Chat } from "@ai-sdk/react";
import type { ChatTransport } from "ai";
import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
import { showMapNotice } from "@/features/map/core";
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
type AgentDataPart, type AgentUiMessage, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
import type { WorkbenchSessionResources } from "./workbench-session-resources";
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
export type AgentSessionRuntime = {
key: string;
scope: string;
client: AgentApiClient;
chat: Chat<AgentUiMessage>;
sessionId: string | null;
title: string;
topicId?: string | null;
purpose: AgentSessionPurpose;
taskId?: string;
draft: string;
reference?: AnswerUiSelection;
taskDiscussion?: { context: AgentTaskDiscussionContext; reportGeneration?: number | null };
permissionOverrides: Record<string, PermissionOverride>;
questionOverrides: Record<string, QuestionOverride>;
messagePage?: AgentMessagePagination;
readingPosition?: AgentReadingPosition;
loadedAt: number;
updatedAt: number;
resumed: boolean;
controller?: AbortController;
error?: string;
runStatus?: AgentRunStatus;
handlers?: {
onData: (part: AgentDataPart) => void;
onEvent: (event: AgentSessionStreamEvent) => void;
onError: (error: Error) => void;
onFinish: (status: AgentRunStatus) => void;
};
};
export function agentRuntimes(resources: WorkbenchSessionResources) {
return [...resources.agents.values()] as AgentSessionRuntime[];
}
export function runtimeRunning(runtime: AgentSessionRuntime) {
return runtime.resumed || runtime.chat.status === "submitted" || runtime.chat.status === "streaming";
}
export function runtimeNeedsInput(runtime: AgentSessionRuntime) {
return toAgentChatMessages(runtime.chat.messages, runtime.permissionOverrides, runtime.questionOverrides).some((message) =>
message.permissions?.some((permission) => permission.status === "pending") || message.questions?.some((question) => question.status === "pending"));
}
/** Server snapshots replace matching messages, while previously loaded pages retain their order. */
export function mergeHistoryMessages(current: AgentUiMessage[], incoming: AgentUiMessage[]) {
if (!current.length) return incoming;
if (!incoming.length) return current;
const firstOverlap = incoming.findIndex((message) => current.some((existing) => existing.id === message.id));
if (firstOverlap < 0) return [...current, ...incoming];
const index = current.findIndex((message) => message.id === incoming[firstOverlap].id);
const incomingById = new Map(incoming.map((message) => [message.id, message]));
const prefix = current.slice(0, index).filter((message) => !incomingById.has(message.id));
const suffix = current.slice(index).map((message) => incomingById.get(message.id) ?? message);
const known = new Set(suffix.map((message) => message.id));
return [...prefix, ...incoming.slice(0, firstOverlap), ...suffix, ...incoming.filter((message) => !known.has(message.id) && !incoming.slice(0, firstOverlap).includes(message))];
}
export function createAgentRuntime(resources: WorkbenchSessionResources, scope: string, client: AgentApiClient,
transport: ChatTransport<AgentUiMessage>, loaded?: AgentLoadedChatSession) {
const runtime = {
key: crypto.randomUUID(), scope, client, sessionId: loaded?.sessionId ?? null,
title: loaded?.title ?? "新对话", topicId: loaded?.topicId, purpose: loaded?.purpose ?? "ordinary_chat", taskId: loaded?.taskId,
draft: "", permissionOverrides: {}, questionOverrides: {}, messagePage: loaded?.messagePage,
loadedAt: loaded ? Date.now() : 0, updatedAt: loaded?.updatedAt ?? Date.now(), resumed: false, runStatus: loaded?.runStatus,
} as AgentSessionRuntime;
const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
onData: (part) => {
if (resources.disposed) return;
const data = part as AgentDataPart;
const sessionId = getDataString(data.data, "session_id");
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
} else if (!active() && data.type === "data-frontend_action") {
void rejectBackgroundAction(resources, runtime, data.data);
} else if (active()) runtime.handlers?.onData(data);
notify();
},
onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); },
onFinish: ({ isAbort, isError }) => {
if (resources.disposed) return;
runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now();
runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed";
if (active()) runtime.handlers?.onFinish(runtime.runStatus);
else notifyBackgroundCompletion(runtime);
notify();
},
});
resources.agents.set(runtime.key, runtime);
resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); });
return runtime;
}
const credentialRequests = new WeakMap<AgentSessionRuntime, Set<string>>();
async function refreshRuntimeCredential(runtime: AgentSessionRuntime, data: Record<string, unknown>) {
const requestId = getDataString(data, "request_id");
if (!requestId || !runtime.sessionId) return;
const pending = credentialRequests.get(runtime) ?? new Set<string>();
credentialRequests.set(runtime, pending);
if (pending.has(requestId)) return;
pending.add(requestId);
try { await runtime.client.refreshCredential(runtime.sessionId, requestId); }
catch { pending.delete(requestId); runtime.error = "会话凭据续期失败,请重新登录后重试"; }
}
async function rejectBackgroundAction(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime, data: Record<string, unknown>) {
const request = parseFrontendActionRequest(data);
if (!request || request.sessionId !== runtime.sessionId) return;
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
}
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
const controller = new AbortController();
runtime.controller = controller;
runtime.resumed = true;
runtime.runStatus = "running";
runtime.error = undefined;
void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40,
onEvent: (event) => {
if (resources.disposed) return;
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
const tail = toAgentUiMessages(event.messages);
const prior = runtime.chat.messages;
// Start filling a disconnected interval before reconciling the live tail.
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
const boundary = event.messagePage?.beforeCursor;
if (boundary) void fillHistoryGap(runtime, boundary, prior, controller.signal).then(() => resources.notify()).catch(() => {});
}
runtime.chat.messages = mergeHistoryMessages(prior, tail);
if (!runtime.messagePage) runtime.messagePage = event.messagePage;
runtime.resumed = event.isStreaming;
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
} else {
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") {
runtime.resumed = false;
runtime.runStatus = event.type === "error" ? "error" : "completed";
runtime.updatedAt = Date.now();
}
}
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
resources.notify();
},
}).catch((error: unknown) => {
if (!controller.signal.aborted) {
runtime.error = error instanceof Error ? error.message : "会话连接中断";
if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error));
}
}).finally(() => {
if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
runtime.loadedAt = Date.now();
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
resources.notify();
});
}
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
message: runtime.error ?? runtime.title });
}
async function fillHistoryGap(runtime: AgentSessionRuntime, cursor: string, prior: AgentUiMessage[], signal: AbortSignal) {
let before: string | null = cursor;
const middle: AgentUiMessage[] = [];
while (before && !signal.aborted) {
const page = await runtime.client.loadOlderMessages(runtime.sessionId!, before, signal);
const messages = toAgentUiMessages(page.messages);
middle.unshift(...messages);
if (messages.some((message) => prior.some((old) => old.id === message.id))) break;
before = page.messagePage.beforeCursor;
}
if (!signal.aborted) runtime.chat.messages = mergeHistoryMessages(mergeHistoryMessages(prior, middle), runtime.chat.messages);
}
@@ -0,0 +1,33 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { SWRConfig } from "swr";
import { expect, it, vi } from "vitest";
import type { AgentApiClient, AgentSessionPage } from "@/features/agent";
import { useAgentHistory } from "./use-agent-history";
import { WorkbenchSessionProvider } from "./workbench-session-provider";
it("keeps a late history refresh in its original project cache", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
const root = createRoot(document.createElement("div"));
const page = (id: string): AgentSessionPage => ({ sessions: [{ id, title: id, createdAt: 1, updatedAt: 1, purpose: "ordinary_chat" }], hasMore: false, nextCursor: null });
let finish!: (page: AgentSessionPage) => void;
const first = vi.fn().mockResolvedValueOnce(page("project-a-original")).mockImplementationOnce(() => new Promise<AgentSessionPage>((resolve) => { finish = resolve; }));
const second = vi.fn(async () => page("project-b-only"));
const clients = { a: { listSessionPage: first }, b: { listSessionPage: second } } as unknown as Record<"a" | "b", AgentApiClient>;
let history!: ReturnType<typeof useAgentHistory>;
function Probe({ scope }: { scope: "a" | "b" }) { history = useAgentHistory(clients[scope], scope); return null; }
const swrCache = new Map();
const render = (scope: "a" | "b") => root.render(createElement(WorkbenchSessionProvider, null,
createElement(SWRConfig, { value: { provider: () => swrCache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
try {
await act(async () => render("a"));
expect(history.recentSessions[0]?.id).toBe("project-a-original");
clock.mockReturnValue(61_000);
let pending!: Promise<void>;
await act(async () => { pending = history.ensure(); });
await act(async () => render("b"));
await act(async () => { finish(page("project-a-late-result")); await pending; });
expect(history.sessions.map((session) => session.id)).toEqual(["project-b-only"]);
expect(second).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); clock.mockRestore(); }
});
@@ -0,0 +1,82 @@
import { useCallback, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import type { AgentApiClient, AgentChatSessionSummary, AgentSessionPage } from "@/features/agent";
import { useWorkbenchSessionResources } from "./workbench-session-resources";
const TTL = 60_000;
export function useAgentHistory(client: AgentApiClient, scope: string) {
const resources = useWorkbenchSessionResources();
const [topicFilter, setTopicFilter] = useState<{ scope: string; id?: string }>({ scope });
const topicId = topicFilter.scope === scope ? topicFilter.id : undefined;
const setTopicId = useCallback((id: string | undefined) => setTopicFilter({ scope, id }), [scope]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(false);
const [pendingUpdateKey, setPendingUpdateKey] = useState<string | null>(null);
const viewKey = JSON.stringify([scope, query, topicId]);
const view = useRef({ scope, query, topicId });
view.current = { scope, query, topicId };
const staged = useRef<{ key: string; page: AgentSessionPage } | null>(null);
const [orderVersion, setOrderVersion] = useState(0);
const key = useCallback((q: string, cursor?: string | null, topic?: string) => JSON.stringify(["history", scope, q, cursor ?? null, topic ?? null]), [scope]);
const fetchPage = useCallback((q: string, cursor?: string | null, force = false, topic?: string) => resources.cache.load(key(q, cursor, topic),
() => client.listSessionPage({ q, topicId: topic, includeCollaboration: true, cursor: cursor ?? undefined, signal: AbortSignal.timeout(15_000) }), TTL, { force }), [client, key, resources]);
const recent = useSWR(["agent-recent-sessions", scope], () => fetchPage(""), {
revalidateOnFocus: false, shouldRetryOnError: false,
});
const pages = useSWRInfinite<AgentSessionPage>(
(_index, previous: AgentSessionPage | null) => !enabled || (previous && !previous.hasMore) ? null
: ["agent-history-page", scope, query, previous?.nextCursor ?? null, topicId ?? null],
([, , q, cursor, topic]: [string, string, string, string | null, string | null]) => fetchPage(q, cursor, false, topic ?? undefined),
{ revalidateOnFocus: false, revalidateFirstPage: false, shouldRetryOnError: false, persistSize: false },
);
const sessions = useMemo(() => [...new Map((pages.data ?? (query || topicId ? [] : recent.data ? [recent.data] : []))
.flatMap((page) => page.sessions).map((session) => [session.id, session])).values()], [pages.data, query, recent.data, topicId]);
const refresh = useCallback(async () => {
const page = staged.current?.key === viewKey ? staged.current.page : await fetchPage(query, null, true, topicId);
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
staged.current = null;
await pages.mutate([page], { revalidate: false });
await pages.setSize(1);
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
setPendingUpdateKey(null);
setOrderVersion((version) => version + 1);
}, [fetchPage, pages, query, recent, scope, topicId, viewKey]);
const ensure = useCallback(async () => {
setOpen(true);
setEnabled(true);
const stale = (resources.cache.peek(key(query, null, topicId))?.expiresAt ?? 0) <= Date.now();
const page = await fetchPage(query, null, false, topicId);
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
// Opening a window never invalidates a fresh page or hides existing content.
if (!pages.data?.length) await pages.mutate([page], { revalidate: false });
else if (stale) { staged.current = { key: viewKey, page }; setPendingUpdateKey(viewKey); }
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
}, [fetchPage, key, pages, query, recent, resources, scope, topicId, viewKey]);
const update = useCallback(async (change: (sessions: AgentChatSessionSummary[]) => AgentChatSessionSummary[]) => {
resources.cache.updateMatching<AgentSessionPage>((cacheKey) => {
if (!cacheKey.startsWith('["history",')) return false;
const value = JSON.parse(cacheKey) as unknown[];
return value[0] === "history" && value[1] === scope;
}, (page) => ({ ...page, sessions: change(page.sessions) }));
const recentPage = resources.cache.peek<AgentSessionPage>(key(""))?.value;
if (recentPage) {
const next = { ...recentPage, sessions: change(recentPage.sessions) };
resources.cache.set(key(""), next, TTL);
if (view.current.scope === scope) await recent.mutate(next, { revalidate: false });
}
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
await pages.mutate((current) => current?.map((page) => ({ ...page, sessions: change(page.sessions) })), { revalidate: false });
// Search and later pages can now have different membership/order. Retain what is being read.
setPendingUpdateKey(viewKey);
}, [key, pages, query, recent, resources, scope, topicId, viewKey]);
return {
topicId, setTopicId, sessions, recentSessions: recent.data?.sessions ?? [], query, setQuery,
loading: (open ? pages.isLoading : recent.isLoading), error: pages.error ?? recent.error,
loadingMore: pages.isValidating, hasMore: pages.data?.at(-1)?.hasMore ?? recent.data?.hasMore ?? false,
loadMore: () => pages.setSize(pages.size + 1), refresh, ensure, update,
pendingUpdate: pendingUpdateKey === viewKey, isOpen: open, orderVersion, close: () => setOpen(false),
};
}
@@ -1,77 +0,0 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent
} from "react";
import {
clampAgentPanelWidth,
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
const panelRef = useRef<HTMLDivElement | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
cleanupRef.current?.();
const startX = event.clientX;
const startWidth = panelRef.current.getBoundingClientRect().width;
const handle = event.currentTarget;
const pointerId = event.pointerId;
handle.setPointerCapture(pointerId);
setResizing(true);
const handlePointerMove = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
pointerEvent.preventDefault();
setWidth(clampAgentPanelWidth(
startWidth + pointerEvent.clientX - startX,
window.innerWidth,
conditionExpanded
));
};
const stopResizing = (pointerEvent?: PointerEvent) => {
if (pointerEvent && pointerEvent.pointerId !== pointerId) return;
cleanupRef.current?.();
setResizing(false);
};
const handleBlur = () => stopResizing();
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
window.removeEventListener("blur", handleBlur);
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
cleanupRef.current = null;
};
cleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
window.addEventListener("blur", handleBlur);
},
[conditionExpanded]
);
return {
handlePointerDown,
panelRef,
resizing,
width: clampAgentPanelWidth(
width ?? getWorkbenchViewportLayout(viewportWidth).agentWidth,
viewportWidth,
conditionExpanded
)
};
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { collectRecentAgentActivities, useAgentPromptSuggestions } from "./use-agent-prompt-suggestions";
import { WorkbenchSessionProvider } from "./workbench-session-provider";
import type { ScheduledConditionItem } from "../types";
import type { AgentChatSessionSummary } from "@/features/agent";
describe("recent activities for Agent suggestions", () => {
@@ -21,3 +25,56 @@ describe("recent activities for Agent suggestions", () => {
expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]);
});
});
describe("recommendation cache lifecycle", () => {
let root: Root | undefined;
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
const container = document.createElement("div");
root = createRoot(container);
const request = vi.fn(async () => [{ question: "核查当前压力", activityId: null }]);
const empty: never[] = [];
function Probe({ enabled, sessionId }: { enabled: boolean; sessionId: string | null }) {
const result = useAgentPromptSuggestions({ enabled, model: "fixture/model", cacheScope: "project:a", sessionId,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return createElement("p", null, result.suggestions[0]?.question ?? "loading");
}
for (let i = 0; i < 10; i++) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: false, sessionId: null }))));
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: true, sessionId: `empty-${i}` }))));
}
expect(request).toHaveBeenCalledTimes(1);
expect(container.textContent).toBe("核查当前压力");
});
it("ignores task heartbeat timestamps but regenerates when an activity changes status", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => [{ question: "复核任务证据", activityId: "task:task-a" }]);
const empty: never[] = [];
function Probe({ time, status }: { time: number; status: "running" | "completed" }) {
const condition: ScheduledConditionItem = { id: "task-a", kind: "condition", taskId: "scada-diagnosis", sessionId: "native-a",
title: "SCADA 证据复核", summary: "检查压力监测", riskLevel: "normal", scheduledAt: "2026-10-22T01:00:00Z", updatedAt: time, status };
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "project:a", sessionId: null, request,
alerts: empty, alertDataSource: "backend", conditions: [condition], runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (let i = 0; i < 10; i++) await act(async () => root!.render(createElement(WorkbenchSessionProvider, null,
createElement(Probe, { time: 1000 + i * 5000, status: "running" }))));
expect(request).toHaveBeenCalledTimes(1);
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { time: 60000, status: "completed" }))));
expect(request).toHaveBeenCalledTimes(2);
});
it("isolates projects and caches generation errors instead of retrying each reopen", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => { throw new Error("unavailable"); });
const empty: never[] = [];
function Probe({ scope, enabled }: { scope: string; enabled: boolean }) {
useAgentPromptSuggestions({ enabled, cacheScope: scope, model: "fixture/model", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (const [scope, enabled] of [["a", true], ["a", false], ["a", true], ["b", true]] as const) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { scope, enabled }))));
}
expect(request).toHaveBeenCalledTimes(2);
});
});

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