feat: migrate drainage frontend to Vite
This commit is contained in:
@@ -34,4 +34,4 @@ Do not update `DESIGN.md` for small bug fixes, minor spacing tweaks, copy correc
|
|||||||
- Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning.
|
- Avoid decorative chrome, oversized headings, nested cards, and visual effects without operational meaning.
|
||||||
- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels.
|
- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels.
|
||||||
- Preserve list identity and scroll position when a collapsed list expands into a detail workspace.
|
- Preserve list identity and scroll position when a collapsed list expands into a detail workspace.
|
||||||
- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
|
- Validate frontend UI changes with `pnpm lint` and `pnpm typecheck`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
+11
-5
@@ -1,5 +1,11 @@
|
|||||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
|
DRAINAGE_MAPBOX_ACCESS_TOKEN=
|
||||||
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
|
||||||
NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou
|
DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||||
NEXT_PUBLIC_ENABLE_DEV_PANEL=false
|
DRAINAGE_TTS_API_URL=/api/tts/edge
|
||||||
|
DRAINAGE_ENABLE_DEV_PANEL=false
|
||||||
|
DRAINAGE_ENABLE_MSW=false
|
||||||
|
|
||||||
|
# Optional development proxy target. This value is not exposed to browser code.
|
||||||
|
AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787
|
||||||
|
TTS_API_INTERNAL_BASE_URL=http://127.0.0.1:8790
|
||||||
|
|||||||
+12
-6
@@ -1,10 +1,16 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.env.local
|
node_modules
|
||||||
.next/
|
.next
|
||||||
node_modules/
|
dist
|
||||||
playwright-report/
|
coverage
|
||||||
test-results/
|
playwright-report
|
||||||
tsconfig.tsbuildinfo
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
*:Zone.Identifier
|
||||||
供水管网_Agent_WebGIS_设计与技术框架.md
|
供水管网_Agent_WebGIS_设计与技术框架.md
|
||||||
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
||||||
todo.md
|
todo.md
|
||||||
|
|||||||
+36
-36
@@ -1,6 +1,6 @@
|
|||||||
# 亚克力设计类别与组件使用清单
|
# 亚克力设计类别与组件使用清单
|
||||||
|
|
||||||
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `app/globals.css` 为准,组件映射以 `features/` 下的现有代码为准。
|
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
|
||||||
|
|
||||||
## 总览
|
## 总览
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- 特征:保留底部分隔和下投影,不使用顶部高光。
|
- 特征:保留底部分隔和下投影,不使用顶部高光。
|
||||||
- 当前组件:
|
- 当前组件:
|
||||||
- `WorkbenchTopBar`,工作台顶部 Header。
|
- `WorkbenchTopBar`,工作台顶部 Header。
|
||||||
- 文件:`features/workbench/components/workbench-top-bar.tsx`。
|
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。
|
||||||
|
|
||||||
### `acrylic-panel`
|
### `acrylic-panel`
|
||||||
|
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
|
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
|
||||||
- `MapNotice`,地图通知。
|
- `MapNotice`,地图通知。
|
||||||
- 直接或间接涉及文件:
|
- 直接或间接涉及文件:
|
||||||
- `features/agent/components/agent-command-panel.tsx`
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
- `features/agent/components/agent-collapsed-rail.tsx`
|
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||||
- `features/map/core/components/map-control-styles.ts`
|
- `src/features/map/core/components/map-control-styles.ts`
|
||||||
- `features/map/core/components/notice.tsx`
|
- `src/features/map/core/components/notice.tsx`
|
||||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
- `features/workbench/components/feature-popover.tsx`
|
- `src/features/workbench/components/feature-popover.tsx`
|
||||||
- `features/workbench/components/feature-insight-panel.tsx`
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
- `features/workbench/components/map-dev-panel.tsx`
|
- `src/features/workbench/components/map-dev-panel.tsx`
|
||||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
|
|
||||||
### `acrylic-control`
|
### `acrylic-control`
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
- `BaseLayersControl`,底图切换入口与预览控件。
|
- `BaseLayersControl`,底图切换入口与预览控件。
|
||||||
- `map-workbench-page.tsx` 中的浮动地图控制入口。
|
- `map-workbench-page.tsx` 中的浮动地图控制入口。
|
||||||
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
|
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
|
||||||
- 共享定义:`features/map/core/components/map-control-styles.ts`。
|
- 共享定义:`src/features/map/core/components/map-control-styles.ts`。
|
||||||
|
|
||||||
### `glass-transient`
|
### `glass-transient`
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
- 模糊:沿用控件级模糊,16px 至 20px。
|
- 模糊:沿用控件级模糊,16px 至 20px。
|
||||||
- 当前组件:
|
- 当前组件:
|
||||||
- `AgentTaskTicker` 的活动任务卡片。
|
- `AgentTaskTicker` 的活动任务卡片。
|
||||||
- 文件:`features/workbench/components/agent-task-ticker.tsx`。
|
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。
|
||||||
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
|
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
|
||||||
|
|
||||||
## 2. 内部实体表面
|
## 2. 内部实体表面
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
### `surface-dock`
|
### `surface-dock`
|
||||||
|
|
||||||
- 用途:预留给固定 Dock 或侧边停靠区域。
|
- 用途:预留给固定 Dock 或侧边停靠区域。
|
||||||
- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。
|
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
|
||||||
- 适用组件:
|
- 适用组件:
|
||||||
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
|
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
|
||||||
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
|
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
|
||||||
@@ -101,17 +101,17 @@
|
|||||||
- Header 下拉菜单的内部控制区。
|
- Header 下拉菜单的内部控制区。
|
||||||
- 通用 `IconButton`。
|
- 通用 `IconButton`。
|
||||||
- 主要文件:
|
- 主要文件:
|
||||||
- `features/agent/components/agent-collapsed-rail.tsx`
|
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||||
- `features/agent/components/agent-command-panel.tsx`
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
- `features/agent/components/agent-operational-brief.tsx`
|
- `src/features/agent/components/agent-operational-brief.tsx`
|
||||||
- `features/map/core/components/control-panel.tsx`
|
- `src/features/map/core/components/control-panel.tsx`
|
||||||
- `features/map/core/components/layer-control.tsx`
|
- `src/features/map/core/components/layer-control.tsx`
|
||||||
- `features/map/core/components/measure-panel.tsx`
|
- `src/features/map/core/components/measure-panel.tsx`
|
||||||
- `features/workbench/components/agent-task-ticker.tsx`
|
- `src/features/workbench/components/agent-task-ticker.tsx`
|
||||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
|
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||||
- `features/workbench/components/feature-insight-panel.tsx`
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
|
|
||||||
### `surface-well`
|
### `surface-well`
|
||||||
|
|
||||||
@@ -132,16 +132,16 @@
|
|||||||
- 要素洞察、属性列表和底图名称标签。
|
- 要素洞察、属性列表和底图名称标签。
|
||||||
- Header 下拉菜单中的详情块与空状态。
|
- Header 下拉菜单中的详情块与空状态。
|
||||||
- 主要文件:
|
- 主要文件:
|
||||||
- `features/agent/components/agent-command-panel.tsx`
|
- `src/features/agent/components/agent-command-panel.tsx`
|
||||||
- `features/agent/components/agent-message-details.tsx`
|
- `src/features/agent/components/agent-message-details.tsx`
|
||||||
- `features/agent/components/agent-operational-brief.tsx`
|
- `src/features/agent/components/agent-operational-brief.tsx`
|
||||||
- `features/agent/components/agent-ui-envelope-renderer.tsx`
|
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
|
||||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||||
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
|
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||||
- `features/workbench/components/feature-insight-panel.tsx`
|
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||||
- `features/workbench/components/feature-property-list.tsx`
|
- `src/features/workbench/components/feature-property-list.tsx`
|
||||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||||
- `features/map/core/components/base-layers-control.tsx`
|
- `src/features/map/core/components/base-layers-control.tsx`
|
||||||
|
|
||||||
## 3. 语义状态表面
|
## 3. 语义状态表面
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
## 共享类映射
|
## 共享类映射
|
||||||
|
|
||||||
地图核心组件通过 `features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
|
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
|
||||||
|
|
||||||
| 共享常量 | 实际材质类 | 典型用途 |
|
| 共享常量 | 实际材质类 | 典型用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -1,130 +1,90 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure
|
||||||
|
|
||||||
This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS.
|
This repository is a Vite React single-page business system for an Agent-driven
|
||||||
|
drainage-network WebGIS. Runtime TypeScript lives under `src/`.
|
||||||
|
|
||||||
- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin.
|
- `src/app/`: application shell and providers.
|
||||||
- `lib/`: shared utilities that are not owned by a feature.
|
- `src/features/map/core/`: reusable map UI and generic map helpers. This layer
|
||||||
- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows.
|
must not know drainage-network business layer IDs or Agent workflows.
|
||||||
- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here.
|
- `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
|
||||||
- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly.
|
adapters, scenarios, and workbench-specific interactions.
|
||||||
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features.
|
- `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
|
||||||
- Root markdown files are product/design references and should not be treated as runtime code.
|
recommendations.
|
||||||
|
- `src/shared/`: reusable UI, AI elements, runtime configuration, and utilities.
|
||||||
|
- `src/mocks/`: MSW handlers and fixtures.
|
||||||
|
- `tests/browser/`: product-specific Playwright regressions.
|
||||||
|
- `public/`: static drainage and SCADA assets plus the runtime-config placeholder.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
Do not add Next.js APIs or route handlers. Backend and BFF behavior belongs in
|
||||||
|
the Agent service or another service. Browser-safe GeoServer reads may happen
|
||||||
|
directly when CORS is enabled.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
Use `pnpm`.
|
Use `pnpm`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
|
||||||
Starts the local development server at `http://localhost:3000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm test:browser
|
||||||
```
|
```
|
||||||
Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes.
|
|
||||||
|
The Vite development server listens on `http://127.0.0.1:5173` by default.
|
||||||
|
`pnpm build` runs TypeScript validation before producing `dist/`.
|
||||||
|
|
||||||
|
## Coding Style
|
||||||
|
|
||||||
|
- Use TypeScript and React function components with explicit exported types at
|
||||||
|
module boundaries.
|
||||||
|
- Use two-space indentation and kebab-case filenames.
|
||||||
|
- Use PascalCase for components and types, camelCase for functions and values,
|
||||||
|
and `useXxx` for hooks.
|
||||||
|
- Keep orchestration components thin. Move sources, layers, event handling, and
|
||||||
|
feature adapters into focused modules.
|
||||||
|
- Keep MapLibre sources, layers, camera helpers, and interactions outside
|
||||||
|
presentational components.
|
||||||
|
- Keep Agent rendering schema-driven. Validate Agent output before it reaches
|
||||||
|
React, and represent map actions as typed data that can be previewed,
|
||||||
|
confirmed, applied, and rolled back.
|
||||||
|
|
||||||
|
## Domain Boundaries
|
||||||
|
|
||||||
|
- `src/features/map/core` owns generic controls, legends, camera helpers, and
|
||||||
|
reusable selection primitives.
|
||||||
|
- `src/features/workbench` owns conduits, junctions, orifices, outfalls, pumps,
|
||||||
|
SCADA sources, simulation overlays, and drainage-specific behavior.
|
||||||
|
- `src/features/agent` owns task reasoning UI and controlled recommendations.
|
||||||
|
|
||||||
|
Do not reintroduce supply-network layer IDs or assets into the drainage config.
|
||||||
|
GeoServer layer names, workspace defaults, map style tokens, titles, and Agent
|
||||||
|
copy must remain drainage-specific.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Frontend changes should pass:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec tsc --noEmit
|
pnpm lint
|
||||||
```
|
pnpm typecheck
|
||||||
Runs TypeScript validation without emitting files.
|
pnpm test
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
|
|
||||||
- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components.
|
|
||||||
- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports.
|
|
||||||
- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`.
|
|
||||||
- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`.
|
|
||||||
- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`.
|
|
||||||
- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`.
|
|
||||||
- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`.
|
|
||||||
- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`.
|
|
||||||
- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`.
|
|
||||||
- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components.
|
|
||||||
- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions.
|
|
||||||
|
|
||||||
## Code Organization Style
|
|
||||||
|
|
||||||
Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file.
|
|
||||||
|
|
||||||
Recommended future pattern:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
features/map/core/
|
|
||||||
components/
|
|
||||||
base-layers-control.tsx
|
|
||||||
scale-line.tsx
|
|
||||||
toolbar.tsx
|
|
||||||
zoom.tsx
|
|
||||||
legend.tsx
|
|
||||||
layer-control.tsx
|
|
||||||
hooks/
|
|
||||||
use-map-camera.ts
|
|
||||||
use-layer-visibility.ts
|
|
||||||
use-map-selection.ts
|
|
||||||
map/
|
|
||||||
style.ts
|
|
||||||
layer-utils.ts
|
|
||||||
|
|
||||||
features/workbench/
|
|
||||||
map-workbench-page.tsx
|
|
||||||
components/
|
|
||||||
workbench-top-bar.tsx
|
|
||||||
simulation-dock.tsx
|
|
||||||
simulation-result-panel.tsx
|
|
||||||
hooks/use-workbench-map.ts
|
|
||||||
map/sources.ts
|
|
||||||
map/layers.ts
|
|
||||||
map/annotation-layers.ts
|
|
||||||
state/types.ts
|
|
||||||
|
|
||||||
features/agent/
|
|
||||||
components/agent-assistant-panel.tsx
|
|
||||||
data/agent-task.ts
|
|
||||||
types.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions.
|
|
||||||
|
|
||||||
Keep the growth boundary clear:
|
|
||||||
- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives.
|
|
||||||
- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions.
|
|
||||||
- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
There is no formal test suite yet. For now, required checks are:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm exec tsc --noEmit
|
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`.
|
Run focused Playwright tests when changing visible behavior, responsive layout,
|
||||||
|
runtime configuration, or map interactions. Name tests after behavior.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Configuration and Security
|
||||||
|
|
||||||
This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as:
|
Browser configuration is injected through `/runtime-config.js` and validated in
|
||||||
|
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy
|
||||||
|
`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease
|
||||||
|
local migration.
|
||||||
|
|
||||||
```txt
|
Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
|
||||||
Add direct GeoServer MVT sources
|
Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
|
||||||
Refactor map workbench shell
|
in untracked environment files.
|
||||||
```
|
|
||||||
|
|
||||||
Pull requests should include:
|
|
||||||
- Summary of user-visible changes.
|
|
||||||
- Notes on map/data source changes.
|
|
||||||
- Screenshots for UI changes.
|
|
||||||
- Verification commands run.
|
|
||||||
|
|
||||||
## Security & Configuration Tips
|
|
||||||
|
|
||||||
Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
|
|
||||||
```
|
|
||||||
|
|
||||||
GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts.
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
: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"
|
||||||
|
|
||||||
|
@tts path /api/tts/edge
|
||||||
|
reverse_proxy @tts {$TTS_API_INTERNAL_BASE_URL:http://127.0.0.1:8790}
|
||||||
|
|
||||||
|
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
|
#### CSS Token Contract
|
||||||
|
|
||||||
`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
|
`src/styles.css` is the single source of truth for surface values. `src/features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
|
||||||
|
|
||||||
### Motion
|
### Motion
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ The Agent panel is a persistent command instrument, so its outer shell should us
|
|||||||
|
|
||||||
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
|
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
|
||||||
|
|
||||||
Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
|
Collapsed Agent state is a floating 72px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
|
||||||
|
|
||||||
Required sections:
|
Required sections:
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
|
|||||||
|
|
||||||
### Map Core Controls
|
### Map Core Controls
|
||||||
|
|
||||||
Reusable controls under `features/map/core/components` should share one compact map-instrument language.
|
Reusable controls under `src/features/map/core/components` should share one compact map-instrument language.
|
||||||
|
|
||||||
#### Control Surface
|
#### Control Surface
|
||||||
|
|
||||||
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
|
|||||||
|
|
||||||
Recommended implementation direction:
|
Recommended implementation direction:
|
||||||
|
|
||||||
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`.
|
- Keep route files thin and move feature UI into `src/features/workbench`, `src/features/map/core`, and `src/features/agent`.
|
||||||
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
|
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
|
||||||
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
|
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
|
||||||
- Define operational colors as shared constants for both map layers and UI legends.
|
- Define operational colors as shared constants for both map layers and UI legends.
|
||||||
@@ -830,16 +830,16 @@ Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component s
|
|||||||
Target component layering:
|
Target component layering:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
shared/ui
|
src/shared/ui
|
||||||
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
|
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
|
||||||
|
|
||||||
features/map/core/components
|
src/features/map/core/components
|
||||||
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
|
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
|
||||||
|
|
||||||
features/workbench/components
|
src/features/workbench/components
|
||||||
Water-network workbench components composed from shared UI and map core controls.
|
Water-network workbench components composed from shared UI and map core controls.
|
||||||
|
|
||||||
features/agent/components
|
src/features/agent/components
|
||||||
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
|
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/drainage-frontend-entrypoint
|
||||||
|
COPY --from=build /app/dist /srv
|
||||||
|
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 | 用途 |
|
| Token | HEX | OKLCH | 用途 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|||||||
@@ -1,22 +1,91 @@
|
|||||||
# Drainage Network Frontend
|
# Next TJWater Drainage Frontend
|
||||||
|
|
||||||
排水管网 WebGIS 业务系统,基于 `next-webgis` 基础项目创建。
|
排水管网 WebGIS Agent 工作台。界面以地图为主视图,集成排水管渠、检查井、排放口、泵、SCADA 监测、异常工况、Agent 对话和受控前端动作。
|
||||||
|
|
||||||
## 开发
|
项目已从 Next.js App Router 迁移到 Vite、React 19、TypeScript 和 Tailwind CSS 4。地图基于 MapLibre GL,流向效果使用 deck.gl,Agent 消息支持代码高亮、数学公式和 Mermaid 图表。
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
- 排水管网 MVT 图层和 SCADA 监测点展示
|
||||||
|
- 地图缩放、测量、绘制、图层控制、要素定位和视图导出
|
||||||
|
- 异常工况列表、详情分析和调度任务跟踪
|
||||||
|
- Agent 流式对话、推荐问题、会话管理和权限批准
|
||||||
|
- 经过 Schema 校验的 UI Envelope 和前端动作执行
|
||||||
|
- 运行时配置注入、MSW 本地模拟和开发面板
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
corepack enable
|
||||||
pnpm install
|
pnpm install
|
||||||
|
cp .env.example .env.local
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
默认访问地址为 <http://localhost:3000>。
|
开发服务器默认地址为 <http://127.0.0.1:5173>。完整对话功能需要同时启动 Agent 后端。
|
||||||
|
|
||||||
## 校验
|
## 运行时配置
|
||||||
|
|
||||||
|
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.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_TTS_API_URL` | `/api/tts/edge` | 语音合成接口地址 |
|
||||||
|
| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
||||||
|
| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
||||||
|
| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址 |
|
||||||
|
| `TTS_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8790` | Vite/Caddy 代理访问的独立 TTS 服务地址 |
|
||||||
|
|
||||||
|
Vite 开发适配器会兼容现有 `.env.local` 中的 `NEXT_PUBLIC_*` 变量,生产容器应使用 `DRAINAGE_*` 变量。
|
||||||
|
|
||||||
|
排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。Agent 流式接口使用 `DRAINAGE_AGENT_API_BASE_URL`。语音合成依赖独立 TTS 服务;默认站内路径会由 Vite 或容器内 Caddy 转发到 `TTS_API_INTERNAL_BASE_URL`。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm lint
|
pnpm dev # 启动 Vite 开发服务器
|
||||||
pnpm exec tsc --noEmit
|
pnpm build # 类型检查并生成生产构建
|
||||||
pnpm build
|
pnpm preview # 预览生产构建
|
||||||
|
pnpm typecheck # 运行 TypeScript 检查
|
||||||
|
pnpm lint # 运行 ESLint
|
||||||
|
pnpm format # 使用 Prettier 格式化文件
|
||||||
|
pnpm test # 运行 Vitest 单元测试
|
||||||
|
pnpm test:watch # 监听模式运行单元测试
|
||||||
|
pnpm test:browser # 运行 Playwright 浏览器测试
|
||||||
```
|
```
|
||||||
|
|
||||||
地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。
|
## Docker
|
||||||
|
|
||||||
|
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`。
|
||||||
|
|
||||||
|
```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,13 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "@/lib/config";
|
|
||||||
const TYPES = { junction: "geo_junctions", conduit: "geo_conduits", pipe: "geo_conduits", orifice: "geo_orifices", outfall: "geo_outfalls", pump: "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 typeName = `${GEOSERVER_WORKSPACE}:${TYPES[body.featureType as keyof typeof TYPES]}`;
|
|
||||||
const url = new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: typeName, 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,30 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
MAX_CONDUIT_FEATURES,
|
|
||||||
MAX_MAP_FEATURE_IDS,
|
|
||||||
createMapFeatureWfsUrl,
|
|
||||||
normalizeMapFeatureCollection,
|
|
||||||
parseMapFeatureQuery
|
|
||||||
} from "@/features/workbench/map/map-feature-query";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const parsed = parseMapFeatureQuery(await request.json().catch(() => null));
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return NextResponse.json({ code: parsed.code }, { status: parsed.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: AbortSignal.timeout(8_000)
|
|
||||||
});
|
|
||||||
const collection = normalizeMapFeatureCollection(
|
|
||||||
await response.json(),
|
|
||||||
parsed.value.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES
|
|
||||||
);
|
|
||||||
if (!response.ok || !collection) throw new Error("Invalid WFS response");
|
|
||||||
return NextResponse.json(collection);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Communicate } from "edge-tts-ts";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
|
|
||||||
const MAX_TEXT_LENGTH = 12_000;
|
|
||||||
|
|
||||||
const jsonError = (message: string, status: number) =>
|
|
||||||
NextResponse.json({ error: message }, { status });
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
let payload: { text?: unknown; voice?: unknown };
|
|
||||||
try {
|
|
||||||
payload = await request.json() as { text?: unknown; voice?: unknown };
|
|
||||||
} catch {
|
|
||||||
return jsonError("请求内容不是有效 JSON", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
||||||
if (!text) return jsonError("缺少待朗读文本", 400);
|
|
||||||
if (text.length > MAX_TEXT_LENGTH) return jsonError(`单次文本不能超过 ${MAX_TEXT_LENGTH} 字`, 413);
|
|
||||||
|
|
||||||
const voice = typeof payload.voice === "string" && payload.voice.trim()
|
|
||||||
? payload.voice.trim()
|
|
||||||
: DEFAULT_VOICE;
|
|
||||||
|
|
||||||
try {
|
|
||||||
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) return jsonError("语音服务返回了空音频", 502);
|
|
||||||
|
|
||||||
const audio = new Uint8Array(byteLength);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
audio.set(chunk, offset);
|
|
||||||
offset += chunk.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/mpeg",
|
|
||||||
"Cache-Control": "no-store"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Agent TTS] 语音生成失败", error);
|
|
||||||
return jsonError("语音生成失败", 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,30 +0,0 @@
|
|||||||
import type { Metadata, Viewport } 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 const viewport: Viewport = {
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
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,24 @@
|
|||||||
|
#!/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 ttsApiUrl "${DRAINAGE_TTS_API_URL:-/api/tts/edge}" \
|
||||||
|
--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_TTS_API_URL: $ttsApiUrl,
|
||||||
|
DRAINAGE_ENABLE_DEV_PANEL: $enableDevPanel,
|
||||||
|
DRAINAGE_ENABLE_MSW: $enableMsw
|
||||||
|
}')
|
||||||
|
|
||||||
|
printf 'globalThis.__DRAINAGE_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -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,36 @@
|
|||||||
|
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/**", "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,66 +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",
|
|
||||||
variant === "obsidian" && "scale-110",
|
|
||||||
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,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": "!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"
|
|
||||||
};
|
|
||||||
|
|
||||||
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] !w-[min(420px,calc(100vw-24px))]", 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-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 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 "地图数据源请求失败,请检查网络或稍后重试。";
|
|
||||||
}
|
|
||||||
+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,10 +0,0 @@
|
|||||||
const DEFAULT_MAP_URL = "https://geoserver.waternetwork.cn/geoserver";
|
|
||||||
const DEFAULT_GEOSERVER_WORKSPACE = "wenzhou";
|
|
||||||
|
|
||||||
export const MAP_URL = (
|
|
||||||
process.env.NEXT_PUBLIC_MAP_URL || DEFAULT_MAP_URL
|
|
||||||
).replace(/\/$/, "");
|
|
||||||
|
|
||||||
export const GEOSERVER_WORKSPACE = (
|
|
||||||
process.env.NEXT_PUBLIC_GEOSERVER_WORKSPACE || DEFAULT_GEOSERVER_WORKSPACE
|
|
||||||
).trim().replace(/^\/+|\/+$/g, "");
|
|
||||||
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,7 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
serverExternalPackages: ["edge-tts-ts"],
|
|
||||||
typedRoutes: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
+45
-21
@@ -1,21 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "drainage-network-frontend",
|
"name": "next-tjwater-drainage-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.25.0",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.8.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:browser": "playwright test"
|
"test:browser": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^4.0.9",
|
"@ai-sdk/react": "^4.0.9",
|
||||||
"@deck.gl/core": "9.3.7",
|
"@deck.gl/core": "^9.3.7",
|
||||||
"@deck.gl/layers": "9.3.7",
|
"@deck.gl/layers": "^9.3.7",
|
||||||
"@deck.gl/mapbox": "9.3.7",
|
"@deck.gl/mapbox": "^9.3.7",
|
||||||
"@radix-ui/react-accordion": "^1.2.14",
|
"@radix-ui/react-accordion": "^1.2.14",
|
||||||
"@radix-ui/react-collapsible": "^1.1.14",
|
"@radix-ui/react-collapsible": "^1.1.14",
|
||||||
"@radix-ui/react-dialog": "^1.1.17",
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
@@ -35,34 +39,54 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"edge-tts-ts": "^1.0.0",
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
"nanoid": "^5.1.14",
|
"nanoid": "^5.1.14",
|
||||||
"next": "15.5.19",
|
"react": "^19.2.7",
|
||||||
"react": "19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-dom": "19.2.7",
|
|
||||||
"shiki": "^4.2.0",
|
"shiki": "^4.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"streamdown": "^2.5.0",
|
"streamdown": "^2.5.0",
|
||||||
"swr": "2.4.2",
|
"swr": "^2.4.2",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"use-stick-to-bottom": "^1.1.6"
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@babel/eslint-parser": "^7.25.9",
|
||||||
|
"@babel/preset-react": "^7.25.9",
|
||||||
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/node": "^24.7.2",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "15.5.19",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"msw": "^2.6.8",
|
||||||
"playwright": "^1.61.1",
|
"playwright": "^1.61.1",
|
||||||
"postcss": "^8.4.49",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^7.0.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -1,19 +1,29 @@
|
|||||||
import { defineConfig } from "playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
|
const testPort = process.env.PLAYWRIGHT_PORT || "5191";
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/browser",
|
testDir: ".",
|
||||||
testMatch: "**/*.e2e.ts",
|
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||||
timeout: 30_000,
|
fullyParallel: true,
|
||||||
|
reporter: "html",
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
viewport: { width: 1440, height: 900 }
|
trace: "on-first-retry"
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "pnpm dev",
|
command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||||
reuseExistingServer: true,
|
url: baseURL,
|
||||||
timeout: 120_000,
|
reuseExistingServer: false
|
||||||
url: baseURL
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
viewport: { width: 1440, height: 900 }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2794
-3330
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,7 +1,12 @@
|
|||||||
|
packages:
|
||||||
|
- .
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
sharp: set this to true or false
|
'@swc/core': true
|
||||||
unrs-resolver: set this to true or false
|
esbuild: true
|
||||||
|
msw: true
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- sharp
|
- "@swc/core"
|
||||||
- unrs-resolver
|
- esbuild
|
||||||
|
- msw
|
||||||
|
|||||||
@@ -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"
|
||||||
|
};
|
||||||
@@ -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__ = {};
|
||||||
@@ -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,120 @@
|
|||||||
|
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),
|
||||||
|
DRAINAGE_TTS_API_URL: expect.any(String)
|
||||||
|
});
|
||||||
|
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||||
|
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||||
|
|
||||||
export type AgentPermissionReply = "once" | "always" | "reject";
|
export type AgentPermissionReply = "once" | "always" | "reject";
|
||||||
@@ -110,9 +112,7 @@ export type AgentApiClient = {
|
|||||||
abort: (sessionId: string) => Promise<void>;
|
abort: (sessionId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
|
const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||||
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
|
|
||||||
: ["", "http://127.0.0.1:8787"];
|
|
||||||
|
|
||||||
const CHAT_PATH = "/api/v1/agent/chat";
|
const CHAT_PATH = "/api/v1/agent/chat";
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { AgentPersona } from "./agent-persona";
|
||||||
|
|
||||||
|
type AgentCollapsedRailProps = {
|
||||||
|
personaState?: PersonaState;
|
||||||
|
statusLabel?: string;
|
||||||
|
onExpand: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentCollapsedRail({
|
||||||
|
personaState,
|
||||||
|
statusLabel = "Agent 已就绪",
|
||||||
|
onExpand
|
||||||
|
}: AgentCollapsedRailProps) {
|
||||||
|
const expandLabel = `展开排水助手面板,当前状态:${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="展开排水助手面板"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+109
-46
@@ -24,7 +24,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||||
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -105,7 +105,10 @@ type AgentCommandPanelProps = {
|
|||||||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||||||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||||||
onStopPrompt?: () => void;
|
onStopPrompt?: () => void;
|
||||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
onReplyPermission?: (
|
||||||
|
permission: AgentPermissionRequest,
|
||||||
|
reply: AgentPermissionReply
|
||||||
|
) => Promise<void> | void;
|
||||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
@@ -227,23 +230,27 @@ export function AgentCommandPanel({
|
|||||||
<header className="agent-panel-header">
|
<header className="agent-panel-header">
|
||||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
<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">
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||||
<AgentPersona
|
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||||
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 flex-1">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">对话主题</p>
|
<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="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))} />
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full",
|
||||||
|
getAgentConnectionClassName(statusLabel)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{getAgentConnectionLabel(statusLabel)}
|
{getAgentConnectionLabel(statusLabel)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
|
<p
|
||||||
|
className="truncate text-sm font-semibold text-slate-950"
|
||||||
|
title={sessionTitle}
|
||||||
|
>
|
||||||
{sessionTitle}
|
{sessionTitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +343,9 @@ export function AgentCommandPanel({
|
|||||||
|
|
||||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||||
<Conversation className="agent-panel-conversation min-h-0">
|
<Conversation className="agent-panel-conversation min-h-0">
|
||||||
<ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
|
<ConversationContent
|
||||||
|
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
||||||
|
>
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<BackendMessageList
|
<BackendMessageList
|
||||||
@@ -383,7 +392,11 @@ export function AgentCommandPanel({
|
|||||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||||
{messages.length === 0 && !streaming ? (
|
{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">
|
<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">
|
<Suggestions
|
||||||
|
aria-label="推荐问题"
|
||||||
|
role="group"
|
||||||
|
className="pointer-events-auto px-0.5"
|
||||||
|
>
|
||||||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||||
<Suggestion
|
<Suggestion
|
||||||
key={suggestion}
|
key={suggestion}
|
||||||
@@ -398,7 +411,7 @@ export function AgentCommandPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<PromptInput
|
<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"
|
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs 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) => {
|
onSubmit={(message) => {
|
||||||
const nextPrompt = message.text.trim();
|
const nextPrompt = message.text.trim();
|
||||||
if (nextPrompt) {
|
if (nextPrompt) {
|
||||||
@@ -432,7 +445,9 @@ export function AgentCommandPanel({
|
|||||||
<VoiceInputButton
|
<VoiceInputButton
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
onClick={isListening ? stopListening : () => startListening(showRecognitionError)}
|
onClick={
|
||||||
|
isListening ? stopListening : () => startListening(showRecognitionError)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
@@ -486,10 +501,9 @@ function VoiceInputButton({
|
|||||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
"border-transparent bg-transparent text-blue-600 shadow-none",
|
||||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||||
"hover:bg-slate-50 hover:text-blue-700",
|
"hover:bg-slate-50 hover:text-blue-700",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-45",
|
"disabled:cursor-not-allowed disabled:opacity-45",
|
||||||
isListening &&
|
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||||
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -501,8 +515,12 @@ function VoiceInputButton({
|
|||||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }}
|
animate={
|
||||||
transition={reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }}
|
reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -524,14 +542,17 @@ function VoiceInputButton({
|
|||||||
className="w-px rounded-full bg-red-500"
|
className="w-px rounded-full bg-red-500"
|
||||||
style={{ height: bar.height }}
|
style={{ height: bar.height }}
|
||||||
animate={
|
animate={
|
||||||
reduceMotion
|
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||||
? { scaleY: 0.72 }
|
|
||||||
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
|
||||||
}
|
}
|
||||||
transition={
|
transition={
|
||||||
reduceMotion
|
reduceMotion
|
||||||
? { duration: 0.16 }
|
? { duration: 0.16 }
|
||||||
: { duration: bar.duration, delay: bar.delay, ease: "easeInOut", repeat: Infinity }
|
: {
|
||||||
|
duration: bar.duration,
|
||||||
|
delay: bar.delay,
|
||||||
|
ease: "easeInOut",
|
||||||
|
repeat: Infinity
|
||||||
|
}
|
||||||
}
|
}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -581,7 +602,11 @@ function ApprovalModeSelect({
|
|||||||
disabled={disabled || !onValueChange}
|
disabled={disabled || !onValueChange}
|
||||||
aria-label="权限批准模式"
|
aria-label="权限批准模式"
|
||||||
>
|
>
|
||||||
{requestApproval ? <Shield size={14} aria-hidden="true" /> : <ShieldCheck size={14} aria-hidden="true" />}
|
{requestApproval ? (
|
||||||
|
<Shield size={14} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
|
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
|
||||||
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -592,7 +617,9 @@ function ApprovalModeSelect({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
|
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">权限批准模式</DropdownMenuLabel>
|
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
|
||||||
|
权限批准模式
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||||
@@ -605,7 +632,9 @@ function ApprovalModeSelect({
|
|||||||
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
|
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
|
||||||
<span className="block text-xs leading-5 text-slate-500">工具权限逐次确认</span>
|
<span className="block text-xs leading-5 text-slate-500">工具权限逐次确认</span>
|
||||||
</span>
|
</span>
|
||||||
{requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" /> : null}
|
{requestApproval ? (
|
||||||
|
<CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -619,7 +648,9 @@ function ApprovalModeSelect({
|
|||||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||||||
</span>
|
</span>
|
||||||
{!requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" /> : null}
|
{!requestApproval ? (
|
||||||
|
<CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -661,7 +692,8 @@ function AgentConversationScrollManager({
|
|||||||
!previousSnapshot ||
|
!previousSnapshot ||
|
||||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
(Boolean(snapshot.lastUserMessageId) &&
|
||||||
|
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||||
|
|
||||||
if (!forceScroll) {
|
if (!forceScroll) {
|
||||||
return;
|
return;
|
||||||
@@ -704,9 +736,7 @@ function AgentModelSelect({
|
|||||||
|
|
||||||
if (!models.length) {
|
if (!models.length) {
|
||||||
return (
|
return (
|
||||||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
|
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">模型</span>
|
||||||
模型
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,10 +782,16 @@ function AgentModelSelect({
|
|||||||
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
|
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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-sm font-semibold text-slate-800">
|
||||||
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
|
{model.label}
|
||||||
</span>
|
</span>
|
||||||
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
|
<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>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -908,7 +944,10 @@ function BackendMessageList({
|
|||||||
messages: AgentChatMessage[];
|
messages: AgentChatMessage[];
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
streamRenderState: AgentStreamRenderState;
|
streamRenderState: AgentStreamRenderState;
|
||||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
onReplyPermission?: (
|
||||||
|
permission: AgentPermissionRequest,
|
||||||
|
reply: AgentPermissionReply
|
||||||
|
) => Promise<void> | void;
|
||||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||||
isSpeechSupported: boolean;
|
isSpeechSupported: boolean;
|
||||||
@@ -920,7 +959,11 @@ function BackendMessageList({
|
|||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
|
<motion.div
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
variants={agentPanelListVariants}
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -938,7 +981,8 @@ function BackendMessageList({
|
|||||||
<MessageContent
|
<MessageContent
|
||||||
className={cn(
|
className={cn(
|
||||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
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"
|
message.role === "assistant" &&
|
||||||
|
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-xs"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1040,7 +1084,12 @@ function AgentSpeechMessage({
|
|||||||
|
|
||||||
const browserSelection = window.getSelection();
|
const browserSelection = window.getSelection();
|
||||||
const container = contentRef.current;
|
const container = contentRef.current;
|
||||||
if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) {
|
if (
|
||||||
|
!browserSelection ||
|
||||||
|
browserSelection.rangeCount === 0 ||
|
||||||
|
browserSelection.isCollapsed ||
|
||||||
|
!container
|
||||||
|
) {
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1157,8 @@ function AgentSpeechMessage({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectionAnchor && typeof document !== "undefined" ? createPortal(
|
{selectionAnchor && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
||||||
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
||||||
@@ -1118,7 +1168,7 @@ function AgentSpeechMessage({
|
|||||||
<motion.button
|
<motion.button
|
||||||
key="speech-selection-action"
|
key="speech-selection-action"
|
||||||
type="button"
|
type="button"
|
||||||
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -1136,7 +1186,10 @@ function AgentSpeechMessage({
|
|||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={speakFromSelection}
|
onClick={speakFromSelection}
|
||||||
>
|
>
|
||||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600" aria-hidden="true">
|
<span
|
||||||
|
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<Volume2 size={15} />
|
<Volume2 size={15} />
|
||||||
</span>
|
</span>
|
||||||
从这里开始朗读
|
从这里开始朗读
|
||||||
@@ -1145,12 +1198,16 @@ function AgentSpeechMessage({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
) : null}
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{isSupported && !disabled ? (
|
{isSupported && !disabled ? (
|
||||||
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
|
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
|
||||||
{speechState === "idle" ? (
|
{speechState === "idle" ? (
|
||||||
<SpeechIconButton label="语音播放" onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}>
|
<SpeechIconButton
|
||||||
|
label="语音播放"
|
||||||
|
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}
|
||||||
|
>
|
||||||
<Volume2 size={15} aria-hidden="true" />
|
<Volume2 size={15} aria-hidden="true" />
|
||||||
</SpeechIconButton>
|
</SpeechIconButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1185,7 +1242,13 @@ function AgentSpeechMessage({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-[11px] text-slate-400">
|
<span className="text-[11px] text-slate-400">
|
||||||
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
|
{speechState === "idle"
|
||||||
|
? "语音播放"
|
||||||
|
: speechState === "loading"
|
||||||
|
? "正在生成"
|
||||||
|
: speechState === "playing"
|
||||||
|
? "播放中"
|
||||||
|
: "已暂停"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
+1
-1
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 pb-3">
|
<div className="px-3 pb-3">
|
||||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
|
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||||
+1
-1
@@ -685,7 +685,7 @@ function QuestionInput({
|
|||||||
) : null}
|
) : null}
|
||||||
{showCustomInput ? (
|
{showCustomInput ? (
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||||
placeholder="输入回答"
|
placeholder="输入回答"
|
||||||
value={value.custom}
|
value={value.custom}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const LazyPersona = lazy(async () => {
|
||||||
|
const module = await import("@/shared/ai-elements/persona");
|
||||||
|
return { default: module.Persona };
|
||||||
|
});
|
||||||
|
|
||||||
|
type AgentPersonaProps = {
|
||||||
|
className?: string;
|
||||||
|
state?: PersonaState;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useElementVisibility() {
|
||||||
|
const containerRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let intersectsViewport = false;
|
||||||
|
const updateVisibility = () => {
|
||||||
|
setVisible(intersectsViewport && document.visibilityState === "visible");
|
||||||
|
};
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
document.addEventListener("visibilitychange", updateVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", updateVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { containerRef, visible };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonaFallback() {
|
||||||
|
return (
|
||||||
|
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
|
||||||
|
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||||
|
const { containerRef, visible } = useElementVisibility();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||||
|
|
||||||
|
const shouldRenderAnimation = !failed && visible;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={containerRef}
|
||||||
|
aria-label="Agent 状态"
|
||||||
|
className={cn("relative block shrink-0", className)}
|
||||||
|
>
|
||||||
|
{shouldRenderAnimation ? (
|
||||||
|
<Suspense fallback={<PersonaFallback />}>
|
||||||
|
<LazyPersona
|
||||||
|
className="h-full w-full scale-110"
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
state={state}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<PersonaFallback />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
|||||||
const { envelope } = result;
|
const { envelope } = result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { env } from "@/shared/config/env";
|
||||||
import {
|
import {
|
||||||
splitSpeechTextIntoChunks,
|
splitSpeechTextIntoChunks,
|
||||||
type AgentSpeakOptions,
|
type AgentSpeakOptions,
|
||||||
@@ -122,7 +123,7 @@ export function useAgentSpeechSynthesis() {
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fetchControllersRef.current.add(controller);
|
fetchControllersRef.current.add(controller);
|
||||||
const promise = fetch("/api/tts/edge", {
|
const promise = fetch(env.DRAINAGE_TTS_API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text }),
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
describe("StreamingTokenResponse", () => {
|
describe("StreamingTokenResponse", () => {
|
||||||
it("renders the backend-smoothed stream without a second timer buffer", () => {
|
it("renders the backend-smoothed stream without a second timer buffer", () => {
|
||||||
const source = readFileSync(
|
const source = readFileSync(
|
||||||
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
|
join(process.cwd(), "src/features/agent/components/streaming-token-response.tsx"),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
describe("Streamdown fullscreen table", () => {
|
describe("Streamdown fullscreen table", () => {
|
||||||
it("disables inherited token animations only inside the fullscreen copy", () => {
|
it("disables inherited token animations only inside the fullscreen copy", () => {
|
||||||
const styles = readFileSync(join(process.cwd(), "app/globals.css"), "utf8");
|
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
|
||||||
|
|
||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/
|
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/
|
||||||
+4
-4
@@ -114,7 +114,7 @@ export function BaseLayersControl({
|
|||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
active
|
active
|
||||||
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
|
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
|
||||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||||
option.disabled && "cursor-not-allowed opacity-50"
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +166,7 @@ export function BaseLayersControl({
|
|||||||
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||||
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||||
option.disabled && "cursor-not-allowed opacity-50"
|
option.disabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -186,7 +186,7 @@ export function BaseLayersControl({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||||
>
|
>
|
||||||
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||||
{activeLayer ? (
|
{activeLayer ? (
|
||||||
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
|
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs">
|
||||||
<Check size={10} aria-hidden="true" />
|
<Check size={10} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
+1
-1
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
|||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||||
item.active
|
item.active
|
||||||
? "bg-blue-600 text-white shadow-sm"
|
? "bg-blue-600 text-white shadow-xs"
|
||||||
: "hover:bg-blue-50 hover:text-blue-700",
|
: "hover:bg-blue-50 hover:text-blue-700",
|
||||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||||
+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]",
|
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]",
|
||||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
item.visible
|
item.visible
|
||||||
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
|
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
|
||||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||||
)}
|
)}
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
@@ -61,7 +60,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
|
|||||||
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||||
>
|
>
|
||||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||||
{item.imageSrc ? <Image src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-semibold">{item.label}</span>
|
<span className="block truncate font-semibold">{item.label}</span>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { toast, Toaster } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
||||||
|
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
||||||
|
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
||||||
|
|
||||||
|
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||||
|
|
||||||
|
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||||
|
|
||||||
|
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||||
|
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
|
||||||
|
"top-right": "top-[84px] right-[72px]",
|
||||||
|
"bottom-left": "bottom-12 left-4",
|
||||||
|
"bottom-right": "bottom-12 right-4"
|
||||||
|
};
|
||||||
|
|
||||||
|
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||||
|
"top-center": "top-center",
|
||||||
|
"top-right": "top-right",
|
||||||
|
"bottom-left": "bottom-left",
|
||||||
|
"bottom-right": "bottom-right"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showMapNotice(options: MapNoticeOptions) {
|
||||||
|
const tone = options.tone ?? "info";
|
||||||
|
const position = options.position ?? DEFAULT_POSITION;
|
||||||
|
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||||
|
const dismissible = options.dismissible ?? tone !== "loading";
|
||||||
|
|
||||||
|
return toast.custom(
|
||||||
|
() => <MapNoticeContent {...options} tone={tone} />,
|
||||||
|
{
|
||||||
|
id: options.id,
|
||||||
|
duration,
|
||||||
|
position: sonnerPositions[position],
|
||||||
|
dismissible,
|
||||||
|
cancel: dismissible
|
||||||
|
? {
|
||||||
|
label: <MapNoticeCloseIcon />,
|
||||||
|
onClick: (event) => event.stopPropagation()
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
classNames: {
|
||||||
|
cancelButton: cn(
|
||||||
|
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
|
)
|
||||||
|
},
|
||||||
|
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissMapNotice(id?: string | number) {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME
|
||||||
|
} from "./map-control-styles";
|
||||||
|
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
||||||
|
|
||||||
|
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||||
|
info: "acrylic-panel text-slate-900",
|
||||||
|
success: "acrylic-panel text-slate-900",
|
||||||
|
warning: "acrylic-panel text-slate-900",
|
||||||
|
error: "acrylic-panel text-slate-900",
|
||||||
|
loading: "acrylic-panel text-slate-900"
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||||
|
info: "text-blue-600",
|
||||||
|
success: "text-green-600",
|
||||||
|
warning: "text-orange-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
loading: "text-blue-600"
|
||||||
|
};
|
||||||
|
|
||||||
|
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
||||||
|
info: "bg-blue-600",
|
||||||
|
success: "bg-green-500",
|
||||||
|
warning: "bg-orange-500",
|
||||||
|
error: "bg-red-500",
|
||||||
|
loading: "bg-blue-600"
|
||||||
|
};
|
||||||
|
|
||||||
|
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
||||||
|
info: "border-blue-100 bg-blue-50",
|
||||||
|
success: "border-green-100 bg-green-50",
|
||||||
|
warning: "border-orange-100 bg-orange-50",
|
||||||
|
error: "border-red-100 bg-red-50",
|
||||||
|
loading: "border-blue-100 bg-blue-50"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapNoticeContent({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
tone = "info",
|
||||||
|
actionLabel,
|
||||||
|
onAction
|
||||||
|
}: MapNoticeOptions) {
|
||||||
|
const Icon = getNoticeIcon(tone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapNoticeFrame tone={tone}>
|
||||||
|
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||||
|
<MapNoticeBody
|
||||||
|
title={title}
|
||||||
|
message={message}
|
||||||
|
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||||
|
/>
|
||||||
|
</MapNoticeFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapNoticeCloseIcon() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="sr-only">关闭通知</span>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
||||||
|
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||||
|
toneClassNames[tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
|
iconClassNames[tone],
|
||||||
|
noticeIconSurfaceClassNames[tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeBody({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||||
|
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoticeIcon(tone: MapNoticeTone) {
|
||||||
|
if (tone === "success") {
|
||||||
|
return CheckCircle2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "warning") {
|
||||||
|
return AlertTriangle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "error") {
|
||||||
|
return XCircle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === "loading") {
|
||||||
|
return Loader2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Info;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
||||||
|
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
|
|
||||||
|
export type MapNoticeOptions = {
|
||||||
|
id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
tone?: MapNoticeTone;
|
||||||
|
duration?: number;
|
||||||
|
position?: MapNoticePosition;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
dismissible?: boolean;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+46
-6
@@ -101,6 +101,10 @@ export function AgentTaskTicker({
|
|||||||
active: false
|
active: false
|
||||||
}));
|
}));
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
|
||||||
|
typeof document === "undefined" || document.visibilityState === "visible"
|
||||||
|
));
|
||||||
|
const [visibilityRevision, setVisibilityRevision] = useState(0);
|
||||||
const runningConditions = conditions;
|
const runningConditions = conditions;
|
||||||
const visibleTickerCards = useMemo(
|
const visibleTickerCards = useMemo(
|
||||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||||
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
|
|||||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNowMs(Date.now());
|
if (!isDocumentVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshNow = () => setNowMs(Date.now());
|
||||||
|
|
||||||
|
refreshNow();
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
setNowMs(Date.now());
|
refreshNow();
|
||||||
}, 1_000);
|
}, 1_000);
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, []);
|
}, [isDocumentVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRotationIndex(0);
|
setRotationIndex(0);
|
||||||
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
|
|||||||
|
|
||||||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
const isVisible = document.visibilityState === "visible";
|
||||||
|
|
||||||
|
releaseTaskSwitchLock();
|
||||||
|
finishTaskSwitchAnimation();
|
||||||
|
setIsHovered(false);
|
||||||
|
setIsDocumentVisible(isVisible);
|
||||||
|
setVisibilityRevision((current) => current + 1);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const section = sectionRef.current;
|
const section = sectionRef.current;
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
|
|||||||
}, [handleWheel]);
|
}, [handleWheel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasMultipleRunningTasks || isHovered) {
|
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
shiftTask(1);
|
shiftTask(1);
|
||||||
}, 6_000);
|
}, 6_000);
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
|
||||||
|
|
||||||
if (visibleTickerCards.length === 0) {
|
if (visibleTickerCards.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
|
|||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
<AnimatePresence
|
||||||
|
key={`ticker-presence-${visibilityRevision}`}
|
||||||
|
custom={transitionDirection}
|
||||||
|
mode="popLayout"
|
||||||
|
initial={false}
|
||||||
|
>
|
||||||
{visibleTickerCards
|
{visibleTickerCards
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -320,6 +359,7 @@ function TickerTaskCard({
|
|||||||
}}
|
}}
|
||||||
exit="exit"
|
exit="exit"
|
||||||
aria-hidden={!isActive}
|
aria-hidden={!isActive}
|
||||||
|
data-condition-id={condition.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
+9
-5
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
@@ -130,14 +129,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
{feature.layer === "scada" ? (
|
{feature.layer === "scada" ? (
|
||||||
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Image src={getScadaPresentation(feature.properties.kind).iconPath} alt="" width={42} height={42} />
|
<img
|
||||||
|
src={getScadaPresentation(feature.properties.kind).iconPath}
|
||||||
|
alt=""
|
||||||
|
width={42}
|
||||||
|
height={42}
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
||||||
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{scadaQueryUuid ? (
|
{scadaQueryUuid ? (
|
||||||
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-sm">
|
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-xs">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
||||||
<button
|
<button
|
||||||
@@ -145,7 +149,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||||
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||||
onClick={() => void handleCopyScadaUuid()}
|
onClick={() => void handleCopyScadaUuid()}
|
||||||
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
>
|
>
|
||||||
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||||
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||||
+3
-3
@@ -7,7 +7,7 @@ import {
|
|||||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
} from "@/features/map/core/components/map-control-styles";
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { WaterNetworkSourceId } from "../map/sources";
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
@@ -138,12 +138,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{canCopy ? (
|
{canCopy ? (
|
||||||
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||||
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user