From ec23d44b02b7cf84fff08f9cfa0d6ce4b943cf18 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 19 Oct 2026 18:46:58 +0800 Subject: [PATCH] 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. --- .env.example | 2 +- README.md | 13 +- docker-entrypoint.sh | 4 +- docs/ACRYLIC_DESIGN_INVENTORY.md | 7 +- docs/agent-answer-ui.md | 2 + docs/component-design-system.md | 8 +- playwright.config.ts | 2 +- public/runtime-config.js | 1 + src/app/app.e2e.ts | 4 +- src/features/agent/api/agent-messages.test.ts | 33 + src/features/agent/api/agent-messages.ts | 21 + src/features/agent/api/client.test.ts | 53 +- src/features/agent/api/client.ts | 109 +++- .../agent/api/responses-transport.test.ts | 57 ++ src/features/agent/api/responses-transport.ts | 108 ++++ src/features/agent/api/swr.test.ts | 15 +- src/features/agent/api/swr.ts | 9 +- .../agent/components/agent-command-panel.tsx | 585 +++++++++++------- .../agent/components/agent-message-inbox.tsx | 37 ++ .../components/agent-operational-brief.tsx | 135 ---- .../components/agent-ui-envelope-renderer.tsx | 7 +- .../agent/frontend-action/index.test.ts | 7 +- src/features/agent/frontend-action/index.ts | 3 +- .../agent/hooks/use-agent-message-inbox.ts | 44 ++ src/features/agent/index.ts | 15 +- .../api/investigation-presentation.test.ts | 30 + .../api/investigation-presentation.ts | 66 ++ .../workbench/api/investigation-report.ts | 14 +- .../api/operational-task-client.test.ts | 24 + .../workbench/api/operational-task-client.ts | 58 +- .../workbench/api/place-search-client.test.ts | 61 ++ .../workbench/api/place-search-client.ts | 53 ++ .../workbench/api/workbench-runtime-client.ts | 11 +- .../workbench/api/workbench-sync-client.ts | 2 + .../agent-execution-plan-panel.test.tsx | 48 +- .../components/agent-execution-plan-panel.tsx | 13 +- .../workbench/components/geocoding-panel.tsx | 166 +++++ .../components/investigation-diagram-view.tsx | 48 +- .../components/investigation-expand-card.tsx | 132 ++++ .../investigation-presentation-panel.tsx | 55 ++ .../components/investigation-report-view.tsx | 22 +- .../components/investigation-task-report.tsx | 238 ++++--- .../components/map-dev-panel.test.tsx | 33 +- .../workbench/components/map-dev-panel.tsx | 298 +++++---- .../scheduled-condition-detail-panel.tsx | 18 +- .../scheduled-condition-feed-utils.test.ts | 35 ++ .../scheduled-condition-feed-utils.ts | 27 +- .../components/scheduled-condition-feed.tsx | 34 +- .../workbench/components/toolbar-panel.tsx | 19 +- .../components/workbench-top-bar-menus.tsx | 18 +- .../components/workbench-top-bar.tsx | 2 +- .../hooks/agent-session-message-state.test.ts | 13 + .../hooks/agent-session-message-state.ts | 14 + ...nvestigation-presentation-follower.test.ts | 142 +++++ .../investigation-presentation-follower.ts | 162 +++++ .../use-agent-prompt-suggestions.test.ts | 23 + .../hooks/use-agent-prompt-suggestions.ts | 81 +++ .../hooks/use-investigation-presentation.ts | 149 +++++ .../workbench/hooks/use-operational-tasks.ts | 1 + .../hooks/use-report-update-motion.test.tsx | 95 +++ .../hooks/use-report-update-motion.ts | 43 ++ .../workbench/hooks/use-workbench-agent.ts | 171 +++-- .../hooks/use-workbench-geocoding.test.ts | 86 +++ .../hooks/use-workbench-geocoding.ts | 104 ++++ .../hooks/use-workbench-sync-notice.test.ts | 77 +++ .../hooks/use-workbench-sync-notice.ts | 30 + .../workbench/hooks/use-workbench-sync.ts | 17 +- .../hooks/workbench-event-channel.test.ts | 33 + .../hooks/workbench-event-channel.ts | 25 + src/features/workbench/map-workbench-page.tsx | 181 +++++- .../workbench/map/agent-presentation.ts | 10 + .../workbench/map/geocoding-location.ts | 22 + .../map/investigation-map-presentation.ts | 142 +++++ src/features/workbench/map/toolbar-config.ts | 8 +- .../workbench/map/workbench-map-controller.ts | 11 + .../managed-test-resources.tsx | 152 +++-- .../utils/investigation-diagram-style.test.ts | 81 ++- .../utils/investigation-diagram-style.ts | 72 ++- .../utils/investigation-text.test.ts | 29 + .../workbench/utils/investigation-text.ts | 37 ++ .../utils/scheduled-condition-prompts.ts | 18 +- src/shared/auth/keycloak-auth.ts | 10 +- src/shared/config/env.test.ts | 8 +- src/shared/config/env.ts | 2 +- src/shared/utils/date-time.test.ts | 23 + src/shared/utils/date-time.ts | 29 + src/styles.css | 493 +++++++++++---- tests/browser/agent-acrylic.e2e.ts | 96 ++- tests/browser/agent-answer-ui-real.e2e.ts | 14 +- tests/browser/agent-answer-ui.e2e.ts | 15 +- tests/browser/agent-migrated-tools.e2e.ts | 49 ++ tests/browser/agent-panel-resize.e2e.ts | 31 +- tests/browser/agent-product-context.e2e.ts | 92 +++ tests/browser/agent-question.e2e.ts | 25 +- tests/browser/agent-response-inputs.e2e.ts | 136 ++++ tests/browser/agent-select-menu.e2e.ts | 81 ++- tests/browser/agent-start-page.e2e.ts | 153 +++++ .../browser/agent-streaming-stability.e2e.ts | 56 +- tests/browser/agent-voice-hydration.e2e.ts | 2 +- tests/browser/button-system.e2e.ts | 2 +- .../browser/dynamic-investigation-flow.e2e.ts | 90 +-- tests/browser/feature-popover-layout.e2e.ts | 2 +- tests/browser/geocoding.e2e.ts | 231 +++++++ .../investigation-diagram-line-breaks.e2e.ts | 101 +++ .../investigation-diagram-styles.e2e.ts | 2 +- .../investigation-evidence-overflow.e2e.ts | 89 +++ .../investigation-live-presentation.e2e.ts | 276 +++++++++ tests/browser/investigation-main-chat.e2e.ts | 208 +++++++ .../investigation-refresh-motion.e2e.ts | 129 ++++ .../browser/investigation-report-cards.e2e.ts | 237 +++++++ tests/browser/map-dev-panel.e2e.ts | 6 +- tests/browser/non-map-panels-layout.e2e.ts | 18 +- .../browser/operational-task-approval.e2e.ts | 6 +- tests/browser/palette.e2e.ts | 4 +- tests/browser/peripheral-sample-panel.e2e.ts | 100 ++- tests/browser/scada-api-source.e2e.ts | 2 +- .../scheduled-condition-acrylic.e2e.ts | 25 + tests/browser/support/mock-agent-api.ts | 28 +- .../browser/support/mock-workbench-context.ts | 2 +- tests/browser/support/responses-fixture.ts | 42 ++ .../browser/workbench-runtime-backend.e2e.ts | 6 +- tests/browser/workbench-sync-reconnect.e2e.ts | 95 +++ tests/browser/workbench-top-bar-mobile.e2e.ts | 4 +- tests/browser/workbench-visual.e2e.ts | 8 +- ...workbench-desktop-light-chromium-linux.png | Bin 232552 -> 271319 bytes ...bench-desktop-satellite-chromium-linux.png | Bin 250112 -> 281299 bytes ...ench-mobile-agent-light-chromium-linux.png | Bin 114558 -> 114568 bytes ...ile-condition-satellite-chromium-linux.png | Bin 98148 -> 98182 bytes ...h-mobile-launcher-light-chromium-linux.png | Bin 96220 -> 96327 bytes tests/browser/workspace-main-frame.e2e.ts | 2 +- vite.config.ts | 2 +- 131 files changed, 6663 insertions(+), 1204 deletions(-) create mode 100644 src/features/agent/api/agent-messages.test.ts create mode 100644 src/features/agent/api/agent-messages.ts create mode 100644 src/features/agent/api/responses-transport.test.ts create mode 100644 src/features/agent/api/responses-transport.ts create mode 100644 src/features/agent/components/agent-message-inbox.tsx delete mode 100644 src/features/agent/components/agent-operational-brief.tsx create mode 100644 src/features/agent/hooks/use-agent-message-inbox.ts create mode 100644 src/features/workbench/api/investigation-presentation.test.ts create mode 100644 src/features/workbench/api/investigation-presentation.ts create mode 100644 src/features/workbench/api/place-search-client.test.ts create mode 100644 src/features/workbench/api/place-search-client.ts create mode 100644 src/features/workbench/components/geocoding-panel.tsx create mode 100644 src/features/workbench/components/investigation-expand-card.tsx create mode 100644 src/features/workbench/components/investigation-presentation-panel.tsx create mode 100644 src/features/workbench/hooks/investigation-presentation-follower.test.ts create mode 100644 src/features/workbench/hooks/investigation-presentation-follower.ts create mode 100644 src/features/workbench/hooks/use-agent-prompt-suggestions.test.ts create mode 100644 src/features/workbench/hooks/use-agent-prompt-suggestions.ts create mode 100644 src/features/workbench/hooks/use-investigation-presentation.ts create mode 100644 src/features/workbench/hooks/use-report-update-motion.test.tsx create mode 100644 src/features/workbench/hooks/use-report-update-motion.ts create mode 100644 src/features/workbench/hooks/use-workbench-geocoding.test.ts create mode 100644 src/features/workbench/hooks/use-workbench-geocoding.ts create mode 100644 src/features/workbench/hooks/use-workbench-sync-notice.test.ts create mode 100644 src/features/workbench/hooks/use-workbench-sync-notice.ts create mode 100644 src/features/workbench/hooks/workbench-event-channel.test.ts create mode 100644 src/features/workbench/hooks/workbench-event-channel.ts create mode 100644 src/features/workbench/map/agent-presentation.ts create mode 100644 src/features/workbench/map/geocoding-location.ts create mode 100644 src/features/workbench/map/investigation-map-presentation.ts create mode 100644 src/features/workbench/utils/investigation-text.test.ts create mode 100644 src/features/workbench/utils/investigation-text.ts create mode 100644 src/shared/utils/date-time.test.ts create mode 100644 src/shared/utils/date-time.ts create mode 100644 tests/browser/agent-migrated-tools.e2e.ts create mode 100644 tests/browser/agent-product-context.e2e.ts create mode 100644 tests/browser/agent-response-inputs.e2e.ts create mode 100644 tests/browser/agent-start-page.e2e.ts create mode 100644 tests/browser/geocoding.e2e.ts create mode 100644 tests/browser/investigation-diagram-line-breaks.e2e.ts create mode 100644 tests/browser/investigation-evidence-overflow.e2e.ts create mode 100644 tests/browser/investigation-live-presentation.e2e.ts create mode 100644 tests/browser/investigation-main-chat.e2e.ts create mode 100644 tests/browser/investigation-refresh-motion.e2e.ts create mode 100644 tests/browser/investigation-report-cards.e2e.ts create mode 100644 tests/browser/support/responses-fixture.ts create mode 100644 tests/browser/workbench-sync-reconnect.e2e.ts diff --git a/.env.example b/.env.example index f764876..5f247a1 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ TJWATER_TIANDITU_TOKEN= TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_GEOSERVER_WORKSPACE=tjwater_next TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 -TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 +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 diff --git a/README.md b/README.md index f00f9ba..55ec709 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量: +开发环境使用内网认证服务时,仅在被 Git 忽略的 `.env.development.local` 中覆盖 +`TJWATER_KEYCLOAK_ISSUER`,保留 `.env.local` 的 HTTPS issuer。Vite 默认开发模式读取 +此覆盖,正常 production 构建不读取。登录 issuer 必须与 Agent、Server MCP 及 worker +一致;更改后重新加载开发服务器并重新登录,不复用旧 issuer 的令牌。 + | 变量 | 默认值 | 说明 | | ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` | @@ -42,7 +47,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 | `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_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | +| `TJWATER_AGENT_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | | `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | | `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | | `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | @@ -58,7 +63,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。 -业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_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 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。 ## 常用命令 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9bd699b..2e2610d 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -10,7 +10,7 @@ runtime_config=$(jq -cn \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ - --arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ + --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}" \ @@ -28,7 +28,7 @@ runtime_config=$(jq -cn \ TJWATER_MAP_URL: $mapUrl, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, - TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl, + TJWATER_AGENT_URL: $agentApiBaseUrl, TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode, TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl, TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode, diff --git a/docs/ACRYLIC_DESIGN_INVENTORY.md b/docs/ACRYLIC_DESIGN_INVENTORY.md index b5ad775..7f8550d 100644 --- a/docs/ACRYLIC_DESIGN_INVENTORY.md +++ b/docs/ACRYLIC_DESIGN_INVENTORY.md @@ -93,7 +93,7 @@ - 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。 - 当前组件范围: - - Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。 + - Agent 收起侧栏条目、输入操作栏和命令面板快捷入口。 - 图层、量测和通用控制面板中的交互控件。 - 预约任务面板与工况详情中的筛选器、元信息块和次级操作。 - 任务浮条的非活动卡片。 @@ -103,7 +103,6 @@ - 主要文件: - `src/features/agent/components/agent-collapsed-rail.tsx` - `src/features/agent/components/agent-command-panel.tsx` - - `src/features/agent/components/agent-operational-brief.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` @@ -127,14 +126,13 @@ - 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。 - 当前组件范围: - - Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。 + - 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-operational-brief.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` @@ -183,7 +181,6 @@ 建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括: - `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。 -- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。 - `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。 - `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。 - `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。 diff --git a/docs/agent-answer-ui.md b/docs/agent-answer-ui.md index 95e234d..f9f0d30 100644 --- a/docs/agent-answer-ui.md +++ b/docs/agent-answer-ui.md @@ -98,3 +98,5 @@ pnpm test:browser tests/browser/agent-answer-ui-real.e2e.ts --workers=1 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 服务和现有会话数据保留。 diff --git a/docs/component-design-system.md b/docs/component-design-system.md index 432045c..b0fa066 100644 --- a/docs/component-design-system.md +++ b/docs/component-design-system.md @@ -33,7 +33,7 @@ - `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。 - `rose`:失败、冲突或破坏性操作。 -宝石蓝用于操作和选中状态,银灰用于材质层级。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。 +宝石蓝用于工作台操作和选中状态,银灰用于工作台材质层级。Agent 对话区使用独立的低饱和雾蓝灰层次:工作面 `#E8ECF3`、阅读面 `#F8F9FC`、用户消息 `#DDE4F1`,主操作使用岩蓝 `#465F91`。岩蓝与工作台品牌色保持同一视觉语言,同时降低亮度和饱和度,避免输入区显得刺眼。这组颜色按阅读层级和对比度选择,不要求遵循固定的相似色公式,也不扩散到地图和顶部工作台。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。 ## 3. 排版规则 @@ -55,7 +55,9 @@ ## 6. 层级与深度 -保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 延续既有实底布局,使用银灰色调。 +保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 使用雾蓝灰实体底板,导航、历史和底部操作区保留同色调的局部亚克力;正文和文字输入区使用冷白实体阅读面,岩蓝只用于主操作、焦点和交互反馈。文字输入与权限、模型、语音和发送操作栏分开承担材质,聚焦边框仅围绕文字输入区。对话内容直接进入消息流,不在顶部重复展示分析摘要。每个功能区只保留一层模糊,历史展开内容与导航共用材质层。 + +桌面普通空会话使用浅雾蓝灰工作面和冷白文字输入区,居中输入组件距面板两侧至少 12px,最大宽度 600px,文字输入区初始高度 160px、字号 16px;多行输入的高度按可用视口限制,保持操作栏和推荐问题可见。模型根据当前用户与项目的近期任务、告警、分析成果和普通会话生成最多三条问题,在输入组件下方独立显示来源与时间,点击直接发送;首次开始对话时,同一个表单以 280ms 位移和尺寸过渡回到底部,随后使用 48px 的紧凑文字区和 40px 操作控件,减少动态效果偏好下则立即切换。移动端保留紧凑输入布局,生成的问题仍使用独立悬浮胶囊。推荐异步加载,不阻塞输入;打开空会话、切换模型或新建空会话时触发,普通重渲染和宽度变化不重新生成,模型失败时不以固定问题冒充生成结果。 | 材质 | 浅色底图 alpha | 影像底图 alpha | |---|---:|---:| @@ -63,7 +65,7 @@ | 浮动业务面板 | 0.56 | 0.72 | | 地图控件 | 0.74 | 0.86 | | Agent 移动抽屉和历史 | 0.82 | 0.88 | -| Agent 浮动输入区和运行摘要 | 0.86 | 0.90 | +| Agent 浮动输入区 | 0.86 | 0.90 | | 正文阅读面 | 0.96 | 0.985 | 分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。 diff --git a/playwright.config.ts b/playwright.config.ts index 606438c..e16f6d7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ trace: "on-first-retry" }, webServer: { - command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} 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 }, diff --git a/public/runtime-config.js b/public/runtime-config.js index 6d780a6..f887a5f 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -4,6 +4,7 @@ globalThis.__TJWATER_CONFIG__ = { 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" }; diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 88f5bc9..af248a9 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -29,7 +29,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { TJWATER_MAP_URL: expect.any(String), TJWATER_GEOSERVER_WORKSPACE: expect.any(String), TJWATER_SERVER_API_BASE_URL: expect.any(String), - TJWATER_AGENT_API_BASE_URL: expect.any(String) + TJWATER_AGENT_URL: expect.any(String) }); expect(Object.keys(runtimeConfig as Record)).not.toContainEqual( expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/) @@ -49,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(); diff --git a/src/features/agent/api/agent-messages.test.ts b/src/features/agent/api/agent-messages.test.ts new file mode 100644 index 0000000..97bd55b --- /dev/null +++ b/src/features/agent/api/agent-messages.test.ts @@ -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(); +}); diff --git a/src/features/agent/api/agent-messages.ts b/src/features/agent/api/agent-messages.ts new file mode 100644 index 0000000..4838565 --- /dev/null +++ b/src/features/agent/api/agent-messages.ts @@ -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; +export type AgentMessagePage = z.infer; diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index be39449..b64d784 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -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(); }); @@ -85,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 () => { @@ -100,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({ diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index 9b8d083..0c6ecfd 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -1,5 +1,6 @@ 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"; @@ -37,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; @@ -70,6 +74,21 @@ export type AgentChatSessionSummary = { 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 & { sessionId: string; isTitleManuallyEdited: boolean; @@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType = | "session_title" | "done" | "error" - | "auth_required"; + | "auth_required" + | "credential_refresh_required" + | "credential_refreshed" + | "credential_refresh_failed" + | "agent_message" + | "user_input"; export type AgentSessionStreamEvent = | { @@ -112,6 +136,12 @@ export type AgentSessionStreamEvent = }; export type AgentApiClient = { + submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise; + listResponseInputs: (sessionId: string) => Promise; + updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise; + listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise; + streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise; + generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise; createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise; listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise; createTaskCollaborationSession: (taskId: string) => Promise; @@ -143,17 +173,18 @@ export type AgentApiClient = { options: { sessionId: string; answers: string[][] } ) => Promise; rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise; + refreshCredential: (sessionId: string, requestId: string) => Promise; abort: (sessionId: string) => Promise; }; export type AgentApiClientOptions = { getAccessToken?: AccessTokenProvider; - getProjectId?: () => Promise; + getProjectId?: () => Promise; }; -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, @@ -187,6 +218,44 @@ export function createAgentApiClient( }; return { + async submitResponseInput(sessionId, text, delivery, id) { + return requestJson("/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(`/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("/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(); + 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("/session", { method: "POST", @@ -254,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( + `/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) { @@ -329,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("/abort", { method: "POST", @@ -346,7 +438,7 @@ async function fetchWithFallback( path: string, init?: RequestInit, getAccessToken?: AccessTokenProvider, - getProjectId?: () => Promise + getProjectId?: () => Promise ) { const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; let lastError: unknown; @@ -361,7 +453,7 @@ async function fetchWithFallback( 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; @@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea 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" ); } diff --git a/src/features/agent/api/responses-transport.test.ts b/src/features/agent/api/responses-transport.test.ts new file mode 100644 index 0000000..9d9f6da --- /dev/null +++ b/src/features/agent/api/responses-transport.test.ts @@ -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[], 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({ 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) => { + 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[] => [ + { 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: "数据源不可用" }); + }); +}); diff --git a/src/features/agent/api/responses-transport.ts b/src/features/agent/api/responses-transport.ts new file mode 100644 index 0000000..06af575 --- /dev/null +++ b/src/features/agent/api/responses-transport.ts @@ -0,0 +1,108 @@ +import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai"; + +const record = (value: unknown): value is Record => 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): ReadableStream { + 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(); + const parse = (block: string, controller: TransformStreamDefaultController) => { + 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) => { + 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({ + 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 extends HttpChatTransport { + protected processResponseStream(stream: ReadableStream) { + return responsesToUiStream(stream); + } +} diff --git a/src/features/agent/api/swr.test.ts b/src/features/agent/api/swr.test.ts index 62f634c..7678d5d 100644 --- a/src/features/agent/api/swr.test.ts +++ b/src/features/agent/api/swr.test.ts @@ -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 () => ({ diff --git a/src/features/agent/api/swr.ts b/src/features/agent/api/swr.ts index 523645a..e116b46 100644 --- a/src/features/agent/api/swr.ts +++ b/src/features/agent/api/swr.ts @@ -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 { const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([ client.getUiRegistry(), diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index b3f214c..793d3dc 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -4,21 +4,25 @@ import { getAnswerPresentation } from "../answer-ui/answer-presentation"; import { Disclosure } from "@/shared/ui/disclosure"; import { Activity, + ArrowUpRight, CheckCircle2, + ChevronDown, ChevronsUpDown, ChevronLeft, - DatabaseZap, + Folder, + FolderPlus, History, LoaderCircle, - MapPinned, Mic, Pause, Play, Plus, + Quote, SendHorizontal, Shield, ShieldCheck, Square, + UserRound, Volume2, X } from "lucide-react"; @@ -27,6 +31,7 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, @@ -58,12 +63,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/ui/cn"; import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status"; -import type { AgentChatSessionSummary } from "../api/client"; +import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client"; import { AgentPersona } from "./agent-persona"; import { agentPanelItemVariants, @@ -71,6 +79,8 @@ import { agentPanelSectionVariants } from "./agent-motion"; import { AgentHistoryPanel } from "./agent-history-panel"; +import { AgentMessageInbox } from "./agent-message-inbox"; +import type { ComponentProps } from "react"; import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details"; import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer"; import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice"; @@ -101,6 +111,11 @@ export type AgentWorkspaceContextItem = { tone?: "neutral" | "info" | "warning"; }; +export type AgentConversationProjectOption = { + id: string; + name: string; +}; + type AgentCommandPanelProps = { presentation?: AgentCommandPanelPresentation; collapsing?: boolean; @@ -108,20 +123,37 @@ type AgentCommandPanelProps = { sessionTitle?: string; sessionHistory?: AgentChatSessionSummary[]; sessionHistoryLoading?: boolean; + promptSuggestions?: Array<{ question: string; sourceLabel?: string }>; + promptSuggestionsLoading?: boolean; + promptSuggestionsUnavailable?: boolean; activeSessionId?: string | null; qaOnly?: boolean; statusLabel?: string; connectionStatus?: AgentConnectionStatus; streaming?: boolean; + inputDelivery?: AgentInputDelivery; + pendingInputs?: AgentResponseInput[]; + onInputDeliveryChange?: (delivery: AgentInputDelivery) => void; + onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise | void; messages?: AgentChatMessage[]; streamRenderState?: AgentStreamRenderState; modelOptions?: AgentModelOption[]; selectedModel?: string; + conversationScope?: "personal" | "project"; + onConversationScopeChange?: (scope: "personal" | "project") => Promise | void; + conversationProjects?: AgentConversationProjectOption[]; + selectedConversationProjectId?: string | null; + onConversationProjectChange?: (projectId: string) => Promise | void; + currentProjectName?: string | null; + onOpenProjectCreator?: () => void; approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; reviewPanel?: ReactNode; + messageInbox?: ComponentProps; workspaceContext?: AgentWorkspaceContextItem[]; prompt: string; + promptFocusRequest?: number; + promptReference?: { title: string; detail?: string; placeholder?: string }; onCollapse: () => void; onPromptChange: Dispatch>; onRefreshHistory?: () => Promise | void; @@ -142,22 +174,6 @@ type AgentCommandPanelProps = { onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void; }; -const AGENT_PROMPT_SUGGESTIONS = [ - "分析当前管网中的水力瓶颈管道,并给出改造建议。", - "供水服务分区分析。", - "帮我分析当前管网压力异常点,并按风险等级排序。", - "帮我生成一份今日运行简报,包含问题、原因和建议。", - "查询关键 SCADA 点位最近 24 小时的异常波动。", - "排查当前管网爆管风险,并说明优先处置建议。" -] as const; - -const AGENT_CAPABILITIES = [ - { icon: DatabaseZap, label: "水力瓶颈诊断" }, - { icon: Activity, label: "压力异常研判" }, - { icon: ShieldCheck, label: "爆管风险排查" }, - { icon: MapPinned, label: "GIS 地图联动" } -] as const; - export function AgentCommandPanel({ presentation = "desktop-dock", collapsing = false, @@ -165,18 +181,35 @@ export function AgentCommandPanel({ sessionTitle = "新对话", sessionHistory = [], sessionHistoryLoading = false, + promptSuggestions = [], + promptSuggestionsLoading = false, + promptSuggestionsUnavailable = false, activeSessionId, qaOnly = false, connectionStatus = "connecting", streaming = false, + inputDelivery = "steer", + pendingInputs = [], + onInputDeliveryChange, + onPendingInputDeliveryChange, messages = [], streamRenderState = {}, modelOptions = [], selectedModel = "", + conversationScope = "project", + onConversationScopeChange, + conversationProjects = [], + selectedConversationProjectId, + onConversationProjectChange, + currentProjectName, + onOpenProjectCreator, approvalMode = "request", uiResults = [], reviewPanel, + messageInbox, prompt, + promptFocusRequest = 0, + promptReference, onCollapse, onPromptChange, onRefreshHistory, @@ -200,11 +233,31 @@ export function AgentCommandPanel({ const historyTriggerRef = useRef(null); const historyPanelRef = useRef(null); const promptRestoreGenerationRef = useRef(0); + const composerRef = useRef(null); + const promptTextareaRef = useRef(null); + const composerStartRectRef = useRef(null); + const composerTransitionRef = useRef(null); const mountedRef = useRef(true); const desktopFloating = presentation === "desktop-floating"; const floatingPresentation = desktopFloating || presentation === "mobile-sheet"; - const hasConversation = messages.length > 0 || streaming; + const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length); + const centeredComposer = !hasConversation && (presentation === "desktop-dock" || Boolean(promptReference)); + const showStartSuggestions = centeredComposer && !qaOnly && !promptReference; + const prefersReducedMotion = useReducedMotion(); const trimmedPrompt = prompt.trim(); + useEffect(() => { + if (!promptFocusRequest) return; + const frame = requestAnimationFrame(() => { + const textarea = promptTextareaRef.current; + textarea?.focus(); + textarea?.setSelectionRange(textarea.value.length, textarea.value.length); + }); + return () => cancelAnimationFrame(frame); + }, [promptFocusRequest]); + const selectedConversationProject = conversationProjects.find( + (project) => project.id === selectedConversationProjectId + ); + const selectedProjectName = selectedConversationProject?.name ?? currentProjectName ?? "当前项目"; const { speechState, speakingMessageId, @@ -296,12 +349,31 @@ export function AgentCommandPanel({ if (streaming) stopListening(); }, [stopListening, streaming]); + useLayoutEffect(() => { + if (centeredComposer || !composerStartRectRef.current || !composerRef.current) return; + const start = composerStartRectRef.current; + composerStartRectRef.current = null; + const end = composerRef.current.getBoundingClientRect(); + if (prefersReducedMotion || (start.x === end.x && start.y === end.y)) return; + composerTransitionRef.current?.cancel(); + composerTransitionRef.current = composerRef.current.animate( + [ + { transform: `translate(${start.x - end.x}px, ${start.y - end.y}px)` }, + { transform: "translate(0, 0)" } + ], + { duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" } + ); + }, [centeredComposer, prefersReducedMotion]); + const submitPrompt = (nextPrompt: string) => { - if (!nextPrompt || streaming) { + if (!nextPrompt) { return; } const restoreGeneration = promptRestoreGenerationRef.current + 1; promptRestoreGenerationRef.current = restoreGeneration; + if (centeredComposer && !prefersReducedMotion && composerRef.current) { + composerStartRectRef.current = composerRef.current.getBoundingClientRect(); + } onPromptChange(""); stopListening(); setScrollRequestId((requestId) => requestId + 1); @@ -335,6 +407,7 @@ export function AgentCommandPanel({ @@ -666,14 +870,14 @@ function VoiceInputButton({ aria-pressed={isListening} title={label} className={cn( - "group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']", - "border-transparent bg-transparent shadow-none", + "group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border-0 after:absolute after:-inset-1 after:content-['']", + "bg-transparent shadow-none", "transition-[color,background-color,border-color,box-shadow,transform] duration-200", - "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1", + "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0", "disabled:cursor-not-allowed disabled:opacity-45", isListening ? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95" - : "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" + : "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]" )} disabled={disabled} onClick={onClick} @@ -762,6 +966,38 @@ function VoiceInputButton({ const AGENT_SELECT_ITEM_CLASS_NAME = "rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none"; +function InputDeliverySelect({ presentation, label, value, onValueChange }: { + presentation: AgentCommandPanelPresentation; + label: string; + value: AgentInputDelivery; + onValueChange?: (delivery: AgentInputDelivery) => void; +}) { + return + + + + + { + if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery); + }}> + {([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => ( + + {text} + + ))} + + + ; +} + function ApprovalModeSelect({ presentation, value, @@ -780,9 +1016,9 @@ function ApprovalModeSelect({ : null} + {messages.map((message) =>
+
+ {message.direction === "to_chat" ? "调查任务来信" : "发给调查任务"} + {messageStatusLabels[message.status]} +
+

{message.text}

+

task_id: {message.task_id}

+

run_id: {message.run_id}

+ {message.failure_detail ?

{message.failure_detail}

: null} +
)} + {hasMore ? : null} + + ; +} diff --git a/src/features/agent/components/agent-operational-brief.tsx b/src/features/agent/components/agent-operational-brief.tsx deleted file mode 100644 index d16795f..0000000 --- a/src/features/agent/components/agent-operational-brief.tsx +++ /dev/null @@ -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 ( -
-
-
- -

{brief.stateLabel}

-
-

- {brief.task} -

-
- -
-
- -
- - -
-
- ); -} - -function AgentBriefMetric({ - icon, - label, - value -}: { - icon: ReactNode; - label: string; - value: string; -}) { - return ( -
-
- {icon} - {label} -
-
- {value} -
-
- ); -} - -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}”的证据链,按数据来源、风险和不确定性汇总。` - }; -} diff --git a/src/features/agent/components/agent-ui-envelope-renderer.tsx b/src/features/agent/components/agent-ui-envelope-renderer.tsx index 4640f17..dd49743 100644 --- a/src/features/agent/components/agent-ui-envelope-renderer.tsx +++ b/src/features/agent/components/agent-ui-envelope-renderer.tsx @@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) { const { envelope } = result; return ( -
+
@@ -260,7 +260,8 @@ function RegisteredComponentEnvelope({ if (envelope.component === "HistoryPanel") { return ( ); } @@ -268,7 +269,7 @@ function RegisteredComponentEnvelope({ if (envelope.component === "ScadaPanel") { return ( ); } diff --git a/src/features/agent/frontend-action/index.test.ts b/src/features/agent/frontend-action/index.test.ts index c035e82..40bec12 100644 --- a/src/features/agent/frontend-action/index.test.ts +++ b/src/features/agent/frontend-action/index.test.ts @@ -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(); }); diff --git a/src/features/agent/frontend-action/index.ts b/src/features/agent/frontend-action/index.ts index 3573cc9..7363aa4 100644 --- a/src/features/agent/frontend-action/index.ts +++ b/src/features/agent/frontend-action/index.ts @@ -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; issuedAt: number; expiresAt: number }; diff --git a/src/features/agent/hooks/use-agent-message-inbox.ts b/src/features/agent/hooks/use-agent-message-inbox.ts new file mode 100644 index 0000000..dd3978b --- /dev/null +++ b/src/features/agent/hooks/use-agent-message-inbox.ts @@ -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 | 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); } + } + }; +} diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts index c92da39..433c0f9 100644 --- a/src/features/agent/index.ts +++ b/src/features/agent/index.ts @@ -3,26 +3,35 @@ export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type 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"; @@ -83,8 +92,10 @@ export type { } from "./types"; export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data"; -export { AgentChartView } from "./components/agent-ui-envelope-renderer"; 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"; diff --git a/src/features/workbench/api/investigation-presentation.test.ts b/src/features/workbench/api/investigation-presentation.test.ts new file mode 100644 index 0000000..5cc7f6d --- /dev/null +++ b/src/features/workbench/api/investigation-presentation.test.ts @@ -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); + }); +}); diff --git a/src/features/workbench/api/investigation-presentation.ts b/src/features/workbench/api/investigation-presentation.ts new file mode 100644 index 0000000..0109158 --- /dev/null +++ b/src/features/workbench/api/investigation-presentation.ts @@ -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>>((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((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; +export type InvestigationPresentationEvent = z.infer; +export type InvestigationPresentationCursor = z.infer; +export type InvestigationOverlay = Extract & { visible: boolean }; +export type InvestigationOverlayData = FeatureCollection; diff --git a/src/features/workbench/api/investigation-report.ts b/src/features/workbench/api/investigation-report.ts index de4f8c2..c749643 100644 --- a/src/features/workbench/api/investigation-report.ts +++ b/src/features/workbench/api/investigation-report.ts @@ -1,9 +1,18 @@ import { z } from "zod"; import type { AgentTaskDiscussionContext } from "@/features/agent"; -export type InvestigationDiscussion = { prompt?: string; context?: AgentTaskDiscussionContext }; +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, @@ -23,6 +32,8 @@ export const investigationDiagramSchema = 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([]) }) @@ -91,3 +102,4 @@ export const investigationReportSchema = z export type InvestigationReport = z.infer; export type InvestigationDiagram = z.infer; +export type InvestigationMapTarget = z.infer; diff --git a/src/features/workbench/api/operational-task-client.test.ts b/src/features/workbench/api/operational-task-client.test.ts index 02e99c3..a2d81f6 100644 --- a/src/features/workbench/api/operational-task-client.test.ts +++ b/src/features/workbench/api/operational-task-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + fetchOperationalTaskEvidenceSnapshot, fetchOperationalTaskNotifications, parseOperationalTask, parseOperationalTaskNotification @@ -53,6 +54,14 @@ describe("operational task contract", () => { 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({ @@ -133,3 +142,18 @@ describe("operational notification contract", () => { 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(); +}); diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts index 7adc59f..8d0fe25 100644 --- a/src/features/workbench/api/operational-task-client.ts +++ b/src/features/workbench/api/operational-task-client.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { investigationReportSchema } from "./investigation-report"; +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"; @@ -45,6 +46,7 @@ const taskSummarySchema = z.object({ 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(), @@ -115,6 +117,14 @@ const reportRevisionSchema = z.object({ 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(), @@ -212,6 +222,7 @@ const externalWorkOrderSnapshotSchema = z.preprocess((value) => { 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([]), @@ -269,7 +280,43 @@ const scheduleSchema = z.object({ }).strict(); const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict(); const taskDataSnapshotSchema = z.object({ - source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data"]), + // 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"]), @@ -286,6 +333,7 @@ export type OperationalTaskEvidence = z.infer; export type OperationalTaskNotification = z.infer; export type OperationalTaskReport = z.infer; export type OperationalTaskReportPage = z.infer; +export type OperationalTaskProgress = z.infer; export type OperationalTaskSchedule = z.infer; export type OperationalTaskDataSnapshot = z.infer; export type ExternalWorkOrderSnapshot = z.infer; @@ -342,6 +390,12 @@ export async function fetchOperationalTaskReports(taskId: string, offset: number )); } +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", diff --git a/src/features/workbench/api/place-search-client.test.ts b/src/features/workbench/api/place-search-client.test.ts new file mode 100644 index 0000000..cdbbb52 --- /dev/null +++ b/src/features/workbench/api/place-search-client.test.ts @@ -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(); + }); +}); diff --git a/src/features/workbench/api/place-search-client.ts b/src/features/workbench/api/place-search-client.ts new file mode 100644 index 0000000..c2b23ca --- /dev/null +++ b/src/features/workbench/api/place-search-client.ts @@ -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; +export type PlaceSearchResults = z.infer; + +export async function searchPlaces( + keyword: string, + projectId: string | null, + getAccessToken?: AccessTokenProvider, + signal?: AbortSignal +): Promise { + 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; +} diff --git a/src/features/workbench/api/workbench-runtime-client.ts b/src/features/workbench/api/workbench-runtime-client.ts index 2b50d5a..e7f7a5f 100644 --- a/src/features/workbench/api/workbench-runtime-client.ts +++ b/src/features/workbench/api/workbench-runtime-client.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { FeatureCollection, Geometry, Position } from "geojson"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; +import { formatDateTime } from "@/shared/utils/date-time"; import type { ScheduledConditionRecord, ScheduledWorkOrderItem, @@ -427,8 +428,7 @@ function getStatusTone(status: string) { } function formatRuntimeAlertTime(value: string) { - const date = new Date(value); - return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; + return formatRuntimeDateTime(value).replace("/", "-"); } function getConditionDataSource( @@ -452,13 +452,12 @@ function resolveOperationalRuntimeSource( } function formatRuntimeDateTime(value: string) { - return new Intl.DateTimeFormat("zh-CN", { + return formatDateTime(value, { month: "2-digit", day: "2-digit", hour: "2-digit", - minute: "2-digit", - hour12: false - }).format(new Date(value)); + minute: "2-digit" + }); } function isGeoJsonFeatureCollection(value: unknown): value is FeatureCollection { diff --git a/src/features/workbench/api/workbench-sync-client.ts b/src/features/workbench/api/workbench-sync-client.ts index 1f1db24..66cd5f5 100644 --- a/src/features/workbench/api/workbench-sync-client.ts +++ b/src/features/workbench/api/workbench-sync-client.ts @@ -34,6 +34,8 @@ const eventSchema = z.object({ "operational.run.queued", "operational.run.changed", "operational.report.published", + "operational.progress.changed", + "operational.presentation.requested", "operational.action.changed", "operational.notification.changed", "workbench.alert.changed", diff --git a/src/features/workbench/components/agent-execution-plan-panel.test.tsx b/src/features/workbench/components/agent-execution-plan-panel.test.tsx index 627de21..9df5b5f 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.test.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import { type AgentExecutionPlan } from "@/features/agent"; @@ -130,6 +130,25 @@ function taskPanel(task: OperationalTask, overrides: Partial { + const task = { ...taskBase, execution_generation: 2 }; + const { rerender, ...props } = taskPanel(task); + const button = screen.getByRole("button", { name: "Agent 协作" }); + expect(button).toHaveAttribute("title", "打开调查协作对话,引用此任务,不会直接发送。"); + fireEvent.click(button); + expect(props.onContinue).toHaveBeenLastCalledWith({ + context: { report_revision: task.latest_report!.revision }, + reportGeneration: task.latest_report!.execution_generation + }); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Agent 协作" })); + expect(props.onContinue).toHaveBeenLastCalledWith({ + context: { report_revision: task.latest_report!.revision }, reportGeneration: null + }); +}); + describe("operational task approval and evidence", () => { it("shows the selected v2 route first and keeps unchosen work available", () => { const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({ @@ -294,7 +313,8 @@ describe("operational task approval and evidence", () => { onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()} onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()} />); - fireEvent.click(screen.getByRole("button", { name: "查看新报告" })); + // Live reading follows published reports without requiring an update click. + expect(screen.queryByRole("button", { name: "查看新报告" })).not.toBeInTheDocument(); expect(screen.getByText(/正在阅读:历史报告/)).toBeVisible(); }); @@ -347,11 +367,24 @@ describe("operational task approval and evidence", () => { task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }} {...props} />); + // An unassigned historical plan cannot supply a numbered report's diagram. + expect(screen.queryByRole("heading", { name: "调查脉络" })).not.toBeInTheDocument(); + expect(within(screen.getByRole("region", { name: "任务报告" })) + .getByText(taskBase.latest_report!.report.current_conclusion, { exact: true })).toBeVisible(); + + rerender(); + // The legacy relationship remains readable when both records lack a round. expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible(); expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument(); }); - it("shows exact payload and evidence metadata to a viewer without action controls", () => { + it("shows exact payload and evidence metadata to a viewer without action controls", async () => { taskPanel(taskBase); expect(screen.getByText("工单标题")).toBeVisible(); expect(screen.getByText("位置")).toBeVisible(); @@ -363,9 +396,10 @@ describe("operational task approval and evidence", () => { expect(screen.getByText(/当前账号仅可查看/)).toBeVisible(); expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "批准创建预约工单" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByLabelText("主要依据").querySelector("summary")!); - expect(screen.getByText("SCADA 水质监测")).toBeVisible(); - expect(screen.getByText("系统数据")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "展开主要依据" })); + fireEvent.click(screen.getByRole("dialog", { name: "主要依据" }).querySelector("summary")!); + await waitFor(() => expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("SCADA 水质监测")).toBeVisible()); + expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("系统数据")).toBeVisible(); }); it("keeps pending state after a decision network error", async () => { @@ -449,7 +483,7 @@ describe("operational task approval and evidence", () => { actions: [completedAction] }); - fireEvent.click(screen.getByText("任务时间线")); + fireEvent.click(screen.getByRole("button", { name: "展开任务时间线" })); const items = [...screen.getByLabelText("任务时间线记录").querySelectorAll("li")]; expect(items.map((item) => item.textContent)).toEqual([ expect.stringContaining("预约工单已创建,等待现场处理"), diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx index be31706..86ef04a 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -1,4 +1,5 @@ import { InvestigationReportView } from "./investigation-report-view"; +import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time"; import { InvestigationTaskReport } from "./investigation-task-report"; import type { InvestigationDiscussion } from "../api/investigation-report"; import type { OperationalTaskReportPage } from "../api/operational-task-client"; @@ -61,6 +62,7 @@ export type ExecutionPlanView = { }; type AgentExecutionPlanPanelProps = { + onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void; task: OperationalTask; onContinue: (discussion?: InvestigationDiscussion) => void; onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; @@ -77,6 +79,7 @@ type AgentExecutionPlanPanelProps = { }; export function AgentExecutionPlanPanel({ + onLocate, task, onContinue, onDecision, @@ -92,7 +95,7 @@ export function AgentExecutionPlanPanel({ focusActionId }: AgentExecutionPlanPanelProps) { if (task.task_type === "event_investigation") return } renderEvidence={(item) => } @@ -412,7 +415,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); } finally { setLoading(false); } }; - return {item.summary}} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5"> + return {item.summary}} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
{item.source_ref ? : null}
{item.has_snapshot && !snapshot ? : null} @@ -424,7 +427,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) { const rows = snapshotRows(snapshot.data).slice(0, 12); - return

{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}

{rows.map(([key, value]) => )}
{snapshot.truncated ?

数据已按有界查询范围截断。

: null}
; + return

{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}

{rows.map(([key, value]) => )}
{snapshot.truncated ?

数据已按有界查询范围截断。

: null}
; } function snapshotRows(value: unknown, prefix = ""): [string, string][] { @@ -434,7 +437,7 @@ function snapshotRows(value: unknown, prefix = ""): [string, string][] { } function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { - return
{label}
{value}
; + return
{label}
{value}
; } export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: { @@ -662,7 +665,7 @@ function dataScopeLabel(scope: string) { return ({ } as Record)[scope] ?? scope; } function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; } function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; } -function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); } +function formatDateTime(value: string) { return formatDisplayDateTime(value, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function toLocalDateTime(value: unknown) { if (typeof value !== "string") return ""; const date = new Date(value); diff --git a/src/features/workbench/components/geocoding-panel.tsx b/src/features/workbench/components/geocoding-panel.tsx new file mode 100644 index 0000000..2e1c330 --- /dev/null +++ b/src/features/workbench/components/geocoding-panel.tsx @@ -0,0 +1,166 @@ +import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react"; +import { useId, useLayoutEffect, useRef } from "react"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { cn } from "@/shared/ui/cn"; +import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding"; +import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-search-client"; + +const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]"; + +function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) { + return ( + <> + {index !== undefined ? : null} + + + {item.name} + {annotation} + + {item.address} + + + ); +} + +export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding }) { + const inputId = useId(); + const statusId = useId(); + const candidateListRef = useRef(null); + const changeSelectionRef = useRef(null); + const { address, result, results, pending, error, enabled } = geocoding; + const selectionCollapsed = Boolean(result && !geocoding.choosing); + const hideSelectionHint = Boolean(results?.items.length && enabled && !error); + useLayoutEffect(() => { + if (selectionCollapsed || !result) return; + const list = candidateListRef.current; + const selected = list?.querySelector('button[aria-pressed="true"]'); + if (!list || !selected) return; + const viewport = list.getBoundingClientRect(); + const row = selected.getBoundingClientRect(); + list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2; + }, [selectionCollapsed, result, results]); + const status = error + ?? (pending ? "正在搜索地名…" + : !enabled ? "等待地图加载后即可定位" + : result ? "已在地图标出所选位置" + : results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字" + : "搜索奉贤区内地址或地名,支持回车查询"); + + return ( +
+
{ + event.preventDefault(); + void geocoding.search(); + }} + > + +
+ geocoding.changeAddress(event.target.value)} + placeholder="道路、门牌号或地名" + className="h-10 min-w-0 flex-1 rounded-xl border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs" + autoComplete="street-address" + maxLength={120} + aria-invalid={Boolean(error)} + aria-describedby={statusId} + /> + +
+
+ {result && !error ?
+
+ {results && results.items.length > 0 ? ( +
+
+ {selectionCollapsed ? "已选位置" : "选择位置"} + {selectionCollapsed ? ( + + ) : {results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}} +
+ {!selectionCollapsed ?
    + {results.items.map((item, index) => ( +
  • + +
  • + ))} +
: null} + {result && selectionCollapsed ? ( +
+
+
+
+ +
+
+
经度 / 纬度
+
+ {`${result.longitude.toFixed(6)}, ${result.latitude.toFixed(6)}`} +
+
+
+
+
+ + +
+
+ ) : null} +

+ {selectionCollapsed ? <>

+
+ ) : null} +
+ ); +} diff --git a/src/features/workbench/components/investigation-diagram-view.tsx b/src/features/workbench/components/investigation-diagram-view.tsx index d60d013..7e228ac 100644 --- a/src/features/workbench/components/investigation-diagram-view.tsx +++ b/src/features/workbench/components/investigation-diagram-view.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react"; -import { GitBranch, Maximize2, RotateCcw, X, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; +import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; import { MessageResponse } from "@/shared/ai-elements/message"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/ui/cn"; -import type { InvestigationDiagram } from "../api/investigation-report"; +import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report"; +import { InvestigationExpandCard } from "./investigation-expand-card"; import { INVESTIGATION_DIAGRAM_FONT, investigationDiagramConfig, @@ -26,13 +27,10 @@ function controlIcon(Icon: LucideIcon, control: string) { }; } -// The icon markers also identify controls in Streamdown's fullscreen portal. const diagramControlIcons = { ZoomInIcon: controlIcon(ZoomIn, "zoom-in"), ZoomOutIcon: controlIcon(ZoomOut, "zoom-out"), - RotateCcwIcon: controlIcon(RotateCcw, "reset"), - Maximize2Icon: controlIcon(Maximize2, "fullscreen"), - XIcon: controlIcon(X, "close") + RotateCcwIcon: controlIcon(RotateCcw, "reset") }; function DiagramUnavailable() { @@ -49,11 +47,15 @@ const getNodeId = (node: Element | null | undefined) => export function InvestigationDiagramView({ diagram, renderDetails, - onAsk + onAsk, + running = false, + onLocate }: { diagram: InvestigationDiagram; renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode; onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void; + running?: boolean; + onLocate?: (targets: InvestigationMapTarget[]) => void; }) { const [selectedNodeId, setSelectedNodeId] = useState(null); const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null); @@ -74,12 +76,8 @@ export function InvestigationDiagramView({ active = false; }; }, [diagram.mermaid]); - return ( -
-

- - {diagram.title} -

+ const content = (close: (restoreFocus?: boolean) => void) => ( + <>

核查方向与判断分支。可放大查看,点击节点阅读关联依据。

@@ -108,22 +106,21 @@ export function InvestigationDiagramView({ {`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``} + >{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram, running)}\n\`\`\``} ) : (

正在排版调查图示。

)}
+ {running && diagram.node_refs.some((node) => node.status === "running") ? ( +

蓝色:正在执行

+ ) : null} {diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (

{selected.label}

+ {selected.status ?

{!running && selected.status === "running" ? "记录时执行中" : { + pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消" + }[selected.status]}

: null} {renderDetails(selected)} + {onLocate && selected.map_targets?.length ? : null} {onAsk ? ( - ) : null}
) : null} - + + ); + return ( + + {content} + ); } diff --git a/src/features/workbench/components/investigation-expand-card.tsx b/src/features/workbench/components/investigation-expand-card.tsx new file mode 100644 index 0000000..0c32e6d --- /dev/null +++ b/src/features/workbench/components/investigation-expand-card.tsx @@ -0,0 +1,132 @@ +import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react"; +import { createPortal } from "react-dom"; +import * as Dialog from "@radix-ui/react-dialog"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Maximize2, X } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/ui/cn"; + +/** Report-specific progressive disclosure. The source stays mounted while reading. */ +export function InvestigationExpandCard({ + title, label = title, preview, children, wide = false, onOpen, contentRef, + triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false +}: { + title: string; + label?: string; + preview: ReactNode; + children: ReactNode | ((close: (restoreFocus?: boolean) => void) => ReactNode); + wide?: boolean; + onOpen?: () => void; + contentRef?: Ref; + triggerLabel?: string; + className?: string; + /** Freeze versioned prose while reading; live controls must not use this. */ + revision?: string; + disabled?: boolean; + /** Move one live content host between slots, preserving expensive renderers. */ + reuseContent?: boolean; +}) { + const id = useId(); + const triggerRef = useRef(null); + const restoreFocusRef = useRef(true); + const [open, setOpen] = useState(false); + const [keyboard, setKeyboard] = useState(false); + const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null); + const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null); + const previewSlot = useRef(null); + const attachPreview = useCallback((slot: HTMLDivElement | null) => { + previewSlot.current = slot; + if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost); + }, [contentHost]); + const attachDetail = useCallback((slot: HTMLDivElement | null) => { + const preview = previewSlot.current; + if (!preview || !contentHost) return; + if (slot) { + // Reserve only the vacated inline space while expanded. The graph itself + // keeps its natural height, including after content updates or resizing. + // Use the layout height, not a bounding box scaled by the card animation. + preview.style.height = getComputedStyle(preview).height; + slot.appendChild(contentHost); + } else { + preview.appendChild(contentHost); + preview.style.removeProperty("height"); + } + }, [contentHost]); + const reduced = useReducedMotion(); + const duration = reduced || keyboard ? 0 : 0.24; + const transition = { duration, ease: [0.22, 1, 0.36, 1] as const }; + const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); }; + const content = reading?.content ?? children; + + return ( + { + setOpen(next); + if (next) { + restoreFocusRef.current = true; + setReading(revision === undefined ? null : { revision, content: children }); + onOpen?.(); + } + }}> + +
+

{title}

+ + + +
+ {/* Contain child margins so the occupied and vacated slots measure alike. */} + {contentHost ?
: preview} + + {contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null} + + + {open ? ( + + + + ) : null} + {open ? ( +
+ { event.stopPropagation(); setKeyboard(true); }} + onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}> + +
+ {title} + + + +
+
+ {contentHost ?
: typeof content === "function" ? content(close) : content} +
+ {reading && revision !== undefined && reading.revision !== revision ? ( +
+ 已有更新,当前保留正在阅读的版本。 + +
+ ) : null} + + +
+ ) : null} + + + + ); +} diff --git a/src/features/workbench/components/investigation-presentation-panel.tsx b/src/features/workbench/components/investigation-presentation-panel.tsx new file mode 100644 index 0000000..fbc1867 --- /dev/null +++ b/src/features/workbench/components/investigation-presentation-panel.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { Eye, EyeOff, LocateFixed, X } from "lucide-react"; +import { AgentChartView } from "@/features/agent"; +import { Button } from "@/shared/ui/button"; +import { Disclosure } from "@/shared/ui/disclosure"; +import type { OperationalTask } from "../api/operational-task-client"; +import type { InvestigationOverlay } from "../api/investigation-presentation"; +import type { InvestigationChart } from "../hooks/use-investigation-presentation"; + +type Props = { + task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null; + cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null; + width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>; + onResumeCamera: () => void; onCloseChart: (id: string) => void; + onToggleOverlay: () => void; onFitOverlay: () => void; + onToggleLayer: (id: string, visible: boolean) => void; +}; + +export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers, + onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) { + const [collapsed, setCollapsed] = useState(false); + const references = (ids: string[]) => + {ids.map((id) =>

{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}

)} +
; + return ; +} diff --git a/src/features/workbench/components/investigation-report-view.tsx b/src/features/workbench/components/investigation-report-view.tsx index e43b8ca..67eeb09 100644 --- a/src/features/workbench/components/investigation-report-view.tsx +++ b/src/features/workbench/components/investigation-report-view.tsx @@ -1,5 +1,7 @@ import { FileText } from "lucide-react"; import { StatusBadge } from "@/shared/ui/status"; +import { formatDateTime } from "@/shared/utils/date-time"; +import { formatInvestigationText } from "../utils/investigation-text"; import type { OperationalTask } from "../api/operational-task-client"; export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) { @@ -24,25 +26,25 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation

{!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"}

-

{report.current_conclusion}

- {insufficient ?

结束原因:{report.stop_reason}

: null} +

{formatInvestigationText(report.current_conclusion)}

+ {insufficient ?

结束原因:{formatInvestigationText(report.stop_reason)}

: null}
{report.data_gaps && report.data_gaps.length > 0 ?

{insufficient ? "关键数据缺口" : "数据范围限制"}

{report.data_gaps.map((gap) =>
-

{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}

-

查询范围:{gap.scope}

-

判断影响:{gap.impact}

-

需补齐:{gap.required_data}

-

依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}

+

{formatInvestigationText(gap.missing_data)}{gap.blocking ? " · 阻止形成结论" : ""}

+

查询范围:{formatInvestigationText(gap.scope)}

+

判断影响:{formatInvestigationText(gap.impact)}

+

需补齐:{formatInvestigationText(gap.required_data)}

+

依据:{formatInvestigationText(gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";"))}

)}
: report.data_gaps == null ?

历史记录未提供结构化数据缺口。

: null} {!insufficient && report.recommendations.length > 0 ? : null} {!insufficient && report.pending_items.length > 0 ? : null} {insufficient ?

补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。

: null}
-

结论边界:{report.conclusion_boundary}

-

报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}

+

结论边界:{formatInvestigationText(report.conclusion_boundary)}

+

报告第 {snapshot.revision} 版 · {formatDateTime(snapshot.published_at)}

:

尚未形成任务报告。

} ; @@ -50,6 +52,6 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation function ReportItems({ title, items }: { title: string; items: string[] }) { return

{title}

-
    {items.map((item, index) =>
  • {item}
  • )}
+
    {items.map((item, index) =>
  • {formatInvestigationText(item)}
  • )}
; } diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx index 0b3a95d..b92345a 100644 --- a/src/features/workbench/components/investigation-task-report.tsx +++ b/src/features/workbench/components/investigation-task-report.tsx @@ -1,9 +1,11 @@ import { useEffect, useState, type ReactNode } from "react"; -import { FileText, History, MessageSquare } from "lucide-react"; +import { MessageSquare } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { Disclosure } from "@/shared/ui/disclosure"; import { StatusBadge } from "@/shared/ui/status"; -import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report"; +import { formatDateTime as dateLabel } from "@/shared/utils/date-time"; +import { formatInvestigationText } from "../utils/investigation-text"; +import type { InvestigationDiagram, InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report"; import type { OperationalTask, OperationalTaskAction, @@ -17,6 +19,8 @@ import { isPendingInvestigationAction } from "../utils/investigation-presentation"; import { InvestigationDiagramView } from "./investigation-diagram-view"; +import { InvestigationExpandCard } from "./investigation-expand-card"; +import { useReportUpdateMotion } from "../hooks/use-report-update-motion"; type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null }; type Props = { @@ -27,9 +31,9 @@ type Props = { onLoadReports?: (offset: number) => Promise; onReportViewed?: (generation: number, revision: number) => void; focusActionId?: string | null; + onLocate?: (targets: InvestigationMapTarget[]) => void; }; -const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false }); const roundLabel = (snapshot: OperationalTaskReport) => snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`; const actionTitle = (action: OperationalTaskAction) => @@ -123,27 +127,35 @@ export function InvestigationTaskReport({ renderEvidence, onLoadReports, onReportViewed, - focusActionId + focusActionId, + onLocate }: Props) { - const [selection, setSelection] = useState(() => - task.latest_report - ? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) } - : null - ); + // Null follows live task content. Only a deliberate history selection freezes a report. + const [selection, setSelection] = useState(null); const [history, setHistory] = useState([]); const [historyOffset, setHistoryOffset] = useState(0); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); - const snapshot = selection?.snapshot ?? null; + const live = selection === null; + const snapshot = selection?.snapshot ?? task.latest_report; const report = snapshot?.report; - const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null)); + const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null; + // A current final report owns its archived graph. During investigation, a + // progress snapshot owns its whole graph, including an explicit null. A prior + // round's final report must never hide the new round's progress. + const currentFinalReport = snapshot?.execution_generation === task.execution_generation + && report?.stage === "final"; + const diagram = live + ? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot) + : selection.diagram; + const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running"); const previousRound = Boolean( snapshot && snapshot.execution_generation !== task.execution_generation ); const olderRevision = Boolean( snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision ); - const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision)); + const latestAvailable = !live; const insufficient = report?.outcome === "insufficient_data"; const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation && task.latest_report?.report.outcome === "insufficient_data"; @@ -153,14 +165,8 @@ export function InvestigationTaskReport({ const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3); const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3); const timeline = investigationTimeline(task, snapshot); - - useEffect(() => { - if (!selection && task.latest_report) - setSelection({ - snapshot: task.latest_report, - diagram: investigationDiagram(task, task.latest_report) - }); - }, [selection, task]); + const reportRef = useReportUpdateMotion(task.task_id, + `${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live); useEffect(() => { if (snapshot?.execution_generation == null || !onReportViewed) return; @@ -182,6 +188,7 @@ export function InvestigationTaskReport({ const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) => onContinue({ prompt, + reportGeneration: snapshot?.execution_generation, context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra } }); const selectReport = (item: OperationalTaskReport) => @@ -202,7 +209,7 @@ export function InvestigationTaskReport({ }; return ( -
+

@@ -227,8 +234,9 @@ export function InvestigationTaskReport({

) : null} -
+
-

- - 当前结果 -

{report ? ( {insufficient @@ -282,37 +287,42 @@ export function InvestigationTaskReport({ ? "调查结论" : "历史报告内容"}

-

{report.current_conclusion}

+

{formatInvestigationText(report.current_conclusion)}

- 结论边界:{report.conclusion_boundary} + 结论边界:{formatInvestigationText(report.conclusion_boundary)}

{insufficient ? (

- 结束原因:{report.stop_reason} + 结束原因:{formatInvestigationText(report.stop_reason)}

) : null}

正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "} {dateLabel(snapshot.published_at)}

- {!insufficient && - (report.recommendations.length > 0 || report.pending_items.length > 0) ? ( - - {report.recommendations.length > 0 ? ( - - ) : null} - {report.pending_items.length > 0 ? ( - - ) : null} - - ) : null} ) : (

尚未发布本轮调查报告。已保存的调查资料可在下方查看。

)} -
+ }> + {report && snapshot ? <> +

{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 · {dateLabel(snapshot.published_at)}

+

{formatInvestigationText(report.current_conclusion)}

+

结论边界:{formatInvestigationText(report.conclusion_boundary)}

+ {insufficient ?

结束原因:{formatInvestigationText(report.stop_reason)}

: <> + {report.recommendations.length > 0 ? : null} + {report.pending_items.length > 0 ? : null} + } + :

尚未发布本轮调查报告。

} + + + {live ?
+

当前调查进展

+

{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}

+ {progress ?

实时内容 · 第 {progress.execution_generation} 轮 · 更新 {dateLabel(progress.published_at)}

: null} +
: null} {pendingActions.length > 0 ? (
@@ -387,13 +397,13 @@ export function InvestigationTaskReport({ {report.data_gaps.map((gap) => (
-

{gap.missing_data}

-

判断影响:{gap.impact}

+

{formatInvestigationText(gap.missing_data)}

+

判断影响:{formatInvestigationText(gap.impact)}

-

查询范围:{gap.scope}

-

需补齐:{gap.required_data}

+

查询范围:{formatInvestigationText(gap.scope)}

+

需补齐:{formatInvestigationText(gap.required_data)}

{gap.evidence_refs.map((id) => ( discuss( - `我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`, + `我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`, { gap_id: gap.gap_id } ) } @@ -433,8 +443,10 @@ export function InvestigationTaskReport({ {diagram ? ( ( <> {node.evidence_refs.map((id) => ( @@ -473,22 +485,43 @@ export function InvestigationTaskReport({ /> ) : null} -
-

主要依据

+ +
+ {majorEvidence.map((item) =>
+

{formatInvestigationText(item.summary)}

+

{item.source_label} · {dateLabel(item.observed_at ?? item.recorded_at)}

+ +
)} +
+

+ {(snapshot ? evidence : task.evidence).length > 0 + ? `共 ${(snapshot ? evidence : task.evidence).length} 条引用依据,展开查看来源与原始记录。` + : "尚无可展示的引用证据。"} +

+ }> + {(close) => <> {majorEvidence.map((item) => (
{renderEvidence(item)} - + ) : null} + +
))} {(snapshot ? evidence : task.evidence).length === 0 ? ( @@ -499,17 +532,20 @@ export function InvestigationTaskReport({ {remainingEvidence.map((item) => (
{renderEvidence(item)} - +
))} @@ -522,45 +558,31 @@ export function InvestigationTaskReport({ .map(renderEvidence)} ) : null} -
+ } + -
- -

- 按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。 -

-
    - {timeline.map((item) => ( -
  1. - -

    {item.title}

    -

    {item.detail}

    -
  2. - ))} -
-
+ + {timeline[0] ? `${timeline[0].title} · ${dateLabel(timeline[0].occurredAt)},共 ${timeline.length} 条记录。` : "尚无调查记录。"} +

+ }> + +
{onLoadReports ? ( - - - 报告版本 - - } - onToggle={(event) => { - if (event.currentTarget.open && history.length === 0 && !historyLoading) - void loadHistory(0); - }} + + {snapshot ? `${roundLabel(snapshot)} · 报告第 ${snapshot.revision} 版 · ${dateLabel(snapshot.published_at)}` : "尚未发布报告。"} +

} + onOpen={() => { if (history.length === 0 && !historyLoading) void loadHistory(0); }} > + {(close) => <> {history.map((item) => ( ) : null} -
+ } + ) : null} -

+

任务更新于 {dateLabel(task.updated_at)}

-
); } +function InvestigationTimeline({ items }: { items: InvestigationTimelineItem[] }) { + return <> +

按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。

+
    + {items.map((item) =>
  1. + +

    {item.title}

    +

    {formatInvestigationText(item.detail)}

    +
  2. )} +
+ {items.length === 0 ?

尚无调查记录。

: null} + ; +} + function EvidenceReference({ id, task, @@ -626,7 +662,7 @@ function ReportItems({ title, items }: { title: string; items: string[] }) {

{title}

    {items.map((item, index) => ( -
  • {item}
  • +
  • {formatInvestigationText(item)}
  • ))}
diff --git a/src/features/workbench/components/map-dev-panel.test.tsx b/src/features/workbench/components/map-dev-panel.test.tsx index 22d73df..c0a3b24 100644 --- a/src/features/workbench/components/map-dev-panel.test.tsx +++ b/src/features/workbench/components/map-dev-panel.test.tsx @@ -9,7 +9,25 @@ import { MapDevPanel } from "./map-dev-panel"; afterEach(cleanup); -describe("MapDevPanel flow controls", () => { +describe("MapDevPanel", () => { + it("defaults to following investigations and allows disabling it", async () => { + const user = userEvent.setup(); + const onFollowChange = vi.fn(); + render( + + ); + const follow = screen.getByRole("checkbox", { name: "跟随所选调查" }); + expect(follow).toBeChecked(); + await user.click(follow); + expect(onFollowChange).toHaveBeenCalledWith(false); + }); + it("updates a pipe with its signed velocity", async () => { const user = userEvent.setup(); const commands = createCommands(); @@ -23,6 +41,7 @@ describe("MapDevPanel flow controls", () => { /> ); + await user.click(screen.getByText("水流调试")); const pipeId = screen.getByLabelText("管道 ID"); const velocity = screen.getByLabelText("有符号流速"); await user.clear(pipeId); @@ -37,7 +56,8 @@ describe("MapDevPanel flow controls", () => { }); }); - it("shows the current flow direction counts", () => { + it("shows the current flow direction counts", async () => { + const user = userEvent.setup(); render( { /> ); - expect(screen.getByRole("status", { - name: "" - })).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1"); + await user.click(screen.getByText("水流调试")); + expect( + screen.getByRole("status", { + name: "" + }) + ).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1"); }); }); diff --git a/src/features/workbench/components/map-dev-panel.tsx b/src/features/workbench/components/map-dev-panel.tsx index 063a033..2c73789 100644 --- a/src/features/workbench/components/map-dev-panel.tsx +++ b/src/features/workbench/components/map-dev-panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; -import { cn } from "@/shared/ui/cn"; +import { Disclosure } from "@/shared/ui/disclosure"; import { FieldSelect } from "@/shared/ui/field-select"; import { Input } from "@/shared/ui/input"; import { @@ -30,6 +30,8 @@ type MapDevPanelProps = { detailFeature: DetailFeature | null; flowDataSource?: "realtime" | "mock"; onClose: () => void; + investigationFollow?: boolean; + onInvestigationFollowChange?: (enabled: boolean) => void; }; const GROUP_LABELS = Object.fromEntries( @@ -102,7 +104,9 @@ export function MapDevPanel({ controllerState, detailFeature, flowDataSource = "mock", - onClose + onClose, + investigationFollow = true, + onInvestigationFollowChange }: MapDevPanelProps) { const [sourceId, setSourceId] = useState("pipes"); const [featureId, setFeatureId] = useState("440681"); @@ -231,13 +235,18 @@ export function MapDevPanel({ return ( @@ -641,27 +685,43 @@ export function MapDevPanel({ function PanelSection({ title, description, + initiallyOpen = false, + status, children }: { title: string; description: string; + initiallyOpen?: boolean; + status?: string; children: React.ReactNode; }) { return ( -
-
-

{title}

-

{description}

+ + {title} + {status ? ( + + {status} + + ) : null} + + } + > +
+

{description}

+ {children}
- {children} -
+
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( ); @@ -677,11 +737,10 @@ function ToggleField({ onChange: (checked: boolean) => void; }) { return ( -
- {hasAlerts ? "需复核" : "正常"} + {hasAlerts ? "待处理" : "暂无待办"}
diff --git a/src/features/workbench/components/workbench-top-bar.tsx b/src/features/workbench/components/workbench-top-bar.tsx index 83d0ba7..401098d 100644 --- a/src/features/workbench/components/workbench-top-bar.tsx +++ b/src/features/workbench/components/workbench-top-bar.tsx @@ -264,7 +264,7 @@ export function WorkbenchTopBar({ function getAlertSourceLabel(source: WorkbenchAlertDataSource) { if (source === "backend") return "后端运行态"; if (source === "mock") return "工况演示回退"; - return "未配置告警源"; + return "未配置"; } function getAnalysisOriginLabel(origin: AnalysisArtifact["origin"]) { diff --git a/src/features/workbench/hooks/agent-session-message-state.test.ts b/src/features/workbench/hooks/agent-session-message-state.test.ts index ce9884b..afd9656 100644 --- a/src/features/workbench/hooks/agent-session-message-state.test.ts +++ b/src/features/workbench/hooks/agent-session-message-state.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest"; import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state"; describe("task context in persisted answers", () => { + it("inserts consumed user input once before the active assistant in live and resumed streams", () => { + const messages = toAgentUiMessages([ + { id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] }, + { id: "answer", role: "assistant", parts: [{ type: "text", text: "处理中" }] } + ]); + const data = { id: "new-input", text: "追加线索", delivery: "steer", status: "pending" }; + expect(applySessionStreamEvent(messages, { type: "user_input", data })).toBe(messages); + const delivered = { type: "user_input" as const, data: { ...data, status: "delivered" } }; + const next = applySessionStreamEvent(messages, delivered); + expect(next.map(({ id }) => id)).toEqual(["original", "new-input", "answer"]); + expect(applySessionStreamEvent(next, delivered)).toBe(next); + }); + it("keeps the report reference of each answer when history is restored", () => { const reference = { task_id: "22222222-2222-4222-8222-222222222222", diff --git a/src/features/workbench/hooks/agent-session-message-state.ts b/src/features/workbench/hooks/agent-session-message-state.ts index f30e6b3..3c21060 100644 --- a/src/features/workbench/hooks/agent-session-message-state.ts +++ b/src/features/workbench/hooks/agent-session-message-state.ts @@ -19,10 +19,14 @@ import { } from "@/features/agent"; type AgentUiDataParts = { + agent_message: Record; task_context: Record; execution_plan: Record; progress: Record; final_answer: Record; + credential_refresh_required: Record; + credential_refreshed: Record; + credential_refresh_failed: Record; todo_update: Record; permission_request: Record; permission_response: Record; @@ -35,6 +39,7 @@ type AgentUiDataParts = { answer_ui: Record; ui_envelope: Record; session_title: Record; + user_input: Record; }; export type AgentUiMessage = UIMessage; @@ -200,6 +205,15 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent return toAgentUiMessages(event.messages); } + if (event.type === "user_input") { + const id = getDataString(event.data, "id"); + const text = getDataString(event.data, "text"); + if (event.data.status !== "delivered" || !id || !text || messages.some((message) => message.id === id)) return messages; + const input: AgentUiMessage = { id, role: "user", parts: [{ type: "text", text }] }; + const tail = messages.at(-1); + return tail?.role === "assistant" ? [...messages.slice(0, -1), input, tail] : [...messages, input]; + } + if (event.type === "token") { const token = getDataString(event.data, "content"); return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages; diff --git a/src/features/workbench/hooks/investigation-presentation-follower.test.ts b/src/features/workbench/hooks/investigation-presentation-follower.test.ts new file mode 100644 index 0000000..b1c1197 --- /dev/null +++ b/src/features/workbench/hooks/investigation-presentation-follower.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import type { InvestigationPresentationCommand, InvestigationPresentationCursor } from "../api/investigation-presentation"; +import type { WorkbenchEvent } from "../api/workbench-sync-client"; +import { InvestigationPresentationFollower } from "./investigation-presentation-follower"; +import { WorkbenchEventChannel } from "./workbench-event-channel"; + +const taskId = "50000000-0000-4000-8000-000000000001"; +const runId = "55000000-0000-4000-8000-000000000001"; +const nextRunId = "55000000-0000-4000-8000-000000000002"; +const highlight: InvestigationPresentationCommand = { action: "highlight_features", layer: "scada", feature_ids: ["MP01"], effect: "pulse" }; +const openChart: InvestigationPresentationCommand = { + action: "open_chart", chart_id: "pressure", evidence_refs: ["reading-1"], + chart: { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] } +}; +const closeChart: InvestigationPresentationCommand = { action: "close_chart", chart_id: "pressure" }; + +function cursor(eventCursor: number, generation = 1): InvestigationPresentationCursor { + return { task_id: taskId, execution_generation: generation, event_cursor: eventCursor, + active_run: { run_id: generation === 1 ? runId : nextRunId, generation, status: "running" } }; +} + +function event(sequence: number, command = highlight, generation = 1): WorkbenchEvent { + return { schema: "workbench-event@1", event_seq: sequence, event_id: "60000000-0000-4000-8000-000000000001", + project_id: "10000000-0000-4000-8000-000000000001", topic: "operational.presentation.requested", + aggregate_type: "operational_presentation", aggregate_id: taskId, aggregate_revision: 1, + occurred_at: "2026-10-10T01:00:00Z", metadata: { + task_id: taskId, run_id: generation === 1 ? runId : nextRunId, execution_generation: generation, command + } }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function setup(overrides: Partial[0]> = {}) { + const channel = new WorkbenchEventChannel(); + const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {}); + const fetchCursor = vi.fn(async (_signal: AbortSignal) => cursor(10)); + const fetchIdentity = vi.fn(async (_signal: AbortSignal) => cursor(10)); + const clear = vi.fn(); + const options = { taskId, subscribe: channel.subscribe, execute, fetchCursor, fetchIdentity, + clear, stopPulse: vi.fn(), onStatus: vi.fn() }; + const follower = new InvestigationPresentationFollower({ ...options, ...overrides }); + return { channel, follower, ...options }; +} + +describe("live investigation presentation following", () => { + it("listens before fetching the baseline and preserves events arriving during cursor lookup", async () => { + const baseline = deferred(); + const order: string[] = []; + const test = setup(); + test.channel.publish({ type: "open" }); + const fetchCursor = vi.fn(() => { + order.push("cursor"); + test.channel.publish({ type: "events", events: [event(8), event(11)] }); + return baseline.promise; + }); + const follower = new InvestigationPresentationFollower({ ...test, + subscribe: (listener) => { order.push("listen"); return test.channel.subscribe(listener); }, fetchCursor }); + follower.start(); + try { + expect(order).toEqual(["listen", "cursor"]); + expect(test.execute).not.toHaveBeenCalled(); + baseline.resolve(cursor(10)); + await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1)); + expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight); + } finally { follower.stop(); } + }); + + it("executes same-batch chart commands in sequence and waits for open before close", async () => { + const painted = deferred(); + const execute = vi.fn((command: InvestigationPresentationCommand) => command.action === "open_chart" ? painted.promise : Promise.resolve()); + const test = setup({ execute }); + test.follower.start(); + test.channel.publish({ type: "open" }); + try { + await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live")); + test.channel.publish({ type: "events", events: [event(12, closeChart), event(11, openChart)] }); + expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart"]); + painted.resolve(); + await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart"])); + } finally { painted.resolve(); test.follower.stop(); } + }); + + it("verifies a new generation without losing its first command or fetching a new baseline", async () => { + const fetchIdentity = vi.fn(async () => cursor(10, 2)); + const test = setup({ fetchIdentity }); + test.follower.start(); + test.channel.publish({ type: "open" }); + try { + await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live")); + test.channel.publish({ type: "events", events: [event(11, highlight, 2)] }); + await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1)); + expect(fetchIdentity).toHaveBeenCalledTimes(1); + expect(test.fetchCursor).toHaveBeenCalledTimes(1); + expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight); + } finally { test.follower.stop(); } + }); + + it("retains a command while identity lookup is stale and applies it once the task snapshot catches up", async () => { + const test = setup(); + test.follower.start(); + test.channel.publish({ type: "open" }); + try { + await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live")); + test.channel.publish({ type: "events", events: [event(11, highlight, 2)] }); + await vi.waitFor(() => expect(test.fetchIdentity).toHaveBeenCalledTimes(1)); + expect(test.execute).not.toHaveBeenCalled(); + test.follower.updateIdentity(cursor(10, 2)); + await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1)); + expect(test.fetchCursor).toHaveBeenCalledTimes(1); + } finally { test.follower.stop(); } + }); + + it("aborts on disconnect and uses a fresh reconnect baseline without replaying old commands", async () => { + const oldDisplay = deferred(); + const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {}) + .mockImplementationOnce(() => oldDisplay.promise); + const fetchCursor = vi.fn(async () => cursor(10)).mockResolvedValueOnce(cursor(10)).mockResolvedValueOnce(cursor(20)); + const test = setup({ execute, fetchCursor }); + test.follower.start(); + test.channel.publish({ type: "open" }); + try { + await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live")); + test.channel.publish({ type: "events", events: [event(11)] }); + const oldSignal = execute.mock.calls[0]?.[1]; + test.channel.publish({ type: "disconnect" }); + expect(oldSignal?.aborted).toBe(true); + const clearCount = test.clear.mock.calls.length; + test.channel.publish({ type: "events", events: [event(12, openChart)] }); + test.channel.publish({ type: "open" }); + await vi.waitFor(() => expect(fetchCursor).toHaveBeenCalledTimes(2)); + test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] }); + oldDisplay.resolve(); + await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"])); + expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount); + expect(execute.mock.calls[1]?.[1].aborted).toBe(false); + } finally { oldDisplay.resolve(); test.follower.stop(); } + }); +}); diff --git a/src/features/workbench/hooks/investigation-presentation-follower.ts b/src/features/workbench/hooks/investigation-presentation-follower.ts new file mode 100644 index 0000000..dedec98 --- /dev/null +++ b/src/features/workbench/hooks/investigation-presentation-follower.ts @@ -0,0 +1,162 @@ +import { + investigationPresentationEventSchema, + type InvestigationPresentationCommand, + type InvestigationPresentationCursor, + type InvestigationPresentationEvent +} from "../api/investigation-presentation"; +import type { WorkbenchEvent } from "../api/workbench-sync-client"; +import type { WorkbenchStreamNotification } from "./workbench-event-channel"; + +export type InvestigationIdentity = Pick; +type Options = { + taskId: string; + subscribe: (listener: (notification: WorkbenchStreamNotification) => void) => () => void; + fetchCursor: (signal: AbortSignal) => Promise; + fetchIdentity: (signal: AbortSignal) => Promise; + execute: (command: InvestigationPresentationCommand, signal: AbortSignal) => Promise; + clear: () => void; + stopPulse: () => void; + onStatus: (status: "connecting" | "live" | "disconnected" | "error", error?: string) => void; +}; +type Pending = { sequence: number; event: InvestigationPresentationEvent }; + +/** No replay: a fresh authorized baseline is taken after every connection, with the listener already attached. */ +export class InvestigationPresentationFollower { + private unsubscribe?: () => void; + private binding: AbortController | null = null; + private work = new AbortController(); + private identity: InvestigationIdentity | null = null; + private baseline: number | null = null; + private lastSeen = 0; + private pending: Pending[] = []; + private draining = false; + private epoch = 0; + private lookupKey: string | null = null; + + constructor(private options: Options) {} + + start() { this.unsubscribe = this.options.subscribe((notification) => this.receive(notification)); } + + stop() { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.disconnect(); + } + + updateIdentity(identity: InvestigationIdentity) { + if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return; + const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation + || identity.active_run && this.identity.active_run && identity.active_run.run_id !== this.identity.active_run.run_id); + if (changed) { + this.work.abort(); + this.work = new AbortController(); + this.options.clear(); + } + // A finished run may no longer be active; retain its identity to finish already received close commands. + this.identity = identity.active_run || !this.identity || identity.execution_generation !== this.identity.execution_generation + ? identity : { ...identity, active_run: this.identity.active_run ? { ...this.identity.active_run, status: "succeeded" } : null }; + if (!identity.active_run || identity.active_run.status !== "running") this.options.stopPulse(); + void this.drain(); + } + + private receive(notification: WorkbenchStreamNotification) { + if (notification.type === "open") { void this.connect(); return; } + if (notification.type !== "events") { this.disconnect(); return; } + if (!this.binding) return; + for (const event of notification.events) this.add(event); + void this.drain(); + } + + private add(event: WorkbenchEvent) { + if (!this.binding) return; + if (event.topic !== "operational.presentation.requested" || event.metadata.task_id !== this.options.taskId) return; + if (event.event_seq <= (this.baseline ?? -1) || event.event_seq <= this.lastSeen || this.pending.some((item) => item.sequence === event.event_seq)) return; + const parsed = investigationPresentationEventSchema.safeParse(event.metadata); + if (!parsed.success) { this.options.onStatus("error", "调查展示参数未通过校验"); return; } + if (this.pending.length >= 1000) { this.disconnect(); this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随"); return; } + this.pending.push({ sequence: event.event_seq, event: parsed.data }); + this.pending.sort((a, b) => a.sequence - b.sequence); + } + + private async connect() { + this.disconnect(); + const epoch = this.epoch; + const controller = new AbortController(); + this.binding = controller; + this.options.onStatus("connecting"); + try { + const cursor = await this.options.fetchCursor(controller.signal); + if (controller.signal.aborted || epoch !== this.epoch) return; + if (cursor.task_id !== this.options.taskId) throw new Error("调查游标与所选任务不一致"); + this.baseline = cursor.event_cursor; + this.lastSeen = cursor.event_cursor; + this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor); + this.updateIdentity(cursor); + this.options.onStatus("live"); + void this.drain(); + } catch (error) { + if (!controller.signal.aborted) { this.disconnect(); this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败"); } + } + } + + private disconnect() { + this.epoch += 1; + this.binding?.abort(); + this.binding = null; + this.work.abort(); + this.work = new AbortController(); + this.identity = null; + this.baseline = null; + this.lastSeen = 0; + this.lookupKey = null; + this.pending = []; + this.options.clear(); + this.options.onStatus("disconnected"); + } + + private async resolveIdentity(event: InvestigationPresentationEvent) { + const key = `${event.execution_generation}:${event.run_id}`; + if (this.lookupKey === key || !this.binding) return; + this.lookupKey = key; + const binding = this.binding; + try { + const identity = await this.options.fetchIdentity(binding.signal); + if (!binding.signal.aborted) this.updateIdentity(identity); + } catch (error) { + if (!binding.signal.aborted) this.options.onStatus("error", error instanceof Error ? error.message : "调查运行状态核验失败"); + } + } + + private async drain() { + if (this.draining || this.baseline === null || !this.identity) return; + this.draining = true; + const epoch = this.epoch; + try { + while (this.pending.length && epoch === this.epoch) { + const next = this.pending[0]; + if (next.sequence <= this.lastSeen || next.event.execution_generation < this.identity!.execution_generation) { this.pending.shift(); continue; } + if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) { + await this.resolveIdentity(next.event); + if (epoch !== this.epoch) break; + if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break; + } + this.pending.shift(); + this.lastSeen = next.sequence; + const signal = this.work.signal; + try { + const command = next.event.command.action === "highlight_features" && this.identity!.active_run?.status !== "running" + ? { ...next.event.command, effect: "steady" as const } : next.event.command; + await this.options.execute(command, signal); + if (!signal.aborted && epoch === this.epoch) this.options.onStatus("live"); + if (this.identity!.active_run?.status !== "running") this.options.stopPulse(); + } catch (error) { + if (!signal.aborted && epoch === this.epoch) this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败"); + } + } + } finally { + this.draining = false; + // A reconnect can establish a new baseline while an old asynchronous command is aborting. + if (epoch !== this.epoch && this.baseline !== null && this.pending.length) void this.drain(); + } + } +} diff --git a/src/features/workbench/hooks/use-agent-prompt-suggestions.test.ts b/src/features/workbench/hooks/use-agent-prompt-suggestions.test.ts new file mode 100644 index 0000000..adf4526 --- /dev/null +++ b/src/features/workbench/hooks/use-agent-prompt-suggestions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions"; +import type { AgentChatSessionSummary } from "@/features/agent"; + +describe("recent activities for Agent suggestions", () => { + it("uses updated ordinary conversations, excludes stale/private task sessions, and preserves real versus demo origins", () => { + const now = Date.parse("2026-10-09T08:00:00Z"); + const session = (id: string, updatedAt: number, purpose: AgentChatSessionSummary["purpose"]): AgentChatSessionSummary => ({ + id, title: id, purpose, createdAt: now - 10 * 24 * 60 * 60_000, updatedAt, + }); + const activities = collectRecentAgentActivities({ + alerts: [ + { id: "demo", title: "演示压力告警", description: "演示", time: "15:00", occurredAt: "2026-10-09T07:00:00Z" }, + { id: "operational-task-notification:real", title: "现场核查待办", description: "真实后台任务", time: "15:05", occurredAt: "2026-10-09T07:05:00Z" }, + ], + alertDataSource: "mock", conditions: [], runtimeDataSource: "mock", artifact: null, + sessions: [session("updated-old-conversation", now - 60_000, "ordinary_chat"), session("stale", now - 8 * 24 * 60 * 60_000, "ordinary_chat"), session("task-private", now, "task_collaboration")], + }, now); + expect(activities.find((item) => item.id === "alert:demo")?.origin).toBe("demo"); + expect(activities.find((item) => item.id === "alert:operational-task-notification:real")?.origin).toBe("backend"); + expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]); + }); +}); diff --git a/src/features/workbench/hooks/use-agent-prompt-suggestions.ts b/src/features/workbench/hooks/use-agent-prompt-suggestions.ts new file mode 100644 index 0000000..075b921 --- /dev/null +++ b/src/features/workbench/hooks/use-agent-prompt-suggestions.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent"; +import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types"; +import type { AnalysisArtifact } from "../workspace/workspace-model"; + +type ActivityInputs = { + alerts: WorkbenchAlert[]; + alertDataSource: WorkbenchAlertDataSource; + conditions: ScheduledConditionItem[]; + runtimeDataSource: WorkbenchRuntimeDataSource; + artifact: AnalysisArtifact | null; + sessions: AgentChatSessionSummary[]; +}; + +const activityLabels = { alert: "告警", task: "任务", analysis: "分析成果", session: "近期会话" } as const; +const timeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }); +type DisplaySuggestion = AgentPromptSuggestion & { sourceLabel: string }; + +export function collectRecentAgentActivities(input: ActivityInputs, now = Date.now()): AgentRecentActivity[] { + const origin = (source: string) => source === "mock" || source === "demo" ? "demo" as const : "backend" as const; + const validDate = (time: string | number) => Number.isFinite(new Date(time).getTime()); + const alerts: AgentRecentActivity[] = input.alerts.filter((item) => item.occurredAt && validDate(item.occurredAt)) + .sort((a, b) => Date.parse(b.occurredAt!) - Date.parse(a.occurredAt!)).slice(0, 4).map((item) => ({ + id: `alert:${item.id}`, kind: "alert", title: item.title.slice(0, 160), occurred_at: new Date(item.occurredAt!).toISOString(), + summary: item.description.slice(0, 600), status: item.severity, + origin: item.id.startsWith("operational-task-notification:") ? "backend" : origin(input.alertDataSource), + })); + const tasks: AgentRecentActivity[] = input.conditions.filter((item) => validDate(item.updatedAt)) + .sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3).map((item) => ({ + id: `task:${item.id}`, kind: "task", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(), + summary: item.summary.slice(0, 600), status: item.status, + origin: origin(item.kind === "condition" ? item.dataSource ?? input.runtimeDataSource : input.runtimeDataSource), + })); + const artifact = input.artifact; + const analyses: AgentRecentActivity[] = artifact && validDate(artifact.generatedAt) ? [{ + id: `analysis:${artifact.id}`, kind: "analysis", title: artifact.title.slice(0, 160), occurred_at: new Date(artifact.generatedAt).toISOString(), + summary: artifact.summary.slice(0, 600), origin: artifact.origin ?? "backend", + }] : []; + const sessions: AgentRecentActivity[] = input.sessions.filter((item) => item.purpose === "ordinary_chat" && item.title.trim() !== "新对话" && + validDate(item.updatedAt) && item.updatedAt >= now - 7 * 24 * 60 * 60_000 && item.updatedAt <= now) + .sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 4).map((item) => ({ + id: `session:${item.id}`, kind: "session", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(), origin: "backend", + })); + return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()]; +} + +export function useAgentPromptSuggestions({ enabled, model, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & { + enabled: boolean; + model: string; + sessionId: string | null; + request: AgentApiClient["generatePromptSuggestions"]; +}) { + const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }), + [alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]); + const snapshot = useRef(activities); + useEffect(() => { snapshot.current = activities; }, [activities]); + const key = JSON.stringify([enabled, model, sessionId]); + const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({ + key: "", suggestions: [], loading: false, unavailable: false, + }); + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + const context = snapshot.current; + setState({ key, suggestions: [], loading: true, unavailable: false }); + void request(context, model, controller.signal).then((suggestions) => { + if (controller.signal.aborted) return; + const sourceById = new Map(context.map((item) => [item.id, item])); + setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => { + const source = item.activityId ? sourceById.get(item.activityId) : undefined; + return { ...item, sourceLabel: source + ? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}` + : "供水任务建议" }; + }) }); + }).catch(() => { + if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true }); + }); + return () => controller.abort(); + }, [enabled, key, model, request]); + return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false }; +} diff --git a/src/features/workbench/hooks/use-investigation-presentation.ts b/src/features/workbench/hooks/use-investigation-presentation.ts new file mode 100644 index 0000000..250db52 --- /dev/null +++ b/src/features/workbench/hooks/use-investigation-presentation.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import type { FeatureCollection } from "geojson"; +import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client"; +import type { InvestigationMapTarget } from "../api/investigation-report"; +import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation"; +import { InvestigationMapPresentation } from "../map/investigation-map-presentation"; +import type { WorkbenchMapController } from "../map/workbench-map-controller"; +import type { WorkbenchSyncState } from "./use-workbench-sync"; +import { InvestigationPresentationFollower } from "./investigation-presentation-follower"; + +export type InvestigationChart = Extract; +type Options = { + enabled: boolean; task: OperationalTask | null; projectId: string | null; + sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider; + mapRef: RefObject; mapReady: boolean; + controller: WorkbenchMapController; getPadding: () => PaddingOptions; + onClearLayers: () => void; +}; + +function frame(signal: AbortSignal) { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const abort = () => { cancelAnimationFrame(id); reject(signal.reason); }; + const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); }); + signal.addEventListener("abort", abort, { once: true }); + }); +} + +/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */ +async function waitForChart(chartId: string, signal: AbortSignal) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + await frame(signal); + const element = [...document.querySelectorAll("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId); + if (element && element.getBoundingClientRect().height > 0) { + const visibleUntil = Date.now() + 500; + while (Date.now() < visibleUntil) await frame(signal); + return; + } + } + throw new Error("图表当前不可见"); +} + +export function useInvestigationPresentation(options: Options) { + const valuesRef = useRef(options); + valuesRef.current = options; + const [charts, setCharts] = useState([]); + const [overlay, setOverlay] = useState(null); + const [cameraPaused, setCameraPaused] = useState(false); + const cameraPausedRef = useRef(false); + const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected"); + const [error, setError] = useState(null); + const [highlightLayers, setHighlightLayers] = useState([]); + const followerRef = useRef(null); + const manualRequestRef = useRef(null); + const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]); + const taskId = options.task?.task_id ?? null; + const subscribe = options.sync.subscribePresentationEvents; + + const clear = useCallback(() => { + scene.clear(); setCharts([]); setOverlay(null); setHighlightLayers([]); + valuesRef.current.onClearLayers(); + }, [scene]); + + useEffect(() => { + manualRequestRef.current?.abort(); + cameraPausedRef.current = false; + setCameraPaused(false); + if (!options.enabled || !taskId) { clear(); return; } + const follower = new InvestigationPresentationFollower({ + taskId, subscribe, + fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal), + fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal), + clear, + stopPulse: () => scene.stopPulse(), + onStatus: (next, message) => { setStatus(next); setError(message ?? null); }, + execute: async (command, signal) => { + signal.throwIfAborted(); + if (command.action === "highlight_features") { + const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal); + signal.throwIfAborted(); + scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current); + setHighlightLayers([command.layer]); + } else if (command.action === "clear_highlights") { + scene.clearHighlights(); setHighlightLayers([]); + } else if (command.action === "open_chart") { + setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]); + await waitForChart(command.chart_id, signal); + } else if (command.action === "close_chart") { + setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id)); + await frame(signal); + } else if (command.action === "set_overlay") { + const next = { ...command, visible: true }; + scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next); + } else { + scene.clearOverlay(command.overlay_id); + setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current); + } + } + }); + followerRef.current = follower; + follower.start(); + return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; }; + }, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]); + + useEffect(() => { + if (options.task) followerRef.current?.updateIdentity(options.task); + }, [options.task]); + + const pauseCamera = useCallback(() => { + cameraPausedRef.current = true; setCameraPaused(true); + }, []); + useEffect(() => { + const map = options.mapRef.current; + if (!map || !options.mapReady || !options.enabled) return; + const canvas = map.getCanvasContainer(); + // Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation. + canvas.addEventListener("pointerdown", pauseCamera); + canvas.addEventListener("wheel", pauseCamera, { passive: true }); + canvas.addEventListener("keydown", pauseCamera); + return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); }; + }, [options.enabled, options.mapReady, options.mapRef, pauseCamera]); + useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]); + + const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => { + manualRequestRef.current?.abort(); + const controller = new AbortController(); manualRequestRef.current = controller; + pauseCamera(); + const groups = new Map(); + for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]); + const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal))); + controller.signal.throwIfAborted(); + const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) }; + if (!data.features.length) throw new Error("未找到关联地图要素"); + scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]); + }, [pauseCamera, scene]); + + return { + charts, overlay, cameraPaused, status, error, highlightLayers, + pauseCamera, + resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); }, + locateTargets, + closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)), + toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } }, + fitOverlay: () => { pauseCamera(); scene.fitOverlay(); } + }; +} diff --git a/src/features/workbench/hooks/use-operational-tasks.ts b/src/features/workbench/hooks/use-operational-tasks.ts index 9fabb0c..8a47137 100644 --- a/src/features/workbench/hooks/use-operational-tasks.ts +++ b/src/features/workbench/hooks/use-operational-tasks.ts @@ -40,6 +40,7 @@ export function collectOperationalTaskEventChanges(events: WorkbenchEvent[]) { const deletedTaskIds = new Set(); let notificationsChanged = false; for (const event of events) { + if (event.topic === "operational.presentation.requested") continue; if (event.topic === "operational.notification.changed") { notificationsChanged = true; continue; diff --git a/src/features/workbench/hooks/use-report-update-motion.test.tsx b/src/features/workbench/hooks/use-report-update-motion.test.tsx new file mode 100644 index 0000000..fe82001 --- /dev/null +++ b/src/features/workbench/hooks/use-report-update-motion.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { animate } from "motion/mini"; +import { useReportUpdateMotion } from "./use-report-update-motion"; + +vi.mock("motion/mini", () => ({ animate: vi.fn() })); + +function Reader({ revision = "1", scope = "task-1", live = true }) { + const ref = useReportUpdateMotion(scope, revision, live); + return
调查报告

{revision}

依据{revision}
; +} + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +function motion(reduced = false) { + const cancel = vi.fn(); + vi.mocked(animate).mockReset(); + vi.mocked(animate).mockReturnValue({ cancel } as unknown as ReturnType); + vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: reduced, addEventListener: vi.fn(), removeEventListener: vi.fn() }))); + // jsdom does not provide Web Animations. + const attach = (element: HTMLElement) => Array.from(element.children) + .forEach((part) => Object.defineProperty(part, "animate", { value: vi.fn() })); + return { animate, cancel, attach }; +} + +describe("report update motion", () => { + it("animates only a new revision and keeps the reading DOM", () => { + const { animate, cancel, attach } = motion(); + const { rerender, unmount } = render(); + const section = screen.getByRole("article", { name: "report" }); + attach(section); + const details = section.querySelector("details")!; + expect(animate).not.toHaveBeenCalled(); + rerender(); + expect(animate).not.toHaveBeenCalled(); + rerender(); + expect(animate).toHaveBeenCalledTimes(2); + expect(animate).toHaveBeenNthCalledWith(1, + section.querySelector("header"), + { opacity: [0, 1], translate: ["0 18px", "0 0"], scale: [0.98, 1] }, + { duration: 0.4, delay: 0, ease: [0.22, 1, 0.36, 1] } + ); + expect(animate).toHaveBeenNthCalledWith(2, section.querySelector("section"), expect.any(Object), expect.objectContaining({ delay: 0.045 })); + expect(section.querySelector("details")).toBe(details); + expect(details.open).toBe(true); + rerender(); + expect(cancel).toHaveBeenCalledTimes(2); + unmount(); + expect(cancel).toHaveBeenCalledTimes(4); + }); + + it("skips history, returning to live content, and task switches", () => { + const { animate, attach } = motion(); + const { rerender } = render(); + attach(screen.getByRole("article", { name: "report" })); + rerender(); + rerender(); + rerender(); + expect(animate).not.toHaveBeenCalled(); + }); + + it("respects reduced motion", () => { + const { animate, attach } = motion(true); + const { rerender } = render(); + attach(screen.getByRole("article", { name: "report" })); + rerender(); + expect(animate).not.toHaveBeenCalled(); + }); + + it("keeps report updates readable when matchMedia is unavailable", () => { + const { animate, attach } = motion(); + vi.stubGlobal("matchMedia", undefined); + const { rerender } = render(); + const section = screen.getByRole("article", { name: "report" }); + attach(section); + rerender(); + expect(section).toHaveTextContent("2"); + expect(animate).not.toHaveBeenCalled(); + }); + + it("lets interaction interrupt the group and leaves focused content still", () => { + const { animate, cancel, attach } = motion(); + const { rerender } = render(); + const reader = screen.getByRole("article", { name: "report" }); + attach(reader); + rerender(); + fireEvent.pointerDown(reader); + expect(cancel).toHaveBeenCalledTimes(2); + reader.querySelector("summary")!.focus(); + vi.mocked(animate).mockClear(); + rerender(); + expect(animate).toHaveBeenCalledTimes(1); + expect(animate).toHaveBeenCalledWith(reader.querySelector("header"), expect.any(Object), expect.any(Object)); + }); +}); diff --git a/src/features/workbench/hooks/use-report-update-motion.ts b/src/features/workbench/hooks/use-report-update-motion.ts new file mode 100644 index 0000000..85de3bd --- /dev/null +++ b/src/features/workbench/hooks/use-report-update-motion.ts @@ -0,0 +1,43 @@ +import { useLayoutEffect, useRef } from "react"; +import { animate } from "motion/mini"; + +/** Reveal new published content without remounting the reader or animating heartbeats. */ +export function useReportUpdateMotion(scope: string, revision: string, live: boolean) { + const ref = useRef(null); + const previous = useRef({ scope, revision, live }); + + useLayoutEffect(() => { + const before = previous.current; + previous.current = { scope, revision, live }; + if (!live || !before.live || before.scope !== scope || before.revision === revision) return; + const reader = ref.current; + if (!reader || typeof window.matchMedia !== "function") return; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + if (reducedMotion.matches) return; + // Replay the Bento reveal across the report, not a browser/page reload. + // Direct children only: portal details and Mermaid's internal DOM stay intact. + const parts = Array.from(reader.querySelectorAll(":scope > header, :scope > section, :scope > p")) + .filter((part) => typeof part.animate === "function" && !part.contains(document.activeElement)); + const animations = parts.map((part, index) => { + const original = { opacity: part.style.opacity, translate: part.style.translate, scale: part.style.scale }; + // Individual transforms leave the expand-card shared-layout transform alone. + const animation = animate(part, { opacity: [0, 1], translate: ["0 18px", "0 0"], scale: [0.98, 1] }, { + duration: 0.4, delay: Math.min(index, 6) * 0.045, ease: [0.22, 1, 0.36, 1] + }); + return () => { animation.cancel(); Object.assign(part.style, original); }; + }); + const cancel = () => animations.forEach((restore) => restore()); + // Interaction wins over the reveal, including expanding a shared-layout card. + reader.addEventListener("pointerdown", cancel, true); + reader.addEventListener("focusin", cancel); + reducedMotion.addEventListener("change", cancel); + return () => { + reader.removeEventListener("pointerdown", cancel, true); + reader.removeEventListener("focusin", cancel); + reducedMotion.removeEventListener("change", cancel); + cancel(); + }; + }, [scope, revision, live]); + + return ref; +} diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index 061288a..438f78e 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; +import { ResponsesChatTransport, createResponseRequest } from "@/features/agent"; import useSWR from "swr"; -import useSWRImmutable from "swr/immutable"; import type { PersonaState } from "@/shared/ai-elements/persona"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; import { showMapNotice } from "@/features/map/core"; import { findOwnedWorkbenchProject } from "../api/project-client"; +import type { InvestigationDiscussion } from "../api/investigation-report"; import { FrontendActionExecutor, agentTaskDiscussionContextSchema, @@ -15,8 +15,10 @@ import { agentBootstrapKey, agentSessionsKey, createAgentApiClient, + useAgentMessageInbox, fetchAgentBootstrap, fetchAgentSessions, + resolveAgentModelSelection, hasPendingAgentInput, isUiEnvelopeAllowed, parseUiEnvelopePayload, @@ -25,6 +27,7 @@ import { type AgentConnectionStatus, type AgentEventTriggerContext, type AgentModelOption, + type AgentInputDelivery, type AgentPermissionReply, type AgentPermissionRequest, type AgentQuestionRequest, @@ -68,6 +71,8 @@ export function useWorkbenchAgent({ const mobileCollapseTimerRef = useRef(null); const sessionStreamAbortRef = useRef(null); const projectIdPromiseRef = useRef | null>(null); + const [conversationScope, setConversationScope] = useState<"personal" | "project">("project"); + const conversationScopeRef = useRef<"personal" | "project">("project"); const getProjectId = useCallback(() => { projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then( ({ project }) => project.project_id, @@ -78,7 +83,15 @@ export function useWorkbenchAgent({ ); return projectIdPromiseRef.current; }, [getAccessToken]); - const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId })); + const getConversationProjectId = useCallback(() => + conversationScopeRef.current === "personal" ? Promise.resolve(undefined) : getProjectId(), [getProjectId]); + const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId: getConversationProjectId })); + const scopeClient = useMemo(() => createAgentApiClient(undefined, { + getAccessToken, + getProjectId: conversationScope === "project" ? getProjectId : undefined + }), [conversationScope, getAccessToken, getProjectId]); + const generatePromptSuggestions = useCallback((...args: Parameters) => + clientRef.current.generatePromptSuggestions(...args), []); const sessionIdRef = useRef(null); const approvalModeRef = useRef("request"); const registryRef = useRef(null); @@ -92,7 +105,7 @@ export function useWorkbenchAgent({ const [sessionId, setSessionId] = useState(null); const [sessionPurpose, setSessionPurpose] = useState("ordinary_chat"); const [sessionTaskId, setSessionTaskId] = useState(null); - const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null); + const [taskDiscussion, setTaskDiscussion] = useState<{ sessionId: string; context: AgentTaskDiscussionContext; reportGeneration?: number | null } | null>(null); const [registry, setRegistry] = useState(null); const [sessionTitle, setSessionTitle] = useState("新对话"); const [statusLabel, setStatusLabel] = useState("Agent 后端连接中"); @@ -101,11 +114,17 @@ export function useWorkbenchAgent({ const [resumedStreaming, setResumedStreaming] = useState(false); const [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(""); + const [inputDelivery, setInputDelivery] = useState("steer"); + const selectedModelRef = useRef(""); + useEffect(() => { selectedModelRef.current = selectedModel; }, [selectedModel]); const [promptDraft, setPromptDraft] = useState(""); + const [promptFocusRequest, setPromptFocusRequest] = useState(0); const [approvalMode, setApprovalModeState] = useState("request"); const [streamRenderState, setStreamRenderState] = useState({}); const [permissionOverrides, setPermissionOverrides] = useState>({}); const [questionOverrides, setQuestionOverrides] = useState>({}); + const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" && conversationScope === "project" ? sessionId : null); + const refreshAgentMessageInbox = agentMessageInbox.refresh; useEffect(() => { sessionIdRef.current = sessionId; @@ -154,6 +173,19 @@ export function useWorkbenchAgent({ [frontendActionExecutor] ); + const credentialRequestsRef = useRef(new Set()); + const handleCredentialRefresh = useCallback((data: unknown) => { + const requestId = getDataString(data, "request_id"); + const activeSessionId = getDataString(data, "session_id") ?? sessionIdRef.current; + if (!requestId || !activeSessionId || activeSessionId !== sessionIdRef.current) return; + if (credentialRequestsRef.current.has(requestId)) return; + credentialRequestsRef.current.add(requestId); + void clientRef.current.refreshCredential(activeSessionId, requestId).catch(() => { + credentialRequestsRef.current.delete(requestId); + setStatusLabel("会话凭据续期失败,请重新登录后重试"); + }); + }, []); + const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => { if (payload.session_id !== activeSessionId) return false; const key = `${activeSessionId}:${payload.envelope_id}`; @@ -183,6 +215,11 @@ export function useWorkbenchAgent({ ); return; } + if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; } + if (part.type === "data-user_input" && part.data.status === "delivered") { + chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data })); + return; + } if (part.type === "data-progress") { const title = getDataString(part.data, "title"); @@ -191,6 +228,7 @@ export function useWorkbenchAgent({ } return; } + if (part.type === "data-credential_refresh_required") { handleCredentialRefresh(part.data); return; } if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; } if (part.type === "data-session_title") { @@ -225,37 +263,37 @@ export function useWorkbenchAgent({ }); }); }, - [applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope] + [refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope] ); const transport = useMemo( () => - new DefaultChatTransport({ - api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`, + new ResponsesChatTransport({ + api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`, headers: async (): Promise> => { const accessToken = await getAccessToken?.(); - const projectId = await getProjectId(); + const projectId = await getConversationProjectId(); return { ...(projectId ? { "X-Project-Id": projectId } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }; }, - prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) { - return { - body: { - ...body, - id, - messages, - trigger, - messageId, - session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined, - approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current, - capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [] - } - }; + async prepareSendMessagesRequest({ messages, body }) { + const catalog = await scopeClient.getModels(); + const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? selectedModelRef.current); + setModelOptions(catalog.models); + setSelectedModel(model); + selectedModelRef.current = model; + if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。"); + return { body: createResponseRequest(messages, { + conversation: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined, + model, approvalMode: readBodyString(body, "approval_mode") ?? approvalModeRef.current, + capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [], + triggerContext: body?.trigger_context, taskContext: body?.task_context + }) }; } }), - [getAccessToken, getProjectId] + [getAccessToken, getConversationProjectId, scopeClient] ); const chat = useChat({ @@ -287,7 +325,7 @@ export function useWorkbenchAgent({ const { data: backendConnection, error: backendConnectionError - } = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), { + } = useSWR([...agentBootstrapKey, getAccessToken, conversationScope], () => fetchAgentBootstrap(scopeClient), { shouldRetryOnError: false }); const { @@ -295,7 +333,7 @@ export function useWorkbenchAgent({ isLoading: sessionHistoryLoading, isValidating: sessionHistoryValidating, mutate: mutateSessionHistory - } = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), { + } = useSWR([...agentSessionsKey, getAccessToken, conversationScope], () => fetchAgentSessions(scopeClient), { revalidateOnFocus: false, shouldRetryOnError: false }); @@ -339,7 +377,7 @@ export function useWorkbenchAgent({ frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry); setRegistry(backendConnection.registry); setModelOptions(backendConnection.models); - setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || ""); + setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current)); if (chatRef.current.status === "ready") { setConnectionStatus("online"); setStatusLabel("准备就绪"); @@ -402,6 +440,20 @@ export function useWorkbenchAgent({ }, [clearMobileCollapseTimer]); const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming; + const { data: pendingInputs = [], mutate: refreshPendingInputs } = useSWR( + sessionId ? ["agent", "response-inputs", getAccessToken, conversationScope, sessionId] : null, + () => clientRef.current.listResponseInputs(sessionId!), + { refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false } + ); + const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => { + if (!sessionIdRef.current) return; + try { + const updated = await clientRef.current.updateResponseInput(sessionIdRef.current, id, delivery); + await refreshPendingInputs((current = []) => current.map((input) => input.id === id ? updated : input), { revalidate: false }); + } catch { + showMapNotice({ tone: "warning", message: "消息可能已开始处理,无法修改发送方式。" }); + } finally { void refreshPendingInputs().catch(() => {}); } + }, [refreshPendingInputs]); const messages = useMemo( () => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides), [chat.messages, permissionOverrides, questionOverrides] @@ -446,6 +498,8 @@ export function useWorkbenchAgent({ return; } + if (event.type === "credential_refresh_required") { handleCredentialRefresh(event.data); return; } + if (event.type === "agent_message") { refreshAgentMessageInbox(); return; } if (event.type === "session_title") { const title = getDataString(event.data, "title"); if (title) { @@ -497,7 +551,7 @@ export function useWorkbenchAgent({ chatRef.current.setMessages((current) => applySessionStreamEvent(current, event)); }, - [claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory] + [refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope, refreshSessionHistory] ); const startSessionStreamSubscription = useCallback( @@ -557,6 +611,7 @@ export function useWorkbenchAgent({ } applySessionId(loadedSession.sessionId); + setTaskDiscussion(null); setSessionPurpose(loadedSession.purpose); setSessionTaskId(loadedSession.taskId ?? null); setSessionTitle(loadedSession.title); @@ -570,6 +625,7 @@ export function useWorkbenchAgent({ startSessionStreamSubscription(loadedSession.sessionId); } await refreshSessionHistory(); + return true; } catch (error) { showMapNotice({ id: "agent-history-status", @@ -582,20 +638,34 @@ export function useWorkbenchAgent({ [applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming] ); - const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => { + const openTaskCollaboration = useCallback(async (taskId: string, discussion?: InvestigationDiscussion) => { const normalizedTaskId = taskId.trim(); if (!normalizedTaskId) { throw new Error("工况任务 ID 不能为空。"); } + if (streaming) { + throw new Error("Agent 正在处理上一条指令,完成后再打开任务协作。"); + } + const normalizedContext = agentTaskDiscussionContextSchema.parse(discussion?.context ?? {}); + conversationScopeRef.current = "project"; + setConversationScope("project"); const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId); const collaborationSessionId = collaboration.session_id || collaboration.id; if (!collaborationSessionId) { throw new Error("Agent 未返回任务协作会话。"); } - await loadHistorySession(collaborationSessionId); - taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) }; + const loaded = await loadHistorySession(collaborationSessionId); + if (!loaded || sessionIdRef.current !== collaborationSessionId) { + throw new Error("任务协作会话未能打开,请重试。"); + } + setTaskDiscussion({ sessionId: collaborationSessionId, context: normalizedContext, reportGeneration: discussion?.reportGeneration }); + const draft = discussion?.prompt; + if (draft?.trim()) { + setPromptDraft((current) => current.trim() ? `${current}\n\n${draft.trim()}` : draft.trim()); + } + setPromptFocusRequest((current) => current + 1); return collaborationSessionId; - }, [loadHistorySession]); + }, [loadHistorySession, streaming]); const renameHistorySession = useCallback( async (nextSessionId: string, title: string) => { @@ -644,6 +714,7 @@ export function useWorkbenchAgent({ setSessionTitle("新对话"); setSessionPurpose("ordinary_chat"); setSessionTaskId(null); + setTaskDiscussion(null); setStatusLabel(nextStatusLabel); setConnectionStatus("online"); setPermissionOverrides({}); @@ -697,6 +768,13 @@ export function useWorkbenchAgent({ resetDraftSession(); }, [chat, resetDraftSession, stopSessionStreamSubscription]); + const changeConversationScope = useCallback(async (scope: "personal" | "project") => { + if (scope === conversationScopeRef.current || streaming) return; + await startNewSession(); + conversationScopeRef.current = scope; + setConversationScope(scope); + }, [startNewSession, streaming]); + const submitPrompt = useCallback( async (prompt: string) => { const normalizedPrompt = prompt.trim(); @@ -705,7 +783,19 @@ export function useWorkbenchAgent({ } if (streaming) { - showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" }); + try { + if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。"); + const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`); + // A failed list refresh must not turn an accepted message into a + // failed send and restore a draft that the user might submit twice. + void refreshPendingInputs((current = []) => receipt.status === "pending" + ? [...current.filter((input) => input.id !== receipt.id), receipt] + : current.filter((input) => input.id !== receipt.id), { revalidate: false }) + .then(() => refreshPendingInputs()).catch(() => {}); + } catch (error) { + showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" }); + throw error; + } return; } @@ -718,12 +808,12 @@ export function useWorkbenchAgent({ session_id: sessionIdRef.current ?? undefined, model: selectedModel || undefined, approval_mode: approvalMode, - task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined + task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined } } ); }, - [approvalMode, chat, selectedModel, streaming] + [approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion] ); const triggerEvent = useCallback( @@ -795,10 +885,10 @@ export function useWorkbenchAgent({ stopSessionStreamSubscription(); const activeSessionId = sessionIdRef.current; if (activeSessionId) { - void clientRef.current.abort(activeSessionId).catch(() => undefined); + void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined); } markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); - }, [chat, stopSessionStreamSubscription]); + }, [chat, refreshPendingInputs, stopSessionStreamSubscription]); const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => { setPermissionOverrides((current) => ({ @@ -907,6 +997,11 @@ export function useWorkbenchAgent({ return { apiClient: clientRef.current, + inputDelivery, + setInputDelivery, + pendingInputs, + updateInputDelivery, + agentMessageInbox, approvalMode, collapsePanel, expandPanel, @@ -917,6 +1012,8 @@ export function useWorkbenchAgent({ openMobilePanel, closeMobilePanel, connectionStatus, + conversationScope, + changeConversationScope, panelCollapsing, panelOpen, personaState, @@ -927,15 +1024,19 @@ export function useWorkbenchAgent({ renameHistorySession, deleteHistorySession, sessionHistory, + generatePromptSuggestions, sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating, sessionId, sessionPurpose, sessionTaskId, + taskDiscussionContext: taskDiscussion?.sessionId === sessionId ? taskDiscussion.context : undefined, + taskDiscussionReportGeneration: taskDiscussion?.sessionId === sessionId ? taskDiscussion.reportGeneration : undefined, sessionTitle, statusLabel, streamRenderState, selectedModel, promptDraft, + promptFocusRequest, setApprovalMode, setPromptDraft, setSelectedModel, diff --git a/src/features/workbench/hooks/use-workbench-geocoding.test.ts b/src/features/workbench/hooks/use-workbench-geocoding.test.ts new file mode 100644 index 0000000..014e2c9 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-geocoding.test.ts @@ -0,0 +1,86 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client"; +import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location"; +import { useWorkbenchGeocoding } from "./use-workbench-geocoding"; + +vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() })); +vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() })); +const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" }; +const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 }; +const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" }; +const remove = vi.fn(); +const options = { + active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true, + getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn() +}; +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(markGeocodingLocation).mockReturnValue({ remove } as unknown as ReturnType); +}); +afterEach(cleanup); + +describe("place search selection lifecycle", () => { + it("does not move until selection and replaces the previous marker", async () => { + vi.mocked(searchPlaces).mockResolvedValue(results); + const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options }); + act(() => result.current.changeAddress("南桥路")); + await act(() => result.current.search()); + expect(result.current.results).toEqual(results); + expect(result.current.result).toBeNull(); + expect(result.current.choosing).toBe(true); + expect(markGeocodingLocation).not.toHaveBeenCalled(); + act(() => result.current.select(location.id)); + expect(result.current.choosing).toBe(false); + expect(locateGeocodingResult).toHaveBeenCalledWith(options.mapRef.current, location, options.getPadding(), true); + act(() => result.current.select(other.id)); + expect(result.current.result).toEqual(other); + expect(remove).toHaveBeenCalledOnce(); + expect(markGeocodingLocation).toHaveBeenLastCalledWith(options.mapRef.current, other); + act(() => result.current.changeSelection()); + expect(result.current.choosing).toBe(true); + expect(result.current.result).toEqual(other); + const moves = vi.mocked(locateGeocodingResult).mock.calls.length; + act(() => result.current.select(other.id)); + expect(result.current.choosing).toBe(false); + expect(locateGeocodingResult).toHaveBeenCalledTimes(moves + 1); + rerender({ ...options, active: false }); + expect(result.current.result).toEqual(other); + act(() => result.current.clear()); + expect(remove).toHaveBeenCalledTimes(2); + expect(result.current.results).toBeNull(); + }); + it.each(["edit", "close", "project"])("ignores a late search after %s", async (action) => { + let resolve!: (value: PlaceSearchResults) => void; + vi.mocked(searchPlaces).mockReturnValue(new Promise((done) => { resolve = done; })); + const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options }); + act(() => result.current.changeAddress("旧地址")); + let pending!: Promise; + act(() => { pending = result.current.search(); }); + const signal = vi.mocked(searchPlaces).mock.calls[0][3]; + if (action === "edit") act(() => result.current.changeAddress("新地址")); + if (action === "close") rerender({ ...options, active: false }); + if (action === "project") rerender({ ...options, projectId: "project-2" }); + expect(signal?.aborted).toBe(true); + await act(async () => { resolve(results); await pending; }); + expect(result.current.results).toBeNull(); + expect(result.current.result).toBeNull(); + expect(result.current.pending).toBe(false); + }); + it("recovers from errors and clears the map when editing the query", async () => { + vi.mocked(searchPlaces).mockRejectedValueOnce(new Error("查询暂不可用")).mockResolvedValueOnce(results); + const { result, unmount } = renderHook(() => useWorkbenchGeocoding(options)); + act(() => result.current.changeAddress("南桥路")); + await act(() => result.current.search()); + expect(result.current.error).toBe("查询暂不可用"); + await act(() => result.current.search()); + expect(result.current.error).toBeNull(); + act(() => result.current.select(location.id)); + act(() => result.current.changeAddress("新地址")); + expect(remove).toHaveBeenCalledOnce(); + expect(result.current.result).toBeNull(); + expect(result.current.results).toBeNull(); + unmount(); + }); +}); diff --git a/src/features/workbench/hooks/use-workbench-geocoding.ts b/src/features/workbench/hooks/use-workbench-geocoding.ts new file mode 100644 index 0000000..ecf7548 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-geocoding.ts @@ -0,0 +1,104 @@ +import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client"; +import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location"; + +type GeocodingOptions = { + active: boolean; + projectId: string | null; + getAccessToken?: AccessTokenProvider; + mapRef: RefObject; + mapReady: boolean; + getPadding: () => PaddingOptions; + reducedMotion: boolean; + onLocate: () => void; +}; + +export function useWorkbenchGeocoding(options: GeocodingOptions) { + const [address, setAddress] = useState(""); + const [result, setResult] = useState(null); + const [results, setResults] = useState(null); + const [choosing, setChoosing] = useState(true); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const requestRef = useRef(null); + const optionsRef = useRef(options); + optionsRef.current = options; + + const cancel = useCallback(() => { + requestRef.current?.abort(); + requestRef.current = null; + setPending(false); + }, []); + const clear = useCallback(() => { + cancel(); + setResult(null); + setResults(null); + setChoosing(true); + setError(null); + }, [cancel]); + useEffect(() => { + clear(); + setAddress(""); + return () => requestRef.current?.abort(); + }, [options.projectId, options.getAccessToken, clear]); + useEffect(() => { + if (!options.active) cancel(); + }, [options.active, cancel]); + + const locate = useCallback(() => { + const current = optionsRef.current; + const map = current.mapRef.current; + if (!result || !current.mapReady || !map) return; + current.onLocate(); + locateGeocodingResult(map, result, current.getPadding(), current.reducedMotion); + }, [result]); + + useEffect(() => { + const map = options.mapRef.current; + if (!map || !options.mapReady || !result) return; + const marker = markGeocodingLocation(map, result); + locate(); + return () => { marker.remove(); }; + }, [options.mapRef, options.mapReady, result, locate]); + + async function search() { + clear(); + if (!address.trim()) { setError("请输入需要搜索的地址或地名"); return; } + const controller = new AbortController(); + requestRef.current = controller; + setPending(true); + try { + const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal); + if (controller.signal.aborted || requestRef.current !== controller) return; + setResults(value); + } catch (failure) { + if (controller.signal.aborted || requestRef.current !== controller) return; + setError(failure instanceof Error && failure.name === "TimeoutError" + ? "地名搜索超时,请稍后重试" + : failure instanceof Error ? failure.message : "地名搜索失败,请稍后重试"); + } finally { + if (requestRef.current === controller) { requestRef.current = null; setPending(false); } + } + } + + return { + address, result, results, choosing, pending, error, locate, search, + select: (id: string) => { + if (pending) return; + const item = results?.items.find((candidate) => candidate.id === id); + if (item) { + if (result?.id === id) locate(); + else setResult(item); + setChoosing(false); + } + }, + changeSelection: () => setChoosing(true), + enabled: options.mapReady, + changeAddress: (value: string) => { clear(); setAddress(value); }, + clear: () => { clear(); setAddress(""); } + }; +} + +export type WorkbenchGeocoding = ReturnType; diff --git a/src/features/workbench/hooks/use-workbench-sync-notice.test.ts b/src/features/workbench/hooks/use-workbench-sync-notice.test.ts new file mode 100644 index 0000000..5f4cdd9 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-sync-notice.test.ts @@ -0,0 +1,77 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { dismissMapNotice, showMapNotice } from "@/features/map/core"; +import { useWorkbenchSyncNotice } from "./use-workbench-sync-notice"; + +vi.mock("@/features/map/core", () => ({ + dismissMapNotice: vi.fn(), + showMapNotice: vi.fn() +})); + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("workbench reconnect notice", () => { + it("keeps repeated brief reconnects silent without leaving a pending warning", () => { + const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), { + initialProps: false + }); + + for (let cycle = 0; cycle < 3; cycle += 1) { + rerender(true); + act(() => vi.advanceTimersByTime(1_000)); + rerender(false); + act(() => vi.advanceTimersByTime(300_000)); + } + + expect(showMapNotice).not.toHaveBeenCalled(); + }); + + it("warns once after a sustained disconnect and dismisses on recovery", () => { + const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), { + initialProps: true + }); + act(() => vi.advanceTimersByTime(14_999)); + expect(showMapNotice).not.toHaveBeenCalled(); + // Snapshot refreshes and failed retries must not restart the waiting period. + rerender(true); + act(() => vi.advanceTimersByTime(1)); + expect(showMapNotice).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + id: "workbench-sync-reconnecting", title: "业务同步正在重连", duration: Infinity + })); + act(() => vi.advanceTimersByTime(60_000)); + expect(showMapNotice).toHaveBeenCalledOnce(); + + vi.mocked(dismissMapNotice).mockClear(); + rerender(false); + expect(dismissMapNotice).toHaveBeenCalledWith("workbench-sync-reconnecting"); + }); + + it("starts a fresh waiting period for a new outage", () => { + const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), { + initialProps: true + }); + act(() => vi.advanceTimersByTime(10_000)); + rerender(false); + rerender(true); + act(() => vi.advanceTimersByTime(5_000)); + expect(showMapNotice).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(10_000)); + expect(showMapNotice).toHaveBeenCalledOnce(); + }); + + it("cancels a pending warning when leaving the workbench", () => { + const { unmount } = renderHook(() => useWorkbenchSyncNotice(true)); + act(() => vi.advanceTimersByTime(10_000)); + unmount(); + act(() => vi.advanceTimersByTime(20_000)); + expect(showMapNotice).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workbench/hooks/use-workbench-sync-notice.ts b/src/features/workbench/hooks/use-workbench-sync-notice.ts new file mode 100644 index 0000000..050c688 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-sync-notice.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { dismissMapNotice, showMapNotice } from "@/features/map/core"; + +const RECONNECT_NOTICE_DELAY_MS = 15_000; +const RECONNECT_NOTICE_ID = "workbench-sync-reconnecting"; + +export function useWorkbenchSyncNotice(reconnecting: boolean) { + useEffect(() => { + if (!reconnecting) { + dismissMapNotice(RECONNECT_NOTICE_ID); + return; + } + + // Scheduled SSE renewal and brief outages normally recover within this window. + const timer = window.setTimeout(() => { + showMapNotice({ + id: RECONNECT_NOTICE_ID, + tone: "warning", + title: "业务同步正在重连", + message: "当前数据可能陈旧,连接恢复后将自动更新。", + duration: Infinity + }); + }, RECONNECT_NOTICE_DELAY_MS); + + return () => { + window.clearTimeout(timer); + dismissMapNotice(RECONNECT_NOTICE_ID); + }; + }, [reconnecting]); +} diff --git a/src/features/workbench/hooks/use-workbench-sync.ts b/src/features/workbench/hooks/use-workbench-sync.ts index 627c2d3..2fb67d1 100644 --- a/src/features/workbench/hooks/use-workbench-sync.ts +++ b/src/features/workbench/hooks/use-workbench-sync.ts @@ -6,6 +6,7 @@ import { type WorkbenchEvent, type WorkbenchSyncSnapshot } from "../api/workbench-sync-client"; +import { WorkbenchEventChannel } from "./workbench-event-channel"; const RECONCILE_INTERVAL_MS = 60_000; const RUNTIME_TOPICS = new Set([ @@ -30,6 +31,7 @@ export type WorkbenchSyncState = { detailResetGeneration: number; acknowledgeEventsThrough: (eventSequence: number) => void; refresh: () => Promise; + subscribePresentationEvents: WorkbenchEventChannel["subscribe"]; }; export function shouldApplyWorkbenchSnapshot(input: { @@ -44,6 +46,7 @@ export function shouldApplyWorkbenchSnapshot(input: { } export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState { + const channelRef = useRef(new WorkbenchEventChannel()); const [snapshot, setSnapshot] = useState(null); const [events, setEvents] = useState([]); const [status, setStatus] = useState("connecting"); @@ -63,6 +66,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc }, []); useEffect(() => { + const channel = channelRef.current; const controller = new AbortController(); let reconnectTimer: number | undefined; let reconcileTimer: number | undefined; @@ -135,13 +139,18 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc getAccessToken, signal: controller.signal, onOpen: () => { + channel.publish({ type: "open" }); statusRef.current = "live"; setStatus("live"); setError(null); retryMs = 1_000; }, - onReset: async () => { await loadSnapshot(true, true); }, + onReset: async () => { + channel.publish({ type: "reset" }); + await loadSnapshot(true, true); + }, onEvents: (nextEvents) => { + channel.publish({ type: "events", events: nextEvents }); const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current); if (unseen.length === 0) return; cursorRef.current = unseen.at(-1)!.event_seq; @@ -155,11 +164,13 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc } }); if (!controller.signal.aborted) { + channel.publish({ type: "disconnect" }); statusRef.current = "reconnecting"; setStatus("reconnecting"); } } catch (nextError) { if (controller.signal.aborted) return; + channel.publish({ type: "disconnect" }); statusRef.current = "reconnecting"; setStatus("reconnecting"); setError(nextError instanceof Error ? nextError.message : "工作台事件连接中断"); @@ -175,6 +186,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc if (statusRef.current !== "live") void loadSnapshot().catch(() => undefined); }, RECONCILE_INTERVAL_MS); return () => { + channel.publish({ type: "disconnect" }); controller.abort(); refreshSnapshotRef.current = null; if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); @@ -190,6 +202,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc error, detailResetGeneration, acknowledgeEventsThrough, - refresh + refresh, + subscribePresentationEvents: channelRef.current.subscribe }; } diff --git a/src/features/workbench/hooks/workbench-event-channel.test.ts b/src/features/workbench/hooks/workbench-event-channel.test.ts new file mode 100644 index 0000000..02c75a1 --- /dev/null +++ b/src/features/workbench/hooks/workbench-event-channel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WorkbenchEvent } from "../api/workbench-sync-client"; +import { WorkbenchEventChannel, type WorkbenchStreamNotification } from "./workbench-event-channel"; + +describe("workbench wire event channel", () => { + it("keeps independent observers live across business consumption, another observer failure and unsubscribe", () => { + const channel = new WorkbenchEventChannel(); + const first = vi.fn(); + const unsubscribeFirst = channel.subscribe(first); + expect(first).toHaveBeenCalledWith({ type: "disconnect" }); + channel.publish({ type: "open" }); + channel.subscribe((notification) => { if (notification.type === "events") throw new Error("observer failed"); }); + const second = vi.fn(); + channel.subscribe(second); + expect(second).toHaveBeenCalledWith({ type: "open" }); + const events = [{ event_seq: 11 }, { event_seq: 12 }] as WorkbenchEvent[]; + const businessPending = [...events]; + const notification: WorkbenchStreamNotification = { type: "events", events }; + channel.publish(notification); + businessPending.splice(0, businessPending.length); + expect(first).toHaveBeenLastCalledWith(notification); + expect(second).toHaveBeenLastCalledWith(notification); + expect(events.map((event) => event.event_seq)).toEqual([11, 12]); + const firstCallCount = first.mock.calls.length; + unsubscribeFirst(); + const next: WorkbenchStreamNotification = { type: "events", events: [{ event_seq: 13 }] as WorkbenchEvent[] }; + channel.publish(next); + expect(first).toHaveBeenCalledTimes(firstCallCount); + expect(second).toHaveBeenLastCalledWith(next); + channel.publish({ type: "disconnect" }); + expect(second).toHaveBeenLastCalledWith({ type: "disconnect" }); + }); +}); diff --git a/src/features/workbench/hooks/workbench-event-channel.ts b/src/features/workbench/hooks/workbench-event-channel.ts new file mode 100644 index 0000000..6a5f040 --- /dev/null +++ b/src/features/workbench/hooks/workbench-event-channel.ts @@ -0,0 +1,25 @@ +import type { WorkbenchEvent } from "../api/workbench-sync-client"; + +export type WorkbenchStreamNotification = + | { type: "open" | "disconnect" | "reset" } + | { type: "events"; events: WorkbenchEvent[] }; + +/** Presentation observes wire events before business acknowledgements or snapshot filtering. */ +export class WorkbenchEventChannel { + private listeners = new Set<(notification: WorkbenchStreamNotification) => void>(); + private live = false; + + subscribe = (listener: (notification: WorkbenchStreamNotification) => void) => { + this.listeners.add(listener); + listener({ type: this.live ? "open" : "disconnect" }); + return () => { this.listeners.delete(listener); }; + }; + + publish(notification: WorkbenchStreamNotification) { + if (notification.type !== "events") this.live = notification.type === "open"; + for (const listener of this.listeners) { + // One presentation observer must not interrupt authoritative business synchronization. + try { listener(notification); } catch { /* Observers own their error presentation. */ } + } + } +} diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index 42a74ad..20f4826 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -13,6 +13,7 @@ import { CalendarClock, Clock3 } from "lucide-react"; import { AgentCommandPanel, AgentPersona, + parseFrontendChart, toTrustedMapAction, type FrontendActionRequest, type UIEnvelopePayload, @@ -26,18 +27,19 @@ import { MapSourceStatusNotice, MapToolbar, MapZoom, - dismissMapNotice, showMapNotice, type MapToolbarItem, type MapSourceStatus } from "@/features/map/core"; import { env } from "@/shared/config/env"; +import { waitForAgentPresentation } from "./map/agent-presentation"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AnalysisResultsPanel } from "./components/analysis-results-panel"; import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel"; import type { PeripheralWorkOrder } from "./peripheral-sample/client"; import { MapDevPanel } from "./components/map-dev-panel"; +import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; @@ -68,11 +70,16 @@ import { } from "./workspace/workspace-context"; import { WORKBENCH_USER } from "./data/workbench-session"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; +import { useAgentPromptSuggestions } from "./hooks/use-agent-prompt-suggestions"; import { operationalTaskConditionId, useOperationalTasks } from "./hooks/use-operational-tasks"; import { useWorkbenchSync } from "./hooks/use-workbench-sync"; +import { useWorkbenchSyncNotice } from "./hooks/use-workbench-sync-notice"; +import { useInvestigationPresentation } from "./hooks/use-investigation-presentation"; +import type { InvestigationMapTarget } from "./api/investigation-report"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchMap } from "./hooks/use-workbench-map"; import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; +import { useWorkbenchGeocoding } from "./hooks/use-workbench-geocoding"; import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation"; import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout"; import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data"; @@ -88,7 +95,7 @@ import { getWorkbenchLayoutCssVariables, resolveMobileWorkbenchOverlayLayout } from "./layout/workbench-layout"; -import { getResponsiveWorkbenchPadding } from "./map/camera"; +import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera"; import { exportMapViewImage } from "./map/export-view"; import { parseScadaAnalysisItems } from "./map/scada-analysis"; import { @@ -135,6 +142,8 @@ export function MapWorkbenchPage({ const mapContainerRef = useRef(null); const [detailFeature, setDetailFeature] = useState(null); const [devPanelOpen, setDevPanelOpen] = useState(false); + const [investigationFollow, setInvestigationFollow] = useState(true); + const presentationLayerChangesRef = useRef(new Map()); const [impactVisible, setImpactVisible] = useState(false); const [activeToolId, setActiveToolId] = useState(null); const prefersReducedMotion = useReducedMotion(); @@ -158,25 +167,7 @@ export function MapWorkbenchPage({ useState>(INITIAL_LAYER_VISIBILITY); const [agentUiResults, setAgentUiResults] = useState([]); const workbenchSync = useWorkbenchSync(getAccessToken); - const workbenchSyncReconnecting = - workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null; - useEffect(() => { - const noticeId = "workbench-sync-reconnecting"; - if (!workbenchSyncReconnecting) { - dismissMapNotice(noticeId); - return; - } - - showMapNotice({ - id: noticeId, - tone: "warning", - title: "业务同步正在重连", - message: "当前数据可能陈旧,连接恢复后将自动更新。", - duration: Infinity - }); - - return () => dismissMapNotice(noticeId); - }, [workbenchSyncReconnecting]); + useWorkbenchSyncNotice(workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null); const { headerDataTime, scheduledConditions: runtimeScheduledConditions, @@ -192,6 +183,8 @@ export function MapWorkbenchPage({ operationalCursorTime } = useWorkbenchRuntimeData(getAccessToken, workbenchSync); const operationalTasks = useOperationalTasks(getAccessToken, workbenchSync); + const selectedInvestigationTask = operationalTasks.tasks.find((task) => + task.task_type === "event_investigation" && operationalTaskConditionId(task.task_id) === selectedConditionId) ?? null; const baseLayerOptions = useMemo( () => BASE_LAYER_OPTIONS.map((option) => ({ @@ -464,6 +457,36 @@ export function MapWorkbenchPage({ void mapController.clearPipeFlowCalculations(); }, [mapController, mapReady, realtimePipeFlows]); + const presentation = useInvestigationPresentation({ + enabled: investigationFollow, task: selectedInvestigationTask, projectId: projectPresentation.projectId, + sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController, + onClearLayers: restorePresentationLayers, + getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 } + }); + const geocoding = useWorkbenchGeocoding({ + active: activeToolId === "geocoding", + projectId: projectPresentation.projectId, + getAccessToken, mapRef, mapReady, + reducedMotion: Boolean(prefersReducedMotion), + onLocate: presentation.pauseCamera, + getPadding: () => { + const map = mapRef.current; + if (!map) return { top: 48, right: 48, bottom: 48, left: 48 }; + const padding = getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth); + return getResponsivePadding(map, isLargeScreen + ? { ...padding, right: Math.max(padding.right, 400) } + : { ...padding, bottom: Math.max(padding.bottom, 420) }); + } + }); + function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) { + void presentation.locateTargets(targets).catch((error) => showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" })); + } + useEffect(() => () => { + restorePresentationLayers(); + // Layer restoration belongs to the followed task, not each detail refresh. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedInvestigationTask?.task_id, investigationFollow]); + const activeAgentUiResults = useMemo( () => agentUiResults.filter((result) => result.sessionId === agent.sessionId), [agent.sessionId, agentUiResults] @@ -622,6 +645,7 @@ export function MapWorkbenchPage({ [layerControlItems] ); function handleToggleLayer(layerControlId: string, visible: boolean) { + presentationLayerChangesRef.current.delete(layerControlId); const map = mapRef.current; setLayerVisibility((current) => ({ ...current, [layerControlId]: visible })); @@ -637,6 +661,20 @@ export function MapWorkbenchPage({ map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); }); } + function handleTogglePresentationLayer(id: string, visible: boolean) { + const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false); + handleToggleLayer(id, visible); + presentationLayerChangesRef.current.set(id, before); + } + function restorePresentationLayers() { + for (const [id, visible] of presentationLayerChangesRef.current) handleToggleLayer(id, visible); + presentationLayerChangesRef.current.clear(); + } + const presentationLayerIds = new Set([ + ...presentation.highlightLayers, + ...(selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? []) + ].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id])); + const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible })); async function handleExportView(preset: ExportViewPreset) { const map = mapRef.current; @@ -731,8 +769,8 @@ export function MapWorkbenchPage({ if (workbenchAlerts.length === 0) { showMapNotice({ tone: "info", - title: "当前无待复核工况", - message: "当前没有需要汇总的工况提醒。" + title: "当前无待处理事项", + message: "当前没有需要汇总的待办或告警。" }); return; } @@ -756,13 +794,13 @@ export function MapWorkbenchPage({ showMapNotice({ tone: "success", title: "已发起工况汇总", - message: "已将待复核工况发送到 Agent。" + message: "已将待办与告警发送给 Agent。" }); } catch (error) { showMapNotice({ tone: "error", title: "工况汇总发起失败", - message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。" + message: error instanceof Error ? error.message : "无法将待办与告警发送给 Agent。" }); } } @@ -837,6 +875,34 @@ export function MapWorkbenchPage({ } async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) { + signal.throwIfAborted(); + if (request.name === "locate_features") { + const action = toTrustedMapAction("locate_features", request.params); + if (!action || action.type !== "locate_features" || !action.layer) throw new Error("INVALID_MAP_TARGET"); + const result = await mapController.locateFeatures(action.layer as Parameters[0], action.featureIds, signal); + return result; + } + if (request.name === "zoom_to_map") { + const action = toTrustedMapAction("zoom_to_map", request.params); + if (!action || action.type !== "zoom_to_map") throw new Error("INVALID_MAP_CAMERA"); + const map = mapRef.current; + if (!map || !mapReady) throw new Error("MAP_NOT_READY"); + map.jumpTo({ center: action.center, zoom: action.zoom }); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + signal.throwIfAborted(); + return { fitted: true }; + } + if (request.name === "show_chart" || request.name === "view_history") { + const chart = parseFrontendChart(request.name === "view_history" ? request.params.chart : request.params); + if (!chart) throw new Error("INVALID_CHART_DATA"); + const data = { x_data: chart.x_data, series: chart.series }; + const envelope: AgentUiResult["envelope"] = request.name === "view_history" + ? { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: { feature_id: request.params.element_id, metric: chart.title, start_time: request.params.start, end_time: request.params.end }, data } + : { kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data }; + setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope }]); + await waitForAgentPresentation(request.actionId, signal); + return { displayed: true, series_count: chart.series.length }; + } if (request.name === "render_scada_analysis") { const items = parseScadaAnalysisItems(request.params.items); if (!items) throw new Error("INVALID_SCADA_ANALYSIS"); @@ -875,8 +941,7 @@ export function MapWorkbenchPage({ async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) { mapController.clearScadaAnalysis(); - await agent.openTaskCollaboration(taskId, discussion?.context); - if (discussion?.prompt) await agent.submitPrompt(discussion.prompt); + await agent.openTaskCollaboration(taskId, discussion); } async function handleDeleteAgentHistorySession(sessionId: string) { @@ -995,6 +1060,7 @@ export function MapWorkbenchPage({ } const toolbarPanelProps: ToolbarPanelProps = { + geocoding, activeToolId, activeMeasureModeId, activeMeasureUnitId, @@ -1067,6 +1133,20 @@ export function MapWorkbenchPage({ const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId ? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId) : undefined; + const promptSuggestions = useAgentPromptSuggestions({ + enabled: agent.conversationScope === "project" && (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming && + agent.sessionPurpose === "ordinary_chat" && agent.connectionStatus === "online" && !agent.sessionHistoryLoading && + !scheduledConditionsLoading && !operationalTasks.loading, + model: agent.selectedModel, + sessionId: agent.sessionId, + request: agent.generatePromptSuggestions, + alerts: workbenchAlerts, + alertDataSource, + conditions: scheduledConditions, + runtimeDataSource, + artifact: latestAnalysisArtifact ?? null, + sessions: agent.sessionHistory, + }); const agentPanelProps: Omit< ComponentProps, "collapsing" | "onCollapse" @@ -1075,15 +1155,24 @@ export function MapWorkbenchPage({ sessionTitle: agent.sessionTitle, sessionHistory: agent.sessionHistory, sessionHistoryLoading: agent.sessionHistoryLoading, + promptSuggestions: promptSuggestions.suggestions, + promptSuggestionsLoading: promptSuggestions.loading, + promptSuggestionsUnavailable: promptSuggestions.unavailable, activeSessionId: agent.sessionId, qaOnly: agent.sessionPurpose === "task_collaboration", statusLabel: agent.statusLabel, connectionStatus: agent.connectionStatus, streaming: agent.streaming, + inputDelivery: agent.inputDelivery, + onInputDeliveryChange: agent.setInputDelivery, + pendingInputs: agent.pendingInputs, + onPendingInputDeliveryChange: agent.updateInputDelivery, messages: agent.messages, streamRenderState: agent.streamRenderState, modelOptions: agent.modelOptions, selectedModel: agent.selectedModel, + conversationScope: agent.conversationScope, + onConversationScopeChange: agent.changeConversationScope, approvalMode: agent.approvalMode, uiResults: activeAgentUiResults, reviewPanel: collaborationReviewTask ? : undefined, workspaceContext: agentWorkspaceContext, prompt: agent.promptDraft, + promptReference: collaborationReviewTask ? { + title: collaborationReviewTask.title, + placeholder: "可询问调查进展、讨论报告或补充信息", + detail: [ + agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration + ? `第 ${agent.taskDiscussionReportGeneration} 轮` : null, + agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null, + agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null, + agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null + ].filter(Boolean).join(" · ") + } : undefined, onRefreshHistory: agent.refreshSessionHistory, onStartNewSession: handleStartNewAgentSession, onLoadHistorySession: handleLoadAgentHistorySession, @@ -1104,6 +1204,12 @@ export function MapWorkbenchPage({ onSelectModel: agent.setSelectedModel, onApprovalModeChange: agent.setApprovalMode, onPromptChange: agent.setPromptDraft, + promptFocusRequest: agent.promptFocusRequest, + messageInbox: agent.sessionPurpose === "ordinary_chat" ? { + ...agent.agentMessageInbox, + onLoadMore: agent.agentMessageInbox.loadMore, + onRefresh: agent.agentMessageInbox.refresh + } : undefined, onSubmitPrompt: agent.submitPrompt, onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal), onStopPrompt: agent.stopPrompt, @@ -1259,6 +1365,13 @@ export function MapWorkbenchPage({ } mapOverlay={
+ {investigationFollow && selectedInvestigationTask && !devPanelOpen ? = 1536 ? 960 : 880) : 432) + 64 : 64) - 24))} + relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer} + onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay} + /> : null} {!devPanelOpen ? ( <>
@@ -1278,7 +1391,9 @@ export function MapWorkbenchPage({ } as CSSProperties } > -
+
{ if (investigationFollow) presentation.pauseCamera(); }} + onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}> - {conditionFeedMounted && !devPanelOpen ? ( -
+ {conditionFeedMounted ? ( +
setDevPanelOpen(false)} /> ) : null} @@ -1409,7 +1527,7 @@ export function MapWorkbenchPage({ ) : null}
{editing ?

工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。

: null}