Compare commits
10
Commits
86389de3fa
...
0995172828
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0995172828 | ||
|
|
72850761ce | ||
|
|
e0cfa3d6eb | ||
|
|
ebe6cdc7f1 | ||
|
|
caf06a8a82 | ||
|
|
21791b9cee | ||
|
|
7fbd8a5618 | ||
|
|
86e9b08235 | ||
|
|
72c8190a78 | ||
|
|
3c0271bae8 |
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.git
|
||||
.next
|
||||
dist
|
||||
dist-server
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
+9
-3
@@ -1,3 +1,9 @@
|
||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
|
||||
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
DRAINAGE_MAPBOX_ACCESS_TOKEN=
|
||||
DRAINAGE_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
@@ -1,10 +1,17 @@
|
||||
.DS_Store
|
||||
.env.local
|
||||
.next/
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
tsconfig.tsbuildinfo
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
dist-server
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
*:Zone.Identifier
|
||||
供水管网_Agent_WebGIS_设计与技术框架.md
|
||||
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
||||
todo.md
|
||||
|
||||
@@ -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-*` 类。
|
||||
@@ -1,130 +1,91 @@
|
||||
# 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.
|
||||
- `lib/`: shared utilities that are not owned by a feature.
|
||||
- `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.
|
||||
- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here.
|
||||
- `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.
|
||||
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features.
|
||||
- Root markdown files are product/design references and should not be treated as runtime code.
|
||||
- `src/app/`: application shell and providers.
|
||||
- `src/features/map/core/`: reusable map UI and generic map helpers. This layer
|
||||
must not know drainage-network business layer IDs or Agent workflows.
|
||||
- `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
|
||||
adapters, scenarios, and workbench-specific interactions.
|
||||
- `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
|
||||
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`.
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Starts the local development server at `http://localhost:3000`.
|
||||
|
||||
```bash
|
||||
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
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
Runs TypeScript validation without emitting files.
|
||||
|
||||
## 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 lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
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
|
||||
Add direct GeoServer MVT sources
|
||||
Refactor map workbench shell
|
||||
```
|
||||
|
||||
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.
|
||||
Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
|
||||
Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
|
||||
in untracked environment files.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ The task ticker is always centered against the full viewport, independent of Age
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
|
||||
|
||||
### 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
|
||||
|
||||
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
|
||||
|
||||
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 MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
|
||||
- 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:
|
||||
|
||||
```txt
|
||||
shared/ui
|
||||
src/shared/ui
|
||||
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.
|
||||
|
||||
features/workbench/components
|
||||
src/features/workbench/components
|
||||
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.
|
||||
```
|
||||
|
||||
|
||||
+23
@@ -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
@@ -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 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -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
|
||||
corepack enable
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
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
|
||||
pnpm lint
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
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 默认阈值。只要构建成功,这些提示不影响静态产物生成。
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapWorkbenchPage } from "@/features/workbench";
|
||||
|
||||
export default function Home() {
|
||||
return <MapWorkbenchPage />;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 秒以上的浏览器进程内存趋势。
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export { MapWorkbenchPage } from "./map-workbench-page";
|
||||
+14
@@ -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>
|
||||
@@ -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(/\/$/, "");
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typedRoutes: false
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+48
-17
@@ -1,18 +1,26 @@
|
||||
{
|
||||
"name": "drainage-network-frontend",
|
||||
"name": "next-tjwater-drainage-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && pnpm build:tts",
|
||||
"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 .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
@@ -32,33 +40,56 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.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",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.14",
|
||||
"next": "15.5.19",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"shiki": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"swr": "2.4.2",
|
||||
"swr": "^2.4.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"use-stick-to-bottom": "^1.1.6"
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"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-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||
"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",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^7.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -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({
|
||||
testDir: "./tests/browser",
|
||||
testMatch: "**/*.e2e.ts",
|
||||
timeout: 30_000,
|
||||
testDir: ".",
|
||||
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||
fullyParallel: true,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
viewport: { width: 1440, height: 900 }
|
||||
baseURL,
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
url: "http://127.0.0.1:3000"
|
||||
command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1440, height: 900 }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Generated
+3203
-3251
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,7 +1,12 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
'@swc/core': true
|
||||
esbuild: true
|
||||
msw: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
- "@swc/core"
|
||||
- esbuild
|
||||
- msw
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__DRAINAGE_CONFIG__ = {};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 AgentPermissionReply = "once" | "always" | "reject";
|
||||
@@ -110,9 +112,7 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
|
||||
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
|
||||
: ["", "http://127.0.0.1:8787"];
|
||||
const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
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
+1
-1
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
|
||||
|
||||
return (
|
||||
<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="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
+36
-21
@@ -157,7 +157,7 @@ export function AgentMessageProgress({
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ProgressList key={running ? "running" : "settled"} progress={progress} running={running} />;
|
||||
return <ProgressList progress={progress} running={running} />;
|
||||
}
|
||||
|
||||
function AgentNestedBlock({
|
||||
@@ -200,35 +200,35 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
|
||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleProgress = running
|
||||
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||
: expanded
|
||||
const compact = running && !expanded;
|
||||
const visibleProgress = expanded
|
||||
? progress
|
||||
: running
|
||||
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||
: [];
|
||||
const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed";
|
||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600 disabled:cursor-default"
|
||||
aria-expanded={running || expanded}
|
||||
disabled={running}
|
||||
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
|
||||
<span>执行进度</span>
|
||||
<span className="flex-1" />
|
||||
<span className="shrink-0 font-normal text-slate-400">
|
||||
{running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: expanded
|
||||
{expanded
|
||||
? `全部 ${progress.length} 条`
|
||||
: running
|
||||
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
|
||||
: `共 ${progress.length} 条`}
|
||||
</span>
|
||||
<ChevronDown
|
||||
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"
|
||||
/>
|
||||
</button>
|
||||
@@ -236,18 +236,21 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
aria-label={!running && expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 space-y-1.5 overflow-hidden"
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode={expanded ? "sync" : "wait"}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{visibleProgress.map((item) => (
|
||||
<motion.li
|
||||
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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
@@ -275,11 +278,23 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -364,7 +379,7 @@ function PermissionList({
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{permissions.slice(-4).map((permission) => (
|
||||
<AnimatedDetailItem key={permission.requestId}>
|
||||
@@ -481,7 +496,7 @@ function QuestionList({
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{questions.slice(-3).map((request) => (
|
||||
<AnimatedDetailItem key={request.requestId}>
|
||||
@@ -670,7 +685,7 @@ function QuestionInput({
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-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="输入回答"
|
||||
value={value.custom}
|
||||
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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
const { envelope } = result;
|
||||
|
||||
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="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">
|
||||
@@ -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
-1
@@ -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 FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
|
||||
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
|
||||
@@ -0,0 +1,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 { AgentPersona } from "./components/agent-persona";
|
||||
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
|
||||
export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
|
||||
export {
|
||||
applyPermissionResponse,
|
||||
applyQuestionResponse,
|
||||
cancelRunningTodos,
|
||||
completeRunningProgress,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
toTodoUpdate,
|
||||
updateMessageById,
|
||||
upsertPermission,
|
||||
upsertProgress,
|
||||
upsertQuestion
|
||||
} from "./session-state";
|
||||
export type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentChatProgress,
|
||||
AgentInteractionToolRef,
|
||||
@@ -26,7 +22,6 @@ export type {
|
||||
AgentQuestionInfo,
|
||||
AgentQuestionOption,
|
||||
AgentQuestionRequest,
|
||||
AgentStreamRenderChunk,
|
||||
AgentStreamRenderMessageState,
|
||||
AgentStreamRenderState,
|
||||
AgentTodoItem,
|
||||
@@ -38,9 +33,3 @@ export type {
|
||||
SafeChartSeries,
|
||||
SafeChartSpec
|
||||
} from "./chart-data";
|
||||
export type {
|
||||
AssistantMessageSections,
|
||||
ContentSegment,
|
||||
ParsedToolContent,
|
||||
ToolCall
|
||||
} from "./message-sections";
|
||||
@@ -12,14 +12,6 @@ import type {
|
||||
|
||||
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) {
|
||||
const payload = asRecord(data);
|
||||
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
|
||||
@@ -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水位 详情");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
describe("StreamingTokenResponse", () => {
|
||||
it("renders the backend-smoothed stream without a second timer buffer", () => {
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderChunk = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderMessageState = {
|
||||
chunks: AgentStreamRenderChunk[];
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
@@ -44,6 +38,8 @@ export type AgentPermissionStatus =
|
||||
|
||||
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||
|
||||
export type AgentApprovalMode = "request" | "always";
|
||||
|
||||
export type AgentModelOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
+41
-4
@@ -20,6 +20,13 @@ describe("toTrustedMapAction", () => {
|
||||
})
|
||||
).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", () => {
|
||||
@@ -63,11 +70,11 @@ describe("toTrustedMapAction", () => {
|
||||
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
|
||||
});
|
||||
|
||||
it("normalizes legacy locate tool actions", () => {
|
||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||
it("normalizes drainage locate tool actions", () => {
|
||||
expect(toTrustedMapAction("locate_conduits", { conduit_ids: "C1,C2" })).toEqual({
|
||||
type: "locate_features",
|
||||
featureIds: ["P1", "P2"],
|
||||
layer: "geo_pipes_mat",
|
||||
featureIds: ["C1", "C2"],
|
||||
layer: "geo_conduits_mat",
|
||||
fallbackText: undefined
|
||||
});
|
||||
});
|
||||
@@ -79,4 +86,34 @@ describe("toTrustedMapAction", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+40
-20
@@ -14,7 +14,7 @@ export type TrustedMapAction =
|
||||
}
|
||||
| {
|
||||
type: "apply_layer_style";
|
||||
layerGroupId?: string;
|
||||
layerGroupId: string;
|
||||
layerId?: string;
|
||||
visible?: boolean;
|
||||
fallbackText?: string;
|
||||
@@ -33,12 +33,14 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
||||
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
||||
const featureIds = readLocateIds(params);
|
||||
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 {
|
||||
type: "locate_features",
|
||||
featureIds,
|
||||
layer:
|
||||
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
|
||||
LEGACY_LOCATE_ACTION_LAYERS[action],
|
||||
layer,
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
@@ -66,11 +68,18 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
||||
if (action === "apply_layer_style") {
|
||||
const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId);
|
||||
const layerId = stringValue(params.layer_id ?? params.layerId);
|
||||
const resolvedLayerGroupId = layerGroupId ?? layerId;
|
||||
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 {
|
||||
type: "apply_layer_style",
|
||||
layerGroupId,
|
||||
layerGroupId: resolvedLayerGroupId,
|
||||
layerId,
|
||||
visible,
|
||||
fallbackText
|
||||
@@ -155,16 +164,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||
locate_conduits: "geo_conduits_mat",
|
||||
locate_junctions: "geo_junctions_mat",
|
||||
locate_pipes: "geo_pipes_mat",
|
||||
locate_valves: "geo_valves",
|
||||
locate_reservoirs: "geo_reservoirs",
|
||||
locate_pumps: "geo_pumps",
|
||||
locate_tanks: "geo_tanks"
|
||||
locate_orifices: "geo_orifices_mat",
|
||||
locate_outfalls: "geo_outfalls_mat",
|
||||
locate_pumps: "geo_pumps_mat",
|
||||
locate_scada: "geo_scadas_mat"
|
||||
};
|
||||
|
||||
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 LOCATE_ID_PARAM_KEYS = [
|
||||
@@ -178,16 +198,16 @@ const LOCATE_ID_PARAM_KEYS = [
|
||||
"node_id",
|
||||
"junction_ids",
|
||||
"junction_id",
|
||||
"pipe_ids",
|
||||
"pipe_id",
|
||||
"valve_ids",
|
||||
"valve_id",
|
||||
"reservoir_ids",
|
||||
"reservoir_id",
|
||||
"conduit_ids",
|
||||
"conduit_id",
|
||||
"orifice_ids",
|
||||
"orifice_id",
|
||||
"outfall_ids",
|
||||
"outfall_id",
|
||||
"pump_ids",
|
||||
"pump_id",
|
||||
"tank_ids",
|
||||
"tank_id"
|
||||
"scada_ids",
|
||||
"scada_id"
|
||||
] as const;
|
||||
|
||||
function readLocateIds(params: Record<string, unknown>) {
|
||||
+4
-4
@@ -114,7 +114,7 @@ export function BaseLayersControl({
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
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",
|
||||
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",
|
||||
MAP_READABLE_RADIUS_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"
|
||||
)}
|
||||
>
|
||||
@@ -186,7 +186,7 @@ export function BaseLayersControl({
|
||||
type="button"
|
||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-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)}>
|
||||
{activeLayer ? (
|
||||
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
)}
|
||||
>
|
||||
{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" />
|
||||
</span>
|
||||
) : null}
|
||||
+1
-1
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
item.active
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "hover:bg-blue-50 hover:text-blue-700",
|
||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||
+1
-1
@@ -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]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
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",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
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)}
|
||||
>
|
||||
<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 className="min-w-0 flex-1">
|
||||
<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;
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user