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.
|
||||
- Use short motion that explains state change. Separate layout motion from dense content entrance when expanding detail panels.
|
||||
- Preserve list identity and scroll position when a collapsed list expands into a detail workspace.
|
||||
- Validate frontend UI changes with `pnpm lint` and `pnpm exec tsc --noEmit`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
|
||||
- Validate frontend UI changes with `pnpm lint` and `pnpm typecheck`; run `pnpm build` when CSS, Tailwind classes, routing, or build-time behavior could be affected.
|
||||
|
||||
@@ -0,0 +1,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
|
||||
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
NEXT_PUBLIC_GEOSERVER_WORKSPACE=wenzhou
|
||||
NEXT_PUBLIC_ENABLE_DEV_PANEL=false
|
||||
DRAINAGE_MAPBOX_ACCESS_TOKEN=
|
||||
DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||
DRAINAGE_GEOSERVER_WORKSPACE=wenzhou
|
||||
DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||
DRAINAGE_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
|
||||
.env.local
|
||||
.next/
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
tsconfig.tsbuildinfo
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
*:Zone.Identifier
|
||||
供水管网_Agent_WebGIS_设计与技术框架.md
|
||||
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
||||
todo.md
|
||||
|
||||
+36
-36
@@ -1,6 +1,6 @@
|
||||
# 亚克力设计类别与组件使用清单
|
||||
|
||||
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `app/globals.css` 为准,组件映射以 `features/` 下的现有代码为准。
|
||||
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
|
||||
|
||||
## 总览
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- 特征:保留底部分隔和下投影,不使用顶部高光。
|
||||
- 当前组件:
|
||||
- `WorkbenchTopBar`,工作台顶部 Header。
|
||||
- 文件:`features/workbench/components/workbench-top-bar.tsx`。
|
||||
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`。
|
||||
|
||||
### `acrylic-panel`
|
||||
|
||||
@@ -42,15 +42,15 @@
|
||||
- `AlertMenu`、`ScenarioMenu`、`UserMenu`,顶部导航下拉菜单。
|
||||
- `MapNotice`,地图通知。
|
||||
- 直接或间接涉及文件:
|
||||
- `features/agent/components/agent-command-panel.tsx`
|
||||
- `features/agent/components/agent-collapsed-rail.tsx`
|
||||
- `features/map/core/components/map-control-styles.ts`
|
||||
- `features/map/core/components/notice.tsx`
|
||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `features/workbench/components/feature-popover.tsx`
|
||||
- `features/workbench/components/feature-insight-panel.tsx`
|
||||
- `features/workbench/components/map-dev-panel.tsx`
|
||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
- `src/features/agent/components/agent-command-panel.tsx`
|
||||
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||
- `src/features/map/core/components/map-control-styles.ts`
|
||||
- `src/features/map/core/components/notice.tsx`
|
||||
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `src/features/workbench/components/feature-popover.tsx`
|
||||
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||
- `src/features/workbench/components/map-dev-panel.tsx`
|
||||
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
|
||||
### `acrylic-control`
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- `BaseLayersControl`,底图切换入口与预览控件。
|
||||
- `map-workbench-page.tsx` 中的浮动地图控制入口。
|
||||
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
|
||||
- 共享定义:`features/map/core/components/map-control-styles.ts`。
|
||||
- 共享定义:`src/features/map/core/components/map-control-styles.ts`。
|
||||
|
||||
### `glass-transient`
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- 模糊:沿用控件级模糊,16px 至 20px。
|
||||
- 当前组件:
|
||||
- `AgentTaskTicker` 的活动任务卡片。
|
||||
- 文件:`features/workbench/components/agent-task-ticker.tsx`。
|
||||
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`。
|
||||
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`。
|
||||
|
||||
## 2. 内部实体表面
|
||||
@@ -82,7 +82,7 @@
|
||||
### `surface-dock`
|
||||
|
||||
- 用途:预留给固定 Dock 或侧边停靠区域。
|
||||
- 当前状态:已在 `app/globals.css` 中定义,暂无组件直接使用。
|
||||
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
|
||||
- 适用组件:
|
||||
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
|
||||
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
|
||||
@@ -101,17 +101,17 @@
|
||||
- Header 下拉菜单的内部控制区。
|
||||
- 通用 `IconButton`。
|
||||
- 主要文件:
|
||||
- `features/agent/components/agent-collapsed-rail.tsx`
|
||||
- `features/agent/components/agent-command-panel.tsx`
|
||||
- `features/agent/components/agent-operational-brief.tsx`
|
||||
- `features/map/core/components/control-panel.tsx`
|
||||
- `features/map/core/components/layer-control.tsx`
|
||||
- `features/map/core/components/measure-panel.tsx`
|
||||
- `features/workbench/components/agent-task-ticker.tsx`
|
||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||
- `features/workbench/components/feature-insight-panel.tsx`
|
||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
- `src/features/agent/components/agent-collapsed-rail.tsx`
|
||||
- `src/features/agent/components/agent-command-panel.tsx`
|
||||
- `src/features/agent/components/agent-operational-brief.tsx`
|
||||
- `src/features/map/core/components/control-panel.tsx`
|
||||
- `src/features/map/core/components/layer-control.tsx`
|
||||
- `src/features/map/core/components/measure-panel.tsx`
|
||||
- `src/features/workbench/components/agent-task-ticker.tsx`
|
||||
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
|
||||
### `surface-well`
|
||||
|
||||
@@ -132,16 +132,16 @@
|
||||
- 要素洞察、属性列表和底图名称标签。
|
||||
- Header 下拉菜单中的详情块与空状态。
|
||||
- 主要文件:
|
||||
- `features/agent/components/agent-command-panel.tsx`
|
||||
- `features/agent/components/agent-message-details.tsx`
|
||||
- `features/agent/components/agent-operational-brief.tsx`
|
||||
- `features/agent/components/agent-ui-envelope-renderer.tsx`
|
||||
- `features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||
- `features/workbench/components/feature-insight-panel.tsx`
|
||||
- `features/workbench/components/feature-property-list.tsx`
|
||||
- `features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
- `features/map/core/components/base-layers-control.tsx`
|
||||
- `src/features/agent/components/agent-command-panel.tsx`
|
||||
- `src/features/agent/components/agent-message-details.tsx`
|
||||
- `src/features/agent/components/agent-operational-brief.tsx`
|
||||
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
|
||||
- `src/features/workbench/components/scheduled-condition-feed.tsx`
|
||||
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
|
||||
- `src/features/workbench/components/feature-insight-panel.tsx`
|
||||
- `src/features/workbench/components/feature-property-list.tsx`
|
||||
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
|
||||
- `src/features/map/core/components/base-layers-control.tsx`
|
||||
|
||||
## 3. 语义状态表面
|
||||
|
||||
@@ -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
|
||||
|
||||
## Project Structure & Module Organization
|
||||
## Project Structure
|
||||
|
||||
This repository is a Next.js App Router business system for an Agent-driven drainage-network WebGIS.
|
||||
This repository is a Vite React single-page business system for an Agent-driven
|
||||
drainage-network WebGIS. Runtime TypeScript lives under `src/`.
|
||||
|
||||
- `app/`: route entrypoints only. Keep `page.tsx`, `layout.tsx`, route-level loading/error, and metadata thin.
|
||||
- `lib/`: shared utilities that are not owned by a feature.
|
||||
- `features/map/core/`: reusable map UI and generic map helpers only. This layer should not know drainage-network business layer IDs, simulation scenarios, or Agent workflows.
|
||||
- `features/workbench/`: the drainage-network WebGIS workbench. Put drainage-network sources, layers, simulation overlays, feature adapters, and workbench-specific map interactions here.
|
||||
- `features/agent/`: Agent-facing task data, panels, and action recommendations. Agent code should produce typed action data instead of mutating the map or UI directly.
|
||||
- `shared/` (planned): reusable UI, API clients, and cross-feature types once code is reused by multiple features.
|
||||
- Root markdown files are product/design references and should not be treated as runtime code.
|
||||
- `src/app/`: application shell and providers.
|
||||
- `src/features/map/core/`: reusable map UI and generic map helpers. This layer
|
||||
must not know drainage-network business layer IDs or Agent workflows.
|
||||
- `src/features/workbench/`: drainage sources, layers, SCADA overlays, feature
|
||||
adapters, scenarios, and workbench-specific interactions.
|
||||
- `src/features/agent/`: Agent protocol, sessions, typed actions, panels, and
|
||||
recommendations.
|
||||
- `src/shared/`: reusable UI, AI elements, runtime configuration, and utilities.
|
||||
- `src/mocks/`: MSW handlers and fixtures.
|
||||
- `tests/browser/`: product-specific Playwright regressions.
|
||||
- `public/`: static drainage and SCADA assets plus the runtime-config placeholder.
|
||||
|
||||
## 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`.
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Starts the local development server at `http://localhost:3000`.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm test:browser
|
||||
```
|
||||
Creates a production Next.js build. Stop `pnpm dev` before running this to avoid concurrent `.next` writes.
|
||||
|
||||
The Vite development server listens on `http://127.0.0.1:5173` by default.
|
||||
`pnpm build` runs TypeScript validation before producing `dist/`.
|
||||
|
||||
## Coding Style
|
||||
|
||||
- Use TypeScript and React function components with explicit exported types at
|
||||
module boundaries.
|
||||
- Use two-space indentation and kebab-case filenames.
|
||||
- Use PascalCase for components and types, camelCase for functions and values,
|
||||
and `useXxx` for hooks.
|
||||
- Keep orchestration components thin. Move sources, layers, event handling, and
|
||||
feature adapters into focused modules.
|
||||
- Keep MapLibre sources, layers, camera helpers, and interactions outside
|
||||
presentational components.
|
||||
- Keep Agent rendering schema-driven. Validate Agent output before it reaches
|
||||
React, and represent map actions as typed data that can be previewed,
|
||||
confirmed, applied, and rolled back.
|
||||
|
||||
## Domain Boundaries
|
||||
|
||||
- `src/features/map/core` owns generic controls, legends, camera helpers, and
|
||||
reusable selection primitives.
|
||||
- `src/features/workbench` owns conduits, junctions, orifices, outfalls, pumps,
|
||||
SCADA sources, simulation overlays, and drainage-specific behavior.
|
||||
- `src/features/agent` owns task reasoning UI and controlled recommendations.
|
||||
|
||||
Do not reintroduce supply-network layer IDs or assets into the drainage config.
|
||||
GeoServer layer names, workspace defaults, map style tokens, titles, and Agent
|
||||
copy must remain drainage-specific.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend changes should pass:
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
Runs TypeScript validation without emitting files.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Use TypeScript and React function components. Prefer explicit props types over inline anonymous object types for reusable components.
|
||||
- Use two-space indentation. Keep imports ordered: external packages first, then `@/` aliases, then local relative imports.
|
||||
- Use kebab-case for filenames and folders: `feature-insight-panel.tsx`, `use-map-selection.ts`, `drainage-network-layers.ts`.
|
||||
- Use PascalCase for React components and component types: `FeatureInsightPanel`, `MapWorkbenchPage`.
|
||||
- Use camelCase for variables, functions, object fields, and non-component exports: `selectedFeatureId`, `createWaterNetworkSources`.
|
||||
- Use `useXxx` for hooks: `useWorkbenchMap`, `usePanelLayout`.
|
||||
- Use PascalCase type names with clear suffixes: `MapViewState`, `AgentUiResult`, `LayerConfig`, `FeatureInsightProps`.
|
||||
- Use UPPER_SNAKE_CASE only for stable global constants: `WATER_NETWORK_BOUNDS`, `SOURCE_LAYERS`.
|
||||
- Use named exports for feature modules. Avoid default exports except for Next.js route files under `app/`.
|
||||
- Keep MapLibre sources, layers, camera helpers, and interactions outside presentational React components.
|
||||
- Prefer typed configuration objects for GeoServer layers, Mapbox basemap settings, and Agent actions.
|
||||
|
||||
## Code Organization Style
|
||||
|
||||
Use thin orchestration components. A page component may compose panels, hooks, and map modules, but should not contain large mock datasets, layer specs, event handlers, and UI markup in one file.
|
||||
|
||||
Recommended future pattern:
|
||||
|
||||
```txt
|
||||
features/map/core/
|
||||
components/
|
||||
base-layers-control.tsx
|
||||
scale-line.tsx
|
||||
toolbar.tsx
|
||||
zoom.tsx
|
||||
legend.tsx
|
||||
layer-control.tsx
|
||||
hooks/
|
||||
use-map-camera.ts
|
||||
use-layer-visibility.ts
|
||||
use-map-selection.ts
|
||||
map/
|
||||
style.ts
|
||||
layer-utils.ts
|
||||
|
||||
features/workbench/
|
||||
map-workbench-page.tsx
|
||||
components/
|
||||
workbench-top-bar.tsx
|
||||
simulation-dock.tsx
|
||||
simulation-result-panel.tsx
|
||||
hooks/use-workbench-map.ts
|
||||
map/sources.ts
|
||||
map/layers.ts
|
||||
map/annotation-layers.ts
|
||||
state/types.ts
|
||||
|
||||
features/agent/
|
||||
components/agent-assistant-panel.tsx
|
||||
data/agent-task.ts
|
||||
types.ts
|
||||
```
|
||||
|
||||
Agent actions should be data, not imperative UI mutations. Let the UI/map state layer validate, preview, confirm, apply, and roll back actions.
|
||||
|
||||
Keep the growth boundary clear:
|
||||
- `features/map/core` owns generic map capabilities such as base layer controls, toolbars, legends, layer controls, camera helpers, and reusable selection primitives.
|
||||
- `features/workbench` owns drainage-network domain behavior such as pipe, manhole, outfall, and pump-station sources, simulation overlays, feature detail adapters, scenario controls, and business-specific interactions.
|
||||
- `features/agent` owns task reasoning UI and typed recommendations. It should communicate with the workbench through action objects such as `preview-impact`, `locate-feature`, or `set-layer-visible`.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
There is no formal test suite yet. For now, required checks are:
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
When tests are added, colocate feature tests near the feature module or place shared integration tests under `tests/`. Name tests after behavior, for example `map-selection.test.ts`.
|
||||
Run focused Playwright tests when changing visible behavior, responsive layout,
|
||||
runtime configuration, or map interactions. Name tests after behavior.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
## Configuration and Security
|
||||
|
||||
This directory currently has no Git history, so no existing commit convention can be inferred. Use concise, imperative commit messages such as:
|
||||
Browser configuration is injected through `/runtime-config.js` and validated in
|
||||
`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy
|
||||
`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease
|
||||
local migration.
|
||||
|
||||
```txt
|
||||
Add direct GeoServer MVT sources
|
||||
Refactor map workbench shell
|
||||
```
|
||||
|
||||
Pull requests should include:
|
||||
- Summary of user-visible changes.
|
||||
- Notes on map/data source changes.
|
||||
- Screenshots for UI changes.
|
||||
- Verification commands run.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Store local secrets in `.env.local`. Do not commit real tokens. The Mapbox token is read from:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
|
||||
```
|
||||
|
||||
GeoServer MVT layers are loaded directly from `geoserver.waternetwork.cn`; confirm CORS remains enabled before removing fallbacks or changing hosts.
|
||||
Do not commit secrets, `.env.local`, generated `dist/`, dependency folders,
|
||||
Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets
|
||||
in untracked environment files.
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
|
||||
`src/styles.css` is the single source of truth for surface values. `src/features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
|
||||
|
||||
### Motion
|
||||
|
||||
@@ -360,7 +360,7 @@ The Agent panel is a persistent command instrument, so its outer shell should us
|
||||
|
||||
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
|
||||
|
||||
Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
|
||||
Collapsed Agent state is a floating 72px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
|
||||
|
||||
Required sections:
|
||||
|
||||
@@ -462,7 +462,7 @@ Each icon button must have `aria-label` and a tooltip. Active state must be visu
|
||||
|
||||
### Map Core Controls
|
||||
|
||||
Reusable controls under `features/map/core/components` should share one compact map-instrument language.
|
||||
Reusable controls under `src/features/map/core/components` should share one compact map-instrument language.
|
||||
|
||||
#### Control Surface
|
||||
|
||||
@@ -807,7 +807,7 @@ The codebase should eventually align to this document rather than letting curren
|
||||
|
||||
Recommended implementation direction:
|
||||
|
||||
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`.
|
||||
- Keep route files thin and move feature UI into `src/features/workbench`, `src/features/map/core`, and `src/features/agent`.
|
||||
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
|
||||
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
|
||||
- Define operational colors as shared constants for both map layers and UI legends.
|
||||
@@ -830,16 +830,16 @@ Do not use MUI, Ant Design, Mantine, Chakra, or another fully styled component s
|
||||
Target component layering:
|
||||
|
||||
```txt
|
||||
shared/ui
|
||||
src/shared/ui
|
||||
Base typed controls built on Radix primitives, shadcn-style source, Tailwind tokens, and project-specific variants.
|
||||
|
||||
features/map/core/components
|
||||
src/features/map/core/components
|
||||
Reusable map instruments such as toolbar, zoom, scaleline, layer panel, measure panel, annotation panel, and legend.
|
||||
|
||||
features/workbench/components
|
||||
src/features/workbench/components
|
||||
Water-network workbench components composed from shared UI and map core controls.
|
||||
|
||||
features/agent/components
|
||||
src/features/agent/components
|
||||
Agent-specific command, message, permission, preview, and UIEnvelope surfaces composed from shared UI.
|
||||
```
|
||||
|
||||
|
||||
+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 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -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
|
||||
corepack enable
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问地址为 <http://localhost:3000>。
|
||||
开发服务器默认地址为 <http://127.0.0.1:5173>。完整对话功能需要同时启动 Agent 后端。
|
||||
|
||||
## 校验
|
||||
## 运行时配置
|
||||
|
||||
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.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
|
||||
pnpm lint
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
pnpm build # 类型检查并生成生产构建
|
||||
pnpm preview # 预览生产构建
|
||||
pnpm typecheck # 运行 TypeScript 检查
|
||||
pnpm lint # 运行 ESLint
|
||||
pnpm format # 使用 Prettier 格式化文件
|
||||
pnpm test # 运行 Vitest 单元测试
|
||||
pnpm test:watch # 监听模式运行单元测试
|
||||
pnpm test:browser # 运行 Playwright 浏览器测试
|
||||
```
|
||||
|
||||
地图服务和公开配置通过 `.env.local` 提供;可从 `.env.example` 创建本地配置,真实令牌不得提交。
|
||||
## Docker
|
||||
|
||||
生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时根据环境变量生成 `/runtime-config.js`。
|
||||
|
||||
```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",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.9",
|
||||
"@deck.gl/core": "9.3.7",
|
||||
"@deck.gl/layers": "9.3.7",
|
||||
"@deck.gl/mapbox": "9.3.7",
|
||||
"@deck.gl/core": "^9.3.7",
|
||||
"@deck.gl/layers": "^9.3.7",
|
||||
"@deck.gl/mapbox": "^9.3.7",
|
||||
"@radix-ui/react-accordion": "^1.2.14",
|
||||
"@radix-ui/react-collapsible": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
@@ -35,34 +39,54 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.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",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.14",
|
||||
"next": "15.5.19",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"shiki": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"swr": "2.4.2",
|
||||
"swr": "^2.4.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"use-stick-to-bottom": "^1.1.6"
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitejs/plugin-react-swc": "^4.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "15.5.19",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"msw": "^2.6.8",
|
||||
"playwright": "^1.61.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^7.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+21
-11
@@ -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({
|
||||
testDir: "./tests/browser",
|
||||
testMatch: "**/*.e2e.ts",
|
||||
timeout: 30_000,
|
||||
testDir: ".",
|
||||
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
|
||||
fullyParallel: true,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL,
|
||||
viewport: { width: 1440, height: 900 }
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
url: baseURL
|
||||
}
|
||||
command: `DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1440, height: 900 }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Generated
+2794
-3330
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,7 +1,12 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
'@swc/core': true
|
||||
esbuild: true
|
||||
msw: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
- "@swc/core"
|
||||
- esbuild
|
||||
- msw
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
trailingComma: "none"
|
||||
};
|
||||
@@ -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 AgentPermissionReply = "once" | "always" | "reject";
|
||||
@@ -110,9 +112,7 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
|
||||
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
|
||||
: ["", "http://127.0.0.1:8787"];
|
||||
const AGENT_API_BASE_URLS = [env.DRAINAGE_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
personaState?: PersonaState;
|
||||
statusLabel?: string;
|
||||
onExpand: () => void;
|
||||
};
|
||||
|
||||
export function AgentCollapsedRail({
|
||||
personaState,
|
||||
statusLabel = "Agent 已就绪",
|
||||
onExpand
|
||||
}: AgentCollapsedRailProps) {
|
||||
const expandLabel = `展开排水助手面板,当前状态:${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>
|
||||
);
|
||||
}
|
||||
+348
-285
@@ -24,7 +24,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 {
|
||||
Conversation,
|
||||
@@ -105,7 +105,10 @@ type AgentCommandPanelProps = {
|
||||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||||
onSubmitPrompt: (prompt: string) => Promise<void> | 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;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
};
|
||||
@@ -223,233 +226,245 @@ export function AgentCommandPanel({
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<header className="agent-panel-header">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona
|
||||
className="h-12 w-12"
|
||||
fallbackClassName="h-12 w-12 rounded-xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">对话主题</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<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-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">
|
||||
对话主题
|
||||
</p>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
getAgentConnectionClassName(statusLabel)
|
||||
)}
|
||||
/>
|
||||
{getAgentConnectionLabel(statusLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-slate-950"
|
||||
title={sessionTitle}
|
||||
>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
|
||||
{sessionTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
<BackendMessageList
|
||||
key="messages"
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
streamRenderState={streamRenderState}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
isSpeechSupported={isSpeechSupported}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={speak}
|
||||
onPauseSpeech={pauseSpeech}
|
||||
onResumeSpeech={resumeSpeech}
|
||||
onStopSpeech={stopSpeech}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="flex flex-1 items-center justify-center px-4 py-8"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState />
|
||||
</motion.div>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打开 Agent 历史记录"
|
||||
title="打开 Agent 历史记录"
|
||||
onClick={() => setHistoryOpen((open) => !open)}
|
||||
className={cn(
|
||||
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
|
||||
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AgentUiEnvelopeRenderer results={uiResults} />
|
||||
</ConversationContent>
|
||||
<AgentConversationScrollManager
|
||||
activeSessionId={activeSessionId}
|
||||
messages={messages}
|
||||
scrollRequestId={scrollRequestId}
|
||||
/>
|
||||
<ConversationScrollButton className="bottom-3" />
|
||||
</Conversation>
|
||||
</AgentContent>
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
|
||||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
suggestion={suggestion}
|
||||
onClick={submitPrompt}
|
||||
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
>
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</Suggestion>
|
||||
))}
|
||||
</Suggestions>
|
||||
>
|
||||
<History size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="折叠 Agent 面板"
|
||||
title="折叠 Agent 面板"
|
||||
onClick={onCollapse}
|
||||
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<PromptInput
|
||||
className="agent-panel-control overflow-hidden rounded-2xl shadow-sm transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:!ring-0"
|
||||
onSubmit={(message) => {
|
||||
const nextPrompt = message.text.trim();
|
||||
if (nextPrompt) {
|
||||
submitPrompt(nextPrompt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
className="min-h-14 text-sm"
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ApprovalModeSelect
|
||||
value={approvalMode}
|
||||
disabled={streaming}
|
||||
onValueChange={onApprovalModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
onValueChange={onSelectModel}
|
||||
/>
|
||||
{isRecognitionSupported ? (
|
||||
<VoiceInputButton
|
||||
isListening={isListening}
|
||||
disabled={streaming}
|
||||
onClick={isListening ? stopListening : () => startListening(showRecognitionError)}
|
||||
/>
|
||||
) : null}
|
||||
<PromptInputSubmit
|
||||
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
className="agent-panel-primary-icon"
|
||||
disabled={!streaming && !trimmedPrompt}
|
||||
status={streaming ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
<AnimatePresence initial={false}>
|
||||
{historyOpen ? (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
||||
</PromptInputSubmit>
|
||||
<AgentHistoryPanel
|
||||
activeSessionId={activeSessionId}
|
||||
loadingSessionId={loadingSessionId}
|
||||
loading={sessionHistoryLoading}
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
.finally(() => setLoadingSessionId(null));
|
||||
}}
|
||||
onRenameSession={onRenameHistorySession}
|
||||
onDeleteSession={onDeleteHistorySession}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length > 0 || streaming ? (
|
||||
<motion.div
|
||||
key="operational-brief"
|
||||
className="space-y-2 px-3 pb-3"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelSectionVariants}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<AgentOperationalBrief
|
||||
messages={messages}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
onSubmitCommand={submitPrompt}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
|
||||
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<Conversation className="agent-panel-conversation min-h-0">
|
||||
<ConversationContent
|
||||
className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{messages.length > 0 ? (
|
||||
<BackendMessageList
|
||||
key="messages"
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
streamRenderState={streamRenderState}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
isSpeechSupported={isSpeechSupported}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={speak}
|
||||
onPauseSpeech={pauseSpeech}
|
||||
onResumeSpeech={resumeSpeech}
|
||||
onStopSpeech={stopSpeech}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="flex flex-1 items-center justify-center px-4 py-8"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AgentUiEnvelopeRenderer results={uiResults} />
|
||||
</ConversationContent>
|
||||
<AgentConversationScrollManager
|
||||
activeSessionId={activeSessionId}
|
||||
messages={messages}
|
||||
scrollRequestId={scrollRequestId}
|
||||
/>
|
||||
<ConversationScrollButton className="bottom-3" />
|
||||
</Conversation>
|
||||
</AgentContent>
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<Suggestions
|
||||
aria-label="推荐问题"
|
||||
role="group"
|
||||
className="pointer-events-auto px-0.5"
|
||||
>
|
||||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
suggestion={suggestion}
|
||||
onClick={submitPrompt}
|
||||
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
>
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</Suggestion>
|
||||
))}
|
||||
</Suggestions>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</Agent>
|
||||
) : null}
|
||||
<PromptInput
|
||||
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) => {
|
||||
const nextPrompt = message.text.trim();
|
||||
if (nextPrompt) {
|
||||
submitPrompt(nextPrompt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
className="min-h-14 text-sm"
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ApprovalModeSelect
|
||||
value={approvalMode}
|
||||
disabled={streaming}
|
||||
onValueChange={onApprovalModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
onValueChange={onSelectModel}
|
||||
/>
|
||||
{isRecognitionSupported ? (
|
||||
<VoiceInputButton
|
||||
isListening={isListening}
|
||||
disabled={streaming}
|
||||
onClick={
|
||||
isListening ? stopListening : () => startListening(showRecognitionError)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PromptInputSubmit
|
||||
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
className="agent-panel-primary-icon"
|
||||
disabled={!streaming && !trimmedPrompt}
|
||||
status={streaming ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
>
|
||||
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</Agent>
|
||||
</aside>
|
||||
</MotionConfig>
|
||||
);
|
||||
@@ -486,10 +501,9 @@ function VoiceInputButton({
|
||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||
"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",
|
||||
isListening &&
|
||||
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||
isListening && "bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
@@ -501,8 +515,12 @@ function VoiceInputButton({
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={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 }}
|
||||
animate={
|
||||
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}
|
||||
|
||||
@@ -524,14 +542,17 @@ function VoiceInputButton({
|
||||
className="w-px rounded-full bg-red-500"
|
||||
style={{ height: bar.height }}
|
||||
animate={
|
||||
reduceMotion
|
||||
? { scaleY: 0.72 }
|
||||
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||
}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { 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"
|
||||
/>
|
||||
@@ -581,7 +602,11 @@ function ApprovalModeSelect({
|
||||
disabled={disabled || !onValueChange}
|
||||
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>
|
||||
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -592,7 +617,9 @@ function ApprovalModeSelect({
|
||||
sideOffset={8}
|
||||
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
|
||||
className={cn(
|
||||
"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-xs leading-5 text-slate-500">工具权限逐次确认</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
|
||||
className={cn(
|
||||
@@ -619,7 +648,9 @@ function ApprovalModeSelect({
|
||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -661,7 +692,8 @@ function AgentConversationScrollManager({
|
||||
!previousSnapshot ||
|
||||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
|
||||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
|
||||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||
(Boolean(snapshot.lastUserMessageId) &&
|
||||
previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
|
||||
|
||||
if (!forceScroll) {
|
||||
return;
|
||||
@@ -704,9 +736,7 @@ function AgentModelSelect({
|
||||
|
||||
if (!models.length) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
|
||||
模型
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">模型</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -752,10 +782,16 @@ function AgentModelSelect({
|
||||
<ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
|
||||
<span className="block truncate text-sm font-semibold text-slate-800">
|
||||
{model.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-slate-500">
|
||||
{model.description}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
|
||||
{selected ? (
|
||||
<CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -908,7 +944,10 @@ function BackendMessageList({
|
||||
messages: AgentChatMessage[];
|
||||
streaming: boolean;
|
||||
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;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
isSpeechSupported: boolean;
|
||||
@@ -920,7 +959,11 @@ function BackendMessageList({
|
||||
onStopSpeech: () => void;
|
||||
}) {
|
||||
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">
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
@@ -938,7 +981,8 @@ function BackendMessageList({
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
|
||||
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
|
||||
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">
|
||||
@@ -1040,7 +1084,12 @@ function AgentSpeechMessage({
|
||||
|
||||
const browserSelection = window.getSelection();
|
||||
const container = contentRef.current;
|
||||
if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) {
|
||||
if (
|
||||
!browserSelection ||
|
||||
browserSelection.rangeCount === 0 ||
|
||||
browserSelection.isCollapsed ||
|
||||
!container
|
||||
) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
@@ -1108,49 +1157,57 @@ function AgentSpeechMessage({
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{selectionAnchor && typeof document !== "undefined" ? createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.button
|
||||
key="speech-selection-action"
|
||||
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"
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
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">
|
||||
<Volume2 size={15} />
|
||||
</span>
|
||||
从这里开始朗读
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>,
|
||||
document.body
|
||||
) : null}
|
||||
{selectionAnchor && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.button
|
||||
key="speech-selection-action"
|
||||
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-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
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"
|
||||
>
|
||||
<Volume2 size={15} />
|
||||
</span>
|
||||
从这里开始朗读
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
|
||||
{isSupported && !disabled ? (
|
||||
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
|
||||
{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" />
|
||||
</SpeechIconButton>
|
||||
) : null}
|
||||
@@ -1185,7 +1242,13 @@ function AgentSpeechMessage({
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
|
||||
{speechState === "idle"
|
||||
? "语音播放"
|
||||
: speechState === "loading"
|
||||
? "正在生成"
|
||||
: speechState === "playing"
|
||||
? "播放中"
|
||||
: "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
+1
-1
@@ -71,7 +71,7 @@ export function AgentHistoryPanel({
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
+1
-1
@@ -685,7 +685,7 @@ function QuestionInput({
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LazyPersona = lazy(async () => {
|
||||
const module = await import("@/shared/ai-elements/persona");
|
||||
return { default: module.Persona };
|
||||
});
|
||||
|
||||
type AgentPersonaProps = {
|
||||
className?: string;
|
||||
state?: PersonaState;
|
||||
};
|
||||
|
||||
function useElementVisibility() {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let intersectsViewport = false;
|
||||
const updateVisibility = () => {
|
||||
setVisible(intersectsViewport && document.visibilityState === "visible");
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||
updateVisibility();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, visible };
|
||||
}
|
||||
|
||||
function PersonaFallback() {
|
||||
return (
|
||||
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500">
|
||||
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||
const { containerRef, visible } = useElementVisibility();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||
|
||||
const shouldRenderAnimation = !failed && visible;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
aria-label="Agent 状态"
|
||||
className={cn("relative block shrink-0", className)}
|
||||
>
|
||||
{shouldRenderAnimation ? (
|
||||
<Suspense fallback={<PersonaFallback />}>
|
||||
<LazyPersona
|
||||
className="h-full w-full scale-110"
|
||||
onLoadError={handleLoadError}
|
||||
state={state}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PersonaFallback />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
const { envelope } = result;
|
||||
|
||||
return (
|
||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
|
||||
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { env } from "@/shared/config/env";
|
||||
import {
|
||||
splitSpeechTextIntoChunks,
|
||||
type AgentSpeakOptions,
|
||||
@@ -122,7 +123,7 @@ export function useAgentSpeechSynthesis() {
|
||||
|
||||
const controller = new AbortController();
|
||||
fetchControllersRef.current.add(controller);
|
||||
const promise = fetch("/api/tts/edge", {
|
||||
const promise = fetch(env.DRAINAGE_TTS_API_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
describe("StreamingTokenResponse", () => {
|
||||
it("renders the backend-smoothed stream without a second timer buffer", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
|
||||
join(process.cwd(), "src/features/agent/components/streaming-token-response.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Streamdown fullscreen table", () => {
|
||||
it("disables inherited token animations only inside the fullscreen copy", () => {
|
||||
const styles = readFileSync(join(process.cwd(), "app/globals.css"), "utf8");
|
||||
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
|
||||
|
||||
expect(styles).toMatch(
|
||||
/\[data-streamdown="table-fullscreen"\] \[data-sd-animate\] \{\s*animation: none;\s*filter: none;\s*opacity: 1;\s*transform: none;\s*\}/
|
||||
+4
-4
@@ -114,7 +114,7 @@ export function BaseLayersControl({
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active
|
||||
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
|
||||
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100"
|
||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
@@ -166,7 +166,7 @@ export function BaseLayersControl({
|
||||
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
@@ -186,7 +186,7 @@ export function BaseLayersControl({
|
||||
type="button"
|
||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
{activeLayer ? (
|
||||
@@ -219,7 +219,7 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs">
|
||||
<Check size={10} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
+1
-1
@@ -111,7 +111,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
item.active
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "hover:bg-blue-50 hover:text-blue-700",
|
||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||
+1
-1
@@ -64,7 +64,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
|
||||
"relative flex min-h-[76px] w-full flex-col items-center justify-center gap-1.5 border px-1.5 py-2 text-center transition duration-150 active:translate-y-px active:scale-[0.98]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
|
||||
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-xs shadow-blue-950/5"
|
||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
@@ -61,7 +60,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }:
|
||||
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
{item.imageSrc ? <Image src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||
{item.imageSrc ? <img src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
||||
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
||||
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
||||
|
||||
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||
|
||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||
|
||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
|
||||
"top-right": "top-[84px] right-[72px]",
|
||||
"bottom-left": "bottom-12 left-4",
|
||||
"bottom-right": "bottom-12 right-4"
|
||||
};
|
||||
|
||||
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
"top-center": "top-center",
|
||||
"top-right": "top-right",
|
||||
"bottom-left": "bottom-left",
|
||||
"bottom-right": "bottom-right"
|
||||
};
|
||||
|
||||
export function showMapNotice(options: MapNoticeOptions) {
|
||||
const tone = options.tone ?? "info";
|
||||
const position = options.position ?? DEFAULT_POSITION;
|
||||
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = options.dismissible ?? tone !== "loading";
|
||||
|
||||
return toast.custom(
|
||||
() => <MapNoticeContent {...options} tone={tone} />,
|
||||
{
|
||||
id: options.id,
|
||||
duration,
|
||||
position: sonnerPositions[position],
|
||||
dismissible,
|
||||
cancel: dismissible
|
||||
? {
|
||||
label: <MapNoticeCloseIcon />,
|
||||
onClick: (event) => event.stopPropagation()
|
||||
}
|
||||
: undefined,
|
||||
classNames: {
|
||||
cancelButton: cn(
|
||||
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)
|
||||
},
|
||||
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function dismissMapNotice(id?: string | number) {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
|
||||
|
||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "acrylic-panel text-slate-900",
|
||||
success: "acrylic-panel text-slate-900",
|
||||
warning: "acrylic-panel text-slate-900",
|
||||
error: "acrylic-panel text-slate-900",
|
||||
loading: "acrylic-panel text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "text-blue-600",
|
||||
success: "text-green-600",
|
||||
warning: "text-orange-500",
|
||||
error: "text-red-500",
|
||||
loading: "text-blue-600"
|
||||
};
|
||||
|
||||
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "bg-blue-600",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-orange-500",
|
||||
error: "bg-red-500",
|
||||
loading: "bg-blue-600"
|
||||
};
|
||||
|
||||
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "border-blue-100 bg-blue-50",
|
||||
success: "border-green-100 bg-green-50",
|
||||
warning: "border-orange-100 bg-orange-50",
|
||||
error: "border-red-100 bg-red-50",
|
||||
loading: "border-blue-100 bg-blue-50"
|
||||
};
|
||||
|
||||
export function MapNoticeContent({
|
||||
title,
|
||||
message,
|
||||
tone = "info",
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapNoticeOptions) {
|
||||
const Icon = getNoticeIcon(tone);
|
||||
|
||||
return (
|
||||
<MapNoticeFrame tone={tone}>
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||
/>
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapNoticeCloseIcon() {
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">关闭通知</span>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||
className={cn(
|
||||
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
toneClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
iconClassNames[tone],
|
||||
noticeIconSurfaceClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeBody({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getNoticeIcon(tone: MapNoticeTone) {
|
||||
if (tone === "success") {
|
||||
return CheckCircle2;
|
||||
}
|
||||
|
||||
if (tone === "warning") {
|
||||
return AlertTriangle;
|
||||
}
|
||||
|
||||
if (tone === "error") {
|
||||
return XCircle;
|
||||
}
|
||||
|
||||
if (tone === "loading") {
|
||||
return Loader2;
|
||||
}
|
||||
|
||||
return Info;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
||||
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
export type MapNoticeOptions = {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
message: string;
|
||||
tone?: MapNoticeTone;
|
||||
duration?: number;
|
||||
position?: MapNoticePosition;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
dismissible?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
|
||||
export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notice-types";
|
||||
|
||||
export function MapToaster() {
|
||||
return (
|
||||
<Toaster
|
||||
closeButton={false}
|
||||
expand
|
||||
visibleToasts={4}
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: "error",
|
||||
title: "地图异常",
|
||||
message,
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapLoadingNotice({
|
||||
message = "正在初始化地图...",
|
||||
title = "地图加载中",
|
||||
id = "map-loading"
|
||||
}: {
|
||||
message?: string;
|
||||
title?: string;
|
||||
id?: string | number;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: "loading",
|
||||
title,
|
||||
message,
|
||||
duration: Infinity,
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message, title]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type MapSourceStatus = "online" | "degraded" | "offline";
|
||||
|
||||
export type MapSourceStatusNoticeProps = {
|
||||
sourceName: string;
|
||||
status: MapSourceStatus;
|
||||
message?: string;
|
||||
id?: string | number;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
export function MapSourceStatusNotice({
|
||||
sourceName,
|
||||
status,
|
||||
message,
|
||||
id = `map-source-${sourceName}`,
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapSourceStatusNoticeProps) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: getSourceStatusTone(status),
|
||||
title: getSourceStatusTitle(sourceName, status),
|
||||
message: message ?? getSourceStatusMessage(status),
|
||||
duration: status === "online" ? 2600 : Infinity,
|
||||
actionLabel,
|
||||
onAction
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||
if (status === "online") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return `${sourceName} 已连接`;
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return `${sourceName} 降级运行`;
|
||||
}
|
||||
|
||||
return `${sourceName} 不可用`;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return "地图数据源已恢复正常。";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "部分地图能力不可用,系统已使用可用替代方案。";
|
||||
}
|
||||
|
||||
return "地图数据源请求失败,请检查网络或稍后重试。";
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
|
||||
import { AgentTaskTicker } from "./agent-task-ticker";
|
||||
|
||||
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
|
||||
return {
|
||||
id,
|
||||
kind: "condition",
|
||||
taskId,
|
||||
scheduledAt: "2026-07-08T10:00:00+08:00",
|
||||
title: id,
|
||||
summary: "Running condition",
|
||||
status: "running",
|
||||
riskLevel: "normal",
|
||||
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
|
||||
sessionId: id,
|
||||
durationMinutes: 10
|
||||
};
|
||||
}
|
||||
|
||||
function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: visibilityState
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
function getActiveConditionId(container: HTMLElement) {
|
||||
const activeCards = container.querySelectorAll<HTMLElement>('article[aria-hidden="false"]');
|
||||
|
||||
return activeCards.item(activeCards.length - 1).dataset.conditionId;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
setDocumentVisibility("visible");
|
||||
});
|
||||
|
||||
describe("AgentTaskTicker page visibility", () => {
|
||||
it("does not rotate or retain exiting cards while the page is hidden", () => {
|
||||
vi.useFakeTimers();
|
||||
setDocumentVisibility("visible");
|
||||
|
||||
const conditions = [
|
||||
createRunningCondition("condition-a", "scada-diagnosis"),
|
||||
createRunningCondition("condition-b", "smart-dispatch"),
|
||||
createRunningCondition("condition-c", "pump-energy"),
|
||||
createRunningCondition("condition-d", "network-simulation")
|
||||
];
|
||||
const { container } = render(<AgentTaskTicker conditions={conditions} />);
|
||||
const initialConditionId = getActiveConditionId(container);
|
||||
|
||||
act(() => {
|
||||
setDocumentVisibility("hidden");
|
||||
vi.advanceTimersByTime(18_000);
|
||||
});
|
||||
|
||||
expect(getActiveConditionId(container)).toBe(initialConditionId);
|
||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
||||
|
||||
act(() => {
|
||||
setDocumentVisibility("visible");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(6_000);
|
||||
});
|
||||
|
||||
expect(getActiveConditionId(container)).not.toBe(initialConditionId);
|
||||
});
|
||||
});
|
||||
+46
-6
@@ -101,6 +101,10 @@ export function AgentTaskTicker({
|
||||
active: 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 visibleTickerCards = useMemo(
|
||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
|
||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
if (!isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshNow = () => setNowMs(Date.now());
|
||||
|
||||
refreshNow();
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
refreshNow();
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
}, [isDocumentVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationIndex(0);
|
||||
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
|
||||
|
||||
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(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) {
|
||||
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
|
||||
}, [handleWheel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleRunningTasks || isHovered) {
|
||||
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
shiftTask(1);
|
||||
}, 6_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
||||
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
|
||||
|
||||
if (visibleTickerCards.length === 0) {
|
||||
return null;
|
||||
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
||||
<AnimatePresence
|
||||
key={`ticker-presence-${visibilityRevision}`}
|
||||
custom={transitionDirection}
|
||||
mode="popLayout"
|
||||
initial={false}
|
||||
>
|
||||
{visibleTickerCards
|
||||
.slice()
|
||||
.reverse()
|
||||
@@ -320,6 +359,7 @@ function TickerTaskCard({
|
||||
}}
|
||||
exit="exit"
|
||||
aria-hidden={!isActive}
|
||||
data-condition-id={condition.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
+9
-5
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
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 { SCADA_ICON_PATHS } from "../map/scada";
|
||||
import type { DetailFeature } from "../types";
|
||||
@@ -130,14 +129,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
{feature.layer === "scada" ? (
|
||||
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
||||
<button
|
||||
@@ -145,7 +149,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||
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")} />
|
||||
<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_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} 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 type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
@@ -138,12 +138,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{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")} />
|
||||
<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>
|
||||
) : 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" />
|
||||
</button>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user