Author SHA1 Message Date
jiang 6983ba03f1 docs: record deployed API port 2026-10-23 16:12:26 +08:00
jiang 4010acd414 docs: clarify peripheral proxy upstream 2026-10-23 16:11:34 +08:00
jiang 535b800401 fix: proxy peripheral API through main server 2026-10-23 16:06:01 +08:00
jiang 2d8506fea0 feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
2026-10-22 18:52:18 +08:00
jiang c6a0e74e72 feat(map): persist city building tiles 2026-10-22 18:24:25 +08:00
jiang 2100353ef5 fix(security): upgrade MapLibre runtime 2026-10-22 14:47:57 +08:00
jiang edeeec3c41 chore(docker): pin patched Node runtime 2026-10-22 13:05:04 +08:00
jiang ec23d44b02 feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
2026-10-19 18:46:58 +08:00
jiang 2c5bf15cce feat(agent): render local interactive answers and fold completed process 2026-10-12 18:54:18 +08:00
jiang e0eb6a721e fix(workbench): align investigation diagram controls 2026-10-10 16:30:20 +08:00
jiang ba78a24384 feat(workbench): redesign investigation reports and diagrams 2026-10-10 16:02:47 +08:00
jiang 85a9a3ab45 fix(ui): refine acrylic controls and timeline interactions
Restore full acrylic materials to stacked task cards and floating controls.
Extend the map behind the navigation header while preserving document and
control insets. Align timeline and scale readouts, refresh dense-marker
hover content, and highlight model and approval menu options.

Keep narrow timeline headers clear of their legend and actions. Update
coupled geometry checks and retain city zoom-12 and menu regression coverage.

Validation: scoped git diff --check and read-only UI geometry inspection.
Test suites were not rerun at the user's request.
2026-10-09 18:11:33 +08:00
jiang 617ba4d87c feat(ui): refine gem-blue acrylic palette and focus borders
Derive the gem-blue palette with OKLCH tonal and complementary-color
rules while preserving acrylic opacity, blur and surface hierarchy.

Darker focus tokens exposed stacked field rings, fallback outlines and
persistent basemap selection rings. Keep fallback focus styles in the
base layer and use one themed field boundary or inset card stroke.
Preserve keyboard focus, invalid states and native input semantics.

Add browser regression coverage for pointer and keyboard focus, all
basemap presentations, narrow layouts and rendered contrast.

Validation in an isolated checkout: pnpm lint, pnpm typecheck,
pnpm test (393 tests), focused browser suites and pnpm build.
2026-10-09 10:57:06 +08:00
jiang d2d705ed4c fix(workbench): refine agent dock and smooth map resizing
Unify dock surfaces, headers and dividers, and remove the visible context strip.
Limit pointer resizing to 400-640px and reveal the edge grip on hover.
Resize and redraw the map in the same layout cycle to prevent blanking and lag.
Preserve horizontal centering during conversation control press feedback.

Validation: isolated lint, typecheck, 21 focused unit tests, and 10 relevant
browser checks passed. Snapshot baselines regenerated from the isolated change.
The existing mobile alert summary test also fails on unchanged HEAD because
the expected demo alert source is unavailable in this environment.
2026-10-08 17:34:42 +08:00
jiang 7c6d06cb43 fix(workbench): share sync reads and retry failed invalidations 2026-10-08 15:41:50 +08:00
jiang 64ef058e0d fix(workbench): refresh work orders after projection events 2026-10-08 11:50:16 +08:00
jiang f2438e4660 refactor(workbench): remove redundant notification state 2026-09-24 17:11:15 +08:00
jiang 66dcb97e1c fix(workbench): keep active notices after reading 2026-09-24 17:00:35 +08:00
jiang 87cc7785da feat(workbench): refine dynamic task collaboration 2026-09-24 16:36:12 +08:00
jiang b64d2b78bb refactor(workbench): remove mock alert generator 2026-09-24 15:20:42 +08:00
jiang a5e5acb03b fix(workbench): suppress unconfigured demo alerts
The not_configured branch previously promoted mock task failures into critical alerts. Return an explicit empty alert source so the exception badge reflects only actionable backend state.
2026-09-24 15:13:14 +08:00
jiang 7e969e7e8c feat(workbench): route actionable notifications 2026-09-24 14:57:47 +08:00
jiang db778e0bac fix(workbench): reuse system reconnect notice 2026-09-24 14:52:56 +08:00
jiang 2191efab2f fix(workbench): hold refresh feedback 2026-09-24 12:57:16 +08:00
jiang 0a53e665eb feat(workbench): add manual task refresh 2026-09-24 12:45:43 +08:00
jiang ddf953b641 refactor(workbench): simplify appointment tasks 2026-09-24 12:01:11 +08:00
jiang 436d5500f1 feat(workbench): integrate operational workflows and map state 2026-09-24 04:34:46 +08:00
jiang 1752b9ef84 feat: extend supply workbench demo and map navigation 2026-09-23 13:19:30 +08:00
jiang bebcf2a06c feat(workbench): integrate runtime data and map tooling 2026-09-22 10:45:46 +08:00
jiang bf44cc0f25 fix: synchronize dynamic GeoServer layer lifecycle
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 59s
Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
2026-09-11 18:25:41 +08:00
jiang 31acd33a2e fix: probe all non-core GeoServer layers
Extend availability checks beyond pumps and tanks so unpublished valve and reservoir layers cannot leave stale controls.
2026-09-11 17:00:54 +08:00
jiang cf699d6207 fix: hide unavailable GeoServer layers 2026-09-11 16:40:24 +08:00
jiang f9c71cc076 feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s
2026-09-08 18:18:40 +08:00
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
jiang abef9ffabf fix: bypass blocked Keycloak session iframe
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m31s
2026-08-19 18:12:21 +08:00
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
jiang 21dbfd82b6 fix: decouple timeline from workspace layout
The regression recurred because a floating timeline height was still exported as shared layout space and its z-index exceeded artifacts and notices. Reserve zero layout height and define the timeline below artifact and notification overlays.
2026-08-19 16:58:55 +08:00
jiang 760370c93f fix: refine floating workspace controls
Keep ScaleInfo pinned to the map corner and move the centered timeline above collisions, preventing the previous conditional offset from lifting the scale. Remove collapsed Agent rail width reservation and add timeline state transitions.
2026-08-19 16:53:21 +08:00
jiang 3db95c27b5 fix: align scale info with floating timeline 2026-08-19 16:45:35 +08:00
jiang 45f1ebee7e feat: add operational timeline workspace 2026-08-19 16:42:41 +08:00
jiang d2f2895c36 feat: refactor artifact analysis workspace 2026-08-19 16:09:51 +08:00
jiang bfadc95f93 feat: add workspace task view 2026-08-19 14:34:03 +08:00
jiang be7b4149f2 feat: redesign floating workspace interactions 2026-08-19 14:12:08 +08:00
jiang 1068304da5 feat: add workspace layout and window swapping 2026-08-19 13:52:44 +08:00
jiang f58e0a70f4 feat: add adaptive agent workspace windows 2026-08-19 12:57:37 +08:00
jiang d08cf2abc1 feat: add Keycloak authentication 2026-08-19 12:11:18 +08:00
jiang bdd5eff776 refactor: simplify workbench layout and browser tests 2026-08-18 17:06:42 +08:00
424 changed files with 50193 additions and 5456 deletions
+13 -2
View File
@@ -1,7 +1,18 @@
TJWATER_AUTH_MODE=required
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
TJWATER_MAPBOX_ACCESS_TOKEN=
TJWATER_TIANDITU_TOKEN=
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
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=/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
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS=8
TJWATER_ENABLE_DEV_PANEL=false
TJWATER_ENABLE_MSW=false
+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
+14 -3
View File
@@ -1,4 +1,6 @@
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS build
ARG NODE_IMAGE=node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
FROM ${NODE_IMAGE} AS build
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
@@ -13,17 +15,26 @@ RUN pnpm install --frozen-lockfile --registry="${NPM_REGISTRY}"
COPY . .
RUN pnpm build
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy-bin
FROM ${NODE_IMAGE} AS runner
ARG ALPINE_MIRROR=mirrors.aliyun.com
RUN sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache jq nodejs
apk add --no-cache ca-certificates jq mailcap && \
mkdir -p /config/caddy /data/caddy /etc/caddy /srv && \
chmod 1777 /config/caddy /data/caddy
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
COPY --from=caddy-bin /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint
COPY --from=build /app/dist /srv
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-frontend/edge-tts-server.cjs
RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint
WORKDIR /srv
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1/ || exit 1
+44 -10
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)。
## 主要功能
- 供水管网地图、业务图层和设备状态展示
@@ -32,18 +34,42 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
开发环境使用内网认证服务时,仅在被 Git 忽略的 `.env.development.local` 中覆盖
`TJWATER_KEYCLOAK_ISSUER`,保留 `.env.local` 的 HTTPS issuer。Vite 默认开发模式读取
此覆盖,正常 production 构建不读取。登录 issuer 必须与 Agent、Server MCP 及 worker
一致;更改后重新加载开发服务器并重新登录,不复用旧 issuer 的令牌。
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
| 变量 | 默认值 | 说明 |
| ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `TJWATER_TIANDITU_TOKEN` | 空 | 天地图矢量、影像及中文注记访问令牌 |
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `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` | `/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 天) |
| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 |
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。Mapbox 和天地图令牌会随底图请求发送到浏览器,应在服务商控制台限制允许的来源域名。
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。外围样板推荐配置 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`;线上 NPMplus 在现有前端 Proxy Host 中增加 `/peripheral` Custom Location,保留该前缀并转发到 `http://192.168.1.114:8001`,Forward Path 留空。容器内部仍监听 `8000`,宿主机使用 `8001` 避免与已有服务冲突。开发和预览模式提供同路径代理,默认使用 `TJWATER_SERVER_API_BASE_URL`。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
Agent 运行时仍可发送消息,底栏可选择“引导”(steer,默认)或“排队”(queue)。右下角只有一个操作位置:运行中输入为空时停止,有内容时发送追加消息;空闲时发送新消息。待处理消息并入输入组件顶部,每条展示正文预览、处理方式和消息菜单;长内容可展开全文,菜单可切换处理方式、退回编辑或关闭排队。编辑需服务端确认撤回,保留已有草稿并等待手动重发。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
## 常用命令
@@ -62,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 .
@@ -98,4 +128,8 @@ src/
## 构建说明
调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。
“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围样板面板可对 synthetic 测试工单和测试数据执行 CRUD,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围样板说明](../next-tjwater-server/docs/peripheral-sample.md)。
生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+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
+25 -3
View File
@@ -2,17 +2,39 @@
set -eu
runtime_config=$(jq -cn \
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
--arg tiandituToken "${TJWATER_TIANDITU_TOKEN:-}" \
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_URL:-http://127.0.0.1:8787}" \
--arg workbenchRuntimeMode "${TJWATER_WORKBENCH_RUNTIME_MODE:-auto}" \
--arg peripheralSampleApiBaseUrl "${TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL:-}" \
--arg peripheralSampleTimeMode "${TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:-demo}" \
--arg peripheralSampleDemoStart "${TJWATER_PERIPHERAL_SAMPLE_DEMO_START:-2026-09-17T00:00:00Z}" \
--arg peripheralSampleDemoEnd "${TJWATER_PERIPHERAL_SAMPLE_DEMO_END:-2026-09-17T12:00:00Z}" \
--arg peripheralSampleRecentHours "${TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS:-8}" \
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
'{
TJWATER_AUTH_MODE: $authMode,
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_TIANDITU_TOKEN: $tiandituToken,
TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
TJWATER_AGENT_URL: $agentApiBaseUrl,
TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode,
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl,
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode,
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: $peripheralSampleDemoStart,
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: $peripheralSampleDemoEnd,
TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: $peripheralSampleRecentHours,
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
TJWATER_ENABLE_MSW: $enableMsw
}')
+136
View File
@@ -0,0 +1,136 @@
# 本地交互式回答
统一样式见 [样式设计指南](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
托管 UI 请求。模型仍使用 Agent 的既有配置与工具调用能力。
## 可用内容与交互
`Stack`、`Section`、`Text`、`Metric`、`Table`、`Chart`、`Evidence`、
`MapLocation`、`FollowUpForm` 共九种组件。图表可切换折线、柱状和数据表;
表格可搜索、排序和选择行,图表可切换已加载区间、显隐序列并选点追问;
证据可按类别筛选、折叠并标注来源;地图定位通过现有控制器执行;表单从空值开始,
由用户主动提交,提交形成普通用户消息。业务证据是否保存以 Agent 实际工具结果为准。
Agent 通过 `present_answer` 发布完整的局部界面块,可在两块之间继续输出文字。
消息顺序为文字、`data-answer_ui`、后续文字与最终报告。同一轮内相同
`block_id` 更新原块;下一轮不会修改上一轮回答。
这是按内容块逐步出现的界面,不是逐 token 编译 JSX 或每个 JSON 属性都立即显示。
结构化消息与普通聊天共用持久化、历史与断线恢复通道。
生成期间展开显示过程;`final_answer` 到达后,仅展开最后报告的完整文字与
`ui_block_ids` 明确引用的已发布组件,其余文字、界面与工具轨迹默认折叠在
“查看处理过程”中。报告组件按引用顺序排列,同一组件不会同时作为报告和过程
挂载,避免重复表单。未被报告引用的过程界面只供回看。
已发布交互界面的普通/协作聊天在最终答复完成、且没有待完成工具时停止本轮原生
生成,防止最终报告之后反复自动修改界面。该收尾不标记后台调查任务完成;
后台执行继续等待其原有运行生命周期与 Server 完成/失败接口。
后台调查执行继续走任务报告与任务展示通道,聊天界面块不构成业务完成或浏览器回执。
## 正式报告与主工作区
正式报告可携带 `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` 和 Server 的 `contracts/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随三端一起交付。
4. 如新组件存在跨字段约束,同时更新三端的协议校验并加边界和交互测试。
导出脚本接受可选目标文件:
```bash
node scripts/export-answer-ui-contract.mjs /path/to/agent/src/chat/answer-ui-catalog.json
```
前端沿用 Zod 3,json-render 0.21 两个包通过作用域 override 使用相同 schema
运行时;实际组件渲染和参数校验已有聚焦测试。升级 json-render 时必须保留这组
兼容性测试,不能只根据包版本判断可用。Agent 原生插件仍使用其 Zod 4 工具边界,
执行后再按生成的 JSON Schema 校验组件参数。
## 校验边界
三端拒绝未知组件、额外属性、任意脚本/事件定义、缺失引用、循环、重复或游离节点。
每块最多 48 节点、深度 12、完整序列化结果不超过 100000 个 JavaScript UTF-16 代码单元,图表必须对齐且数值有限;表格每行与列数
一致,表单字段名唯一。组件只消费明确的 props,没有动态代码、网络 URL 或任意动作 DSL。
无法解析的历史内容显示该块的 `fallback_text`。
## 验证
```bash
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 tests/browser/agent-report-workspace.e2e.ts
pnpm build
```
Agent 仓库:
```bash
bun run check
bun test tests/chat/answer-stream-projection.test.ts tests/chat/answer-ui.test.ts tests/routes/chatStream.test.ts tests/routes/taskCollaboration.test.ts tests/runtime/opencode.test.ts
bun run tests/integration/answer-ui-smoke.ts
```
原生 smoke 使用独立临时数据库,不发模型推理请求。浏览器用例用明确标注的演练
事件验证组件、更新、历史恢复、键盘展开证据、表单提交和 320/375px 布局,不能当作
真实模型已正确选择组件或现场资料已核验的证明。
前端、Agent 与 Server 需同步更新;Agent 进程需重新加载原生插件,Server 需加载新报告 schema。无需新增环境变量、
domain key 或数据库迁移。本轮不重启既有服务;浏览器演练和本地测试不代表运行中的服务已完成同步。
## 真实模型验收(显式启用)
以下浏览器用例直接连接本机 Agent,输入均为明确的演练资料。工作台地图与项目
读取使用测试数据,聊天、模型工具调用、流式 UI、聊天持久化与表单回传使用真实
服务。每次创建独立验收会话,结束后仅删除该会话,不删除既有用户记录。此用例
会发送模型请求,默认浏览器测试不执行它。
```bash
TJWATER_ANSWER_UI_REAL_AGENT_URL=http://127.0.0.1:18787 \
TJWATER_ANSWER_UI_REAL_MODEL_LABEL=专家 \
pnpm test:browser tests/browser/agent-answer-ui-real.e2e.ts --workers=1
```
先在 Agent 仓库启动隔离实例,可指定独立 INTEGRATION_DATA_DIR 保留联调数据。
可省略模型标签来使用默认模型。该用例不是生产 OAuth 登录或真实供水业务核验。
Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓存,避免同时
运行的开发服务将旧临时目录的 React 预构建写回测试缓存。
消息入口为 `POST /api/v1/responses`,采用 Responses 文本输入、Response 输出和标准 SSE 生命周期。前端 `ResponsesChatTransport` 将网络事件转换为已有 UIMessage 渲染数据;审批、凭据续期、地图动作及 answer-ui 走 `tjwater.*` 扩展事件,不将 AI SDK 的 UIMessage 流作为网络协议。会话、模型及动作回执等接口统一使用 `/api/v1` 前缀,原 `/agent/chat` 路径已移除;Agent 服务和现有会话数据保留。
+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%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="490" viewBox="0 0 1120 490">
<rect width="1120" height="490" fill="#F5F7FA"/>
<g font-family="Noto Sans SC Variable,Noto Sans CJK SC,Microsoft YaHei,sans-serif" fill="#232935">
<text x="32" y="46" font-size="24" font-weight="600">宝石蓝 · 银灰亚克力</text>
<text x="32" y="76" font-size="14" fill="#4E5566">同色相色阶 + 低彩度互补色 · 色板为不透明基准,亚克力透明度独立设置</text>
<rect x="32" y="102" width="192" height="236" rx="8" fill="#4263C7" stroke="#BEC2CD"/>
<text x="32" y="373" font-size="20" font-family="Consolas,monospace">#4263C7</text>
<text x="32" y="402" font-size="16">宝石蓝 · 主操作 · 品牌</text>
<text x="32" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.529 C 0.160 h 267.06</text>
<rect x="248" y="102" width="192" height="236" rx="8" fill="#E6E9F2" stroke="#BEC2CD"/>
<text x="248" y="373" font-size="20" font-family="Consolas,monospace">#E6E9F2</text>
<text x="248" y="402" font-size="16">银霜 · 亚克力材质</text>
<text x="248" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.935 C 0.012 h 267.06</text>
<rect x="464" y="102" width="192" height="236" rx="8" fill="#F5F7FA" stroke="#BEC2CD"/>
<text x="464" y="373" font-size="20" font-family="Consolas,monospace">#F5F7FA</text>
<text x="464" y="402" font-size="16">冷瓷 · 阅读 · 表单</text>
<text x="464" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.975 C 0.005 h 267.06</text>
<rect x="680" y="102" width="192" height="236" rx="8" fill="#D6C294" stroke="#BEC2CD"/>
<text x="680" y="373" font-size="20" font-family="Consolas,monospace">#D6C294</text>
<text x="680" y="402" font-size="16">香槟 · 互补暖色点缀</text>
<text x="680" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.820 C 0.065 h 87.06</text>
<rect x="896" y="102" width="192" height="236" rx="8" fill="#232935" stroke="#BEC2CD"/>
<text x="896" y="373" font-size="20" font-family="Consolas,monospace">#232935</text>
<text x="896" y="402" font-size="16">深墨 · 标题 · 正文</text>
<text x="896" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.280 C 0.025 h 267.06</text>
<text x="32" y="468" font-size="13" fill="#4E5566">主色 h = 267.06°;同色色阶保持 h;香槟色 h = (267.06° + 180°) mod 360° = 87.06°</text>
</g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -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 提示;未新增依赖、提交或部署。
+115
View File
@@ -0,0 +1,115 @@
# 非地图面板整理
2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。
## 视觉与交互
沿用现有浅色阅读面、Noto Sans SC 中文字体、蓝色操作强调和状态徽标。优先修复可读性、对齐和窄屏裁切,不引入第二套主题、字体或组件库。
- `FieldSelect` 复用现有 Radix Select:统一标签、40px 最小高度、长选项换行、禁用态、键盘选择和弹层层级。清空受控值后回到占位提示,不触发额外业务动作。
- `Checkbox` 保留原生输入与标签语义,统一勾选、焦点和禁用样式。反馈确认仍须用户主动勾选。
- `Disclosure` 保留原生 details/summary 语义,统一展开箭头、焦点和点击区域。
- 调查目标和外围历史回放的日期字段单列展示,避免窄面板内日期与时分被裁切。两处均已使用共享自定义日期时间弹窗,保留各自本地时间/UTC 的业务语义。
- 手机端 Agent 欢迎区改为紧凑横排,避免被底部输入区遮挡。
- 桌面成果专注视图为 Agent 折叠栏预留空间,标题不再被覆盖,仍可重新展开助手。
- 成果图表显式使用页面的中文字体栈。此前 ECharts 画布仍以 `11px sans-serif` 绘制中文图例,在测试环境显示方框;浏览器画布探针已验证修复前失败、修复后在 1280/375px 通过,截图可见完整“调度下限”。源码只有这一处 ReactECharts 入口。
## 检查覆盖
| 面板 | 本次检查 |
| --- | --- |
| 调查任务、生命周期、运行与历史结果 | 表单、确认区、折叠内容、长标识、320/375/1280px |
| 已保存比较结论、Evidence 查看 | 四分支、加载/空/错误态、只读重试、键盘下拉、320/375/1280px |
| 外围样板、模拟数据、历史回放 | 下拉、日期、状态徽标、任务历史、375px |
| 反馈关联、人工复核 | 确认勾选、展开内容、禁用与只读边界、窄屏 |
| Agent 对话与历史 | 桌面抽屉、320/375px 欢迎区与滚动容器边界 |
| 工况列表与详情 | 375px 抽屉布局 |
| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 |
| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 |
`FeatureInsightPanel` 当前只有定义,没有运行入口,未伪造页面声称已完成运行检查。实际要素详情使用地图上的 `FeaturePopover`,不在本次非地图面板范围。
## 验证与限制
最终代码检查:`pnpm typecheck`、`pnpm lint`、全量 68 文件 / 350 项 Vitest、`pnpm build` 通过。生产构建仍提示部分 chunk 超过 500kB,本次未扩展打包优化范围。调查/反馈/外围/比较/生命周期的 29 项与非地图布局的 5 项专项浏览器用例通过,比较区新增的弹层不透明度和背景色检查另行重跑 6 项通过。非地图布局用例同时覆盖开发面板键盘选项和成果图表画布字体。
浏览器用例使用显式模拟接口,不代表真实账号、Agent 和 PG 的全链路验收。主要回归在 `tests/browser/non-map-panels-layout.e2e.ts`、`run-comparison.e2e.ts` 及既有调查、反馈、外围与生命周期测试中;截图保存在忽略的 `test-results/non-map-panels/`、`test-results/run-comparison/` 等目录,不作为发布资源。
本次也运行了完整浏览器套件,当次为 66 通过、33 失败,不能宣称全量通过。已确认的失败包括旧用例依赖 Agent 初始展开或已不存在的“工作台导航”、旧截图基线,以及新移动用例当时选择器不唯一。后者已改为语义化 `role=log` 并在专项回归中通过;其他旧用例与真实地图依赖仍需单独梳理,不通过修改产品默认行为或批量更新截图掩盖失败。
运行内容来自现有接口,无需手动替换界面占位数据。人工验收时请在桌面全宽与 375px 下检查长任务名称、日期输入、下拉键盘操作和成果标题。
## 后续交互回归
继续核查旧失败用例时发现并修复了两处真实缺陷:
- 窄桌面展开工况详情会自动收起 Agent;此前聊天草稿存在被卸载的面板内部,重新展开时丢失。现在由稳定的工作台 hook 持有草稿,桌面和手机面板共享受控值,新建会话仍清空。此状态只在当前页面内保留,不是刷新后的持久化草稿,也没有新增存储层。面板仍可卸载,避免为了保留文字让隐藏的语音控件继续活动。
- 新增调查/外围入口后,1536px 顶栏的异常处置与调查按钮发生重叠。现在保留按钮宽度、收紧组间距,仅在 1800px 以上展开次要按钮文字;较窄桌面省略模型只读提示,完整操作名称仍通过无障碍标签提供。手机标题显示短称“供水”,而不是省略号。
`workspace-main-frame.e2e.ts` 现在验证默认收起、主动展开后的相邻布局、收起恢复、成果 split/focus 切换,并检查 320/375/1024/1280/1440/1536/1600/1800/1920px 顶栏按钮不重叠、标题不截断。顶栏几何断言修复前失败,修复后通过。截图位于 `test-results/workspace-regression/`。
`agent-voice-hydration.e2e.ts` 与 `agent-panel-resize.e2e.ts` 改为先通过真实按钮打开助手,再检查语音、几何和草稿状态;不再假定 Agent 初始展开或折叠后保留旧版 72px 导航宽度。工况自动收起场景使用其实际适用的 1280px 宽度,保留了未发送草稿不丢失的断言。布局测试按实时几何等待入场动画完成,不固定动画中途的坐标,也没有加入固定 sleep。
该轮完整浏览器套件为 79 通过、21 失败,共 100 项。仍有旧导航/默认展开假设、截图基线与动画时机相关失败,不能宣称全量通过,也未批量更新基线。
## 调查任务日期时间补充
根因是原先只统一了 `datetime-local` 输入框外观,其日历和时间弹窗仍由浏览器绘制。调查任务的开始、结束时窗已替换为共享 `DateTimeField`:沿用浅色面板、蓝色选中态和现有 Radix 弹层/下拉,不引入新依赖。
- 日历、月份、年份、小时和分钟均在应用内绘制,窄屏弹窗限制宽高并允许纵向滚动。
- 点击确认才更新值,取消或 Esc 放弃草稿并将焦点返回字段;日历支持方向键和 PageUp/PageDown。
- 年份编辑保持输入焦点,支持闰年和真实月份天数;无效源值不会被静默截断后保存。
- 已关闭任务不可编辑。组件仍输出本地 `YYYY-MM-DDTHH:mm`,原有目标保存逻辑负责转换 UTC,不改变业务时区语义。
- `date-time-field.test.tsx` 覆盖组件边界。
调查任务两个日期字段均已替换;同类的外围历史回放已在后续批次统一,见下节。
补充后的验证:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项单元测试和 `pnpm build` 全部通过;日期时间、调查面板、非地图布局、运行控制及任务生命周期的 20 项浏览器回归通过。生产构建仍有既有大 chunk 提示。上述浏览器检查使用模拟接口,不替代真实系统验收。
日期回归用例做过红绿验证:临时将调查时窗重新接回原生 `datetime-local`,确认/提交用例因缺少自定义字段入口失败;恢复 `DateTimeField` 后重新运行全部 3 项日期用例通过。代码未提交。
按用户要求,每轮 Playwright 结束后检查其浏览器和临时 Vite 服务是否退出,不关闭用户已有的 5173 开发服务。
## 外围历史回放日期补充
外围面板的 `HistoryTime` 同样仍由浏览器绘制日期时间弹窗,已删除该包装并复用 `DateTimeField`。保留“开始(UTC)/结束(UTC)”标签和原来的 UTC 查询拼接,不套用调查任务的本地时间转换。扫描 `src/` 已无原生 date/time/datetime-local 输入入口。
`peripheral-sample-panel.e2e.ts` 新增实际交互断言:修改起止日期时间后只有点击“读取历史读数”才发送 GET;浏览器为 Asia/Shanghai 时,08:15 仍按 08:15Z 查询;取消不改值、不发查询,也没有任何外围写请求。320/375/1280px 弹窗边界与无横向溢出通过,截图位于忽略的 `test-results/peripheral-datetime/`。
红绿验证:替换前该用例因缺少自定义日期入口失败,替换后外围面板、调查日期时间、反馈导入共 14 项浏览器测试通过。类型检查、Lint、69 文件 / 357 项单元测试与生产构建通过,构建仍提示既有大 chunk。仍使用模拟接口,不代表真实外围服务验收;完整浏览器套件的失败不因本轮专项通过而视为消除。
## 问答与流式测试入口修订
重新运行确认旧问答/流式用例默认寻找尚未展开的 Agent 输入框;显式展开后还暴露了授权项目模拟缺失。请求失败探针记录 `/api/v1/projects` 连接拒绝,Agent 客户端因此未进入回复与模型查询。现在使用明确的测试运行配置和已有项目/SCADA mock,不改产品授权流程;临时日志已移除。
流式用例另外仍断言旧浮动简报和浮动输入区。当前 `desktop-dock` 分支将简报和输入区放入正常布局,已改为验证简报在对话区上方、输入框在滚动区域下方、分析中操作禁用及区域不重叠;进度窗口几何采样、计划卡片、结束滚动、保持展开、选中文本朗读等原断言仍保留。问答回复的精确请求号与 payload 断言未放宽。两个文件共 3 项重跑通过,其中问答桌面和流式用例此前失败。
本轮最终完整浏览器套件:103 项中 85 通过、18 失败,耗时 2.7 分钟。失败分布为 `app.e2e.ts` 4 项、`agent-acrylic.e2e.ts` 4 项、开发面板/地图通知/移动转桌面/窄桌面工况/地图相机稳定性各 1 项、视觉基线 5 项。不得将全部失败归因于旧断言,仍需逐项核查实际行为。此前工况动画时机失败在本轮未复现,未作修复,仍作为时序风险保留;问答与流式是本轮明确修订后通过的两项。完整结果与失败上下文位于忽略的 `test-results/non-map-followup-full/`。
## 历史菜单、Esc 分层与全量回归收尾
继续核查上述 18 个失败后,确认并修复了以下真实行为问题:
- 手机 Agent 内的 Radix 模型菜单和权限模式菜单已处理 Esc,并将事件标记为 `defaultPrevented`,但外层 `MobileWorkbenchSheet` 的 window 监听仍无条件关闭抽屉。外层现在只处理未被内层消费的 Esc。模型和权限菜单各自关闭后,焦点返回原触发按钮;没有内层菜单时,Esc 仍关闭抽屉。
- Dev Panel 有相同的事件分层缺陷。`FieldSelect` 打开时第一次 Esc 只关闭选择菜单并返回焦点,第二次 Esc 才关闭开发面板。
- 手机端已打开 Agent 时跨越 1024px 进入桌面,原逻辑先清空移动抽屉状态,导致用户打开 Agent 的意图丢失。现在先将移动 Agent 状态迁移为桌面展开状态,再清空移动抽屉。
- Dev Panel 打开时,地图工具栏仍在面板下面渲染。现在开发面板显示期间不挂载桌面地图工具栏与其工具面板。
- 1024px 下展开工况任务会自动折叠 Agent,但 880px 工况面板与 72px 折叠栏重叠 4px。布局变量现显式记录折叠栏宽度,工况面板在该宽度下收敛到 852px,二者保留 24px 间距。
同时修正了三类失真的回归断言。地图容器变宽或变窄时,MapLibre 的 `resize()` 会发出 `movestart`,但经纬度、缩放、方位角和俯仰并未改变;相机稳定性用例现在直接比较这五项参数。地图通知位置测试改用确定会生成通知的“操作参考”入口。工况材质测试等待入场动画结束后再读取 transform 和 will-change,不再采样动画中间帧。焦点、几何和业务请求断言均保留。
320px 与 375px 的模型菜单截图位于 `test-results/agent-history-ui/320-model-menu.png` 和 `375-model-menu.png`,长标题重命名截图位于同一目录。模型名称、说明和菜单边界完整,权限模式、模型、语音和发送控件没有重叠。五张过期的工作台视觉基线经逐张检查后更新,覆盖桌面浅色/卫星底图、1920px Agent 收起状态、手机启动器和手机 Agent 半屏;手机工况卫星图无需更新。视觉用例更新后再次无更新参数运行,6/6 通过。
最终验证结果:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项 Vitest、`pnpm build` 全部通过。构建继续报告既有的 500kB 以上 chunk 提示,CSS 校验和 TTS bundle 通过。完整 Playwright 套件为 104/104 通过,耗时 2.4 分钟,结果位于忽略的 `test-results/non-map-followup-final-green/`。这些浏览器用例仍使用模拟接口,不替代真实账号、Agent、PG 和外围服务的全链路验收。
## 新增业务组件统一设计
2026-09-18 对调查任务与外围样板的新组件完成统一重构。现行规则见 [样式设计指南](style-design-guide.md),视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。
- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。
- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。
- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;synthetic 数据、测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。
- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。
- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。
本轮最终检查为 `pnpm typecheck`、`pnpm lint`、76 文件 / 395 项 Vitest 和 `pnpm build` 通过。新增组件相关的 17 文件 / 118 项单测通过,核心业务面板 Playwright 为 31/31 通过。完整浏览器套件共 110 项,并发运行 108 项通过;2 项在等待 Toast 退出时超过 15 秒,随后单进程精确复跑 2/2 通过。截图位于忽略的 `test-results/task-lifecycle/`、`run-control/`、`run-comparison/`、`peripheral-datetime/` 和 `/tmp/feedback-*.png`。
+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)。
+13 -2
View File
@@ -4,8 +4,12 @@
"private": true,
"type": "module",
"packageManager": "pnpm@11.8.0",
"engines": {
"node": ">=24.22.1 <25"
},
"scripts": {
"dev": "vite",
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
@@ -20,6 +24,8 @@
"dependencies": {
"@ai-sdk/react": "^4.0.9",
"@fontsource-variable/noto-sans-sc": "5.3.0",
"@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0",
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17",
@@ -28,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",
@@ -43,12 +50,15 @@
"echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0",
"keycloak-js": "26.2.4",
"lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1",
"maplibre-gl": "^6.4.1",
"motion": "^12.40.0",
"nanoid": "^5.1.14",
"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",
@@ -83,7 +93,8 @@
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.0",
"vite": "^7.0.0",
"vitest": "^4.1.9"
"vitest": "^4.1.9",
"zod-to-json-schema": "^3.25.2"
},
"msw": {
"workerDirectory": [
+6 -2
View File
@@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
const runningInCi = Boolean(process.env.CI);
export default defineConfig({
testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true,
reporter: "html",
forbidOnly: runningInCi,
retries: runningInCi ? 1 : 0,
workers: runningInCi ? 2 : undefined,
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
use: {
baseURL,
locale: "zh-CN",
@@ -16,7 +20,7 @@ export default defineConfig({
trace: "on-first-retry"
},
webServer: {
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} TJWATER_AUTH_MODE=disabled TJWATER_AGENT_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL,
reuseExistingServer: false
},
+450 -167
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,6 +1,11 @@
packages:
- .
# Keep the catalog and json-render on the existing application schema runtime.
overrides:
'@json-render/core>zod': 3.25.76
'@json-render/react>zod': 3.25.76
allowBuilds:
'@swc/core': true
esbuild: true
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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#6F8292" stroke="#fff" stroke-width="5"/>
<rect x="25" y="29" width="46" height="38" rx="9" fill="none" stroke="#fff" stroke-width="5"/>
<circle cx="48" cy="47" r="10" fill="none" stroke="#fff" stroke-width="4"/>
<path d="m48 47 7-6M36 67v7m24-7v7" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#6D7F8C" stroke="#fff" stroke-width="5"/>
<path d="M29 29h38M48 29v31m-12-12 12 12 12-12" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25 70c6-5 12-5 18 0s12 5 18 0 12-5 18 0" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#287A89" stroke="#fff" stroke-width="5"/>
<circle cx="48" cy="48" r="20" fill="none" stroke="#fff" stroke-width="5"/>
<path d="m48 48 12-11M22 48h6m40 0h6" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
<circle cx="48" cy="48" r="4" fill="#fff"/>
<path d="M37 62h22" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#A15F55" stroke="#fff" stroke-width="5"/>
<path d="M38 33c0-7 4-11 10-11s10 4 10 11M35 34h26M38 34v32h20V34M29 46h9m20 0h9M26 42h7v9h-7m44-9h-7v9h7M32 68h32" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<rect x="10" y="10" width="76" height="76" rx="20" fill="#526F83" stroke="#fff" stroke-width="5"/>
<path d="M28 69V37l20-11 20 11v32M24 69h48" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="48" cy="46" r="8" fill="none" stroke="#fff" stroke-width="4"/>
<path d="m48 46 5-4M39 69V58h18v11" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#70818F" stroke="#fff" stroke-width="5"/>
<path d="M23 61h50M48 61V36h18" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="23" cy="61" r="5" fill="#fff"/>
<circle cx="73" cy="61" r="5" fill="#fff"/>
<circle cx="67" cy="36" r="5" fill="#fff"/>
<circle cx="48" cy="61" r="6" fill="#70818F" stroke="#fff" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#737988" stroke="#fff" stroke-width="5"/>
<path d="M24 32 46 48 24 64V32Zm48 0L50 48l22 16V32Z" fill="none" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
<circle cx="48" cy="48" r="5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><path d="M511.948675 512.025215m-486.374101 0a486.374101 486.374101 0 1 0 972.748201 0 486.374101 486.374101 0 1 0-972.748201 0Z" fill="#FFFFFF"></path><path d="M149.958351 874.041137A511.972737 511.972737 0 1 1 511.974273 1023.997952a508.644915 508.644915 0 0 1-362.015922-149.956815zM51.19881 512.025215a460.775464 460.775464 0 1 0 460.775463-460.775464 461.313035 461.313035 0 0 0-460.775463 460.775464z m263.358776 232.794003a258.77662 258.77662 0 0 1-16.8951-363.500643 1179.175609 1179.175609 0 0 1 197.903061-193.62809l20.09493-16.306331 20.043733 16.306331a1178.535643 1178.535643 0 0 1 197.92866 193.62809 258.725423 258.725423 0 0 1-16.869502 363.500643 286.704733 286.704733 0 0 1-402.205782 0z m295.792249-168.055051v126.58526h62.486273v-125.894096a85.62744 85.62744 0 0 0-83.349162-86.728182h-2.841449a86.113814 86.113814 0 0 0-85.934624 81.198876v41.904968a23.29476 23.29476 0 1 1-46.56392 0v-8.319556h-63.075041v8.319556a86.395399 86.395399 0 1 0 172.7652 0v-37.015628a23.269161 23.269161 0 1 1 46.538322 0z m-174.070731-21.426059l77.615067-111.789247h-33.278228v-22.296413h-88.648079v22.296413H358.382452z m122.156695-95.815698a9.522693 9.522693 0 0 0-2.559863 7.449204 10.828223 10.828223 0 0 0 10.393046 10.469842h51.990832a10.469842 10.469842 0 1 0 0-20.939685h-52.937981a9.599489 9.599489 0 0 0-7.014027 3.046238z m-166.391139-49.507763h88.648079v-22.322012h-88.648079z m0-33.483017h88.648079v-11.212203h-88.648079z" fill="#13A8A8"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+10 -1
View File
@@ -1 +1,10 @@
globalThis.__TJWATER_CONFIG__ = {};
globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_TIANDITU_TOKEN: "",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000",
TJWATER_WORKBENCH_RUNTIME_MODE: "auto"
};
+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`);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import process from "node:process";
import { writeFile } from "node:fs/promises";
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 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:
"Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
schema: answerUiCatalog.jsonSchema(),
components: answerUiCatalog.componentNames,
props: Object.fromEntries(
Object.entries(answerUiCatalog.data.components).map(([name, definition]) => [
name,
zodToJsonSchema(definition.props, { $refStrategy: "none" })
])
)
};
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();
}
+34 -11
View File
@@ -1,4 +1,12 @@
import { readThemeColor } from "../../tests/browser/support/theme-colors";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "../../tests/browser/support/mock-agent-api";
import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
});
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
const riveRequests: string[] = [];
@@ -11,13 +19,17 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("供水管网智能调度系统")).toBeVisible();
await expect(page.getByText("调度工作台")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByLabel("推荐问题")).toBeVisible();
const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__);
expect(runtimeConfig).toMatchObject({
TJWATER_MAP_URL: expect.any(String),
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
TJWATER_AGENT_API_BASE_URL: expect.any(String)
TJWATER_SERVER_API_BASE_URL: expect.any(String),
TJWATER_AGENT_URL: expect.any(String)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
@@ -37,7 +49,7 @@ test("animates the compact header menu without overflowing", async ({ page }) =>
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await page.locator('button[aria-label^="查看异常处置面板"]:visible').click();
await page.locator('button[aria-label^="查看待办与告警"]:visible').click();
const menu = page.locator('[role="menu"][data-state="open"]');
await expect(menu).toBeVisible();
@@ -71,8 +83,11 @@ test("renders map notices through a single toaster", async ({ page }) => {
test("preserves compact typography in header and agent controls", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first();
const headerControl = page
.locator("header.acrylic-navigation button.font-semibold:visible")
.first();
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
@@ -80,11 +95,12 @@ 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 }) => {
await page.goto("/");
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
@@ -100,7 +116,7 @@ test("lets interactive utilities override surface materials", async ({ page }) =
expect(hoverBackground).not.toBe(restingBackground);
});
test("keeps menu origin and integrated composer styling through Tailwind v4", async ({ page }) => {
test("keeps menu origin and the solid Main Frame styling through Tailwind v4", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
@@ -112,10 +128,17 @@ test("keeps menu origin and integrated composer styling through Tailwind v4", as
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
);
expect(commandShadow).toBe("none");
await expect(agentCommandControl).toHaveCSS("border-top-width", "0px");
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const navigation = page.getByRole("complementary", { name: "Agent 命令面板" });
const navigationMaterial = await navigation.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
});
expect(navigationMaterial.backdropFilter).toBe("none");
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);
});
+8 -3
View File
@@ -1,11 +1,16 @@
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() {
export function App({ authentication }: { authentication: Authentication }) {
return (
<AppProviders>
<MapWorkbenchPage />
<ProjectWorkbench
user={authentication.user ?? undefined}
onLogout={authentication.logout}
getAccessToken={authentication.getAccessToken}
/>
<MapToaster />
</AppProviders>
);
+17
View File
@@ -0,0 +1,17 @@
import { Droplet } from "lucide-react";
import { StartupScreen } from "@/shared/ui/startup-screen";
export function AuthStartupScreen({ error }: { error?: boolean }) {
return (
<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()}
/>
);
}
@@ -0,0 +1,50 @@
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,
messageId,
disabled,
streaming,
streamDone,
onLocate,
onSubmit,
onAsk
}: {
parts: AnswerUiPart[];
messageId: string;
disabled: boolean;
streaming: boolean;
streamDone: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
}) {
return (
<div className="space-y-4">
{parts.map((part, index) =>
part.type === "answer-ui" ? (
<AnswerUiView
key={`ui-${part.block.block_id}`}
block={part.block}
disabled={disabled}
onLocate={onLocate}
onSubmit={onSubmit}
onAsk={onAsk}
/>
) : (
<StreamingTokenResponse
key={`text-${index}`}
className="leading-6"
content={part.text}
messageId={`${messageId}-${index}`}
streamDone={streamDone}
streaming={streaming}
/>
)
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { AgentChatMessage } from "../types";
import { getAnswerPresentation } from "./answer-presentation";
import type { AnswerUiPart } from "./contract";
const ui = (id: string): AnswerUiPart => ({
type: "answer-ui",
block: {
version: "answer-ui@1",
block_id: id,
fallback_text: id,
spec: {
root: "text",
elements: { text: { type: "Text", props: { text: id, tone: "neutral" }, children: [] } }
}
}
});
const parts: AnswerUiPart[] = [
{ type: "text", text: "正在核对资料。" },
ui("report-chart"),
ui("temporary"),
{ type: "text", text: "查询结束。" }
];
const message: AgentChatMessage = {
id: "a",
role: "assistant",
content: "正在核对资料。",
answerParts: parts
};
describe("final report and collapsible process", () => {
it("shows all incremental content while waiting for final_answer", () => {
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
});
it("keeps only the final report and explicitly selected components expanded", () => {
const view = getAnswerPresentation({
...message,
finalAnswer: {
content: "最后的报告内容。",
uiBlockIds: ["report-chart", "report-chart", "missing"]
}
});
expect(view.report).toEqual([{ type: "text", text: "最后的报告内容。" }, parts[1]]);
expect(view.process).toEqual([parts[0], parts[2], parts[3]]);
expect(view.final).toBe(true);
});
it("folds every UI block when the final report does not reference any", () => {
const view = getAnswerPresentation({
...message,
finalAnswer: { content: "报告", uiBlockIds: [] }
});
expect(view.report).toEqual([{ type: "text", text: "报告" }]);
expect(view.process).toEqual(parts);
});
});
@@ -0,0 +1,25 @@
import type { AgentChatMessage } from "../types";
import type { AnswerUiPart } from "./contract";
export function getAnswerPresentation(message: AgentChatMessage): {
report: AnswerUiPart[];
process: AnswerUiPart[];
final: boolean;
} {
const parts =
message.answerParts ??
(message.content ? [{ type: "text" as const, text: message.content }] : []);
if (!message.finalAnswer) return { report: parts, process: [], final: false };
const selected = new Set(message.finalAnswer.uiBlockIds);
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }];
for (const id of selected) {
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
if (part) report.push(part);
}
const process = parts.filter((part) =>
part.type === "answer-ui"
? !selected.has(part.block.block_id)
: part.text.trim() !== message.finalAnswer!.content.trim()
);
return { report, process, final: true };
}
@@ -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>;
}
@@ -0,0 +1,138 @@
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({
version: "answer-ui@1",
block_id: "test",
fallback_text: "演练",
spec: { root: "node", elements: { node: { type, props, children: [] } } }
});
if (!parsed) throw new Error("invalid test fixture");
return parsed;
}
describe("local interactive answer renderer", () => {
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", {
title: "演练压力",
chart_type: "line",
source: "演练数据",
x_data: ["08:00", "09:00"],
series: [{ name: "压力", data: [0.3, 0.2] }]
})}
/>
);
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.3");
expect(screen.getByRole("table")).toHaveTextContent("09:00");
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
.fn()
.mockResolvedValue({ located_ids: ["DEMO-P001"], missing_ids: ["DEMO-P002"] });
render(
<AnswerUiView
block={block("MapLocation", {
label: "定位演练监测点",
layer: "scada",
feature_ids: ["DEMO-P001", "DEMO-P002"],
source: "演练数据"
})}
onLocate={locate}
/>
);
expect(locate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "定位演练监测点" }));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("另有 1 个未找到"));
expect(locate).toHaveBeenCalledWith({
layer: "scada",
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(
() =>
new Promise<void>((resolve) => {
complete = resolve;
})
);
render(
<AnswerUiView
block={block("FollowUpForm", {
title: "补充现场资料",
description: "由用户提供",
fields: [{ name: "location", label: "现场位置", kind: "text", required: true }]
})}
onSubmit={submit}
/>
);
expect(screen.getByLabelText("现场位置(必填)")).toHaveValue("");
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("请填写必填资料");
fireEvent.change(screen.getByLabelText("现场位置(必填)"), {
target: { value: "用户指定位置" }
});
fireEvent.submit(screen.getByRole("form", { name: "补充现场资料" }));
expect(submit).toHaveBeenCalledTimes(1);
expect(submit.mock.calls[0]).toEqual([expect.stringContaining("现场位置:用户指定位置")]);
expect(screen.getByRole("button", { name: "正在发送…" })).toBeDisabled();
complete();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("资料已发送,请查看 Agent 回复")
);
});
});
@@ -0,0 +1,371 @@
import { AskAboutContent } from "./answer-ui-interactions";
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/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 { 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 { 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";
const { registry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
Stack: ({ props, children }) => (
<div
className={cn(
"answer-ui-stack",
props.columns === "two" && "answer-ui-stack-comparison",
props.gap === "compact" && "answer-ui-stack-compact"
)}
>
{children}
</div>
),
Section: ({ props, children }) => (
<section className="answer-ui-section">
<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-muted-foreground">{props.description}</p>
) : null}
<div className="mt-3 space-y-3">{children}</div>
</section>
),
Text: ({ props }) => (
<p
className={cn(
"whitespace-pre-wrap text-sm leading-6",
props.tone === "warning"
? "text-[var(--status-warning-foreground)]"
: props.tone === "info"
? "text-[var(--action-blue-hover)]"
: "text-[var(--ink)]"
)}
>
{props.text}
</p>
),
Metric: ({ props }) => (
<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 }) => (
<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} />
}
});
export function AnswerUiView({
block,
disabled = false,
onLocate,
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, 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>
</div>
</InteractionContext.Provider>
);
}
const evidenceKinds = {
observed: { label: "查询记录", icon: Database },
human_input: { label: "用户提供", icon: MessageSquare },
inference: { label: "分析推断", icon: Lightbulb },
demo: { label: "演练数据", icon: FlaskConical }
};
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 (
<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>
);
}
function LocateButton({
label,
source,
...target
}: AnswerUiMapTarget & { label: string; source: string }) {
const { disabled, onLocate } = useContext(InteractionContext);
const [running, setRunning] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<div className="answer-ui-location">
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
const output = result as
{ located_ids?: string[]; missing_ids?: string[] } | undefined;
setFeedback(
output?.missing_ids?.length
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
: "已定位到地图"
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "地图定位失败");
} finally {
setRunning(false);
}
}}
>
{running ? "正在定位…" : label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
{feedback ? (
<p role="status" className="mt-1 text-xs leading-5">
{feedback}
</p>
) : null}
</div>
</div>
);
}
type FormField = {
name: string;
label: string;
kind: "text" | "datetime" | "number";
required: boolean;
};
function FollowUpForm({
title,
description,
fields
}: {
title: string;
description: string;
fields: FormField[];
}) {
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
const prefix = useId();
const [values, setValues] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
return (
<form
className="answer-ui-form space-y-3"
aria-label={title}
onSubmit={async (event) => {
event.preventDefault();
if (fields.some((field) => field.required && !values[field.name]?.trim())) {
setFeedback("请填写必填资料。");
return;
}
if (!onSubmit || disabled || submitting) return;
const entries = fields
.filter((field) => values[field.name]?.trim())
.map(
(field) =>
`${field.label}:${values[field.name].trim()}${field.kind === "datetime" ? "(北京时间)" : ""}`
);
if (!entries.length) {
setFeedback("请至少填写一项资料。");
return;
}
if (entries.join("\n").length > 9000) {
setFeedback("补充资料过长,请分次发送。");
return;
}
setSubmitting(true);
setFeedback(null);
try {
await onSubmit(
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
);
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
setSubmitting(false);
}
}}
>
<div className="flex items-start gap-2.5">
<span className="answer-ui-evidence-icon">
<FileCheck2 size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
</div>
</div>
{fields.map((field) =>
field.kind === "datetime" ? (
<DateTimeField
key={field.name}
label={`${field.label}${field.required ? "(必填)" : ""}`}
value={values[field.name] ?? ""}
disabled={disabled || submitting || !onSubmit}
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
/>
) : (
<div key={field.name} className="space-y-1.5">
<label htmlFor={`${prefix}-${field.name}`} className="block text-xs font-medium">
{field.label}
{field.required ? "(必填)" : ""}
</label>
{field.kind === "text" ? (
<Textarea
id={`${prefix}-${field.name}`}
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
) : (
<Input
className="h-10"
id={`${prefix}-${field.name}`}
type="number"
value={values[field.name] ?? ""}
maxLength={500}
disabled={disabled || submitting || !onSubmit}
onChange={(event) => {
const value = event.currentTarget.value;
setValues((current) => ({ ...current, [field.name]: value }));
}}
/>
)}
</div>
)
)}
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || submitting || !onSubmit}
>
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
</Button>
{feedback ? (
<p role="status" className="text-xs leading-5">
{feedback}
</p>
) : null}
</form>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const shortText = z.string().max(200);
const text = z.string().max(4000);
const optionalText = shortText.nullable();
const featureId = z.string().min(1).max(128);
/** This is the source of the generated Agent contract, not a remotely hosted registry. */
export const answerUiCatalog = defineCatalog(schema, {
actions: {},
components: {
Stack: {
props: z
.object({ columns: z.enum(["one", "two"]), gap: z.enum(["compact", "normal"]) })
.strict(),
slots: ["default"],
description: "组织内容;双列在窄面板自动改为单列。"
},
Section: {
props: z.object({ title: shortText, description: text.nullable() }).strict(),
slots: ["default"],
description: "带标题的资料分区,保留真实内容层级。"
},
Text: {
props: z.object({ text, tone: z.enum(["neutral", "info", "warning"]) }).strict(),
description: "简短说明、判断边界或资料缺口。"
},
Metric: {
props: z
.object({ label: shortText, value: shortText, unit: optionalText, source: optionalText })
.strict(),
description: "展示真实指标及其单位、来源;不能臆造读数。"
},
Table: {
props: z
.object({
title: shortText,
columns: z.array(shortText).min(1).max(8),
rows: z.array(z.array(z.string().max(1000)).max(8)).max(50)
})
.strict(),
description: "按相同字段比较资料,避免无法比较的指标混列。"
},
Chart: {
props: z
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
x_data: z.array(shortText).min(1).max(100),
series: z
.array(
z
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
.strict()
)
.min(1)
.max(8),
source: text
})
.strict(),
description:
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
},
Evidence: {
props: z
.object({
title: shortText,
summary: text,
source: shortText,
evidence_id: optionalText,
observed_at: optionalText,
kind: z.enum(["observed", "human_input", "inference", "demo"])
})
.strict(),
description: "展示证据依据;明确区分查询记录、用户事实、推断和演练数据。"
},
MapLocation: {
props: z
.object({
label: shortText,
layer: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
feature_ids: z.array(featureId).min(1).max(100),
source: shortText
})
.strict(),
description: "用户点击后定位已有要素;ID 必须来自查询或用户明确提供,不绘制臆测影响范围。"
},
FollowUpForm: {
props: z
.object({
title: shortText,
description: text,
fields: z
.array(
z
.object({
name: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
label: shortText,
kind: z.enum(["text", "datetime", "number"]),
required: z.boolean()
})
.strict()
)
.min(1)
.max(12)
})
.strict(),
description:
"让用户主动填写并提交补充资料;无默认事实。提交产生用户消息,是否写入业务证据由现有 Agent 工具处理。"
}
}
});
export const ANSWER_UI_VERSION = "answer-ui@1" as const;
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { parseAnswerUiBlock } from "./contract";
const block = {
version: "answer-ui@1",
block_id: "reading",
fallback_text: "仍待核对",
spec: {
root: "note",
elements: { note: { type: "Text", props: { text: "仍待核对", tone: "neutral" }, children: [] } }
}
};
describe("untrusted answer components", () => {
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
expect(parseAnswerUiBlock(block)).not.toBeNull();
for (const node of [
{ type: "iframe", props: { src: "https://example.com" }, children: [] },
{
...block.spec.elements.note,
props: { ...block.spec.elements.note.props, script: "alert(1)" }
},
{ ...block.spec.elements.note, on: { click: { action: "fetch" } } }
])
expect(
parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: node } } })
).toBeNull();
});
it("rejects missing, duplicate, cyclic, unreachable and excessive trees", () => {
const stack = { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["note"] };
for (const children of [["missing"], ["note", "note"], ["root"]])
expect(
parseAnswerUiBlock({
...block,
spec: { root: "root", elements: { root: { ...stack, children }, ...block.spec.elements } }
})
).toBeNull();
expect(
parseAnswerUiBlock({
...block,
spec: {
...block.spec,
elements: { ...block.spec.elements, unused: block.spec.elements.note }
}
})
).toBeNull();
});
it("requires aligned, finite chart values and unambiguous form fields", () => {
const chart = {
type: "Chart",
props: {
title: "演练压力",
chart_type: "line",
source: "演练数据",
x_data: ["08:00"],
series: [{ name: "压力", data: [0.3] }]
},
children: []
};
const withNode = (node: unknown) => ({
...block,
spec: { root: "note", elements: { note: node } }
});
expect(parseAnswerUiBlock(withNode(chart))).not.toBeNull();
for (const data of [[0.3, 0.2], [Infinity], [NaN]])
expect(
parseAnswerUiBlock(
withNode({ ...chart, props: { ...chart.props, series: [{ name: "压力", data }] } })
)
).toBeNull();
const field = { name: "location", label: "位置", kind: "text", required: true };
expect(
parseAnswerUiBlock(
withNode({
type: "FollowUpForm",
props: { title: "核验", description: "用户补充", fields: [field, field] },
children: []
})
)
).toBeNull();
});
});
+76
View File
@@ -0,0 +1,76 @@
import type { Spec } from "@json-render/core";
import { z } from "zod";
import { answerUiCatalog, ANSWER_UI_VERSION } from "./catalog";
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
const element = z
.object({ type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48) })
.strict();
const specSchema = z.object({ root: key, elements: z.record(element) }).strict();
const payloadSchema = z
.object({
version: z.literal(ANSWER_UI_VERSION),
block_id: key,
fallback_text: z.string().min(1).max(4000),
spec: specSchema
})
.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;
fallback_text: string;
spec: Spec;
};
export type AnswerUiPart =
{ type: "text"; text: string } | { type: "answer-ui"; block: AnswerUiBlock };
export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
const parsed = payloadSchema.safeParse(value);
if (!parsed.success || JSON.stringify(parsed.data).length > 100000) return null;
const { spec } = parsed.data;
const nodes = Object.keys(spec.elements);
if (!nodes.length || nodes.length > 48 || nodes.some((name) => !key.safeParse(name).success))
return null;
const definitions = answerUiCatalog.data.components;
for (const node of Object.values(spec.elements)) {
const definition = Object.hasOwn(definitions, node.type)
? definitions[node.type as keyof typeof definitions]
: undefined;
if (!definition?.props.safeParse(node.props).success) return null;
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
if (node.type === "Chart") {
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
}
if (node.type === "Table") {
const data = node.props as { columns: unknown[]; rows: unknown[][] };
if (data.rows.some((row) => row.length !== data.columns.length)) return null;
}
if (node.type === "FollowUpForm") {
const data = node.props as { fields: Array<{ name: string }> };
if (new Set(data.fields.map((field) => field.name)).size !== data.fields.length) return null;
}
}
const visited = new Set<string>();
const visit = (id: string, depth: number): boolean => {
if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false;
visited.add(id);
return spec.elements[id].children.every((child) => visit(child, depth + 1));
};
if (!visit(spec.root, 0) || visited.size !== nodes.length) return null;
return parsed.data;
}
@@ -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,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
afterEach(() => vi.unstubAllGlobals());
it("reads mailbox pages without sending or consuming messages and validates the contract", async () => {
const message = { message_id: "m1", task_id: "t1", run_id: "r1", source_session_id: "task-session", target_session_id: "chat-session", direction: "to_chat", text: "现场记录已核对", status: "pending", in_reply_to: null, created_at: "2026-10-13T00:00:00Z", source_owner_id: "private-owner" };
const fetchMock = vi.fn().mockResolvedValue(Response.json({ items: [message], next_cursor: "m1" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "p1" });
const page = await client.listAgentMessages("chat-session", { cursor: "previous" });
expect(page.items[0]).not.toHaveProperty("source_owner_id");
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/session/chat-session/agent-messages?limit=50&cursor=previous");
expect(init.method).toBeUndefined();
expect(init.body).toBeUndefined();
expect(new Headers(init.headers).get("X-Project-Id")).toBe("p1");
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, target_session_id: null, direction: "to_task" }], next_cursor: null }));
expect((await client.listAgentMessages("chat-session")).items[0].target_session_id).toBeNull();
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, status: "invented" }], next_cursor: null }));
await expect(client.listAgentMessages("chat-session")).rejects.toThrow();
});
it("accepts inbox notifications only for the subscribed session", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response([
'event: agent_message\ndata: {"session_id":"other"}\n\n',
'event: agent_message\ndata: {"session_id":"main"}\n\n',
': heartbeat\n\n'
].join(""))));
const onMessage = vi.fn();
await createAgentApiClient("http://agent.local").streamAgentMessages("main", { onMessage });
expect(onMessage).toHaveBeenCalledOnce();
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const agentMessageSchema = z.object({
message_id: z.string().min(1),
task_id: z.string().min(1),
run_id: z.string().min(1),
source_session_id: z.string().min(1),
target_session_id: z.string().min(1).nullable(),
direction: z.enum(["to_task", "to_chat"]),
text: z.string(),
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
in_reply_to: z.string().nullable(),
created_at: z.string(),
failure_detail: z.string().nullable().optional()
});
export const agentMessagePageSchema = z.object({
items: z.array(agentMessageSchema),
next_cursor: z.string().nullable()
});
export type AgentMailboxMessage = z.infer<typeof agentMessageSchema>;
export type AgentMessagePage = z.infer<typeof agentMessagePageSchema>;
@@ -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();
});
});
+120 -5
View File
@@ -1,7 +1,83 @@
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, 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) {
expect(new Headers(request?.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(request?.headers).get("X-Project-Id")).toBe("project");
}
});
it("uses the scoped Agent request and rejects invented activity references", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ suggestions: [{ question: "核对城南压力告警的证据", activity_id: "alert:pressure" }] }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
const activities = [{ id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const }];
const controller = new AbortController();
expect(await client.generatePromptSuggestions(activities, "test/model", controller.signal)).toEqual([{ question: "核对城南压力告警的证据", activityId: "alert:pressure" }]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/suggestions");
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
expect(init.signal).toBe(controller.signal);
fetchMock.mockResolvedValue(Response.json({ suggestions: [{ question: "引用其他项目", activity_id: "unknown" }] }));
await expect(client.generatePromptSuggestions(activities, "test/model")).rejects.toThrow("Invalid Agent suggestion");
vi.unstubAllGlobals();
});
});
describe("Agent API client sessions", () => {
it("forces renewal and sends only the refresh request id with verified context headers", async () => {
const getAccessToken = vi.fn(async () => "TEST_NEW_TOKEN");
const fetchMock = vi.fn(async () => Response.json({ accepted: true }, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local", { getAccessToken, getProjectId: async () => "p" }).refreshCredential("s", "request-1");
expect(getAccessToken).toHaveBeenCalledWith(true);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_NEW_TOKEN");
expect(JSON.parse(String(init.body))).toEqual({ request_id: "request-1" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -33,7 +109,11 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
},
{
id: "old",
@@ -43,7 +123,11 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
}
]);
});
@@ -79,10 +163,10 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
"http://not-agent.local/api/v1/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
@@ -94,7 +178,7 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
"http://agent.local/api/v1/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
@@ -105,6 +189,37 @@ describe("Agent API client sessions", () => {
);
});
it("adds the current Keycloak access token without dropping request headers", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
expect(getAccessToken).toHaveBeenCalledOnce();
const init = fetchMock.mock.calls[0]?.[1];
if (!init) throw new Error("Expected Agent request init");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("sends the authorized project context on Agent requests", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const getProjectId = vi.fn().mockResolvedValue("project-117");
await createAgentApiClient("http://agent.local", { getProjectId }).listSessions();
expect(getProjectId).toHaveBeenCalledOnce();
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("X-Project-Id")).toBe("project-117");
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
+306 -91
View File
@@ -1,6 +1,28 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
export type AgentEventTriggerContext = {
schema_version: "agent-trigger@1";
kind: "event";
trigger_id: string;
occurred_at: string;
source: "peripheral_sample";
subject_type: "work_order";
subject_id: string;
workflow: "water_quality_complaint";
payload: {
title: string;
summary: string;
location?: string | null;
priority: "low" | "normal" | "high" | "urgent";
scenario_code: "WQ-SECONDARY-SUPPLY-001";
origin: "synthetic";
};
};
export type AgentPermissionReply = "once" | "always" | "reject";
@@ -16,10 +38,16 @@ export type AgentModelsResponse = {
models: AgentModelOption[];
};
export type AgentInputDelivery = "steer" | "queue";
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;
@@ -28,26 +56,60 @@ export type AgentChatSession = {
updated_at?: number | string;
is_title_manually_edited?: boolean;
parent_session_id?: string;
created?: boolean;
event_duplicate?: boolean;
trigger_id?: string;
purpose?: AgentSessionPurpose;
task_id?: string;
};
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;
};
export type AgentRecentActivity = {
id: string;
kind: "alert" | "task" | "analysis" | "session";
title: string;
occurred_at: string;
summary?: string;
status?: string;
origin: "backend" | "agent" | "demo";
};
export type AgentPromptSuggestion = {
question: string;
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 =
| "execution_plan"
| "final_answer"
| "token"
| "progress"
| "todo_update"
@@ -58,15 +120,23 @@ export type AgentSessionStreamDataEventType =
| "tool_call"
| "frontend_action"
| "frontend_action_result"
| "answer_ui"
| "ui_envelope"
| "session_title"
| "done"
| "error"
| "auth_required";
| "auth_required"
| "credential_refresh_required"
| "credential_refreshed"
| "credential_refresh_failed"
| "agent_message"
| "user_input";
export type AgentSessionStreamEvent =
| {
type: "state";
executionPlans?: unknown[];
messagePage?: AgentMessagePagination;
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -79,13 +149,30 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
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; 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, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
messageLimit?: number;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
@@ -109,56 +196,172 @@ export type AgentApiClient = {
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string | undefined>;
};
const CHAT_PATH = "/api/v1/agent/chat";
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
path,
init,
options.getAccessToken,
options.getProjectId
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
};
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
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" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
});
},
async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
return result.items;
},
async updateResponseInput(sessionId, id, delivery) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
body: JSON.stringify({ activities, model }),
signal,
});
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
throw new Error("Invalid Agent suggestions");
}
const ids = new Set(activities.map((item) => item.id));
const questions = new Set<string>();
return result.suggestions.map((item): AgentPromptSuggestion => {
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
throw new Error("Invalid Agent suggestion");
}
const question = item.question.trim();
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
questions.add(question);
return { question, activityId: item.activity_id as string | null };
});
},
async createSession(options) {
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger_context: options?.triggerContext, topic_id: options?.topicId })
});
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
async listSessions(listOptions) {
const params = new URLSearchParams();
if (listOptions?.purpose) params.set("purpose", listOptions.purpose);
if (listOptions?.taskId) params.set("task_id", listOptions.taskId);
const payload = await requestJson<{ sessions?: unknown[] }>(
`/sessions${params.size ? `?${params.toString()}` : ""}`
);
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`,
{ method: "POST" }
);
},
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
return requestJson<unknown>("/frontend-action-registry");
},
async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
});
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/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;
@@ -173,13 +376,8 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/stream`,
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream${options.messageLimit ? `?message_limit=${options.messageLimit}` : ""}`,
{ signal: options.signal }
);
@@ -192,18 +390,29 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
await readSessionSseStream(response, options.onEvent);
},
async listAgentMessages(sessionId, options) {
const query = new URLSearchParams({ limit: "50" });
if (options?.cursor) query.set("cursor", options.cursor);
return agentMessagePageSchema.parse(await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
));
},
async streamAgentMessages(sessionId, options) {
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
await readSessionSseStream(response, (event) => {
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
});
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
@@ -217,46 +426,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
const payload = await requestJson<unknown>("/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
return requestJson<unknown>("/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
return requestJson<unknown>(
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -268,9 +461,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -281,9 +472,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -292,10 +481,15 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
});
},
async refreshCredential(sessionId, requestId) {
await options.getAccessToken?.(true);
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId })
});
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
await requestJson<unknown>("/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
@@ -304,38 +498,29 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId);
const headers = hasContextHeaders ? new Headers(init?.headers) : undefined;
if (accessToken) headers?.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -429,6 +614,10 @@ function readRunStatus(value: unknown): AgentRunStatus | undefined {
: undefined;
}
function readSessionPurpose(value: unknown): AgentSessionPurpose {
return value === "task_execution" || value === "task_collaboration" ? value : "ordinary_chat";
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
@@ -442,12 +631,22 @@ 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,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
runStatus: readRunStatus(value.run_status),
purpose: readSessionPurpose(value.purpose),
taskId: typeof value.task_id === "string" ? value.task_id : undefined,
trigger: isRecord(value.trigger) && value.trigger.workflow === "water_quality_complaint" &&
typeof value.trigger.subject_id === "string" && typeof value.trigger.occurred_at === "string" &&
Number.isFinite(Date.parse(value.trigger.occurred_at))
? { subjectId: value.trigger.subject_id, workflow: "water_quality_complaint", occurredAt: value.trigger.occurred_at }
: undefined
};
}
@@ -462,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)
};
}
@@ -527,6 +727,8 @@ function emitSessionSseEvent(
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
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,
@@ -548,9 +750,11 @@ function emitSessionSseEvent(
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
return (
value === "final_answer" ||
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
@@ -558,11 +762,17 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "tool_call" ||
value === "frontend_action" ||
value === "frontend_action_result" ||
value === "answer_ui" ||
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
value === "credential_refresh_failed" ||
value === "auth_required"
|| value === "agent_message"
|| value === "user_input"
);
}
@@ -579,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 };
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { UIMessage, UIMessageChunk } from "ai";
import { createResponseRequest, responsesToUiStream } from "./responses-transport";
const bytes = (events: Record<string, unknown>[], partial = false) => {
const text = events.map((event) => `event: ${String(event.type)}\r\ndata: ${JSON.stringify(event)}\r\n\r\n`).join("");
const encoded = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({ start(controller) {
if (partial) for (let i = 0; i < encoded.length; i += 7) controller.enqueue(encoded.slice(i, i + 7));
else controller.enqueue(encoded);
controller.close();
} });
};
const collect = async (stream: ReadableStream<UIMessageChunk>) => {
const parts: UIMessageChunk[] = [];
const reader = stream.getReader();
while (true) { const next = await reader.read(); if (next.done) break; parts.push(next.value); }
return parts;
};
const events = (): Record<string, unknown>[] => [
{ type: "response.created", sequence_number: 0, response: { id: "resp_1" } },
{ type: "response.output_item.added", sequence_number: 1, item: { type: "message", id: "msg_1", role: "assistant", content: [] } },
{ type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", content_index: 0, part: { type: "output_text", text: "" } },
{ type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", content_index: 0, delta: "压力待核验" },
{ type: "tjwater.permission_request", sequence_number: 4, response_id: "resp_1", data: { request_id: "permission_1" } },
{ type: "response.output_text.done", sequence_number: 5, item_id: "msg_1", content_index: 0, text: "压力待核验" },
{ type: "response.completed", sequence_number: 6, response: { id: "resp_1", status: "completed" } },
];
describe("Responses transport", () => {
it("sends only the latest user input and keeps business options explicit", () => {
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: { client_message_id: "new", approval_mode: "always", capabilities: [] },
});
});
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
expect(await collect(responsesToUiStream(bytes(events(), true)))).toEqual([
{ type: "start", messageId: "msg_1" }, { type: "text-start", id: "msg_1:0" },
{ type: "text-delta", id: "msg_1:0", delta: "压力待核验" },
{ type: "data-permission_request", data: { request_id: "permission_1" } },
{ type: "text-end", id: "msg_1:0" }, { type: "finish", finishReason: "stop" },
]);
});
it("reports premature disconnects, mismatched owners and invalid event order", async () => {
await expect(collect(responsesToUiStream(bytes(events().slice(0, 5))))).rejects.toThrow("断开");
const wrong = events(); wrong[4] = { ...wrong[4], response_id: "other" };
await expect(collect(responsesToUiStream(bytes(wrong)))).rejects.toThrow("归属");
await expect(collect(responsesToUiStream(bytes([events()[0]!, events()[3]!])))).rejects.toThrow();
});
it("turns failed and cancelled Responses into visible errors", async () => {
const failed = events();
failed[6] = { type: "response.failed", sequence_number: 6, response: { id: "resp_1", error: { message: "数据源不可用" } } };
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
});
});
@@ -0,0 +1,111 @@
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
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();
if (!text) throw new Error("Responses 请求需要用户文本");
return {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}),
},
};
}
/** Keep UI rendering independent of the public Responses wire protocol. */
export function responsesToUiStream(stream: ReadableStream<Uint8Array>): ReadableStream<UIMessageChunk> {
const decoder = new TextDecoder();
let buffer = "";
let responseId: string | undefined;
let messageStarted = false;
let messageId: string | undefined;
let terminal = false;
let sequence = -1;
const texts = new Set<string>();
const parse = (block: string, controller: TransformStreamDefaultController<UIMessageChunk>) => {
if (block.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
const lines = block.split(/\r?\n/);
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
if (!data) return;
if (terminal) throw new Error("Responses 流在结束后仍返回事件");
const value: unknown = JSON.parse(data);
if (!record(value) || typeof value.type !== "string" || !Number.isInteger(value.sequence_number) || (value.sequence_number as number) <= sequence) throw new Error("Responses 流事件格式不正确");
sequence = value.sequence_number as number;
const named = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
if (named && named !== value.type) throw new Error("Responses 流事件类型不一致");
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 流缺少创建事件");
const textId = () => {
if (value.item_id !== messageId || !Number.isInteger(value.content_index) || (value.content_index as number) < 0) throw new Error("Responses 文本归属不正确");
return `${messageId}:${String(value.content_index)}`;
};
if (value.type === "response.output_item.added" && record(value.item) && value.item.type === "message") {
if (messageStarted || typeof value.item.id !== "string") throw new Error("Responses 回答消息不正确");
messageStarted = true;
messageId = value.item.id;
controller.enqueue({ type: "start", messageId: value.item.id });
} else if (value.type === "response.content_part.added" && record(value.part) && value.part.type === "output_text") {
if (!messageStarted) throw new Error("Responses 流缺少回答消息");
texts.add(textId());
controller.enqueue({ type: "text-start", id: textId() });
} else if (value.type === "response.output_text.delta") {
if (!texts.has(textId()) || typeof value.delta !== "string") throw new Error("Responses 文本事件顺序不正确");
controller.enqueue({ type: "text-delta", id: textId(), delta: value.delta });
} else if (value.type === "response.output_text.done") {
if (!texts.delete(textId())) throw new Error("Responses 文本结束事件不正确");
controller.enqueue({ type: "text-end", id: textId() });
} else if (value.type.startsWith("tjwater.")) {
if (value.response_id !== responseId || !/^tjwater\.[a-z][a-z0-9_]*$/.test(value.type)) throw new Error("业务流事件归属不正确");
controller.enqueue({ type: `data-${value.type.slice(8)}`, data: value.data,
...(typeof value.id === "string" ? { id: value.id } : {}), ...(value.transient === true ? { transient: true } : {}) } as UIMessageChunk);
} else if (["response.completed", "response.failed", "response.incomplete"].includes(value.type)) {
if (!record(value.response) || value.response.id !== responseId || texts.size) throw new Error("Responses 结束事件不正确");
terminal = true;
if (!messageStarted) controller.enqueue({ type: "start", messageId: responseId });
if (value.type !== "response.completed") {
const failure = value.response.error;
controller.enqueue({ type: "error", errorText: record(failure) && typeof failure.message === "string" ? failure.message : "请求未完成或已中止" });
}
controller.enqueue({ type: "finish", finishReason: value.type === "response.completed" ? "stop" : "error" });
}
};
const flushBlocks = (controller: TransformStreamDefaultController<UIMessageChunk>) => {
let match: RegExpExecArray | null;
while ((match = /\r?\n\r?\n/.exec(buffer))) {
const block = buffer.slice(0, match.index);
buffer = buffer.slice(match.index + match[0].length);
parse(block, controller);
}
if (buffer.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
};
return stream.pipeThrough(new TransformStream<Uint8Array, UIMessageChunk>({
transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); flushBlocks(controller); },
flush(controller) {
buffer += decoder.decode();
flushBlocks(controller);
if (buffer.trim()) parse(buffer, controller);
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
},
}));
}
export class ResponsesChatTransport<Message extends UIMessage> extends HttpChatTransport<Message> {
protected processResponseStream(stream: ReadableStream<Uint8Array>) {
return responsesToUiStream(stream);
}
}
+18 -3
View File
@@ -1,8 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
import { fetchAgentBootstrap, fetchAgentSessions, resolveAgentModelSelection } from "./swr";
describe("Agent API SWR helpers", () => {
it("preserves valid model choices and resolves removed choices against the current catalog", () => {
const models = [
{ id: "primary/fast", label: "快速", description: "Primary", icon: "bolt" as const },
{ id: "secondary/expert", label: "专家", description: "Secondary", icon: "sparkle" as const }
];
const catalog = { defaultModel: "secondary/expert", models };
expect(resolveAgentModelSelection(catalog, "primary/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection(catalog, "retired/fast")).toBe("secondary/expert");
expect(resolveAgentModelSelection(catalog)).toBe("secondary/expert");
expect(resolveAgentModelSelection({ ...catalog, defaultModel: "missing/model" }, "retired/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection({ defaultModel: "primary/fast", models: [] }, "primary/fast")).toBe("");
});
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
@@ -35,13 +48,15 @@ describe("Agent API SWR helpers", () => {
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
updatedAt: 2,
purpose: "ordinary_chat" as const
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
await expect(fetchAgentSessions(client)).resolves.toEqual(sessions);
expect(client.listSessions).toHaveBeenCalledWith({ purpose: "ordinary_chat" });
});
});
+11 -2
View File
@@ -1,7 +1,8 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
AgentModelOption,
AgentModelsResponse
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
@@ -16,6 +17,12 @@ export type AgentBootstrapData = {
defaultModel: string;
};
export function resolveAgentModelSelection(catalog: AgentModelsResponse, current = ""): string {
if (catalog.models.some((model) => model.id === current)) return current;
if (catalog.models.some((model) => model.id === catalog.defaultModel)) return catalog.defaultModel;
return catalog.models[0]?.id ?? "";
}
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
@@ -32,5 +39,7 @@ export async function fetchAgentBootstrap(client: AgentApiClient): Promise<Agent
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
const sessions = await client.listSessions({ purpose: "ordinary_chat" });
// Keep the product boundary even while an older Agent service is rolling forward.
return sessions.filter((session) => session.purpose === "ordinary_chat");
}
@@ -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-panel 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 surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white"
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-slate-500">
{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-slate-500"
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-blue-200"
: "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-slate-500">
删除后无法从历史记录中恢复
</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-white 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-slate-500">
{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-blue-600"
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-slate-500 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-slate-500"
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);
}
@@ -13,6 +13,9 @@ import { AnimatePresence, motion } from "motion/react";
import { useId, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import {
StatusBadge,
StatusDot,
@@ -218,9 +221,9 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
return (
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500">
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
{running ? (
<StatusDot tone="info" activity="live" aria-hidden="true" />
) : null}
@@ -236,8 +239,8 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
<button
type="button"
className={cn(
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600",
expanded && "text-blue-500"
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-[var(--ink-muted)] transition-colors [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
expanded && "text-[var(--action-blue-hover)]"
)}
aria-controls={listId}
aria-expanded={expanded}
@@ -296,7 +299,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{item.detail ? (
<span
className={cn(
"block leading-5 text-slate-500",
"block leading-5 text-[var(--ink-muted)]",
compact ? "truncate" : "break-words"
)}
title={item.detail}
@@ -312,7 +315,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
className={cn(
"min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running"
? "text-blue-600"
? "text-[var(--action-blue-hover)]"
: [
`status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]"
@@ -429,7 +432,7 @@ function PermissionRequestCard({
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
<p className="mt-0.5 truncate font-mono text-xs text-[var(--ink-muted)]" title={target}>
{target}
</p>
{permission.error ? (
@@ -470,7 +473,7 @@ function PermissionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
className="h-8 border-[var(--surface-border)] bg-[var(--action-soft)] px-2 text-xs text-[var(--action-blue-hover)] hover:bg-[var(--action-selection-soft)]"
disabled={disabled}
onClick={() => reply("always")}
>
@@ -506,7 +509,7 @@ function QuestionList({
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
<AgentNestedBlock icon={HelpCircle} iconClassName="text-[var(--action-blue-hover)]" title="补充信息" floating>
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
@@ -587,7 +590,7 @@ function QuestionRequestCard({
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
<p className="mt-2 truncate text-[var(--ink-muted)]" title={request.answers.map((answer) => answer.join("、")).join(";")}>
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
</p>
) : null}
@@ -617,7 +620,7 @@ function QuestionRequestCard({
type="button"
size="sm"
variant="outline"
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
className="h-8 border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs text-slate-600 hover:bg-[var(--surface-reading)]"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
@@ -686,21 +689,20 @@ function QuestionInput({
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
const Choice = question.multiple ? Checkbox : Radio;
return (
<label
key={option.label}
className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
checked
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
<Choice
name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
@@ -708,7 +710,7 @@ function QuestionInput({
<span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span>
<span className="block leading-4 text-[var(--ink-muted)]">{option.description}</span>
) : null}
</span>
</label>
@@ -719,27 +721,24 @@ function QuestionInput({
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
value.customSelected
? "border-blue-300 bg-blue-50/70 text-blue-700"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
: "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
disabled && "cursor-default opacity-60"
)}
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="h-3.5 w-3.5 accent-blue-600"
checked={value.customSelected}
disabled={disabled}
onChange={(event) => toggleCustom(event.currentTarget.checked)}
/>
{question.multiple ? (
<Checkbox name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
) : (
<Radio name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
)}
<span className="font-semibold text-slate-700">自定义回答</span>
</label>
) : null}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
<Textarea
className="min-h-16 resize-none border-[var(--surface-divider)]/80 px-2 py-1.5 text-xs leading-5 text-slate-700 shadow-none md:text-xs disabled:opacity-60"
aria-label="自定义回答内容"
placeholder="输入自定义回答"
value={value.custom}
@@ -0,0 +1,39 @@
import { Mail } from "lucide-react";
import { Disclosure } from "@/shared/ui/disclosure";
import { Button } from "@/shared/ui/button";
import type { AgentMailboxMessage } from "../api/agent-messages";
const messageStatusLabels = { pending: "待接收", enqueued: "已排队", delivered: "已投递", failed: "投递失败" };
export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMore, onLoadMore, onRefresh }: {
messages: AgentMailboxMessage[];
loading?: boolean;
error?: string | null;
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => Promise<void>;
onRefresh?: () => void;
}) {
if (!messages.length && !loading && !error) return null;
const received = messages.filter((message) => message.direction === "to_chat").length;
return <Disclosure label={<span className="flex items-center gap-2 px-3"><Mail size={14} />任务消息{received ? ` · 收到 ${received} 条` : ""}</span>} initiallyOpen className="surface-reading rounded-xl">
<div className="space-y-3 p-3 text-xs" aria-live="polite">
<p className="text-[var(--ink-muted)]">新消息会在此显示。由你决定是否继续发送指令。</p>
{loading ? <p className="text-[var(--ink-muted)]">正在加载任务消息…</p> : null}
{error ? <div className="flex items-center justify-between gap-2"><p className="text-[var(--status-warning-foreground)]">{error}</p><Button variant="ghost" size="sm" onClick={onRefresh}>重试</Button></div> : null}
{messages.map((message) => <article key={message.message_id} className="space-y-1.5 border-t border-[var(--surface-divider)] pt-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-[var(--ink-muted)]">
<span>{message.direction === "to_chat" ? "调查任务来信" : "发给调查任务"}</span>
<span>{messageStatusLabels[message.status]}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</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}
</div>
</Disclosure>;
}
@@ -1,135 +0,0 @@
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
import type { AgentChatMessage } from "../types";
export function AgentOperationalBrief({
messages,
statusLabel,
streaming,
onSubmitCommand
}: {
messages: AgentChatMessage[];
statusLabel: string;
streaming: boolean;
onSubmitCommand: (prompt: string) => void;
}) {
const brief = createOperationalBrief(messages, statusLabel, streaming);
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
{brief.task}
</p>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric
icon={<Radio size={13} aria-hidden="true" />}
label="证据"
value={brief.evidence}
/>
<AgentBriefMetric
icon={<Route size={13} aria-hidden="true" />}
label="计划"
value={brief.plan}
/>
<AgentBriefMetric
icon={<ClipboardCheck size={13} aria-hidden="true" />}
label="确认"
value={brief.confirmation}
/>
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
type="button"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
预览影响
</Button>
<Button
type="button"
variant="outline"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
检查证据
</Button>
</div>
</div>
);
}
function AgentBriefMetric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
{value}
</dd>
</div>
);
}
function createOperationalBrief(
messages: AgentChatMessage[],
statusLabel: string,
streaming: boolean
) {
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations = countPendingAgentInputs(messages);
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan =
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel =
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
task,
evidence,
plan,
confirmation,
stateLabel,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -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-slate-300/70 bg-slate-100 text-slate-500">
<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>;
}
@@ -6,6 +6,7 @@ import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
import type { parseFrontendChart } from "../frontend-action/chart";
import {
agentLayoutTransition,
agentPanelItemVariants,
@@ -47,18 +48,18 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result;
return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div data-agent-ui-result={result.id} className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
{getEnvelopeIcon(envelope)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
<p className="text-xs text-[var(--ink-muted)]">{getSurfaceLabel(envelope.surface)}</p>
</div>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
<span className="shrink-0 rounded-full bg-[var(--surface-control)] px-2 py-1 text-xs font-semibold text-slate-600">
agent-ui@1
</span>
</div>
@@ -70,26 +71,30 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
);
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
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, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data);
const data = normalizeSafeChartData(envelope.data, 100);
if (!data || data.series.length === 0 || data.xData.length === 0) {
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
}
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>
<p className="mt-1 text-xs text-slate-500">
<p className="mt-1 text-xs text-[var(--ink-muted)]">
{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">
@@ -100,7 +105,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
</div>
</div>
<SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
</div>
);
}
@@ -255,7 +260,8 @@ function RegisteredComponentEnvelope({
if (envelope.component === "HistoryPanel") {
return (
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
{envelope.data ? <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: "监测历史", chart_type: "line" }, data: envelope.data }} /> : null}
</TrustedPanelShell>
);
}
@@ -263,7 +269,7 @@ function RegisteredComponentEnvelope({
if (envelope.component === "ScadaPanel") {
return (
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
</TrustedPanelShell>
);
}
@@ -302,7 +308,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
<dl className="grid grid-cols-2 gap-2">
{rows.map((row) => (
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
<dt className="truncate text-xs text-[var(--ink-muted)]">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value}
</dd>
@@ -381,7 +387,7 @@ function chartStroke(index: number) {
}
function chartColorClass(index: number) {
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
}
function formatChartNumber(value: number) {
@@ -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>
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { executionPlanStages, parseExecutionPlan, readExecutionPlanHistory } from "./execution-plan";
const snapshot = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1, updated_at: "2026-09-23T01:00:00.000Z",
title: "调查", reason: "开始",
nodes: [{ id: "a", title: '水质"] --> injected["<script>', status: "waiting", owner: "human", detail: "等待采样", evidence: [] }], edges: []
};
describe("execution plan projection", () => {
it("groups independent checks before their shared downstream decision", () => {
const plan = parseExecutionPlan({ ...snapshot,
nodes: ["a", "b", "c", "d"].map((id) => ({ ...snapshot.nodes[0], id })),
edges: [{ from: "a", to: "b", label: "" }, { from: "a", to: "c", label: "" }, { from: "b", to: "d", label: "" }, { from: "c", to: "d", label: "" }]
}, "s1")!;
expect(executionPlanStages(plan).map((stage) => stage.map((node) => node.id))).toEqual([["a"], ["b", "c"], ["d"]]);
});
it("rejects other sessions and dangling dependencies", () => {
expect(parseExecutionPlan(snapshot, "s2")).toBeNull();
expect(parseExecutionPlan({ ...snapshot, edges: [{ from: "a", to: "b", label: "" }] }, "s1")).toBeNull();
});
it("restores and deduplicates UI and active-run snapshots without completing waiting work", () => {
const history = readExecutionPlanHistory([{ parts: [{ type: "data-execution_plan", data: snapshot }] }, { executionPlans: [snapshot] }], "s1");
expect(history).toHaveLength(1);
expect(history[0].nodes[0].status).toBe("waiting");
});
it("rejects cyclic dependencies rather than guessing their order", () => {
expect(parseExecutionPlan({ ...snapshot, nodes: [snapshot.nodes[0], { ...snapshot.nodes[0], id: "b" }], edges: [{ from: "a", to: "b", label: "" }, { from: "b", to: "a", label: "" }] }, "s1")).toBeNull();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod";
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const schema = z.object({
schema_version: z.enum(["execution-plan@1", "execution-plan@2"]),
session_id: z.string().min(1),
revision: z.number().int().positive(),
updated_at: z.string().datetime(),
title: z.string().min(1).max(120),
reason: z.string().min(1).max(1000),
nodes: z.array(z.object({
id, title: z.string().min(1).max(120),
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
owner: z.enum(["agent", "human"]),
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12),
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).optional(),
selected_edge_id: id.nullable().optional(), gate_ref: z.string().uuid().nullable().optional(),
revisits_node_id: id.nullable().optional()
})).min(1).max(40),
edges: z.array(z.object({ id: id.optional(), from: id, to: id, label: z.string().max(120) })).max(80)
}).refine((plan) => {
const ids = new Set(plan.nodes.map((node) => node.id));
return ids.size === plan.nodes.length && plan.edges.every((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
});
export type AgentExecutionPlan = z.infer<typeof schema>;
export function parseExecutionPlan(value: unknown, sessionId: string): AgentExecutionPlan | null {
const parsed = schema.safeParse(value);
return parsed.success && parsed.data.session_id === sessionId && executionPlanStages(parsed.data).length ? parsed.data : null;
}
export function readExecutionPlanHistory(messages: unknown[], sessionId: string) {
const plans = messages.flatMap((message) => {
const value = (message as { executionPlans?: unknown } | null)?.executionPlans;
const parts = (message as { parts?: unknown } | null)?.parts;
const data = Array.isArray(parts) ? parts.filter((part) => part?.type === "data-execution_plan").map((part) => part.data) : [];
return [...(Array.isArray(value) ? value : []), ...data];
}).map((value) => parseExecutionPlan(value, sessionId)).filter((plan): plan is AgentExecutionPlan => plan !== null);
return [...new Map(plans.map((plan) => [plan.revision, plan])).values()].sort((a, b) => a.revision - b.revision);
}
export const executionStatusLabels = {
pending: "待执行", running: "执行中", waiting: "等待资料/人工", completed: "已完成",
failed: "失败", skipped: "不适用", cancelled: "已取消"
};
/** Same-stage nodes have no dependency on one another; this describes eligibility, not a scheduler. */
export function executionPlanStages(plan: AgentExecutionPlan): AgentExecutionPlan["nodes"][] {
const remaining = new Set(plan.nodes.map((node) => node.id));
const stages: AgentExecutionPlan["nodes"][] = [];
while (remaining.size) {
const nodes = plan.nodes.filter((node) => remaining.has(node.id) &&
plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
if (!nodes.length) return []; // Malformed cyclic history is never converted into a guessed sequence.
stages.push(nodes);
nodes.forEach((node) => remaining.delete(node.id));
}
return stages;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { appendExecutionReportText, readExecutionReport, updateExecutionReportProgress } from "./execution-report";
describe("execution report", () => {
it("uses assistant output and public tool records without exposing reasoning or inventing completion", () => {
const report = readExecutionReport([
{ role: "user", content: "不应成为调查结论" },
{ id: "a", role: "assistant", parts: [
{ type: "text", text: "疑似二次供水问题,待现场核验。" },
{ type: "data-progress", data: { id: "private", phase: "planning", title: "推理内容", status: "running" } },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "核对记录", status: "running" } }
] }
]);
expect(report.responses).toEqual([{ id: "a", text: "疑似二次供水问题,待现场核验。" }]);
expect(report.activities).toHaveLength(1);
expect(report.activities[0].status).toBe("running");
expect(updateExecutionReportProgress(report, { id: "tool", phase: "tool", title: "查询失败", status: "error" }).activities[0].status).toBe("error");
});
it("joins resumed chunks to the active snapshot despite differing transport IDs", () => {
const report = readExecutionReport([{ id: "legacy", role: "assistant", content: "上游指标正常。" }]);
const next = appendExecutionReportText(report, { message_id: "ui-assistant", content: "等待现场采样。" });
expect(next.responses).toEqual([{ id: "ui-assistant", text: "上游指标正常。等待现场采样。" }]);
});
it("keeps a new empty assistant turn separate from the previous report", () => {
const report = readExecutionReport([{ id: "old", role: "assistant", content: "前序结论" }, { id: "current", role: "assistant", content: "" }]);
const next = appendExecutionReportText(report, { message_id: "current-ui", content: "最新进展" });
expect(next.responses.map((response) => response.text)).toEqual(["前序结论", "最新进展"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
export type AgentExecutionReport = {
responses: { id: string; text: string }[];
activities: { id: string; title: string; detail?: string; status: "running" | "completed" | "error" }[];
};
const record = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
export function updateExecutionReportProgress(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const progress = record(value);
// The report contains public execution facts, not the model's reasoning stream.
if (!progress || !["tool", "start", "execution_plan"].includes(String(progress.phase)) ||
typeof progress.id !== "string" || typeof progress.title !== "string" ||
!["running", "completed", "error"].includes(String(progress.status))) return report;
const activity = {
id: progress.id, title: progress.title,
detail: typeof progress.detail === "string" ? progress.detail : undefined,
status: progress.status as "running" | "completed" | "error"
};
const existing = report.activities.findIndex((item) => item.id === activity.id);
return { ...report, activities: existing < 0 ? [...report.activities, activity].slice(-8) :
report.activities.map((item, index) => index === existing ? activity : item) };
}
export function readExecutionReport(messages: unknown[]): AgentExecutionReport {
let report: AgentExecutionReport = { responses: [], activities: [] };
messages.forEach((value, index) => {
const message = record(value);
if (!message || message.role !== "assistant") return;
const parts = Array.isArray(message.parts) ? message.parts.map(record).filter((part) => part !== null) : [];
const text = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("") ||
(typeof message.content === "string" ? message.content : "");
report.responses.push({ id: typeof message.id === "string" ? message.id : `response-${index}`, text });
for (const progress of Array.isArray(message.progress) ? message.progress : []) report = updateExecutionReportProgress(report, progress);
for (const part of parts) if (part.type === "data-progress") report = updateExecutionReportProgress(report, part.data);
});
return { ...report, responses: report.responses.slice(-3) };
}
export function appendExecutionReportText(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const data = record(value);
if (!data || typeof data.content !== "string") return report;
const id = typeof data.message_id === "string" ? data.message_id : "current-response";
const matching = report.responses.findIndex((response) => response.id === id);
// Active-run snapshots and UI stream chunks currently use different assistant IDs.
const existing = matching < 0 ? report.responses.length - 1 : matching;
return { ...report, responses: existing < 0 ? [...report.responses, { id, text: data.content }].slice(-3) :
report.responses.map((response, index) => index === existing ? { ...response, id, text: response.text + data.content } : response) };
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { parseFrontendChart } from "./chart";
describe("frontend chart boundary", () => {
const chart = { title: "压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1", data: [20, 21] }] };
it("accepts finite aligned series and rejects code, unaligned data and excessive points", () => {
expect(parseFrontendChart(chart)).not.toBeNull();
expect(parseFrontendChart({ ...chart, formatter: "function(){}" })).toBeNull();
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20] }] })).toBeNull();
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
});
});
@@ -0,0 +1,11 @@
import { z } from "zod";
const chartSchema = z.object({
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
export const parseFrontendChart = (value: unknown) => {
const parsed = chartSchema.safeParse(value);
return parsed.success ? parsed.data : null;
};
@@ -13,7 +13,7 @@ beforeEach(() => {
});
describe("SCADA frontend actions", () => {
it("accepts only the two registered SCADA action names", () => {
it("accepts registered view actions and rejects unknown action names", () => {
expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1",
actions: [
@@ -23,9 +23,10 @@ describe("SCADA frontend actions", () => {
]
})?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis",
"clear_scada_analysis"
"clear_scada_analysis",
"zoom_to_map"
]);
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("execute_script"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
});
@@ -62,7 +63,7 @@ function createRequest(name: string) {
toolCallId: "call-1",
sessionId: "session-1",
name,
params: { items: [{ sensor_id: "MP01", level: "high" }] },
params: { items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(),
expiresAt: Date.now() + 15_000
};
+2 -1
View File
@@ -1,4 +1,5 @@
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const;
export { parseFrontendChart } from "./chart";
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useEffect(() => {
if (!sessionId) return;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500;
function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
.finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,
loadMore: async () => {
if (!sessionId || !inbox.data?.next_cursor || loadingMore) return;
setLoadingMore(true);
try {
const page = await client.listAgentMessages(sessionId, { cursor: inbox.data.next_cursor });
await inbox.mutate((current) => current ? { items: [...current.items, ...page.items.filter((item) => !current.items.some((existing) => existing.message_id === item.message_id))], next_cursor: page.next_cursor } : page, { revalidate: false });
} finally { setLoadingMore(false); }
}
};
}
+33 -2
View File
@@ -1,19 +1,37 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type AgentTaskDiscussionContext, type AgentTaskContextReference } from "./task-context";
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type { AgentCommandPanelPresentation } from "./components/agent-command-panel";
export type {
AgentCommandPanelPresentation,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentInputDelivery,
AgentResponseInput,
AgentChatSessionSummary,
AgentTopic,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent
AgentSessionStreamEvent,
AgentRecentActivity,
AgentPromptSuggestion
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
resolveAgentModelSelection,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
@@ -73,3 +91,16 @@ export type {
AgentUiResult
} from "./types";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
export { parseFrontendChart } from "./frontend-action/chart";
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";
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const agentTaskDiscussionContextSchema = z
.object({
report_revision: z.number().int().positive().optional(),
evidence_id: z.string().min(1).max(128).optional(),
gap_id: z.string().min(1).max(128).optional()
})
.strict();
export type AgentTaskDiscussionContext = z.infer<typeof agentTaskDiscussionContextSchema>;
export const agentTaskContextReferenceSchema = z
.object({
task_id: z.string().uuid(),
execution_generation: z.number().int().positive().nullable(),
report_revision: z.number().int().positive().nullable()
})
.strict();
export type AgentTaskContextReference = z.infer<typeof agentTaskContextReferenceSchema>;
+3
View File
@@ -2,10 +2,13 @@ export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
finalAnswer?: { content: string; uiBlockIds: string[] };
progress?: AgentChatProgress[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
taskContext?: import("./task-context").AgentTaskContextReference;
};
export type AgentStreamRenderMessageState = {
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
layer: "pipes",
fallbackText: undefined
});
});
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
layer: "junctions"
});
});
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
type: "locate_features",
featureIds: ["S-1"],
layer: "geo_scada"
layer: "scada"
});
});

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