Compare commits

...
10 Commits
Author SHA1 Message Date
jiang 0995172828 fix: align drainage frontend configuration 2026-07-22 15:01:35 +08:00
jiang 72850761ce feat: migrate drainage frontend to Vite 2026-07-22 12:47:36 +08:00
jiang e0cfa3d6eb fix: compact collapsed agent rail 2026-07-21 14:51:59 +08:00
jiang ebe6cdc7f1 fix: improve mobile workbench controls 2026-07-21 12:35:36 +08:00
jiang caf06a8a82 style: improve SCADA analysis visibility 2026-07-21 10:17:26 +08:00
jiang 21791b9cee fix: prevent agent progress layout jitter
The previous popLayout stabilization kept margin-based spacing, so exiting rows temporarily added 6px to bounded Agent lists.

Use flex gaps and a geometry regression check so exited rows no longer change container height.
2026-07-21 10:15:19 +08:00
jiang 7fbd8a5618 feat: extend agent-driven map workbench 2026-07-20 19:57:35 +08:00
jiang 86e9b08235 fix: externalize edge tts server package 2026-07-20 19:32:40 +08:00
jiang 72c8190a78 fix: stabilize agent progress display 2026-07-20 19:13:43 +08:00
jiang 3c0271bae8 fix: disable fullscreen table token animation 2026-07-20 18:47:26 +08:00
228 changed files with 11348 additions and 6069 deletions
@@ -34,4 +34,4 @@ Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy correc
- Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning. - Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning.
- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels. - Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels.
- Preserve list identity and scroll position when a collapsed list expands into a detail workspace. - Preserve list identity and scroll position when a collapsed list expands into a detail workspace.
- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected. - Validate frontend UI changes with `pnpm lint` and `pnpm typecheck`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
+11
View File
@@ -0,0 +1,11 @@
node_modules
.git
.next
dist
dist-server
coverage
playwright-report
test-results
.env
.env.*
*.log
+9 -3
View File
@@ -1,3 +1,9 @@
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here DRAINAGE_MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
DRAINAGE_ENABLE_DEV_PANEL=false
DRAINAGE_ENABLE_MSW=false
# Optional server-only Edge TTS voice. Never exposed through runtime-config.js.
EDGE_TTS_VOICE=zh-CN-XiaoxiaoNeural
+13 -6
View File
@@ -1,10 +1,17 @@
.DS_Store .DS_Store
.env.local node_modules
.next/ .next
node_modules/ dist
playwright-report/ dist-server
test-results/ coverage
tsconfig.tsbuildinfo playwright-report
test-results
.env
.env.*
!.env.example
*.log
*.tsbuildinfo
*:Zone.Identifier
供水管网_Agent_WebGIS_设计与技术框架.md 供水管网_Agent_WebGIS_设计与技术框架.md
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md 现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
todo.md todo.md
+227
View File
@@ -0,0 +1,227 @@
# 亚克力设计类别与组件使用清单
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
## 总览
当前材质体系分为 3 组,共 13 个语义类:
| 分组 | 数量 | 类别 | 是否使用背景模糊 |
|---|---:|---|---|
| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 |
| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 |
| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 |
只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。
## 1. 外层亚克力与临时玻璃
这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。
### `acrylic-navigation`
- 用途:全局导航栏。
- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。
- 特征:保留底部分隔和下投影,不使用顶部高光。
- 当前组件:
- `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`
### `acrylic-panel`
- 用途:承载持续显示或需要集中阅读的主要浮动面板。
- 模糊:使用面板级模糊,20px 至 24px。
- 当前组件:
- `AgentCommandPanel`Agent 命令面板外壳。
- `AgentCollapsedRail`Agent 收起状态侧栏。
- `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。
- `LayerControl``ControlPanel``Legend``BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。
- `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。
- `FeatureInsightPanel`,要素洞察面板。
- `MapDevPanel`,地图开发调试面板。
- `AlertMenu``ScenarioMenu``UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。
- 直接或间接涉及文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/map/core/components/map-control-styles.ts`
- `src/features/map/core/components/notice.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/feature-popover.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/map-dev-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control`
- 用途:始终悬浮在地图上的紧凑型独立控件。
- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。
- 当前组件:
- `Toolbar`,地图主工具栏。
- `DrawToolbar`,绘制工具栏。
- `ZoomControl`,缩放控件。
- `ScaleLine`,比例尺。
- `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`src/features/map/core/components/map-control-styles.ts`
### `glass-transient`
- 用途:短时出现、需要突出当前任务状态的玻璃浮层。
- 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件:
- `AgentTaskTicker` 的活动任务卡片。
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`
## 2. 内部实体表面
这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。
### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
- 后续可能增加的对象目录、调度队列等常驻侧栏。
- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`
### `surface-control`
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
- 当前组件范围:
- Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。
- 图层、量测和通用控制面板中的交互控件。
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
- 任务浮条的非活动卡片。
- 要素洞察面板的内部按钮。
- Header 下拉菜单的内部控制区。
- 通用 `IconButton`
- 主要文件:
- `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`
- `src/features/workbench/components/agent-task-ticker.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well`
- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。
- 当前组件:
- `LayerPanel``LayerControl` 的未激活条目。
- `MeasurePanel``DrawToolbar``AnnotationPanel` 的内部区域。
- `ControlPanel``Legend``BaseLayersControl` 的分组与空白区域。
- Header 下拉菜单的普通菜单项。
- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。
### `surface-reading`
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
- 当前组件范围:
- Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-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`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/feature-property-list.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面
语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel``surface-control``surface-reading` 来建立界面层级。
| 类名 | 含义 | 当前使用位置 |
|---|---|---|
| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 |
| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 |
| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 |
| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 |
| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 |
## 未使用样式的建议落点
### `surface-dock`
建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用:
- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。
- `ScheduledConditionFeed``ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。
- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。
如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`
### `material-tone-danger`
建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括:
- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。
- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。
- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`
- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。
不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。
### `material-tone-agent`
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`
- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。
## 共享类映射
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
| 共享常量 | 实际材质类 | 典型用途 |
|---|---|---|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 |
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 |
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 |
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 |
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 |
## 自适应与无障碍规则
- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。
- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。
- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。
- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。
- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。
## 使用约束
1. 一个浮动区域只允许最外层使用 `acrylic-*``glass-transient`
2. 外层亚克力内部使用 `surface-control``surface-well``surface-reading`,不要叠加第二层模糊。
3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。
4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。
5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。
6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。
## 当前待清理项
- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。
- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。
- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。
- Agent 面板仍有少量局部类直接使用 `--surface-control``--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。
+71 -110
View File
@@ -1,130 +1,91 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Project Structure
This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS. This repository is a Vite React single-page business system for an Agent-driven
drainage-network WebGIS. Runtime TypeScript lives under `src/`.
- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin. - `src/app/`: application shell and providers.
- `lib/`: shared utilities that are not owned by a feature. - `src/features/map/core/`: reusable map UI and generic map helpers. This layer
- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows. must not know drainage-network business layer IDs or Agent workflows.
- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here. - `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly. adapters, scenarios, and workbench-specific interactions.
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features. - `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
- Root markdown files are product/design references and should not be treated as runtime code. recommendations.
- `src/shared/`: reusable UI, AI elements, runtime configuration, and utilities.
- `src/mocks/`: MSW handlers and fixtures.
- `tests/browser/`: product-specific Playwright regressions.
- `public/`: static drainage and SCADA assets plus the runtime-config placeholder.
- `server/`: same-origin Edge TTS adapter used by Vite and the production container.
## Build, Test, and Development Commands Do not add Next.js APIs or general BFF routes. Agent behavior belongs in the
Agent service; the local `server/` exception is limited to the same-origin Edge
TTS network adapter. Browser-safe GeoServer reads may happen directly when CORS
is enabled.
## Commands
Use `pnpm`. Use `pnpm`.
```bash ```bash
pnpm dev pnpm dev
```
Starts the local development server at `http://localhost:3000`.
```bash
pnpm build pnpm build
pnpm typecheck
pnpm lint
pnpm test
pnpm test:browser
``` ```
Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes.
The Vite development server listens on `http://127.0.0.1:5173` by default.
`pnpm build` runs TypeScript validation before producing `dist/`.
## Coding Style
- Use TypeScript and React function components with explicit exported types at
module boundaries.
- Use two-space indentation and kebab-case filenames.
- Use PascalCase for components and types, camelCase for functions and values,
and `useXxx` for hooks.
- Keep orchestration components thin. Move sources, layers, event handling, and
feature adapters into focused modules.
- Keep MapLibre sources, layers, camera helpers, and interactions outside
presentational components.
- Keep Agent rendering schema-driven. Validate Agent output before it reaches
React, and represent map actions as typed data that can be previewed,
confirmed, applied, and rolled back.
## Domain Boundaries
- `src/features/map/core` owns generic controls, legends, camera helpers, and
reusable selection primitives.
- `src/features/workbench` owns conduits, junctions, orifices, outfalls, pumps,
SCADA sources, simulation overlays, and drainage-specific behavior.
- `src/features/agent` owns task reasoning UI and controlled recommendations.
Do not reintroduce supply-network layer IDs or assets into the drainage config.
GeoServer layer names, workspace defaults, map style tokens, titles, and Agent
copy must remain drainage-specific.
## Verification
Frontend changes should pass:
```bash ```bash
pnpm exec tsc --noEmit pnpm lint
``` pnpm typecheck
Runs TypeScript validation without emitting files. pnpm test
## Coding Style & Naming Conventions
- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components.
- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports.
- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`.
- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`.
- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`.
- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`.
- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`.
- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`.
- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`.
- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components.
- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions.
## Code Organization Style
Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file.
Recommended future pattern:
```txt
features/map/core/
components/
base-layers-control.tsx
scale-line.tsx
toolbar.tsx
zoom.tsx
legend.tsx
layer-control.tsx
hooks/
use-map-camera.ts
use-layer-visibility.ts
use-map-selection.ts
map/
style.ts
layer-utils.ts
features/workbench/
map-workbench-page.tsx
components/
workbench-top-bar.tsx
simulation-dock.tsx
simulation-result-panel.tsx
hooks/use-workbench-map.ts
map/sources.ts
map/layers.ts
map/annotation-layers.ts
state/types.ts
features/agent/
components/agent-assistant-panel.tsx
data/agent-task.ts
types.ts
```
Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions.
Keep the growth boundary clear:
- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives.
- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions.
- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`.
## Testing Guidelines
There is no formal test suite yet. For now, required checks are:
```bash
pnpm exec tsc --noEmit
pnpm build pnpm build
``` ```
When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`. Run focused Playwright tests when changing visible behavior, responsive layout,
runtime configuration, or map interactions. Name tests after behavior.
## Commit & Pull Request Guidelines ## Configuration and Security
This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as: Browser configuration is injected through `/runtime-config.js` and validated in
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables; legacy `NEXT_PUBLIC_*`
names are not supported.
```txt Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
Add direct GeoServer MVT sources Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
Refactor map workbench shell in untracked environment files.
```
Pull requests should include:
- Summary of user-visible changes.
- Notes on map/data source changes.
- Screenshots for UI changes.
- Verification commands run.
## Security & Configuration Tips
Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from:
```env
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
```
GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts.
+19
View File
@@ -0,0 +1,19 @@
:80 {
root * /srv
encode zstd gzip
@runtimeConfig path /runtime-config.js
header @runtimeConfig Cache-Control "no-store"
@immutableAssets path /assets/*
header @immutableAssets Cache-Control "public, max-age=31536000, immutable"
handle /api/tts/edge {
reverse_proxy 127.0.0.1:8790
}
handle {
try_files {path} /index.html
file_server
}
}
+8 -8
View File
@@ -250,7 +250,7 @@ The task ticker is always centered against the full viewport, independent of Age
#### CSS Token Contract #### CSS Token Contract
`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding. `src/styles.css` is the single source of truth for surface values. `src/features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
### Motion ### Motion
@@ -360,7 +360,7 @@ The Agent panel is a persistent command instrument, so its outer shell should us
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer. Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers. Collapsed Agent state is a floating 72px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
Required sections: Required sections:
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
### Map Core Controls ### Map Core Controls
Reusable controls under `features/map/core/components` should share one compact map-instrument language. Reusable controls under `src/features/map/core/components` should share one compact map-instrument language.
#### Control Surface #### Control Surface
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
Recommended implementation direction: Recommended implementation direction:
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`. - Keep route files thin and move feature UI into `src/features/workbench`, `src/features/map/core`, and `src/features/agent`.
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values. - Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components. - Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
- Define operational colors as shared constants for both map layers and UI legends. - Define operational colors as shared constants for both map layers and UI legends.
@@ -830,16 +830,16 @@ Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component s
Target component layering: Target component layering:
```txt ```txt
shared/ui src/shared/ui
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants. Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
features/map/core/components src/features/map/core/components
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend. Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
features/workbench/components src/features/workbench/components
Water-network workbench components composed from shared UI and map core controls. Water-network workbench components composed from shared UI and map core controls.
features/agent/components src/features/agent/components
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI. Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
``` ```
+23
View File
@@ -0,0 +1,23 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM caddy:2.10.2-alpine
RUN apk add --no-cache jq nodejs
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/drainage-frontend-entrypoint
COPY --from=build /app/dist /srv
COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/drainage-frontend/edge-tts-server.cjs
RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/drainage-frontend-entrypoint"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+1 -1
View File
@@ -8,7 +8,7 @@
## 颜色系统 ## 颜色系统
运行时唯一来源是 `features/workbench/map/map-colors.ts``MAP_STYLE_TOKENS`。业务图层、模拟、工具、图例和测试不得建立平行颜色常量。 运行时唯一来源是 `src/features/workbench/map/map-colors.ts``MAP_STYLE_TOKENS`。业务图层、模拟、工具、图例和测试不得建立平行颜色常量。
| Token | HEX | OKLCH | 用途 | | Token | HEX | OKLCH | 用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
+72 -9
View File
@@ -1,22 +1,85 @@
# Drainage Network Frontend # Next TJWater Drainage Frontend
排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建 排水管网 WebGIS Agent 工作台。界面以地图为主视图,集成排水管渠、检查井、排放口、泵、SCADA 监测、异常工况、Agent 对话和受控前端动作
## 开发 项目已从 Next.js App Router 迁移到 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
## 主要功能
- 排水管网 MVT 图层和 SCADA 监测点展示
- 地图缩放、测量、绘制、图层控制、要素定位和视图导出
- 异常工况列表、详情分析和调度任务跟踪
- Agent 流式对话、推荐问题、会话管理和权限批准
- 经过 Schema 校验的 UI Envelope 和前端动作执行
- 运行时配置注入、MSW 本地模拟和开发面板
## 开发环境
```bash ```bash
corepack enable
pnpm install pnpm install
cp .env.example .env.local
pnpm dev pnpm dev
``` ```
默认访问地址为 <http://localhost:3000> 开发服务器默认地址为 <http://127.0.0.1:5173>。完整对话功能需要同时启动 Agent 后端
## 校验 ## 运行时配置
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_``NEXT_PUBLIC_` 前缀。开发环境可在 `.env.local` 中设置:
| 变量 | 默认值 | 说明 |
| ------------------------------ | --------------------------------------------- | ------------------------------------ |
| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 |
| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 |
| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。所有 Agent 请求由浏览器直接访问 `DRAINAGE_AGENT_API_BASE_URL`。语音播放请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
## 常用命令
```bash ```bash
pnpm lint pnpm dev # 启动 Vite 开发服务器
pnpm exec tsc --noEmit pnpm build # 类型检查并生成生产构建
pnpm build pnpm preview # 预览生产构建
pnpm typecheck # 运行 TypeScript 检查
pnpm lint # 运行 ESLint
pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试
``` ```
地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。 ## Docker
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`,同时启动仅监听容器回环地址的 Edge TTS 适配器。
```bash
docker build -t next-tjwater-drainage-frontend .
docker run --rm -p 8080:80 --env-file .env.local next-tjwater-drainage-frontend
```
启动后访问 <http://localhost:8080>。
## 项目结构
```text
src/
├── app/ 应用入口、Provider 和基础浏览器回归测试
├── features/
│ ├── agent/ Agent 协议、会话、动作执行和界面组件
│ ├── map/ 地图核心控件
│ └── workbench/ 排水业务、SCADA、调度面板和地图编排
├── mocks/ MSW handlers 和测试数据
├── shared/ 通用 UI、AI 元素、配置和工具
├── test/ Vitest 测试环境
└── styles.css Tailwind CSS 入口和全局样式
```
产品级浏览器回归仍位于 `tests/browser/`
## 构建说明
生产构建可能提示 deck.gl 和 luma.gl 的第三方循环分块,以及 Shiki、Mermaid 语言包超过 Rollup 默认阈值。只要构建成功,这些提示不影响静态产物生成。
-12
View File
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { MAP_URL } from "@/lib/config";
const TYPES = { junction: "drainage:geo_junctions", conduit: "drainage:geo_conduits", pipe: "drainage:geo_conduits", orifice: "drainage:geo_orifices", outfall: "drainage:geo_outfalls", pump: "drainage:geo_pumps" } as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null;
if (!body || !Array.isArray(body.ids) || body.ids.length < 1 || body.ids.length > 100 || !body.ids.every((id) => typeof id === "string" && id.trim())) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400 });
if (typeof body.featureType !== "string" || !(body.featureType in TYPES)) return NextResponse.json({ code: "UNSUPPORTED_FEATURE_TYPE" }, { status: 422 });
const ids = body.ids.map((id) => String(id).trim()); const cql = `id IN (${ids.map((id) => `'${id.replaceAll("'", "''")}'`).join(",")})`;
const url = new URL(`${MAP_URL}/drainage/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: TYPES[body.featureType as keyof typeof TYPES], outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString();
try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), cache: "no-store" }); const data = await response.json(); if (!response.ok || !data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) throw new Error(); return NextResponse.json({ type: "FeatureCollection", features: data.features.slice(0, 100) }); }
catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); }
}
-58
View File
@@ -1,58 +0,0 @@
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const AGENT_BACKEND_BASE_URL =
process.env.AGENT_API_INTERNAL_BASE_URL ??
process.env.NEXT_PUBLIC_AGENT_API_BASE_URL ??
"http://127.0.0.1:8787";
type RouteContext = {
params: Promise<{ path?: string[] }>;
};
export async function GET(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function POST(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function PATCH(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
export async function DELETE(request: NextRequest, context: RouteContext) {
return proxyAgentRequest(request, context);
}
async function proxyAgentRequest(request: NextRequest, context: RouteContext) {
const { path = [] } = await context.params;
const upstreamUrl = new URL(`/api/v1/agent/chat/${path.map(encodeURIComponent).join("/")}`, AGENT_BACKEND_BASE_URL);
upstreamUrl.search = request.nextUrl.search;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("content-length");
const body = request.method === "GET" || request.method === "HEAD" ? undefined : request.body;
const upstreamResponse = await fetch(upstreamUrl, {
method: request.method,
headers,
body,
cache: "no-store",
duplex: body ? "half" : undefined
} as RequestInit & { duplex?: "half" });
const responseHeaders = new Headers(upstreamResponse.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders
});
}
-25
View File
@@ -1,25 +0,0 @@
import type { Metadata } from "next";
import { MapToaster } from "@/features/map/core/components/notice";
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
import "./globals.css";
export const metadata: Metadata = {
title: "排水管网",
description: "Agent 编排型沉浸式排水管网业务工作台"
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
{children}
<MapToaster />
</body>
</html>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { MapWorkbenchPage } from "@/features/workbench";
export default function Home() {
return <MapWorkbenchPage />;
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared",
"ui": "@/shared/ui",
"lib": "@/lib",
"utils": "@/lib/utils",
"hooks": "@/shared/hooks"
},
"iconLibrary": "lucide"
}
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
runtime_config=$(jq -cn \
--arg mapboxAccessToken "${DRAINAGE_MAPBOX_ACCESS_TOKEN:-}" \
--arg mapUrl "${DRAINAGE_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${DRAINAGE_GEOSERVER_WORKSPACE:-wenzhou}" \
--arg agentApiBaseUrl "${DRAINAGE_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg enableDevPanel "${DRAINAGE_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${DRAINAGE_ENABLE_MSW:-false}" \
'{
DRAINAGE_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
DRAINAGE_MAP_URL: $mapUrl,
DRAINAGE_GEOSERVER_WORKSPACE: $geoserverWorkspace,
DRAINAGE_AGENT_API_BASE_URL: $agentApiBaseUrl,
DRAINAGE_ENABLE_DEV_PANEL: $enableDevPanel,
DRAINAGE_ENABLE_MSW: $enableMsw
}')
printf 'globalThis.__DRAINAGE_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js
node /usr/local/lib/drainage-frontend/edge-tts-server.cjs &
tts_pid=$!
"$@" &
app_pid=$!
terminate() {
kill "$app_pid" "$tts_pid" 2>/dev/null || true
}
trap terminate INT TERM
status=0
wait -n "$app_pid" "$tts_pid" || status=$?
terminate
wait "$app_pid" 2>/dev/null || true
wait "$tts_pid" 2>/dev/null || true
exit "$status"
+81
View File
@@ -0,0 +1,81 @@
# Rive Persona 内存问题排查记录
日期:2026-07-21
## 背景
`next-webgis` 中的 Obsidian Persona 使用 Rive WebGL2 渲染。迁移到
`next-tjwater-frontend` 的 Vite、React 运行环境后,页面加载 Persona 时出现浏览器
`Out of Memory`,同时需要保留原有羽化灰黑球体的视觉效果。
本次排障验证使用 `@rive-app/react-webgl2@4.29.3`,依赖声明保留 4.x 兼容版本范围。
资源仍使用:
```text
https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv
```
## 已证实的触发条件
在同一 Chromium 环境下进行了对照测试。以下内存值是排障时的单次观测范围,
用于判断增长趋势,不应视为跨设备性能基准。
| 场景 | 结果 |
| ------------------------------------------------- | ----------------------------------------------- |
| Vite、WebGL2、绑定 Rive View Model | 渲染进程快速增长到约 4.6 GB,随后失去响应或崩溃 |
| Vite、阻止 `.riv` 加载 | 约 29 MB 至 61 MB,保持稳定 |
| Vite、Canvas、不绑定 View Model | 内存稳定,但渲染边缘和质感不符合原设计 |
| Vite、Canvas、调用 `bindViewModelInstance` | 再次出现持续增长 |
| Vite、WebGL2 4.29.3、调用 `bindViewModelInstance` | 再次出现持续增长 |
| Vite、WebGL2 4.29.3、不绑定 View Model | 约 71 MB 至 116 MB,最终约 78 MB |
| `next-webgis` 原实现 | 约 165 MB 至 221 MB,未出现持续增长 |
可以确认:在迁移后的 Vite 应用中,`bindViewModelInstance` 是泄漏的稳定触发点。
`useViewModelInstance` 内部也会执行该绑定,因此仅替换颜色 hook 不能解决问题。
不能仅凭现有证据断言缺陷完全属于 Vite、React 或 Rive 某一方。`next-webgis` 的同一
资源和完整绑定能够稳定运行,说明这是新运行环境与 Rive View Model 绑定生命周期的
兼容问题。若要定位到 Rive 内部对象,需要进一步制作最小复现并进行运行时级堆分析。
## 为什么不是 TypeScript 7
TypeScript 在该项目中只通过 `tsc --noEmit` 做静态检查。浏览器实际执行的是 Vite 和
SWC 转换后的 JavaScript。泄漏可以通过是否调用 Rive View Model 绑定稳定开关,因此
与 TypeScript 类型检查器无关。
## 最终修改
1. 保留 Rive WebGL2、Obsidian 资源和 `default` 状态机。
2. 移除 `useViewModel``useViewModelInstance``useViewModelInstanceColor` 及所有
`bindViewModelInstance` 调用。
3. 使用 CSS `brightness``contrast` 保持浅色界面中的灰黑视觉,不再监听并不存在的
应用深色主题。
4. Persona 仅在元素进入视口且页面可见时挂载,避免桌面和移动响应式节点同时初始化。
5. 首次初始化延迟到下一帧,使 React Strict Mode 的探测挂载可以在创建 Rive 实例前取消。
6. 保留懒加载和加载失败占位,但移除实验阶段的多皮肤、重复状态推导和无用事件透传。
7. 移除 Persona 环境变量开关,动画由组件生命周期直接管理。
## 回归保护
- `src/shared/ai-elements/persona.test.tsx` 验证加载的是 Obsidian 和 `default` 状态机,
同时保证不会调用 `bindViewModelInstance`
- `src/app/app.e2e.ts` 验证页面只发出一次 `.riv` 请求。
- 桌面 1440 x 900 和移动端 375 x 812 均验证无横向溢出。
验证命令:
```bash
pnpm typecheck
pnpm lint
pnpm test
pnpm test:browser
pnpm build
```
## 后续修改约束
- 在独立最小复现和持续内存采样通过前,不要重新引入 Rive View Model 绑定。
- 不要用 CSS 隐藏两个已挂载的 Persona,响应式副本必须通过可见性挂载保持单实例。
- 不要移除 Strict Mode 延迟初始化,除非重新验证开发和生产生命周期。
- 更新 Rive 版本时,至少验证单次 `.riv` 请求、状态机切换、页面切换后的实例清理和
30 秒以上的浏览器进程内存趋势。
+43
View File
@@ -0,0 +1,43 @@
import babelParser from "@babel/eslint-parser";
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
export default [
{
ignores: [
".next/**",
"dist/**",
"dist-server/**",
"node_modules/**",
"playwright-report/**",
"test-results/**"
]
},
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
globals: globals.browser,
parser: babelParser,
parserOptions: {
requireConfigFile: false,
babelOptions: {
presets: ["@babel/preset-typescript", ["@babel/preset-react", { runtime: "automatic" }]]
}
}
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
"no-undef": "off",
"no-unused-vars": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
}
}
];
-41
View File
@@ -1,41 +0,0 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const nextConfigRequire = createRequire(require.resolve("eslint-config-next"));
const nextPlugin = nextConfigRequire("@next/eslint-plugin-next");
const reactHooksPlugin = nextConfigRequire("eslint-plugin-react-hooks");
const tsParser = nextConfigRequire("@typescript-eslint/parser");
export default [
{
ignores: [".next/**", "node_modules/**", "out/**"]
},
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: "latest",
sourceType: "module"
}
},
plugins: {
"@next/next": nextPlugin,
"react-hooks": reactHooksPlugin
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
...reactHooksPlugin.configs.recommended.rules
},
settings: {
next: {
rootDir: ["./"]
}
}
}
];
@@ -1,40 +0,0 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
return (
<aside
aria-label="Agent 折叠栏"
className={cn("agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}
>
<button
type="button"
aria-label="展开排水助手面板"
title="展开排水助手面板"
onClick={onExpand}
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
>
<AgentPersona
className="h-12 w-12"
fallbackClassName="h-12 w-12"
state={personaState}
statusLabel={statusLabel}
/>
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition-transform group-hover:translate-x-0.5">
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
</span>
</button>
</aside>
);
}
@@ -1,699 +0,0 @@
"use client";
import {
Activity,
CheckCircle2,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
GitMerge,
History,
MapPinned,
Plus,
SendHorizontal
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentOperationalBrief } from "./agent-operational-brief";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { StreamingTokenResponse } from "./streaming-token-response";
import type {
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
type AgentCommandPanelProps = {
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
uiResults?: AgentUiResult[];
onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
onSelectModel?: (model: string) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => void;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
const AGENT_PROMPT_SUGGESTIONS = [
"当前监测数据质量如何,是否存在缺失、失真或可信度不足的问题?",
"当前监测数据中有哪些异常问题需要重点关注?",
"当前管网哪些区域可能存在雨污混接,判断依据是什么?"
] as const;
const AGENT_CAPABILITIES = [
{ icon: DatabaseZap, label: "监测质量诊断" },
{ icon: Activity, label: "异常状态研判" },
{ icon: GitMerge, label: "雨污混接识别" },
{ icon: MapPinned, label: "GIS 地图联动" }
] as const;
export function AgentCommandPanel({
collapsing = false,
personaState,
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
streaming = false,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
uiResults = [],
onCollapse,
onRefreshHistory,
onStartNewSession,
onLoadHistorySession,
onRenameHistorySession,
onDeleteHistorySession,
onSelectModel,
onSubmitPrompt,
onStopPrompt,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: AgentCommandPanelProps) {
const [prompt, setPrompt] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (historyOpen) {
void Promise.resolve(onRefreshHistory?.());
}
}, [historyOpen, onRefreshHistory]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
return;
}
setPrompt("");
setScrollRequestId((requestId) => requestId + 1);
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
setPrompt(nextPrompt);
});
};
return (
<MotionConfig reducedMotion="user">
<aside
aria-label="Agent 命令面板"
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
"agent-panel-shell",
"acrylic-panel border",
"rounded-2xl",
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<header className="agent-panel-header acrylic-panel">
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona
className="h-12 w-12"
fallbackClassName="h-12 w-12 rounded-xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
{sessionTitle}
</p>
</div>
</div>
</div>
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
>
<History size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</header>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
<BackendMessageList
key="messages"
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : (
<motion.div
key="empty"
className="flex flex-1 items-center justify-center px-4 py-8"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState />
</motion.div>
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
onClick={submitPrompt}
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
>
<span className="truncate">{suggestion}</span>
</Suggestion>
))}
</Suggestions>
</div>
) : null}
<PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-sm transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:!ring-0"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-end">
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</Agent>
</aside>
</MotionConfig>
);
}
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastUserMessageId: string | null;
scrollRequestId: number;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
}) {
const { scrollToBottom } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const lastUserMessageId = getLastUserMessageId(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastUserMessageId,
scrollRequestId
}),
[activeSessionId, lastUserMessageId, scrollRequestId]
);
useEffect(() => {
const previousSnapshot = previousSnapshotRef.current;
previousSnapshotRef.current = snapshot;
const forceScroll =
!previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
if (!forceScroll) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({
animation: "instant",
ignoreEscapes: true,
wait: false
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot]);
return null;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
return messages[index].id;
}
}
return null;
}
function AgentModelSelect({
models,
value,
onValueChange
}: {
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
<span className="shrink-0">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={8}
className="agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
</span>
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
}
function FastModelIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-sky-600"
aria-hidden="true"
>
<path
d="M10.67 21c-.35 0-.62-.31-.57-.66L11 14H7.5c-.88 0-.33-.75-.31-.78 1.26-2.23 3.15-5.53 5.65-9.93.1-.18.3-.29.5-.29.35 0 .62.31.57.66l-.9 6.34h3.51c.4 0 .62.19.4.66-3.29 5.74-5.2 9.09-5.75 10.05-.1.18-.29.29-.5.29"
fill="currentColor"
/>
</svg>
);
}
function ExpertModelIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-violet-600"
aria-hidden="true"
>
<path
d="m19.46 8 .79-1.75L22 5.46c.39-.18.39-.73 0-.91l-1.75-.79L19.46 2c-.18-.39-.73-.39-.91 0l-.79 1.75-1.76.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.74.39.92 0M11.5 9.5 9.91 6c-.35-.78-1.47-.78-1.82 0L6.5 9.5 3 11.09c-.78.36-.78 1.47 0 1.82l3.5 1.59L8.09 18c.36.78 1.47.78 1.82 0l1.59-3.5 3.5-1.59c.78-.36.78-1.47 0-1.82zm7.04 6.5-.79 1.75-1.75.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.73.39.91 0l.79-1.75 1.76-.79c.39-.18.39-.73 0-.91l-1.75-.79-.79-1.76c-.18-.39-.74-.39-.92 0"
fill="currentColor"
/>
</svg>
);
}
function AgentReadyMark() {
return (
<svg viewBox="0 0 1024 1024" className="h-12 w-12 overflow-visible" aria-hidden="true">
<g className="agent-ready-orbit">
<path
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
fill="#CE75FF"
/>
<path
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="#F62E76"
/>
<path
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="#CD88FF"
/>
</g>
<path
className="agent-ready-core"
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
fill="#F3E2FF"
/>
</svg>
);
}
function AgentEmptyState() {
return (
<section
aria-labelledby="agent-empty-state-title"
className="surface-reading mx-auto w-full max-w-sm rounded-2xl border border-slate-200/80 px-4 pb-4 pt-5 text-center shadow-sm"
>
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full border border-violet-100 bg-white shadow-[0_10px_28px_rgba(126,34,206,0.12)]">
<AgentReadyMark />
</div>
<h2 id="agent-empty-state-title" className="mt-4 text-lg font-semibold leading-7 text-slate-900">
</h2>
<p className="mx-auto mt-2 max-w-[30ch] text-sm leading-6 text-slate-500">
使
</p>
<ul className="mt-5 grid grid-cols-2 gap-2" aria-label="Agent 分析能力">
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
<li
key={label}
className="surface-control flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200/70 px-2 py-2.5 text-center shadow-[0_6px_18px_rgba(15,23,42,0.04)] sm:flex-row sm:gap-2 sm:px-2.5 sm:text-left"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<Icon size={17} strokeWidth={1.9} aria-hidden="true" />
</span>
<span className="whitespace-nowrap text-xs font-semibold leading-5 text-slate-800">{label}</span>
</li>
))}
</ul>
</section>
);
}
function BackendMessageList({
messages,
streaming,
streamRenderState,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => (
<motion.div
key={message.id}
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Message
from={message.role}
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
/>
) : null}
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
className="leading-6"
content={message.content}
messageId={message.id}
streamDone={streamRenderState[message.id]?.done ?? !streaming}
streaming={streaming && index === messages.length - 1}
/>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)
) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">...</span>
) : null}
{message.role === "assistant" ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
</div>
</MessageContent>
</Message>
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
}
@@ -1,62 +0,0 @@
"use client";
import { Bot } from "lucide-react";
import { useState } from "react";
import { Persona, type PersonaState, type PersonaVariant } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
type AgentPersonaProps = {
className?: string;
fallbackClassName?: string;
state?: PersonaState;
statusLabel?: string;
streaming?: boolean;
variant?: PersonaVariant;
};
export function AgentPersona({
className,
fallbackClassName,
state,
statusLabel,
streaming = false,
variant = "obsidian"
}: AgentPersonaProps) {
const [failed, setFailed] = useState(false);
const personaState = state ?? getAgentPersonaState(statusLabel, streaming);
if (failed) {
return (
<span
className={cn(
"grid shrink-0 place-items-center rounded-xl bg-violet-100 text-violet-700",
className,
fallbackClassName
)}
>
<Bot size={19} aria-hidden="true" />
</span>
);
}
return (
<Persona
className={cn("shrink-0", className)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
/>
);
}
export function getAgentPersonaState(statusLabel = "", streaming = false): PersonaState {
if (/失败|不可用|错误|降级/.test(statusLabel)) {
return "asleep";
}
if (streaming || /请求|分析|生成|连接中|加载/.test(statusLabel)) {
return "thinking";
}
return "idle";
}
-107
View File
@@ -1,107 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
describe("parseAssistantMessageSections", () => {
it("returns plain assistant content when there is no thought block", () => {
expect(parseAssistantMessageSections("直接回答")).toEqual({
answer: "直接回答",
thought: null,
thoughtComplete: false
});
});
it("extracts a completed thought block and keeps the final answer visible", () => {
expect(parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答")).toEqual({
answer: "最终回答",
thought: "先分析需求",
thoughtComplete: true
});
});
it("supports streaming thought content before the closing tag arrives", () => {
expect(parseAssistantMessageSections("准备中...\n<think>继续推理中")).toEqual({
answer: "准备中...",
thought: "继续推理中",
thoughtComplete: false
});
});
it("merges multiple thought blocks into a single collapsed section", () => {
expect(
parseAssistantMessageSections(
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾"
)
).toEqual({
answer: "答案开头\n\n答案结尾",
thought: "第一段思考\n\n第二段思考",
thoughtComplete: true
});
});
});
describe("parseContentWithToolCalls", () => {
it("returns a single text segment when there are no tool calls", () => {
const result = parseContentWithToolCalls("普通文本回答");
expect(result.segments).toEqual([{ type: "text", content: "普通文本回答" }]);
expect(result.toolCalls).toHaveLength(0);
});
it("parses a complete tool_call block", () => {
const content =
'分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].tool).toBe("locate_junctions");
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
expect(result.segments).toHaveLength(3);
expect(result.segments[0]).toEqual({ type: "text", content: "分析完成。" });
expect(result.segments[1]).toMatchObject({
type: "tool_call",
toolCall: { tool: "locate_junctions" }
});
expect(result.segments[2]).toEqual({ type: "text", content: "以上是结果。" });
});
it("parses multiple tool_call blocks", () => {
const content =
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(2);
expect(result.toolCalls[0].tool).toBe("locate_pipes");
expect(result.toolCalls[1].tool).toBe("chart");
expect(result.segments).toHaveLength(4);
});
it("detects an unclosed tool_call tag as pending while streaming", () => {
const result = parseContentWithToolCalls('正在分析...\n<tool_call>{"tool":"locate_no');
expect(result.segments).toEqual([
{ type: "text", content: "正在分析..." },
{ type: "tool_call_pending" }
]);
expect(result.toolCalls).toHaveLength(0);
});
it("strips partial opening tags during streaming", () => {
const result = parseContentWithToolCalls("正在分析...\n<tool_c");
expect(result.segments).toEqual([{ type: "text", content: "正在分析..." }]);
});
it("handles malformed JSON gracefully", () => {
const result = parseContentWithToolCalls("前文\n<tool_call>{invalid json}</tool_call>\n后文");
expect(result.toolCalls).toHaveLength(0);
expect(result.segments.length).toBeGreaterThanOrEqual(2);
});
it("returns empty segments for empty content", () => {
const result = parseContentWithToolCalls("");
expect(result.segments).toHaveLength(0);
expect(result.toolCalls).toHaveLength(0);
});
});
-129
View File
@@ -1,129 +0,0 @@
export type AssistantMessageSections = {
answer: string;
thought: string | null;
thoughtComplete: boolean;
};
export type ToolCall = {
id: string;
tool: string;
params: Record<string, unknown>;
};
export type ContentSegment =
| { type: "text"; content: string }
| { type: "tool_call"; toolCall: ToolCall }
| { type: "tool_call_pending" };
export type ParsedToolContent = {
segments: ContentSegment[];
toolCalls: ToolCall[];
};
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
export function parseAssistantMessageSections(content: string): AssistantMessageSections {
if (!content) {
return { answer: "", thought: null, thoughtComplete: false };
}
const thoughtParts: string[] = [];
let answer = content;
answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => {
const trimmedThought = thoughtContent.trim();
if (trimmedThought) {
thoughtParts.push(trimmedThought);
}
return "\n";
});
const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG);
const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG);
const hasUnclosedThought = lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex;
if (hasUnclosedThought) {
const streamingThought = answer.slice(lastOpenIndex + THINK_OPEN_TAG.length).trim();
if (streamingThought) {
thoughtParts.push(streamingThought);
}
answer = answer.slice(0, lastOpenIndex);
}
const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim();
const normalizedThought = thoughtParts.join("\n\n").trim();
return {
answer: normalizedAnswer,
thought: normalizedThought || null,
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought
};
}
const TOOL_CALL_OPEN_TAG = "<tool_call>";
const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
export function parseContentWithToolCalls(content: string): ParsedToolContent {
if (!content) {
return { segments: [], toolCalls: [] };
}
const segments: ContentSegment[] = [];
const toolCalls: ToolCall[] = [];
let lastIndex = 0;
let toolCallIndex = 0;
let match: RegExpExecArray | null;
while ((match = TOOL_CALL_PATTERN.exec(content)) !== null) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
try {
const parsed = JSON.parse(match[1].trim()) as {
tool?: string;
params?: Record<string, unknown>;
};
const toolCall: ToolCall = {
id: `tc-${toolCallIndex++}`,
tool: parsed.tool ?? "unknown",
params: parsed.params ?? {}
};
segments.push({ type: "tool_call", toolCall });
toolCalls.push(toolCall);
} catch {
segments.push({ type: "text", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
const remaining = content.slice(lastIndex);
const unclosedIndex = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
if (unclosedIndex !== -1) {
const textBefore = remaining.slice(0, unclosedIndex);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
segments.push({ type: "tool_call_pending" });
} else {
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
if (cleaned) {
segments.push({ type: "text", content: cleaned });
}
}
if (segments.length === 0) {
segments.push({ type: "text", content });
}
return { segments, toolCalls };
}
-354
View File
@@ -1,354 +0,0 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { useEffect } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
export type MapNoticeOptions = {
id?: string | number;
title?: string;
message: string;
tone?: MapNoticeTone;
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
onAction?: () => void;
dismissible?: boolean;
};
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "top-[84px]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export function MapToaster() {
return (
<Toaster
closeButton={false}
expand
visibleToasts={4}
position={sonnerPositions[DEFAULT_POSITION]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-auto",
closeButton:
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
}
}}
/>
);
}
export function showMapNotice(options: MapNoticeOptions) {
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
return toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("!fixed !z-[60]", positionClassNames[position])
}
);
}
export function dismissMapNotice(id?: string | number) {
toast.dismiss(id);
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useEffect(() => {
showMapNotice({
id,
tone: "error",
title: "地图异常",
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
}
export function MapLoadingNotice({
message = "正在初始化地图...",
title = "地图加载中",
id = "map-loading"
}: {
message?: string;
title?: string;
id?: string | number;
}) {
useEffect(() => {
showMapNotice({
id,
tone: "loading",
title,
message,
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
}
export type MapSourceStatus = "online" | "degraded" | "offline";
export type MapSourceStatusNoticeProps = {
sourceName: string;
status: MapSourceStatus;
message?: string;
id?: string | number;
actionLabel?: string;
onAction?: () => void;
};
export function MapSourceStatusNotice({
sourceName,
status,
message,
id = `map-source-${sourceName}`,
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
useEffect(() => {
showMapNotice({
id,
tone: getSourceStatusTone(status),
title: getSourceStatusTitle(sourceName, status),
message: message ?? getSourceStatusMessage(status),
duration: status === "online" ? 2600 : Infinity,
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function MapNoticeContent({
title,
message,
tone = "info",
actionLabel,
onAction
}: MapNoticeOptions) {
const Icon = getNoticeIcon(tone);
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
/>
</MapNoticeFrame>
);
}
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
);
}
function MapNoticeBody({
title,
message,
action
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{action}
</span>
);
}
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
);
}
function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
function getNoticeIcon(tone: MapNoticeTone) {
if (tone === "success") {
return CheckCircle2;
}
if (tone === "warning") {
return AlertTriangle;
}
if (tone === "error") {
return XCircle;
}
if (tone === "loading") {
return Loader2;
}
return Info;
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
}
if (status === "degraded") {
return "warning";
}
return "error";
}
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
if (status === "online") {
return `${sourceName} 已连接`;
}
if (status === "degraded") {
return `${sourceName} 降级运行`;
}
return `${sourceName} 不可用`;
}
function getSourceStatusMessage(status: MapSourceStatus) {
if (status === "online") {
return "地图数据源已恢复正常。";
}
if (status === "degraded") {
return "部分地图能力不可用,系统已使用可用替代方案。";
}
return "地图数据源请求失败,请检查网络或稍后重试。";
}
-1
View File
@@ -1 +0,0 @@
export { MapWorkbenchPage } from "./map-workbench-page";
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Agent 编排型沉浸式排水管网业务工作台" />
<title>排水管网</title>
</head>
<body>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
const DEFAULT_MAP_URL = "https://geoserver.waternetwork.cn/geoserver";
export const MAP_URL = (
process.env.NEXT_PUBLIC_MAP_URL || DEFAULT_MAP_URL
).replace(/\/$/, "");
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typedRoutes: false
};
export default nextConfig;
+48 -17
View File
@@ -1,18 +1,26 @@
{ {
"name": "drainage-network-frontend", "name": "next-tjwater-drainage-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.25.0", "type": "module",
"packageManager": "pnpm@11.8.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "vite",
"build": "next build", "build": "tsc --noEmit && vite build && pnpm build:tts",
"start": "next start", "build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test" "test:browser": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.9", "@ai-sdk/react": "^4.0.9",
"@deck.gl/core": "^9.3.7",
"@deck.gl/layers": "^9.3.7",
"@deck.gl/mapbox": "^9.3.7",
"@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-dialog": "^1.1.17",
@@ -32,33 +40,56 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"edge-tts-ts": "^1.0.0",
"katex": "^0.17.0", "katex": "^0.17.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"motion": "^12.40.0", "motion": "^12.40.0",
"nanoid": "^5.1.14", "nanoid": "^5.1.14",
"next": "15.5.19", "react": "^19.2.7",
"react": "19.2.7", "react-dom": "^19.2.7",
"react-dom": "19.2.7",
"shiki": "^4.2.0", "shiki": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"swr": "2.4.2", "swr": "^2.4.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"use-stick-to-bottom": "^1.1.6" "use-stick-to-bottom": "^1.1.6",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@babel/eslint-parser": "^7.25.9",
"@babel/preset-react": "^7.25.9",
"@babel/preset-typescript": "^7.26.0",
"@eslint/js": "^9.17.0",
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^24.7.2",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20", "@vitejs/plugin-react-swc": "^4.0.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "15.5.19", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"esbuild": "^0.28.0",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"msw": "^2.6.8",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"postcss": "^8.4.49", "prettier": "^3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0",
"typescript": "^5.7.2", "typescript": "^7.0.0",
"vite": "^7.0.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }
+22 -10
View File
@@ -1,17 +1,29 @@
import { defineConfig } from "playwright/test"; import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
export default defineConfig({ export default defineConfig({
testDir: "./tests/browser", testDir: ".",
testMatch: "**/*.e2e.ts", testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
timeout: 30_000, fullyParallel: true,
reporter: "html",
use: { use: {
baseURL: "http://127.0.0.1:3000", baseURL,
viewport: { width: 1440, height: 900 } trace: "on-first-retry"
}, },
webServer: { webServer: {
command: "pnpm dev", command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
reuseExistingServer: true, url: baseURL,
timeout: 120_000, reuseExistingServer: false
url: "http://127.0.0.1:3000" },
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 }
} }
}
]
}); });
+3203 -3251
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -1,7 +1,12 @@
packages:
- .
allowBuilds: allowBuilds:
sharp: set this to true or false '@swc/core': true
unrs-resolver: set this to true or false esbuild: true
msw: true
onlyBuiltDependencies: onlyBuiltDependencies:
- sharp - "@swc/core"
- unrs-resolver - esbuild
- msw
-8
View File
@@ -1,8 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
export default {
printWidth: 100,
tabWidth: 2,
semi: true,
trailingComma: "none"
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="32" fill="#0E7490"/>
<circle cx="32" cy="32" r="30.5" stroke="#67E8F9" stroke-width="3"/>
<g stroke="#CFFAFE" stroke-width="3" stroke-linecap="round">
<path d="M32 13v8M50 26l-9 3M43 48l-6-9M21 48l6-9M14 26l9 3"/>
</g>
<g fill="#FFFFFF">
<circle cx="32" cy="12" r="3"/>
<circle cx="51" cy="26" r="3"/>
<circle cx="44" cy="49" r="3"/>
<circle cx="20" cy="49" r="3"/>
<circle cx="13" cy="26" r="3"/>
</g>
<circle cx="32" cy="31" r="11" fill="#155E75" stroke="#FFFFFF" stroke-width="3"/>
<path d="M24.5 32h4l2.5-6 3.5 12 2.5-6h2.5" stroke="#FFFFFF" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 762 B

+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+1
View File
@@ -0,0 +1 @@
globalThis.__DRAINAGE_CONFIG__ = {};
+24
View File
@@ -0,0 +1,24 @@
import { createServer } from "node:http";
import { createEdgeTtsMiddleware } from "./edge-tts-service";
const host = "127.0.0.1";
const port = 8790;
const handleEdgeTts = createEdgeTtsMiddleware({ trustProxy: true });
const server = createServer((request, response) => {
void handleEdgeTts(request, response, () => {
response.statusCode = 404;
response.end("Not Found");
});
});
server.listen(port, host, () => {
console.log(`[Agent TTS] Edge TTS adapter listening on http://${host}:${port}`);
});
function closeServer() {
server.close(() => process.exit(0));
}
process.on("SIGINT", closeServer);
process.on("SIGTERM", closeServer);
+102
View File
@@ -0,0 +1,102 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createEdgeTtsMiddleware,
type EdgeTtsMiddlewareOptions,
type EdgeTtsSynthesizer
} from "./edge-tts-service";
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
);
});
async function startServer(options: EdgeTtsMiddlewareOptions) {
const middleware = createEdgeTtsMiddleware(options);
const server = createServer((request, response) => {
void middleware(request, response, () => {
response.statusCode = 404;
response.end();
});
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}`;
}
function postSpeech(baseUrl: string, body: unknown, headers = true) {
return fetch(`${baseUrl}/api/tts/edge`, {
method: "POST",
...(headers ? { headers: { "Content-Type": "application/json" } } : {}),
body: typeof body === "string" ? body : JSON.stringify(body)
});
}
describe("Edge TTS adapter", () => {
it("returns synthesized MP3 audio with the server-owned voice", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([73, 68, 51]));
const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" });
const response = await postSpeech(baseUrl, { text: " 朗读调度结果 " });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("audio/mpeg");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from([73, 68, 51]));
expect(synthesize).toHaveBeenCalledWith("朗读调度结果", "zh-CN-XiaoxiaoNeural");
});
it("rejects invalid, unsafe and oversized requests before synthesis", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ synthesize });
expect((await postSpeech(baseUrl, "not-json")).status).toBe(400);
expect((await postSpeech(baseUrl, { text: "测试" }, false)).status).toBe(415);
expect((await postSpeech(baseUrl, { text: "测试", voice: "unsafe" })).status).toBe(400);
expect((await postSpeech(baseUrl, { text: "排".repeat(521) })).status).toBe(413);
expect(synthesize).not.toHaveBeenCalled();
});
it("limits requests by client and releases the window", async () => {
let currentTime = 1_000;
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({
synthesize,
maxRequestsPerWindow: 1,
rateWindowMs: 1_000,
now: () => currentTime
});
expect((await postSpeech(baseUrl, { text: "第一次" })).status).toBe(200);
expect((await postSpeech(baseUrl, { text: "第二次" })).status).toBe(429);
currentTime += 1_000;
expect((await postSpeech(baseUrl, { text: "新窗口" })).status).toBe(200);
});
it("caps concurrent synthesis work", async () => {
let releaseFirst: ((audio: Uint8Array) => void) | undefined;
const firstAudio = new Promise<Uint8Array>((resolve) => {
releaseFirst = resolve;
});
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => firstAudio);
const baseUrl = await startServer({ synthesize, maxConcurrent: 1 });
const firstRequest = postSpeech(baseUrl, { text: "第一段" });
await vi.waitFor(() => expect(synthesize).toHaveBeenCalledTimes(1));
expect((await postSpeech(baseUrl, { text: "第二段" })).status).toBe(429);
releaseFirst?.(Uint8Array.from([1]));
expect((await firstRequest).status).toBe(200);
});
it("returns a gateway timeout without releasing the occupied slot early", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => new Promise(() => undefined));
const baseUrl = await startServer({ synthesize, timeoutMs: 5 });
expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Communicate } from "edge-tts-ts";
const EDGE_TTS_PATH = "/api/tts/edge";
const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
const MAX_REQUEST_BYTES = 8 * 1024;
const MAX_TEXT_LENGTH = 520;
const MAX_AUDIO_BYTES = 5 * 1024 * 1024;
const DEFAULT_MAX_CONCURRENT = 8;
const DEFAULT_RATE_LIMIT = 60;
const DEFAULT_RATE_WINDOW_MS = 60_000;
const DEFAULT_TIMEOUT_MS = 30_000;
interface EdgeTtsPayload {
text?: unknown;
voice?: unknown;
}
interface RateBucket {
count: number;
startedAt: number;
}
export type EdgeTtsSynthesizer = (text: string, voice: string) => Promise<Uint8Array>;
export type EdgeTtsMiddlewareOptions = {
defaultVoice?: string;
maxConcurrent?: number;
maxRequestsPerWindow?: number;
now?: () => number;
rateWindowMs?: number;
synthesize?: EdgeTtsSynthesizer;
timeoutMs?: number;
trustProxy?: boolean;
};
function sendJson(response: ServerResponse, status: number, message: string) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify({ error: message }));
}
async function readJson(request: IncomingMessage): Promise<EdgeTtsPayload> {
const chunks: Buffer[] = [];
let byteLength = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
byteLength += buffer.byteLength;
if (byteLength > MAX_REQUEST_BYTES) {
throw new RangeError("请求内容过大");
}
chunks.push(buffer);
}
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new SyntaxError("请求内容必须是 JSON 对象");
}
return payload as EdgeTtsPayload;
}
function isLoopback(address: string | undefined) {
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}
function getClientKey(request: IncomingMessage, trustProxy: boolean) {
const remoteAddress = request.socket.remoteAddress;
if (trustProxy && isLoopback(remoteAddress)) {
const forwardedFor = request.headers["x-forwarded-for"];
const firstAddress = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(",", 1)[0];
if (firstAddress?.trim()) return firstAddress.trim();
}
return remoteAddress || "unknown";
}
export async function synthesizeEdgeSpeech(text: string, voice: string): Promise<Uint8Array> {
const communicate = new Communicate(text, { voice });
const chunks: Uint8Array[] = [];
let byteLength = 0;
for await (const chunk of communicate.stream()) {
if (chunk.type !== "audio") continue;
chunks.push(chunk.data);
byteLength += chunk.data.byteLength;
}
if (!byteLength) throw new Error("语音服务返回了空音频");
const audio = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
audio.set(chunk, offset);
offset += chunk.byteLength;
}
return audio;
}
export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) {
const synthesize = options.synthesize ?? synthesizeEdgeSpeech;
const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE;
const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT;
const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const trustProxy = options.trustProxy ?? false;
const now = options.now ?? Date.now;
const rateBuckets = new Map<string, RateBucket>();
let activeRequests = 0;
return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => {
if (request.url?.split("?", 1)[0] !== EDGE_TTS_PATH) {
next?.();
return;
}
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
sendJson(response, 405, "仅支持 POST 请求");
return;
}
if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
sendJson(response, 415, "请求必须使用 application/json");
return;
}
const currentTime = now();
const clientKey = getClientKey(request, trustProxy);
const existingBucket = rateBuckets.get(clientKey);
const bucket = !existingBucket || currentTime - existingBucket.startedAt >= rateWindowMs
? { count: 0, startedAt: currentTime }
: existingBucket;
bucket.count += 1;
rateBuckets.set(clientKey, bucket);
if (rateBuckets.size > 1_024) {
for (const [key, value] of rateBuckets) {
if (currentTime - value.startedAt >= rateWindowMs) rateBuckets.delete(key);
}
}
if (bucket.count > maxRequestsPerWindow) {
response.setHeader("Retry-After", String(Math.max(1, Math.ceil(rateWindowMs / 1_000))));
sendJson(response, 429, "语音请求过于频繁,请稍后重试");
return;
}
let payload: EdgeTtsPayload;
try {
payload = await readJson(request);
} catch (error) {
sendJson(response, error instanceof RangeError ? 413 : 400, "请求内容不是有效 JSON");
return;
}
if (Object.prototype.hasOwnProperty.call(payload, "voice")) {
sendJson(response, 400, "语音角色只能由服务端配置");
return;
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
if (!text) {
sendJson(response, 400, "缺少待朗读文本");
return;
}
if (text.length > MAX_TEXT_LENGTH) {
sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH}`);
return;
}
if (activeRequests >= maxConcurrent) {
response.setHeader("Retry-After", "1");
sendJson(response, 429, "语音服务繁忙,请稍后重试");
return;
}
activeRequests += 1;
const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice));
void synthesisTask.finally(() => {
activeRequests -= 1;
}).catch(() => undefined);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const audio = await Promise.race([
synthesisTask,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new DOMException("语音服务响应超时", "TimeoutError")), timeoutMs);
})
]);
if (!audio.byteLength) {
sendJson(response, 502, "语音服务返回了空音频");
return;
}
if (audio.byteLength > MAX_AUDIO_BYTES) {
sendJson(response, 502, "语音服务返回的音频过大");
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "audio/mpeg");
response.setHeader("Cache-Control", "no-store");
response.end(Buffer.from(audio));
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") {
sendJson(response, 504, "语音服务响应超时");
return;
}
console.error("[Agent TTS] 语音生成失败", error);
sendJson(response, 502, "语音生成失败");
} finally {
if (timeout) clearTimeout(timeout);
}
};
}
-288
View File
@@ -1,288 +0,0 @@
"use client";
import type { RiveParameters } from "@rive-app/react-webgl2";
import {
useRive,
useStateMachineInput,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor
} from "@rive-app/react-webgl2";
import type { FC, ReactNode } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const useStrictModeSafeInit = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setReady(true));
return () => {
cancelAnimationFrame(id);
setReady(false);
};
}, []);
return ready;
};
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
const sources = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv"
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv"
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv"
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv"
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv"
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv"
}
};
export type PersonaVariant = keyof typeof sources;
export type PersonaProps = {
state?: PersonaState;
onLoad?: RiveParameters["onLoad"];
onLoadError?: RiveParameters["onLoadError"];
onReady?: () => void;
onPause?: RiveParameters["onPause"];
onPlay?: RiveParameters["onPlay"];
onStop?: RiveParameters["onStop"];
className?: string;
variant?: PersonaVariant;
};
const stateMachine = "default";
const getCurrentTheme = (): "light" | "dark" => {
if (typeof window !== "undefined") {
if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}
}
return "light";
};
const useTheme = (enabled: boolean) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
if (!enabled) {
return;
}
setTheme(getCurrentTheme());
const observer = new MutationObserver(() => {
setTheme(getCurrentTheme());
});
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true
});
let mql: MediaQueryList | null = null;
const handleMediaChange = () => {
setTheme(getCurrentTheme());
};
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)");
mql.addEventListener("change", handleMediaChange);
}
return () => {
observer.disconnect();
if (mql) {
mql.removeEventListener("change", handleMediaChange);
}
};
}, [enabled]);
return theme;
};
type PersonaSource = (typeof sources)[PersonaVariant];
type PersonaWithModelProps = {
rive: ReturnType<typeof useRive>["rive"];
source: PersonaSource;
children: ReactNode;
};
const PersonaWithModel = memo(({ rive, source, children }: PersonaWithModelProps) => {
const theme = useTheme(source.dynamicColor);
const viewModel = useViewModel(rive, { useDefault: true });
const viewModelInstance = useViewModelInstance(viewModel, {
rive,
useDefault: true
});
const viewModelInstanceColor = useViewModelInstanceColor(
"color",
viewModelInstance
);
useEffect(() => {
if (!(viewModelInstanceColor && source.dynamicColor)) {
return;
}
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
viewModelInstanceColor.setRgb(r, g, b);
}, [viewModelInstanceColor, theme, source.dynamicColor]);
return children;
});
PersonaWithModel.displayName = "PersonaWithModel";
const PersonaWithoutModel = memo(({ children }: { children: ReactNode }) => children);
PersonaWithoutModel.displayName = "PersonaWithoutModel";
export const Persona: FC<PersonaProps> = memo(
({
variant = "obsidian",
state = "idle",
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
className
}) => {
const source = sources[variant];
const callbacksRef = useRef({
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop
});
useEffect(() => {
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop
};
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
const stableCallbacks = useMemo(
() => ({
onLoad: ((loadedRive) =>
callbacksRef.current.onLoad?.(loadedRive)) as RiveParameters["onLoad"],
onLoadError: ((error) =>
callbacksRef.current.onLoadError?.(error)) as RiveParameters["onLoadError"],
onPause: ((event) =>
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
onPlay: ((event) =>
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
onReady: () => callbacksRef.current.onReady?.(),
onStop: ((event) =>
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"]
}),
[]
);
const ready = useStrictModeSafeInit();
const { rive, RiveComponent } = useRive(
ready
? {
autoplay: true,
onLoad: stableCallbacks.onLoad,
onLoadError: stableCallbacks.onLoadError,
onPause: stableCallbacks.onPause,
onPlay: stableCallbacks.onPlay,
onRiveReady: stableCallbacks.onReady,
onStop: stableCallbacks.onStop,
src: source.source,
stateMachines: stateMachine
}
: null
);
const listeningInput = useStateMachineInput(
rive,
stateMachine,
"listening"
);
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
useEffect(() => {
if (listeningInput) {
listeningInput.value = state === "listening";
}
if (thinkingInput) {
thinkingInput.value = state === "thinking";
}
if (speakingInput) {
speakingInput.value = state === "speaking";
}
if (asleepInput) {
asleepInput.value = state === "asleep";
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
const visual = <RiveComponent className={cn("size-16 shrink-0", className)} />;
if (!source.hasModel) {
return <PersonaWithoutModel>{visual}</PersonaWithoutModel>;
}
return (
<PersonaWithModel rive={rive} source={source}>
{visual}
</PersonaWithModel>
);
}
);
Persona.displayName = "Persona";
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
test("renders the migrated WebGIS workbench shell", async ({ page }) => {
const riveRequests: string[] = [];
page.on("request", (request) => {
if (/\.riv(?:\?|$)/.test(request.url())) {
riveRequests.push(request.url());
}
});
await page.goto("/");
await expect(page.getByText("排水管网智能调度平台")).toBeVisible();
await expect(page.getByText("调度工作台")).toBeVisible();
await expect(page.getByLabel("推荐问题")).toBeVisible();
const runtimeConfig = await page.evaluate(() => globalThis.__DRAINAGE_CONFIG__);
expect(runtimeConfig).toMatchObject({
DRAINAGE_MAP_URL: expect.any(String),
DRAINAGE_GEOSERVER_WORKSPACE: expect.any(String),
DRAINAGE_AGENT_API_BASE_URL: expect.any(String)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
);
expect(runtimeConfig).not.toHaveProperty("DRAINAGE_TTS_API_URL");
expect(runtimeConfig).not.toHaveProperty("EDGE_TTS_VOICE");
const mapAsset = await page.request.get("/icons/scada-integrated-monitoring.png");
expect(mapAsset.ok()).toBe(true);
expect(mapAsset.headers()["content-type"]).toBe("image/png");
await expect(page.getByLabel("Agent 状态").first()).toBeVisible();
await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1);
});
test("animates the compact header menu without overflowing", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await page.locator('button[aria-label^="查看异常处置面板"]:visible').click();
const menu = page.locator('[role="menu"][data-state="open"]');
await expect(menu).toBeVisible();
const animation = await menu.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
duration: Number.parseFloat(style.animationDuration) * 1_000,
name: style.animationName
};
});
expect(animation.name).toContain("enter");
expect(animation.duration).toBeGreaterThan(0);
const menuBounds = await menu.boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds!.x).toBeGreaterThanOrEqual(0);
expect(menuBounds!.x + menuBounds!.width).toBeLessThanOrEqual(375);
});
test("renders map notices through a single toaster", async ({ page }) => {
await page.goto("/");
await expect(page.locator("[data-sonner-toaster]")).toHaveCount(1);
await page.getByLabel("打开用户菜单").click();
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
await expect(page.locator("[data-sonner-toast]")).toHaveCount(1);
});
test("preserves compact typography in header and agent controls", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
const headerControl = page
.locator("header.acrylic-navigation button.font-semibold:visible")
.first();
const approvalControl = page.getByRole("button", { name: "权限批准模式" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
await expect(headerControl).toHaveCSS("font-weight", "600");
await expect(approvalControl).toHaveCSS("font-size", "12px");
await expect(approvalControl).toHaveCSS("line-height", "16px");
await expect(suggestion).toHaveCSS("font-size", "12px");
await expect(suggestion).toHaveCSS("font-weight", "500");
});
test("lets interactive utilities override surface materials", async ({ page }) => {
await page.goto("/");
await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" });
const suggestion = page.locator("[aria-label=推荐问题] button").first();
const restingBackground = await suggestion.evaluate(
(element) => window.getComputedStyle(element).backgroundColor
);
await suggestion.hover();
const hoverBackground = await suggestion.evaluate(
(element) => window.getComputedStyle(element).backgroundColor
);
expect(hoverBackground).not.toBe(restingBackground);
});
test("preserves v3 menu and panel styling through Tailwind v4", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.getByLabel("打开用户菜单").click();
const menu = page.locator('[role="menu"][data-state="open"]');
const menuTransformOrigin = await menu.evaluate(
(element) => window.getComputedStyle(element).transformOrigin
);
expect(menuTransformOrigin).not.toBe("50% 50%");
await page.keyboard.press("Escape");
const agentCommandControl = page.locator(".agent-panel-control.shadow-xs").first();
const commandShadow = await agentCommandControl.evaluate(
(element) => window.getComputedStyle(element).boxShadow
);
expect(commandShadow).toContain("rgba(0, 0, 0, 0.05) 0px 1px 2px 0px");
expect(commandShadow).not.toContain("0px 1px 3px 0px");
});
+12
View File
@@ -0,0 +1,12 @@
import { MapToaster } from "@/features/map/core/components/notice";
import { MapWorkbenchPage } from "@/features/workbench";
import { AppProviders } from "./providers";
export function App() {
return (
<AppProviders>
<MapWorkbenchPage />
<MapToaster />
</AppProviders>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
import { SWRConfig } from "swr";
async function fetcher<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json() as Promise<T>;
}
export function AppProviders({ children }: PropsWithChildren) {
return (
<SWRConfig
value={{
fetcher,
revalidateOnFocus: false,
shouldRetryOnError: false
}}
>
{children}
</SWRConfig>
);
}
@@ -1,3 +1,5 @@
import { env } from "@/shared/config/env";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentPermissionReply = "once" | "always" | "reject";
@@ -110,9 +112,7 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>; abort: (sessionId: string) => Promise<void>;
}; };
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const CHAT_PATH = "/api/v1/agent/chat"; const CHAT_PATH = "/api/v1/agent/chat";
@@ -0,0 +1,44 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({
personaState,
statusLabel = "Agent 已就绪",
onExpand
}: AgentCollapsedRailProps) {
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
return (
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
<button
type="button"
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
</span>
</button>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
return ( return (
<div className="px-3 pb-3"> <div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm"> <div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-slate-950"></p> <p className="text-sm font-semibold text-slate-950"></p>
@@ -157,7 +157,7 @@ export function AgentMessageProgress({
return null; return null;
} }
return <ProgressList key={running ? "running" : "settled"} progress={progress} running={running} />; return <ProgressList progress={progress} running={running} />;
} }
function AgentNestedBlock({ function AgentNestedBlock({
@@ -200,35 +200,35 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const visibleProgress = running const compact = running && !expanded;
? progress.slice(-STREAMING_PROGRESS_LIMIT) const visibleProgress = expanded
: expanded
? progress ? progress
: running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: []; : [];
const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed"; const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5"> <div className="agent-panel-nested rounded-xl p-2.5">
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600 disabled:cursor-default" className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={running || expanded} aria-expanded={expanded}
disabled={running}
onClick={() => setExpanded((current) => !current)} onClick={() => setExpanded((current) => !current)}
> >
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" /> <ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span></span> <span></span>
<span className="flex-1" /> <span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400"> <span className="shrink-0 font-normal text-slate-400">
{running {expanded
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}`
: expanded
? `全部 ${progress.length}` ? `全部 ${progress.length}`
: running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}`
: `${progress.length}`} : `${progress.length}`}
</span> </span>
<ChevronDown <ChevronDown
size={14} size={14}
className={cn("shrink-0 text-slate-400 transition-transform", !running && expanded && "rotate-180")} className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
@@ -236,18 +236,21 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{visibleProgress.length ? ( {visibleProgress.length ? (
<motion.ol <motion.ol
key={listMode} key={listMode}
aria-label={!running && expanded ? "全部执行进度" : "最近执行进度"} aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 space-y-1.5 overflow-hidden" className="mt-2 flex flex-col gap-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition} transition={agentLayoutTransition}
> >
<AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}> <AnimatePresence initial={false} mode="popLayout">
{visibleProgress.map((item) => ( {visibleProgress.map((item) => (
<motion.li <motion.li
key={item.id} key={item.id}
className="grid h-12 grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 overflow-hidden text-xs" className={cn(
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
compact && "h-12 overflow-hidden"
)}
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }} exit={{ opacity: 0, y: -2 }}
@@ -275,11 +278,23 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
} }
/> />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}> <span
className={cn(
"block font-semibold text-slate-800",
compact ? "truncate" : "break-words"
)}
title={item.title}
>
{item.title} {item.title}
</span> </span>
{item.detail ? ( {item.detail ? (
<span className="block truncate leading-5 text-slate-500" title={item.detail}> <span
className={cn(
"block leading-5 text-slate-500",
compact ? "truncate" : "break-words"
)}
title={item.detail}
>
{item.detail} {item.detail}
</span> </span>
) : null} ) : null}
@@ -364,7 +379,7 @@ function PermissionList({
}) { }) {
return ( return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求"> <AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate"> <motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => ( {permissions.slice(-4).map((permission) => (
<AnimatedDetailItem key={permission.requestId}> <AnimatedDetailItem key={permission.requestId}>
@@ -481,7 +496,7 @@ function QuestionList({
}) { }) {
return ( return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating> <AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate"> <motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => ( {questions.slice(-3).map((request) => (
<AnimatedDetailItem key={request.requestId}> <AnimatedDetailItem key={request.requestId}>
@@ -670,7 +685,7 @@ function QuestionInput({
) : null} ) : null}
{showCustomInput ? ( {showCustomInput ? (
<textarea <textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60" className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答" placeholder="输入回答"
value={value.custom} value={value.custom}
disabled={disabled} disabled={disabled}
@@ -0,0 +1,79 @@
import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
const LazyPersona = lazy(async () => {
const module = await import("@/shared/ai-elements/persona");
return { default: module.Persona };
});
type AgentPersonaProps = {
className?: string;
state?: PersonaState;
};
function useElementVisibility() {
const containerRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let intersectsViewport = false;
const updateVisibility = () => {
setVisible(intersectsViewport && document.visibilityState === "visible");
};
const observer = new IntersectionObserver(([entry]) => {
intersectsViewport = Boolean(entry?.isIntersecting);
updateVisibility();
});
observer.observe(container);
document.addEventListener("visibilitychange", updateVisibility);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", updateVisibility);
};
}, []);
return { containerRef, visible };
}
function PersonaFallback() {
return (
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
</span>
);
}
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
const { containerRef, visible } = useElementVisibility();
const [failed, setFailed] = useState(false);
const handleLoadError = useCallback(() => setFailed(true), []);
const shouldRenderAnimation = !failed && visible;
return (
<span
ref={containerRef}
aria-label="Agent 状态"
className={cn("relative block shrink-0", className)}
>
{shouldRenderAnimation ? (
<Suspense fallback={<PersonaFallback />}>
<LazyPersona
className="h-full w-full scale-110"
onLoadError={handleLoadError}
state={state}
/>
</Suspense>
) : (
<PersonaFallback />
)}
</span>
);
}
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result; const { envelope } = result;
return ( return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm"> <div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700"> <span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FrontendActionExecutor } from "./executor";
import { parseFrontendActionRegistry, parseFrontendActionRequest, type FrontendActionResult } from ".";
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value)
});
});
describe("SCADA frontend actions", () => {
it("accepts only the two registered SCADA action names", () => {
expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1",
actions: [
{ id: "render_scada_analysis", version: "frontend-action@1" },
{ id: "clear_scada_analysis", version: "frontend-action@1" },
{ id: "zoom_to_map", version: "frontend-action@1" }
]
})?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis",
"clear_scada_analysis"
]);
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
});
it("submits the real browser output and replays the terminal receipt idempotently", async () => {
const execute = vi.fn(async () => ({
rendered_ids: ["MP01"],
missing_ids: [],
level_counts: { high: 1, medium: 0, low: 0, unrated: 0 },
fitted: true
}));
const submit = vi.fn(async (
_sessionId: string,
_actionId: string,
_result: FrontendActionResult
) => undefined);
const executor = new FrontendActionExecutor(execute, submit);
const request = createRequest("render_scada_analysis");
await executor.handle(request, "session-1");
await executor.handle(request, "session-1");
expect(execute).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledTimes(2);
expect(submit.mock.calls[0][2]).toMatchObject({
status: "succeeded",
output: { rendered_ids: ["MP01"], fitted: true }
});
expect(submit.mock.calls[1][2]).toEqual(submit.mock.calls[0][2]);
});
});
function createRequest(name: string) {
return {
version: "frontend-action@1",
actionId: "action-1",
toolCallId: "call-1",
sessionId: "session-1",
name,
params: { items: [{ sensor_id: "MP01", level: "high" }] },
issuedAt: Date.now(),
expiresAt: Date.now() + 15_000
};
}
@@ -1,4 +1,4 @@
export const FRONTEND_ACTION_NAMES = ["zoom_to_map", "locate_features", "view_history"] as const; export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number]; 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 FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number }; export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
+347
View File
@@ -0,0 +1,347 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
splitSpeechTextIntoChunks,
type AgentSpeakOptions,
type AgentSpeechState
} from "../speech";
interface BrowserSpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface BrowserSpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message?: string;
}
interface BrowserSpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: BrowserSpeechRecognitionEvent) => void) | null;
onerror: ((event: BrowserSpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition;
declare global {
interface Window {
SpeechRecognition?: BrowserSpeechRecognitionConstructor;
webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor;
}
}
const subscribeToBrowserCapabilities = () => () => undefined;
function getSpeechSynthesisSupport() {
return (
typeof window.Audio !== "undefined" &&
typeof window.URL !== "undefined" &&
typeof window.fetch !== "undefined"
);
}
function getSpeechRecognitionSupport() {
return "SpeechRecognition" in window || "webkitSpeechRecognition" in window;
}
function getServerBrowserCapability() {
return false;
}
export function useAgentSpeechSynthesis() {
const [speechState, setSpeechState] = useState<AgentSpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentAudioUrlRef = useRef<string | null>(null);
const audioUrlsRef = useRef<Set<string>>(new Set());
const fetchControllersRef = useRef<Set<AbortController>>(new Set());
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]);
const currentChunkIndexRef = useRef(0);
const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null);
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(async () => undefined);
const isSupported = useSyncExternalStore(
subscribeToBrowserCapabilities,
getSpeechSynthesisSupport,
getServerBrowserCapability
);
const detachAudio = useCallback((revokeUrl: boolean) => {
const audio = audioRef.current;
audioRef.current = null;
if (audio) {
audio.pause();
audio.onended = null;
audio.onerror = null;
audio.removeAttribute("src");
audio.load();
}
const currentUrl = currentAudioUrlRef.current;
currentAudioUrlRef.current = null;
if (revokeUrl && currentUrl) {
URL.revokeObjectURL(currentUrl);
audioUrlsRef.current.delete(currentUrl);
chunkUrlCacheRef.current.delete(currentChunkIndexRef.current);
}
}, []);
const releaseAudio = useCallback(() => {
fetchControllersRef.current.forEach((controller) => controller.abort());
fetchControllersRef.current.clear();
chunkPromisesRef.current.clear();
detachAudio(false);
audioUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
audioUrlsRef.current.clear();
chunkUrlCacheRef.current.clear();
chunksRef.current = [];
currentChunkIndexRef.current = 0;
activeMessageIdRef.current = null;
}, [detachAudio]);
const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => {
const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex);
if (cachedUrl) return Promise.resolve(cachedUrl);
const currentPromise = chunkPromisesRef.current.get(chunkIndex);
if (currentPromise) return currentPromise;
const text = chunksRef.current[chunkIndex];
if (!text) return Promise.reject(new Error("语音片段不存在"));
const controller = new AbortController();
fetchControllersRef.current.add(controller);
const promise = fetch("/api/tts/edge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(payload?.error ?? `语音生成失败 (${response.status})`);
}
const audioBlob = await response.blob();
if (!audioBlob.size) throw new Error("语音服务返回了空音频");
if (playbackToken !== playbackTokenRef.current) {
throw new DOMException("语音播放已取消", "AbortError");
}
const objectUrl = URL.createObjectURL(audioBlob);
audioUrlsRef.current.add(objectUrl);
chunkUrlCacheRef.current.set(chunkIndex, objectUrl);
return objectUrl;
})
.finally(() => {
fetchControllersRef.current.delete(controller);
chunkPromisesRef.current.delete(chunkIndex);
});
chunkPromisesRef.current.set(chunkIndex, promise);
return promise;
}, []);
const prefetchChunk = useCallback((chunkIndex: number, playbackToken: number) => {
if (chunkIndex >= chunksRef.current.length) return;
void fetchChunkAudio(chunkIndex, playbackToken).catch(() => undefined);
}, [fetchChunkAudio]);
const playChunk = useCallback(async (chunkIndex: number, playbackToken: number) => {
setSpeechState("loading");
const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken);
if (playbackToken !== playbackTokenRef.current) return;
detachAudio(true);
currentChunkIndexRef.current = chunkIndex;
const audio = new Audio(objectUrl);
audio.preload = "auto";
audioRef.current = audio;
currentAudioUrlRef.current = objectUrl;
audio.onended = () => {
if (playbackToken !== playbackTokenRef.current) return;
detachAudio(true);
const nextChunkIndex = chunkIndex + 1;
if (nextChunkIndex >= chunksRef.current.length) {
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
return;
}
currentChunkIndexRef.current = nextChunkIndex;
void playChunkRef.current(nextChunkIndex, playbackToken);
};
audio.onerror = () => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
};
await audio.play();
if (playbackToken !== playbackTokenRef.current) return;
setSpeechState("playing");
prefetchChunk(chunkIndex + 1, playbackToken);
}, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]);
useEffect(() => {
playChunkRef.current = playChunk;
}, [playChunk]);
const speak = useCallback(async (messageId: string, text: string, options: AgentSpeakOptions = {}) => {
const normalizedText = text.trim();
if (!isSupported || !normalizedText) return;
const startOffset = Math.max(0, Math.min(options.startOffset ?? 0, normalizedText.length));
const chunks = splitSpeechTextIntoChunks(normalizedText.slice(startOffset));
if (!chunks.length) return;
const playbackToken = playbackTokenRef.current + 1;
playbackTokenRef.current = playbackToken;
releaseAudio();
chunksRef.current = chunks;
activeMessageIdRef.current = messageId;
setSpeakingMessageId(messageId);
setSpeechState("loading");
try {
await playChunk(0, playbackToken);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
}
}, [isSupported, playChunk, releaseAudio]);
const pause = useCallback(() => {
if (!audioRef.current || speechState !== "playing") return;
audioRef.current.pause();
setSpeechState("paused");
}, [speechState]);
const resume = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
void audio.play().catch(() => {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
});
}, [releaseAudio]);
const stop = useCallback(() => {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
}, [releaseAudio]);
useEffect(() => () => {
playbackTokenRef.current += 1;
releaseAudio();
}, [releaseAudio]);
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
}
export function useAgentSpeechRecognition(onResult: (text: string) => void) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<BrowserSpeechRecognition | null>(null);
const onResultRef = useRef(onResult);
const onErrorRef = useRef<(message: string) => void>(() => undefined);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
const isSupported = useSyncExternalStore(
subscribeToBrowserCapabilities,
getSpeechRecognitionSupport,
getServerBrowserCapability
);
const start = useCallback((onError?: (message: string) => void) => {
if (!isSupported || recognitionRef.current) return;
const Recognition = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Recognition) return;
onErrorRef.current = onError ?? (() => undefined);
const recognition = new Recognition();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = (event) => {
for (let index = event.resultIndex; index < event.results.length; index += 1) {
if (event.results[index].isFinal) {
onResultRef.current(event.results[index][0].transcript);
}
}
};
recognition.onerror = (event) => {
setIsListening(false);
if (event.error !== "aborted") {
onErrorRef.current(getSpeechRecognitionErrorMessage(event.error));
}
};
recognition.onend = () => {
if (recognitionRef.current === recognition) {
recognitionRef.current = null;
}
setIsListening(false);
};
recognitionRef.current = recognition;
try {
recognition.start();
setIsListening(true);
} catch {
recognitionRef.current = null;
setIsListening(false);
onErrorRef.current("无法启动语音输入,请检查浏览器麦克风权限后重试");
}
}, [isSupported]);
const stop = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsListening(false);
}, []);
useEffect(() => () => recognitionRef.current?.abort(), []);
return { isListening, start, stop, isSupported };
}
function getSpeechRecognitionErrorMessage(error: string) {
switch (error) {
case "not-allowed":
case "service-not-allowed":
return "无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试";
case "audio-capture":
return "未检测到可用麦克风,请检查设备连接";
case "network":
return "语音识别服务连接失败,请检查网络后重试";
case "no-speech":
return "未检测到语音,请靠近麦克风后重试";
default:
return "语音输入暂时不可用,请稍后重试";
}
}
@@ -2,20 +2,16 @@ export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel"; export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona"; export { AgentPersona } from "./components/agent-persona";
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data"; export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
export { export {
applyPermissionResponse, applyPermissionResponse,
applyQuestionResponse, applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate, toTodoUpdate,
updateMessageById,
upsertPermission, upsertPermission,
upsertProgress, upsertProgress,
upsertQuestion upsertQuestion
} from "./session-state"; } from "./session-state";
export type { export type {
AgentApprovalMode,
AgentChatMessage, AgentChatMessage,
AgentChatProgress, AgentChatProgress,
AgentInteractionToolRef, AgentInteractionToolRef,
@@ -26,7 +22,6 @@ export type {
AgentQuestionInfo, AgentQuestionInfo,
AgentQuestionOption, AgentQuestionOption,
AgentQuestionRequest, AgentQuestionRequest,
AgentStreamRenderChunk,
AgentStreamRenderMessageState, AgentStreamRenderMessageState,
AgentStreamRenderState, AgentStreamRenderState,
AgentTodoItem, AgentTodoItem,
@@ -38,9 +33,3 @@ export type {
SafeChartSeries, SafeChartSeries,
SafeChartSpec SafeChartSpec
} from "./chart-data"; } from "./chart-data";
export type {
AssistantMessageSections,
ContentSegment,
ParsedToolContent,
ToolCall
} from "./message-sections";
@@ -12,14 +12,6 @@ import type {
type RecordValue = Record<string, unknown>; type RecordValue = Record<string, unknown>;
export function updateMessageById(
messages: AgentChatMessage[],
messageId: string,
updater: (message: AgentChatMessage) => AgentChatMessage
) {
return messages.map((message) => (message.id === messageId ? updater(message) : message));
}
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) { export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
const payload = asRecord(data); const payload = asRecord(data);
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`; const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
findSpeechSelectionStartOffset,
splitSpeechTextIntoChunks,
stripSpeechMarkdown
} from "./speech";
describe("Agent 语音文本处理", () => {
it("定位经过空白压缩的选择文本", () => {
const text = "第一段内容。\n第二段 包含空格。";
expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe(text.indexOf("第二段"));
expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull();
});
it("按句子拆分长文本", () => {
const chunks = splitSpeechTextIntoChunks(`${"甲".repeat(260)}${"乙".repeat(260)}`);
expect(chunks).toHaveLength(2);
expect(chunks.join("")).toContain("乙");
});
it("移除会干扰朗读的 Markdown 标记", () => {
expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情");
});
});
+111
View File
@@ -0,0 +1,111 @@
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
const MAX_SPEECH_CHUNK_LENGTH = 520;
const MIN_SPEECH_CHUNK_LENGTH = 180;
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;\n]+(?:[。!?!?;]+|(?=\n|$))/g;
export type AgentSpeechState = "idle" | "loading" | "playing" | "paused";
export type AgentSpeakOptions = {
startOffset?: number;
};
const normalizeWithOffsetMap = (value: string) => {
let normalized = "";
const offsetMap: number[] = [];
let previousWasWhitespace = false;
Array.from(value).forEach((character, index) => {
if (/\s/u.test(character)) {
if (!previousWasWhitespace && normalized.length > 0) {
normalized += " ";
offsetMap.push(index);
}
previousWasWhitespace = true;
return;
}
normalized += character;
offsetMap.push(index);
previousWasWhitespace = false;
});
return { normalized: normalized.trimEnd(), offsetMap };
};
export function stripSpeechMarkdown(markdown: string) {
return markdown
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
.replace(/#{1,6}\s+/g, "")
.replace(/\*{1,3}(.+?)\*{1,3}/g, "$1")
.replace(/~~(.+?)~~/g, "$1")
.replace(/^>\s+/gm, "")
.replace(/^[-*+]\s+/gm, "")
.replace(/^\d+\.\s+/gm, "")
.replace(/<[^>]+>/g, "")
.replace(/\n{2,}/g, "\n")
.trim();
}
export function findSpeechSelectionStartOffset(text: string, selectedText: string): number | null {
const needle = selectedText.trim();
if (!needle) return null;
const exactIndex = text.indexOf(needle);
if (exactIndex >= 0) return exactIndex;
const normalizedNeedle = compactWhitespace(needle);
if (!normalizedNeedle) return null;
const haystack = normalizeWithOffsetMap(text);
const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle);
if (normalizedIndex < 0) return null;
return haystack.offsetMap[normalizedIndex] ?? null;
}
export function splitSpeechTextIntoChunks(text: string): string[] {
const normalizedText = text.trim();
if (!normalizedText) return [];
const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) =>
compactWhitespace(match[0])
).filter(Boolean);
const sourceSegments = segments.length > 0 ? segments : [normalizedText];
const chunks: string[] = [];
let currentChunk = "";
const flush = () => {
if (!currentChunk) return;
chunks.push(currentChunk);
currentChunk = "";
};
for (const segment of sourceSegments) {
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
flush();
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
}
continue;
}
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
if (
currentChunk &&
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
) {
flush();
currentChunk = segment;
continue;
}
currentChunk = candidate;
}
flush();
return chunks;
}
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
describe("StreamingTokenResponse", () => { describe("StreamingTokenResponse", () => {
it("renders the backend-smoothed stream without a second timer buffer", () => { it("renders the backend-smoothed stream without a second timer buffer", () => {
const source = readFileSync( const source = readFileSync(
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"), join(process.cwd(), "src/features/agent/components/streaming-token-response.tsx"),
"utf8" "utf8"
); );
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("Streamdown fullscreen table", () => {
it("disables inherited token animations only inside the fullscreen copy", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
expect(styles).toMatch(
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/
);
});
});
@@ -8,13 +8,7 @@ export type AgentChatMessage = {
todos?: AgentTodoUpdate; todos?: AgentTodoUpdate;
}; };
export type AgentStreamRenderChunk = {
id: number;
text: string;
};
export type AgentStreamRenderMessageState = { export type AgentStreamRenderMessageState = {
chunks: AgentStreamRenderChunk[];
done: boolean; done: boolean;
}; };
@@ -44,6 +38,8 @@ export type AgentPermissionStatus =
export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always";
export type AgentModelOption = { export type AgentModelOption = {
id: string; id: string;
label: string; label: string;
@@ -20,6 +20,13 @@ describe("toTrustedMapAction", () => {
}) })
).toBeNull(); ).toBeNull();
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull(); expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
expect(
toTrustedMapAction("apply_layer_style", {
layer_group_id: "conduits",
layer_id: "scada",
visible: true
})
).toBeNull();
}); });
it("rejects out-of-range coordinates and zoom", () => { it("rejects out-of-range coordinates and zoom", () => {
@@ -63,11 +70,11 @@ describe("toTrustedMapAction", () => {
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3); expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
}); });
it("normalizes legacy locate tool actions", () => { it("normalizes drainage locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({ expect(toTrustedMapAction("locate_conduits", { conduit_ids: "C1,C2" })).toEqual({
type: "locate_features", type: "locate_features",
featureIds: ["P1", "P2"], featureIds: ["C1", "C2"],
layer: "geo_pipes_mat", layer: "geo_conduits_mat",
fallbackText: undefined fallbackText: undefined
}); });
}); });
@@ -79,4 +86,34 @@ describe("toTrustedMapAction", () => {
layer: "geo_junctions_mat" layer: "geo_junctions_mat"
}); });
}); });
it.each([
["locate_orifices", "orifice_id", "geo_orifices_mat"],
["locate_outfalls", "outfall_id", "geo_outfalls_mat"],
["locate_pumps", "pump_id", "geo_pumps_mat"],
["locate_scada", "scada_id", "geo_scadas_mat"]
])("accepts %s for a drainage source", (action, key, layer) => {
expect(toTrustedMapAction(action, { [key]: "asset-1" })).toMatchObject({
type: "locate_features",
featureIds: ["asset-1"],
layer
});
});
it("rejects stale supply layers and accepts drainage visibility controls", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: ["P1"] })).toBeNull();
expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_pipes_mat" })).toBeNull();
expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toMatchObject({
type: "apply_layer_style",
layerGroupId: "conduits",
layerId: "conduits",
visible: true
});
expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({
type: "apply_layer_style",
layerGroupId: "simulation",
visible: false
});
expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toBeNull();
});
}); });
@@ -14,7 +14,7 @@ export type TrustedMapAction =
} }
| { | {
type: "apply_layer_style"; type: "apply_layer_style";
layerGroupId?: string; layerGroupId: string;
layerId?: string; layerId?: string;
visible?: boolean; visible?: boolean;
fallbackText?: string; fallbackText?: string;
@@ -33,12 +33,14 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) { if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
const featureIds = readLocateIds(params); const featureIds = readLocateIds(params);
if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null; if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null;
const layer =
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action];
if (layer && !ALLOWED_LOCATE_LAYERS.has(layer)) return null;
return { return {
type: "locate_features", type: "locate_features",
featureIds, featureIds,
layer: layer,
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action],
fallbackText fallbackText
}; };
} }
@@ -66,11 +68,18 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
if (action === "apply_layer_style") { if (action === "apply_layer_style") {
const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId); const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId);
const layerId = stringValue(params.layer_id ?? params.layerId); const layerId = stringValue(params.layer_id ?? params.layerId);
const resolvedLayerGroupId = layerGroupId ?? layerId;
const visible = booleanValue(params.visible); const visible = booleanValue(params.visible);
if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null; if (
!resolvedLayerGroupId ||
visible === undefined ||
(layerGroupId && !ALLOWED_LAYER_GROUP_IDS.has(layerGroupId)) ||
(layerId && !ALLOWED_LAYER_IDS.has(layerId)) ||
(layerGroupId && layerId && layerGroupId !== layerId)
) return null;
return { return {
type: "apply_layer_style", type: "apply_layer_style",
layerGroupId, layerGroupId: resolvedLayerGroupId,
layerId, layerId,
visible, visible,
fallbackText fallbackText
@@ -155,16 +164,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = { const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_conduits: "geo_conduits_mat",
locate_junctions: "geo_junctions_mat", locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat", locate_orifices: "geo_orifices_mat",
locate_valves: "geo_valves", locate_outfalls: "geo_outfalls_mat",
locate_reservoirs: "geo_reservoirs", locate_pumps: "geo_pumps_mat",
locate_pumps: "geo_pumps", locate_scada: "geo_scadas_mat"
locate_tanks: "geo_tanks"
}; };
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]); const DRAINAGE_SOURCE_IDS = ["conduits", "junctions", "orifices", "outfalls", "pumps", "scada"];
const ALLOWED_LAYER_IDS = new Set(DRAINAGE_SOURCE_IDS);
const ALLOWED_LAYER_GROUP_IDS = new Set([...DRAINAGE_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...DRAINAGE_SOURCE_IDS,
"geo_conduits_mat",
"geo_junctions_mat",
"geo_orifices_mat",
"geo_outfalls_mat",
"geo_pumps_mat",
"geo_scadas_mat"
]);
const WEB_MERCATOR_RADIUS = 6378137; const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [ const LOCATE_ID_PARAM_KEYS = [
@@ -178,16 +198,16 @@ const LOCATE_ID_PARAM_KEYS = [
"node_id", "node_id",
"junction_ids", "junction_ids",
"junction_id", "junction_id",
"pipe_ids", "conduit_ids",
"pipe_id", "conduit_id",
"valve_ids", "orifice_ids",
"valve_id", "orifice_id",
"reservoir_ids", "outfall_ids",
"reservoir_id", "outfall_id",
"pump_ids", "pump_ids",
"pump_id", "pump_id",
"tank_ids", "scada_ids",
"tank_id" "scada_id"
] as const; ] as const;
function readLocateIds(params: Record<string, unknown>) { function readLocateIds(params: Record<string, unknown>) {
@@ -114,7 +114,7 @@ export function BaseLayersControl({
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100" ? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40", : "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50" option.disabled && "cursor-not-allowed opacity-50"
)} )}
@@ -166,7 +166,7 @@ export function BaseLayersControl({
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150", "flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40", active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50" option.disabled && "cursor-not-allowed opacity-50"
)} )}
> >
@@ -186,7 +186,7 @@ export function BaseLayersControl({
type="button" type="button"
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`} title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)} className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
> >
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
)} )}
> >
{active ? ( {active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm"> <span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs">
<Check size={10} aria-hidden="true" /> <Check size={10} aria-hidden="true" />
</span> </span>
) : null} ) : null}
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
item.active item.active
? "bg-blue-600 text-white shadow-sm" ? "bg-blue-600 text-white shadow-xs"
: "hover:bg-blue-50 hover:text-blue-700", : "hover:bg-blue-50 hover:text-blue-700",
isDanger && "hover:bg-red-50 hover:text-red-700", isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600" item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
@@ -64,7 +64,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]", "relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5" ? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white", : "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)} )}
@@ -1,5 +1,4 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -61,7 +60,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)} className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
> >
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true"> <span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
{item.imageSrc ? <Image src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />} {item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span> <span className="block truncate font-semibold">{item.label}</span>
@@ -0,0 +1,60 @@
"use client";
import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
export function showMapNotice(options: MapNoticeOptions) {
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
return toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
}
);
}
export function dismissMapNotice(id?: string | number) {
toast.dismiss(id);
}
@@ -0,0 +1,154 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export function MapNoticeContent({
title,
message,
tone = "info",
actionLabel,
onAction
}: MapNoticeOptions) {
const Icon = getNoticeIcon(tone);
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
/>
</MapNoticeFrame>
);
}
export function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
);
}
function MapNoticeBody({
title,
message,
action
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{action}
</span>
);
}
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
);
}
function getNoticeIcon(tone: MapNoticeTone) {
if (tone === "success") {
return CheckCircle2;
}
if (tone === "warning") {
return AlertTriangle;
}
if (tone === "error") {
return XCircle;
}
if (tone === "loading") {
return Loader2;
}
return Info;
}
@@ -0,0 +1,14 @@
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
export type MapNoticeOptions = {
id?: string | number;
title?: string;
message: string;
tone?: MapNoticeTone;
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
onAction?: () => void;
dismissible?: boolean;
};
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notice-types";
export function MapToaster() {
return (
<Toaster
closeButton={false}
expand
visibleToasts={4}
position="top-center"
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-auto",
closeButton:
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
}
}}
/>
);
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useEffect(() => {
showMapNotice({
id,
tone: "error",
title: "地图异常",
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
}
export function MapLoadingNotice({
message = "正在初始化地图...",
title = "地图加载中",
id = "map-loading"
}: {
message?: string;
title?: string;
id?: string | number;
}) {
useEffect(() => {
showMapNotice({
id,
tone: "loading",
title,
message,
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
}
export type MapSourceStatus = "online" | "degraded" | "offline";
export type MapSourceStatusNoticeProps = {
sourceName: string;
status: MapSourceStatus;
message?: string;
id?: string | number;
actionLabel?: string;
onAction?: () => void;
};
export function MapSourceStatusNotice({
sourceName,
status,
message,
id = `map-source-${sourceName}`,
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
useEffect(() => {
showMapNotice({
id,
tone: getSourceStatusTone(status),
title: getSourceStatusTitle(sourceName, status),
message: message ?? getSourceStatusMessage(status),
duration: status === "online" ? 2600 : Infinity,
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
}
if (status === "degraded") {
return "warning";
}
return "error";
}
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
if (status === "online") {
return `${sourceName} 已连接`;
}
if (status === "degraded") {
return `${sourceName} 降级运行`;
}
return `${sourceName} 不可用`;
}
function getSourceStatusMessage(status: MapSourceStatus) {
if (status === "online") {
return "地图数据源已恢复正常。";
}
if (status === "degraded") {
return "部分地图能力不可用,系统已使用可用替代方案。";
}
return "地图数据源请求失败,请检查网络或稍后重试。";
}
@@ -0,0 +1,75 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
import { AgentTaskTicker } from "./agent-task-ticker";
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId,
scheduledAt: "2026-07-08T10:00:00+08:00",
title: id,
summary: "Running condition",
status: "running",
riskLevel: "normal",
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
sessionId: id,
durationMinutes: 10
};
}
function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: visibilityState
});
document.dispatchEvent(new Event("visibilitychange"));
}
function getActiveConditionId(container: HTMLElement) {
const activeCards = container.querySelectorAll<HTMLElement>('article[aria-hidden="false"]');
return activeCards.item(activeCards.length - 1).dataset.conditionId;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
setDocumentVisibility("visible");
});
describe("AgentTaskTicker page visibility", () => {
it("does not rotate or retain exiting cards while the page is hidden", () => {
vi.useFakeTimers();
setDocumentVisibility("visible");
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch"),
createRunningCondition("condition-c", "pump-energy"),
createRunningCondition("condition-d", "network-simulation")
];
const { container } = render(<AgentTaskTicker conditions={conditions} />);
const initialConditionId = getActiveConditionId(container);
act(() => {
setDocumentVisibility("hidden");
vi.advanceTimersByTime(18_000);
});
expect(getActiveConditionId(container)).toBe(initialConditionId);
expect(container.querySelectorAll("article")).toHaveLength(3);
act(() => {
setDocumentVisibility("visible");
});
act(() => {
vi.advanceTimersByTime(6_000);
});
expect(getActiveConditionId(container)).not.toBe(initialConditionId);
});
});

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