29 Commits
Author SHA1 Message Date
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
337 changed files with 35450 additions and 3769 deletions
+10 -2
View File
@@ -2,9 +2,17 @@ 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=
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
+1
View File
@@ -5,6 +5,7 @@ dist-server
coverage
playwright-report
test-results
/public/city-model
.env
.env.*
!.env.example
+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
+34 -13
View File
@@ -32,25 +32,42 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `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_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` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
| `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 |
`.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_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
Agent 运行时仍可发送消息,默认“引导当前任务”(steer),也可选择“排队处理”(queue);停止按钮独立保留。尚未处理的消息显示在输入框上方,可以逐条切换发送方式。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
## 常用命令
@@ -105,4 +122,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 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。
+19 -3
View File
@@ -6,9 +6,17 @@ runtime_config=$(jq -cn \
--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}" \
'{
@@ -16,9 +24,17 @@ runtime_config=$(jq -cn \
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
}')
+224
View File
@@ -0,0 +1,224 @@
# 亚克力设计类别与组件使用清单
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
## 总览
当前材质体系分为 3 组,共 13 个语义类:
| 分组 | 数量 | 类别 | 是否使用背景模糊 |
|---|---:|---|---|
| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 |
| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 |
| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 |
只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。
## 1. 外层亚克力与临时玻璃
这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。
### `acrylic-navigation`
- 用途:全局导航栏。
- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。
- 特征:保留底部分隔和下投影,不使用顶部高光。
- 当前组件:
- `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。
### `acrylic-panel`
- 用途:承载持续显示或需要集中阅读的主要浮动面板。
- 模糊:使用面板级模糊,20px 至 24px。
- 当前组件:
- `AgentCommandPanel`,Agent 命令面板外壳。
- `AgentCollapsedRail`,Agent 收起状态侧栏。
- `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。
- `LayerControl`、`ControlPanel`、`Legend`、`BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。
- `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。
- `FeatureInsightPanel`,要素洞察面板。
- `MapDevPanel`,地图开发调试面板。
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。
- 直接或间接涉及文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/map/core/components/map-control-styles.ts`
- `src/features/map/core/components/notice.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/feature-popover.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/map-dev-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control`
- 用途:始终悬浮在地图上的紧凑型独立控件。
- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。
- 当前组件:
- `Toolbar`,地图主工具栏。
- `DrawToolbar`,绘制工具栏。
- `ZoomControl`,缩放控件。
- `ScaleLine`,比例尺。
- `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`src/features/map/core/components/map-control-styles.ts`。
### `glass-transient`
- 用途:短时出现、需要突出当前任务状态的玻璃浮层。
- 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件:
- `AgentTaskTicker` 的活动任务卡片。
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
## 2. 内部实体表面
这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。
### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
- 后续可能增加的对象目录、调度队列等常驻侧栏。
- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`。
### `surface-control`
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
- 当前组件范围:
- Agent 收起侧栏条目、输入操作栏和命令面板快捷入口。
- 图层、量测和通用控制面板中的交互控件。
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
- 任务浮条的非活动卡片。
- 要素洞察面板的内部按钮。
- Header 下拉菜单的内部控制区。
- 通用 `IconButton`。
- 主要文件:
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/map/core/components/control-panel.tsx`
- `src/features/map/core/components/layer-control.tsx`
- `src/features/map/core/components/measure-panel.tsx`
- `src/features/workbench/components/agent-task-ticker.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well`
- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。
- 当前组件:
- `LayerPanel`、`LayerControl` 的未激活条目。
- `MeasurePanel`、`DrawToolbar`、`AnnotationPanel` 的内部区域。
- `ControlPanel`、`Legend`、`BaseLayersControl` 的分组与空白区域。
- Header 下拉菜单的普通菜单项。
- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。
### `surface-reading`
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
- 当前组件范围:
- Agent 消息详情、UI Envelope 内容和命令面板空状态。
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/feature-property-list.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面
语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel`、`surface-control` 或 `surface-reading` 来建立界面层级。
| 类名 | 含义 | 当前使用位置 |
|---|---|---|
| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 |
| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 |
| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 |
| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 |
| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 |
## 未使用样式的建议落点
### `surface-dock`
建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用:
- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。
- `ScheduledConditionFeed` 或 `ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。
- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。
如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`。
### `material-tone-danger`
建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括:
- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。
- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。
- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`。
- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。
不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。
### `material-tone-agent`
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。
## 共享类映射
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
| 共享常量 | 实际材质类 | 典型用途 |
|---|---|---|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 |
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 |
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 |
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 |
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 |
## 自适应与无障碍规则
- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。
- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。
- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。
- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。
- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。
## 使用约束
1. 一个浮动区域只允许最外层使用 `acrylic-*` 或 `glass-transient`。
2. 外层亚克力内部使用 `surface-control`、`surface-well` 或 `surface-reading`,不要叠加第二层模糊。
3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。
4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。
5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。
6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。
## 当前待清理项
- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。
- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。
- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。
- Agent 面板仍有少量局部类直接使用 `--surface-control`、`--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。
+102
View File
@@ -0,0 +1,102 @@
# 本地交互式回答
聊天保留 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 完成/失败接口。
后台调查执行继续走任务报告与任务展示通道,聊天界面块不构成业务完成或浏览器回执。
## 扩展组件
1. 在 `src/features/agent/answer-ui/catalog.ts` 增加严格的 Zod 参数定义、用途描述与必要的数据上限。
2. 在 `answer-ui-view.tsx` 注册 React 组件。需要工作台交互时通过 Agent 公共 API 的回调接入,保持依赖方向。
3. 从本仓库运行 `pnpm export:answer-ui-contract`,更新相邻 Agent 仓库的
`src/chat/answer-ui-catalog.json`。组件目录是唯一参数定义源,生成文件需随两边一起交付。
4. 如新组件存在跨字段约束,同时更新两端的协议校验并加边界和交互测试。
导出脚本接受可选目标文件:
```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、约 100 KB,图表必须对齐且数值有限;表格每行与列数
一致,表单字段名唯一。组件只消费明确的 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
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 需一起更新,Agent 进程需重新加载原生插件。无需新增环境变量、
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 服务和现有会话数据保留。
+96
View File
@@ -0,0 +1,96 @@
# 新增业务组件设计规范
本规范覆盖调查任务、运行控制、人工复核、外围样板、日期时间与共享表单组件。地图仍是主工作区,侧栏负责定位当前任务、显示状态、给出下一步操作和按需展开证据。
## 1. 视觉主题
方向为宝石蓝操作、银灰亚克力、少量香槟重点的浅色专业调度台。界面保持高信息密度,使用稳定的标题、状态、操作、证据顺序。沿用既有字体、布局、圆角和 acrylic 材质,以透底、色调和轻微背景色差区分导航、面板、分组与阅读区。
## 2. 色彩角色
采用同色相色阶与低彩度互补色规则。宝石蓝 `#4263C7` 转换为 OKLCH 后约为 `L=0.529317, C=0.160186, h=267.059148°`;中性材质和文字保持该色相,香槟辅助色使用 `(h + 180°) mod 360°`。色阶先计算后转换为 sRGB HEX,均处于 sRGB 色域。透明度不参与色板生成。
| 角色 | L | C | h | HEX |
|---|---:|---:|---:|---|
| 宝石蓝主色 | 0.529317 | 0.160186 | 267.06° | `#4263C7` |
| 银霜材质 | 0.935 | 0.012 | 267.06° | `#E6E9F2` |
| 冷瓷阅读面 | 0.975 | 0.005 | 267.06° | `#F5F7FA` |
| 香槟辅助色 | 0.820 | 0.065 | 87.06° | `#D6C294` |
| 深墨文字 | 0.280 | 0.025 | 267.06° | `#232935` |
完整色板见 [宝石蓝配色](gem-blue-palette.svg)。
- `--surface-navigation`:宝石蓝 8% 与银霜在 OKLab 空间混合的导航色调。浅色混色使用 OKLab,避免低彩度颜色在色相插值时偏色。
- `--surface-panel`:银霜色调;`--surface-well` 为其半透明次级面。
- `--surface-subtle`:`L=0.950, C=0.010, h=267.06°`,得到 `#EBEEF5`,供桌面 dock、分组和普通控件使用。
- `--surface-reading`:冷瓷正文、表单和报告阅读面,保持较高遮蔽度。
- `--action-selection-soft`:`L=0.920, C=0.035, h=267.06°`,得到 `#DAE5FD`,文字使用深宝石蓝。
- `--action-blue`:主操作 `#4263C7`;hover 将 L 降低 0.055、C 乘 0.95,得到 `#3653B0`;pressed 将 L 降低 0.105、C 乘 0.90,得到 `#2B469C`。
- `--surface-border` / `--surface-divider`:保持同色相,分别使用 `(L=0.815, C=0.015)` 和 `(L=0.885, C=0.012)`,得到银灰边界 `#BEC2CD` / `#D5D9E1`。
- `--action-focus-ring`:深宝石蓝焦点环;`--ink-muted`:`L=0.450, C=0.030, h=267.06°`,得到辅助文字 `#4E5566`。
- `cyan-700`:外围样板入口及其来源标识。
- `emerald`:已完成、已通过。
- `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。
- `rose`:失败、冲突或破坏性操作。
宝石蓝用于工作台操作和选中状态,银灰用于工作台材质层级。Agent 对话区使用独立的低饱和雾蓝灰层次:工作面 `#E8ECF3`、阅读面 `#F8F9FC`、用户消息 `#DDE4F1`,主操作使用岩蓝 `#465F91`。岩蓝与工作台品牌色保持同一视觉语言,同时降低亮度和饱和度,避免输入区显得刺眼。这组颜色按阅读层级和对比度选择,不要求遵循固定的相似色公式,也不扩散到地图和顶部工作台。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
## 3. 排版规则
沿用 Noto Sans SC。侧栏标题使用 14px,业务区标题使用 13px,正文和表单标签使用 12px,辅助信息使用 11px。编号、修订号、时间和数量使用等宽数字。标签左对齐,数值列右对齐。
## 4. 组件规则
- `WorkbenchSection` 统一业务区标题、说明、状态和操作,不在内部再次堆叠同级标题卡。
- `WorkbenchNotice` 使用左侧状态线传达信息、警告与错误,避免大面积色块。
- `WorkbenchEmptyState` 只说明当前为空和下一步,不占用过高空间。
- 表单控件高度为 40px,按钮使用现有 8px 或 12px 圆角体系。
- 主操作每个业务区最多一个。其余操作使用 outline 或 ghost。
## 5. 布局原则
每个业务区按“定位、状态、下一步、证据”排列。侧栏内容使用 12px 至 14px 的紧凑间距。复杂原始对象、请求参数、历史和测试资源按需展开。移动端保持单列,不用横向滚动承载业务字段。
地图背景延伸到顶部导航背后,地图控件和分析正文仍预留 56px 导航高度。独立浮层使用完整亚克力材质,阅读面和控件内部的填色不额外叠加模糊;任务堆叠保留完整圆角和每层 7px 的错位。时间轴容器不足 640px 时省略统计摘要,优先保证日期、图例和操作按钮互不重叠。
## 6. 层级与深度
保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 使用雾蓝灰实体底板,导航、历史和底部操作区保留同色调的局部亚克力;正文和文字输入区使用冷白实体阅读面,岩蓝只用于主操作、焦点和交互反馈。文字输入与权限、模型、语音和发送操作栏分开承担材质,聚焦边框仅围绕文字输入区。对话内容直接进入消息流,不在顶部重复展示分析摘要。每个功能区只保留一层模糊,历史展开内容与导航共用材质层。
桌面普通空会话使用浅雾蓝灰工作面和冷白文字输入区,居中输入组件距面板两侧至少 12px,最大宽度 600px,文字输入区初始高度 160px、字号 16px;多行输入的高度按可用视口限制,保持操作栏和推荐问题可见。模型根据当前用户与项目的近期任务、告警、分析成果和普通会话生成最多三条问题,在输入组件下方独立显示来源与时间,点击直接发送;首次开始对话时,同一个表单以 280ms 位移和尺寸过渡回到底部,随后使用 48px 的紧凑文字区和 40px 操作控件,减少动态效果偏好下则立即切换。移动端保留紧凑输入布局,生成的问题仍使用独立悬浮胶囊。推荐异步加载,不阻塞输入;打开空会话、切换模型或新建空会话时触发,普通重渲染和宽度变化不重新生成,模型失败时不以固定问题冒充生成结果。
| 材质 | 浅色底图 alpha | 影像底图 alpha |
|---|---:|---:|
| 导航 | 0.64 | 0.80 |
| 浮动业务面板 | 0.56 | 0.72 |
| 地图控件 | 0.74 | 0.86 |
| Agent 移动抽屉和历史 | 0.82 | 0.88 |
| Agent 浮动输入区 | 0.86 | 0.90 |
| 正文阅读面 | 0.96 | 0.985 |
分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
## 7. 使用约束
- 必须优先显示当前状态和可执行的下一步。
- 调查计划由 Agent 动态发布。默认呈现本次选中的路线和当前待办,未选路线及旧修订按需展开;条件、并行和审核状态只读取明确的结构化字段。
- 必须明确 synthetic、只读、结果未知和未配置状态。
- 必须保留完整请求号、对象修订和证据来源的可读入口。
- 不用颜色代替文字状态。
- 不在一个业务区内放置多个同等醒目的主按钮。
- 不用大块空卡显示一句空状态。
- 不引入第二套字体、主题或 CSS 方案。
## 8. 响应式与无障碍
支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。
表单字段使用共享 `.form-field` 规则:聚焦时只改变原有 1px 边框的颜色,不叠加外环或浏览器默认轮廓。组合输入由最外层字段容器承担焦点边界,内部输入保持无边框;无边框的亚克力输入区使用 1px 内描边,保持材质与布局不变。无效字段保留错误色,按钮与选择控件保留单独的键盘焦点提示。全局焦点样式仅作为基础层兜底,不能覆盖组件自身规则。
底图选择卡片的选中态使用 1px 主题边框和勾选标记,不附加常驻外环。键盘聚焦使用共享的 1px 内描边;鼠标选中、聚焦与按压均保持卡片尺寸及半透明表面不变。图层面板、独立底图面板与浮动底图控件使用同一规则。
普通文字与实际背景对比度至少 4.5:1,焦点和必要的非文字状态标记至少 3:1。透明区域按合成背景计算。不支持模糊或降低透明度时保留不透明的银霜、冷瓷层级;强制颜色模式使用系统颜色。主题由现有 Tailwind 与 CSS 变量统一提供,不新增切换入口。
## 9. 交互提示
任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。
+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

+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 对调查任务与外围样板的新组件完成统一重构。完整规则见 `docs/component-design-system.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`。
+10 -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",
@@ -45,9 +51,10 @@
"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",
"shiki": "^4.2.0",
@@ -84,7 +91,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": [
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
trace: "on-first-retry"
},
webServer: {
command: `TJWATER_AUTH_MODE=disabled 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
},
+132 -167
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@json-render/core>zod': 3.25.76
'@json-render/react>zod': 3.25.76
importers:
.:
@@ -14,6 +18,12 @@ importers:
'@fontsource-variable/noto-sans-sc':
specifier: 5.3.0
version: 5.3.0
'@json-render/core':
specifier: ^0.21.0
version: 0.21.0(zod@3.25.76)
'@json-render/react':
specifier: ^0.21.0
version: 0.21.0(react@19.2.7)(zod@3.25.76)
'@radix-ui/react-accordion':
specifier: ^1.2.14
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -90,14 +100,17 @@ importers:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
maplibre-gl:
specifier: ^4.7.1
version: 4.7.1
specifier: ^6.4.1
version: 6.15.0
motion:
specifier: ^12.40.0
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
nanoid:
specifier: ^5.1.14
version: 5.1.16
pmtiles:
specifier: 4.3.0
version: 4.3.0
react:
specifier: ^19.2.7
version: 19.2.7
@@ -204,6 +217,9 @@ importers:
vitest:
specifier: ^4.1.9
version: 4.1.10(@types/node@24.13.3)(jsdom@25.0.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0))
zod-to-json-schema:
specifier: ^3.25.2
version: 3.25.2(zod@3.25.76)
packages:
@@ -737,34 +753,45 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
'@json-render/core@0.21.0':
resolution: {integrity: sha512-vysEbTjO1y8mEgAsp0tztRyaWCkNsBrG8MhAqbndGK3qmMXjMRdCNtk21+AQRNKdRelFGVAwBgtUNW8tfjQlsw==}
peerDependencies:
zod: 3.25.76
'@json-render/react@0.21.0':
resolution: {integrity: sha512-z/fZg0L8fJe2dnnQjWkmMw409iBclSWcOqdjoBy8eLHeBr/7F7VipzkY1CfgA2Fa5uW+zxZ54OvWMtdDcsp/yg==}
peerDependencies:
react: ^19.2.3
'@mapbox/jsonlint-lines-primitives@2.0.3':
resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
engines: {node: '>= 22'}
'@mapbox/point-geometry@0.1.0':
resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==}
'@mapbox/point-geometry@1.1.0':
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
'@mapbox/tiny-sdf@2.2.0':
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
'@mapbox/tiny-sdf@2.3.0':
resolution: {integrity: sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==}
'@mapbox/unitbezier@0.0.1':
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
'@mapbox/unitbezier@1.0.0':
resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
'@mapbox/vector-tile@1.3.1':
resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==}
'@mapbox/vector-tile@3.0.0':
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
'@mapbox/whoots-js@3.1.0':
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'}
'@maplibre/geojson-vt@6.3.0':
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
'@maplibre/maplibre-gl-style-spec@20.4.0':
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
'@maplibre/maplibre-gl-style-spec@26.4.4':
resolution: {integrity: sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==}
hasBin: true
'@maplibre/mlt@1.3.0':
resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
'@maplibre/vt-pbf@4.3.2':
resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
@@ -1725,9 +1752,6 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson-vt@3.2.5':
resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1740,12 +1764,6 @@ packages:
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/mapbox__point-geometry@0.1.4':
resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==}
'@types/mapbox__vector-tile@1.3.4':
resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -1755,9 +1773,6 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1772,9 +1787,6 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/supercluster@7.1.3':
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2019,6 +2031,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.1.0:
resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
@@ -2353,8 +2368,8 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
earcut@3.2.3:
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
earcut@3.2.4:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
@@ -2456,6 +2471,7 @@ packages:
eslint@9.39.5:
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -2531,6 +2547,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -2581,9 +2600,6 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
geojson-vt@4.0.3:
resolution: {integrity: sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -2604,10 +2620,6 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
gl-matrix@3.4.4:
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
@@ -2615,10 +2627,6 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
global-prefix@4.0.0:
resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==}
engines: {node: '>=16'}
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -2724,9 +2732,6 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2746,10 +2751,6 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
ini@4.1.3:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -2797,10 +2798,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
@@ -2871,10 +2868,6 @@ packages:
khroma@2.1.0:
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
layout-base@1.0.2:
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
@@ -2990,8 +2983,8 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
maplibre-gl@4.7.1:
resolution: {integrity: sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==}
maplibre-gl@6.15.0:
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
markdown-table@3.0.4:
@@ -3329,8 +3322,8 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pbf@3.3.0:
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
pbf@5.1.2:
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
hasBin: true
picocolors@1.1.1:
@@ -3354,6 +3347,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
pmtiles@4.3.0:
resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -3390,9 +3386,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
@@ -3505,6 +3498,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3640,9 +3637,6 @@ packages:
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
supercluster@8.0.1:
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -3916,9 +3910,6 @@ packages:
jsdom:
optional: true
vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -3948,11 +3939,6 @@ packages:
engines: {node: '>= 8'}
hasBin: true
which@4.0.0:
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -4004,6 +3990,11 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: ^3.25.28 || ^4
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -4542,35 +4533,54 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mapbox/geojson-rewind@0.5.2':
'@json-render/core@0.21.0(zod@3.25.76)':
dependencies:
get-stream: 6.0.1
minimist: 1.2.8
zod: 3.25.76
'@json-render/react@0.21.0(react@19.2.7)(zod@3.25.76)':
dependencies:
'@json-render/core': 0.21.0(zod@3.25.76)
react: 19.2.7
transitivePeerDependencies:
- zod
'@mapbox/jsonlint-lines-primitives@2.0.3': {}
'@mapbox/point-geometry@0.1.0': {}
'@mapbox/point-geometry@1.1.0': {}
'@mapbox/tiny-sdf@2.2.0': {}
'@mapbox/tiny-sdf@2.3.0': {}
'@mapbox/unitbezier@0.0.1': {}
'@mapbox/unitbezier@1.0.0': {}
'@mapbox/vector-tile@1.3.1':
'@mapbox/vector-tile@3.0.0':
dependencies:
'@mapbox/point-geometry': 0.1.0
'@mapbox/point-geometry': 1.1.0
'@types/geojson': 7946.0.16
pbf: 5.1.2
'@mapbox/whoots-js@3.1.0': {}
'@maplibre/geojson-vt@6.3.0':
dependencies:
kdbush: 4.1.0
'@maplibre/maplibre-gl-style-spec@20.4.0':
'@maplibre/maplibre-gl-style-spec@26.4.4':
dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.3
'@mapbox/unitbezier': 0.0.1
'@mapbox/unitbezier': 1.0.0
json-stringify-pretty-compact: 4.0.0
minimist: 1.2.8
quickselect: 2.0.0
rw: 1.3.3
quickselect: 3.0.0
tinyqueue: 3.0.0
'@maplibre/mlt@1.3.0':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@maplibre/vt-pbf@4.3.2':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@types/geojson': 7946.0.16
pbf: 5.1.2
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -5487,10 +5497,6 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson-vt@3.2.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.5':
@@ -5501,14 +5507,6 @@ snapshots:
'@types/katex@0.16.8': {}
'@types/mapbox__point-geometry@0.1.4': {}
'@types/mapbox__vector-tile@1.3.4':
dependencies:
'@types/geojson': 7946.0.16
'@types/mapbox__point-geometry': 0.1.4
'@types/pbf': 3.0.5
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -5519,8 +5517,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pbf@3.0.5': {}
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -5535,10 +5531,6 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/supercluster@7.1.3':
dependencies:
'@types/geojson': 7946.0.16
'@types/trusted-types@2.0.7':
optional: true
@@ -5719,6 +5711,10 @@ snapshots:
baseline-browser-mapping@2.10.43: {}
bidi-js@1.1.0:
dependencies:
require-from-string: 2.0.2
brace-expansion@1.1.16:
dependencies:
balanced-match: 1.0.2
@@ -6064,7 +6060,7 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
earcut@3.2.3: {}
earcut@3.2.4: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.7):
dependencies:
@@ -6271,6 +6267,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.8.3: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -6314,8 +6312,6 @@ snapshots:
gensync@1.0.0-beta.2: {}
geojson-vt@4.0.3: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
@@ -6340,20 +6336,12 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
get-stream@6.0.1: {}
gl-matrix@3.4.4: {}
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
global-prefix@4.0.0:
dependencies:
ini: 4.1.3
kind-of: 6.0.3
which: 4.0.0
globals@14.0.0: {}
globals@15.15.0: {}
@@ -6535,8 +6523,6 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
import-fresh@3.3.1:
@@ -6550,8 +6536,6 @@ snapshots:
indent-string@4.0.0: {}
ini@4.1.3: {}
inline-style-parser@0.2.7: {}
internmap@1.0.1: {}
@@ -6585,8 +6569,6 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.5: {}
isomorphic-ws@5.0.0(ws@8.21.1):
dependencies:
ws: 8.21.1
@@ -6659,8 +6641,6 @@ snapshots:
khroma@2.1.0: {}
kind-of@6.0.3: {}
layout-base@1.0.2: {}
layout-base@2.0.1: {}
@@ -6745,34 +6725,26 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
maplibre-gl@4.7.1:
maplibre-gl@6.15.0:
dependencies:
'@mapbox/geojson-rewind': 0.5.2
'@mapbox/jsonlint-lines-primitives': 2.0.3
'@mapbox/point-geometry': 0.1.0
'@mapbox/tiny-sdf': 2.2.0
'@mapbox/unitbezier': 0.0.1
'@mapbox/vector-tile': 1.3.1
'@mapbox/whoots-js': 3.1.0
'@maplibre/maplibre-gl-style-spec': 20.4.0
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.3.0
'@mapbox/unitbezier': 1.0.0
'@mapbox/vector-tile': 3.0.0
'@maplibre/geojson-vt': 6.3.0
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.2
'@types/geojson': 7946.0.16
'@types/geojson-vt': 3.2.5
'@types/mapbox__point-geometry': 0.1.4
'@types/mapbox__vector-tile': 1.3.4
'@types/pbf': 3.0.5
'@types/supercluster': 7.1.3
earcut: 3.2.3
geojson-vt: 4.0.3
bidi-js: 1.1.0
earcut: 3.2.4
gl-matrix: 3.4.4
global-prefix: 4.0.0
kdbush: 4.1.0
murmurhash-js: 1.0.0
pbf: 3.3.0
pbf: 5.1.2
potpack: 2.1.0
quickselect: 3.0.0
supercluster: 8.0.1
tinyqueue: 3.0.0
vt-pbf: 3.1.3
markdown-table@3.0.4: {}
@@ -7354,9 +7326,8 @@ snapshots:
pathe@2.0.3: {}
pbf@3.3.0:
pbf@5.1.2:
dependencies:
ieee754: 1.2.1
resolve-protobuf-schema: 2.1.0
picocolors@1.1.1: {}
@@ -7373,6 +7344,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pmtiles@4.3.0:
dependencies:
fflate: 0.8.3
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -7404,8 +7379,6 @@ snapshots:
punycode@2.3.1: {}
quickselect@2.0.0: {}
quickselect@3.0.0: {}
react-dom@19.2.7(react@19.2.7):
@@ -7554,6 +7527,8 @@ snapshots:
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0:
@@ -7727,10 +7702,6 @@ snapshots:
stylis@4.4.0: {}
supercluster@8.0.1:
dependencies:
kdbush: 4.1.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -7974,12 +7945,6 @@ snapshots:
transitivePeerDependencies:
- msw
vt-pbf@3.1.3:
dependencies:
'@mapbox/point-geometry': 0.1.0
'@mapbox/vector-tile': 1.3.1
pbf: 3.3.0
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
@@ -8003,10 +7968,6 @@ snapshots:
dependencies:
isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -8044,6 +8005,10 @@ snapshots:
yocto-queue@0.1.0: {}
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
zod@3.25.76: {}
zrender@6.1.0:
+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: 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

+6 -1
View File
@@ -1,5 +1,10 @@
globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
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"
};
+22
View File
@@ -0,0 +1,22 @@
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 target = resolve(process.argv[2] ?? "../next-tjwater-agent/src/chat/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" })
])
)
};
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
+29 -14
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();
@@ -85,6 +100,7 @@ test("preserves compact typography in header and agent controls", async ({ page
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();
@@ -112,18 +128,17 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const navigationMaterial = await navigation.evaluate(
(element) => {
const style = window.getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor
};
}
);
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("rgb(237, 241, 245)");
expect(navigationMaterial.backgroundColor).toBe(await readThemeColor(navigation, "--agent-dock-surface"));
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
});
@@ -0,0 +1,46 @@
import { StreamingTokenResponse } from "../components/streaming-token-response";
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
import type { AnswerUiPart } from "./contract";
export function AnswerPartsView({
parts,
messageId,
disabled,
streaming,
streamDone,
onLocate,
onSubmit
}: {
parts: AnswerUiPart[];
messageId: string;
disabled: boolean;
streaming: boolean;
streamDone: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | 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}
/>
) : (
<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,91 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AnswerUiView } from "./answer-ui-view";
import { parseAnswerUiBlock } from "./contract";
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("renders through json-render and lets readers inspect exact chart data", () => {
render(
<AnswerUiView
block={block("Chart", {
title: "演练压力",
chart_type: "line",
source: "演练数据",
x_data: ["08:00", "09:00"],
series: [{ name: "压力", data: [0.3, 0.2] }]
})}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.3");
expect(screen.getByRole("table")).toHaveTextContent("09:00");
expect(screen.getByRole("button", { name: /^数据$/ })).toHaveAttribute("aria-pressed", "true");
});
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("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,379 @@
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
import { createContext, useContext, useId, useState } from "react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { cn } from "@/shared/ui/cn";
import { AgentChartView } from "../components/agent-ui-envelope-renderer";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
export type AnswerUiMapTarget = {
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
feature_ids: string[];
};
type Interactions = {
disabled: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
};
const InteractionContext = createContext<Interactions>({ disabled: true });
const { registry } = defineRegistry(answerUiCatalog, {
actions: {},
components: {
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="text-sm font-semibold text-[var(--ink)]">{props.title}</h3>
{props.description ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{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 }) => (
<div className="min-w-0 py-2">
<p className="text-xs text-[var(--ink-muted)]">{props.label}</p>
<p className="mt-1 break-words text-xl font-semibold tabular-nums text-[var(--ink)]">
{props.value}
{props.unit ? <span className="ml-1 text-xs font-normal">{props.unit}</span> : null}
</p>
{props.source ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">来源:{props.source}</p>
) : null}
</div>
),
Table: ({ props }) => (
<DataTable title={props.title} columns={props.columns} rows={props.rows} />
),
Chart: ({ props }) => <AnswerChart {...props} />,
Evidence: ({ props }) => (
<Disclosure label={props.title} className="answer-ui-evidence">
<div className="space-y-2 py-2 text-xs leading-5">
<p className="text-[var(--ink-muted)]">
{
(
{
observed: "查询记录",
human_input: "用户提供",
inference: "分析推断",
demo: "演练数据"
} as const
)[props.kind]
}{" "}
· {props.source}
</p>
<p className="whitespace-pre-wrap">{props.summary}</p>
{props.observed_at ? <p>记录时间:{props.observed_at}</p> : null}
{props.evidence_id ? <p>资料编号:{props.evidence_id}</p> : null}
</div>
</Disclosure>
),
MapLocation: ({ props }) => <LocateButton {...props} />,
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
}
});
export function AnswerUiView({
block,
disabled = false,
onLocate,
onSubmit
}: { block: AnswerUiBlock } & Partial<Interactions>) {
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit }}>
<div className="answer-ui-root" data-answer-ui={block.block_id}>
<JSONUIProvider registry={registry}>
<Renderer spec={block.spec} registry={registry} />
</JSONUIProvider>
</div>
</InteractionContext.Provider>
);
}
function DataTable({
title,
columns,
rows
}: {
title: string;
columns: string[];
rows: string[][];
}) {
return (
<div className="min-w-0">
<div
role="region"
aria-label={title}
tabIndex={0}
className="overflow-x-auto rounded-lg border border-[var(--surface-divider)]"
>
<table className="w-full border-collapse text-left text-xs">
<caption className="px-3 py-2 text-left font-medium text-[var(--ink)]">{title}</caption>
<thead className="bg-[var(--surface-subtle)]">
<tr>
{columns.map((column, index) => (
<th key={index} className="whitespace-nowrap px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-t border-[var(--surface-divider)]">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="min-w-24 px-3 py-2 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
type ChartProps = {
title: string;
chart_type: "line" | "bar";
x_data: string[];
series: Array<{ name: string; data: number[] }>;
source: string;
};
function AnswerChart(props: ChartProps) {
const [mode, setMode] = useState<"line" | "bar" | "table">(props.chart_type);
return (
<div className="min-w-0">
<div role="group" aria-label={`${props.title}显示方式`} className="mb-2 flex flex-wrap gap-1">
{(
[
["line", "折线"],
["bar", "柱状"],
["table", "数据"]
] as const
).map(([value, label]) => (
<Button
key={value}
size="sm"
variant="ghost"
aria-pressed={mode === value}
onClick={() => setMode(value)}
>
{label}
</Button>
))}
</div>
{mode === "table" ? (
<DataTable
title={props.title}
columns={["时间或类别", ...props.series.map((series) => series.name)]}
rows={props.x_data.map((label, index) => [
label,
...props.series.map((series) => String(series.data[index]))
])}
/>
) : (
<AgentChartView chart={{ ...props, chart_type: mode }} />
)}
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-[var(--ink-muted)]">
来源:{props.source}
</p>
</div>
);
}
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>
<Button
size="sm"
variant="outline"
disabled={disabled || running || !onLocate}
onClick={async () => {
setRunning(true);
setFeedback(null);
try {
const result = await onLocate?.(target);
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
setFeedback(
output?.missing_ids?.length
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
: "已定位到地图"
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "地图定位失败");
} finally {
setRunning(false);
}
}}
>
{running ? "正在定位…" : label}
</Button>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
{feedback ? (
<p role="status" className="mt-1 text-xs leading-5">
{feedback}
</p>
) : null}
</div>
);
}
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 } = 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("资料已发送,请查看 Agent 回复。");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
} finally {
setSubmitting(false);
}
}}
>
<div>
<h4 className="text-sm font-semibold">{title}</h4>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
</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
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 ? "正在发送…" : "发送补充资料"}
</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();
});
});
+63
View File
@@ -0,0 +1,63 @@
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();
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,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>;
+72 -5
View File
@@ -1,7 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
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" }));
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" });
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 +80,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
},
{
id: "old",
@@ -43,7 +93,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
}
]);
});
@@ -79,10 +132,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 +147,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({
@@ -122,6 +175,20 @@ describe("Agent API client sessions", () => {
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",
+179 -25
View File
@@ -1,7 +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";
@@ -17,6 +38,9 @@ export type AgentModelsResponse = {
models: AgentModelOption[];
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentChatSession = {
id?: string;
session_id: string;
@@ -29,9 +53,15 @@ 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;
createdAt: number;
@@ -40,6 +70,23 @@ export type AgentChatSessionSummary = {
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
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 AgentLoadedChatSession = AgentChatSessionSummary & {
@@ -49,6 +96,8 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
};
export type AgentSessionStreamDataEventType =
| "execution_plan"
| "final_answer"
| "token"
| "progress"
| "todo_update"
@@ -59,15 +108,22 @@ 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[];
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -80,8 +136,15 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
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>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
@@ -110,16 +173,18 @@ 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>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
@@ -137,7 +202,8 @@ export function createAgentApiClient(
setActiveBaseUrl,
path,
init,
options.getAccessToken
options.getAccessToken,
options.getProjectId
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(path, init);
@@ -152,19 +218,69 @@ export function createAgentApiClient(
};
return {
async createSession() {
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 generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
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({})
body: JSON.stringify({ trigger_context: options?.triggerContext })
});
},
async listSessions() {
const payload = await requestJson<{ sessions?: unknown[] }>("/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 createTaskCollaborationSession(taskId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
{ method: "POST" }
);
},
async getFrontendActionRegistry() {
return requestJson<unknown>("/frontend-action-registry");
},
@@ -207,6 +323,22 @@ export function createAgentApiClient(
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) {
@@ -282,6 +414,13 @@ export function createAgentApiClient(
});
},
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 requestJson<unknown>("/abort", {
method: "POST",
@@ -298,22 +437,23 @@ async function fetchWithFallback(
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider
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 requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
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}`, requestInit);
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -337,12 +477,6 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
@@ -413,6 +547,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;
@@ -431,7 +569,14 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
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
};
}
@@ -511,6 +656,7 @@ 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,
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
@@ -532,9 +678,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" ||
@@ -542,11 +690,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"
);
}
@@ -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: { 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,108 @@
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;
}) {
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: { 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;
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");
}
@@ -20,7 +20,7 @@ export function AgentCollapsedRail({
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter surface-dock pointer-events-auto w-[72px] rounded-2xl border p-1.5 shadow-[0_8px_24px_rgba(15,23,42,0.1)]"
"agent-rail-enter acrylic-control pointer-events-auto w-[72px] rounded-2xl border p-1.5"
)}
>
<button
@@ -28,7 +28,7 @@ export function AgentCollapsedRail({
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="agent-rail-item group relative flex h-14 w-full items-center justify-center rounded-xl border border-transparent bg-transparent hover:bg-[var(--action-selection-soft)]"
>
<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">
File diff suppressed because it is too large Load Diff
@@ -75,7 +75,7 @@ export function AgentHistoryPanel({
<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">
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
</div>
@@ -103,7 +103,7 @@ export function AgentHistoryPanel({
{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"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
@@ -128,7 +128,7 @@ export function AgentHistoryPanel({
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"
? "agent-history-item-active border border-[var(--surface-border)]"
: "border border-transparent"
)}
initial="initial"
@@ -153,7 +153,7 @@ export function AgentHistoryPanel({
>
删除“{session.title}”?
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
删除后无法从历史记录中恢复
</span>
</motion.div>
@@ -172,7 +172,7 @@ export function AgentHistoryPanel({
>
<Input
autoFocus
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
@@ -201,7 +201,7 @@ export function AgentHistoryPanel({
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
{formatSessionUpdatedAt(session.updatedAt)}
</span>
</motion.button>
@@ -214,7 +214,7 @@ export function AgentHistoryPanel({
{itemLoading || saving || deleting ? (
<Loader2
size={13}
className="animate-spin text-blue-600"
className="animate-spin text-[var(--action-blue-hover)]"
aria-hidden="true"
/>
) : null}
@@ -289,7 +289,7 @@ export function AgentHistoryPanel({
title="删除对话"
size="icon-sm"
variant="ghost"
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
@@ -307,7 +307,7 @@ export function AgentHistoryPanel({
) : (
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
@@ -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,37 @@
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>
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
{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}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -44,7 +44,7 @@ function useElementVisibility() {
function PersonaFallback() {
return (
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
<span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
</span>
);
@@ -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,9 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
);
}
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
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 ?? "图表数据未通过前端安全子集校验。"} />;
@@ -85,7 +90,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
<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>
@@ -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) {
+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); }
}
};
}
+23 -1
View File
@@ -1,23 +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,
AgentConversationProjectOption,
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,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent
AgentSessionStreamEvent,
AgentRecentActivity,
AgentPromptSuggestion
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
resolveAgentModelSelection,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
@@ -77,3 +91,11 @@ export type {
AgentUiResult
} from "./types";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
export { parseFrontendChart } from "./frontend-action/chart";
export { parseAnswerUiBlock } from "./answer-ui/contract";
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
+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"
});
});
+9 -18
View File
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_scada: "geo_scada",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
locate_junctions: "junctions",
locate_pipes: "pipes",
locate_valves: "valves",
locate_reservoirs: "reservoirs",
locate_scada: "scada",
locate_pumps: "pumps",
locate_tanks: "tanks"
};
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS,
"pumps",
"tanks",
"geo_junctions_mat",
"geo_pipes_mat",
"geo_valves",
"geo_reservoirs",
"geo_scada",
"geo_pumps",
"geo_tanks"
...SUPPLY_SOURCE_IDS
]);
const WEB_MERCATOR_RADIUS = 6378137;
@@ -57,7 +57,7 @@ export function MapAnnotationPanel({
/>
))
) : (
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
<div className={cn("px-3 py-2 text-xs text-[var(--ink-muted)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
{emptyText}
</div>
)}
@@ -8,6 +8,7 @@ import {
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
@@ -85,12 +86,12 @@ export function BaseLayersControl({
)}
>
<div className="flex items-center gap-2 px-1">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Map size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">
<p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">
当前 {activeLayer?.label ?? "--"}
</p>
</div>
@@ -110,10 +111,11 @@ export function BaseLayersControl({
className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
@@ -163,8 +165,9 @@ export function BaseLayersControl({
className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
@@ -184,11 +187,11 @@ export function BaseLayersControl({
type="button"
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)}
>
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? (
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-[var(--surface-control)]")} />
) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
@@ -213,11 +216,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100"
option.swatchClassName ?? "bg-[var(--surface-control)]"
)}
>
{active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs">
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white shadow-xs">
<Check size={10} aria-hidden="true" />
</span>
) : null}
@@ -0,0 +1,69 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { getCombinedCameraAction, setThreeDimensionalNavigation } from "./camera-actions";
describe("combined camera mode action", () => {
it.each([
{ bearing: 0, pitch: 0, expected: "enter-3d" },
{ bearing: 0.4, pitch: 50, expected: "exit-3d" },
{ bearing: 0.6, pitch: 50, expected: "reset-north" },
{ bearing: -35, pitch: 45, expected: "reset-north" }
] as const)(
"resolves $expected for bearing=$bearing pitch=$pitch",
({ bearing, pitch, expected }) => {
expect(getCombinedCameraAction(bearing, pitch)).toBe(expected);
}
);
it("enables and disables only the 3D navigation gestures", () => {
const canvas = document.createElement("canvas");
const handlers = {
dragRotate: { enable: vi.fn(), disable: vi.fn() },
getCanvas: () => canvas,
touchPitch: { enable: vi.fn(), disable: vi.fn() },
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
};
const map = handlers as unknown as MapLibreMap;
setThreeDimensionalNavigation(map, true);
expect(handlers.dragRotate.enable).toHaveBeenCalledOnce();
expect(handlers.touchPitch.enable).toHaveBeenCalledOnce();
expect(handlers.touchZoomRotate.enableRotation).toHaveBeenCalledOnce();
setThreeDimensionalNavigation(map, false);
expect(handlers.dragRotate.disable).toHaveBeenCalledOnce();
expect(handlers.touchPitch.disable).toHaveBeenCalledOnce();
expect(handlers.touchZoomRotate.disableRotation).toHaveBeenCalledOnce();
});
it("overrides the browser context menu while 3D navigation is enabled", () => {
const canvas = document.createElement("canvas");
const onMapMouseDown = vi.fn();
canvas.addEventListener("mousedown", onMapMouseDown);
const map = {
dragRotate: { enable: vi.fn(), disable: vi.fn() },
getCanvas: () => canvas,
touchPitch: { enable: vi.fn(), disable: vi.fn() },
touchZoomRotate: { enableRotation: vi.fn(), disableRotation: vi.fn() }
} as unknown as MapLibreMap;
setThreeDimensionalNavigation(map, true);
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 2 }));
expect(onMapMouseDown).toHaveBeenCalledOnce();
expect(
canvas.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
)
).toBe(false);
canvas.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ctrlKey: true }));
expect(onMapMouseDown).toHaveBeenCalledTimes(2);
setThreeDimensionalNavigation(map, false);
expect(
canvas.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 })
)
).toBe(true);
});
});
@@ -0,0 +1,42 @@
import type { Map as MapLibreMap } from "maplibre-gl";
export type CombinedCameraAction = "enter-3d" | "reset-north" | "exit-3d";
export const CAMERA_MODE_ANGLE_TOLERANCE = 0.5;
const browserContextMenuOverrides = new WeakMap<MapLibreMap, () => void>();
export function getCombinedCameraAction(bearing: number, pitch: number): CombinedCameraAction {
if (pitch <= CAMERA_MODE_ANGLE_TOLERANCE) return "enter-3d";
if (Math.abs(bearing) > CAMERA_MODE_ANGLE_TOLERANCE) return "reset-north";
return "exit-3d";
}
export function setThreeDimensionalNavigation(map: MapLibreMap, enabled: boolean) {
if (enabled) {
overrideBrowserContextMenu(map);
map.dragRotate.enable();
map.touchPitch.enable();
map.touchZoomRotate.enableRotation();
return;
}
browserContextMenuOverrides.get(map)?.();
map.dragRotate.disable();
map.touchPitch.disable();
map.touchZoomRotate.disableRotation();
}
function overrideBrowserContextMenu(map: MapLibreMap) {
if (browserContextMenuOverrides.has(map)) return;
const canvas = map.getCanvas();
const preventBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
canvas.addEventListener("contextmenu", preventBrowserContextMenu, true);
browserContextMenuOverrides.set(map, () => {
canvas.removeEventListener("contextmenu", preventBrowserContextMenu, true);
browserContextMenuOverrides.delete(map);
});
}
@@ -16,6 +16,7 @@ export type MapControlPanelProps = {
description?: string;
icon: LucideIcon;
widthClassName?: string;
contentClassName?: string;
visible?: boolean;
children: ReactNode;
};
@@ -25,6 +26,7 @@ export function MapControlPanel({
description,
icon: Icon,
widthClassName = "w-[292px]",
contentClassName,
visible = true,
children
}: MapControlPanelProps) {
@@ -43,15 +45,15 @@ export function MapControlPanel({
)}
>
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Icon size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
{description ? <p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">{description}</p> : null}
</div>
</div>
<div className="relative space-y-3">{children}</div>
<div className={cn("relative space-y-3", contentClassName)}>{children}</div>
</section>
);
}
@@ -59,7 +61,7 @@ export function MapControlPanel({
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)]">{title}</div>
<div className="space-y-1.5">{children}</div>
</section>
);
@@ -83,7 +85,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-blue-600 text-white" : "surface-control text-slate-600 hover:text-blue-700",
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
)}
>
@@ -110,7 +112,7 @@ export function MapActionChip({
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
@@ -161,17 +163,17 @@ export function MapActionRow({
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]"
: strong
? "border-blue-100 bg-blue-50/95 text-blue-800"
: "surface-control border-transparent text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
: "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
muted && "opacity-70",
disabled &&
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
disabled && "shadow-none"
)}
>
@@ -180,10 +182,10 @@ export function MapActionRow({
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
isPrimary
? "bg-white/16 text-white"
? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong
? "bg-blue-600 text-white"
: "bg-slate-100 text-slate-500",
? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600"
)}
>
@@ -191,19 +193,19 @@ export function MapActionRow({
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{label}</span>
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
<span className={cn("block truncate text-xs", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")}>{description}</span>
</span>
{status ? (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
isPrimary ? "bg-[var(--surface-reading)]/15 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
)}
>
{status}
</span>
) : (
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")} />
)}
</button>
);
@@ -212,7 +214,7 @@ export function MapActionRow({
export function MapMetricTile({ label, value }: { label: string; value: string }) {
return (
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="text-xs text-slate-500">{label}</div>
<div className="text-xs text-[var(--ink-muted)]">{label}</div>
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
</div>
);
@@ -53,19 +53,19 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700",
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
)}
>
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600"
)}
>
@@ -108,8 +108,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-blue-50 hover:text-blue-700",
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)}
@@ -120,7 +120,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipContent
side="top"
sideOffset={7}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{item.label}
</TooltipContent>
@@ -11,9 +11,26 @@ export type MapLayerControlItem = {
id: string;
label: string;
description?: string;
featureCount?: number;
minZoom?: number;
visible: boolean;
disabled?: boolean;
locked?: boolean;
group?: string;
groupLabel?: string;
groupDescription?: string;
groupDefaultOpen?: boolean;
collection?: string;
collectionLabel?: string;
visual?: MapLayerControlVisual;
};
export type MapLayerControlVisual = {
kind: "line" | "line-stack" | "point" | "ring" | "area" | "image";
color?: string;
colors?: string[];
dashed?: boolean;
imageSrc?: string;
};
type MapLayerControlProps = {
@@ -41,12 +58,12 @@ export function MapLayerControl({
>
<div className="flex items-center justify-between gap-3 px-1">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Layers3 size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 text-xs text-slate-500">
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{items.filter((item) => item.visible).length}/{items.length} 可见
</p>
</div>
@@ -69,8 +86,8 @@ export function MapLayerControl({
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "surface-control border-blue-100 text-slate-900"
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
? "surface-control border-[var(--surface-border)] text-slate-900"
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
@@ -78,19 +95,19 @@ export function MapLayerControl({
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
{item.description ? <span className="block truncate text-xs text-[var(--ink-muted)]">{item.description}</span> : null}
</span>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
item.visible ? "bg-blue-600" : "bg-slate-300"
item.visible ? "bg-[var(--action-blue)]" : "bg-slate-300"
)}
aria-hidden="true"
/>
+245 -48
View File
@@ -1,13 +1,15 @@
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
import { Check, ChevronRight, Eye, EyeOff, Lock, Map as MapIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem } from "./layer-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
} from "./map-control-styles";
export type MapLayerPanelProps = {
@@ -43,50 +45,244 @@ type LayerVisibilitySectionProps = {
};
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
return (
<div>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
<div className="grid grid-cols-3 gap-2">
{items.map((item) => {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
const groups = groupLayerItems(items);
const [openGroupIds, setOpenGroupIds] = useState(
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
);
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-pressed={item.visible}
aria-label={`${item.label}${item.description ? `,${item.description}` : ""}`}
title={item.description}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center transition-colors",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
<span className="block w-full truncate text-xs font-semibold leading-tight">{item.label}</span>
</button>
);
})}
</div>
const handleGroupToggle = (groupId: string, open: boolean) => {
setOpenGroupIds((current) => {
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
return next;
});
};
return (
<div className="space-y-2">
{groups.map((group) => (
<details
key={group.id}
open={openGroupIds.has(group.id)}
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border"
>
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]">
<ChevronRight
size={14}
aria-hidden="true"
className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description}
</span>
) : null}
</span>
<span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
{group.items.filter((item) => item.visible).length}/{group.items.length}
</span>
</summary>
<div className="border-t border-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45 p-1.5">
{groupLayerItemsByCollection(group.items).map((collection) => (
<div key={collection.id} className="not-first:mt-2.5">
{collection.label ? (
<div className="mb-1 flex items-center gap-2 px-1.5 text-[10px] font-medium text-[var(--ink-muted)]">
<span className="truncate">{collection.label}</span>
<span className="h-px min-w-3 flex-1 bg-[var(--surface-divider)]/80" aria-hidden="true" />
<span className="shrink-0 tabular-nums">{collection.items.length} 层</span>
</div>
) : null}
<div className="space-y-1">
{collection.items.map((item) => (
<LayerVisibilityRow
key={item.id}
item={item}
onToggleLayer={onToggleLayer}
/>
))}
</div>
</div>
))}
</div>
</details>
))}
</div>
);
}
function groupLayerItems(items: MapLayerControlItem[]) {
const groups = new Map<string, {
id: string;
label: string;
description?: string;
defaultOpen: boolean;
items: MapLayerControlItem[];
}>();
items.forEach((item) => {
const id = item.group ?? "business";
const existing = groups.get(id);
if (existing) {
existing.items.push(item);
return;
}
groups.set(id, {
id,
label: item.groupLabel ?? "业务图层",
description: item.groupDescription,
defaultOpen: item.groupDefaultOpen ?? true,
items: [item]
});
});
return [...groups.values()];
}
function groupLayerItemsByCollection(items: MapLayerControlItem[]) {
const collections = new Map<string, {
id: string;
label?: string;
items: MapLayerControlItem[];
}>();
items.forEach((item) => {
const id = item.collection ?? "default";
const existing = collections.get(id);
if (existing) {
existing.items.push(item);
return;
}
collections.set(id, {
id,
label: item.collectionLabel,
items: [item]
});
});
return [...collections.values()];
}
function LayerVisibilityRow({
item,
onToggleLayer
}: {
item: MapLayerControlItem;
onToggleLayer: (id: string, visible: boolean) => void;
}) {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
const meta = formatLayerMeta(item);
return (
<button
type="button"
disabled={disabled}
aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75",
disabled && "cursor-not-allowed opacity-55 active:scale-100"
)}
>
<LayerVisual visual={item.visual} visible={item.visible} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-tight">{item.label}</span>
{meta ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{meta}
</span>
) : null}
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
</button>
);
}
function formatLayerMeta(item: MapLayerControlItem) {
const details = [item.description];
if (item.featureCount !== undefined) details.push(formatFeatureCount(item.featureCount));
if (item.minZoom !== undefined) details.push(`${item.minZoom} 级起`);
return details.filter(Boolean).join(" · ");
}
function formatFeatureCount(count: number) {
return `${new Intl.NumberFormat("zh-CN", {
notation: count >= 10_000 ? "compact" : "standard",
maximumFractionDigits: 1
}).format(count)} 要素`;
}
function LayerVisual({
visual,
visible
}: {
visual?: MapLayerControlVisual;
visible: boolean;
}) {
const color = visual?.color ?? "#64748B";
return (
<span
aria-hidden="true"
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5 transition-opacity duration-150 motion-reduce:transition-none",
!visible && "opacity-45"
)}
>
{visual?.kind === "image" && visual.imageSrc ? (
<img src={visual.imageSrc} alt="" width={24} height={24} />
) : visual?.kind === "line-stack" ? (
<svg width="26" height="22" viewBox="0 0 26 22">
{(visual.colors?.length ? visual.colors : [color, color, color]).slice(0, 3).map((lineColor, index) => (
<g key={`${lineColor}-${index}`}>
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke="white" strokeWidth={4 + index} strokeLinecap="round" />
<line x1="2" y1={5 + index * 6} x2="24" y2={5 + index * 6} stroke={lineColor} strokeWidth={1.5 + index * 0.7} strokeLinecap="round" />
</g>
))}
</svg>
) : visual?.kind === "line" ? (
<svg width="26" height="12" viewBox="0 0 26 12">
<line x1="2" y1="6" x2="24" y2="6" stroke="white" strokeWidth="6" strokeLinecap="round" />
<line
x1="2"
y1="6"
x2="24"
y2="6"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={visual.dashed ? "5 3" : undefined}
/>
</svg>
) : visual?.kind === "ring" ? (
<span className="h-3.5 w-3.5 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />
) : visual?.kind === "area" ? (
<span
className="h-4 w-5 rounded-[4px] border"
style={{ backgroundColor: `${color}24`, borderColor: color }}
/>
) : (
<span
className="h-3.5 w-3.5 rounded-full"
style={{ backgroundColor: color, boxShadow: `0 0 0 2px white, 0 0 0 3px ${color}` }}
/>
)}
</span>
);
}
type BaseLayerSectionProps = {
options: BaseLayerOption[];
activeLayerId: string;
@@ -97,8 +293,8 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
return (
<div>
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-slate-500">底图</span>
<Map size={14} aria-hidden="true" className="text-slate-400" />
<span className="text-xs font-semibold text-[var(--ink-muted)]">底图</span>
<MapIcon size={14} aria-hidden="true" className="text-[var(--ink-muted)]" />
</div>
<div className="grid grid-cols-3 gap-2">
{options.map((option) => {
@@ -114,10 +310,11 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
@@ -144,11 +341,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100"
option.swatchClassName ?? "bg-[var(--surface-control)]"
)}
>
{active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white">
<Check size={10} aria-hidden="true" />
</span>
) : null}
+135 -33
View File
@@ -1,11 +1,8 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusDot } from "@/shared/ui/status";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
@@ -14,9 +11,15 @@ export type MapLegendItem = {
label: string;
description?: string;
color: string;
layerControlId?: string;
group?: string;
groupLabel?: string;
groupDescription?: string;
groupLayout?: "list" | "grid";
icon?: LucideIcon;
imageSrc?: string;
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
lineWidth?: number;
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square" | "area";
};
type MapLegendProps = {
@@ -37,7 +40,7 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
)}
>
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
<p className="mt-0.5 px-1 text-xs text-[var(--ink-muted)]">当前地图符号与分级</p>
<MapLegendList items={items} className="mt-3" />
</section>
);
@@ -46,44 +49,134 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
type MapLegendListProps = {
items: MapLegendItem[];
className?: string;
showStatusDot?: boolean;
};
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
return (
<ul className={cn("space-y-1.5", className)}>
{items.map((item) => {
const Icon = item.icon;
export function MapLegendList({ items, className = "" }: MapLegendListProps) {
const groups = groupLegendItems(items);
return (
<li
key={item.id}
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
</span>
if (groups.length === 0) {
return (
<div className={cn("surface-well rounded-xl border px-3 py-4 text-center", className)}>
<p className="text-xs font-semibold text-slate-700">暂无可见业务图层</p>
<p className="mt-1 text-[11px] leading-relaxed text-[var(--ink-muted)]">请先在图层面板中打开需要查看的内容。</p>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
{groups.map((group) => (
<section
key={group.id}
aria-label={group.label}
className="surface-well overflow-hidden rounded-xl border"
>
<div className="flex min-h-10 items-center gap-2 px-2.5 py-1.5">
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description}
</span>
) : null}
</span>
{showStatusDot ? <StatusDot tone="success" size="md" /> : null}
</li>
);
})}
</ul>
<span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
{group.items.length} 类
</span>
</div>
<ul
className={cn(
"border-t border-[var(--surface-divider)]/70",
group.layout === "grid"
? "grid grid-cols-2 gap-px bg-[var(--surface-divider)]/70"
: "divide-y divide-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45"
)}
>
{group.items.map((item) => (
<LegendItem key={item.id} item={item} compact={group.layout === "grid"} />
))}
</ul>
</section>
))}
</div>
);
}
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
if (shape === "line") {
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
}
function groupLegendItems(items: MapLegendItem[]) {
const groups = new Map<string, {
id: string;
label: string;
description?: string;
layout: "list" | "grid";
items: MapLegendItem[];
}>();
items.forEach((item) => {
const id = item.group ?? "legend";
const existing = groups.get(id);
if (existing) {
existing.items.push(item);
return;
}
groups.set(id, {
id,
label: item.groupLabel ?? "地图符号",
description: item.groupDescription,
layout: item.groupLayout ?? "list",
items: [item]
});
});
return [...groups.values()];
}
if (shape === "dashed-line" || shape === "dash-dot-line") {
function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }) {
const Icon = item.icon;
return (
<li
className={cn(
"flex items-center text-slate-700",
compact ? "min-h-11 gap-2 bg-[var(--surface-reading)]/70 px-2 py-1.5" : "min-h-9 gap-2.5 px-2.5 py-1.5"
)}
>
<span
aria-hidden="true"
className={cn(
"grid shrink-0 place-items-center bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
compact ? "h-7 w-7" : "h-7 w-9"
)}
>
{item.imageSrc ? (
<img src={item.imageSrc} alt="" width={24} height={24} />
) : Icon ? (
<Icon size={15} style={{ color: item.color }} />
) : (
<MapLegendSwatch color={item.color} shape={item.shape} lineWidth={item.lineWidth} />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-semibold leading-tight">{item.label}</span>
{compact && item.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">{item.description}</span>
) : null}
</span>
{!compact && item.description ? (
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{item.description}</span>
) : null}
</li>
);
}
export function MapLegendSwatch({
color,
shape = "dot",
lineWidth = 3
}: Pick<MapLegendItem, "color" | "shape" | "lineWidth">) {
if (shape === "line" || shape === "dashed-line" || shape === "dash-dot-line") {
const dashArray = shape === "dashed-line" ? "5 3" : shape === "dash-dot-line" ? "8 3 2 3" : undefined;
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden="true">
<line x1="1" y1="4" x2="23" y2="4" stroke={color} strokeWidth="3" strokeDasharray={shape === "dashed-line" ? "5 3" : "8 3 2 3"} />
<svg width="28" height="12" viewBox="0 0 28 12" aria-hidden="true">
<line x1="2" y1="6" x2="26" y2="6" stroke="white" strokeWidth={lineWidth + 3} strokeLinecap="round" strokeDasharray={dashArray} />
<line x1="2" y1="6" x2="26" y2="6" stroke={color} strokeWidth={lineWidth} strokeLinecap="round" strokeDasharray={dashArray} />
</svg>
);
}
@@ -92,6 +185,15 @@ export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "c
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
}
if (shape === "area") {
return (
<span
className="h-4 w-5 rounded-[4px] border"
style={{ backgroundColor: `${color}24`, borderColor: color }}
/>
);
}
if (shape === "ring") {
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
}
@@ -10,6 +10,9 @@ export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_SELECTION_CARD_FOCUS_CLASS_NAME =
"outline-hidden focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1";
export const MAP_CONTROL_SURFACE_CLASS_NAME =
"acrylic-control border";
@@ -1,13 +1,9 @@
import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { ChevronRight, Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapModeButton,
MapPanelSection
} from "./control-panel";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
@@ -36,12 +32,18 @@ export type MapMeasureAction = {
description: string;
icon?: LucideIcon;
strong?: boolean;
variant?: "default" | "primary";
variant?: "default" | "primary" | "active";
tone?: "default" | "danger";
disabled?: boolean;
onClick?: () => void;
};
export type MapMeasureStatus = {
label: string;
description: string;
tone?: "idle" | "active" | "ready" | "disabled";
};
export type MapMeasurePanelProps = {
modes: MapMeasureMode[];
activeModeId: string;
@@ -52,6 +54,7 @@ export type MapMeasurePanelProps = {
units: MapMeasureUnit[];
activeUnitId: string;
actions: MapMeasureAction[];
status: MapMeasureStatus;
onSelectMode?: (id: string) => void;
onSelectUnit?: (id: string) => void;
};
@@ -66,90 +69,227 @@ export function MapMeasurePanel({
units,
activeUnitId,
actions,
status,
onSelectMode,
onSelectUnit
}: MapMeasurePanelProps) {
const primaryAction = actions[0];
const secondaryActions = actions.slice(1);
return (
<div className="space-y-3">
<MapPanelSection title="模式">
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
<section>
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)] max-lg:hidden">
测量类型
</div>
<div
role="group"
aria-label="测量类型"
className={cn("surface-well grid grid-cols-3 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
>
{modes.map((mode) => (
<MapModeButton
<button
key={mode.id}
icon={mode.icon}
label={mode.label}
active={mode.id === activeModeId}
type="button"
aria-pressed={mode.id === activeModeId}
disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)}
/>
className={cn(
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100"
)}
>
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span className="truncate">{mode.label}</span>
</button>
))}
</div>
</MapPanelSection>
</section>
<MapPanelSection title="结果">
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="min-w-0 border-b border-slate-100 pb-3">
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
<section
aria-label="测量读数"
className={cn("overflow-hidden", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}
>
<div className="px-3.5 py-2.5">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 pt-0.5">
<div className="text-xs font-semibold text-[var(--action-blue-hover)]">{resultLabel}</div>
<div className="mt-1.5 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums">
{resultValue}
</span>
<span className="shrink-0 text-xs font-semibold text-[var(--ink-muted)]">{resultUnit}</span>
</div>
</div>
{metrics.length > 0 ? (
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
{metrics.map((metric) => (
<div
key={metric.label}
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<div className="truncate text-xs text-slate-500">{metric.label}</div>
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
</div>
))}
</div>
) : null}
</div>
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
{units.map((unit) => (
<button
key={unit.id}
type="button"
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-10 text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
: "surface-control text-slate-600 hover:text-blue-700"
)}
>
{unit.label}
</button>
))}
<div
role="group"
aria-label="测量单位"
className={cn("surface-well grid shrink-0 grid-cols-2 gap-1 border p-1", MAP_READABLE_RADIUS_CLASS_NAME)}
>
{units.map((unit) => (
<button
key={unit.id}
type="button"
aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
)}
>
{unit.label}
</button>
))}
</div>
</div>
</div>
</MapPanelSection>
<MapPanelSection title="操作">
{actions.map((action) => (
<MapActionRow
key={action.id}
icon={action.icon ?? getDefaultActionIcon(action.id)}
label={action.label}
description={action.description}
strong={action.strong}
variant={action.variant}
tone={action.tone}
disabled={action.disabled}
onClick={action.onClick}
{metrics.length > 0 ? (
<dl className="grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] border-t border-[var(--surface-divider)]/80">
{metrics.map((metric, index) => (
<div
key={metric.label}
className={cn("min-w-0 px-3.5 py-2", index > 0 && "border-l border-[var(--surface-divider)]/80")}
>
<dt className="truncate text-[11px] text-[var(--ink-muted)]">{metric.label}</dt>
<dd className="mt-0.5 truncate text-sm font-semibold tabular-nums text-slate-900">
{metric.value}
</dd>
</div>
))}
</dl>
) : null}
<div
role="status"
className={cn(
"flex min-h-10 items-center gap-2.5 border-t px-3.5 py-2",
status.tone === "active"
? "border-[var(--surface-border)] bg-[var(--action-soft)]/90"
: status.tone === "ready"
? "border-emerald-100 bg-emerald-50/80"
: "border-[var(--surface-divider)]/80 bg-[var(--surface-reading)]/75"
)}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
status.tone === "active"
? "bg-[var(--action-blue)] motion-safe:animate-pulse"
: status.tone === "ready"
? "bg-emerald-500"
: status.tone === "disabled"
? "bg-[var(--status-warning-mark)]"
: "bg-slate-400"
)}
aria-hidden="true"
/>
))}
</MapPanelSection>
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold text-slate-800">{status.label}</div>
<div className="truncate text-[11px] text-[var(--ink-muted)]">{status.description}</div>
</div>
</div>
</section>
{primaryAction ? (
<section aria-label="测量操作" className="space-y-2">
<MeasureActionButton action={primaryAction} primary />
{secondaryActions.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{secondaryActions.map((action) => (
<MeasureActionButton key={action.id} action={action} />
))}
</div>
) : null}
</section>
) : null}
</div>
);
}
function MeasureActionButton({
action,
primary = false
}: {
action: MapMeasureAction;
primary?: boolean;
}) {
const Icon = action.icon ?? getDefaultActionIcon(action.id);
const danger = action.tone === "danger";
const active = action.variant === "active";
return (
<button
type="button"
title={action.description}
data-state={active ? "active" : undefined}
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none",
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
MAP_COMPACT_RADIUS_CLASS_NAME,
primary
? active
? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70"
: action.variant === "primary"
? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn(
MAP_READABLE_SURFACE_CLASS_NAME,
danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50"
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
),
action.disabled &&
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700"
)}
>
{primary ? (
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active
? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]"
)}
>
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
</span>
) : (
<Icon size={15} strokeWidth={2} aria-hidden="true" />
)}
{primary ? (
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]">
{action.description}
</span>
</span>
) : (
<span>{action.label}</span>
)}
{primary ? (
active ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
进行中
</span>
) : (
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--ink-muted)]" />
)
) : null}
</button>
);
}
function getDefaultActionIcon(actionId: string) {
if (actionId.includes("copy")) {
return Copy;
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700",
"pointer-events-auto absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)] hover:text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
@@ -117,7 +117,10 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
onClick={onClick}
size="sm"
variant="outline"
className={cn("mt-2 border-blue-100 bg-blue-50 text-blue-700", MAP_COMPACT_RADIUS_CLASS_NAME)}
className={cn(
"pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
{label}
</Button>
+3 -3
View File
@@ -15,9 +15,9 @@ export function MapToaster() {
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-auto",
toast: "group pointer-events-none",
closeButton:
"absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700"
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
}
}}
/>
@@ -90,7 +90,7 @@ export function MapSourceStatusNotice({
tone: getSourceStatusTone(status),
title: getSourceStatusTitle(sourceName, status),
message: message ?? getSourceStatusMessage(status),
duration: status === "online" ? 2600 : Infinity,
duration: status === "online" ? 2600 : status === "degraded" ? 8000 : Infinity,
actionLabel,
onAction
});
@@ -115,17 +115,16 @@ export function MapScaleLine({
<ScaleLineDivider />
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
<div className="flex min-w-0 items-center justify-end gap-2 overflow-hidden whitespace-nowrap text-slate-700">
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
<span className="text-slate-600">EPSG:3857</span>
{coordinateReadout ? (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
{coordinateReadout.mercatorText}
</span>
) : (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-left tabular-nums")}>{coordinateLabel}</span>
)}
<span className="text-slate-500">Mapbox/OSM</span>
<span className="shrink-0 text-slate-600">EPSG:3857</span>
</div>
</div>
);
+3 -3
View File
@@ -70,9 +70,9 @@ export function MapToolbar({
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tool.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]"
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0"
tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0"
)}
>
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
@@ -87,7 +87,7 @@ export function MapToolbar({
<TooltipContent
side={isVertical ? "left" : "top"}
sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{title}
</TooltipContent>
+135 -45
View File
@@ -1,13 +1,13 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import { Home, Minus, Navigation2, Plus, type LucideIcon } from "lucide-react";
import { type ReactNode, type RefObject, useCallback, useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn";
import {
CAMERA_MODE_ANGLE_TOLERANCE,
getCombinedCameraAction,
setThreeDimensionalNavigation
} from "./camera-actions";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
@@ -19,10 +19,60 @@ type MapZoomProps = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onHome: () => void;
reducedMotion?: boolean;
className?: string;
};
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
export function MapZoom({
mapRef,
mapReady,
onHome,
reducedMotion = false,
className = ""
}: MapZoomProps) {
const [orientation, setOrientation] = useState({ bearing: 0, pitch: 0 });
const is3d = orientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE;
const cameraAction = getCombinedCameraAction(orientation.bearing, orientation.pitch);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
const syncOrientation = () => {
const nextOrientation = { bearing: map.getBearing(), pitch: map.getPitch() };
setOrientation(nextOrientation);
setThreeDimensionalNavigation(map, nextOrientation.pitch > CAMERA_MODE_ANGLE_TOLERANCE);
};
syncOrientation();
map.on("pitch", syncOrientation);
map.on("rotate", syncOrientation);
return () => {
map.off("pitch", syncOrientation);
map.off("rotate", syncOrientation);
};
}, [mapRef, mapReady]);
const handleCameraMode = useCallback(() => {
const map = mapRef.current;
if (!map) return;
const action = getCombinedCameraAction(map.getBearing(), map.getPitch());
const duration = reducedMotion ? 0 : 350;
if (action === "enter-3d") {
setThreeDimensionalNavigation(map, true);
map.easeTo({
pitch: window.matchMedia("(max-width: 639px)").matches ? 45 : 50,
duration
});
return;
}
if (action === "reset-north") {
map.easeTo({ bearing: 0, duration });
return;
}
setThreeDimensionalNavigation(map, false);
map.easeTo({ pitch: 0, bearing: 0, duration });
}, [mapRef, reducedMotion]);
const handleZoomIn = useCallback(() => {
mapRef.current?.zoomIn({ duration: 260 });
}, [mapRef]);
@@ -36,10 +86,7 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
<div
role="group"
aria-label="地图缩放"
className={cn(
"pointer-events-auto flex w-12 flex-col gap-2",
className
)}
className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
>
<div
className={cn(
@@ -48,34 +95,33 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<ControlButton
icon={Plus}
label="放大"
disabled={!mapReady}
onClick={handleZoomIn}
/>
<ControlButton icon={Plus} label="放大" disabled={!mapReady} onClick={handleZoomIn} />
<Divider />
<ControlButton icon={Minus} label="缩小" disabled={!mapReady} onClick={handleZoomOut} />
<Divider />
<ControlButton
icon={Minus}
label="缩小"
content={
<CompassModeIndicator
bearing={orientation.bearing}
pitch={orientation.pitch}
mode={is3d ? "3D" : "2D"}
/>
}
label={
cameraAction === "reset-north"
? "恢复北向"
: cameraAction === "exit-3d"
? "切换到二维"
: "切换到三维"
}
pressed={is3d}
disabled={!mapReady}
onClick={handleZoomOut}
onClick={handleCameraMode}
/>
</div>
<div
className={cn(
"p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<ControlButton
icon={Home}
label="缩放到全局"
disabled={!mapReady}
onClick={onHome}
/>
<div className={cn("p-1", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<ControlButton icon={Home} label="缩放到全局" disabled={!mapReady} onClick={onHome} />
</div>
</div>
</TooltipProvider>
@@ -83,19 +129,29 @@ export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomPro
}
type ControlButtonProps = {
icon: LucideIcon;
icon?: LucideIcon;
content?: ReactNode;
pressed?: boolean;
label: string;
disabled: boolean;
onClick: () => void;
};
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
function ControlButton({
icon: Icon,
content,
pressed,
label,
disabled,
onClick
}: ControlButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
aria-pressed={pressed}
disabled={disabled}
onClick={onClick}
className={cn(
@@ -103,17 +159,17 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME,
disabled &&
"text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400"
pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]"
)}
>
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
</button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{label}
</TooltipContent>
@@ -121,11 +177,45 @@ function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonPr
);
}
function Divider() {
function CompassModeIndicator({
bearing,
pitch,
mode
}: {
bearing: number;
pitch: number;
mode: "2D" | "3D";
}) {
return (
<div
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
<span
className="relative grid h-7 w-7 place-items-center"
style={{ perspective: "70px" }}
aria-hidden="true"
/>
data-map-compass="true"
data-bearing={bearing.toFixed(1)}
data-pitch={pitch.toFixed(1)}
data-map-mode={mode.toLowerCase()}
>
<Navigation2
size={17}
strokeWidth={2.2}
className="transition-transform duration-150 ease-out motion-reduce:transition-none"
style={{ transform: `rotateX(${Math.min(pitch, 60) * 0.65}deg) rotateZ(${-bearing}deg)` }}
/>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 min-w-4 rounded-[4px] px-0.5 text-center text-[8px] font-bold leading-3 tracking-[-0.02em] shadow-xs ring-1",
mode === "3D"
? "bg-[var(--action-blue)] text-white ring-[var(--action-blue)]/30"
: "bg-[var(--surface-reading)] text-slate-600 ring-[var(--surface-border)]"
)}
>
{mode}
</span>
</span>
);
}
function Divider() {
return <div className="my-0.5 h-px w-3.5 self-center bg-slate-300/70" aria-hidden="true" />;
}
+5 -1
View File
@@ -21,7 +21,11 @@ export {
type MapDrawToolbarProps,
type MapDrawTool
} from "./components/draw-toolbar";
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
export {
MapLayerControl,
type MapLayerControlItem,
type MapLayerControlVisual
} from "./components/layer-control";
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export {
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchElementGisProperties } from "./gis-properties-client";
describe("GIS properties client", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads validated attributes for the selected project element", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
element_type: "pipes",
element_id: "P/117",
groups: [{
dataset_key: "fx_pipe_inventory_20200811",
dataset_title: "奉贤 DN100 及以上管网台账",
source_date: "2020-08-11",
layer_key: "fx_20200811_dn100_pipes",
layer_name: "DN100 及以上管线台账",
source_fid: 117,
match_method: "geometry",
match_distance_m: 0.12,
attributes: { PIPE_D: 300, MATERIAL: "DI" }
}]
}), { status: 200, headers: { "Content-Type": "application/json" } }));
const groups = await fetchElementGisProperties(
"pipes",
"P/117",
"project-117",
async () => "read-token"
);
expect(groups[0].attributes).toEqual({ PIPE_D: 300, MATERIAL: "DI" });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/elements/pipes/P%2F117/gis-properties");
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-117");
});
});
@@ -0,0 +1,114 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
dataset_title: z.string().min(1),
source_date: z.string().date(),
layer_key: z.string().min(1),
layer_name: z.string().min(1),
source_fid: z.number().int(),
match_method: z.enum(["node_pair", "geometry", "source"]),
match_distance_m: z.number().finite().nonnegative().nullable(),
attributes: z.record(z.unknown())
});
const elementGisPropertiesSchema = z.object({
element_type: z.enum([
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada"
]),
element_id: z.string().min(1),
groups: z.array(gisAttributeGroupSchema)
});
const sourceGisFeaturePropertiesSchema = z.object({
layer_key: z.string().min(1),
source_fid: z.number().int().nonnegative(),
group: gisAttributeGroupSchema
});
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
elementId: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
}
const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
}
return result.groups;
}
export async function fetchSourceGisFeatureProperties(
layerKey: string,
sourceFid: number,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
"features",
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
}
const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致");
}
return [result.group];
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { investigationPresentationEventSchema } from "./investigation-presentation";
const event = (command: unknown) => ({ task_id: "71000000-0000-4000-8000-000000000001", run_id: "71000000-0000-4000-8000-000000000002", execution_generation: 1, command });
const overlay = { action: "set_overlay", overlay_id: "scope", title: "投诉记录分布", mode: "heatmap", evidence_id: "complaints",
data: { type: "FeatureCollection", features: [{ type: "Feature", properties: { weight: 2 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } }] } };
describe("investigation presentation boundary", () => {
it("accepts only bounded registered target actions", () => {
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" })).success).toBe(true);
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "drainage", feature_ids: ["P1"], effect: "pulse" })).success).toBe(false);
expect(investigationPresentationEventSchema.safeParse(event({ action: "run_script", script: "alert(1)" })).success).toBe(false);
});
it("rejects untrusted chart fields and mismatched series", () => {
const chart = { title: "投诉时段压力", chart_type: "line", x_data: ["08:00", "09:00"], series: [{ name: "P1", data: [20, 21] }] };
const command = { action: "open_chart", chart_id: "p1", chart, evidence_refs: ["pressure"] };
expect(investigationPresentationEventSchema.safeParse(event(command)).success).toBe(true);
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, formatter: "script" } })).success).toBe(false);
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, series: [{ name: "P1", data: [20] }] } })).success).toBe(false);
});
it("keeps heatmap coordinates and weights finite and point-only", () => {
expect(investigationPresentationEventSchema.safeParse(event(overlay)).success).toBe(true);
for (const feature of [
{ type: "Feature", properties: { weight: -1 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
{ type: "Feature", properties: { weight: "2" }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [Infinity, 31.1] } },
{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [[121.4, 31.1], [121.5, 31.2]] } }
]) expect(investigationPresentationEventSchema.safeParse(event({ ...overlay, data: { type: "FeatureCollection", features: [feature] } })).success).toBe(false);
});
});
@@ -0,0 +1,66 @@
import { z } from "zod";
import type { FeatureCollection, Geometry } from "geojson";
import { parseFrontendChart } from "@/features/agent";
import { investigationMapTargetSchema } from "./investigation-report";
const id = z.string().trim().min(1).max(128);
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null);
const position = z.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]).rest(z.number().finite()).refine((value) => value.length <= 3);
const line = z.array(position).min(2).max(20000);
const ring = z.array(position).min(4).max(20000).refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
const geometrySchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
z.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) }).strict(),
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
z.object({ type: z.literal("MultiLineString"), coordinates: z.array(line).min(1).max(20000) }).strict(),
z.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) }).strict(),
z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000) }).strict()
]).refine((geometry) => {
const count = (coordinates: unknown): number => !Array.isArray(coordinates) ? 0
: typeof coordinates[0] === "number" ? 1 : coordinates.reduce<number>((total, item) => total + count(item), 0);
return count(geometry.coordinates) <= 20000;
});
export const investigationOverlayDataSchema = z.object({
type: z.literal("FeatureCollection"),
features: z.array(z.object({
type: z.literal("Feature"),
id: z.union([z.string(), z.number().finite()]).optional(),
properties: z.record(z.string(), z.unknown()).nullable(),
geometry: geometrySchema
}).strict()).min(1).max(1000)
}).strict();
const overlaySchema = z.object({
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema
}).strict();
const commandSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id,
feature_ids: z.array(id).min(1).max(100), effect: z.enum(["steady", "pulse"]) }).strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z.object({ action: z.literal("open_chart"), chart_id: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(),
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
overlaySchema,
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
]).superRefine((command, ctx) => {
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (command.data.features.some((feature) => feature.geometry.type !== "Point"
|| (feature.properties?.weight !== undefined && (typeof feature.properties.weight !== "number" || !Number.isFinite(feature.properties.weight) || feature.properties.weight < 0)))) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
export const investigationPresentationEventSchema = z.object({
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
});
export const investigationPresentationCursorSchema = z.object({
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
event_cursor: z.number().int().nonnegative()
}).strict();
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean };
export type InvestigationOverlayData = FeatureCollection<Geometry>;
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { investigationReportSchema } from "./investigation-report";
const base = {
title: "调查报告",
current_conclusion: "无法确定原因",
conclusion_boundary: "仅有上游监测",
evidence_refs: ["query-1"],
recommendations: [],
pending_items: []
};
const result = {
...base,
stage: "final",
outcome: "insufficient_data",
stop_reason: "缺少采样",
data_gaps: [
{
gap_id: "g1",
node_id: "read",
missing_data: "末梢采样",
scope: "投诉小区,投诉时段",
impact: "无法确定异常来源",
required_data: "该时段采样结果",
evidence_refs: ["query-1"],
blocking: true
}
]
};
describe("investigation report read contract", () => {
it("preserves unknown historical facts instead of inventing a result or empty gap list", () => {
const legacy = investigationReportSchema.parse(base);
expect(legacy.outcome).toBeUndefined();
expect(legacy.data_gaps).toBeUndefined();
expect(
investigationReportSchema.parse({
...base,
stage: null,
outcome: null,
data_gaps: null,
stop_reason: null
}).data_gaps
).toBeNull();
});
it("accepts an evidenced final insufficient-data result", () => {
expect(investigationReportSchema.parse(result).outcome).toBe("insufficient_data");
});
it("reads a narrative diagram without requiring a legacy execution plan", () => {
const value = investigationReportSchema.parse({
...result,
data_gaps: [{ ...result.data_gaps[0], node_id: null }],
diagram: {
title: "调查脉络",
mermaid: 'flowchart TD\nA["查询"] --> B["判断"]',
node_refs: [{ node_id: "A", label: "查询", evidence_refs: ["query-1"] }]
}
});
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
expect(value.data_gaps?.[0]?.node_id).toBeNull();
});
it.each([
{ outcome: "other" },
{ stage: "interim" },
{ stop_reason: null },
{ data_gaps: [] },
{ recommendations: ["自动派单"] },
{ pending_items: ["等人工"] },
{ outcome: "concluded" },
{ data_gaps: [{ ...result.data_gaps[0], evidence_refs: [] }] },
{ data_gaps: [result.data_gaps[0], result.data_gaps[0]] },
{ schema: "invented" }
])("rejects inconsistent business facts %j", (changes) => {
expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow();
});
});
@@ -0,0 +1,105 @@
import { z } from "zod";
import type { AgentTaskDiscussionContext } from "@/features/agent";
export type InvestigationDiscussion = {
prompt?: string;
context?: AgentTaskDiscussionContext;
// Display metadata from the selected report, never the task's current round.
reportGeneration?: number | null;
};
const text = z.string().trim().min(1);
export const investigationMapTargetSchema = z.object({
source_id: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
feature_id: text.max(128)
}).strict();
export const investigationDiagramSchema = z
.object({
title: text,
mermaid: text
.max(20000)
.refine(
(source) =>
/^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source) &&
!source.includes("```") &&
!source.includes("%%{") &&
!/(?:^|[;\n])\s*click\b/.test(source),
"调查图只接受不含指令或回调的 Mermaid 流程描述"
),
node_refs: z
.array(
z
.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([])
})
.strict()
)
.max(100)
.default([])
})
.strict()
.superRefine((diagram, ctx) => {
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
}
});
const dataGapSchema = z
.object({
gap_id: text,
node_id: text.nullish(),
missing_data: text,
scope: text,
impact: text,
required_data: text,
evidence_refs: z.array(text).min(1),
blocking: z.boolean()
})
.strict();
/** Historical omissions remain unknown. Never infer outcomes from report prose. */
export const investigationReportSchema = z
.object({
title: text,
current_conclusion: text,
conclusion_boundary: text,
evidence_refs: z.array(text),
recommendations: z.array(text),
pending_items: z.array(text),
stage: z.enum(["interim", "final"]).nullish(),
outcome: z.enum(["concluded", "insufficient_data"]).nullish(),
stop_reason: text.nullish(),
data_gaps: z.array(dataGapSchema).nullish(),
diagram: investigationDiagramSchema.nullish()
})
.strict()
.superRefine((report, ctx) => {
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
if (report.outcome == null) {
if (report.stage != null || report.stop_reason != null || report.data_gaps != null)
invalid("报告结构化字段必须一起提供");
return;
}
if (report.stage == null || report.data_gaps == null) {
invalid("报告缺少阶段或数据缺口清单");
return;
}
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length)
invalid("数据缺口编号重复");
const blocking = report.data_gaps.some((gap) => gap.blocking);
if (report.outcome === "insufficient_data") {
if (report.stage !== "final" || !report.stop_reason || !blocking)
invalid("证据不足必须提供最终报告、结束原因和关键缺口");
if (report.recommendations.length || report.pending_items.length)
invalid("证据不足退出后不再生成后续处置建议或待办");
} else if (report.stop_reason != null || blocking)
invalid("已形成结论不能包含结束原因或阻塞缺口");
});
export type InvestigationReport = z.infer<typeof investigationReportSchema>;
export type InvestigationDiagram = z.infer<typeof investigationDiagramSchema>;
export type InvestigationMapTarget = z.infer<typeof investigationMapTargetSchema>;
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskNotifications,
parseOperationalTask,
parseOperationalTaskNotification
} from "./operational-task-client";
vi.mock("./project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })),
createWorkbenchHeaders: vi.fn(() => ({})),
getServerError: vi.fn(() => "请求失败")
}));
const id = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const time = "2026-09-23T01:00:00Z";
function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
execution_request_id: id, title: "水质投诉", summary: "调查中",
source: { kind: "external_system", external_id: null, external_revision: null, event_kind: null },
trigger: { kind: "event", occurred_at: time, detail: "投诉上报" },
scheduled_for: null, agent_session_id: "session", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1, created_at: time, updated_at: time,
latest_plan: { ...snapshot, plan: { schema: "execution-plan@1", title: "调查", reason: "投诉",
nodes: [{ id: "check", title: "核查", owner: "agent", status: "completed", detail: "已核查", evidence_refs: [], action_refs: [] }], edges: [] } },
latest_report: { ...snapshot, report: { title: "调查报告", current_conclusion: "待采样",
conclusion_boundary: "尚无现场证据", evidence_refs: [], recommendations: [], pending_items: ["采样"] } },
evidence: [], actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false,
can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false,
can_decide_commands: false, can_execute_commands: false }
};
}
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
it("accepts execution generation on both plan and report", () => {
const parsed = parseOperationalTask(task());
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
expect(parsed.latest_report).toHaveProperty("execution_generation", 2);
});
it("accepts legacy null or missing snapshot generation", () => {
expect(() => parseOperationalTask(task(null))).not.toThrow();
const legacy = task();
Reflect.deleteProperty(legacy.latest_plan, "execution_generation");
Reflect.deleteProperty(legacy.latest_report, "execution_generation");
expect(() => parseOperationalTask(legacy)).not.toThrow();
});
it("accepts a task without any plan or report", () => {
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
});
it("reads ongoing progress without needing a conclusion or execution plan", () => {
const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session",
progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } };
const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress });
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("running");
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1");
expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow();
});
it("accepts a withdrawn peripheral source timestamp", () => {
const value = task();
expect(parseOperationalTask({
...value,
source: { ...value.source, deleted_at: time }
}).source.deleted_at).toBe(time);
expect(() => parseOperationalTask({
...value,
source: { ...value.source, deleted_at: "not-a-date" }
})).toThrow();
});
it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
});
});
describe("operational notification contract", () => {
const notification = {
notification_id: id,
task_id: id,
kind: "action_approval_pending",
title: "待审批工单",
description: "请审核派单内容",
required_permission: null,
status: "unread",
version: 1,
created_at: time,
read_at: null,
read_by: null
};
it("keeps legacy notifications active by default", () => {
expect(parseOperationalTaskNotification(notification)).toMatchObject({
lifecycle_status: "active",
subject_type: "task",
subject_id: null
});
});
it("accepts resolved notifications with a business subject", () => {
expect(parseOperationalTaskNotification({
...notification,
lifecycle_status: "resolved",
subject_type: "action",
subject_id: id,
resolved_at: time,
resolution_reason: "action_approved"
}).lifecycle_status).toBe("resolved");
});
it("requests the active inbox instead of hiding already read work", async () => {
let request: Request | undefined;
vi.stubGlobal("fetch", vi.fn(async (input, init) => {
request = new Request(input, init);
return new Response(JSON.stringify({
items: [{
...notification,
lifecycle_status: "active",
status: "read",
version: 2,
read_at: time,
read_by: `principal:${id}`
}],
limit: 100,
offset: 0,
next_offset: null
}), { status: 200 });
}));
const items = await fetchOperationalTaskNotifications();
expect(items).toHaveLength(1);
expect(items[0]?.status).toBe("read");
expect(new URL(request!.url).searchParams.get("unread_only")).toBe("false");
});
});
it("reads persisted MCP evidence without expanding task access-request scopes", async () => {
const snapshot = { source: "query_model_baseline_series", source_ref: "mcp:fixture", queried_at: time,
origin: "system_data", data: { semantics: "model_baseline", data: [] }, truncated: true };
vi.stubGlobal("fetch", vi.fn(async () => Response.json(snapshot)));
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(snapshot);
for (const source of ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"]) {
const analysis = { ...snapshot, source, data: { semantics: "analysis", data: {} } };
vi.stubGlobal("fetch", vi.fn(async () => Response.json(analysis)));
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(analysis);
}
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
});
@@ -0,0 +1,519 @@
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema } from "./investigation-presentation";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]);
const workbenchDateTimeSchema = z.string().datetime({ offset: true });
const taskSourceSchema = z.object({
kind: z.enum(["schedule", "external_system", "operator", "agent"]),
external_id: z.string().min(1).nullable(),
external_revision: z.number().int().positive().nullable(),
event_kind: z.string().min(1).nullable(),
deleted_at: workbenchDateTimeSchema.nullish()
}).strict();
const taskTriggerSchema = z.object({
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
occurred_at: workbenchDateTimeSchema,
detail: z.string().min(1)
}).strict();
const operationalTaskRunSchema = z.object({
run_id: z.string().uuid(),
task_id: z.string().uuid(),
generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
revision: z.number().int().positive(),
agent_session_id: z.string().min(1).nullable(),
lease_expires_at: workbenchDateTimeSchema.nullable(),
heartbeat_at: workbenchDateTimeSchema.nullable(),
started_at: workbenchDateTimeSchema.nullable(),
finished_at: workbenchDateTimeSchema.nullable(),
failure_code: z.string().min(1).nullable(),
failure_detail: z.string().min(1).nullable(),
dispatch_attempts: z.number().int().nonnegative(),
next_dispatch_at: workbenchDateTimeSchema,
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const taskSummarySchema = z.object({
project_id: z.string().uuid(),
task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
workflow: z.string().min(1),
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
active_run: operationalTaskRunSchema.nullable(),
latest_run: operationalTaskRunSchema.nullable().optional(),
status: taskStatusSchema,
priority: taskPrioritySchema,
revision: z.number().int().positive(),
execution_generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
title: z.string().min(1),
summary: z.string().min(1),
source: taskSourceSchema,
trigger: taskTriggerSchema,
scheduled_for: workbenchDateTimeSchema.nullable(),
agent_session_id: z.string().min(1).nullable(),
agent_lease_expires_at: workbenchDateTimeSchema.nullable(),
current_plan_revision: z.number().int().positive().nullable(),
current_report_revision: z.number().int().positive().nullable(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const evidenceSchema = z.object({
evidence_id: z.string().min(1),
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
summary: z.string().min(1),
source_label: z.string().min(1),
source_ref: z.string().min(1).nullable(),
observed_at: workbenchDateTimeSchema,
recorded_at: workbenchDateTimeSchema,
recorded_by: z.string().min(1),
has_snapshot: z.boolean()
}).strict();
const planNodeSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
owner: z.enum(["agent", "human"]),
status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]),
detail: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
action_refs: z.array(z.string().uuid()),
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).nullish(),
selected_edge_id: z.string().min(1).nullish(),
gate_ref: z.string().uuid().nullish(),
revisits_node_id: z.string().min(1).nullish()
}).strict();
const planInputSchema = z.object({
schema: z.enum(["execution-plan@1", "execution-plan@2"]),
title: z.string().min(1),
reason: z.string().min(1),
nodes: z.array(planNodeSchema).min(1).max(100),
edges: z.array(z.object({
id: z.string().min(1).nullish(),
from: z.string().min(1),
to: z.string().min(1),
condition: z.string().min(1).nullable()
}).strict()).max(300)
}).strict();
const planSnapshotSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
plan: planInputSchema
}).strict();
const reportInputSchema = investigationReportSchema;
const reportRevisionSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
report: reportInputSchema
}).strict();
const progressSnapshotSchema = z.object({
revision: z.number().int().positive(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
published_at: workbenchDateTimeSchema,
source_session_id: z.string().min(1),
progress: z.object({ summary: z.string().trim().min(1), diagram: investigationDiagramSchema.nullable() }).strict()
}).strict();
const reportPageSchema = z.object({
items: z.array(reportRevisionSchema),
next_offset: z.number().int().nonnegative().nullable(),
}).strict();
const actionReceiptSchema = z.object({
adapter: z.string().min(1),
receipt_id: z.string().min(1),
submitted_at: workbenchDateTimeSchema,
completed_at: workbenchDateTimeSchema.nullable(),
external_status: z.string().min(1),
detail: z.string().min(1)
}).strict();
const actionSchema = z.object({
action_id: z.string().uuid(),
task_id: z.string().uuid(),
version: z.number().int().positive(),
kind: z.enum(["create_work_order", "issue_command"]),
reason: z.string().min(1),
payload: z.record(z.string(), z.unknown()),
evidence_refs: z.array(z.string().min(1)),
node_id: z.string().min(1).nullable(),
required_approval_permission: z.string().min(1),
required_execution_permission: z.string().min(1),
approval_status: z.enum(["pending", "approved", "rejected"]),
execution_status: z.enum(["not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"]),
execution_request_id: z.string().uuid().nullable(),
proposed_by: z.string().min(1),
proposed_at: workbenchDateTimeSchema,
edited_by: z.string().min(1).nullish(),
edited_at: workbenchDateTimeSchema.nullish(),
decided_by: z.string().min(1).nullable(),
decided_at: workbenchDateTimeSchema.nullable(),
decision_comment: z.string().min(1).nullable(),
executed_by: z.string().min(1).nullable(),
executed_at: workbenchDateTimeSchema.nullable(),
receipt: actionReceiptSchema.nullable(),
execution_detail: z.string().min(1).nullable()
}).strict();
const accessRequestSchema = z.object({
access_request_id: z.string().uuid(),
task_id: z.string().uuid(),
node_id: z.string().min(1),
scopes: z.array(z.string().min(1)),
reason: z.string().min(1),
status: z.enum(["pending", "approved", "rejected"]),
approved_scopes: z.array(z.string().min(1)),
version: z.number().int().positive(),
requested_by: z.string().min(1),
requested_at: workbenchDateTimeSchema,
decided_by: z.string().min(1).nullable(),
decided_at: workbenchDateTimeSchema.nullable(),
comment: z.string().min(1).nullable()
}).strict();
const taskPermissionsSchema = z.object({
can_publish_artifacts: z.boolean(),
can_propose_actions: z.boolean(),
can_sync_external_feedback: z.boolean(),
can_decide_work_orders: z.boolean(),
can_execute_work_orders: z.boolean(),
can_decide_commands: z.boolean(),
can_execute_commands: z.boolean(),
can_decide_access: z.boolean().default(false)
}).strict();
const externalWorkOrderActivitySchema = z.object({
sequence: z.number().int().positive(),
occurred_at: workbenchDateTimeSchema,
actor: z.string().min(1),
kind: z.enum(["created", "updated", "feedback"]),
title: z.string().min(1),
detail: z.string().min(1)
}).strict();
const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
const { subtasks: _legacySubtasks, ...flat } = value as Record<string, unknown>;
return flat;
}
return value;
}, z.object({
work_order_id: z.string().uuid(),
revision: z.number().int().positive(),
title: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"]),
priority: z.enum(["low", "normal", "high", "urgent"]),
assignee: z.string().min(1).nullable(),
scheduled_at: workbenchDateTimeSchema.nullable(),
work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]),
location: z.string().min(1).nullable(),
scenario_code: z.string().min(1).nullable(),
feedback_requirements: z.array(z.string().min(1)).default([]),
activity: z.array(externalWorkOrderActivitySchema).default([]),
origin: z.literal("synthetic"),
updated_at: workbenchDateTimeSchema
}).strict());
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
latest_progress: progressSnapshotSchema.nullish(),
evidence: z.array(evidenceSchema),
actions: z.array(actionSchema),
access_requests: z.array(accessRequestSchema).default([]),
permissions: taskPermissionsSchema,
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
}).strict();
const notificationSchema = z.object({
notification_id: z.string().uuid(),
task_id: z.string().uuid(),
kind: z.enum([
"task_created",
"action_pending",
"report_updated",
"task_completed",
"task_failed",
"run_delivery_degraded",
"access_approval_pending",
"action_approval_pending",
"human_input_required",
"external_coordination_failed"
]),
title: z.string().min(1),
description: z.string().min(1),
lifecycle_status: z.enum(["active", "resolved"]).default("active"),
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"),
subject_id: z.string().uuid().nullable().default(null),
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
resolved_at: workbenchDateTimeSchema.nullable().default(null),
resolution_reason: z.string().min(1).nullable().default(null),
status: z.enum(["unread", "read"]),
version: z.number().int().positive(),
required_permission: z.string().min(1).nullable(),
created_at: workbenchDateTimeSchema,
read_at: workbenchDateTimeSchema.nullable(),
read_by: z.string().min(1).nullable()
}).strict();
const notificationPageSchema = z.object({
items: z.array(notificationSchema),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable()
}).strict();
const scheduleSchema = z.object({
schedule_id: z.string().uuid(),
schedule_key: z.string().min(1),
title: z.string().min(1),
summary: z.string().min(1),
interval_seconds: z.number().int().min(60).max(31_536_000),
next_run_at: workbenchDateTimeSchema,
priority: taskPrioritySchema,
enabled: z.boolean(),
version: z.number().int().positive(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict();
const taskDataSnapshotSchema = z.object({
// Snapshot sources include product MCP queries; access-request scopes remain unchanged.
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
"get_analysis_run",
"get_element_gis_properties",
"get_element_properties",
"get_model_baseline_at_time",
"get_network_options",
"get_network_schema",
"get_scada_device",
"get_sensor_placement_run",
"get_source_gis_properties",
"get_task_context",
"get_task_evidence",
"get_workbench_runtime",
"list_analysis_results",
"list_analysis_runs",
"list_elements",
"list_scada_devices",
"list_sensor_placement_runs",
"query_analysis_series",
"query_analysis_values",
"query_element_history",
"query_model_baseline_field",
"query_model_baseline_series",
"query_peripheral_data",
"query_scada_readings",
"run_burst_detection",
"run_simulation",
"run_contaminant_simulation",
"analyze_valve_closure",
"analyze_flushing",
"analyze_pressure_regulation",
"optimize_sensor_placement",
"run_burst_location",
"analyze_valve_isolation",
"run_leakage_identification",
]),
source_ref: z.string().min(1),
queried_at: workbenchDateTimeSchema,
origin: z.enum(["system_data", "synthetic"]),
data: z.unknown(),
truncated: z.boolean()
}).strict();
export type OperationalTaskSummary = z.infer<typeof taskSummarySchema>;
export type OperationalTask = z.infer<typeof operationalTaskSchema>;
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
export type OperationalTaskAction = z.infer<typeof actionSchema>;
export type OperationalTaskAccessRequest = z.infer<typeof accessRequestSchema>;
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
export type OperationalTaskReport = z.infer<typeof reportRevisionSchema>;
export type OperationalTaskReportPage = z.infer<typeof reportPageSchema>;
export type OperationalTaskProgress = z.infer<typeof progressSnapshotSchema>;
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
export type OperationalTaskDecision = "approved" | "rejected";
export function parseOperationalTask(value: unknown) {
return operationalTaskSchema.parse(value);
}
export function parseOperationalTaskSummary(value: unknown) {
return taskSummarySchema.parse(value);
}
export function parseOperationalTaskNotification(value: unknown) {
return notificationSchema.parse(value);
}
export type CreateOperationalTaskInput = {
request_id: string;
task_type: OperationalTask["task_type"];
workflow: string;
title: string;
summary: string;
priority: OperationalTask["priority"];
source: OperationalTask["source"];
trigger: OperationalTask["trigger"];
scheduled_for?: string;
agent_session_id?: string;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
return payload;
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}`,
getAccessToken,
{ signal }
));
}
export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) {
return reportPageSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken,
));
}
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return investigationPresentationCursorSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal }
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function decideOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
decision: OperationalTaskDecision;
comment?: string;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function updateOperationalWorkOrderDraft(taskId: string, actionId: string, input: {
request_id: string; expected_version: number; payload: Record<string, unknown>;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/draft`,
getAccessToken, { method: "PATCH", body: JSON.stringify(input) }
));
}
export async function confirmOperationalWorkOrderDispatch(taskId: string, actionId: string, input: {
request_id: string; expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/confirm-dispatch`,
getAccessToken, { method: "POST", body: JSON.stringify(input) }
));
}
export async function decideOperationalTaskAccess(taskId: string, accessRequestId: string, input: {
request_id: string; expected_version: number; decision: "approved" | "rejected";
approved_scopes: string[]; comment?: string;
}, getAccessToken?: AccessTokenProvider) {
return accessRequestSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/access-requests/${encodeURIComponent(accessRequestId)}/decision`,
getAccessToken, { method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return notificationPageSchema.parse(await operationalTaskRequest(
"/operational-task-notifications?unread_only=false&limit=100&offset=0",
getAccessToken,
{ signal }
)).items;
}
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
return notificationSchema.parse(await operationalTaskRequest(
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
getAccessToken,
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
));
}
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return schedulePageSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ signal }
)).items;
}
export async function createOperationalTaskSchedule(input: {
request_id: string;
schedule_key: string;
title: string;
summary: string;
interval_seconds: number;
next_run_at: string;
priority: "normal" | "urgent";
}, getAccessToken?: AccessTokenProvider) {
return scheduleSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
getAccessToken
));
}
export function createOperationalRequestId() {
return crypto.randomUUID();
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { searchPlaces } from "./place-search-client";
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
afterEach(() => vi.restoreAllMocks());
describe("Fengxian place search client", () => {
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/place-search-requests");
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body as string)).toEqual({ keyword: "南桥路" });
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("accepts empty search results", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
});
it("accepts at most ten Fengxian candidates", async () => {
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
});
it.each([
{ ...results, items: [{ ...item, longitude: 181 }] },
{ ...results, items: [{ ...item, latitude: 91 }] },
{ ...results, items: [item, item] },
{ ...results, region: "北京市" },
{ ...results, items: [{ ...item, district: "徐汇区" }] },
{ ...results, total: 0 },
{ ...results, total: 11 },
{ ...results, items: Array.from({ length: 11 }, (_, index) => ({ ...item, id: `place-${index}` })) },
{ status: "0", location: { lon: "121", lat: "31" } }
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
});
it("does not fetch blank keywords", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(searchPlaces(" ", "project-1")).rejects.toThrow("请输入");
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([403, 502, 503, 504])("preserves server failures for HTTP %s", async (status) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "查询暂不可用" }, { status }));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("查询暂不可用");
});
it("does not fetch after cancellation during token refresh", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
const controller = new AbortController();
await expect(searchPlaces("南桥路", "project-1", async () => {
controller.abort(); return "read-token";
}, controller.signal)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
const placeSchema = z.object({
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90),
category: z.string().nullable(), district: z.literal("奉贤区")
});
const resultsSchema = z.object({
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
truncated: z.boolean().default(false), region: z.literal("奉贤区")
});
export type PlaceSearchItem = z.infer<typeof placeSchema>;
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
export async function searchPlaces(
keyword: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<PlaceSearchResults> {
const query = keyword.trim();
if (!query) throw new Error("请输入需要搜索的地址或地名");
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify({ keyword: query }),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
const parsed = resultsSchema.safeParse(payload);
if (!parsed.success
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|| parsed.data.total !== parsed.data.items.length) {
throw new Error("未获得有效的地名搜索结果,请稍后重试");
}
return parsed.data;
}
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchProjectPresentation,
findOwnedWorkbenchProject,
parseProjectMapExtent
} from "./project-client";
describe("workbench project client", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("selects the active project owned by the configured GeoServer workspace", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({
items: [
{ project_id: "inactive", gs_workspace: "tjwater_next", status: "disabled" },
{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }
],
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const result = await findOwnedWorkbenchProject(async () => "token");
expect(result.project.project_id).toBe("supply");
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBeNull();
});
it("does not infer a project when the authorized list has no workspace match", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
items: [], total: 0, limit: 1000, offset: 0
}), { status: 200 })));
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("shares project lookup while cancelling only the caller that aborts", async () => {
const projectPage = JSON.stringify({
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
});
let resolveFetch!: (response: Response) => void;
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
resolveFetch = resolve;
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
})
);
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = async () => null;
const controller = new AbortController();
const cancelled = findOwnedWorkbenchProject(getAccessToken, controller.signal);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const active = findOwnedWorkbenchProject(getAccessToken);
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
resolveFetch(new Response(projectPage, { status: 200 }));
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("refreshes tokens, expires project metadata and evicts failed shared lookups", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
const page = { items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1, limit: 1000, offset: 0 };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "temporary" }), { status: 503 }))
.mockImplementation(async () => new Response(JSON.stringify(page), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
let token = "first";
const provider = vi.fn(async () => token);
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("temporary");
await findOwnedWorkbenchProject(provider);
token = "renewed";
expect((await findOwnedWorkbenchProject(provider)).accessToken).toBe("renewed");
expect(fetchMock).toHaveBeenCalledTimes(2);
clock.mockReturnValue(5 * 60_000);
await findOwnedWorkbenchProject(provider);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(new Headers(fetchMock.mock.calls[2][1].headers).get("Authorization")).toBe("Bearer renewed");
await findOwnedWorkbenchProject(async () => "another-identity");
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("loads the authoritative project extent and access identity independently", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
if (path === "/api/v1/projects") {
return new Response(JSON.stringify({
items: [{
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
status: "active",
project_role: "member"
}],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 });
}
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer token");
expect(headers.get("X-Project-Id")).toBe("supply");
if (path === "/api/v1/projects/current/metadata") {
return new Response(JSON.stringify({
project_id: "supply",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
map_extent: { bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23], zoom: 13 },
status: "active",
project_role: "member"
}), { status: 200 });
}
return new Response(JSON.stringify({
user_id: "user-1",
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "supply",
project_role: "member",
permissions: ["webgis.view"]
}), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const presentation = await fetchWorkbenchProjectPresentation(async () => "token");
expect(presentation).toMatchObject({
projectId: "supply",
projectName: "北区供水项目",
username: "north.operator",
projectRole: "member",
metadataAvailable: true,
accessContextAvailable: true,
networkView: {
bbox3857: [13358338.895, 3503549.844, 13369470.85, 3516410.23]
}
});
});
it("rejects malformed or reversed project extents without losing the fallback path", () => {
expect(parseProjectMapExtent({ bbox: [10, 20, 5, 30] })).toBeNull();
expect(parseProjectMapExtent({ bbox: [0, 0, 30_000_000, 1] })).toBeNull();
expect(parseProjectMapExtent({ xmin: 1, ymin: 2, xmax: 3, ymax: 4 })).toEqual({
bbox3857: [1, 2, 3, 4]
});
});
});

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