Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 | ||
|
|
4596af7c12 | ||
|
|
5332f8f0c5 | ||
|
|
1993cadba8 | ||
|
|
7a3677ee9f | ||
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d | ||
|
|
f5e7312e3b | ||
|
|
c6e6e24aab | ||
|
|
adb53d9a13 | ||
|
|
14c76231d5 | ||
|
|
694f7629ee | ||
|
|
701c5a949d | ||
|
|
435a4172e4 | ||
|
|
600a8703ed | ||
|
|
b13fbe7dca | ||
|
|
e2e296dc06 | ||
|
|
9c2a6a386a | ||
|
|
a4e7ab263a | ||
|
|
67c1a88afe | ||
|
|
76e62a2d3d | ||
|
|
758100b345 | ||
|
|
cf6386d209 | ||
|
|
0dea655f68 | ||
|
|
6ff8886524 | ||
|
|
7cd0c61181 | ||
|
|
f6d2e19397 | ||
|
|
181871e0cf | ||
|
|
8934844bd9 | ||
|
|
24cddc18a6 | ||
|
|
7f07f0449d | ||
|
|
757eea49de | ||
|
|
bb7311589c | ||
|
|
877b79ada8 | ||
|
|
a6ea97142a | ||
|
|
4374c89a63 | ||
|
|
224d53a04d | ||
|
|
7d2ae87e39 | ||
|
|
1e872ca873 | ||
|
|
e2a6bb0e7d |
@@ -3,6 +3,8 @@ node_modules
|
||||
out
|
||||
build
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.env*.local
|
||||
README.md
|
||||
docker-compose.yml
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
KEYCLOAK_CLIENT_ID="tjwater"
|
||||
KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5"
|
||||
KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater"
|
||||
NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS"
|
||||
NEXTAUTH_URL="https://demo.waternetwork.cn/"
|
||||
|
||||
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
|
||||
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
|
||||
NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn"
|
||||
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
|
||||
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
||||
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
||||
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
|
||||
NEXT_PUBLIC_NETWORK_NAME="tjwater"
|
||||
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
|
||||
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
|
||||
@@ -0,0 +1,14 @@
|
||||
KEYCLOAK_CLIENT_ID="tjwater"
|
||||
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
|
||||
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
|
||||
NEXTAUTH_SECRET="replace-with-nextauth-secret"
|
||||
NEXTAUTH_URL="https://frontend.example.com/"
|
||||
|
||||
BACKEND_URL="https://server.example.com"
|
||||
AGENT_URL="https://agent.example.com"
|
||||
MAP_URL="https://geoserver.example.com/geoserver"
|
||||
MAP_WORKSPACE="tjwater"
|
||||
MAP_EXTENT="13490131,3630016,13525879,3666968.25"
|
||||
NETWORK_NAME="tjwater"
|
||||
MAPBOX_TOKEN="replace-with-public-mapbox-token"
|
||||
TIANDITU_TOKEN="replace-with-public-tianditu-token"
|
||||
@@ -100,18 +100,11 @@ jobs:
|
||||
}
|
||||
|
||||
docker build \
|
||||
--network=host \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
|
||||
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
|
||||
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
|
||||
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
|
||||
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
|
||||
+6
-2
@@ -19,6 +19,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/public/runtime-config.js
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -26,8 +27,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -35,3 +37,5 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
memery.md
|
||||
|
||||
docs/
|
||||
|
||||
@@ -24,7 +24,7 @@ npm run start
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
|
||||
+15
-21
@@ -1,32 +1,23 @@
|
||||
FROM refinedev/node:22 AS base
|
||||
FROM node:22.23.1-bookworm-slim AS base
|
||||
|
||||
WORKDIR /app/refine
|
||||
|
||||
ARG NPM_CONFIG_REGISTRY
|
||||
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f package-lock.json ]; then npm ci || (echo "===== npm debug logs =====" && find /root/.npm/_logs -maxdepth 1 -type f -name "*-debug-0.log" -print -exec cat {} \; && exit 1); \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
||||
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
||||
ARG NEXT_PUBLIC_BACKEND_URL
|
||||
ARG NEXT_PUBLIC_AGENT_URL
|
||||
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||
ARG NEXT_PUBLIC_MAP_URL
|
||||
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||
ARG NEXT_PUBLIC_MAP_EXTENT
|
||||
ARG NEXT_PUBLIC_NETWORK_NAME
|
||||
ARG NEXT_PUBLIC_MAPBOX_TOKEN
|
||||
ARG NEXT_PUBLIC_TIANDITU_TOKEN
|
||||
|
||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
@@ -37,19 +28,22 @@ FROM base AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/refine/public ./public
|
||||
COPY --from=builder --chown=node:node /app/refine/public ./public
|
||||
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown refine:nodejs .next
|
||||
RUN chown node:node .next
|
||||
|
||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/standalone ./
|
||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/static ./.next/static
|
||||
COPY --from=builder --chown=node:node /app/refine/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/refine/.next/static ./.next/static
|
||||
|
||||
USER refine
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Frontend Naming Audit
|
||||
|
||||
DOC-004 audit for the internal `TJWaterFrontend_Refine` application.
|
||||
|
||||
## Local frontend naming
|
||||
|
||||
- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`.
|
||||
- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`.
|
||||
- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`.
|
||||
- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`.
|
||||
|
||||
## API boundary naming
|
||||
|
||||
Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`.
|
||||
|
||||
Current direct API calls mostly use new `kebab-case` URL paths, including:
|
||||
|
||||
- `/api/v1/admin/projects`
|
||||
- `/api/v1/audit/logs`
|
||||
- `/api/v1/projects/open`
|
||||
- `/api/v1/project-info`
|
||||
- `/api/v1/schemes`
|
||||
- `/api/v1/sensor-placement-schemes`
|
||||
- `/api/v1/burst-analysis`
|
||||
- `/api/v1/valve-isolation-analysis`
|
||||
- `/api/v1/flushing-analysis`
|
||||
- `/api/v1/contaminant-simulation`
|
||||
- `/api/v1/simulations/run-by-date`
|
||||
- `/api/v1/burst-detection/detect`
|
||||
- `/api/v1/burst-location/locate`
|
||||
- `/api/v1/scada/by-ids-field-time-range`
|
||||
- `/api/v1/composite/clean-scada`
|
||||
- `/api/v1/agent/chat/render-ref/{render_ref}`
|
||||
|
||||
## Legacy URL inventory
|
||||
|
||||
The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases:
|
||||
|
||||
| Current frontend URL | Files | Suggested target |
|
||||
| --- | --- | --- |
|
||||
| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` |
|
||||
| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` |
|
||||
| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` |
|
||||
| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||
| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||
| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` |
|
||||
| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` |
|
||||
| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` |
|
||||
| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` |
|
||||
| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` |
|
||||
|
||||
Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls.
|
||||
|
||||
## Follow-up
|
||||
|
||||
Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy.
|
||||
@@ -1,48 +1,91 @@
|
||||
# my-refine-app
|
||||
# TJWaterFrontend_Refine 内部前端
|
||||
|
||||
<div align="center" style="margin: 30px;">
|
||||
<a href="https://refine.dev">
|
||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
||||
</a>
|
||||
</div>
|
||||
<br/>
|
||||
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
|
||||
|
||||
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
|
||||
## 技术栈
|
||||
|
||||
## Getting Started
|
||||
- Next.js 16
|
||||
- React 19
|
||||
- Refine 5
|
||||
- MUI 6 / MUI X
|
||||
- OpenLayers、deck.gl、Turf
|
||||
- Zustand、NextAuth、Jest
|
||||
|
||||
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨
|
||||
## 目录结构
|
||||
|
||||
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
### Running the development server.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```text
|
||||
src/app/ Next.js App Router 页面
|
||||
src/components/ 复用 UI 组件
|
||||
src/providers/ Refine、认证、数据和主题 provider
|
||||
src/hooks/ 业务 hooks
|
||||
src/utils/ 通用工具
|
||||
public/ 静态资源
|
||||
scripts/ 运行时配置和辅助脚本
|
||||
Dockerfile 镜像构建文件
|
||||
docker-compose.yml 本地编排参考
|
||||
```
|
||||
|
||||
### Building for production.
|
||||
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
|
||||
|
||||
## 本地开发
|
||||
|
||||
要求 Node.js 20 或更高版本:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running the production server.
|
||||
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
npm run lint
|
||||
npm test
|
||||
npm run test:coverage
|
||||
npm run build
|
||||
npm run start
|
||||
docker build -t tjwater-frontend:local .
|
||||
```
|
||||
|
||||
## Learn More
|
||||
- `npm run lint`:运行 ESLint。
|
||||
- `npm test`:运行 Jest。
|
||||
- `npm run test:coverage`:生成测试覆盖率。
|
||||
- `npm run build`:生成生产构建。
|
||||
- `npm run start`:启动生产模式服务。
|
||||
|
||||
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs)
|
||||
## 配置说明
|
||||
|
||||
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
|
||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
||||
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
|
||||
|
||||
## License
|
||||
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
|
||||
|
||||
MIT
|
||||
## 开发规范
|
||||
|
||||
- React 组件文件使用 `PascalCase.tsx`。
|
||||
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
|
||||
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
|
||||
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
|
||||
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
|
||||
|
||||
## 测试与发布
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
发布镜像前运行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
|
||||
|
||||
## 安全规则
|
||||
|
||||
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"contract_version": "1.0.0",
|
||||
"contracts": {
|
||||
"agent": {
|
||||
"file": "agent-v1.openapi.json",
|
||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+8
-12
@@ -6,19 +6,15 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
|
||||
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
|
||||
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
|
||||
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
||||
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
||||
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
|
||||
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
|
||||
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
|
||||
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: ${BACKEND_URL}
|
||||
AGENT_URL: ${AGENT_URL}
|
||||
MAP_URL: ${MAP_URL}
|
||||
MAP_WORKSPACE: ${MAP_WORKSPACE}
|
||||
MAP_EXTENT: ${MAP_EXTENT}
|
||||
NETWORK_NAME: ${NETWORK_NAME}
|
||||
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
|
||||
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
|
||||
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
|
||||
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const parseExtent = (value) => {
|
||||
if (!value) {
|
||||
return [13508849, 3608036, 13555781, 3633813];
|
||||
}
|
||||
|
||||
const extent = value.split(",").map(Number);
|
||||
return extent.length === 4 && extent.every(Number.isFinite)
|
||||
? extent
|
||||
: [13508849, 3608036, 13555781, 3633813];
|
||||
};
|
||||
|
||||
const config = {
|
||||
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
||||
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
||||
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
"/app/refine/public/runtime-config.js",
|
||||
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,167 @@
|
||||
# Chat 流式生成动画改造经验
|
||||
|
||||
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||
|
||||
## 目标
|
||||
|
||||
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||
|
||||
## 文本流式生成
|
||||
|
||||
### 经验结论
|
||||
|
||||
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||
|
||||
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||
|
||||
- token 先进入缓冲区。
|
||||
- 前端按固定节奏释放 chunk。
|
||||
- chunk 尽量按词、短语、标点边界切分。
|
||||
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||
|
||||
当前实现采用:
|
||||
|
||||
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||
- 小缓冲按较短 chunk 输出。
|
||||
- 大缓冲最多每帧释放 `160` 字符。
|
||||
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||
|
||||
## Markdown 动画
|
||||
|
||||
### 经验结论
|
||||
|
||||
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||
|
||||
典型例子:
|
||||
|
||||
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||
|
||||
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||
|
||||
当前策略:
|
||||
|
||||
- Markdown 仍完整解析,保证格式正确。
|
||||
- 在 rehype 阶段处理 AST。
|
||||
- 从 AST 尾部反向找最后的可见 text node。
|
||||
- 只给最后一段尾部文本加动画。
|
||||
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||
|
||||
## 当前文字动画建议
|
||||
|
||||
推荐保留轻量动画:
|
||||
|
||||
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||
- 使用 `clip-path` 做左到右 reveal。
|
||||
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||
- 时长控制在 `120ms - 260ms`。
|
||||
|
||||
不要做:
|
||||
|
||||
- 外层整段 `motion.div` 淡入。
|
||||
- 每次流式更新都改变 key。
|
||||
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||
- 在生成中对已有文本重复动画。
|
||||
|
||||
## 滚动和边框稳定
|
||||
|
||||
### 经验结论
|
||||
|
||||
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||
|
||||
当前策略:
|
||||
|
||||
- 生成中不再每个 token 精确贴底。
|
||||
- 底部保留生成缓冲区,当前约 `180px`。
|
||||
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||
- 用户离开底部附近后,不再强制自动跟随。
|
||||
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||
|
||||
## 图表生成
|
||||
|
||||
### 经验结论
|
||||
|
||||
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||
|
||||
当前策略:
|
||||
|
||||
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||
- ECharts 在 skeleton 下方淡入。
|
||||
- 容器尺寸保持一致,避免边框高度突变。
|
||||
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||
|
||||
图表类型动画建议:
|
||||
|
||||
- 折线图:平滑 enter,面积渐显。
|
||||
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||
- 饼图:使用 expansion/sweep 类进入动画。
|
||||
- update 动画要短于 enter 动画。
|
||||
|
||||
### 踩坑
|
||||
|
||||
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||
|
||||
## 状态提示
|
||||
|
||||
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||
|
||||
推荐:
|
||||
|
||||
- 状态放在“分析结果”标题行右侧。
|
||||
- 使用小尺寸、低干扰的 pulsing dots。
|
||||
- 不使用末尾光标,避免和业务文本混在一起。
|
||||
|
||||
## 验证建议
|
||||
|
||||
每次调整流式动画后至少跑:
|
||||
|
||||
```bash
|
||||
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||
npx tsc --noEmit
|
||||
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||
```
|
||||
|
||||
人工验证重点:
|
||||
|
||||
- 长中文回答是否明显落后后端真实速度。
|
||||
- Markdown 加粗、列表、代码块是否乱序。
|
||||
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||
- 用户手动上滚后是否停止强制跟随。
|
||||
|
||||
## 后续调整原则
|
||||
|
||||
1. 先调节 token playback,再调动画。
|
||||
2. 动画只作用于新增内容,已有内容不能重播。
|
||||
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||
Generated
+254
-3359
File diff suppressed because it is too large
Load Diff
+9
-13
@@ -6,14 +6,16 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
||||
"build": "refine build",
|
||||
"start": "refine start",
|
||||
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||
"runtime:config": "node scripts/generate-runtime-config.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"refine": "refine",
|
||||
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||
"api:check": "node scripts/check-api-contracts.mjs",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,11 +40,13 @@
|
||||
"deck.gl": "^9.1.14",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.5",
|
||||
"edge-tts-ts": "^1.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.5",
|
||||
"ol": "^10.7.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -58,12 +62,6 @@
|
||||
"fast-xml-parser": "5.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@refinedev/cli": "^2.16.52",
|
||||
"@refinedev/devtools": "^2.0.5",
|
||||
"@refinedev/devtools-internal": "^2.0.2",
|
||||
"@refinedev/devtools-server": "^2.0.2",
|
||||
"@refinedev/devtools-shared": "^2.0.2",
|
||||
"@refinedev/devtools-ui": "^2.0.3",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -80,10 +78,8 @@
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"refine": {
|
||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
|
||||
<title id="title">TJWater</title>
|
||||
<rect width="72" height="72" rx="18" fill="#1478d4"/>
|
||||
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
|
||||
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
|
||||
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
|
||||
<rect width="1440" height="900" fill="#edf5f5"/>
|
||||
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
|
||||
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
|
||||
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
|
||||
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
|
||||
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
|
||||
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
|
||||
</g>
|
||||
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
|
||||
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
|
||||
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
|
||||
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
|
||||
<circle cx="320" cy="659" r="8"/>
|
||||
</g>
|
||||
<g fill="#0b8f82">
|
||||
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
|
||||
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
|
||||
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
|
||||
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
|
||||
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
|
||||
<circle cx="615" cy="448" r="334"/>
|
||||
</g>
|
||||
<g fill="#1478d4" opacity=".08">
|
||||
<rect x="40" y="40" width="118" height="10" rx="5"/>
|
||||
<rect x="40" y="62" width="72" height="6" rx="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,57 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const run = promisify(execFile);
|
||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const generator = join(
|
||||
projectRoot,
|
||||
"node_modules",
|
||||
"openapi-typescript",
|
||||
"bin",
|
||||
"cli.js",
|
||||
);
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "tjwater-api-contracts-"),
|
||||
);
|
||||
|
||||
try {
|
||||
for (const [name, contract] of Object.entries(manifest.contracts)) {
|
||||
const contractPath = fileURLToPath(
|
||||
new URL(`../contracts/${contract.file}`, import.meta.url),
|
||||
);
|
||||
const payload = await readFile(contractPath);
|
||||
const actual = createHash("sha256").update(payload).digest("hex");
|
||||
if (actual !== contract.sha256) {
|
||||
throw new Error(
|
||||
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
const generatedName = `${name}Api.ts`;
|
||||
const temporaryOutput = join(temporaryDirectory, generatedName);
|
||||
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
|
||||
const expectedOutput = await readFile(
|
||||
new URL(`../src/generated/${generatedName}`, import.meta.url),
|
||||
);
|
||||
const generatedOutput = await readFile(temporaryOutput);
|
||||
if (!expectedOutput.equals(generatedOutput)) {
|
||||
throw new Error(
|
||||
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import nextEnv from "@next/env";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const projectDir = process.cwd();
|
||||
const { loadEnvConfig } = nextEnv;
|
||||
|
||||
loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
|
||||
|
||||
const parseExtent = (value) => {
|
||||
if (!value) {
|
||||
return [13508849, 3608036, 13555781, 3633813];
|
||||
}
|
||||
|
||||
const extent = value.split(",").map(Number);
|
||||
return extent.length === 4 && extent.every(Number.isFinite)
|
||||
? extent
|
||||
: [13508849, 3608036, 13555781, 3633813];
|
||||
};
|
||||
|
||||
const config = {
|
||||
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
||||
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
||||
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||
};
|
||||
|
||||
const outputPath = path.join(projectDir, "public", "runtime-config.js");
|
||||
|
||||
fs.writeFileSync(
|
||||
outputPath,
|
||||
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||
);
|
||||
|
||||
console.log(`Generated ${path.relative(projectDir, outputPath)}`);
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||
import {
|
||||
RAINBOW_COLORS,
|
||||
RISK_BREAKS,
|
||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
const Timeline = dynamic(
|
||||
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
|
||||
{
|
||||
loading: () => <MapTimelineSkeleton />,
|
||||
},
|
||||
);
|
||||
const HealthRiskStatistics = dynamic(
|
||||
() =>
|
||||
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
|
||||
},
|
||||
);
|
||||
const PredictDataPanel = dynamic(
|
||||
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<HealthRiskProvider>
|
||||
<MapToolbar
|
||||
queryType="realtime"
|
||||
hiddenButtons={["style"]}
|
||||
HistoryPanel={PredictDataPanel}
|
||||
/>
|
||||
<Timeline />
|
||||
<HealthRiskStatistics />
|
||||
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
||||
<StyleLegend
|
||||
layerName="管道"
|
||||
layerId="health-risk"
|
||||
property="健康风险"
|
||||
colors={RAINBOW_COLORS}
|
||||
type="line"
|
||||
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
||||
breaks={[0, ...RISK_BREAKS]}
|
||||
/>
|
||||
</Box>
|
||||
</HealthRiskProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const BurstDetectionPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-detection" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="burst_detection"
|
||||
hiddenButtons={["style"]}
|
||||
/>
|
||||
<BurstDetectionPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const BurstLocationPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-location" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="burst_location"
|
||||
hiddenButtons={["style"]}
|
||||
/>
|
||||
<BurstLocationPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const BurstPipeAnalysisPanel = dynamic(
|
||||
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="burst_analysis"
|
||||
enableCompare
|
||||
/>
|
||||
<BurstPipeAnalysisPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const WaterQualityPanel = dynamic(
|
||||
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="contaminant_analysis"
|
||||
enableCompare
|
||||
/>
|
||||
<WaterQualityPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const DMALeakDetectionPanel = dynamic(
|
||||
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="dma_leak_identification"
|
||||
hiddenButtons={["style"]}
|
||||
/>
|
||||
<DMALeakDetectionPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const FlushingAnalysisPanel = dynamic(
|
||||
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
||||
<FlushingAnalysisPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
|
||||
export default function MapLayout({ children }: { children: ReactNode }) {
|
||||
return <MapComponent>{children}</MapComponent>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const MonitoringPlaceOptimizationPanel = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<MapPanelSkeleton variant="monitoring-place-optimization" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MapToolbar hiddenButtons={["style"]} />
|
||||
<MonitoringPlaceOptimizationPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
MapPanelSkeleton,
|
||||
MapTimelineSkeleton,
|
||||
} from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const Timeline = dynamic(
|
||||
() => import("@components/olmap/core/Controls/Timeline"),
|
||||
{
|
||||
loading: () => <MapTimelineSkeleton />,
|
||||
},
|
||||
);
|
||||
const SCADADeviceList = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADeviceList"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="network-simulation" />,
|
||||
},
|
||||
);
|
||||
const SCADADataPanel = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedDeviceIds(ids);
|
||||
setPanelVisible(ids.length > 0);
|
||||
}, []);
|
||||
|
||||
const handleDeviceClick = useCallback(() => {
|
||||
setPanelVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapToolbar queryType="realtime" />
|
||||
<Timeline />
|
||||
<SCADADeviceList
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedDeviceIds={selectedDeviceIds}
|
||||
/>
|
||||
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
const SCADADeviceList = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADeviceList"),
|
||||
{
|
||||
loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />,
|
||||
},
|
||||
);
|
||||
const SCADADataPanel = dynamic(
|
||||
() => import("@components/olmap/SCADA/SCADADataPanel"),
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedDeviceIds(ids);
|
||||
setPanelVisible(ids.length > 0);
|
||||
}, []);
|
||||
|
||||
const handleDeviceClick = useCallback(() => {
|
||||
setPanelVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapToolbar hiddenButtons={["style"]} />
|
||||
<SCADADeviceList
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedDeviceIds={selectedDeviceIds}
|
||||
showCleaning={true}
|
||||
/>
|
||||
<SCADADataPanel
|
||||
deviceIds={selectedDeviceIds}
|
||||
visible={panelVisible}
|
||||
showCleaning={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuditLogPanel } from "@/components/audit/AuditLogPanel";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
return <AuditLogPanel />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
||||
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||
import {
|
||||
RAINBOW_COLORS,
|
||||
RISK_BREAKS,
|
||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<HealthRiskProvider>
|
||||
<MapComponent>
|
||||
<MapToolbar
|
||||
queryType="realtime"
|
||||
hiddenButtons={["style"]}
|
||||
HistoryPanel={PredictDataPanel}
|
||||
/>
|
||||
<Timeline />
|
||||
<HealthRiskStatistics />
|
||||
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
||||
<StyleLegend
|
||||
layerName="管道"
|
||||
layerId="health-risk"
|
||||
property="健康风险"
|
||||
colors={RAINBOW_COLORS}
|
||||
type="line"
|
||||
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
||||
breaks={[0, ...RISK_BREAKS]}
|
||||
/>
|
||||
</Box>
|
||||
</MapComponent>
|
||||
</HealthRiskProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} />
|
||||
<BurstDetectionPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="burst_location"
|
||||
hiddenButtons={["style"]}
|
||||
/>
|
||||
<BurstLocationPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="burst_analysis"
|
||||
enableCompare
|
||||
/>
|
||||
<BurstPipeAnalysisPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="contaminant_analysis"
|
||||
enableCompare
|
||||
/>
|
||||
<WaterQualityPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar
|
||||
queryType="scheme"
|
||||
schemeType="dma_leak_identification"
|
||||
hiddenButtons={["style"]}
|
||||
/>
|
||||
<DMALeakDetectionPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
||||
<FlushingAnalysisPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import React, { Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||
import { Header } from "@components/header";
|
||||
import { Title } from "@components/title";
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
import { AppSider } from "@components/sider/AppSider";
|
||||
import { ThemedLayout } from "@refinedev/mui";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -19,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = cookieStore.get("theme");
|
||||
@@ -35,16 +35,20 @@ export default async function MainLayout({
|
||||
<ThemedLayout
|
||||
Header={Header}
|
||||
Title={Title}
|
||||
Sider={AppSider}
|
||||
childrenBoxProps={{
|
||||
sx: { height: "100vh", p: 0 },
|
||||
sx: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
containerBoxProps={{
|
||||
sx: { height: "100%" },
|
||||
sx: { height: "100vh", overflow: "hidden" },
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
{children}
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar hiddenButtons={["style"]} />
|
||||
<MonitoringPlaceOptimizationPanel />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedDeviceIds(ids);
|
||||
setPanelVisible(ids.length > 0);
|
||||
}, []);
|
||||
|
||||
const handleDeviceClick = useCallback(() => {
|
||||
setPanelVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar queryType="realtime" />
|
||||
<Timeline />
|
||||
<SCADADeviceList
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedDeviceIds={selectedDeviceIds}
|
||||
/>
|
||||
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <MapSkeleton />;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedDeviceIds(ids);
|
||||
setPanelVisible(ids.length > 0);
|
||||
}, []);
|
||||
|
||||
const handleDeviceClick = useCallback(() => {
|
||||
setPanelVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<MapComponent>
|
||||
<MapToolbar hiddenButtons={["style"]} />
|
||||
<SCADADeviceList
|
||||
onDeviceClick={handleDeviceClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedDeviceIds={selectedDeviceIds}
|
||||
showCleaning={true}
|
||||
/>
|
||||
<SCADADataPanel
|
||||
deviceIds={selectedDeviceIds}
|
||||
visible={panelVisible}
|
||||
showCleaning={true}
|
||||
/>
|
||||
</MapComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel";
|
||||
|
||||
export default function SystemAdminPage() {
|
||||
return <SystemAdminPanel />;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Refine,
|
||||
type AccessControlProvider,
|
||||
type AuthProvider,
|
||||
} from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import { RefineSnackbarProvider } from "@refinedev/mui";
|
||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
|
||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||
import { config } from "@config/config";
|
||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||
import { LuReplace } from "react-icons/lu";
|
||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||
import {
|
||||
FactCheck as FactCheckIcon,
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
type RefineContextProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
export const RefineContext = (
|
||||
props: React.PropsWithChildren<RefineContextProps>,
|
||||
) => (
|
||||
<SessionProvider>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
type AppProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||
const resetAccess = useAccessStore((state) => state.reset);
|
||||
const can = (permission: string) => permissions.includes(permission);
|
||||
|
||||
useEffect(() => {
|
||||
setAccessToken(
|
||||
typeof data?.accessToken === "string" ? data.accessToken : null,
|
||||
);
|
||||
}, [data?.accessToken, setAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") {
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setAccessLoading(true);
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
setAccessContext(await response.json());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) resetAccess();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
currentProjectId,
|
||||
resetAccess,
|
||||
setAccessContext,
|
||||
setAccessLoading,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || !data?.user?.id) return;
|
||||
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
||||
if (sessionStorage.getItem(auditKey)) return;
|
||||
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "login" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) sessionStorage.setItem(auditKey, "1");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [data?.user?.id, status]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <span>loading...</span>;
|
||||
}
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
login: async () => {
|
||||
signIn("keycloak", {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "logout" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
} catch {
|
||||
// Logout must still complete when audit storage is unavailable.
|
||||
}
|
||||
if (data?.user?.id) {
|
||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||
}
|
||||
signOut({ redirect: true, callbackUrl: "/login" });
|
||||
return { success: true };
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
return { logout: true };
|
||||
}
|
||||
return { error };
|
||||
},
|
||||
check: async () =>
|
||||
status === "unauthenticated"
|
||||
? { authenticated: false, redirectTo: "/login" }
|
||||
: { authenticated: true },
|
||||
getPermissions: async () => permissions,
|
||||
getIdentity: async () => {
|
||||
if (!data?.user) return null;
|
||||
return {
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
name: data.user.name,
|
||||
avatar: data.user.image,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const accessControlProvider: AccessControlProvider = {
|
||||
can: async ({ resource }) => {
|
||||
const requiredPermission = resource
|
||||
? resourcePermissions[resource]
|
||||
: undefined;
|
||||
return {
|
||||
can: !requiredPermission || permissions.includes(requiredPermission),
|
||||
reason: requiredPermission
|
||||
? `需要权限:${requiredPermission}`
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const resources = [
|
||||
...(can(permissionCodes.simulationView)
|
||||
? [
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.scadaClean)
|
||||
? [
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.optimizationRun)
|
||||
? [
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.riskRun)
|
||||
? [
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: { label: "事件模拟" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun)
|
||||
? [
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.environmentManage)
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.auditView)
|
||||
? [
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<ProjectProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</ProjectProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import {
|
||||
RefineSnackbarProvider,
|
||||
useNotificationProvider,
|
||||
} from "@refinedev/mui";
|
||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
|
||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||
import { LuReplace } from "react-icons/lu";
|
||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||
import {
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
type RefineContextProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
export const RefineContext = (
|
||||
props: React.PropsWithChildren<RefineContextProps>
|
||||
) => {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ProjectProvider>
|
||||
<App {...props} />
|
||||
</ProjectProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
type AppProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
|
||||
useEffect(() => {
|
||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
||||
}, [data?.accessToken, setAccessToken]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <span>loading...</span>;
|
||||
}
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
login: async () => {
|
||||
signIn("keycloak", {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
logout: async () => {
|
||||
signOut({
|
||||
redirect: true,
|
||||
callbackUrl: "/login",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
return {
|
||||
logout: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
};
|
||||
},
|
||||
check: async () => {
|
||||
if (status === "unauthenticated") {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/login",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
};
|
||||
},
|
||||
getPermissions: async () => {
|
||||
return null;
|
||||
},
|
||||
getIdentity: async () => {
|
||||
if (data?.user) {
|
||||
const { user } = data;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar: user.image,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const defaultMode = props?.defaultMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
resources={[
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: {
|
||||
// icon: <MdWater className="w-6 h-6" />,
|
||||
label: "事件模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,16 +9,26 @@ type KeycloakTokenResponse = {
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!;
|
||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!;
|
||||
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
||||
const getKeycloakTokenEndpoint = () => {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||
return issuer
|
||||
? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||
if (!token.refreshToken) {
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
|
||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
|
||||
|
||||
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: keycloakClientId,
|
||||
@@ -50,12 +60,13 @@ const authOptions: NextAuthOptions = {
|
||||
// Configure one or more authentication providers
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
||||
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
username: profile.preferred_username,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
image: Avatar.src,
|
||||
@@ -69,6 +80,12 @@ const authOptions: NextAuthOptions = {
|
||||
if (profile?.sub) {
|
||||
token.sub = profile.sub;
|
||||
}
|
||||
const preferredUsername = (
|
||||
profile as { preferred_username?: unknown } | undefined
|
||||
)?.preferred_username;
|
||||
if (typeof preferredUsername === "string") {
|
||||
token.username = preferredUsername;
|
||||
}
|
||||
|
||||
if (account) {
|
||||
if (account.access_token) {
|
||||
@@ -94,6 +111,9 @@ const authOptions: NextAuthOptions = {
|
||||
if (session.user && token.sub) {
|
||||
session.user.id = token.sub;
|
||||
}
|
||||
if (session.user && token.username) {
|
||||
session.user.username = token.username;
|
||||
}
|
||||
if (token.accessToken) {
|
||||
session.accessToken = token.accessToken;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const streamMock = jest.fn();
|
||||
|
||||
jest.mock("edge-tts-ts", () => ({
|
||||
Communicate: jest.fn().mockImplementation(() => ({
|
||||
stream: streamMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("POST /api/tts/edge", () => {
|
||||
beforeEach(() => {
|
||||
streamMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns synthesized mp3 audio", async () => {
|
||||
streamMock.mockImplementation(async function* () {
|
||||
yield { type: "audio", data: new Uint8Array([1, 2]) };
|
||||
yield { type: "SentenceBoundary", offset: 0, duration: 1, text: "测试" };
|
||||
yield { type: "audio", data: new Uint8Array([3]) };
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/tts/edge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "测试文本" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
|
||||
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects empty text", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/tts/edge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: " " }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "text is required" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Communicate } from "edge-tts-ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
|
||||
const MAX_TEXT_LENGTH = 12000;
|
||||
|
||||
type EdgeTtsRequest = {
|
||||
text?: unknown;
|
||||
voice?: unknown;
|
||||
};
|
||||
|
||||
const jsonError = (message: string, status: number) =>
|
||||
NextResponse.json({ error: message }, { status });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let payload: EdgeTtsRequest;
|
||||
try {
|
||||
payload = (await request.json()) as EdgeTtsRequest;
|
||||
} catch {
|
||||
return jsonError("Invalid JSON body", 400);
|
||||
}
|
||||
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
if (!text) {
|
||||
return jsonError("text is required", 400);
|
||||
}
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
return jsonError(`text must be ${MAX_TEXT_LENGTH} characters or fewer`, 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 === 0) {
|
||||
return jsonError("Edge TTS returned empty audio", 502);
|
||||
}
|
||||
|
||||
const audio = new Uint8Array(byteLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
audio.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
const audioBuffer = audio.buffer.slice(
|
||||
audio.byteOffset,
|
||||
audio.byteOffset + audio.byteLength,
|
||||
);
|
||||
|
||||
return new Response(audioBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/mpeg",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[EdgeTTS] Failed to synthesize speech:", error);
|
||||
return jsonError("Failed to synthesize speech", 502);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import React, { Suspense } from "react";
|
||||
import { RefineContext } from "./_refine_context";
|
||||
import { RefineContext } from "./RefineContext";
|
||||
import { META_DATA } from "@config/config";
|
||||
|
||||
export const metadata: Metadata = META_DATA;
|
||||
@@ -16,8 +17,9 @@ export default async function RootLayout({
|
||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||
<Suspense>
|
||||
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import Login from "./page";
|
||||
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useLogin: () => ({ mutate: mockLogin }),
|
||||
}));
|
||||
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockLogin.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts Keycloak login once when the page opens", () => {
|
||||
const { rerender } = render(<Login />);
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogin).toHaveBeenCalledWith({});
|
||||
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
|
||||
|
||||
rerender(<Login />);
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a manual fallback when automatic redirect does not complete", () => {
|
||||
render(<Login />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "继续登录" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
|
||||
expect(mockLogin).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+239
-34
@@ -1,54 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useLogin } from "@refinedev/core";
|
||||
import { Title } from "@components/title";
|
||||
import { PROJECT_TITLE } from "@config/config";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FALLBACK_DELAY_MS = 4000;
|
||||
|
||||
export default function Login() {
|
||||
const { mutate: login } = useLogin();
|
||||
const loginStartedRef = useRef(false);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginStartedRef.current) {
|
||||
loginStartedRef.current = true;
|
||||
login({});
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(
|
||||
() => setShowFallback(true),
|
||||
FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(fallbackTimer);
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
color: "oklch(0.3 0.055 215)",
|
||||
bgcolor: "oklch(0.965 0.014 205)",
|
||||
backgroundImage: `
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 50%,
|
||||
oklch(0.975 0.01 205 / 62%) 68%,
|
||||
oklch(0.975 0.01 205 / 82%) 100%
|
||||
),
|
||||
url("/login-network-blueprint.svg")
|
||||
`,
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
"@keyframes tjwaterEnter": {
|
||||
from: { opacity: 0, transform: "translateY(12px)" },
|
||||
to: { opacity: 1, transform: "translateY(0)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
gap="36px"
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 1720,
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
mx: "auto",
|
||||
px: {
|
||||
xs: 1.75,
|
||||
sm: 3,
|
||||
md: "clamp(40px, 5vw, 88px)",
|
||||
},
|
||||
py: {
|
||||
xs: "max(28px, env(safe-area-inset-top))",
|
||||
md: "clamp(32px, 4.5vw, 76px)",
|
||||
},
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "minmax(0, 1fr)",
|
||||
md: "minmax(360px, 1fr) minmax(400px, 460px)",
|
||||
},
|
||||
gridTemplateAreas: {
|
||||
xs: '"brand" "status"',
|
||||
md: '"brand status"',
|
||||
},
|
||||
alignContent: { xs: "center", md: "stretch" },
|
||||
alignItems: "center",
|
||||
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<Title collapsed={false} />
|
||||
</Box>
|
||||
<Button
|
||||
style={{ width: "240px" }}
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
<Stack
|
||||
spacing={{ xs: 0, md: 3 }}
|
||||
direction={{ xs: "row", md: "column" }}
|
||||
alignItems={{ xs: "center", md: "flex-start" }}
|
||||
sx={{
|
||||
position: "relative",
|
||||
isolation: "isolate",
|
||||
gridArea: "brand",
|
||||
width: "fit-content",
|
||||
maxWidth: "100%",
|
||||
animation:
|
||||
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
"&::before": {
|
||||
position: "absolute",
|
||||
zIndex: -1,
|
||||
inset: { xs: "-24px -20px", md: "-54px -72px" },
|
||||
background: {
|
||||
xs: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.965 0.014 205 / 98%) 0%,
|
||||
oklch(0.965 0.014 205 / 88%) 58%,
|
||||
transparent 84%
|
||||
)`,
|
||||
md: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.925 0.018 205 / 98%) 0%,
|
||||
oklch(0.925 0.018 205 / 94%) 48%,
|
||||
oklch(0.925 0.018 205 / 62%) 65%,
|
||||
transparent 82%
|
||||
)`,
|
||||
},
|
||||
pointerEvents: "none",
|
||||
content: '""',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
||||
Powered by
|
||||
<Image
|
||||
style={{ padding: "0 5px" }}
|
||||
alt="Keycloak"
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
||||
width={18}
|
||||
height={18}
|
||||
<Box
|
||||
component="img"
|
||||
src="/login-logo-mark.svg"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
sx={{
|
||||
width: { xs: 42, md: 56 },
|
||||
height: { xs: 42, md: 56 },
|
||||
flex: { xs: "0 0 42px", md: "0 0 auto" },
|
||||
mr: { xs: 1.5, md: 0 },
|
||||
}}
|
||||
/>
|
||||
Keycloak
|
||||
</Typography>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{
|
||||
maxWidth: 680,
|
||||
m: 0,
|
||||
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
|
||||
fontWeight: 720,
|
||||
lineHeight: { xs: 1.4, md: 1.28 },
|
||||
textWrap: "balance",
|
||||
}}
|
||||
>
|
||||
{PROJECT_TITLE}
|
||||
</Typography>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
bgcolor: "oklch(0.58 0.12 180)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
gridArea: "status",
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
justifySelf: "stretch",
|
||||
p: { xs: 2.75, sm: 4.5 },
|
||||
overflow: "hidden",
|
||||
borderRadius: { xs: 4, sm: 4.5 },
|
||||
bgcolor: "oklch(0.995 0.004 205 / 97%)",
|
||||
boxShadow:
|
||||
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
|
||||
animation:
|
||||
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress
|
||||
size={30}
|
||||
thickness={4.5}
|
||||
aria-label="正在连接统一身份认证"
|
||||
sx={{
|
||||
flex: "0 0 auto",
|
||||
color: "oklch(0.57 0.16 242)",
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
animation: "none",
|
||||
"& .MuiCircularProgress-circle": {
|
||||
animation: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
|
||||
>
|
||||
正在进入账号登录
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 14, sm: 15 },
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
正在连接统一身份认证服务,请稍候。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{showFallback ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
borderRadius: 3,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
textTransform: "none",
|
||||
boxShadow:
|
||||
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
|
||||
transitionProperty:
|
||||
"transform, background-color, box-shadow",
|
||||
"&:active": { transform: "scale(0.96)" },
|
||||
}}
|
||||
>
|
||||
继续登录
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 12, sm: 13 },
|
||||
lineHeight: 1.7,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
账号与访问权限由统一身份认证服务管理
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { permissionForPath } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
|
||||
export const RoutePermissionGuard = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const loading = useAccessStore((state) => state.loading);
|
||||
const requiredPermission = permissionForPath(pathname);
|
||||
|
||||
if (requiredPermission && loading) {
|
||||
return (
|
||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredPermission && !permissions.includes(requiredPermission)) {
|
||||
return (
|
||||
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Alert severity="error" icon={<LockOutlinedIcon />}>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography fontWeight={700}>无权访问此功能</Typography>
|
||||
<Typography variant="body2">
|
||||
当前项目角色缺少权限:{requiredPermission}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { AgentComposer } from "./AgentComposer";
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img {...props} alt={props.alt ?? ""} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
initial: _initial,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AgentComposer", () => {
|
||||
it("places voice input immediately before send without an attachment action", () => {
|
||||
render(
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<AgentComposer
|
||||
isStreaming={false}
|
||||
isListening={false}
|
||||
isSttSupported
|
||||
presets={[]}
|
||||
onSend={jest.fn()}
|
||||
onAbort={jest.fn()}
|
||||
onStartListening={jest.fn()}
|
||||
onStopListening={jest.fn()}
|
||||
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
||||
selectedModel="test-model"
|
||||
onModelChange={jest.fn()}
|
||||
approvalMode="request"
|
||||
onApprovalModeChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "语音输入" });
|
||||
const sendButton = screen.getByRole("button", { name: "发送" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
|
||||
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,11 @@ import StopRounded from "@mui/icons-material/StopRounded";
|
||||
import MicRounded from "@mui/icons-material/MicRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||
import type { AgentModelOption } from "@/lib/chatModels";
|
||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
|
||||
export type AgentComposerHandle = {
|
||||
@@ -48,12 +48,23 @@ type AgentComposerProps = {
|
||||
onAbort: () => void;
|
||||
onStartListening: () => void;
|
||||
onStopListening: () => void;
|
||||
selectedModel: AgentModel;
|
||||
modelOptions: AgentModelOption[];
|
||||
selectedModel?: AgentModel;
|
||||
onModelChange: (model: AgentModel) => void;
|
||||
approvalMode: AgentApprovalMode;
|
||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||
};
|
||||
|
||||
const renderModelIcon = (
|
||||
icon: AgentModelOption["icon"] | undefined,
|
||||
props?: React.ComponentProps<typeof BoltRounded>,
|
||||
) =>
|
||||
icon === "bolt" ? (
|
||||
<BoltRounded {...props} />
|
||||
) : (
|
||||
<AutoAwesomeRounded {...props} />
|
||||
);
|
||||
|
||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||
isHydrating = false,
|
||||
isStreaming,
|
||||
@@ -64,6 +75,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
onAbort,
|
||||
onStartListening,
|
||||
onStopListening,
|
||||
modelOptions,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
approvalMode,
|
||||
@@ -74,6 +86,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
const [input, setInput] = React.useState("");
|
||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
@@ -111,16 +124,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Image
|
||||
src="/ai-agent.svg"
|
||||
alt="TJWater Agent"
|
||||
width={18}
|
||||
height={18}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<AutoAwesomeRounded
|
||||
titleAccess="快捷指令图标"
|
||||
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
管网分析快捷指令
|
||||
</Typography>
|
||||
@@ -216,41 +223,6 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
|
||||
<AttachFileRounded fontSize="small" />
|
||||
</IconButton>
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||
<Select
|
||||
value={approvalMode}
|
||||
@@ -347,19 +319,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
value={selectedModel ?? ""}
|
||||
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||
disabled={isHydrating || isStreaming}
|
||||
disabled={isHydrating || isStreaming || modelOptions.length === 0}
|
||||
aria-label="模型选择"
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{val === "deepseek/deepseek-v4-flash" ? (
|
||||
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
||||
) : (
|
||||
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
||||
)}
|
||||
renderValue={() => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{renderModelIcon(selectedModelOption?.icon, {
|
||||
sx: {
|
||||
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
|
||||
color: "inherit",
|
||||
transition: "color 0.2s",
|
||||
},
|
||||
})}
|
||||
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
||||
{selectedModelOption?.label ?? "模型"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -433,33 +407,61 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src="/deepseek-logo.svg"
|
||||
alt="DeepSeek"
|
||||
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
||||
/>
|
||||
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||
DEEPSEEK V4
|
||||
模型选择
|
||||
</Typography>
|
||||
</Box>
|
||||
<MenuItem value="deepseek/deepseek-v4-flash">
|
||||
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="deepseek/deepseek-v4-pro">
|
||||
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{modelOptions.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{renderModelIcon(model.icon, {
|
||||
className: "icon",
|
||||
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
|
||||
})}
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||
{model.description ? (
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isStreaming ? (
|
||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
|
||||
@@ -1,14 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Element, Root, RootContent, Text } from "hast";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||
|
||||
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||
|
||||
export const MarkdownBlock = ({ children }: { children: string }) => {
|
||||
const isTextNode = (node: RootContent): node is Text => node.type === "text";
|
||||
|
||||
const isElementNode = (node: RootContent): node is Element => node.type === "element";
|
||||
|
||||
const createFadeSpan = (value: string, fadeKey: string): Element => ({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [markdownStyles.streamFade],
|
||||
dataStreamFadeKey: fadeKey,
|
||||
dataStreamRevealLength: value.length,
|
||||
},
|
||||
children: [{ type: "text", value }],
|
||||
});
|
||||
|
||||
const splitTextTail = (value: string, tailLength: number) => {
|
||||
const codePoints = Array.from(value);
|
||||
const stableText = codePoints.slice(0, -tailLength).join("");
|
||||
const animatedText = codePoints.slice(-tailLength).join("");
|
||||
return { stableText, animatedText };
|
||||
};
|
||||
|
||||
const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => {
|
||||
return () => (tree: Root) => {
|
||||
let remainingFadeLength = fadeLength;
|
||||
|
||||
const visitChildren = (parent: Element | Root) => {
|
||||
const nextChildren: RootContent[] = [];
|
||||
|
||||
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = (parent.children as RootContent[])[index];
|
||||
if (isTextNode(child)) {
|
||||
if (!child.value.trim()) {
|
||||
nextChildren.unshift(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remainingFadeLength <= 0) {
|
||||
nextChildren.unshift(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
const textLength = Array.from(child.value).length;
|
||||
const tailLength = Math.min(textLength, remainingFadeLength);
|
||||
const { stableText, animatedText } = splitTextTail(child.value, tailLength);
|
||||
remainingFadeLength -= tailLength;
|
||||
|
||||
if (animatedText) {
|
||||
nextChildren.unshift(createFadeSpan(animatedText, fadeKey));
|
||||
}
|
||||
if (stableText) {
|
||||
nextChildren.unshift({ ...child, value: stableText });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isElementNode(child)) {
|
||||
visitChildren(child);
|
||||
}
|
||||
|
||||
nextChildren.unshift(child);
|
||||
}
|
||||
|
||||
parent.children = nextChildren as typeof parent.children;
|
||||
};
|
||||
|
||||
visitChildren(tree);
|
||||
};
|
||||
};
|
||||
|
||||
const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => {
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
const fadeKeyValue = node?.properties?.dataStreamFadeKey;
|
||||
const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined;
|
||||
const revealLengthValue = node?.properties?.dataStreamRevealLength;
|
||||
const revealLength =
|
||||
typeof revealLengthValue === "number"
|
||||
? revealLengthValue
|
||||
: typeof revealLengthValue === "string"
|
||||
? Number.parseInt(revealLengthValue, 10)
|
||||
: 0;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!fadeKey) return;
|
||||
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const duration = Math.min(260, Math.max(120, revealLength * 14));
|
||||
const animation = element.animate(
|
||||
[
|
||||
{ clipPath: "inset(0 100% 0 0)", opacity: 0.46 },
|
||||
{ clipPath: "inset(0 0% 0 0)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
fill: "both",
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
animation.cancel();
|
||||
};
|
||||
}, [fadeKey, revealLength]);
|
||||
|
||||
return (
|
||||
<span {...props} ref={ref}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents: Components = {
|
||||
span: StreamFadeSpan,
|
||||
};
|
||||
|
||||
export const MarkdownBlock = ({
|
||||
children,
|
||||
streamFadeKey,
|
||||
streamFadeLength,
|
||||
}: {
|
||||
children: string;
|
||||
streamFadeKey?: string;
|
||||
streamFadeLength?: number | null;
|
||||
}) => {
|
||||
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const selectedText = window.getSelection()?.toString();
|
||||
if (!selectedText) return;
|
||||
@@ -16,12 +143,26 @@ export const MarkdownBlock = ({ children }: { children: string }) => {
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||
}, []);
|
||||
const rehypePlugins = React.useMemo(
|
||||
() =>
|
||||
typeof streamFadeLength === "number" && streamFadeLength > 0
|
||||
? [createStreamFadePlugin(
|
||||
streamFadeLength,
|
||||
streamFadeKey ?? `stream-tail-${children.length}`,
|
||||
)]
|
||||
: [],
|
||||
[children.length, streamFadeKey, streamFadeLength],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { AgentTurn } from "./AgentTurn";
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img {...props} alt={props.alt ?? ""} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
initial: _initial,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
span: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
|
||||
<span {...props}>{children}</span>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("./AgentMarkdownBlock", () => ({
|
||||
MarkdownBlock: ({ children }: { children: string }) => (
|
||||
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
|
||||
),
|
||||
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
|
||||
}));
|
||||
|
||||
describe("AgentTurn speech selection", () => {
|
||||
it("shows a floating action and reads from the selected text", async () => {
|
||||
const content = "第一段内容。\n\n第二段内容。";
|
||||
const speechText = "第一段内容。\n第二段内容。";
|
||||
const onSpeak = jest.fn();
|
||||
const removeAllRanges = jest.fn();
|
||||
|
||||
render(
|
||||
<AgentTurn
|
||||
message={{ id: "assistant-1", role: "assistant", content }}
|
||||
isStreaming={false}
|
||||
messageSpeechState="idle"
|
||||
onSpeak={onSpeak}
|
||||
onPause={jest.fn()}
|
||||
onResume={jest.fn()}
|
||||
onStopSpeech={jest.fn()}
|
||||
isTtsSupported
|
||||
onCreateBranch={jest.fn()}
|
||||
onReplyPermission={jest.fn()}
|
||||
onReplyQuestion={jest.fn()}
|
||||
onRejectQuestion={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedParagraph = screen.getByText("第二段内容。");
|
||||
const selectedTextNode = selectedParagraph.firstChild as Text;
|
||||
const range = {
|
||||
commonAncestorContainer: selectedTextNode,
|
||||
getBoundingClientRect: () => ({
|
||||
width: 72,
|
||||
height: 20,
|
||||
top: 120,
|
||||
right: 172,
|
||||
bottom: 140,
|
||||
left: 100,
|
||||
x: 100,
|
||||
y: 120,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
} as unknown as Range;
|
||||
const selection = {
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => range,
|
||||
toString: () => "第二段",
|
||||
removeAllRanges,
|
||||
} as unknown as Selection;
|
||||
|
||||
jest.spyOn(window, "getSelection").mockReturnValue(selection);
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
fireEvent.pointerUp(selectedParagraph);
|
||||
|
||||
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
|
||||
fireEvent.click(speechAction);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
|
||||
startOffset: speechText.indexOf("第二段"),
|
||||
});
|
||||
});
|
||||
expect(removeAllRanges).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import React, { useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Grow,
|
||||
IconButton,
|
||||
Paper,
|
||||
Popper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -22,10 +26,14 @@ import {
|
||||
parseContentWithToolCalls,
|
||||
type ContentSegment,
|
||||
} from "./chatMessageSections";
|
||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||
import type {
|
||||
Message,
|
||||
SpeechState,
|
||||
} from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./globalChatboxUtils";
|
||||
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||
import { ChatInlineChart } from "./ChatInlineChart";
|
||||
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||
@@ -36,11 +44,57 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
import StopRounded from "@mui/icons-material/StopRounded";
|
||||
|
||||
const floatingActionSurfaceSx = {
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
p: 0.5,
|
||||
borderRadius: "16px",
|
||||
bgcolor: alpha("#fff", 0.8),
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||
overflow: "hidden",
|
||||
} as const;
|
||||
|
||||
const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const;
|
||||
const floatingIconButtonSx = {
|
||||
width: 28,
|
||||
height: 28,
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
color: "#00acc1",
|
||||
bgcolor: alpha("#00acc1", 0.1),
|
||||
},
|
||||
} as const;
|
||||
const floatingSpeechButtonSx = {
|
||||
minHeight: 34,
|
||||
px: 1.25,
|
||||
color: "text.primary",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0,
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: "12px",
|
||||
"&:hover": {
|
||||
bgcolor: alpha("#00acc1", 0.1),
|
||||
color: "#00acc1",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type SpeechSelection = {
|
||||
startOffset: number;
|
||||
anchorRect: DOMRect;
|
||||
};
|
||||
|
||||
type AgentTurnProps = {
|
||||
message: Message;
|
||||
isStreaming: boolean;
|
||||
messageSpeechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onSpeak: (
|
||||
messageId: string,
|
||||
text: string,
|
||||
options?: { startOffset?: number },
|
||||
) => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStopSpeech: () => void;
|
||||
@@ -51,6 +105,105 @@ type AgentTurnProps = {
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const StreamingStatus = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.75}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.35,
|
||||
borderRadius: 999,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.07),
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.35} alignItems="center">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
animate={{ opacity: [0.28, 0.86, 0.28] }}
|
||||
transition={{
|
||||
duration: 0.95,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.14,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
background: theme.palette.primary.main,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||
正在生成
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const StreamingMarkdownBlock = ({
|
||||
text,
|
||||
isStreaming,
|
||||
segmentKey,
|
||||
}: {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
segmentKey: string;
|
||||
}) => {
|
||||
const [streamTextState, setStreamTextState] = React.useState<{
|
||||
displayText: string;
|
||||
animatedTailLength: number;
|
||||
}>({
|
||||
displayText: text,
|
||||
animatedTailLength: 0,
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setStreamTextState((current) => {
|
||||
if (current.displayText === text) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (!isStreaming) {
|
||||
return {
|
||||
displayText: text,
|
||||
animatedTailLength: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (current.displayText === text) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: text,
|
||||
animatedTailLength:
|
||||
text.length > current.displayText.length &&
|
||||
text.startsWith(current.displayText)
|
||||
? Math.min(48, text.length - current.displayText.length)
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
}, [isStreaming, text]);
|
||||
|
||||
return (
|
||||
<MarkdownBlock
|
||||
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
||||
streamFadeLength={streamTextState.animatedTailLength}
|
||||
>
|
||||
{streamTextState.displayText}
|
||||
</MarkdownBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentTurn = React.memo(
|
||||
({
|
||||
message,
|
||||
@@ -69,7 +222,10 @@ export const AgentTurn = React.memo(
|
||||
const theme = useTheme();
|
||||
const isUser = message.role === "user";
|
||||
const isErrorMessage = Boolean(message.isError);
|
||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||
const isProgressComplete = message.progress?.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
) ?? false;
|
||||
@@ -85,6 +241,98 @@ export const AgentTurn = React.memo(
|
||||
[isErrorMessage, isUser, message.content],
|
||||
);
|
||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||
const speechText = useMemo(
|
||||
() => stripMarkdown(answerContent),
|
||||
[answerContent],
|
||||
);
|
||||
const captureSpeechSelection = React.useCallback(() => {
|
||||
if (!isTtsSupported || isStreamingAssistant) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const container = answerContentRef.current;
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString();
|
||||
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
||||
if (startOffset === null) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorRect = range.getBoundingClientRect();
|
||||
if (anchorRect.width === 0 && anchorRect.height === 0) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeechSelection({ startOffset, anchorRect });
|
||||
}, [isStreamingAssistant, isTtsSupported, speechText]);
|
||||
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||
window.requestAnimationFrame(captureSpeechSelection);
|
||||
}, [captureSpeechSelection]);
|
||||
React.useEffect(() => {
|
||||
setSpeechSelection(null);
|
||||
}, [message.id, speechText]);
|
||||
React.useEffect(() => {
|
||||
if (!speechSelection) return;
|
||||
|
||||
const closeSpeechSelection = () => setSpeechSelection(null);
|
||||
const handleSelectionChange = () => {
|
||||
if (window.getSelection()?.isCollapsed) {
|
||||
closeSpeechSelection();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeSpeechSelection();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", closeSpeechSelection);
|
||||
window.addEventListener("scroll", closeSpeechSelection, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", closeSpeechSelection);
|
||||
window.removeEventListener("scroll", closeSpeechSelection, true);
|
||||
};
|
||||
}, [speechSelection]);
|
||||
const handleSpeakMessage = () => {
|
||||
onSpeak(message.id, speechText);
|
||||
};
|
||||
const handleSpeakFromSelection = () => {
|
||||
if (!speechSelection) return;
|
||||
|
||||
onSpeak(message.id, speechText, {
|
||||
startOffset: speechSelection.startOffset,
|
||||
});
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSpeechSelection(null);
|
||||
};
|
||||
const speechSelectionAnchor = React.useMemo(
|
||||
() => speechSelection
|
||||
? {
|
||||
getBoundingClientRect: () => speechSelection.anchorRect,
|
||||
}
|
||||
: null,
|
||||
[speechSelection],
|
||||
);
|
||||
const isSpeechSelectionOpen = Boolean(speechSelection);
|
||||
const contentSegments: ContentSegment[] = useMemo(
|
||||
() =>
|
||||
!isUser && !isErrorMessage
|
||||
@@ -233,22 +481,36 @@ export const AgentTurn = React.memo(
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
ref={answerContentRef}
|
||||
onPointerUp={handleCaptureSpeechSelection}
|
||||
onKeyUp={handleCaptureSpeechSelection}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#fff", 0.4),
|
||||
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.2}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
分析结果
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
分析结果
|
||||
</Typography>
|
||||
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||
</Stack>
|
||||
{contentSegments.map((segment, segIdx) => {
|
||||
if (segment.type === "text") {
|
||||
const text = segment.content.trim();
|
||||
if (!text && contentSegments.length > 1) return null;
|
||||
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
|
||||
return (
|
||||
<StreamingMarkdownBlock
|
||||
key={segIdx}
|
||||
text={text || "..."}
|
||||
isStreaming={isStreamingAssistant}
|
||||
segmentKey={`${message.id}-${segIdx}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (segment.type === "tool_call") {
|
||||
if (
|
||||
@@ -267,6 +529,7 @@ export const AgentTurn = React.memo(
|
||||
series={p.series}
|
||||
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||
isStreaming={isStreamingAssistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -279,9 +542,10 @@ export const AgentTurn = React.memo(
|
||||
}
|
||||
if (segment.type === "tool_call_pending") {
|
||||
return (
|
||||
<Typography key="tool-pending" variant="caption" color="text.secondary">
|
||||
正在准备工具调用...
|
||||
</Typography>
|
||||
<ChartGenerationSkeleton
|
||||
key="tool-pending"
|
||||
status={<StreamingStatus />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -289,6 +553,43 @@ export const AgentTurn = React.memo(
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Popper
|
||||
open={isSpeechSelectionOpen}
|
||||
anchorEl={speechSelectionAnchor}
|
||||
placement="top"
|
||||
transition
|
||||
modifiers={[
|
||||
{ name: "offset", options: { offset: [0, 8] } },
|
||||
{ name: "flip", enabled: true },
|
||||
{ name: "preventOverflow", options: { padding: 8 } },
|
||||
]}
|
||||
sx={{ zIndex: theme.zIndex.tooltip }}
|
||||
>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
timeout={floatingActionTransitionTimeout}
|
||||
style={{
|
||||
transformOrigin: placement.startsWith("bottom")
|
||||
? "center top"
|
||||
: "center bottom",
|
||||
}}
|
||||
>
|
||||
<Paper elevation={4} sx={floatingActionSurfaceSx}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleSpeakFromSelection}
|
||||
sx={floatingSpeechButtonSx}
|
||||
>
|
||||
从这里开始朗读
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
|
||||
{visibleChartArtifacts.map((artifact) => (
|
||||
<ChatInlineChart
|
||||
key={artifact.id}
|
||||
@@ -306,63 +607,54 @@ export const AgentTurn = React.memo(
|
||||
series={artifact.params.series}
|
||||
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||
isStreaming={isStreamingAssistant}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<AnimatePresence>
|
||||
{isHovered && !isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
p: 0.5,
|
||||
borderRadius: "16px",
|
||||
bgcolor: alpha("#fff", 0.8),
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||
<Grow
|
||||
in={isHovered && !isStreaming}
|
||||
timeout={floatingActionTransitionTimeout}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{ transformOrigin: "right bottom" }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
...floatingActionSurfaceSx,
|
||||
position: "absolute",
|
||||
top: -14,
|
||||
right: 12,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
}}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
// Could add a toast here
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => {
|
||||
onCreateBranch(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => onCreateBranch(message.id)}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</Grow>
|
||||
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -373,13 +665,28 @@ export const AgentTurn = React.memo(
|
||||
{messageSpeechState === "idle" ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||
onClick={handleSpeakMessage}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{messageSpeechState === "loading" ? (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled
|
||||
aria-label="正在生成语音"
|
||||
sx={{ color: "primary.main", p: 0.5 }}
|
||||
>
|
||||
<CircularProgress size={16} thickness={5} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "playing" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const renderCounts = new Map<string, number>();
|
||||
const mountCounts = new Map<string, number>();
|
||||
const unmountCounts = new Map<string, number>();
|
||||
const streamingFlags = new Map<string, boolean>();
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
@@ -16,17 +19,31 @@ jest.mock("next/image", () => ({
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
div: ({
|
||||
children,
|
||||
animate: _animate,
|
||||
exit: _exit,
|
||||
initial: _initial,
|
||||
layout: _layout,
|
||||
transition: _transition,
|
||||
whileHover: _whileHover,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("./GlobalChatbox.parts", () => ({
|
||||
TypingIndicator: () => <div>typing</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./AgentTurn", () => ({
|
||||
AgentTurn: ({ message }: { message: Message }) => {
|
||||
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
||||
React.useEffect(() => {
|
||||
mountCounts.set(message.id, (mountCounts.get(message.id) ?? 0) + 1);
|
||||
return () => {
|
||||
unmountCounts.set(message.id, (unmountCounts.get(message.id) ?? 0) + 1);
|
||||
};
|
||||
}, [message.id]);
|
||||
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||
streamingFlags.set(message.id, isStreaming);
|
||||
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||
},
|
||||
}));
|
||||
@@ -49,6 +66,9 @@ describe("AgentWorkspace", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
renderCounts.clear();
|
||||
mountCounts.clear();
|
||||
unmountCounts.clear();
|
||||
streamingFlags.clear();
|
||||
});
|
||||
|
||||
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
||||
@@ -106,5 +126,42 @@ describe("AgentWorkspace", () => {
|
||||
expect(renderCounts.get("user-1")).toBe(1);
|
||||
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||
expect(streamingFlags.get("assistant-2")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not remount the streaming assistant turn when streaming finishes", () => {
|
||||
const userMessage: Message = {
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "question",
|
||||
};
|
||||
const assistantMessage: Message = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "final answer",
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentWorkspace
|
||||
{...defaultProps}
|
||||
isStreaming
|
||||
messages={[userMessage, assistantMessage]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(streamingFlags.get("assistant-1")).toBe(true);
|
||||
|
||||
rerender(
|
||||
<AgentWorkspace
|
||||
{...defaultProps}
|
||||
isStreaming={false}
|
||||
messages={[userMessage, assistantMessage]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mountCounts.get("assistant-1")).toBe(1);
|
||||
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||
import MapRounded from "@mui/icons-material/MapRounded";
|
||||
|
||||
import { AgentTurn } from "./AgentTurn";
|
||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import type {
|
||||
Message,
|
||||
@@ -21,10 +20,16 @@ type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
isLoadingSession?: boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onSpeak: (
|
||||
messageId: string,
|
||||
text: string,
|
||||
options?: { startOffset?: number },
|
||||
) => void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
@@ -37,10 +42,15 @@ type AgentWorkspaceProps = {
|
||||
|
||||
type TurnListProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
isAssistantStreaming: boolean;
|
||||
streamingMessageId: string | null;
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onSpeak: (
|
||||
messageId: string,
|
||||
text: string,
|
||||
options?: { startOffset?: number },
|
||||
) => void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
@@ -51,13 +61,19 @@ type TurnListProps = {
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
|
||||
|
||||
const sameMessages = (left: Message[], right: Message[]) =>
|
||||
left.length === right.length &&
|
||||
left.every((message, index) => message === right[index]);
|
||||
|
||||
const TurnItem = React.memo(AgentTurn);
|
||||
|
||||
const TurnListInner = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
isAssistantStreaming,
|
||||
streamingMessageId,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
@@ -73,10 +89,10 @@ const TurnListInner = ({
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<AgentTurn
|
||||
<TurnItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
@@ -97,7 +113,8 @@ const TurnList = React.memo(
|
||||
TurnListInner,
|
||||
(prevProps, nextProps) =>
|
||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||
prevProps.isStreaming === nextProps.isStreaming &&
|
||||
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||
prevProps.speechState === nextProps.speechState &&
|
||||
prevProps.onSpeak === nextProps.onSpeak &&
|
||||
@@ -293,7 +310,9 @@ export const AgentWorkspace = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingSession = false,
|
||||
scrollContainerRef,
|
||||
bottomRef,
|
||||
onScrollStateChange,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
@@ -306,24 +325,28 @@ export const AgentWorkspace = ({
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentWorkspaceProps) => {
|
||||
const theme = useTheme();
|
||||
const latestAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "assistant");
|
||||
const showTypingIndicator =
|
||||
isStreaming &&
|
||||
(!latestAssistant ||
|
||||
(latestAssistant.content.trim().length === 0 &&
|
||||
!(latestAssistant.artifacts?.length)));
|
||||
const streamingMessage =
|
||||
const streamingMessageId =
|
||||
isStreaming && messages.at(-1)?.role === "assistant"
|
||||
? messages.at(-1)
|
||||
: undefined;
|
||||
const historyMessages =
|
||||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
||||
? messages.at(-1)?.id ?? null
|
||||
: null;
|
||||
const handleScroll = React.useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!onScrollStateChange) return;
|
||||
const target = event.currentTarget;
|
||||
const distanceToBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
onScrollStateChange(
|
||||
distanceToBottom <
|
||||
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
|
||||
);
|
||||
},
|
||||
[isStreaming, onScrollStateChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
@@ -331,6 +354,7 @@ export const AgentWorkspace = ({
|
||||
py: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
scrollbarGutter: "stable",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
@@ -345,8 +369,9 @@ export const AgentWorkspace = ({
|
||||
{messages.length > 0 ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TurnList
|
||||
messages={historyMessages}
|
||||
isStreaming={isStreaming}
|
||||
messages={messages}
|
||||
isAssistantStreaming={isStreaming}
|
||||
streamingMessageId={streamingMessageId}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
@@ -359,51 +384,18 @@ export const AgentWorkspace = ({
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
|
||||
{streamingMessage ? (
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoadingSession && showTypingIndicator ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.3,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#fff", 0.82),
|
||||
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
|
||||
}}
|
||||
>
|
||||
<TypingIndicator />
|
||||
</Paper>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<div ref={bottomRef} style={{ height: 1 }} />
|
||||
<div
|
||||
ref={bottomRef}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import * as echarts from "echarts";
|
||||
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inline chart rendered inside a chat message bubble. */
|
||||
@@ -47,8 +48,12 @@ export interface ChatInlineChartProps {
|
||||
series?: unknown;
|
||||
y_axis_name?: string;
|
||||
x_axis_name?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const CHART_MIN_HEIGHT = 286;
|
||||
|
||||
const COLORS = [
|
||||
"#5470c6",
|
||||
"#91cc75",
|
||||
@@ -61,6 +66,49 @@ const COLORS = [
|
||||
"#ea7ccc",
|
||||
];
|
||||
|
||||
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
|
||||
<Stack spacing={1.25} sx={{ p: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Skeleton variant="text" width="34%" height={20} />
|
||||
{status}
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Skeleton variant="text" width="24%" height={16} />
|
||||
<Skeleton variant="text" width="18%" height={16} />
|
||||
<Skeleton variant="text" width="20%" height={16} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
mb: 1,
|
||||
minHeight: CHART_MIN_HEIGHT,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||
bgcolor: alpha("#fff", 0.78),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ChartSkeletonContent status={status} />
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
@@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
series,
|
||||
y_axis_name: yAxisName,
|
||||
x_axis_name: xAxisName,
|
||||
isStreaming = false,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
|
||||
const { xData, series: chartSeries } = useMemo(
|
||||
() => normalizeChartData(x_data, series),
|
||||
[x_data, series],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setShowIntroSkeleton(false);
|
||||
}, isStreaming ? 360 : 260);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isStreaming]);
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!chartSeries.length) return null;
|
||||
|
||||
@@ -208,6 +266,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
animation: true,
|
||||
animationDuration: isStreaming ? 560 : 420,
|
||||
animationDurationUpdate: 240,
|
||||
animationEasing: "cubicOut",
|
||||
animationEasingUpdate: "cubicOut",
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||
series: [
|
||||
@@ -223,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
},
|
||||
},
|
||||
label: { fontSize: 11 },
|
||||
animationType: "expansion",
|
||||
animationDuration: isStreaming ? 560 : 420,
|
||||
animationDelay: (idx: number) => idx * 40,
|
||||
animationDurationUpdate: 240,
|
||||
},
|
||||
],
|
||||
color: COLORS,
|
||||
@@ -231,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
|
||||
/* ---------- Line / Bar chart ---------- */
|
||||
return {
|
||||
animation: true,
|
||||
animationDuration: isStreaming ? 560 : 420,
|
||||
animationDurationUpdate: 240,
|
||||
animationEasing: "cubicOut",
|
||||
animationEasingUpdate: "cubicOut",
|
||||
tooltip: { trigger: "axis", confine: true },
|
||||
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||
grid: {
|
||||
@@ -262,14 +334,22 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
: undefined,
|
||||
series: chartSeries.map((s, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
const isLineSeries = chartType === "line";
|
||||
return {
|
||||
name: s.name,
|
||||
type: (s.type ?? chartType) as string,
|
||||
data: s.data,
|
||||
symbol: chartType === "line" ? "none" : undefined,
|
||||
smooth: chartType === "line",
|
||||
symbol: isLineSeries ? "none" : undefined,
|
||||
smooth: isLineSeries,
|
||||
itemStyle: { color },
|
||||
...(chartType === "line"
|
||||
animationDuration: isStreaming ? 560 : 420,
|
||||
animationDurationUpdate: 240,
|
||||
animationDelay:
|
||||
chartType === "bar"
|
||||
? (idx: number) => i * 80 + idx * 18
|
||||
: i * 80,
|
||||
animationDelayUpdate: 0,
|
||||
...(isLineSeries
|
||||
? {
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
@@ -284,44 +364,96 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
}),
|
||||
color: COLORS,
|
||||
};
|
||||
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName]);
|
||||
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
图表数据为空
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
mb: 1,
|
||||
minHeight: 72,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
px: 2,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||
bgcolor: alpha("#fff", 0.72),
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
图表数据为空
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
mb: 1,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||
bgcolor: alpha("#fff", 0.92),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
mb: 1,
|
||||
minHeight: CHART_MIN_HEIGHT,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||
bgcolor: alpha("#fff", 0.92),
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{showIntroSkeleton ? (
|
||||
<Box
|
||||
key="chart-intro-skeleton"
|
||||
component={motion.div}
|
||||
aria-hidden
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
bgcolor: alpha("#fff", 0.92),
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<ChartSkeletonContent />
|
||||
</Box>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
|
||||
transition={{ duration: 0.24, ease: "easeOut" }}
|
||||
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ px: 1, pb: 1 }}>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: 240, width: "100%" }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
|
||||
import { GlobalChatbox } from "./GlobalChatbox";
|
||||
|
||||
const createSession = jest.fn();
|
||||
let mockCurrentProjectId = "project-1";
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useNotification: () => ({ open: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/chatModels", () => ({
|
||||
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
||||
}));
|
||||
|
||||
jest.mock("@/store/projectStore", () => ({
|
||||
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
|
||||
selector({ currentProjectId: mockCurrentProjectId }),
|
||||
}));
|
||||
|
||||
jest.mock("./globalChatboxVoice", () => ({
|
||||
useSpeechSynthesis: () => ({
|
||||
speechState: "idle",
|
||||
speakingMessageId: null,
|
||||
speak: jest.fn(),
|
||||
pause: jest.fn(),
|
||||
resume: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
isSupported: true,
|
||||
}),
|
||||
useSpeechRecognition: () => ({
|
||||
isListening: false,
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
isSupported: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./hooks/useAgentToolActions", () => ({
|
||||
useAgentToolActions: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("./hooks/useAgentChatSession", () => ({
|
||||
useAgentChatSession: () => ({
|
||||
messages: [],
|
||||
chatSessions: [],
|
||||
activeSessionId: undefined,
|
||||
isHydrating: false,
|
||||
loadingSessionId: null,
|
||||
isStreaming: false,
|
||||
sessionTitle: "新会话",
|
||||
sendPrompt: jest.fn(),
|
||||
createBranch: jest.fn(),
|
||||
abort: jest.fn(),
|
||||
replyPermission: jest.fn(),
|
||||
replyQuestion: jest.fn(),
|
||||
rejectQuestion: jest.fn(),
|
||||
createSession,
|
||||
renameSession: jest.fn(),
|
||||
removeSession: jest.fn(),
|
||||
switchSession: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./AgentHeader", () => ({
|
||||
AgentHeader: () => <div>Agent header</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./AgentHistoryPanel", () => ({
|
||||
AgentHistoryPanel: () => <div>History</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./AgentWorkspace", () => ({
|
||||
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./AgentComposer", () => ({
|
||||
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
||||
return <div>Composer</div>;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./GlobalChatboxParts", () => ({
|
||||
Blob: () => null,
|
||||
}));
|
||||
|
||||
describe("GlobalChatbox lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
createSession.mockClear();
|
||||
mockCurrentProjectId = "project-1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||
|
||||
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
|
||||
act(() => jest.advanceTimersByTime(300));
|
||||
|
||||
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||
|
||||
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockCurrentProjectId = "project-2";
|
||||
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
|
||||
expect(createSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -10,33 +10,39 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
|
||||
import { getAccessToken } from "@/lib/authToken";
|
||||
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||
import { AgentHeader } from "./AgentHeader";
|
||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||
import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import { Blob } from "./GlobalChatbox.parts";
|
||||
import { Blob } from "./GlobalChatboxParts";
|
||||
import type { Props } from "./GlobalChatbox.types";
|
||||
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
|
||||
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
||||
import { PRESET_PROMPTS } from "./globalChatboxUtils";
|
||||
import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice";
|
||||
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||
|
||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||
|
||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [width, setWidth] = useState(520);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
||||
"deepseek/deepseek-v4-pro",
|
||||
);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||
const [approvalMode, setApprovalMode] =
|
||||
useState<AgentApprovalMode>("request");
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||
const hasResetForOpenRef = useRef(false);
|
||||
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||
const theme = useTheme();
|
||||
const { open: openNotification } = useNotification();
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
@@ -62,6 +68,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
isSupported: isSttSupported,
|
||||
} = useSpeechRecognition(handleSpeechResult);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const modelConfig = await fetchAgentModels();
|
||||
if (cancelled) return;
|
||||
setModelOptions(modelConfig.models);
|
||||
setSelectedModel((current) => {
|
||||
if (current && modelConfig.models.some((model) => model.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return modelConfig.defaultModel;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to load agent models:", error);
|
||||
if (!cancelled) {
|
||||
setModelOptions([]);
|
||||
setSelectedModel(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadModels();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToolCall = useAgentToolActions();
|
||||
const {
|
||||
messages,
|
||||
@@ -93,27 +129,78 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
bottomRef.current?.scrollIntoView({ behavior });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom(isStreaming ? "auto" : "smooth");
|
||||
}, [isStreaming, messages, scrollToBottom]);
|
||||
const cancelStreamingScroll = useCallback(() => {
|
||||
if (streamingScrollFrameRef.current === null) return;
|
||||
window.cancelAnimationFrame(streamingScrollFrameRef.current);
|
||||
streamingScrollFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
hasResetForOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasResetForOpenRef.current || isHydrating) return;
|
||||
hasResetForOpenRef.current = true;
|
||||
const scheduleStreamingScrollToBottom = useCallback(() => {
|
||||
if (streamingScrollFrameRef.current !== null) return;
|
||||
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
streamingScrollFrameRef.current = null;
|
||||
const container = workspaceScrollRef.current;
|
||||
if (!container || !isNearBottomRef.current) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
createSession();
|
||||
composerRef.current?.clear();
|
||||
setIsHistoryOpen(false);
|
||||
const distanceToBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
|
||||
|
||||
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
|
||||
isNearBottomRef.current = isNearBottom;
|
||||
}, []);
|
||||
|
||||
const resetConversationView = useCallback(() => {
|
||||
composerRef.current?.clear();
|
||||
setIsHistoryOpen(false);
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus();
|
||||
isNearBottomRef.current = true;
|
||||
cancelStreamingScroll();
|
||||
scrollToBottom("auto");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [createSession, isHydrating, open, scrollToBottom]);
|
||||
}, [cancelStreamingScroll, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (!isNearBottomRef.current) return;
|
||||
scheduleStreamingScrollToBottom();
|
||||
return;
|
||||
}
|
||||
cancelStreamingScroll();
|
||||
scrollToBottom("smooth");
|
||||
}, [
|
||||
cancelStreamingScroll,
|
||||
isStreaming,
|
||||
messages,
|
||||
scheduleStreamingScrollToBottom,
|
||||
scrollToBottom,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelStreamingScroll();
|
||||
},
|
||||
[cancelStreamingScroll],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
isHydrating ||
|
||||
initializedProjectIdRef.current === currentProjectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializedProjectIdRef.current = currentProjectId;
|
||||
createSession();
|
||||
resetConversationView();
|
||||
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||
|
||||
const handleSend = useCallback(async (prompt: string) => {
|
||||
if (isStreaming || isCheckingAuth) return;
|
||||
@@ -148,12 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
handleStopSpeech();
|
||||
stopListening();
|
||||
createSession();
|
||||
composerRef.current?.clear();
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus();
|
||||
scrollToBottom("auto");
|
||||
}, 0);
|
||||
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||
resetConversationView();
|
||||
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||
|
||||
const handleHistoryToggle = useCallback(() => {
|
||||
setIsHistoryOpen((prev) => !prev);
|
||||
@@ -227,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
disableEnforceFocus
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||
pointerEvents: "none",
|
||||
@@ -347,7 +431,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
isLoadingSession={Boolean(loadingSessionId)}
|
||||
scrollContainerRef={workspaceScrollRef}
|
||||
bottomRef={bottomRef}
|
||||
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={handleSpeak}
|
||||
@@ -372,6 +458,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
onAbort={abort}
|
||||
onStartListening={startListening}
|
||||
onStopListening={stopListening}
|
||||
modelOptions={modelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
approvalMode={approvalMode}
|
||||
|
||||
@@ -70,7 +70,7 @@ export type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export type SpeechState = "idle" | "playing" | "paused";
|
||||
export type SpeechState = "idle" | "loading" | "playing" | "paused";
|
||||
|
||||
export type ChatSessionSummary = {
|
||||
id: string;
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import config from "@/config/config";
|
||||
import type { SpeechState } from "./GlobalChatbox.types";
|
||||
|
||||
type AudioStreamStartResponse = {
|
||||
stream_id?: string;
|
||||
audio_url?: string;
|
||||
status_url?: string;
|
||||
result_url?: string;
|
||||
sample_rate?: number;
|
||||
channels?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type AudioStreamStatusResponse = {
|
||||
state?: "starting" | "running" | "done" | "failed" | "closed";
|
||||
ready?: boolean;
|
||||
failed?: boolean;
|
||||
closed?: boolean;
|
||||
status_text?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type AudioStreamResultResponse = {
|
||||
run_status?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// WebKit Speech Recognition compatibility
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition?: {
|
||||
new (): SpeechRecognition;
|
||||
prototype: SpeechRecognition;
|
||||
};
|
||||
webkitSpeechRecognition?: {
|
||||
new (): SpeechRecognition;
|
||||
prototype: SpeechRecognition;
|
||||
};
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpeechSynthesis() {
|
||||
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
||||
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
||||
const streamIdRef = useRef<string | null>(null);
|
||||
const closeUrlRef = useRef<string | null>(null);
|
||||
const statusUrlRef = useRef<string | null>(null);
|
||||
const resultUrlRef = useRef<string | null>(null);
|
||||
const statusPollTimeoutRef = useRef<number | null>(null);
|
||||
const playbackTokenRef = useRef(0);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.FormData !== "undefined" &&
|
||||
(typeof window.AudioContext !== "undefined" ||
|
||||
typeof window.webkitAudioContext !== "undefined");
|
||||
|
||||
const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []);
|
||||
|
||||
const buildServiceUrl = useCallback(
|
||||
(path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`,
|
||||
[trimTrailingSlash],
|
||||
);
|
||||
|
||||
const resolveServiceUrl = useCallback(
|
||||
(pathOrUrl: string) => {
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) {
|
||||
return pathOrUrl;
|
||||
}
|
||||
return buildServiceUrl(pathOrUrl);
|
||||
},
|
||||
[buildServiceUrl],
|
||||
);
|
||||
|
||||
const withQueryParams = useCallback(
|
||||
(urlString: string, params: Record<string, string>) => {
|
||||
const url = new URL(urlString);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
return url.toString();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
return payload.error || payload.message || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeStream = useCallback(async (closeUrl: string) => {
|
||||
const response = await fetch(closeUrl, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopStatusPolling = useCallback(() => {
|
||||
if (statusPollTimeoutRef.current !== null) {
|
||||
window.clearTimeout(statusPollTimeoutRef.current);
|
||||
statusPollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStreamResult = useCallback(
|
||||
async (resultUrl: string) => {
|
||||
const response = await fetch(resultUrl);
|
||||
if (response.status === 202) {
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(
|
||||
response,
|
||||
`Audio stream result failed with status ${response.status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AudioStreamResultResponse;
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[readErrorMessage],
|
||||
);
|
||||
|
||||
const clearAudio = useCallback(async () => {
|
||||
const abortController = streamAbortControllerRef.current;
|
||||
streamAbortControllerRef.current = null;
|
||||
abortController?.abort();
|
||||
|
||||
activeSourceNodesRef.current.forEach((source) => {
|
||||
try {
|
||||
source.onended = null;
|
||||
source.stop();
|
||||
} catch {
|
||||
// ignore stop errors when source already ended
|
||||
}
|
||||
source.disconnect();
|
||||
});
|
||||
activeSourceNodesRef.current.clear();
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
audioContextRef.current = null;
|
||||
if (!audioContext) return;
|
||||
|
||||
try {
|
||||
await audioContext.close();
|
||||
} catch {
|
||||
// ignore close errors when context already closed
|
||||
}
|
||||
}, []);
|
||||
|
||||
const playPcmStream = useCallback(
|
||||
async ({
|
||||
audioUrl,
|
||||
sampleRate,
|
||||
channels,
|
||||
playbackToken,
|
||||
}: {
|
||||
audioUrl: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
playbackToken: number;
|
||||
}) => {
|
||||
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error("WebAudio AudioContext is not available in this browser");
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
streamAbortControllerRef.current = abortController;
|
||||
|
||||
const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(response, `Audio stream failed with status ${response.status}`),
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Audio stream response body is missing");
|
||||
}
|
||||
|
||||
const audioContext = new AudioContextCtor({
|
||||
sampleRate,
|
||||
});
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const bytesPerFrame = Math.max(1, channels) * 2;
|
||||
let bufferedRemainder = new Uint8Array(0);
|
||||
let nextStartTime = audioContext.currentTime + 0.05;
|
||||
let activeSources = 0;
|
||||
let streamEnded = false;
|
||||
let resolvePlaybackDrain: (() => void) | null = null;
|
||||
const playbackDrainPromise = new Promise<void>((resolve) => {
|
||||
resolvePlaybackDrain = resolve;
|
||||
});
|
||||
|
||||
const maybeResolvePlaybackDrain = () => {
|
||||
if (streamEnded && activeSources === 0) {
|
||||
resolvePlaybackDrain?.();
|
||||
}
|
||||
};
|
||||
|
||||
const schedulePcmChunk = (pcmBytes: Uint8Array) => {
|
||||
const frameCount = pcmBytes.byteLength / bytesPerFrame;
|
||||
if (frameCount <= 0) return;
|
||||
|
||||
const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate);
|
||||
const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength);
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
for (let channel = 0; channel < Math.max(1, channels); channel += 1) {
|
||||
const sampleIndex = frame * Math.max(1, channels) + channel;
|
||||
const pcm = view.getInt16(sampleIndex * 2, true);
|
||||
buffer.getChannelData(channel)[frame] = pcm / 32768;
|
||||
}
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01);
|
||||
nextStartTime = sourceStartTime + buffer.duration;
|
||||
|
||||
activeSources += 1;
|
||||
activeSourceNodesRef.current.add(source);
|
||||
source.onended = () => {
|
||||
activeSources -= 1;
|
||||
activeSourceNodesRef.current.delete(source);
|
||||
source.disconnect();
|
||||
maybeResolvePlaybackDrain();
|
||||
};
|
||||
source.start(sourceStartTime);
|
||||
};
|
||||
|
||||
const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => {
|
||||
if (a.byteLength === 0) return b;
|
||||
if (b.byteLength === 0) return a;
|
||||
const merged = new Uint8Array(a.byteLength + b.byteLength);
|
||||
merged.set(a);
|
||||
merged.set(b, a.byteLength);
|
||||
return merged;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (playbackToken !== playbackTokenRef.current) {
|
||||
throw new DOMException("PCM stream playback cancelled", "AbortError");
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
|
||||
const merged = concatUint8Arrays(bufferedRemainder, value);
|
||||
const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame);
|
||||
if (alignedByteLength === 0) {
|
||||
bufferedRemainder = new Uint8Array(merged);
|
||||
continue;
|
||||
}
|
||||
|
||||
const alignedChunk = merged.slice(0, alignedByteLength);
|
||||
bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength));
|
||||
schedulePcmChunk(alignedChunk);
|
||||
}
|
||||
|
||||
streamEnded = true;
|
||||
maybeResolvePlaybackDrain();
|
||||
await playbackDrainPromise;
|
||||
},
|
||||
[readErrorMessage, withQueryParams],
|
||||
);
|
||||
|
||||
const stopPlayback = useCallback(async () => {
|
||||
await clearAudio();
|
||||
stopStatusPolling();
|
||||
|
||||
const closeUrl = closeUrlRef.current;
|
||||
streamIdRef.current = null;
|
||||
closeUrlRef.current = null;
|
||||
statusUrlRef.current = null;
|
||||
resultUrlRef.current = null;
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
|
||||
if (closeUrl) {
|
||||
try {
|
||||
await closeStream(closeUrl);
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to close audio stream:", error);
|
||||
}
|
||||
}
|
||||
}, [clearAudio, closeStream, stopStatusPolling]);
|
||||
|
||||
const pollStreamStatus = useCallback(
|
||||
(playbackToken: number, statusUrl: string, resultUrl: string) => {
|
||||
stopStatusPolling();
|
||||
|
||||
statusPollTimeoutRef.current = window.setTimeout(async () => {
|
||||
if (
|
||||
playbackToken !== playbackTokenRef.current ||
|
||||
statusUrlRef.current !== statusUrl ||
|
||||
resultUrlRef.current !== resultUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(statusUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(
|
||||
response,
|
||||
`Audio stream status failed with status ${response.status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AudioStreamStatusResponse;
|
||||
if (
|
||||
playbackToken !== playbackTokenRef.current ||
|
||||
statusUrlRef.current !== statusUrl ||
|
||||
resultUrlRef.current !== resultUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.failed || payload.state === "failed") {
|
||||
console.error(
|
||||
"[GlobalChatbox] Audio stream failed:",
|
||||
payload.error || payload.status_text || statusUrl,
|
||||
);
|
||||
playbackTokenRef.current += 1;
|
||||
void stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.closed || payload.state === "closed") {
|
||||
stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.ready || payload.state === "done") {
|
||||
try {
|
||||
const isResultReady = await fetchStreamResult(resultUrl);
|
||||
if (isResultReady) {
|
||||
stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
||||
}
|
||||
}
|
||||
|
||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||
} catch (error) {
|
||||
if (
|
||||
playbackToken === playbackTokenRef.current &&
|
||||
statusUrlRef.current === statusUrl &&
|
||||
resultUrlRef.current === resultUrl
|
||||
) {
|
||||
console.error("[GlobalChatbox] Failed to poll audio stream status:", error);
|
||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
[fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
playbackTokenRef.current += 1;
|
||||
void stopPlayback();
|
||||
}, [stopPlayback]);
|
||||
|
||||
const speak = useCallback(
|
||||
async (messageId: string, text: string) => {
|
||||
const normalizedText = text.trim();
|
||||
if (!isSupported || !normalizedText) return;
|
||||
|
||||
const playbackToken = playbackTokenRef.current + 1;
|
||||
playbackTokenRef.current = playbackToken;
|
||||
await stopPlayback();
|
||||
|
||||
setSpeakingMessageId(messageId);
|
||||
setSpeechState("playing");
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("text", normalizedText);
|
||||
formData.append("demo_id", "demo-1");
|
||||
|
||||
const response = await fetch(buildServiceUrl("/api/generate-stream/start"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(
|
||||
response,
|
||||
`Audio stream start failed with status ${response.status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AudioStreamStartResponse;
|
||||
const streamId = payload.stream_id;
|
||||
const sampleRate =
|
||||
typeof payload.sample_rate === "number" && payload.sample_rate > 0
|
||||
? payload.sample_rate
|
||||
: 24000;
|
||||
const channels =
|
||||
typeof payload.channels === "number" && payload.channels > 0
|
||||
? payload.channels
|
||||
: 1;
|
||||
const audioUrl = payload.audio_url
|
||||
? resolveServiceUrl(payload.audio_url)
|
||||
: buildServiceUrl(
|
||||
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`,
|
||||
);
|
||||
const rawStatusUrl = payload.status_url
|
||||
? resolveServiceUrl(payload.status_url)
|
||||
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`);
|
||||
const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" });
|
||||
const rawResultUrl = payload.result_url
|
||||
? resolveServiceUrl(payload.result_url)
|
||||
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`);
|
||||
const resultUrl = withQueryParams(rawResultUrl, {
|
||||
compact: "1",
|
||||
include_audio: "0",
|
||||
});
|
||||
const closeUrl = buildServiceUrl(
|
||||
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`,
|
||||
);
|
||||
|
||||
if (!streamId) {
|
||||
throw new Error(payload.error || "Audio stream start response is missing stream_id");
|
||||
}
|
||||
|
||||
if (playbackToken !== playbackTokenRef.current) {
|
||||
await closeStream(closeUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
streamIdRef.current = streamId;
|
||||
closeUrlRef.current = closeUrl;
|
||||
statusUrlRef.current = statusUrl;
|
||||
resultUrlRef.current = resultUrl;
|
||||
|
||||
pollStreamStatus(playbackToken, statusUrl, resultUrl);
|
||||
await playPcmStream({
|
||||
audioUrl,
|
||||
sampleRate,
|
||||
channels,
|
||||
playbackToken,
|
||||
});
|
||||
|
||||
if (playbackToken !== playbackTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clearAudio();
|
||||
if (streamIdRef.current === streamId) {
|
||||
streamIdRef.current = null;
|
||||
closeUrlRef.current = null;
|
||||
statusUrlRef.current = null;
|
||||
resultUrlRef.current = null;
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
}
|
||||
stopStatusPolling();
|
||||
await fetchStreamResult(resultUrl).catch((error) => {
|
||||
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
|
||||
});
|
||||
await closeStream(closeUrl);
|
||||
} catch (error) {
|
||||
await clearAudio();
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === "AbortError" &&
|
||||
playbackToken !== playbackTokenRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const closeUrl = closeUrlRef.current;
|
||||
streamIdRef.current = null;
|
||||
closeUrlRef.current = null;
|
||||
statusUrlRef.current = null;
|
||||
resultUrlRef.current = null;
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
if (closeUrl) {
|
||||
try {
|
||||
await closeStream(closeUrl);
|
||||
} catch (closeError) {
|
||||
console.error("[GlobalChatbox] Failed to close audio stream:", closeError);
|
||||
}
|
||||
}
|
||||
console.error("[GlobalChatbox] Failed to play audio stream:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
buildServiceUrl,
|
||||
clearAudio,
|
||||
closeStream,
|
||||
fetchStreamResult,
|
||||
isSupported,
|
||||
playPcmStream,
|
||||
readErrorMessage,
|
||||
resolveServiceUrl,
|
||||
pollStreamStatus,
|
||||
stopPlayback,
|
||||
stopStatusPolling,
|
||||
withQueryParams,
|
||||
],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!isSupported || !audioContextRef.current) return;
|
||||
void audioContextRef.current.suspend().then(
|
||||
() => {
|
||||
setSpeechState("paused");
|
||||
},
|
||||
(error) => {
|
||||
console.error("[GlobalChatbox] Failed to pause PCM playback:", error);
|
||||
},
|
||||
);
|
||||
}, [isSupported]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!isSupported || !audioContextRef.current) return;
|
||||
void audioContextRef.current.resume().then(
|
||||
() => {
|
||||
setSpeechState("playing");
|
||||
},
|
||||
(error) => {
|
||||
playbackTokenRef.current += 1;
|
||||
void stopPlayback();
|
||||
console.error("[GlobalChatbox] Failed to resume audio playback:", error);
|
||||
},
|
||||
);
|
||||
}, [isSupported, stopPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
playbackTokenRef.current += 1;
|
||||
void stopPlayback();
|
||||
};
|
||||
}, [stopPlayback]);
|
||||
|
||||
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
|
||||
}
|
||||
|
||||
export function useSpeechRecognition(onResult: (text: string) => void) {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const onResultRef = useRef(onResult);
|
||||
useEffect(() => {
|
||||
onResultRef.current = onResult;
|
||||
}, [onResult]);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported || recognitionRef.current) return;
|
||||
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||
if (!Ctor) return;
|
||||
|
||||
const recognition = new Ctor();
|
||||
recognition.lang = "zh-CN";
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
if (event.results[i].isFinal) {
|
||||
onResultRef.current(event.results[i][0].transcript);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [isSupported]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
recognitionRef.current = null;
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isListening, start, stop, isSupported };
|
||||
}
|
||||
@@ -115,3 +115,8 @@
|
||||
color: var(--chat-md-quote-text);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.streamFade {
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
-30
@@ -2,36 +2,6 @@
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
|
||||
export const TypingIndicator = () => {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: [-4, 4, -4] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const Blob = ({
|
||||
color,
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
createEmptyChatState,
|
||||
saveActiveChatState,
|
||||
deleteChatSession,
|
||||
listChatSessions,
|
||||
loadChatSessionById,
|
||||
updateChatSessionTitle,
|
||||
} from "./chatStorage";
|
||||
|
||||
const apiFetch = jest.fn();
|
||||
@@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
|
||||
describe("chatStorage backend-only persistence", () => {
|
||||
describe("chatStorage backend session operations", () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
});
|
||||
@@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => {
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a backend conversation when saving the first non-empty state", async () => {
|
||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith("/api/v1/agent/chat/session")) {
|
||||
expect(init?.method).toBe("POST");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ session_id: "chat-new-1" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
title: "新对话",
|
||||
is_title_manually_edited: false,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request ${url}`);
|
||||
});
|
||||
|
||||
const savedSessionId = await saveActiveChatState(
|
||||
{
|
||||
title: "新对话",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [
|
||||
it("lists backend sessions sorted by created time", async () => {
|
||||
apiFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [
|
||||
{
|
||||
id: "message-2",
|
||||
role: "user",
|
||||
content: "第一条消息",
|
||||
id: "session-old",
|
||||
title: "旧会话",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "session-new",
|
||||
title: "新会话",
|
||||
created_at: "2026-01-03T00:00:00.000Z",
|
||||
updated_at: "2026-01-03T00:00:00.000Z",
|
||||
is_streaming: true,
|
||||
run_status: "running",
|
||||
},
|
||||
],
|
||||
sessionId: undefined,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
expect(savedSessionId).toBe("chat-new-1");
|
||||
await expect(listChatSessions()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-new",
|
||||
title: "新会话",
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "session-old",
|
||||
title: "旧会话",
|
||||
}),
|
||||
]);
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
it("loads a backend session state", async () => {
|
||||
apiFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: "session-1",
|
||||
title: "管网分析",
|
||||
is_title_manually_edited: true,
|
||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||
is_streaming: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(loadChatSessionById("session-1")).resolves.toMatchObject({
|
||||
title: "管网分析",
|
||||
isTitleManuallyEdited: true,
|
||||
sessionId: "session-1",
|
||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||
});
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
it("updates a backend session title through the title endpoint", async () => {
|
||||
apiFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => "",
|
||||
});
|
||||
|
||||
await updateChatSessionTitle("session-1", " 新标题 ", {
|
||||
isTitleManuallyEdited: true,
|
||||
});
|
||||
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||
title: "新标题",
|
||||
is_title_manually_edited: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a backend session and returns the next active session id", async () => {
|
||||
apiFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [
|
||||
{
|
||||
id: "session-next",
|
||||
title: "下一会话",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(deleteChatSession("session-1")).resolves.toBe("session-next");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||
expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "./GlobalChatbox.types";
|
||||
import { cloneMessages } from "./GlobalChatbox.utils";
|
||||
import { cloneMessages } from "./globalChatboxUtils";
|
||||
|
||||
type BackendSessionPayload = {
|
||||
id?: string;
|
||||
@@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({
|
||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||
|
||||
const hasChatContent = (state: {
|
||||
messages: Message[];
|
||||
sessionId?: string;
|
||||
}) =>
|
||||
state.messages.length > 0 ||
|
||||
Boolean(state.sessionId);
|
||||
|
||||
const compareSessionsByAnchorTime = (
|
||||
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||
@@ -53,10 +46,9 @@ const toMillis = (value: string | number | undefined) =>
|
||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||
|
||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -80,11 +72,10 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
|
||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
@@ -113,71 +104,13 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
||||
};
|
||||
};
|
||||
|
||||
const createBackendChatSession = async (payload?: {
|
||||
sessionId?: string;
|
||||
parentSessionId?: string;
|
||||
}) => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: payload?.sessionId,
|
||||
parent_session_id: payload?.parentSessionId,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
session_id?: string;
|
||||
};
|
||||
const sessionId = body.session_id?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("backend did not return session_id");
|
||||
}
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const saveBackendChatState = async (
|
||||
sessionId: string,
|
||||
state: LoadedChatState,
|
||||
): Promise<string> => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: normalizeTitle(state.title),
|
||||
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
||||
messages: sanitizeMessages(state.messages),
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const payload = (await response.json()) as { id?: string; session_id?: string };
|
||||
return payload.id ?? payload.session_id ?? sessionId;
|
||||
};
|
||||
|
||||
const updateBackendChatSessionTitle = async (
|
||||
sessionId: string,
|
||||
title: string,
|
||||
isTitleManuallyEdited?: boolean,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -188,7 +121,6 @@ const updateBackendChatSessionTitle = async (
|
||||
is_title_manually_edited: isTitleManuallyEdited,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
@@ -199,11 +131,10 @@ const updateBackendChatSessionTitle = async (
|
||||
|
||||
const deleteBackendChatSession = async (sessionId: string) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
@@ -212,27 +143,6 @@ const deleteBackendChatSession = async (sessionId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const saveActiveChatState = async (
|
||||
state: LoadedChatState,
|
||||
): Promise<string | undefined> => {
|
||||
if (typeof window === "undefined") return state.sessionId;
|
||||
|
||||
if (!hasChatContent(state)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let backendSessionId = state.sessionId;
|
||||
if (!backendSessionId) {
|
||||
backendSessionId = await createBackendChatSession();
|
||||
}
|
||||
|
||||
const savedSessionId = await saveBackendChatState(backendSessionId, {
|
||||
...state,
|
||||
sessionId: backendSessionId,
|
||||
});
|
||||
return savedSessionId;
|
||||
};
|
||||
|
||||
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
if (typeof window === "undefined") return [];
|
||||
return await fetchBackendChatSessions();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { cloneMessage } from "./GlobalChatbox.utils";
|
||||
import { cloneMessage } from "./globalChatboxUtils";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
describe("cloneMessage", () => {
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SpeechState } from "./GlobalChatbox.types";
|
||||
import { splitSpeechTextIntoChunks } from "./speechStartOptions";
|
||||
|
||||
type SpeakOptions = {
|
||||
startOffset?: number;
|
||||
};
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition?: {
|
||||
new (): SpeechRecognition;
|
||||
prototype: SpeechRecognition;
|
||||
};
|
||||
webkitSpeechRecognition?: {
|
||||
new (): SpeechRecognition;
|
||||
prototype: SpeechRecognition;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpeechSynthesis() {
|
||||
const [speechState, setSpeechState] = useState<SpeechState>("idle");
|
||||
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
const audioObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||
const fetchAbortControllersRef = useRef<Set<AbortController>>(new Set());
|
||||
const chunkAudioUrlCacheRef = useRef<Map<number, string>>(new Map());
|
||||
const chunkFetchPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
|
||||
const chunksRef = useRef<string[]>([]);
|
||||
const currentChunkIndexRef = useRef(0);
|
||||
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
const playbackTokenRef = useRef(0);
|
||||
const activeMessageIdRef = useRef<string | null>(null);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.Audio !== "undefined" &&
|
||||
typeof window.URL !== "undefined" &&
|
||||
typeof window.fetch !== "undefined";
|
||||
|
||||
const detachCurrentAudio = useCallback((revokeCurrentUrl: boolean) => {
|
||||
const audio = audioRef.current;
|
||||
audioRef.current = null;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
|
||||
const currentUrl = currentAudioUrlRef.current;
|
||||
currentAudioUrlRef.current = null;
|
||||
if (revokeCurrentUrl && currentUrl) {
|
||||
URL.revokeObjectURL(currentUrl);
|
||||
audioObjectUrlsRef.current.delete(currentUrl);
|
||||
chunkAudioUrlCacheRef.current.delete(currentChunkIndexRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const releaseAudio = useCallback(() => {
|
||||
fetchAbortControllersRef.current.forEach((controller) => controller.abort());
|
||||
fetchAbortControllersRef.current.clear();
|
||||
chunkFetchPromisesRef.current.clear();
|
||||
detachCurrentAudio(false);
|
||||
audioObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
audioObjectUrlsRef.current.clear();
|
||||
chunkAudioUrlCacheRef.current.clear();
|
||||
chunksRef.current = [];
|
||||
currentChunkIndexRef.current = 0;
|
||||
activeMessageIdRef.current = null;
|
||||
}, [detachCurrentAudio]);
|
||||
|
||||
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
return payload.error || payload.message || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchChunkAudio = useCallback(
|
||||
(chunkIndex: number, playbackToken: number) => {
|
||||
const cachedUrl = chunkAudioUrlCacheRef.current.get(chunkIndex);
|
||||
if (cachedUrl) return Promise.resolve(cachedUrl);
|
||||
|
||||
const existingPromise = chunkFetchPromisesRef.current.get(chunkIndex);
|
||||
if (existingPromise) return existingPromise;
|
||||
|
||||
const chunkText = chunksRef.current[chunkIndex];
|
||||
if (!chunkText) {
|
||||
return Promise.reject(new Error("Speech chunk is missing"));
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
fetchAbortControllersRef.current.add(abortController);
|
||||
|
||||
const promise = fetch("/api/tts/edge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: chunkText }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(response, `Edge TTS failed with status ${response.status}`),
|
||||
);
|
||||
}
|
||||
const audioBlob = await response.blob();
|
||||
if (!audioBlob.size) {
|
||||
throw new Error("Edge TTS returned empty audio");
|
||||
}
|
||||
if (playbackToken !== playbackTokenRef.current) {
|
||||
throw new DOMException("Edge TTS chunk cancelled", "AbortError");
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(audioBlob);
|
||||
audioObjectUrlsRef.current.add(objectUrl);
|
||||
chunkAudioUrlCacheRef.current.set(chunkIndex, objectUrl);
|
||||
return objectUrl;
|
||||
})
|
||||
.finally(() => {
|
||||
fetchAbortControllersRef.current.delete(abortController);
|
||||
chunkFetchPromisesRef.current.delete(chunkIndex);
|
||||
});
|
||||
|
||||
chunkFetchPromisesRef.current.set(chunkIndex, promise);
|
||||
return promise;
|
||||
},
|
||||
[readErrorMessage],
|
||||
);
|
||||
|
||||
const prefetchChunk = useCallback(
|
||||
(chunkIndex: number, playbackToken: number) => {
|
||||
if (chunkIndex >= chunksRef.current.length) return;
|
||||
void fetchChunkAudio(chunkIndex, playbackToken).catch((error) => {
|
||||
if (
|
||||
playbackToken === playbackTokenRef.current &&
|
||||
!(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
console.error("[GlobalChatbox] Failed to prefetch Edge TTS chunk:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
[fetchChunkAudio],
|
||||
);
|
||||
|
||||
const playChunk = useCallback(
|
||||
async (chunkIndex: number, playbackToken: number) => {
|
||||
setSpeechState("loading");
|
||||
const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken);
|
||||
if (playbackToken !== playbackTokenRef.current) return;
|
||||
|
||||
detachCurrentAudio(true);
|
||||
currentChunkIndexRef.current = chunkIndex;
|
||||
const audio = new Audio(objectUrl);
|
||||
audio.preload = "auto";
|
||||
audioRef.current = audio;
|
||||
currentAudioUrlRef.current = objectUrl;
|
||||
|
||||
audio.onended = () => {
|
||||
if (playbackToken !== playbackTokenRef.current) return;
|
||||
detachCurrentAudio(true);
|
||||
const nextChunkIndex = chunkIndex + 1;
|
||||
if (nextChunkIndex >= chunksRef.current.length) {
|
||||
releaseAudio();
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
currentChunkIndexRef.current = nextChunkIndex;
|
||||
void playChunkRef.current(nextChunkIndex, playbackToken);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
if (playbackToken !== playbackTokenRef.current) return;
|
||||
playbackTokenRef.current += 1;
|
||||
releaseAudio();
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
console.error("[GlobalChatbox] Edge TTS audio playback failed");
|
||||
};
|
||||
|
||||
await audio.play();
|
||||
if (playbackToken !== playbackTokenRef.current) return;
|
||||
setSpeechState("playing");
|
||||
prefetchChunk(chunkIndex + 1, playbackToken);
|
||||
},
|
||||
[detachCurrentAudio, fetchChunkAudio, prefetchChunk, releaseAudio],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
playChunkRef.current = playChunk;
|
||||
}, [playChunk]);
|
||||
|
||||
const playExistingAudio = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return false;
|
||||
|
||||
setSpeakingMessageId(activeMessageIdRef.current);
|
||||
setSpeechState("playing");
|
||||
try {
|
||||
await audio.play();
|
||||
prefetchChunk(currentChunkIndexRef.current + 1, playbackTokenRef.current);
|
||||
return true;
|
||||
} catch (error) {
|
||||
playbackTokenRef.current += 1;
|
||||
releaseAudio();
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
console.error("[GlobalChatbox] Failed to resume Edge TTS playback:", error);
|
||||
return false;
|
||||
}
|
||||
}, [prefetchChunk, releaseAudio]);
|
||||
|
||||
const speak = useCallback(
|
||||
async (messageId: string, text: string, options: SpeakOptions = {}) => {
|
||||
const normalizedText = text.trim();
|
||||
if (!isSupported || !normalizedText) return;
|
||||
|
||||
const startOffset = Math.max(
|
||||
0,
|
||||
Math.min(options.startOffset ?? 0, normalizedText.length),
|
||||
);
|
||||
const textToSpeak = normalizedText.slice(startOffset).trim();
|
||||
const chunks = splitSpeechTextIntoChunks(textToSpeak);
|
||||
if (!chunks.length) return;
|
||||
|
||||
const playbackToken = playbackTokenRef.current + 1;
|
||||
playbackTokenRef.current = playbackToken;
|
||||
releaseAudio();
|
||||
|
||||
chunksRef.current = chunks;
|
||||
currentChunkIndexRef.current = 0;
|
||||
activeMessageIdRef.current = messageId;
|
||||
|
||||
setSpeakingMessageId(messageId);
|
||||
setSpeechState("loading");
|
||||
|
||||
try {
|
||||
await playChunk(0, playbackToken);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === "AbortError" &&
|
||||
playbackToken !== playbackTokenRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAudio();
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
console.error("[GlobalChatbox] Failed to play Edge TTS audio:", error);
|
||||
}
|
||||
},
|
||||
[isSupported, playChunk, releaseAudio],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!isSupported || !audio || speechState !== "playing") return;
|
||||
audio.pause();
|
||||
setSpeechState("paused");
|
||||
}, [isSupported, speechState]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!isSupported) return;
|
||||
void playExistingAudio();
|
||||
}, [isSupported, playExistingAudio]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
playbackTokenRef.current += 1;
|
||||
releaseAudio();
|
||||
setSpeechState("idle");
|
||||
setSpeakingMessageId(null);
|
||||
}, [releaseAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
playbackTokenRef.current += 1;
|
||||
releaseAudio();
|
||||
};
|
||||
}, [releaseAudio]);
|
||||
|
||||
return {
|
||||
speechState,
|
||||
speakingMessageId,
|
||||
speak,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
isSupported,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSpeechRecognition(onResult: (text: string) => void) {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const onResultRef = useRef(onResult);
|
||||
useEffect(() => {
|
||||
onResultRef.current = onResult;
|
||||
}, [onResult]);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported || recognitionRef.current) return;
|
||||
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||
if (!Ctor) return;
|
||||
|
||||
const recognition = new Ctor();
|
||||
recognition.lang = "zh-CN";
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
if (event.results[i].isFinal) {
|
||||
onResultRef.current(event.results[i][0].transcript);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
setIsListening(true);
|
||||
recognition.start();
|
||||
}, [isSupported]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
recognitionRef.current = null;
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isListening, start, stop, isSupported };
|
||||
}
|
||||
@@ -7,18 +7,9 @@ import type {
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
ChatProgress,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "../GlobalChatbox.types";
|
||||
import { createId } from "../GlobalChatbox.utils";
|
||||
|
||||
export const createPersistedStateKey = (state: LoadedChatState) =>
|
||||
JSON.stringify({
|
||||
title: state.title ?? null,
|
||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
||||
sessionId: state.sessionId ?? null,
|
||||
messages: state.messages,
|
||||
});
|
||||
import { createId } from "../globalChatboxUtils";
|
||||
|
||||
export const upsertProgress = (
|
||||
progress: ChatProgress[] | undefined,
|
||||
|
||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
||||
|
||||
const listChatSessions = jest.fn();
|
||||
const deleteChatSession = jest.fn();
|
||||
const saveActiveChatState = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
|
||||
jest.mock("../chatStorage", () => ({
|
||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
||||
messages: [],
|
||||
sessionId: "session-loaded",
|
||||
})),
|
||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||
}));
|
||||
|
||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
||||
beforeEach(() => {
|
||||
listChatSessions.mockReset();
|
||||
deleteChatSession.mockReset();
|
||||
saveActiveChatState.mockReset();
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||
deleteChatSession.mockImplementation(async () => undefined);
|
||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
||||
|
||||
const listChatSessions = jest.fn();
|
||||
const deleteChatSession = jest.fn();
|
||||
const saveActiveChatState = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
|
||||
jest.mock("../chatStorage", () => ({
|
||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
||||
messages: [],
|
||||
sessionId: "session-loaded",
|
||||
})),
|
||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||
}));
|
||||
|
||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
||||
beforeEach(() => {
|
||||
listChatSessions.mockReset();
|
||||
deleteChatSession.mockReset();
|
||||
saveActiveChatState.mockReset();
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||
deleteChatSession.mockImplementation(async () => undefined);
|
||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
@@ -190,7 +186,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists a new conversation only after the stream is done", async () => {
|
||||
it("does not autosave full messages after the stream is done", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
@@ -220,8 +216,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "token",
|
||||
@@ -234,8 +228,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "done",
|
||||
@@ -247,14 +239,11 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
||||
sessionId: "chat-stream-1",
|
||||
messages: [
|
||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||
],
|
||||
});
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||
]);
|
||||
expect(result.current.activeSessionId).toBe("chat-stream-1");
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Tests for useAgentChatSession are split by behavior boundary.
|
||||
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
|
||||
@@ -4,12 +4,75 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||
import { cloneMessages } from "../globalChatboxUtils";
|
||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||
|
||||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||||
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
||||
|
||||
const sliceCodePoints = (value: string, count: number) =>
|
||||
Array.from(value).slice(0, count).join("");
|
||||
|
||||
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
||||
|
||||
const getSegmenter = () => {
|
||||
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
||||
cachedSegmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter("zh", { granularity: "word" })
|
||||
: null;
|
||||
return cachedSegmenter;
|
||||
};
|
||||
|
||||
const getPlaybackChunkSize = (bufferLength: number) => {
|
||||
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
||||
if (bufferLength >= 300) return 112;
|
||||
if (bufferLength >= 140) return 72;
|
||||
if (bufferLength >= 64) return 44;
|
||||
return TOKEN_PLAYBACK_BASE_CHARS;
|
||||
};
|
||||
|
||||
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
||||
if (content.length <= maxChars) return content;
|
||||
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
||||
|
||||
const segmenter = getSegmenter();
|
||||
if (segmenter) {
|
||||
let chunk = "";
|
||||
for (const segment of segmenter.segment(content)) {
|
||||
chunk += segment.segment;
|
||||
if (
|
||||
chunk.length >= maxChars ||
|
||||
(chunk.length >= targetChars &&
|
||||
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
||||
) {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
||||
if (phrase) return phrase;
|
||||
|
||||
const cjkChunk = content.match(
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
||||
)?.[0];
|
||||
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
||||
|
||||
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
||||
if (wordChunk) {
|
||||
return wordChunk.length <= maxChars
|
||||
? wordChunk
|
||||
: sliceCodePoints(wordChunk, maxChars);
|
||||
}
|
||||
|
||||
return sliceCodePoints(content, Math.min(maxChars, 12));
|
||||
};
|
||||
|
||||
export const useAgentChatSession = ({
|
||||
projectId,
|
||||
onToolCall,
|
||||
@@ -17,7 +80,6 @@ export const useAgentChatSession = ({
|
||||
getModel,
|
||||
getApprovalMode,
|
||||
}: UseAgentChatSessionOptions) => {
|
||||
const hydrationCompletedRef = useRef(false);
|
||||
const hydrationNonceRef = useRef(0);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -35,14 +97,11 @@ export const useAgentChatSession = ({
|
||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const titleUpdateNonceRef = useRef(0);
|
||||
const lastPersistedStateKeyRef = useRef(
|
||||
createPersistedStateKey({
|
||||
sessionId: undefined,
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
}),
|
||||
);
|
||||
const pendingTokenRef = useRef<{
|
||||
assistantMessageId: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId;
|
||||
@@ -52,6 +111,99 @@ export const useAgentChatSession = ({
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
const applyTokenContent = useCallback((assistantMessageId: string, content: string) => {
|
||||
if (!content) return;
|
||||
setMessages((prev) => {
|
||||
const next = prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
content: message.content + content,
|
||||
isError: false,
|
||||
}
|
||||
: message,
|
||||
);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cancelTokenPlayback = useCallback(() => {
|
||||
const intervalId = tokenPlaybackIntervalRef.current;
|
||||
if (intervalId === null) return;
|
||||
window.clearInterval(intervalId);
|
||||
tokenPlaybackIntervalRef.current = null;
|
||||
}, []);
|
||||
|
||||
const flushPendingTokens = useCallback(() => {
|
||||
const pending = pendingTokenRef.current;
|
||||
pendingTokenRef.current = null;
|
||||
cancelTokenPlayback();
|
||||
if (!pending) return;
|
||||
applyTokenContent(pending.assistantMessageId, pending.content);
|
||||
}, [applyTokenContent, cancelTokenPlayback]);
|
||||
|
||||
const scheduleTokenPlayback = useCallback(() => {
|
||||
if (tokenPlaybackIntervalRef.current !== null) return;
|
||||
const id = window.setInterval(() => {
|
||||
const pending = pendingTokenRef.current;
|
||||
if (!pending) {
|
||||
window.clearInterval(id);
|
||||
tokenPlaybackIntervalRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = takeNextTokenPlaybackChunk(
|
||||
pending.content,
|
||||
getPlaybackChunkSize(pending.content.length),
|
||||
);
|
||||
if (!chunk) {
|
||||
window.clearInterval(id);
|
||||
tokenPlaybackIntervalRef.current = null;
|
||||
pendingTokenRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = pending.content.slice(chunk.length);
|
||||
pendingTokenRef.current = remaining
|
||||
? { assistantMessageId: pending.assistantMessageId, content: remaining }
|
||||
: null;
|
||||
applyTokenContent(pending.assistantMessageId, chunk);
|
||||
|
||||
if (!remaining) {
|
||||
window.clearInterval(id);
|
||||
tokenPlaybackIntervalRef.current = null;
|
||||
}
|
||||
}, TOKEN_PLAYBACK_INTERVAL_MS);
|
||||
tokenPlaybackIntervalRef.current = id;
|
||||
}, [applyTokenContent]);
|
||||
|
||||
const queueTokenContent = useCallback(
|
||||
(assistantMessageId: string, content: string) => {
|
||||
const pending = pendingTokenRef.current;
|
||||
if (pending && pending.assistantMessageId !== assistantMessageId) {
|
||||
flushPendingTokens();
|
||||
}
|
||||
pendingTokenRef.current = {
|
||||
assistantMessageId,
|
||||
content:
|
||||
pending?.assistantMessageId === assistantMessageId
|
||||
? pending.content + content
|
||||
: content,
|
||||
};
|
||||
scheduleTokenPlayback();
|
||||
},
|
||||
[flushPendingTokens, scheduleTokenPlayback],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
pendingTokenRef.current = null;
|
||||
cancelTokenPlayback();
|
||||
},
|
||||
[cancelTokenPlayback],
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||
@@ -62,17 +214,9 @@ export const useAgentChatSession = ({
|
||||
|
||||
const hydrate = async () => {
|
||||
setIsHydrating(true);
|
||||
hydrationCompletedRef.current = false;
|
||||
|
||||
if (!projectId) {
|
||||
sessionIdRef.current = undefined;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
});
|
||||
hydrationCompletedRef.current = true;
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
setMessages([]);
|
||||
@@ -93,8 +237,6 @@ export const useAgentChatSession = ({
|
||||
if (cancelled) return;
|
||||
|
||||
sessionIdRef.current = loadedState.sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
||||
hydrationCompletedRef.current = true;
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
|
||||
@@ -127,51 +269,6 @@ export const useAgentChatSession = ({
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
||||
|
||||
const currentHydrationNonce = hydrationNonceRef.current;
|
||||
const persistTimer = window.setTimeout(() => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state: LoadedChatState = {
|
||||
title: sessionTitle,
|
||||
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
||||
messages,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
const currentStateKey = createPersistedStateKey(state);
|
||||
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
void saveActiveChatState(state)
|
||||
.then((sessionId) => {
|
||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||
sessionIdRef.current = sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
...state,
|
||||
sessionId,
|
||||
});
|
||||
return listChatSessions();
|
||||
})
|
||||
.then((sessions) => {
|
||||
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||
setChatSessions(sessions);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||
});
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(persistTimer);
|
||||
};
|
||||
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
||||
|
||||
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
@@ -199,6 +296,10 @@ export const useAgentChatSession = ({
|
||||
assistantMessageId?: string;
|
||||
},
|
||||
) => {
|
||||
if (event.type !== "token") {
|
||||
flushPendingTokens();
|
||||
}
|
||||
|
||||
if (
|
||||
event.type !== "session_title" &&
|
||||
"sessionId" in event &&
|
||||
@@ -226,12 +327,6 @@ export const useAgentChatSession = ({
|
||||
const targetSessionId = event.sessionId || currentSessionId;
|
||||
if (targetSessionId === currentSessionId) {
|
||||
setSessionTitle(nextTitle);
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
sessionId: targetSessionId,
|
||||
title: nextTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: messagesRef.current,
|
||||
});
|
||||
}
|
||||
if (targetSessionId) {
|
||||
const currentNonce = ++titleUpdateNonceRef.current;
|
||||
@@ -257,17 +352,7 @@ export const useAgentChatSession = ({
|
||||
}
|
||||
|
||||
if (event.type === "token") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
content: message.content + event.content,
|
||||
isError: false,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
queueTokenContent(assistantMessageId, event.content);
|
||||
} else if (event.type === "progress") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
@@ -371,9 +456,30 @@ export const useAgentChatSession = ({
|
||||
),
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "auth_required") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
content: message.content || `⚠️ **${event.message}**`,
|
||||
isError: true,
|
||||
progress: completeRunningProgress(message.progress),
|
||||
todos: cancelRunningTodos(message.todos),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[appendArtifact, getLastAssistantMessageId, onToolCall],
|
||||
[
|
||||
appendArtifact,
|
||||
flushPendingTokens,
|
||||
getLastAssistantMessageId,
|
||||
onToolCall,
|
||||
queueTokenContent,
|
||||
],
|
||||
);
|
||||
|
||||
const resumeStreamingSession = useCallback(
|
||||
@@ -389,18 +495,20 @@ export const useAgentChatSession = ({
|
||||
onEvent: (event) => applyStreamEvent(event),
|
||||
})
|
||||
.catch((error) => {
|
||||
flushPendingTokens();
|
||||
if (!controller.signal.aborted) {
|
||||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
flushPendingTokens();
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
[applyStreamEvent],
|
||||
[applyStreamEvent, flushPendingTokens],
|
||||
);
|
||||
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||
|
||||
@@ -449,6 +557,7 @@ export const useAgentChatSession = ({
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
flushPendingTokens();
|
||||
if (controller.signal.aborted) {
|
||||
setMessages((prev) =>
|
||||
prev
|
||||
@@ -485,12 +594,14 @@ export const useAgentChatSession = ({
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} finally {
|
||||
flushPendingTokens();
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
applyStreamEvent,
|
||||
flushPendingTokens,
|
||||
getApprovalMode,
|
||||
getModel,
|
||||
isHydrating,
|
||||
@@ -503,6 +614,7 @@ export const useAgentChatSession = ({
|
||||
const abort = useCallback(() => {
|
||||
const controller = abortRef.current;
|
||||
controller?.abort();
|
||||
flushPendingTokens();
|
||||
setIsStreaming(false);
|
||||
const assistantMessageId = getLastAssistantMessageId();
|
||||
|
||||
@@ -525,7 +637,7 @@ export const useAgentChatSession = ({
|
||||
}
|
||||
});
|
||||
cancelPromiseRef.current = trackedCancelPromise;
|
||||
}, [getLastAssistantMessageId]);
|
||||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||||
|
||||
const replyPermission = useCallback(
|
||||
async (requestId: string, reply: PermissionReply) => {
|
||||
@@ -738,23 +850,18 @@ export const useAgentChatSession = ({
|
||||
const createSession = useCallback(() => {
|
||||
if (isHydrating || isStreaming) return;
|
||||
|
||||
flushPendingTokens();
|
||||
const controller = abortRef.current;
|
||||
controller?.abort();
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = undefined;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
title: "新对话",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
});
|
||||
setMessages([]);
|
||||
setSessionTitle("新对话");
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
setIsStreaming(false);
|
||||
}, [isHydrating, isStreaming]);
|
||||
}, [flushPendingTokens, isHydrating, isStreaming]);
|
||||
|
||||
const switchSession = useCallback(
|
||||
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||
@@ -777,7 +884,6 @@ export const useAgentChatSession = ({
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = nextState.sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
@@ -821,12 +927,6 @@ export const useAgentChatSession = ({
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = undefined;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
});
|
||||
setMessages([]);
|
||||
setSessionTitle(undefined);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
@@ -842,7 +942,6 @@ export const useAgentChatSession = ({
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = nextState.sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
@@ -884,18 +983,12 @@ export const useAgentChatSession = ({
|
||||
if (sessionIdRef.current === targetSessionId) {
|
||||
setSessionTitle(normalizedTitle);
|
||||
setIsSessionTitleManuallyEdited(true);
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
sessionId: targetSessionId,
|
||||
title: normalizedTitle,
|
||||
isTitleManuallyEdited: true,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||
}
|
||||
},
|
||||
[isHydrating, messages],
|
||||
[isHydrating],
|
||||
);
|
||||
|
||||
const createBranch = useCallback(
|
||||
@@ -920,12 +1013,6 @@ export const useAgentChatSession = ({
|
||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||
setSessionTitle(forkTitle);
|
||||
try {
|
||||
await saveActiveChatState({
|
||||
title: forkTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: copiedMessages,
|
||||
sessionId: forkedSessionId,
|
||||
});
|
||||
setChatSessions(await listChatSessions());
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||
|
||||
@@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = {
|
||||
},
|
||||
) => void;
|
||||
onBeforeSend?: () => void;
|
||||
getModel?: () => AgentModel;
|
||||
getModel?: () => AgentModel | undefined;
|
||||
getApprovalMode?: () => AgentApprovalMode;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
findSpeechSelectionStartOffset,
|
||||
splitSpeechTextIntoChunks,
|
||||
} from "./speechStartOptions";
|
||||
|
||||
describe("findSpeechSelectionStartOffset", () => {
|
||||
it("finds the reading start from selected reply text", () => {
|
||||
const text = "第一段内容。\n\n第二段 包含空格。\n第三段内容。";
|
||||
|
||||
expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe(
|
||||
text.indexOf("第二段"),
|
||||
);
|
||||
expect(findSpeechSelectionStartOffset(text, "第三段")).toBe(text.indexOf("第三段"));
|
||||
expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitSpeechTextIntoChunks", () => {
|
||||
it("splits long text into bounded chunks", () => {
|
||||
const text = Array.from({ length: 80 }, (_, index) => `第${index}句内容足够长。`).join("");
|
||||
const chunks = splitSpeechTextIntoChunks(text);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.length <= 520)).toBe(true);
|
||||
expect(chunks.join("")).toBe(text);
|
||||
});
|
||||
|
||||
it("keeps short text as one chunk", () => {
|
||||
expect(splitSpeechTextIntoChunks("短句。")).toEqual(["短句。"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
const MAX_SPEECH_CHUNK_LENGTH = 520;
|
||||
const MIN_SPEECH_CHUNK_LENGTH = 180;
|
||||
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
|
||||
|
||||
const normalizeWithOffsetMap = (value: string) => {
|
||||
let normalized = "";
|
||||
const offsetMap: number[] = [];
|
||||
let isPreviousWhitespace = false;
|
||||
|
||||
Array.from(value).forEach((char, index) => {
|
||||
if (/\s/u.test(char)) {
|
||||
if (!isPreviousWhitespace && normalized.length > 0) {
|
||||
normalized += " ";
|
||||
offsetMap.push(index);
|
||||
}
|
||||
isPreviousWhitespace = true;
|
||||
return;
|
||||
}
|
||||
|
||||
normalized += char;
|
||||
offsetMap.push(index);
|
||||
isPreviousWhitespace = false;
|
||||
});
|
||||
|
||||
return {
|
||||
normalized: normalized.trimEnd(),
|
||||
offsetMap,
|
||||
};
|
||||
};
|
||||
|
||||
export function findSpeechSelectionStartOffset(
|
||||
text: string,
|
||||
selectedText: string,
|
||||
): number | null {
|
||||
const needle = selectedText.trim();
|
||||
if (!needle) return null;
|
||||
|
||||
const exactIndex = text.indexOf(needle);
|
||||
if (exactIndex >= 0) return exactIndex;
|
||||
|
||||
const normalizedNeedle = compactWhitespace(needle);
|
||||
if (!normalizedNeedle) return null;
|
||||
|
||||
const haystack = normalizeWithOffsetMap(text);
|
||||
const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle);
|
||||
if (normalizedIndex < 0) return null;
|
||||
|
||||
return haystack.offsetMap[normalizedIndex] ?? null;
|
||||
}
|
||||
|
||||
export function splitSpeechTextIntoChunks(text: string): string[] {
|
||||
const normalizedText = text.trim();
|
||||
if (!normalizedText) return [];
|
||||
|
||||
const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) =>
|
||||
compactWhitespace(match[0]),
|
||||
).filter(Boolean);
|
||||
const sourceSegments = segments.length > 0 ? segments : [normalizedText];
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = "";
|
||||
|
||||
const flush = () => {
|
||||
if (!currentChunk) return;
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = "";
|
||||
};
|
||||
|
||||
const pushLongSegment = (segment: string) => {
|
||||
for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) {
|
||||
chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH));
|
||||
}
|
||||
};
|
||||
|
||||
sourceSegments.forEach((segment) => {
|
||||
if (segment.length > MAX_SPEECH_CHUNK_LENGTH) {
|
||||
flush();
|
||||
pushLongSegment(segment);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
|
||||
if (
|
||||
currentChunk &&
|
||||
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
|
||||
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
|
||||
) {
|
||||
flush();
|
||||
currentChunk = segment;
|
||||
return;
|
||||
}
|
||||
|
||||
currentChunk = candidate;
|
||||
});
|
||||
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
|
||||
|
||||
describe("parseApplyLayerStylePayload", () => {
|
||||
it("accepts a valid snake_case interval contract", () => {
|
||||
expect(
|
||||
parseApplyLayerStylePayload({
|
||||
layer_id: "pipes",
|
||||
style_config: {
|
||||
property: "velocity",
|
||||
classification_method: "custom_breaks",
|
||||
segments: 3,
|
||||
custom_breaks: [0, 1, 2, 3],
|
||||
color_type: "custom",
|
||||
custom_colors: ["#000000", "#777777", "#ffffff"],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
layerId: "pipes",
|
||||
resetToDefault: false,
|
||||
styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid class counts and array cardinalities", () => {
|
||||
expect(
|
||||
parseApplyLayerStylePayload({
|
||||
layer_id: "pipes",
|
||||
style_config: { segments: 1, property: "velocity" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseApplyLayerStylePayload({
|
||||
layer_id: "junctions",
|
||||
style_config: {
|
||||
segments: 3,
|
||||
custom_breaks: [0, 1, 2],
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps camelCase compatibility", () => {
|
||||
expect(
|
||||
parseApplyLayerStylePayload({
|
||||
layerId: "junctions",
|
||||
styleConfig: { opacity: 0.5, colorType: "gradient" },
|
||||
}),
|
||||
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
|
||||
import type {
|
||||
ClassificationMethod,
|
||||
ColorType,
|
||||
StyleConfig,
|
||||
DefaultLayerStyleId,
|
||||
} from "@components/olmap/core/Controls/styleEditorTypes";
|
||||
|
||||
export type ApplyLayerStyleActionPayload = {
|
||||
layerId: DefaultLayerStyleId;
|
||||
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
|
||||
.filter((item): item is string => item !== undefined)
|
||||
: undefined;
|
||||
|
||||
const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => {
|
||||
const normalized = asString(value);
|
||||
return normalized === "pretty_breaks" || normalized === "custom_breaks"
|
||||
? normalized
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const asColorType = (value: unknown): ColorType | undefined => {
|
||||
const normalized = asString(value);
|
||||
return normalized === "single" ||
|
||||
normalized === "gradient" ||
|
||||
normalized === "rainbow" ||
|
||||
normalized === "custom"
|
||||
? normalized
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
if (normalized === "junctions" || normalized === "pipes") {
|
||||
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
|
||||
? (params.styleConfig as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const classificationValue =
|
||||
rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod;
|
||||
const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType;
|
||||
const segmentsValue = rawStyleConfig?.segments;
|
||||
const segments = asNumber(segmentsValue);
|
||||
if (
|
||||
(classificationValue !== undefined && !asClassificationMethod(classificationValue)) ||
|
||||
(colorTypeValue !== undefined && !asColorType(colorTypeValue)) ||
|
||||
(segmentsValue !== undefined &&
|
||||
(!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
|
||||
? {
|
||||
property: asString(rawStyleConfig.property),
|
||||
classificationMethod: asString(
|
||||
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
||||
),
|
||||
segments: asNumber(rawStyleConfig.segments),
|
||||
classificationMethod: asClassificationMethod(classificationValue),
|
||||
segments,
|
||||
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||
minStrokeWidth: asNumber(
|
||||
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
|
||||
fixedStrokeWidth: asNumber(
|
||||
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||
),
|
||||
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
||||
colorType: asColorType(colorTypeValue),
|
||||
singlePaletteIndex: asNumber(
|
||||
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||
),
|
||||
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (styleConfig) {
|
||||
const numericValues = [
|
||||
styleConfig.minSize,
|
||||
styleConfig.maxSize,
|
||||
styleConfig.minStrokeWidth,
|
||||
styleConfig.maxStrokeWidth,
|
||||
styleConfig.fixedStrokeWidth,
|
||||
].filter((value): value is number => value !== undefined);
|
||||
if (numericValues.some((value) => value <= 0)) return null;
|
||||
const paletteIndexes: Array<[number | undefined, number]> = [
|
||||
[styleConfig.singlePaletteIndex, 7],
|
||||
[styleConfig.gradientPaletteIndex, 3],
|
||||
[styleConfig.rainbowPaletteIndex, 2],
|
||||
];
|
||||
if (
|
||||
paletteIndexes.some(
|
||||
([index, length]) =>
|
||||
index !== undefined &&
|
||||
(!Number.isInteger(index) || index < 0 || index >= length),
|
||||
)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.opacity !== undefined &&
|
||||
(styleConfig.opacity < 0 || styleConfig.opacity > 1)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.customBreaks &&
|
||||
styleConfig.customBreaks.some(
|
||||
(value, index, values) => index > 0 && value <= values[index - 1],
|
||||
)
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.segments !== undefined &&
|
||||
styleConfig.customBreaks &&
|
||||
styleConfig.customBreaks.length !== styleConfig.segments + 1
|
||||
) return null;
|
||||
if (
|
||||
styleConfig.segments !== undefined &&
|
||||
styleConfig.customColors &&
|
||||
styleConfig.customColors.length !== styleConfig.segments
|
||||
) return null;
|
||||
}
|
||||
|
||||
const hasStyleOverrides =
|
||||
styleConfig &&
|
||||
Object.values(styleConfig).some((value) =>
|
||||
|
||||
@@ -27,6 +27,10 @@ import { GlobalChatbox } from "@components/chat/GlobalChatbox";
|
||||
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
|
||||
const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE";
|
||||
const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME";
|
||||
const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT";
|
||||
|
||||
type IUser = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -70,9 +74,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
setMapWorkspace(workspace);
|
||||
setNetworkName(networkName);
|
||||
setMapExtent(extent);
|
||||
localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace);
|
||||
localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName);
|
||||
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(","));
|
||||
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace);
|
||||
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
|
||||
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
|
||||
localStorage.removeItem(`${workspace}_map_view`);
|
||||
setCurrentProjectId(projectId || networkName || workspace);
|
||||
setShowProjectSelector(false);
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import { Box, Skeleton } from "@mui/material";
|
||||
|
||||
import type { MapSkeletonVariant } from "./mapComponentSkeletonConfig";
|
||||
|
||||
const skeletonSx = {
|
||||
transform: "none",
|
||||
bgcolor: "rgba(37, 125, 212, 0.10)",
|
||||
"&::after": {
|
||||
background:
|
||||
"linear-gradient(90deg, transparent, rgba(37, 125, 212, 0.12), transparent)",
|
||||
},
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
animation: "none",
|
||||
"&::after": {
|
||||
animation: "none",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const Line = ({ width = "44%", height = 18 }: { width?: string; height?: number }) => (
|
||||
<Skeleton
|
||||
variant="text"
|
||||
animation="wave"
|
||||
width={width}
|
||||
height={height}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
);
|
||||
|
||||
const Field = () => (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Line width="34%" height={18} />
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={40}
|
||||
sx={{ ...skeletonSx, mt: 0.5, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const TwoColumns = ({ children }: { children: React.ReactNode }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const Action = ({
|
||||
width = "100%",
|
||||
height = 40,
|
||||
testId,
|
||||
}: {
|
||||
width?: string;
|
||||
height?: number;
|
||||
testId?: string;
|
||||
}) => (
|
||||
<Skeleton
|
||||
data-testid={testId}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={width}
|
||||
height={height}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const Notice = ({ testId }: { testId?: string }) => (
|
||||
<Box
|
||||
data-testid={testId}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gap: 0.5,
|
||||
p: 1.5,
|
||||
bgcolor: "rgba(37, 125, 212, 0.07)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Line width="58%" height={18} />
|
||||
<Line width="88%" height={16} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const SelectionHeader = ({ actionWidth = "34%" }: { actionWidth?: string }) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="44%" height={20} />
|
||||
</Box>
|
||||
<Action width={actionWidth} height={32} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const EmptySelection = ({ height = 44 }: { height?: number }) => (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={height}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const Divider = () => (
|
||||
<Box sx={{ height: 1, flex: "0 0 auto", bgcolor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
);
|
||||
|
||||
const AdvancedRow = ({ testId }: { testId?: string }) => (
|
||||
<Box
|
||||
data-testid={testId}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.25,
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(15, 23, 42, 0.10)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="34%" height={18} />
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={24}
|
||||
height={24}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const SelectionList = ({ rows = 3 }: { rows?: number }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gap: 1,
|
||||
p: 1.25,
|
||||
bgcolor: "rgba(15, 23, 42, 0.025)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows }, (_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1 }}
|
||||
>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={26}
|
||||
height={26}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width={`${72 - index * 8}%`} height={16} />
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={48}
|
||||
height={26}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const AnalysisFormSkeleton = ({ variant }: { variant: MapSkeletonVariant }) => {
|
||||
switch (variant) {
|
||||
case "burst-detection":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-detection-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Notice />
|
||||
<Line width="88%" height={16} />
|
||||
<Box sx={{ display: "flex", gap: 1.5, mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "burst-location":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-location-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Notice />
|
||||
<TwoColumns>
|
||||
<Field />
|
||||
<Field />
|
||||
</TwoColumns>
|
||||
<Field />
|
||||
<AdvancedRow />
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "burst-simulation":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="burst-simulation-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
</Box>
|
||||
<Box
|
||||
data-testid="burst-simulation-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<Field />
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "contaminant-simulation":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="contaminant-simulation-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection />
|
||||
</Box>
|
||||
<Box
|
||||
data-testid="contaminant-simulation-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "dma-leak-detection":
|
||||
return (
|
||||
<>
|
||||
<Notice testId="dma-notice-skeleton" />
|
||||
<Box
|
||||
data-testid="dma-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<AdvancedRow testId="dma-advanced-skeleton" />
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action testId="dma-primary-action-skeleton" />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "flushing-analysis":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="flushing-valve-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection height={192} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
data-testid="flushing-node-selection"
|
||||
sx={{ display: "grid", gap: 1 }}
|
||||
>
|
||||
<SelectionHeader actionWidth="32%" />
|
||||
<EmptySelection height={48} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
data-testid="flushing-primary-fields"
|
||||
data-layout="vertical-vertical-two-columns"
|
||||
sx={{ display: "grid", gap: 1.5 }}
|
||||
>
|
||||
<Field />
|
||||
<Field />
|
||||
<TwoColumns>
|
||||
<Field />
|
||||
<Field />
|
||||
</TwoColumns>
|
||||
</Box>
|
||||
<Box sx={{ mt: "auto", pt: 1.5 }}>
|
||||
<Action />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
case "monitoring-place-optimization":
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
data-testid="monitoring-primary-fields"
|
||||
data-layout="vertical"
|
||||
sx={{ display: "grid", gap: 2 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Field key={index} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Action width="132px" />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DeviceListSkeleton = ({ cleaning }: { cleaning: boolean }) => (
|
||||
<Box
|
||||
data-testid={cleaning ? "cleaning-device-content" : "network-device-content"}
|
||||
sx={{ display: "grid", gap: 2 }}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={40}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={38}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Line width="58%" height={16} />
|
||||
</Box>
|
||||
{cleaning && (
|
||||
<Skeleton
|
||||
data-testid="cleaning-action-skeleton"
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ height: 1, bgcolor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
<SelectionList rows={6} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const RiskChartSkeleton = () => (
|
||||
<Box
|
||||
data-testid="health-risk-chart-content"
|
||||
sx={{ display: "grid", height: "100%", minHeight: 360, gap: 2 }}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Line width="32%" height={18} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Action width="22%" />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-around",
|
||||
gap: 2,
|
||||
minHeight: 300,
|
||||
px: 3,
|
||||
pt: 3,
|
||||
pb: 2,
|
||||
bgcolor: "rgba(15, 23, 42, 0.025)",
|
||||
borderRadius: 1,
|
||||
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.10)",
|
||||
}}
|
||||
>
|
||||
{[36, 58, 44, 76, 62, 84].map((height, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width="9%"
|
||||
height={`${height}%`}
|
||||
sx={{ ...skeletonSx, borderRadius: "6px 6px 2px 2px" }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export function MapPageSkeletonContent({
|
||||
variant,
|
||||
}: {
|
||||
variant: MapSkeletonVariant;
|
||||
}) {
|
||||
if (variant === "network-simulation") {
|
||||
return <DeviceListSkeleton cleaning={false} />;
|
||||
}
|
||||
|
||||
if (variant === "scada-data-cleaning") {
|
||||
return <DeviceListSkeleton cleaning />;
|
||||
}
|
||||
|
||||
if (variant === "health-risk-analysis") {
|
||||
return <RiskChartSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid={`${variant}-content`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AnalysisFormSkeleton variant={variant} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapTimelineSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="正在加载时间轴"
|
||||
data-component-skeleton="map-timeline"
|
||||
data-testid="map-timeline-skeleton"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: 16,
|
||||
width: "min(950px, calc(100% - 32px))",
|
||||
transform: "translateX(-50%)",
|
||||
display: "grid",
|
||||
gap: 1.5,
|
||||
p: 2,
|
||||
bgcolor: "rgba(255, 255, 255, 0.96)",
|
||||
borderRadius: 1.5,
|
||||
boxShadow:
|
||||
"0 14px 30px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="circular"
|
||||
animation="wave"
|
||||
width={32}
|
||||
height={32}
|
||||
sx={skeletonSx}
|
||||
/>
|
||||
))}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
width={132}
|
||||
height={32}
|
||||
sx={{ ...skeletonSx, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation="wave"
|
||||
height={8}
|
||||
sx={{ ...skeletonSx, borderRadius: 999 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MapPanelSkeleton,
|
||||
MapTimelineSkeleton,
|
||||
} from "./MapComponentSkeletons";
|
||||
import {
|
||||
MAP_SKELETON_CONFIGS,
|
||||
MAP_SKELETON_VARIANTS,
|
||||
type MapSkeletonVariant,
|
||||
} from "./mapComponentSkeletonConfig";
|
||||
|
||||
describe("map component skeletons", () => {
|
||||
it("keeps the loading state inside the analysis panel geometry", () => {
|
||||
render(<MapPanelSkeleton variant="burst-simulation" />);
|
||||
|
||||
const panel = screen.getByRole("status", {
|
||||
name: "正在加载爆管分析",
|
||||
});
|
||||
|
||||
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
|
||||
expect(panel).toHaveAttribute("data-panel-side", "right");
|
||||
expect(panel).toHaveAttribute("data-panel-width", "520");
|
||||
expect(panel).toHaveStyle({
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
expect(screen.getByTestId("burst-simulation-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an explicit panel configuration for every heavy map component", () => {
|
||||
expect(Object.keys(MAP_SKELETON_CONFIGS).sort()).toEqual(
|
||||
[...MAP_SKELETON_VARIANTS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps known panel chrome out of the skeleton layer", () => {
|
||||
render(<MapPanelSkeleton variant="burst-simulation" />);
|
||||
|
||||
const header = screen.getByTestId("map-panel-header");
|
||||
const tabs = screen.getByTestId("map-panel-tabs");
|
||||
|
||||
expect(header.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
|
||||
expect(tabs.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("正在加载组件")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(tabs).getByText("分析要件"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(MAP_SKELETON_VARIANTS)(
|
||||
"renders the component-specific panel for %s",
|
||||
(variant: MapSkeletonVariant) => {
|
||||
const config = MAP_SKELETON_CONFIGS[variant];
|
||||
|
||||
render(<MapPanelSkeleton variant={variant} />);
|
||||
|
||||
const panel = screen.getByRole("status", {
|
||||
name: `正在加载${config.title}`,
|
||||
});
|
||||
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
|
||||
expect(panel).toHaveAttribute("data-panel-side", config.side);
|
||||
expect(panel).toHaveAttribute(
|
||||
"data-panel-width",
|
||||
String(config.panelWidth),
|
||||
);
|
||||
expect(screen.getByText(config.panelTitle)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("map-panel-tab-label")).toHaveLength(
|
||||
config.tabLabels.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes cleaning controls from the simulation device list", () => {
|
||||
const { rerender } = render(
|
||||
<MapPanelSkeleton variant="network-simulation" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("network-device-content")).toBeInTheDocument();
|
||||
|
||||
rerender(<MapPanelSkeleton variant="scada-data-cleaning" />);
|
||||
|
||||
expect(screen.getByTestId("cleaning-device-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cleaning-action-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the DMA parameter panel's vertical control distribution", () => {
|
||||
render(<MapPanelSkeleton variant="dma-leak-detection" />);
|
||||
|
||||
const fields = screen.getByTestId("dma-primary-fields");
|
||||
|
||||
expect(screen.getByTestId("dma-notice-skeleton")).toBeInTheDocument();
|
||||
expect(fields).toHaveAttribute("data-layout", "vertical");
|
||||
expect(fields.children).toHaveLength(5);
|
||||
expect(screen.getByTestId("dma-advanced-skeleton")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dma-primary-action-skeleton")).toHaveStyle({
|
||||
width: "100%",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["burst-detection", "burst-detection-primary-fields", "vertical", 3],
|
||||
["burst-location", "burst-location-primary-fields", "vertical", 2],
|
||||
["burst-simulation", "burst-simulation-primary-fields", "vertical", 3],
|
||||
[
|
||||
"contaminant-simulation",
|
||||
"contaminant-simulation-primary-fields",
|
||||
"vertical",
|
||||
4,
|
||||
],
|
||||
[
|
||||
"flushing-analysis",
|
||||
"flushing-primary-fields",
|
||||
"vertical-vertical-two-columns",
|
||||
3,
|
||||
],
|
||||
[
|
||||
"monitoring-place-optimization",
|
||||
"monitoring-primary-fields",
|
||||
"vertical",
|
||||
5,
|
||||
],
|
||||
] as const)(
|
||||
"matches the primary control distribution for %s",
|
||||
(variant, testId, layout, childCount) => {
|
||||
render(<MapPanelSkeleton variant={variant} />);
|
||||
|
||||
const fields = screen.getByTestId(testId);
|
||||
|
||||
expect(fields).toHaveAttribute("data-layout", layout);
|
||||
expect(fields.children).toHaveLength(childCount);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the two flushing selection regions separate", () => {
|
||||
render(<MapPanelSkeleton variant="flushing-analysis" />);
|
||||
|
||||
expect(screen.getByTestId("flushing-valve-selection")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("flushing-node-selection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the timeline as an independent loading boundary", () => {
|
||||
render(<MapTimelineSkeleton />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("status", { name: "正在加载时间轴" }),
|
||||
).toHaveAttribute("data-component-skeleton", "map-timeline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Box, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
import { MapPageSkeletonContent } from "./MapComponentSkeletonLayouts";
|
||||
import {
|
||||
MAP_SKELETON_CONFIGS,
|
||||
type MapSkeletonVariant,
|
||||
} from "./mapComponentSkeletonConfig";
|
||||
|
||||
export { MapTimelineSkeleton } from "./MapComponentSkeletonLayouts";
|
||||
|
||||
export interface MapPanelSkeletonProps {
|
||||
variant: MapSkeletonVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地图业务面板的组件级加载占位。
|
||||
* 只占据最终面板区域,不替换地图、工具栏或其他已经完成加载的组件。
|
||||
*/
|
||||
export function MapPanelSkeleton({ variant }: MapPanelSkeletonProps) {
|
||||
const config = MAP_SKELETON_CONFIGS[variant];
|
||||
const horizontalPosition =
|
||||
config.side === "left"
|
||||
? { left: { xs: 12, md: 16 }, right: "auto" }
|
||||
: { right: { xs: 12, md: 16 }, left: "auto" };
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`正在加载${config.title}`}
|
||||
data-component-skeleton="map-panel"
|
||||
data-testid="map-panel-skeleton"
|
||||
data-panel-side={config.side}
|
||||
data-panel-width={config.panelWidth}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: { xs: 12, md: config.panelTop },
|
||||
width: {
|
||||
xs: "calc(100% - 24px)",
|
||||
sm: `min(${Math.min(config.panelWidth, 640)}px, calc(100% - 32px))`,
|
||||
md: config.panelWidth,
|
||||
},
|
||||
height: {
|
||||
xs: "calc(100% - 24px)",
|
||||
md: `min(${config.panelMaxHeight}px, calc(100% - ${config.panelTop + 16}px))`,
|
||||
},
|
||||
zIndex: 1300,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
bgcolor: "rgba(255, 255, 255, 0.97)",
|
||||
borderRadius: 1.5,
|
||||
boxShadow:
|
||||
"0 20px 34px rgba(15, 23, 42, 0.18), 0 4px 10px rgba(15, 23, 42, 0.08)",
|
||||
pointerEvents: "none",
|
||||
...horizontalPosition,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-testid="map-panel-header"
|
||||
sx={{
|
||||
minHeight: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.25,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
bgcolor: "#257DD4",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
lang="zh-CN"
|
||||
sx={{
|
||||
display: "block",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{config.panelTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CircularProgress
|
||||
aria-label={`正在加载${config.title}`}
|
||||
size={20}
|
||||
thickness={4}
|
||||
sx={{ color: "rgba(255, 255, 255, 0.90)" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{config.tabLabels.length > 0 && (
|
||||
<Box
|
||||
data-testid="map-panel-tabs"
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${config.tabLabels.length}, minmax(0, 1fr))`,
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
bgcolor: "#fff",
|
||||
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.08)",
|
||||
}}
|
||||
>
|
||||
{config.tabLabels.map((label, index) => (
|
||||
<Box
|
||||
key={label}
|
||||
data-testid="map-panel-tab-label"
|
||||
sx={{ display: "grid", justifyItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
lang="zh-CN"
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
color: index === 0 ? "#257DD4" : "text.secondary",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{index === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "78%",
|
||||
height: 2,
|
||||
bgcolor: "rgba(37, 125, 212, 0.34)",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
p: variant === "health-risk-analysis" ? 2 : 2.5,
|
||||
bgcolor:
|
||||
variant === "network-simulation" ||
|
||||
variant === "scada-data-cleaning"
|
||||
? "rgba(248, 250, 252, 0.98)"
|
||||
: "#fff",
|
||||
}}
|
||||
>
|
||||
<MapPageSkeletonContent variant={variant} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Box, Skeleton, CircularProgress } from "@mui/material";
|
||||
|
||||
/**
|
||||
* 地图页面骨架屏组件
|
||||
* 提供即时视觉反馈,模拟地图界面布局
|
||||
*/
|
||||
export function MapSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
bgcolor: "background.default",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 主地图区域骨架 */}
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
animation="wave"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 中央加载指示器 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} thickness={4} color="primary" />
|
||||
</Box>
|
||||
|
||||
{/* 左侧工具栏骨架 (垂直) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
left: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="circular"
|
||||
width={40}
|
||||
height={40}
|
||||
animation="wave"
|
||||
sx={{ boxShadow: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 右侧控制面板骨架 (抽屉式) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: { xs: "100%", sm: 360 },
|
||||
height: "100%",
|
||||
bgcolor: "background.paper",
|
||||
borderLeft: 1,
|
||||
borderColor: "divider",
|
||||
p: 3,
|
||||
zIndex: 5,
|
||||
display: { xs: "none", md: "flex" },
|
||||
flexDirection: "column",
|
||||
boxShadow: -2,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
|
||||
|
||||
{/* 面板内容区块 */}
|
||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="circular" width={36} height={36} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="80%" />
|
||||
<Skeleton variant="text" width="50%" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 底部时间轴/控制条骨架 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: { xs: "90%", md: "60%" },
|
||||
height: 64,
|
||||
bgcolor: "background.paper",
|
||||
borderRadius: 4,
|
||||
boxShadow: 3,
|
||||
p: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
<Skeleton variant="rectangular" width="100%" height={8} sx={{ borderRadius: 4 }} />
|
||||
<Skeleton variant="text" width={40} />
|
||||
</Box>
|
||||
|
||||
{/* 缩放控制骨架 (右下) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 110,
|
||||
right: { xs: 20, md: 380 }, // Adjust if drawer is open
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
zIndex: 4,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版骨架屏 - 用于非地图页面
|
||||
*/
|
||||
export function SimpleSkeleton() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
p: 3,
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<Skeleton width="40%" height={40} animation="wave" sx={{ mb: 3 }} />
|
||||
<Skeleton width="100%" height={60} animation="wave" sx={{ mb: 2 }} />
|
||||
<Skeleton width="100%" height={300} animation="wave" sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="30%"
|
||||
height={150}
|
||||
animation="wave"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user