Compare commits
92
Commits
2691f42581
...
agent-mvp
| 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 | ||
|
|
9c0a7a2864 | ||
|
|
ab9e2a0420 | ||
|
|
eee165c812 | ||
|
|
213a01ff7d | ||
|
|
0501afaced | ||
|
|
d80a071987 | ||
|
|
216c7b1ab9 | ||
|
|
7d966a5e91 | ||
|
|
22afdbf2e8 | ||
|
|
ed9828befe | ||
|
|
968d798a2a | ||
|
|
7da0ed0e39 | ||
|
|
166b45e529 | ||
|
|
e5f13c3d46 | ||
|
|
36cdb1df8d | ||
|
|
865e425748 | ||
|
|
3a36c693cd | ||
|
|
b23cb6acdd |
@@ -3,6 +3,8 @@ node_modules
|
|||||||
out
|
out
|
||||||
build
|
build
|
||||||
.git
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
.env*.local
|
.env*.local
|
||||||
README.md
|
README.md
|
||||||
docker-compose.yml
|
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 \
|
docker build \
|
||||||
|
--network=host \
|
||||||
-f ./Dockerfile \
|
-f ./Dockerfile \
|
||||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||||
-t "${IMAGE_NAME}:latest" \
|
-t "${IMAGE_NAME}:latest" \
|
||||||
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
|
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
|
||||||
--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 }}" \
|
|
||||||
.
|
.
|
||||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
push_with_retry "${IMAGE_NAME}:latest"
|
push_with_retry "${IMAGE_NAME}:latest"
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Copilot Instructions for TJWaterFrontend_Refine
|
|
||||||
|
|
||||||
## Environment Setup
|
|
||||||
|
|
||||||
1. **Node.js**: Ensure you have Node.js v18 or later installed.
|
|
||||||
2. **Dependencies**: Run `npm install` to install all project dependencies.
|
|
||||||
3. **Environment Variables**: Create a `.env.local` file in the root directory with
|
|
||||||
|
|
||||||
Using bash setup dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build, Test, and Lint
|
|
||||||
|
|
||||||
- **Dev Server**: `npm run dev` (Runs with increased memory limit: `--max_old_space_size=4096`)
|
|
||||||
- **Build**: `npm run build`
|
|
||||||
- **Lint**: `npm run lint` (ESLint)
|
|
||||||
- **Test**: `npm run test` (Jest)
|
|
||||||
- Run a specific test file: `npm run test -- <path/to/file>`
|
|
||||||
- Run a specific test case: `npm run test -- -t 'test name'`
|
|
||||||
|
|
||||||
## High-Level Architecture
|
|
||||||
|
|
||||||
- **Framework**: **Next.js 16 (App Router)** integrated with **Refine** (`@refinedev/core`).
|
|
||||||
- **Routing**:
|
|
||||||
- Routes are defined in `src/app`.
|
|
||||||
- Refine resources (e.g., `/network-simulation`, `/hydraulic-simulation/*`) map directly to these routes.
|
|
||||||
- Configuration is central in `src/app/_refine_context.tsx`.
|
|
||||||
- **State Management**:
|
|
||||||
- **Global App State**: **Zustand** (`src/store`).
|
|
||||||
- **Server State**: Managed by Refine hooks (`useList`, `useOne`, etc.) via **React Query**.
|
|
||||||
- **Authentication**:
|
|
||||||
- **NextAuth.js** handling Keycloak integration.
|
|
||||||
- Session token is synced to Zustand (`useAuthStore`) in `RefineContext`.
|
|
||||||
- **Data Layer**:
|
|
||||||
- Custom Data Provider: `src/providers/data-provider`.
|
|
||||||
- API Utilities: `src/lib/api.ts`, `src/lib/apiFetch.ts`.
|
|
||||||
- **UI & Styling**:
|
|
||||||
- **Material UI (MUI)**: Primary component library (`@mui/material`, `@refinedev/mui`).
|
|
||||||
- **Tailwind CSS v4**: Utility classes for layout and custom styling (`@tailwindcss/postcss`).
|
|
||||||
- **Mapping**: OpenLayers (`ol`), deck.gl, Turf.js.
|
|
||||||
- **Charts**: ECharts, MUI X Charts.
|
|
||||||
|
|
||||||
## Key Conventions
|
|
||||||
|
|
||||||
- **Refine Integration**:
|
|
||||||
- Use Refine hooks (`useTable`, `useForm`, `useNavigation`) for data-heavy components.
|
|
||||||
- Resources are defined in the `<Refine>` component in `src/app/_refine_context.tsx`.
|
|
||||||
- **Project Structure**:
|
|
||||||
- `src/components/`: Grouped by feature (e.g., `olmap`, `project`) or common UI elements.
|
|
||||||
- `src/lib/`: Utility functions and API helpers.
|
|
||||||
- `src/providers/`: Refine providers (data, etc.).
|
|
||||||
- **Imports**:
|
|
||||||
- Use absolute imports with `@/` alias (e.g., `@/components`, `@/store`, `@/lib`).
|
|
||||||
- _Note_: `@libs` alias in tsconfig points to non-existent `src/libs` folder; prefer `@/lib`.
|
|
||||||
- **Styling**:
|
|
||||||
- Prefer MUI components for standard UI elements.
|
|
||||||
- Use Tailwind utility classes for layout and custom overrides.
|
|
||||||
+6
-2
@@ -19,6 +19,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
/public/runtime-config.js
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -26,8 +27,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -35,3 +37,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
memery.md
|
memery.md
|
||||||
|
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand.
|
||||||
|
|
||||||
|
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use npm and Node 20 or newer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:coverage
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Tests use Jest with React Testing Library. Name tests `*.test.ts` or `*.test.tsx` near the related code when possible. Add tests for user-visible behavior, state transitions, route guards, data transforms, and map/chat interactions. Run `npm test` or `npm run test:coverage` before larger PRs.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses Conventional Commit messages such as `feat(map): add coordinate zoom action` and `fix(chat): hide raw permission metadata`, with occasional Chinese summaries. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
|
||||||
|
|
||||||
|
PRs should include a UI/behavior summary, verification commands, screenshots for visual changes, and notes for changed environment variables or backend API expectations.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit `.env`, `.next/`, `node_modules/`, local caches, or private map/API tokens. Public build-time variables should be documented; sensitive values belong in Gitea secrets.
|
||||||
+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
|
FROM base AS deps
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
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; \
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM base AS builder
|
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 --from=deps /app/refine/node_modules ./node_modules
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -37,19 +28,22 @@ FROM base AS runner
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
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 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=node:node /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/static ./.next/static
|
||||||
|
|
||||||
USER refine
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
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;">
|
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
|
||||||
<a href="https://refine.dev">
|
|
||||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
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.
|
```text
|
||||||
|
src/app/ Next.js App Router 页面
|
||||||
## Available Scripts
|
src/components/ 复用 UI 组件
|
||||||
|
src/providers/ Refine、认证、数据和主题 provider
|
||||||
### Running the development server.
|
src/hooks/ 业务 hooks
|
||||||
|
src/utils/ 通用工具
|
||||||
```bash
|
public/ 静态资源
|
||||||
npm run dev
|
scripts/ 运行时配置和辅助脚本
|
||||||
|
Dockerfile 镜像构建文件
|
||||||
|
docker-compose.yml 本地编排参考
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building for production.
|
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
要求 Node.js 20 或更高版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the production server.
|
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```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)
|
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
|
||||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
|
||||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
|
||||||
|
|
||||||
## 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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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:
|
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_ID: ${KEYCLOAK_CLIENT_ID}
|
||||||
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||||
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
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"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||||
"build": "refine build",
|
"runtime:config": "node scripts/generate-runtime-config.mjs",
|
||||||
"start": "refine start",
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"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"
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -38,11 +40,13 @@
|
|||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
|
"edge-tts-ts": "^1.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -58,12 +62,6 @@
|
|||||||
"fast-xml-parser": "5.5.9"
|
"fast-xml-parser": "5.5.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -80,10 +78,8 @@
|
|||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"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 type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -19,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
|||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const theme = cookieStore.get("theme");
|
const theme = cookieStore.get("theme");
|
||||||
@@ -35,16 +35,20 @@ export default async function MainLayout({
|
|||||||
<ThemedLayout
|
<ThemedLayout
|
||||||
Header={Header}
|
Header={Header}
|
||||||
Title={Title}
|
Title={Title}
|
||||||
|
Sider={AppSider}
|
||||||
childrenBoxProps={{
|
childrenBoxProps={{
|
||||||
sx: { height: "100vh", p: 0 },
|
sx: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
p: 0,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
containerBoxProps={{
|
containerBoxProps={{
|
||||||
sx: { height: "100%" },
|
sx: { height: "100vh", overflow: "hidden" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
|
||||||
{children}
|
{children}
|
||||||
</Suspense>
|
|
||||||
</ThemedLayout>
|
</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;
|
refresh_token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!;
|
const getKeycloakTokenEndpoint = () => {
|
||||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!;
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||||
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!;
|
return issuer
|
||||||
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||||
if (!token.refreshToken) {
|
if (!token.refreshToken) {
|
||||||
return { ...token, error: "RefreshAccessTokenError" };
|
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({
|
const body = new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
client_id: keycloakClientId,
|
client_id: keycloakClientId,
|
||||||
@@ -50,12 +60,13 @@ const authOptions: NextAuthOptions = {
|
|||||||
// Configure one or more authentication providers
|
// Configure one or more authentication providers
|
||||||
providers: [
|
providers: [
|
||||||
KeycloakProvider({
|
KeycloakProvider({
|
||||||
clientId: keycloakClientId,
|
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
||||||
clientSecret: keycloakClientSecret,
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
||||||
issuer: keycloakIssuer,
|
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
|
username: profile.preferred_username,
|
||||||
name: profile.name ?? profile.preferred_username,
|
name: profile.name ?? profile.preferred_username,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: Avatar.src,
|
image: Avatar.src,
|
||||||
@@ -69,6 +80,12 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (profile?.sub) {
|
if (profile?.sub) {
|
||||||
token.sub = 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) {
|
||||||
if (account.access_token) {
|
if (account.access_token) {
|
||||||
@@ -94,6 +111,9 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (session.user && token.sub) {
|
if (session.user && token.sub) {
|
||||||
session.user.id = token.sub;
|
session.user.id = token.sub;
|
||||||
}
|
}
|
||||||
|
if (session.user && token.username) {
|
||||||
|
session.user.username = token.username;
|
||||||
|
}
|
||||||
if (token.accessToken) {
|
if (token.accessToken) {
|
||||||
session.accessToken = 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 type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import Script from "next/script";
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
import { RefineContext } from "./_refine_context";
|
import { RefineContext } from "./RefineContext";
|
||||||
import { META_DATA } from "@config/config";
|
import { META_DATA } from "@config/config";
|
||||||
|
|
||||||
export const metadata: Metadata = META_DATA;
|
export const metadata: Metadata = META_DATA;
|
||||||
@@ -16,8 +17,9 @@ export default async function RootLayout({
|
|||||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body>
|
<body>
|
||||||
|
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
||||||
</Suspense>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+234
-29
@@ -1,54 +1,259 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
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 Typography from "@mui/material/Typography";
|
||||||
import { useLogin } from "@refinedev/core";
|
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() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
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 (
|
return (
|
||||||
<Container
|
<Box
|
||||||
style={{
|
component="main"
|
||||||
height: "100vh",
|
sx={{
|
||||||
display: "flex",
|
minHeight: "100vh",
|
||||||
justifyContent: "center",
|
"@supports (height: 100svh)": {
|
||||||
alignItems: "center",
|
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
|
<Box
|
||||||
display="flex"
|
sx={{
|
||||||
gap="36px"
|
width: "100%",
|
||||||
justifyContent="center"
|
maxWidth: 1720,
|
||||||
flexDirection="column"
|
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">
|
<Stack
|
||||||
<Title collapsed={false} />
|
spacing={{ xs: 0, md: 3 }}
|
||||||
</Box>
|
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: '""',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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
|
<Button
|
||||||
style={{ width: "240px" }}
|
fullWidth
|
||||||
size="large"
|
size="large"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => login({})}
|
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)" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Sign in
|
继续登录
|
||||||
</Button>
|
</Button>
|
||||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
) : null}
|
||||||
Powered by
|
|
||||||
<Image
|
<Typography
|
||||||
style={{ padding: "0 5px" }}
|
sx={{
|
||||||
alt="Keycloak"
|
color: "oklch(0.52 0.035 215)",
|
||||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
fontSize: { xs: 12, sm: 13 },
|
||||||
width={18}
|
lineHeight: 1.7,
|
||||||
height={18}
|
textAlign: "center",
|
||||||
/>
|
}}
|
||||||
Keycloak
|
>
|
||||||
|
账号与访问权限由统一身份认证服务管理
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
|||||||
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
|
||||||
import { ChatInlineChart } from "./ChatInlineChart";
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
import type { ChatChartSeries } from "./ChatInlineChart";
|
|
||||||
import type { AgentArtifact } from "./GlobalChatbox.types";
|
import type { AgentArtifact } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
||||||
@@ -61,8 +60,13 @@ export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }
|
|||||||
chart_type={
|
chart_type={
|
||||||
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
}
|
}
|
||||||
x_data={(artifact.params.x_data as string[]) ?? []}
|
x_data={
|
||||||
series={(artifact.params.series as ChatChartSeries[]) ?? []}
|
artifact.params.x_data ??
|
||||||
|
artifact.params.xData ??
|
||||||
|
artifact.params.labels ??
|
||||||
|
artifact.params.categories
|
||||||
|
}
|
||||||
|
series={artifact.params.series}
|
||||||
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 MicRounded from "@mui/icons-material/MicRounded";
|
||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
|
||||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||||
|
import type { AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
export type AgentComposerHandle = {
|
export type AgentComposerHandle = {
|
||||||
@@ -48,12 +48,23 @@ type AgentComposerProps = {
|
|||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onStartListening: () => void;
|
onStartListening: () => void;
|
||||||
onStopListening: () => void;
|
onStopListening: () => void;
|
||||||
selectedModel: AgentModel;
|
modelOptions: AgentModelOption[];
|
||||||
|
selectedModel?: AgentModel;
|
||||||
onModelChange: (model: AgentModel) => void;
|
onModelChange: (model: AgentModel) => void;
|
||||||
approvalMode: AgentApprovalMode;
|
approvalMode: AgentApprovalMode;
|
||||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
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({
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -64,6 +75,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
onAbort,
|
onAbort,
|
||||||
onStartListening,
|
onStartListening,
|
||||||
onStopListening,
|
onStopListening,
|
||||||
|
modelOptions,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
approvalMode,
|
approvalMode,
|
||||||
@@ -74,6 +86,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
const [input, setInput] = React.useState("");
|
const [input, setInput] = React.useState("");
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
|
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -111,15 +124,9 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Image
|
<AutoAwesomeRounded
|
||||||
src="/ai-agent.svg"
|
titleAccess="快捷指令图标"
|
||||||
alt="TJWater Agent"
|
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
style={{
|
|
||||||
objectFit: "contain",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
管网分析快捷指令
|
管网分析快捷指令
|
||||||
@@ -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" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<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 }}>
|
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||||
<Select
|
<Select
|
||||||
value={approvalMode}
|
value={approvalMode}
|
||||||
@@ -347,19 +319,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
<Select
|
<Select
|
||||||
value={selectedModel}
|
value={selectedModel ?? ""}
|
||||||
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming || modelOptions.length === 0}
|
||||||
aria-label="模型选择"
|
aria-label="模型选择"
|
||||||
renderValue={(val) => (
|
renderValue={() => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{val === "deepseek/deepseek-v4-flash" ? (
|
{renderModelIcon(selectedModelOption?.icon, {
|
||||||
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
sx: {
|
||||||
) : (
|
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
|
||||||
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
color: "inherit",
|
||||||
)}
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
})}
|
||||||
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, 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>
|
</Typography>
|
||||||
</Box>
|
</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 sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||||
<Box
|
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
|
||||||
component="img"
|
|
||||||
src="/deepseek-logo.svg"
|
|
||||||
alt="DeepSeek"
|
|
||||||
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||||
DEEPSEEK V4
|
模型选择
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<MenuItem value="deepseek/deepseek-v4-flash">
|
{modelOptions.map((model) => (
|
||||||
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
|
<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>
|
<Box>
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
{model.description ? (
|
||||||
</Box>
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||||
</MenuItem>
|
) : null}
|
||||||
<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>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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">
|
<AnimatePresence mode="wait">
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
|
|||||||
@@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) =>
|
|||||||
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
describe("AgentHistoryPanel", () => {
|
describe("AgentHistoryPanel", () => {
|
||||||
|
it("shows skeleton rows while history sessions are loading", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[]}
|
||||||
|
isLoadingSessions
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={jest.fn()}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the loading history session item", () => {
|
||||||
|
const onSelectSession = jest.fn();
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
const onDeleteSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-loading",
|
||||||
|
title: "正在加载的会话",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
loadingSessionId="session-loading"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话"));
|
||||||
|
|
||||||
|
expect(onSelectSession).not.toHaveBeenCalled();
|
||||||
|
expect(onRenameSession).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renames a history session from the list", () => {
|
it("renames a history session from the list", () => {
|
||||||
const onRenameSession = jest.fn();
|
const onRenameSession = jest.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -34,9 +35,11 @@ type AgentHistoryPanelProps = {
|
|||||||
sessions: ChatSessionSummary[];
|
sessions: ChatSessionSummary[];
|
||||||
activeSessionId?: string;
|
activeSessionId?: string;
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
|
isLoadingSessions?: boolean;
|
||||||
|
loadingSessionId?: string;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onRenameSession: (sessionId: string, title: string) => void;
|
onRenameSession: (sessionId: string, title: string) => void;
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string, title: string) => void;
|
||||||
onDeleteSession: (sessionId: string) => void;
|
onDeleteSession: (sessionId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({
|
|||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
|
isLoadingSessions = false,
|
||||||
|
loadingSessionId,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onRenameSession,
|
onRenameSession,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
@@ -127,6 +132,30 @@ export const AgentHistoryPanel = ({
|
|||||||
(session) => session.id === pendingDeleteSessionId,
|
(session) => session.id === pendingDeleteSessionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderSessionListSkeleton = () => (
|
||||||
|
<Stack spacing={1} aria-label="正在加载历史会话">
|
||||||
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
|
<Paper
|
||||||
|
key={index}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.48),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.68)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} />
|
||||||
|
<Skeleton variant="text" width="32%" height={14} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
const handleStartRename = (sessionId: string, title: string) => {
|
const handleStartRename = (sessionId: string, title: string) => {
|
||||||
setEditingSessionId(sessionId);
|
setEditingSessionId(sessionId);
|
||||||
setDraftTitle(title);
|
setDraftTitle(title);
|
||||||
@@ -215,7 +244,9 @@ export const AgentHistoryPanel = ({
|
|||||||
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
||||||
{sessions.length === 0 ? (
|
{isLoadingSessions ? (
|
||||||
|
renderSessionListSkeleton()
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
<Stack
|
<Stack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
@@ -271,27 +302,42 @@ export const AgentHistoryPanel = ({
|
|||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{groupSessions.map((session) => {
|
{groupSessions.map((session) => {
|
||||||
const isActive = session.id === activeSessionId;
|
const isActive = session.id === activeSessionId;
|
||||||
|
const isLoading = session.id === loadingSessionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
key={session.id}
|
key={session.id}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
|
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editingSessionId === session.id) return;
|
if (editingSessionId === session.id || isLoading) return;
|
||||||
onSelectSession(session.id);
|
onSelectSession(session.id, session.title);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.25,
|
px: 1.25,
|
||||||
py: 1,
|
py: 1,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
cursor: isHydrating ? "default" : "pointer",
|
cursor: isHydrating || isLoading ? "default" : "pointer",
|
||||||
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
|
bgcolor:
|
||||||
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
|
isActive || isLoading
|
||||||
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
|
? alpha("#00acc1", 0.12)
|
||||||
|
: alpha("#fff", 0.56),
|
||||||
|
border: `1px solid ${
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.25)
|
||||||
|
: alpha("#fff", 0.72)
|
||||||
|
}`,
|
||||||
|
boxShadow:
|
||||||
|
isActive || isLoading
|
||||||
|
? `0 8px 20px ${alpha("#00acc1", 0.12)}`
|
||||||
|
: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
pointerEvents: isHydrating ? "none" : "auto",
|
pointerEvents: isHydrating || isLoading ? "none" : "auto",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
|
bgcolor:
|
||||||
|
isActive || isLoading
|
||||||
|
? alpha("#00acc1", 0.14)
|
||||||
|
: alpha("#fff", 0.86),
|
||||||
borderColor: alpha("#00acc1", 0.2),
|
borderColor: alpha("#00acc1", 0.2),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -382,6 +428,11 @@ export const AgentHistoryPanel = ({
|
|||||||
<CloseRounded sx={{ fontSize: 16 }} />
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : isLoading ? (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
<Skeleton variant="text" width="74%" height={18} />
|
||||||
|
<Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
) : pendingDeleteSessionId === session.id ? (
|
) : pendingDeleteSessionId === session.id ? (
|
||||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
<Box
|
<Box
|
||||||
@@ -437,7 +488,7 @@ export const AgentHistoryPanel = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
|
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
|
||||||
<Stack direction="row" spacing={0.25}>
|
<Stack direction="row" spacing={0.25}>
|
||||||
<Tooltip title="修改会话标题">
|
<Tooltip title="修改会话标题">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
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, "");
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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
|
||||||
|
components={markdownComponents}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||||
|
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
|
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
|
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||||
|
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||||
|
if (permission.permission === "bash") return "执行终端命令";
|
||||||
|
if (permission.permission === "edit") return "修改文件内容";
|
||||||
|
return permission.permission || "工具权限请求";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionPrimaryValue = (
|
||||||
|
permission: NonNullable<Message["permissions"]>[number],
|
||||||
|
) => {
|
||||||
|
if (typeof permission.target === "string" && permission.target.trim()) {
|
||||||
|
return permission.target.trim();
|
||||||
|
}
|
||||||
|
return permission.patterns[0] ?? permission.permission;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionIcon = ({
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
}) => {
|
||||||
|
if (permission.permission === "bash") {
|
||||||
|
return <TerminalRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
if (permission.permission === "external_directory") {
|
||||||
|
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||||
|
if (status === "approved_always") return "已始终允许";
|
||||||
|
if (status === "approved_once") return "已允许一次";
|
||||||
|
if (status === "rejected") return "已拒绝";
|
||||||
|
if (status === "aborted") return "已中断";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待确认";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingPermissionColor = "#f9a825";
|
||||||
|
const approvedOncePermissionColor = "#00838f";
|
||||||
|
|
||||||
|
const getPermissionStatusColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return approvedOncePermissionColor;
|
||||||
|
if (status === "approved_always") return theme.palette.success.main;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return pendingPermissionColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusTextColor = (
|
||||||
|
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "approved_once") return "#006c78";
|
||||||
|
if (status === "approved_always") return theme.palette.success.dark;
|
||||||
|
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||||
|
if (status === "aborted") return theme.palette.text.secondary;
|
||||||
|
return "#8a5a00";
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionRequestCard = ({
|
||||||
|
permission,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isPending =
|
||||||
|
isRunning && (permission.status === "pending" || permission.status === "error");
|
||||||
|
const isSubmitting = isRunning && permission.status === "submitting";
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: "10px auto 10px 0",
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 999px 999px 0",
|
||||||
|
bgcolor: accentColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
pl: 1.75,
|
||||||
|
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: accentColor,
|
||||||
|
bgcolor: alpha(accentColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={statusLabel}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: alpha(accentColor, 0.12),
|
||||||
|
color: statusTextColor,
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#000", 0.025),
|
||||||
|
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
请求目标
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||||
|
sx={{
|
||||||
|
mt: 0.25,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{permission.error ? (
|
||||||
|
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permission.error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "once")}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
minWidth: 94,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
bgcolor: "#00838f",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#006c78",
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
允许一次
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "always")}
|
||||||
|
startIcon={<PushPinRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "#00838f",
|
||||||
|
borderColor: alpha("#00838f", 0.24),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha("#00838f", 0.36),
|
||||||
|
bgcolor: alpha("#00838f", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
始终允许
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "reject")}
|
||||||
|
startIcon={<BlockRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionRequestGroup = ({
|
||||||
|
permissions,
|
||||||
|
isRunning,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permissions: NonNullable<Message["permissions"]>;
|
||||||
|
isRunning: boolean;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||||
|
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
|
||||||
|
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
|
||||||
|
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
|
||||||
|
const pendingCount = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
).length;
|
||||||
|
const hasPendingPermissions = pendingCount > 0;
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const latestPermissions = permissions.slice(-3);
|
||||||
|
const pendingPermissions = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
);
|
||||||
|
const summaryItems = [
|
||||||
|
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||||
|
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
||||||
|
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
||||||
|
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||||||
|
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||||
|
];
|
||||||
|
const chipColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusColor("rejected", theme)
|
||||||
|
: getPermissionStatusColor("approved_always", theme);
|
||||||
|
const chipTextColor =
|
||||||
|
pendingCount > 0
|
||||||
|
? getPermissionStatusTextColor("pending", theme)
|
||||||
|
: abortedCount > 0
|
||||||
|
? getPermissionStatusTextColor("aborted", theme)
|
||||||
|
: rejectedCount > 0
|
||||||
|
? getPermissionStatusTextColor("rejected", theme)
|
||||||
|
: getPermissionStatusTextColor("approved_always", theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.46),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.025) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: chipColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(chipColor, 0.15)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerifiedUserRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
权限请求
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={0.6}
|
||||||
|
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.45,
|
||||||
|
height: 22,
|
||||||
|
px: 0.8,
|
||||||
|
borderRadius: "11px",
|
||||||
|
bgcolor: alpha(item.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(item.color, 0.12)}`,
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: "textColor" in item ? item.textColor : item.color,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
<Box component="span">{item.value} 项</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{isRunning && pendingCount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`待确认 ${pendingCount} 项`}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: chipTextColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
|
||||||
|
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
|
||||||
|
{latestPermissions.map((permission, index) => {
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const isLast = index === latestPermissions.length - 1;
|
||||||
|
const itemColor = getPermissionStatusColor(permission.status, theme);
|
||||||
|
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={permission.requestId}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
|
||||||
|
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: itemColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
title={primaryValue}
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getPermissionStatusLabel(permission.status)}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: itemTextColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.85 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!expanded && isRunning && hasPendingPermissions ? (
|
||||||
|
<motion.div
|
||||||
|
key="pending-permissions"
|
||||||
|
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{pendingPermissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
FormControlLabel,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
|
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const getQuestionStatusLabel = (
|
||||||
|
status: NonNullable<Message["questions"]>[number]["status"],
|
||||||
|
) => {
|
||||||
|
if (status === "answered") return "已回答";
|
||||||
|
if (status === "rejected") return "已跳过";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待回答";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestionStatusColor = (
|
||||||
|
status: NonNullable<Message["questions"]>[number]["status"],
|
||||||
|
theme: Theme,
|
||||||
|
) => {
|
||||||
|
if (status === "answered") return theme.palette.success.main;
|
||||||
|
if (status === "rejected") return theme.palette.text.secondary;
|
||||||
|
if (status === "error") return theme.palette.error.main;
|
||||||
|
return "#0288d1";
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuestionRequestCard = ({
|
||||||
|
questionRequest,
|
||||||
|
onReply,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
questionRequest: NonNullable<Message["questions"]>[number];
|
||||||
|
onReply: (requestId: string, answers: string[][]) => void;
|
||||||
|
onReject: (requestId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isEditable =
|
||||||
|
questionRequest.status === "pending" || questionRequest.status === "error";
|
||||||
|
const isSubmitting = questionRequest.status === "submitting";
|
||||||
|
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
||||||
|
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
|
||||||
|
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
|
||||||
|
const [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const answers = React.useMemo(
|
||||||
|
() =>
|
||||||
|
questionRequest.questions.map((question, index) => {
|
||||||
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(question.custom !== false && question.options.length === 0);
|
||||||
|
const customAnswer = custom[index]?.trim();
|
||||||
|
return isCustomSelected && customAnswer
|
||||||
|
? [...selectedAnswers, customAnswer]
|
||||||
|
: selectedAnswers;
|
||||||
|
}),
|
||||||
|
[custom, customSelected, questionRequest.questions, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
isEditable &&
|
||||||
|
questionRequest.questions.length > 0 &&
|
||||||
|
questionRequest.questions.every((_, index) => {
|
||||||
|
const answer = answers[index] ?? [];
|
||||||
|
return answer.some((item) => item.trim().length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerSummary = (questionRequest.answers ?? [])
|
||||||
|
.map((answer) => answer.join("、"))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.52),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: "10px auto 10px 0",
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 999px 999px 0",
|
||||||
|
bgcolor: statusColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
pl: 1.75,
|
||||||
|
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: statusColor,
|
||||||
|
bgcolor: alpha(statusColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(statusColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpOutlineRounded sx={{ fontSize: 21 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
需要补充信息
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getQuestionStatusLabel(questionRequest.status)}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor: alpha(statusColor, 0.12),
|
||||||
|
color: statusColor,
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
||||||
|
{questionRequest.questions.map((question, index) => {
|
||||||
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomEnabled = question.custom !== false;
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(isCustomEnabled && question.options.length === 0);
|
||||||
|
const setQuestionAnswers = (nextAnswers: string[]) => {
|
||||||
|
setSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: nextAnswers,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const setQuestionCustomSelected = (checked: boolean) => {
|
||||||
|
setCustomSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: checked,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${question.header}-${index}`}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#000", 0.025),
|
||||||
|
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
{question.header || `问题 ${index + 1}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{question.question}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{question.options.length ? (
|
||||||
|
<Stack spacing={0.75} sx={{ mt: 1 }}>
|
||||||
|
{question.options.map((option) => {
|
||||||
|
const checked = selectedAnswers.includes(option.label);
|
||||||
|
if (question.multiple) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={option.label}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
setQuestionAnswers([...selectedAnswers, option.label]);
|
||||||
|
} else {
|
||||||
|
setQuestionAnswers(
|
||||||
|
selectedAnswers.filter((item) => item !== option.label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={750}>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
{option.description ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{option.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ alignItems: "flex-start", m: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.label}
|
||||||
|
size="small"
|
||||||
|
variant={checked ? "contained" : "outlined"}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
onClick={() => {
|
||||||
|
setQuestionAnswers([option.label]);
|
||||||
|
setQuestionCustomSelected(false);
|
||||||
|
}}
|
||||||
|
startIcon={
|
||||||
|
checked ? (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<RadioButtonUncheckedRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
|
||||||
|
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
{option.description ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isCustomEnabled ? (
|
||||||
|
question.multiple ? (
|
||||||
|
<FormControlLabel
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={isCustomSelected}
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuestionCustomSelected(event.target.checked)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
color: alpha("#0288d1", 0.55),
|
||||||
|
"&.Mui-checked": { color: "#0288d1" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
|
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
自定义回答
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 38,
|
||||||
|
m: 0,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${
|
||||||
|
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
|
||||||
|
}`,
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? alpha("#0288d1", 0.1)
|
||||||
|
: alpha("#fff", 0.45),
|
||||||
|
transition: "background-color 0.18s ease, border-color 0.18s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? alpha("#0288d1", 0.13)
|
||||||
|
: alpha("#0288d1", 0.07),
|
||||||
|
},
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
color: isCustomSelected ? "#0277bd" : "text.primary",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={isCustomSelected ? "contained" : "outlined"}
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
onClick={() => {
|
||||||
|
setQuestionAnswers([]);
|
||||||
|
setQuestionCustomSelected(true);
|
||||||
|
}}
|
||||||
|
startIcon={
|
||||||
|
isCustomSelected ? (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<EditNoteRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
|
||||||
|
borderColor: isCustomSelected
|
||||||
|
? "#0288d1"
|
||||||
|
: alpha("#0288d1", 0.22),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isCustomSelected
|
||||||
|
? "#0277bd"
|
||||||
|
: alpha("#0288d1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={800}>
|
||||||
|
自定义回答
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 0.85,
|
||||||
|
px: 1.15,
|
||||||
|
py: 0.85,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha("#fff", 0.62),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.82)}`,
|
||||||
|
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
|
||||||
|
backdropFilter: "blur(18px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={5}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
value={custom[index] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCustom((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="输入自定义回答"
|
||||||
|
InputProps={{
|
||||||
|
disableUnderline: true,
|
||||||
|
sx: {
|
||||||
|
alignItems: "flex-start",
|
||||||
|
fontSize: "0.88rem",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "text.primary",
|
||||||
|
"& textarea::placeholder": {
|
||||||
|
color: alpha(theme.palette.text.primary, 0.38),
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{questionRequest.status === "answered" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="success.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.07),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已回答{answerSummary ? `:${answerSummary}` : ""}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{questionRequest.status === "rejected" ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已跳过
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{questionRequest.error ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{questionRequest.error}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isEditable || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReject(questionRequest.requestId)}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "text.secondary",
|
||||||
|
borderColor: alpha(theme.palette.text.secondary, 0.22),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
跳过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={() => onReply(questionRequest.requestId, answers)}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
minWidth: 104,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
bgcolor: "#0288d1",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#0277bd",
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
提交回答
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionRequestGroup = ({
|
||||||
|
questions,
|
||||||
|
onReply,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
questions: NonNullable<Message["questions"]>;
|
||||||
|
onReply: (requestId: string, answers: string[][]) => void;
|
||||||
|
onReject: (requestId: string) => void;
|
||||||
|
}) => (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{questions.map((question) => (
|
||||||
|
<QuestionRequestCard
|
||||||
|
key={question.requestId}
|
||||||
|
questionRequest={question}
|
||||||
|
onReply={onReply}
|
||||||
|
onReject={onReject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
export const TodoPlanCard = ({
|
||||||
|
todoUpdate,
|
||||||
|
}: {
|
||||||
|
todoUpdate: NonNullable<Message["todos"]>;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const total = todoUpdate.todos.length;
|
||||||
|
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||||
|
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
||||||
|
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||||
|
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
||||||
|
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
const isAborted = cancelled > 0 && completed + cancelled === total;
|
||||||
|
const canCollapse = total > 4;
|
||||||
|
const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted);
|
||||||
|
const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos;
|
||||||
|
const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : [];
|
||||||
|
const hiddenCount = expanded ? 0 : collapsibleTodos.length;
|
||||||
|
const latestUpdatedAt = Math.max(
|
||||||
|
todoUpdate.createdAt,
|
||||||
|
...todoUpdate.todos
|
||||||
|
.map((todo) => todo.updatedAt ?? todo.createdAt ?? 0)
|
||||||
|
.filter((value) => value > 0),
|
||||||
|
);
|
||||||
|
const updatedAtLabel =
|
||||||
|
latestUpdatedAt > 0
|
||||||
|
? new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(latestUpdatedAt))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
|
||||||
|
}
|
||||||
|
if (status === "in_progress") {
|
||||||
|
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
|
||||||
|
}
|
||||||
|
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => {
|
||||||
|
if (priority === "high") return { label: "高优先级", color: "#8a5a00" };
|
||||||
|
if (priority === "medium") return { label: "中优先级", color: "#9a6a16" };
|
||||||
|
if (priority === "low") return { label: "低优先级", color: "#8d7960" };
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusSummary = isAborted
|
||||||
|
? `${completed} 完成 / ${cancelled} 中止`
|
||||||
|
: [
|
||||||
|
completed ? `${completed} 完成` : null,
|
||||||
|
running ? "1 进行中" : null,
|
||||||
|
pending ? `${pending} 待办` : null,
|
||||||
|
cancelled ? `${cancelled} 中止` : null,
|
||||||
|
].filter(Boolean).join(" / ") || "等待任务";
|
||||||
|
const renderTodoRow = (
|
||||||
|
todo: NonNullable<Message["todos"]>["todos"][number],
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const visual = getTodoVisual(todo.status);
|
||||||
|
const priority = getPriorityLabel(todo.priority);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={`${todo.id}-${index}`}
|
||||||
|
direction="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||||
|
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 1.25,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
mt: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visual.icon}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: 1.45,
|
||||||
|
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
|
||||||
|
{priority ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={priority.label}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: priority.color,
|
||||||
|
bgcolor: alpha(priority.color, 0.045),
|
||||||
|
border: `1px solid ${alpha(priority.color, 0.16)}`,
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={visual.label}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#00838f", 0.16)}`,
|
||||||
|
bgcolor: alpha("#f8fbfc", 0.82),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (canCollapse) {
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (canCollapse && (event.key === "Enter" || event.key === " ")) {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.4,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: canCollapse ? "pointer" : "default",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "#00838f",
|
||||||
|
bgcolor: alpha("#00838f", 0.1),
|
||||||
|
border: `1px solid ${alpha("#00838f", 0.14)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.75}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
会话任务
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "10px",
|
||||||
|
fontSize: "0.66rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
||||||
|
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{canCollapse ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
bgcolor: alpha("#00838f", 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
|
||||||
|
transition: "width 0.25s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
|
||||||
|
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
|
||||||
|
{canCollapse ? (
|
||||||
|
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
|
||||||
|
<Stack spacing={0}>
|
||||||
|
{collapsibleTodos.map((todo, index) =>
|
||||||
|
renderTodoRow(todo, index + pinnedTodos.length),
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
) : null}
|
||||||
|
{hiddenCount > 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
pt: 0.8,
|
||||||
|
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
还有 {hiddenCount} 项,展开查看全部
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+377
-654
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
import type { Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
const renderCounts = new Map<string, number>();
|
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", () => ({
|
jest.mock("next/image", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
@@ -16,17 +19,31 @@ jest.mock("next/image", () => ({
|
|||||||
jest.mock("framer-motion", () => ({
|
jest.mock("framer-motion", () => ({
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
motion: {
|
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", () => ({
|
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);
|
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>;
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -41,13 +58,31 @@ describe("AgentWorkspace", () => {
|
|||||||
onResumeSpeech: jest.fn(),
|
onResumeSpeech: jest.fn(),
|
||||||
onStopSpeech: jest.fn(),
|
onStopSpeech: jest.fn(),
|
||||||
isTtsSupported: false,
|
isTtsSupported: false,
|
||||||
onRegenerate: jest.fn(),
|
|
||||||
onCreateBranch: jest.fn(),
|
onCreateBranch: jest.fn(),
|
||||||
onReplyPermission: jest.fn(),
|
onReplyPermission: jest.fn(),
|
||||||
|
onReplyQuestion: jest.fn(),
|
||||||
|
onRejectQuestion: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
renderCounts.clear();
|
renderCounts.clear();
|
||||||
|
mountCounts.clear();
|
||||||
|
unmountCounts.clear();
|
||||||
|
streamingFlags.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
isLoadingSession
|
||||||
|
messages={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
@@ -91,5 +126,42 @@ describe("AgentWorkspace", () => {
|
|||||||
expect(renderCounts.get("user-1")).toBe(1);
|
expect(renderCounts.get("user-1")).toBe(1);
|
||||||
expect(renderCounts.get("assistant-1")).toBe(1);
|
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||||
expect(renderCounts.get("assistant-2")).toBe(2);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
||||||
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
||||||
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||||
import MapRounded from "@mui/icons-material/MapRounded";
|
import MapRounded from "@mui/icons-material/MapRounded";
|
||||||
|
|
||||||
import { AgentTurn } from "./AgentTurn";
|
import { AgentTurn } from "./AgentTurn";
|
||||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
@@ -20,39 +19,61 @@ import type {
|
|||||||
type AgentWorkspaceProps = {
|
type AgentWorkspaceProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isLoadingSession?: boolean;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
onPauseSpeech: () => void;
|
onPauseSpeech: () => void;
|
||||||
onResumeSpeech: () => void;
|
onResumeSpeech: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onRegenerate: (messageId: string) => void;
|
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
isAssistantStreaming: boolean;
|
||||||
|
streamingMessageId: string | null;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { startOffset?: number },
|
||||||
|
) => void;
|
||||||
onPauseSpeech: () => void;
|
onPauseSpeech: () => void;
|
||||||
onResumeSpeech: () => void;
|
onResumeSpeech: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onRegenerate: (messageId: string) => void;
|
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
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[]) =>
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
left.length === right.length &&
|
left.length === right.length &&
|
||||||
left.every((message, index) => message === right[index]);
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnItem = React.memo(AgentTurn);
|
||||||
|
|
||||||
const TurnListInner = ({
|
const TurnListInner = ({
|
||||||
messages,
|
messages,
|
||||||
|
isAssistantStreaming,
|
||||||
|
streamingMessageId,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
@@ -60,25 +81,28 @@ const TurnListInner = ({
|
|||||||
onResumeSpeech,
|
onResumeSpeech,
|
||||||
onStopSpeech,
|
onStopSpeech,
|
||||||
isTtsSupported,
|
isTtsSupported,
|
||||||
onRegenerate,
|
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onReplyPermission,
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: TurnListProps) => {
|
}: TurnListProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<AgentTurn
|
<TurnItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
onPause={onPauseSpeech}
|
onPause={onPauseSpeech}
|
||||||
onResume={onResumeSpeech}
|
onResume={onResumeSpeech}
|
||||||
onStopSpeech={onStopSpeech}
|
onStopSpeech={onStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
onRegenerate={onRegenerate}
|
|
||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onReplyPermission={onReplyPermission}
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -89,6 +113,8 @@ const TurnList = React.memo(
|
|||||||
TurnListInner,
|
TurnListInner,
|
||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
|
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||||
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
prevProps.speechState === nextProps.speechState &&
|
prevProps.speechState === nextProps.speechState &&
|
||||||
prevProps.onSpeak === nextProps.onSpeak &&
|
prevProps.onSpeak === nextProps.onSpeak &&
|
||||||
@@ -96,9 +122,10 @@ const TurnList = React.memo(
|
|||||||
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||||
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
prevProps.onRegenerate === nextProps.onRegenerate &&
|
|
||||||
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||||||
prevProps.onReplyPermission === nextProps.onReplyPermission,
|
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||||||
|
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||||||
|
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||||||
);
|
);
|
||||||
|
|
||||||
TurnList.displayName = "TurnList";
|
TurnList.displayName = "TurnList";
|
||||||
@@ -217,10 +244,75 @@ const EmptyState = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SessionLoadingSkeleton = () => (
|
||||||
|
<Stack
|
||||||
|
spacing={2.25}
|
||||||
|
aria-label="正在加载历史记录"
|
||||||
|
sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 2 }, (_, turnIndex) => (
|
||||||
|
<Stack key={turnIndex} spacing={1.25}>
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: turnIndex === 0 ? "72%" : "64%",
|
||||||
|
maxWidth: "86%",
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
bgcolor: alpha("#00acc1", 0.16),
|
||||||
|
border: `1px solid ${alpha("#00acc1", 0.12)}`,
|
||||||
|
boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.85}>
|
||||||
|
<Skeleton variant="text" width="76%" height={18} />
|
||||||
|
<Skeleton variant="text" width="48%" height={15} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<Skeleton
|
||||||
|
variant="circular"
|
||||||
|
width={34}
|
||||||
|
height={34}
|
||||||
|
sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }}
|
||||||
|
/>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.52),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Skeleton variant="text" width="38%" height={16} />
|
||||||
|
<Skeleton variant="text" width="94%" height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} />
|
||||||
|
<Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} />
|
||||||
|
<Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
export const AgentWorkspace = ({
|
export const AgentWorkspace = ({
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isLoadingSession = false,
|
||||||
|
scrollContainerRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
|
onScrollStateChange,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
@@ -228,28 +320,33 @@ export const AgentWorkspace = ({
|
|||||||
onResumeSpeech,
|
onResumeSpeech,
|
||||||
onStopSpeech,
|
onStopSpeech,
|
||||||
isTtsSupported,
|
isTtsSupported,
|
||||||
onRegenerate,
|
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onReplyPermission,
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
const theme = useTheme();
|
const streamingMessageId =
|
||||||
const latestAssistant = [...messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.role === "assistant");
|
|
||||||
const showTypingIndicator =
|
|
||||||
isStreaming &&
|
|
||||||
(!latestAssistant ||
|
|
||||||
(latestAssistant.content.trim().length === 0 &&
|
|
||||||
!(latestAssistant.artifacts?.length)));
|
|
||||||
const streamingMessage =
|
|
||||||
isStreaming && messages.at(-1)?.role === "assistant"
|
isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
? messages.at(-1)
|
? messages.at(-1)?.id ?? null
|
||||||
: undefined;
|
: null;
|
||||||
const historyMessages =
|
const handleScroll = React.useCallback(
|
||||||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
(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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
@@ -257,9 +354,14 @@ export const AgentWorkspace = ({
|
|||||||
py: 2,
|
py: 2,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
scrollbarGutter: "stable",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isLoadingSession ? (
|
||||||
|
<SessionLoadingSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{messages.length === 0 ? <EmptyState /> : null}
|
{messages.length === 0 ? <EmptyState /> : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -267,7 +369,9 @@ export const AgentWorkspace = ({
|
|||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<TurnList
|
<TurnList
|
||||||
messages={historyMessages}
|
messages={messages}
|
||||||
|
isAssistantStreaming={isStreaming}
|
||||||
|
streamingMessageId={streamingMessageId}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
@@ -275,51 +379,23 @@ export const AgentWorkspace = ({
|
|||||||
onResumeSpeech={onResumeSpeech}
|
onResumeSpeech={onResumeSpeech}
|
||||||
onStopSpeech={onStopSpeech}
|
onStopSpeech={onStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
onRegenerate={onRegenerate}
|
|
||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onReplyPermission={onReplyPermission}
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{streamingMessage ? (
|
|
||||||
<TurnList
|
|
||||||
messages={[streamingMessage]}
|
|
||||||
speakingMessageId={speakingMessageId}
|
|
||||||
speechState={speechState}
|
|
||||||
onSpeak={onSpeak}
|
|
||||||
onPauseSpeech={onPauseSpeech}
|
|
||||||
onResumeSpeech={onResumeSpeech}
|
|
||||||
onStopSpeech={onStopSpeech}
|
|
||||||
isTtsSupported={isTtsSupported}
|
|
||||||
onRegenerate={onRegenerate}
|
|
||||||
onCreateBranch={onCreateBranch}
|
|
||||||
onReplyPermission={onReplyPermission}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{showTypingIndicator ? (
|
<div
|
||||||
<motion.div
|
ref={bottomRef}
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
style={{
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
flexShrink: 0,
|
||||||
transition={{ type: "spring", stiffness: 300 }}
|
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||||
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 }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { normalizeChartData } from "./ChatInlineChart";
|
||||||
|
|
||||||
|
describe("normalizeChartData", () => {
|
||||||
|
it("keeps standard bar chart series data", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], [
|
||||||
|
{ name: "数量", data: [3, 5], type: "bar" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "数量", data: [3, 5], type: "bar" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes line chart point arrays into x labels and y values", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "压力", data: [["10:00", 12.5], ["11:00", 13.1]] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["10:00", "11:00"],
|
||||||
|
series: [{ name: "压力", data: [12.5, 13.1], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pie chart point objects into a single series", () => {
|
||||||
|
const result = normalizeChartData(undefined, [
|
||||||
|
{ name: "低风险", value: 8 },
|
||||||
|
{ name: "高风险", value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["低风险", "高风险"],
|
||||||
|
series: [{ name: "数据", data: [8, 2], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a single series object", () => {
|
||||||
|
const result = normalizeChartData(["A", "B"], {
|
||||||
|
name: "流量",
|
||||||
|
values: ["1.2", "2.4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
xData: ["A", "B"],
|
||||||
|
series: [{ name: "流量", data: [1.2, 2.4], type: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import * as echarts from "echarts";
|
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. */
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
@@ -16,15 +17,43 @@ export interface ChatChartSeries {
|
|||||||
type?: "line" | "bar";
|
type?: "line" | "bar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RawChartPoint =
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| [unknown, unknown]
|
||||||
|
| RawChartPointObject;
|
||||||
|
|
||||||
|
type RawChartPointObject = {
|
||||||
|
x?: unknown;
|
||||||
|
y?: unknown;
|
||||||
|
time?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawChartSeries = {
|
||||||
|
name?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
points?: unknown;
|
||||||
|
values?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatInlineChartProps {
|
export interface ChatInlineChartProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
chart_type?: "line" | "bar" | "pie";
|
chart_type?: "line" | "bar" | "pie";
|
||||||
x_data?: string[];
|
x_data?: unknown;
|
||||||
series?: ChatChartSeries[];
|
series?: unknown;
|
||||||
y_axis_name?: string;
|
y_axis_name?: string;
|
||||||
x_axis_name?: string;
|
x_axis_name?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CHART_HEIGHT = 240;
|
||||||
|
export const CHART_MIN_HEIGHT = 286;
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#5470c6",
|
"#5470c6",
|
||||||
"#91cc75",
|
"#91cc75",
|
||||||
@@ -37,28 +66,211 @@ const COLORS = [
|
|||||||
"#ea7ccc",
|
"#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;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointToLabelValue = (
|
||||||
|
point: RawChartPoint,
|
||||||
|
fallbackLabel: string,
|
||||||
|
): { label: string; value: number } | null => {
|
||||||
|
const directValue = toFiniteNumber(point);
|
||||||
|
if (directValue !== null) {
|
||||||
|
return { label: fallbackLabel, value: directValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(point)) {
|
||||||
|
const value = toFiniteNumber(point[1]);
|
||||||
|
if (value === null) return null;
|
||||||
|
return { label: String(point[0] ?? fallbackLabel), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point && typeof point === "object") {
|
||||||
|
const value = toFiniteNumber(point.value ?? point.y);
|
||||||
|
if (value === null) return null;
|
||||||
|
const label =
|
||||||
|
point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
||||||
|
return { label: String(label), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeXData = (rawXData: unknown): string[] =>
|
||||||
|
Array.isArray(rawXData)
|
||||||
|
? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined =>
|
||||||
|
type === "line" || type === "bar" ? type : undefined;
|
||||||
|
|
||||||
|
const isRawChartPoint = (item: unknown): boolean => {
|
||||||
|
if (toFiniteNumber(item) !== null) return true;
|
||||||
|
if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const rawItem = item as RawChartSeries & RawChartPointObject;
|
||||||
|
return (
|
||||||
|
rawItem.data === undefined &&
|
||||||
|
rawItem.points === undefined &&
|
||||||
|
rawItem.values === undefined &&
|
||||||
|
toFiniteNumber(rawItem.value ?? rawItem.y) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => {
|
||||||
|
if (!Array.isArray(rawSeries)) {
|
||||||
|
return rawSeries && typeof rawSeries === "object" ? [rawSeries] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint)
|
||||||
|
? [{ name: "数据", data: rawSeries }]
|
||||||
|
: rawSeries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeChartData = (
|
||||||
|
rawXData: unknown,
|
||||||
|
rawSeries: unknown,
|
||||||
|
): { xData: string[]; series: ChatChartSeries[] } => {
|
||||||
|
const xData = normalizeXData(rawXData);
|
||||||
|
const rawSeriesItems = normalizeRawSeriesItems(rawSeries);
|
||||||
|
if (!rawSeriesItems.length) {
|
||||||
|
return { xData, series: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeries = rawSeriesItems
|
||||||
|
.map((rawItem, seriesIndex): ChatChartSeries | null => {
|
||||||
|
const item =
|
||||||
|
rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
||||||
|
? (rawItem as RawChartSeries)
|
||||||
|
: ({ data: rawItem } satisfies RawChartSeries);
|
||||||
|
const rawData = item.data ?? item.points ?? item.values;
|
||||||
|
if (!Array.isArray(rawData)) return null;
|
||||||
|
|
||||||
|
const labelsFromPoints: string[] = [];
|
||||||
|
const data = rawData
|
||||||
|
.map((point, index) => {
|
||||||
|
const parsed = pointToLabelValue(
|
||||||
|
point as RawChartPoint,
|
||||||
|
xData[index] ?? `${index + 1}`,
|
||||||
|
);
|
||||||
|
if (!parsed) return null;
|
||||||
|
labelsFromPoints[index] = parsed.label;
|
||||||
|
return parsed.value;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
if (!data.length) return null;
|
||||||
|
if (!xData.length && labelsFromPoints.length) {
|
||||||
|
xData.push(...labelsFromPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
typeof item.name === "string" && item.name.trim()
|
||||||
|
? item.name
|
||||||
|
: `系列 ${seriesIndex + 1}`,
|
||||||
|
data,
|
||||||
|
type: normalizeSeriesType(item.type),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ChatChartSeries => Boolean(item));
|
||||||
|
|
||||||
|
return { xData, series: normalizedSeries };
|
||||||
|
};
|
||||||
|
|
||||||
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||||
title,
|
title,
|
||||||
chart_type: chartType = "line",
|
chart_type: chartType = "line",
|
||||||
x_data: xData,
|
x_data,
|
||||||
series = [],
|
series,
|
||||||
y_axis_name: yAxisName,
|
y_axis_name: yAxisName,
|
||||||
x_axis_name: xAxisName,
|
x_axis_name: xAxisName,
|
||||||
|
isStreaming = false,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
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(() => {
|
const option = useMemo(() => {
|
||||||
if (!series.length) return null;
|
if (!chartSeries.length) return null;
|
||||||
|
|
||||||
/* ---------- Pie chart ---------- */
|
/* ---------- Pie chart ---------- */
|
||||||
if (chartType === "pie") {
|
if (chartType === "pie") {
|
||||||
const pieData =
|
const pieData =
|
||||||
series[0]?.data.map((value, i) => ({
|
chartSeries[0]?.data.map((value, i) => ({
|
||||||
name: xData?.[i] ?? `${i}`,
|
name: xData?.[i] ?? `${i}`,
|
||||||
value,
|
value,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "item" },
|
tooltip: { trigger: "item" },
|
||||||
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
series: [
|
series: [
|
||||||
@@ -74,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
label: { fontSize: 11 },
|
label: { fontSize: 11 },
|
||||||
|
animationType: "expansion",
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDelay: (idx: number) => idx * 40,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
@@ -82,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
|
|
||||||
/* ---------- Line / Bar chart ---------- */
|
/* ---------- Line / Bar chart ---------- */
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "axis", confine: true },
|
tooltip: { trigger: "axis", confine: true },
|
||||||
legend: { top: "top", textStyle: { fontSize: 11 } },
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
grid: {
|
grid: {
|
||||||
@@ -111,16 +332,24 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
xData && xData.length > 20
|
xData && xData.length > 20
|
||||||
? [{ type: "inside", start: 0, end: 100 }]
|
? [{ type: "inside", start: 0, end: 100 }]
|
||||||
: undefined,
|
: undefined,
|
||||||
series: series.map((s, i) => {
|
series: chartSeries.map((s, i) => {
|
||||||
const color = COLORS[i % COLORS.length];
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const isLineSeries = chartType === "line";
|
||||||
return {
|
return {
|
||||||
name: s.name,
|
name: s.name,
|
||||||
type: (s.type ?? chartType) as string,
|
type: (s.type ?? chartType) as string,
|
||||||
data: s.data,
|
data: s.data,
|
||||||
symbol: chartType === "line" ? "none" : undefined,
|
symbol: isLineSeries ? "none" : undefined,
|
||||||
smooth: chartType === "line",
|
smooth: isLineSeries,
|
||||||
itemStyle: { color },
|
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: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
@@ -135,28 +364,73 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
}),
|
}),
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
};
|
};
|
||||||
}, [chartType, xData, series, title, yAxisName, xAxisName]);
|
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||||
|
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
图表数据为空
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
mb: 1,
|
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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
bgcolor: alpha("#fff", 0.92),
|
bgcolor: alpha("#fff", 0.92),
|
||||||
overflow: "hidden",
|
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 && (
|
{title && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
@@ -165,14 +439,21 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ px: 1, pb: 1 }}>
|
<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 }}
|
||||||
|
>
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={option}
|
option={option}
|
||||||
style={{ height: 240, width: "100%" }}
|
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||||
notMerge
|
notMerge
|
||||||
lazyUpdate
|
lazyUpdate
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||||||
actionLabel: "定位到地图",
|
actionLabel: "定位到地图",
|
||||||
color: "#3ba272",
|
color: "#3ba272",
|
||||||
},
|
},
|
||||||
|
zoom_to_map: {
|
||||||
|
label: "缩放到坐标",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "缩放到地图",
|
||||||
|
color: "#0ea5e9",
|
||||||
|
},
|
||||||
view_history: {
|
view_history: {
|
||||||
label: "查看计算结果",
|
label: "查看计算结果",
|
||||||
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
@@ -176,6 +182,46 @@ function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZoomTo3857Action(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Extract<ChatToolAction, { type: "zoom_to_map" }> | null {
|
||||||
|
const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center;
|
||||||
|
const tuple = Array.isArray(rawCoordinate)
|
||||||
|
? rawCoordinate
|
||||||
|
: [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude];
|
||||||
|
const x = readFiniteNumber(tuple[0]);
|
||||||
|
const y = readFiniteNumber(tuple[1]);
|
||||||
|
if (x === null || y === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = readFiniteNumber(params.zoom);
|
||||||
|
const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs);
|
||||||
|
const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs;
|
||||||
|
const normalizedSourceCrs =
|
||||||
|
typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : "";
|
||||||
|
const sourceCrs =
|
||||||
|
normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857";
|
||||||
|
return {
|
||||||
|
type: "zoom_to_map",
|
||||||
|
coordinate: [x, y],
|
||||||
|
sourceCrs,
|
||||||
|
zoom: zoom ?? undefined,
|
||||||
|
durationMs: durationMs ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getToolDescription(toolCall: ToolCall): string {
|
function getToolDescription(toolCall: ToolCall): string {
|
||||||
const { params } = toolCall;
|
const { params } = toolCall;
|
||||||
const resolveScadaFeatureInfos = (): [string, string][] => {
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
@@ -281,6 +327,14 @@ function getToolDescription(toolCall: ToolCall): string {
|
|||||||
case "render_junctions": {
|
case "render_junctions": {
|
||||||
return (params.render_ref as string | undefined) ?? "渲染引用";
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
||||||
}
|
}
|
||||||
|
case "zoom_to_map": {
|
||||||
|
const action = buildZoomTo3857Action(params);
|
||||||
|
if (!action) {
|
||||||
|
return "地图坐标";
|
||||||
|
}
|
||||||
|
const zoom = action.zoom === undefined ? "" : ` · zoom ${action.zoom}`;
|
||||||
|
return `${action.coordinate[0]}, ${action.coordinate[1]} · ${action.sourceCrs}${zoom}`;
|
||||||
|
}
|
||||||
case APPLY_LAYER_STYLE_TOOL: {
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
const payload = parseApplyLayerStylePayload(params);
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
||||||
@@ -341,6 +395,8 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
(params.end as string | undefined),
|
(params.end as string | undefined),
|
||||||
});
|
});
|
||||||
switch (toolCall.tool) {
|
switch (toolCall.tool) {
|
||||||
|
case "zoom_to_map":
|
||||||
|
return buildZoomTo3857Action(params);
|
||||||
case "locate_features": {
|
case "locate_features": {
|
||||||
const featureTypeRaw = params.feature_type;
|
const featureTypeRaw = params.feature_type;
|
||||||
const featureType =
|
const featureType =
|
||||||
|
|||||||
@@ -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 { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
import { getAccessToken } from "@/lib/authToken";
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
|
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
import { Blob } from "./GlobalChatbox.parts";
|
import { Blob } from "./GlobalChatboxParts";
|
||||||
import type { Props } from "./GlobalChatbox.types";
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
|
import { PRESET_PROMPTS } from "./globalChatboxUtils";
|
||||||
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice";
|
||||||
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
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 }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
"deepseek/deepseek-v4-pro",
|
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||||
);
|
|
||||||
const [approvalMode, setApprovalMode] =
|
const [approvalMode, setApprovalMode] =
|
||||||
useState<AgentApprovalMode>("request");
|
useState<AgentApprovalMode>("request");
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
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 composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { open: openNotification } = useNotification();
|
const { open: openNotification } = useNotification();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
@@ -62,19 +68,51 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isSupported: isSttSupported,
|
isSupported: isSttSupported,
|
||||||
} = useSpeechRecognition(handleSpeechResult);
|
} = 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 handleToolCall = useAgentToolActions();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
chatSessions,
|
chatSessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
|
loadingSessionId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
|
||||||
createBranch,
|
createBranch,
|
||||||
abort,
|
abort,
|
||||||
replyPermission,
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
@@ -91,27 +129,78 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior });
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const cancelStreamingScroll = useCallback(() => {
|
||||||
scrollToBottom(isStreaming ? "auto" : "smooth");
|
if (streamingScrollFrameRef.current === null) return;
|
||||||
}, [isStreaming, messages, scrollToBottom]);
|
window.cancelAnimationFrame(streamingScrollFrameRef.current);
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const scheduleStreamingScrollToBottom = useCallback(() => {
|
||||||
if (!open) {
|
if (streamingScrollFrameRef.current !== null) return;
|
||||||
hasResetForOpenRef.current = false;
|
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
return;
|
streamingScrollFrameRef.current = null;
|
||||||
}
|
const container = workspaceScrollRef.current;
|
||||||
if (hasResetForOpenRef.current || isHydrating) return;
|
if (!container || !isNearBottomRef.current) return;
|
||||||
hasResetForOpenRef.current = true;
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const distanceToBottom =
|
||||||
createSession();
|
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();
|
composerRef.current?.clear();
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
composerRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
scrollToBottom("auto");
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
}, [cancelStreamingScroll, scrollToBottom]);
|
||||||
}, [createSession, isHydrating, open, 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) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming || isCheckingAuth) return;
|
if (isStreaming || isCheckingAuth) return;
|
||||||
@@ -146,21 +235,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
createSession();
|
createSession();
|
||||||
composerRef.current?.clear();
|
resetConversationView();
|
||||||
window.setTimeout(() => {
|
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||||
composerRef.current?.focus();
|
|
||||||
scrollToBottom("auto");
|
|
||||||
}, 0);
|
|
||||||
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(sessionId: string) => {
|
(sessionId: string, title: string) => {
|
||||||
composerRef.current?.clear();
|
composerRef.current?.clear();
|
||||||
void switchSession(sessionId);
|
void switchSession(sessionId, title);
|
||||||
},
|
},
|
||||||
[switchSession],
|
[switchSession],
|
||||||
);
|
);
|
||||||
@@ -225,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
hideBackdrop
|
hideBackdrop
|
||||||
disableScrollLock
|
disableScrollLock
|
||||||
disableEnforceFocus
|
disableEnforceFocus
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
@@ -325,12 +411,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
sessions={chatSessions}
|
sessions={chatSessions}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating}
|
||||||
|
isLoadingSessions={isHydrating && chatSessions.length === 0}
|
||||||
|
loadingSessionId={loadingSessionId}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
handleNewConversation();
|
handleNewConversation();
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
}}
|
}}
|
||||||
onSelectSession={(id) => {
|
onSelectSession={(id, title) => {
|
||||||
handleSelectSession(id);
|
handleSelectSession(id, title);
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
}}
|
}}
|
||||||
onRenameSession={handleRenameSession}
|
onRenameSession={handleRenameSession}
|
||||||
@@ -342,7 +430,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
<AgentWorkspace
|
<AgentWorkspace
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
isLoadingSession={Boolean(loadingSessionId)}
|
||||||
|
scrollContainerRef={workspaceScrollRef}
|
||||||
bottomRef={bottomRef}
|
bottomRef={bottomRef}
|
||||||
|
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={handleSpeak}
|
onSpeak={handleSpeak}
|
||||||
@@ -350,9 +441,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onResumeSpeech={handleResumeSpeech}
|
onResumeSpeech={handleResumeSpeech}
|
||||||
onStopSpeech={handleStopSpeech}
|
onStopSpeech={handleStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
onRegenerate={regenerate}
|
|
||||||
onCreateBranch={createBranch}
|
onCreateBranch={createBranch}
|
||||||
onReplyPermission={replyPermission}
|
onReplyPermission={replyPermission}
|
||||||
|
onReplyQuestion={replyQuestion}
|
||||||
|
onRejectQuestion={rejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
@@ -366,6 +458,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onAbort={abort}
|
onAbort={abort}
|
||||||
onStartListening={startListening}
|
onStartListening={startListening}
|
||||||
onStopListening={stopListening}
|
onStopListening={stopListening}
|
||||||
|
modelOptions={modelOptions}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
approvalMode={approvalMode}
|
approvalMode={approvalMode}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
export type ChatProgress = {
|
export type ChatProgress = {
|
||||||
id: string;
|
id: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -28,6 +33,7 @@ export type AgentPermissionStatus =
|
|||||||
| "approved_once"
|
| "approved_once"
|
||||||
| "approved_always"
|
| "approved_always"
|
||||||
| "rejected"
|
| "rejected"
|
||||||
|
| "aborted"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AgentPermissionRequest = {
|
export type AgentPermissionRequest = {
|
||||||
@@ -35,7 +41,7 @@ export type AgentPermissionRequest = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
metadata: Record<string, unknown>;
|
target?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -55,6 +61,8 @@ export type Message = {
|
|||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
permissions?: AgentPermissionRequest[];
|
permissions?: AgentPermissionRequest[];
|
||||||
|
questions?: AgentQuestionRequest[];
|
||||||
|
todos?: AgentTodoUpdate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -62,7 +70,7 @@ export type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpeechState = "idle" | "playing" | "paused";
|
export type SpeechState = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
export type ChatSessionSummary = {
|
export type ChatSessionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { Message } from "./GlobalChatbox.types";
|
|
||||||
|
|
||||||
export const createId = () =>
|
|
||||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
export const PRESET_PROMPTS = [
|
|
||||||
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
|
||||||
"供水服务分区分析。",
|
|
||||||
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
|
||||||
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
|
||||||
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
|
||||||
"排查当前管网爆管风险,并说明优先处置建议。",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const stripMarkdown = (md: string): string =>
|
|
||||||
md
|
|
||||||
.replace(/```[\s\S]*?```/g, "")
|
|
||||||
.replace(/`([^`]+)`/g, "$1")
|
|
||||||
.replace(/!\[.*?\]\(.*?\)/g, "")
|
|
||||||
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
|
|
||||||
.replace(/#{1,6}\s+/g, "")
|
|
||||||
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
||||||
.replace(/\*(.+?)\*/g, "$1")
|
|
||||||
.replace(/~~(.+?)~~/g, "$1")
|
|
||||||
.replace(/>\s+/g, "")
|
|
||||||
.replace(/[-*+]\s+/g, "")
|
|
||||||
.replace(/\d+\.\s+/g, "")
|
|
||||||
.replace(/\n{2,}/g, "\n")
|
|
||||||
.replace(/<[^>]+>/g, "")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
export const cloneMessage = (message: Message): Message => ({
|
|
||||||
...message,
|
|
||||||
progress: message.progress ? [...message.progress] : undefined,
|
|
||||||
artifacts: message.artifacts ? [...message.artifacts] : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
|
||||||
@@ -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);
|
color: var(--chat-md-quote-text);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streamFade {
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|||||||
-30
@@ -2,36 +2,6 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from "framer-motion";
|
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 = ({
|
export const Blob = ({
|
||||||
color,
|
color,
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
createEmptyChatState,
|
createEmptyChatState,
|
||||||
saveActiveChatState,
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
updateChatSessionTitle,
|
||||||
} from "./chatStorage";
|
} from "./chatStorage";
|
||||||
|
|
||||||
const apiFetch = jest.fn();
|
const apiFetch = jest.fn();
|
||||||
@@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({
|
|||||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("chatStorage backend-only persistence", () => {
|
describe("chatStorage backend session operations", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiFetch.mockReset();
|
apiFetch.mockReset();
|
||||||
});
|
});
|
||||||
@@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
expect(apiFetch).not.toHaveBeenCalled();
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a backend conversation when saving the first non-empty state", async () => {
|
it("lists backend sessions sorted by created time", async () => {
|
||||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
apiFetch.mockResolvedValueOnce({
|
||||||
if (url.endsWith("/api/v1/agent/chat/session")) {
|
|
||||||
expect(init?.method).toBe("POST");
|
|
||||||
return {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ session_id: "chat-new-1" }),
|
json: async () => ({
|
||||||
} as Response;
|
sessions: [
|
||||||
}
|
|
||||||
|
|
||||||
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: "新对话",
|
id: "session-old",
|
||||||
isTitleManuallyEdited: false,
|
title: "旧会话",
|
||||||
messages: [
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "message-2",
|
id: "session-new",
|
||||||
role: "user",
|
title: "新会话",
|
||||||
content: "第一条消息",
|
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,
|
LoadedChatState,
|
||||||
Message,
|
Message,
|
||||||
} from "./GlobalChatbox.types";
|
} from "./GlobalChatbox.types";
|
||||||
import { cloneMessages } from "./GlobalChatbox.utils";
|
import { cloneMessages } from "./globalChatboxUtils";
|
||||||
|
|
||||||
type BackendSessionPayload = {
|
type BackendSessionPayload = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({
|
|||||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
const hasChatContent = (state: {
|
|
||||||
messages: Message[];
|
|
||||||
sessionId?: string;
|
|
||||||
}) =>
|
|
||||||
state.messages.length > 0 ||
|
|
||||||
Boolean(state.sessionId);
|
|
||||||
|
|
||||||
const compareSessionsByAnchorTime = (
|
const compareSessionsByAnchorTime = (
|
||||||
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
right: 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 normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
|
|
||||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
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",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -80,11 +72,10 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
|||||||
|
|
||||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
const response = await apiFetch(
|
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",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
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 (
|
const updateBackendChatSessionTitle = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
title: string,
|
title: string,
|
||||||
isTitleManuallyEdited?: boolean,
|
isTitleManuallyEdited?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
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",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -188,7 +121,6 @@ const updateBackendChatSessionTitle = async (
|
|||||||
is_title_manually_edited: isTitleManuallyEdited,
|
is_title_manually_edited: isTitleManuallyEdited,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -199,11 +131,10 @@ const updateBackendChatSessionTitle = async (
|
|||||||
|
|
||||||
const deleteBackendChatSession = async (sessionId: string) => {
|
const deleteBackendChatSession = async (sessionId: string) => {
|
||||||
const response = await apiFetch(
|
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",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
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[]> => {
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
return await fetchBackendChatSessions();
|
return await fetchBackendChatSessions();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { cloneMessage } from "./globalChatboxUtils";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
describe("cloneMessage", () => {
|
||||||
|
it("normalizes persisted question and todo arrays", () => {
|
||||||
|
const message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要补充信息",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "请选择分析范围",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
todos: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
} as unknown as Message;
|
||||||
|
|
||||||
|
const cloned = cloneMessage(message);
|
||||||
|
|
||||||
|
expect(cloned.questions?.[0]?.questions[0]?.options).toEqual([]);
|
||||||
|
expect(cloned.todos?.todos).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
|
export const createId = () =>
|
||||||
|
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
export const PRESET_PROMPTS = [
|
||||||
|
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
||||||
|
"供水服务分区分析。",
|
||||||
|
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||||||
|
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
||||||
|
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
||||||
|
"排查当前管网爆管风险,并说明优先处置建议。",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const stripMarkdown = (md: string): string =>
|
||||||
|
md
|
||||||
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
|
.replace(/!\[.*?\]\(.*?\)/g, "")
|
||||||
|
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
|
||||||
|
.replace(/#{1,6}\s+/g, "")
|
||||||
|
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||||
|
.replace(/\*(.+?)\*/g, "$1")
|
||||||
|
.replace(/~~(.+?)~~/g, "$1")
|
||||||
|
.replace(/>\s+/g, "")
|
||||||
|
.replace(/[-*+]\s+/g, "")
|
||||||
|
.replace(/\d+\.\s+/g, "")
|
||||||
|
.replace(/\n{2,}/g, "\n")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const normalizeQuestionRequests = (
|
||||||
|
questions: Message["questions"],
|
||||||
|
): Message["questions"] =>
|
||||||
|
Array.isArray(questions)
|
||||||
|
? questions.map((request) => ({
|
||||||
|
...request,
|
||||||
|
questions: Array.isArray(request.questions)
|
||||||
|
? request.questions.map((question) => ({
|
||||||
|
...question,
|
||||||
|
header: typeof question.header === "string" ? question.header : "",
|
||||||
|
question:
|
||||||
|
typeof question.question === "string" ? question.question : "",
|
||||||
|
options: Array.isArray(question.options)
|
||||||
|
? question.options.map((option) => ({
|
||||||
|
label:
|
||||||
|
typeof option.label === "string" ? option.label : "",
|
||||||
|
description:
|
||||||
|
typeof option.description === "string"
|
||||||
|
? option.description
|
||||||
|
: "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
answers: Array.isArray(request.answers)
|
||||||
|
? request.answers.map((answer) =>
|
||||||
|
Array.isArray(answer)
|
||||||
|
? answer.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
} satisfies AgentQuestionRequest))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => {
|
||||||
|
if (!todos) return undefined;
|
||||||
|
return {
|
||||||
|
...todos,
|
||||||
|
todos: Array.isArray(todos.todos)
|
||||||
|
? todos.todos.map((todo) => ({ ...todo }))
|
||||||
|
: [],
|
||||||
|
} satisfies AgentTodoUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cloneMessage = (message: Message): Message => ({
|
||||||
|
...message,
|
||||||
|
progress: Array.isArray(message.progress) ? [...message.progress] : undefined,
|
||||||
|
artifacts: Array.isArray(message.artifacts) ? [...message.artifacts] : undefined,
|
||||||
|
permissions: Array.isArray(message.permissions)
|
||||||
|
? message.permissions.map((permission) => ({
|
||||||
|
...permission,
|
||||||
|
patterns: Array.isArray(permission.patterns)
|
||||||
|
? [...permission.patterns]
|
||||||
|
: [],
|
||||||
|
always: Array.isArray(permission.always) ? [...permission.always] : [],
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
questions: normalizeQuestionRequests(message.questions),
|
||||||
|
todos: normalizeTodoUpdate(message.todos),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloneMessages = (messages: Message[]) => messages.map(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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
PermissionReply,
|
||||||
|
StreamEvent,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type {
|
||||||
|
AgentPermissionRequest,
|
||||||
|
ChatProgress,
|
||||||
|
Message,
|
||||||
|
} from "../GlobalChatbox.types";
|
||||||
|
import { createId } from "../globalChatboxUtils";
|
||||||
|
|
||||||
|
export const upsertProgress = (
|
||||||
|
progress: ChatProgress[] | undefined,
|
||||||
|
event: StreamEvent & { type: "progress" },
|
||||||
|
) => {
|
||||||
|
const next = [...(progress ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.id === event.id);
|
||||||
|
const existing = index >= 0 ? next[index] : undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||||
|
const isRunning = event.status === "running";
|
||||||
|
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||||
|
const elapsedMs = isRunning
|
||||||
|
? event.elapsedMs ??
|
||||||
|
existing?.elapsedMs ??
|
||||||
|
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||||
|
: undefined;
|
||||||
|
const elapsedSnapshotAt = isRunning
|
||||||
|
? event.elapsedMs !== undefined
|
||||||
|
? now
|
||||||
|
: existing?.elapsedSnapshotAt ?? now
|
||||||
|
: undefined;
|
||||||
|
const durationMs = !isRunning
|
||||||
|
? event.durationMs ??
|
||||||
|
existing?.durationMs ??
|
||||||
|
(startedAt !== undefined && endedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - startedAt)
|
||||||
|
: undefined)
|
||||||
|
: undefined;
|
||||||
|
const nextItem: ChatProgress = {
|
||||||
|
id: event.id,
|
||||||
|
phase: event.phase,
|
||||||
|
status: event.status,
|
||||||
|
title: event.title,
|
||||||
|
detail: event.detail,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
elapsedSnapshotAt,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = nextItem;
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||||
|
progress?.map((item) => {
|
||||||
|
if (item.status !== "running") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs:
|
||||||
|
item.durationMs ??
|
||||||
|
(item.startedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - item.startedAt)
|
||||||
|
: item.elapsedMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||||
|
todoUpdate
|
||||||
|
? {
|
||||||
|
...todoUpdate,
|
||||||
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
|
...todo,
|
||||||
|
status: "cancelled" as const,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: todo,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const upsertPermission = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "permission_request" },
|
||||||
|
) => {
|
||||||
|
const next = [...(permissions ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||||
|
const nextItem: AgentPermissionRequest = {
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
permission: event.permission,
|
||||||
|
patterns: event.patterns,
|
||||||
|
target: event.target,
|
||||||
|
always: event.always,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||||
|
if (reply === "always") return "approved_always";
|
||||||
|
if (reply === "once") return "approved_once";
|
||||||
|
return "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isActionableQuestionRequest = (question: {
|
||||||
|
requestId: string;
|
||||||
|
tool?: AgentQuestionRequest["tool"];
|
||||||
|
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||||
|
|
||||||
|
export const toQuestionRequest = (
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
status: AgentQuestionRequest["status"] = "pending",
|
||||||
|
): AgentQuestionRequest => ({
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getQuestionContentSignature = (
|
||||||
|
questions: AgentQuestionRequest["questions"],
|
||||||
|
) =>
|
||||||
|
JSON.stringify(
|
||||||
|
questions.map((question) => ({
|
||||||
|
header: question.header,
|
||||||
|
question: question.question,
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
multiple: question.multiple ?? false,
|
||||||
|
custom: question.custom !== false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isSameQuestionRequest = (
|
||||||
|
question: AgentQuestionRequest,
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
) => {
|
||||||
|
if (question.requestId === event.requestId) return true;
|
||||||
|
if (question.tool?.callID && event.tool?.callID) {
|
||||||
|
return question.tool.callID === event.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
question.status === "pending" &&
|
||||||
|
question.sessionId === event.sessionId &&
|
||||||
|
getQuestionContentSignature(question.questions) ===
|
||||||
|
getQuestionContentSignature(event.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSameQuestionPair = (
|
||||||
|
left: AgentQuestionRequest,
|
||||||
|
right: AgentQuestionRequest,
|
||||||
|
) => {
|
||||||
|
if (left.requestId === right.requestId) return true;
|
||||||
|
if (left.tool?.callID && right.tool?.callID) {
|
||||||
|
return left.tool.callID === right.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.status === "pending" &&
|
||||||
|
right.status === "pending" &&
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
getQuestionContentSignature(left.questions) ===
|
||||||
|
getQuestionContentSignature(right.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||||
|
const seen: AgentQuestionRequest[] = [];
|
||||||
|
let changed = false;
|
||||||
|
const nextMessages = messages.map((message) => {
|
||||||
|
if (!message.questions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const nextQuestions = message.questions.filter((question) => {
|
||||||
|
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||||
|
changed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.push(question);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (nextQuestions.length === message.questions.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextMessages : messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertQuestionAcrossMessages = (
|
||||||
|
messages: Message[],
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
assistantMessageId: string,
|
||||||
|
) => {
|
||||||
|
let existing: AgentQuestionRequest | undefined;
|
||||||
|
for (const message of messages) {
|
||||||
|
const match = message.questions?.find((question) =>
|
||||||
|
isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
existing = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||||
|
existing?.status === "submitting" ? "submitting" : undefined;
|
||||||
|
const nextQuestion =
|
||||||
|
existing &&
|
||||||
|
isActionableQuestionRequest(existing) &&
|
||||||
|
!isActionableQuestionRequest(event)
|
||||||
|
? {
|
||||||
|
...existing,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool ?? existing.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: existingStatus ?? existing.status,
|
||||||
|
}
|
||||||
|
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||||
|
const targetMessageId = existing
|
||||||
|
? messages.find((message) =>
|
||||||
|
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||||
|
)?.id ?? assistantMessageId
|
||||||
|
: assistantMessageId;
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
const filteredQuestions = message.questions?.filter(
|
||||||
|
(question) => !isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (message.id !== targetMessageId) {
|
||||||
|
return filteredQuestions?.length === message.questions?.length
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyQuestionResponse = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "question_response" },
|
||||||
|
) =>
|
||||||
|
(questions ?? []).map((question) =>
|
||||||
|
question.requestId === event.requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||||
|
answers: event.answers ?? question.answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createTodoUpdateFromEvent = (
|
||||||
|
event: StreamEvent & { type: "todo_update" },
|
||||||
|
): AgentTodoUpdate => ({
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
messageId: event.messageId,
|
||||||
|
todos: event.todos,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeSessionTodos = (
|
||||||
|
messages: Message[],
|
||||||
|
nextTodoUpdate?: AgentTodoUpdate,
|
||||||
|
targetAssistantMessageId?: string,
|
||||||
|
) => {
|
||||||
|
let latestTodoUpdate = nextTodoUpdate;
|
||||||
|
if (!latestTodoUpdate) {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.todos) {
|
||||||
|
latestTodoUpdate = message.todos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestTodoUpdate) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMessageId =
|
||||||
|
targetAssistantMessageId ??
|
||||||
|
[...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||||
|
if (!targetMessageId) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextMessages = messages.map((message) => {
|
||||||
|
if (message.id === targetMessageId) {
|
||||||
|
if (message.todos === latestTodoUpdate) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
todos: latestTodoUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!message.todos) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
todos: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? nextMessages : messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abortOpenPermissionsAfterAbort = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!permissions?.length) return permissions;
|
||||||
|
let changed = false;
|
||||||
|
const nextPermissions = permissions.map((permission) => {
|
||||||
|
if (
|
||||||
|
permission.status !== "pending" &&
|
||||||
|
permission.status !== "submitting" &&
|
||||||
|
permission.status !== "error"
|
||||||
|
) {
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...permission,
|
||||||
|
status: "aborted" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextPermissions : permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rejectOpenQuestionsAfterAbort = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!questions?.length) return questions;
|
||||||
|
let changed = false;
|
||||||
|
const nextQuestions = questions.map((question) => {
|
||||||
|
if (
|
||||||
|
question.status !== "pending" &&
|
||||||
|
question.status !== "submitting" &&
|
||||||
|
question.status !== "error"
|
||||||
|
) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...question,
|
||||||
|
status: "rejected" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextQuestions : questions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
|
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
||||||
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
|
const hasVisibleOutput =
|
||||||
|
message.content.trim().length > 0 ||
|
||||||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(abortedPermissions?.length) ||
|
||||||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledTodos);
|
||||||
|
|
||||||
|
if (!hasVisibleOutput) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completedProgress,
|
||||||
|
permissions: abortedPermissions,
|
||||||
|
questions: rejectedQuestions,
|
||||||
|
todos: cancelledTodos,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserMessage = (content: string): Message => {
|
||||||
|
const id = createId();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAssistantMessage = (): Message => ({
|
||||||
|
id: createId(),
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
forkAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listChatSessions = jest.fn();
|
||||||
|
const deleteChatSession = jest.fn();
|
||||||
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("../chatStorage", () => ({
|
||||||
|
createEmptyChatState: jest.fn(() => ({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
})),
|
||||||
|
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||||
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
|
title: "已存在会话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
})),
|
||||||
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentChatSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
listChatSessions.mockReset();
|
||||||
|
deleteChatSession.mockReset();
|
||||||
|
updateChatSessionTitle.mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentChatSession actions", () => {
|
||||||
|
it("tracks permission requests and submits replies", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("删除临时文件");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
target: "rm tmp.txt",
|
||||||
|
always: ["rm *"],
|
||||||
|
createdAt: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.replyPermission("perm-1", "once");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
||||||
|
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
status: "approved_once",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
({ onEvent, signal }) =>
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
onEvent({
|
||||||
|
type: "progress",
|
||||||
|
sessionId: "session-1",
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "开始分析",
|
||||||
|
startedAt: 1000,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-1",
|
||||||
|
content: "分析水位",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-2",
|
||||||
|
content: "生成建议",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1001,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-abort",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
target: "npm test",
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1002,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "question-abort",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "请选择范围",
|
||||||
|
options: [{ label: "城区", description: "中心城区" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1003,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendPrompt("测试中断");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
content: "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "request-received",
|
||||||
|
status: "completed",
|
||||||
|
durationMs: expect.any(Number),
|
||||||
|
endedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
todos: expect.objectContaining({
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-1",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-2",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
permissions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-abort",
|
||||||
|
status: "aborted",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
questions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-abort",
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores generated session titles after the title was edited manually", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "session-1",
|
||||||
|
title: "自动标题",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("session-loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.renameSession("session-loaded", "手动标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("帮我分析一下");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("手动标题");
|
||||||
|
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||||
|
"session-loaded",
|
||||||
|
"自动标题",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply a late generated title to a newly created session", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
let resolveStream: (() => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveStream = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("帮我分析一下");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "old-session",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("新对话");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "old-session",
|
||||||
|
title: "旧请求标题",
|
||||||
|
});
|
||||||
|
resolveStream?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("新对话");
|
||||||
|
expect(updateChatSessionTitle).toHaveBeenCalledWith(
|
||||||
|
"old-session",
|
||||||
|
"旧请求标题",
|
||||||
|
{ isTitleManuallyEdited: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forks a copied conversation from an assistant message", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("第一轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.createBranch(firstAssistantMessageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
|
||||||
|
expect(result.current.activeSessionId).toBe("forked-session");
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
expect(result.current.messages[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "user",
|
||||||
|
content: "第一轮",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(streamAgentChat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+429
-350
@@ -7,6 +7,7 @@ import {
|
|||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
@@ -16,13 +17,13 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: jest.fn(async () => undefined),
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
jest.mock("../chatStorage", () => ({
|
jest.mock("../chatStorage", () => ({
|
||||||
@@ -40,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
sessionId: "session-loaded",
|
sessionId: "session-loaded",
|
||||||
})),
|
})),
|
||||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
|
||||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -48,23 +48,24 @@ describe("useAgentChatSession", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
deleteChatSession.mockReset();
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
|
||||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useAgentChatSession lifecycle and resume", () => {
|
||||||
it("does not add a new empty session to history until there is actual chat content", async () => {
|
it("does not add a new empty session to history until there is actual chat content", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ describe("useAgentChatSession", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
@@ -215,8 +216,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "token",
|
type: "token",
|
||||||
@@ -229,8 +228,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -242,19 +239,104 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
expect(result.current.messages).toEqual([
|
||||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
|
||||||
sessionId: "chat-stream-1",
|
|
||||||
messages: [
|
|
||||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||||
],
|
]);
|
||||||
});
|
expect(result.current.activeSessionId).toBe("chat-stream-1");
|
||||||
} finally {
|
} finally {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows shared todo state only on the latest assistant message in a session", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat)
|
||||||
|
.mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-1",
|
||||||
|
content: "创建任务列表",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1000,
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-1",
|
||||||
|
content: "创建任务列表",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-2",
|
||||||
|
content: "更新任务状态",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 2000,
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("创建任务");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("更新任务");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
const assistantMessages = result.current.messages.filter(
|
||||||
|
(message) => message.role === "assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantMessages).toHaveLength(2);
|
||||||
|
expect(assistantMessages[0].todos).toBeUndefined();
|
||||||
|
expect(assistantMessages[1].todos).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 2000,
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-1",
|
||||||
|
status: "completed",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-2",
|
||||||
|
status: "in_progress",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates a backend streaming session and resumes its stream", async () => {
|
it("hydrates a backend streaming session and resumes its stream", async () => {
|
||||||
listChatSessions.mockResolvedValue([
|
listChatSessions.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -333,6 +415,337 @@ describe("useAgentChatSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies question responses to the message that owns the request", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "q-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "选择范围",
|
||||||
|
options: [],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_response",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "q-1",
|
||||||
|
answers: [["城区"]],
|
||||||
|
rejected: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "q-1",
|
||||||
|
status: "answered",
|
||||||
|
answers: [["城区"]],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates question requests across assistant messages", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "call-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
const allQuestions = result.current.messages.flatMap(
|
||||||
|
(message) => message.questions ?? [],
|
||||||
|
);
|
||||||
|
expect(allQuestions).toHaveLength(1);
|
||||||
|
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-1",
|
||||||
|
tool: expect.objectContaining({ callID: "call-1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "call-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
const allQuestions = result.current.messages.flatMap(
|
||||||
|
(message) => message.questions ?? [],
|
||||||
|
);
|
||||||
|
expect(allQuestions).toHaveLength(1);
|
||||||
|
expect(allQuestions[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-1",
|
||||||
|
tool: expect.objectContaining({ callID: "call-1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates persisted duplicate questions from state events", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const duplicateQuestion = {
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending" as const,
|
||||||
|
};
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [{ ...duplicateQuestion, requestId: "question-1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "后续消息",
|
||||||
|
questions: [{ ...duplicateQuestion, requestId: "call-1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.current.messages.flatMap((message) => message.questions ?? []),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.current.messages[1].questions).toHaveLength(1);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
||||||
listChatSessions.mockResolvedValue([
|
listChatSessions.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -363,339 +776,5 @@ describe("useAgentChatSession", () => {
|
|||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks permission requests and submits replies", async () => {
|
});
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
|
||||||
emitStreamEvent = onEvent;
|
|
||||||
await new Promise<void>(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
void result.current.sendPrompt("删除临时文件");
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
emitStreamEvent?.({
|
|
||||||
type: "permission_request",
|
|
||||||
sessionId: "session-1",
|
|
||||||
requestId: "perm-1",
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["rm *"],
|
|
||||||
metadata: { command: "rm tmp.txt" },
|
|
||||||
always: ["rm *"],
|
|
||||||
createdAt: 123,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
requestId: "perm-1",
|
|
||||||
sessionId: "session-1",
|
|
||||||
status: "pending",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.replyPermission("perm-1", "once");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
|
||||||
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
requestId: "perm-1",
|
|
||||||
status: "approved_once",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finalizes running progress when aborting an active prompt", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
|
||||||
({ onEvent, signal }) =>
|
|
||||||
new Promise<void>((_, reject) => {
|
|
||||||
onEvent({
|
|
||||||
type: "progress",
|
|
||||||
sessionId: "session-1",
|
|
||||||
id: "request-received",
|
|
||||||
phase: "start",
|
|
||||||
status: "running",
|
|
||||||
title: "开始分析",
|
|
||||||
startedAt: 1000,
|
|
||||||
} satisfies StreamEvent);
|
|
||||||
|
|
||||||
signal?.addEventListener("abort", () => {
|
|
||||||
reject(new Error("aborted"));
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
void result.current.sendPrompt("测试中断");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
|
||||||
|
|
||||||
expect(result.current.messages.at(-1)).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
role: "assistant",
|
|
||||||
content: "⚠️ **请求已中断**",
|
|
||||||
isError: true,
|
|
||||||
progress: [
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "request-received",
|
|
||||||
status: "completed",
|
|
||||||
durationMs: expect.any(Number),
|
|
||||||
endedAt: expect.any(Number),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores generated session titles after the title was edited manually", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
|
||||||
onEvent({
|
|
||||||
type: "session_title",
|
|
||||||
sessionId: "session-1",
|
|
||||||
title: "自动标题",
|
|
||||||
});
|
|
||||||
onEvent({
|
|
||||||
type: "done",
|
|
||||||
sessionId: "session-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.switchSession("session-loaded");
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.renameSession("session-loaded", "手动标题");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendPrompt("帮我分析一下");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.sessionTitle).toBe("手动标题");
|
|
||||||
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
|
||||||
"session-loaded",
|
|
||||||
"自动标题",
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not apply a late generated title to a newly created session", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
|
||||||
let resolveStream: (() => void) | undefined;
|
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
|
||||||
emitStreamEvent = onEvent;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
resolveStream = resolve;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
void result.current.sendPrompt("帮我分析一下");
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
emitStreamEvent?.({
|
|
||||||
type: "done",
|
|
||||||
sessionId: "old-session",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.createSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.sessionTitle).toBe("新对话");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
emitStreamEvent?.({
|
|
||||||
type: "session_title",
|
|
||||||
sessionId: "old-session",
|
|
||||||
title: "旧请求标题",
|
|
||||||
});
|
|
||||||
resolveStream?.();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.sessionTitle).toBe("新对话");
|
|
||||||
expect(updateChatSessionTitle).toHaveBeenCalledWith(
|
|
||||||
"old-session",
|
|
||||||
"旧请求标题",
|
|
||||||
{ isTitleManuallyEdited: false },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks the backend to undo the previous user turn before regenerating", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendPrompt("重新分析压力异常");
|
|
||||||
});
|
|
||||||
const assistantMessageId = result.current.messages[1]?.id ?? "";
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.regenerate(assistantMessageId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(streamAgentChat).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
message: "重新分析压力异常",
|
|
||||||
regenerateFromMessageIndex: 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the current chain when regenerating a middle assistant message", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendPrompt("第一轮");
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendPrompt("第二轮");
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.regenerate(firstAssistantMessageId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(2);
|
|
||||||
expect(result.current.messages[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
role: "user",
|
|
||||||
content: "第一轮",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.current.messages[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(streamAgentChat).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
expect.objectContaining({
|
|
||||||
message: "第一轮",
|
|
||||||
regenerateFromMessageIndex: 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forks a copied conversation from an assistant message", async () => {
|
|
||||||
listChatSessions.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentChatSession({
|
|
||||||
projectId: "project-1",
|
|
||||||
onToolCall: jest.fn(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendPrompt("第一轮");
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.createBranch(firstAssistantMessageId);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
|
|
||||||
expect(result.current.activeSessionId).toBe("forked-session");
|
|
||||||
expect(result.current.messages).toHaveLength(2);
|
|
||||||
expect(result.current.messages[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
role: "user",
|
|
||||||
content: "第一轮",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.current.messages[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
role: "assistant",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(streamAgentChat).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -2,207 +2,76 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
abortAgentChat,
|
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
forkAgentChat,
|
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||||
replyAgentPermission,
|
import { cloneMessages } from "../globalChatboxUtils";
|
||||||
resumeAgentChatStream,
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||||
streamAgentChat,
|
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||||
} from "@/lib/chatStream";
|
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||||
import type {
|
|
||||||
AgentApprovalMode,
|
|
||||||
AgentModel,
|
|
||||||
PermissionReply,
|
|
||||||
StreamEvent,
|
|
||||||
} from "@/lib/chatStream";
|
|
||||||
import type {
|
|
||||||
AgentArtifact,
|
|
||||||
AgentPermissionRequest,
|
|
||||||
ChatProgress,
|
|
||||||
ChatSessionSummary,
|
|
||||||
LoadedChatState,
|
|
||||||
Message,
|
|
||||||
} from "../GlobalChatbox.types";
|
|
||||||
import {
|
|
||||||
cloneMessages,
|
|
||||||
createId,
|
|
||||||
} from "../GlobalChatbox.utils";
|
|
||||||
import {
|
|
||||||
createEmptyChatState,
|
|
||||||
deleteChatSession,
|
|
||||||
listChatSessions,
|
|
||||||
loadChatSessionById,
|
|
||||||
saveActiveChatState,
|
|
||||||
updateChatSessionTitle,
|
|
||||||
} from "../chatStorage";
|
|
||||||
|
|
||||||
type UseAgentChatSessionOptions = {
|
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||||||
projectId?: string | null;
|
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||||||
onToolCall: (
|
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
||||||
event: StreamEvent & { type: "tool_call" },
|
|
||||||
options: {
|
const sliceCodePoints = (value: string, count: number) =>
|
||||||
assistantMessageId: string;
|
Array.from(value).slice(0, count).join("");
|
||||||
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
|
||||||
},
|
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
||||||
) => void;
|
|
||||||
onBeforeSend?: () => void;
|
const getSegmenter = () => {
|
||||||
getModel?: () => AgentModel;
|
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
||||||
getApprovalMode?: () => AgentApprovalMode;
|
cachedSegmenter =
|
||||||
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||||
|
? new Intl.Segmenter("zh", { granularity: "word" })
|
||||||
|
: null;
|
||||||
|
return cachedSegmenter;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PromptRunOptions = {
|
const getPlaybackChunkSize = (bufferLength: number) => {
|
||||||
prompt: string;
|
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
||||||
sessionIdOverride?: string;
|
if (bufferLength >= 300) return 112;
|
||||||
regenerateFromMessageIndex?: number;
|
if (bufferLength >= 140) return 72;
|
||||||
preparedMessages?: Message[];
|
if (bufferLength >= 64) return 44;
|
||||||
userMessage?: Message;
|
return TOKEN_PLAYBACK_BASE_CHARS;
|
||||||
assistantMessage?: Message;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPersistedStateKey = (state: LoadedChatState) =>
|
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
||||||
JSON.stringify({
|
if (content.length <= maxChars) return content;
|
||||||
title: state.title ?? null,
|
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
||||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
|
||||||
sessionId: state.sessionId ?? null,
|
|
||||||
messages: state.messages,
|
|
||||||
});
|
|
||||||
|
|
||||||
const upsertProgress = (
|
const segmenter = getSegmenter();
|
||||||
progress: ChatProgress[] | undefined,
|
if (segmenter) {
|
||||||
event: StreamEvent & { type: "progress" },
|
let chunk = "";
|
||||||
) => {
|
for (const segment of segmenter.segment(content)) {
|
||||||
const next = [...(progress ?? [])];
|
chunk += segment.segment;
|
||||||
const index = next.findIndex((item) => item.id === event.id);
|
if (
|
||||||
const existing = index >= 0 ? next[index] : undefined;
|
chunk.length >= maxChars ||
|
||||||
const now = Date.now();
|
(chunk.length >= targetChars &&
|
||||||
const startedAt = event.startedAt ?? existing?.startedAt;
|
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
||||||
const isRunning = event.status === "running";
|
) {
|
||||||
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
return chunk;
|
||||||
const elapsedMs = isRunning
|
|
||||||
? event.elapsedMs ??
|
|
||||||
existing?.elapsedMs ??
|
|
||||||
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
|
||||||
: undefined;
|
|
||||||
const elapsedSnapshotAt = isRunning
|
|
||||||
? event.elapsedMs !== undefined
|
|
||||||
? now
|
|
||||||
: existing?.elapsedSnapshotAt ?? now
|
|
||||||
: undefined;
|
|
||||||
const durationMs = !isRunning
|
|
||||||
? event.durationMs ??
|
|
||||||
existing?.durationMs ??
|
|
||||||
(startedAt !== undefined && endedAt !== undefined
|
|
||||||
? Math.max(0, endedAt - startedAt)
|
|
||||||
: undefined)
|
|
||||||
: undefined;
|
|
||||||
const nextItem: ChatProgress = {
|
|
||||||
id: event.id,
|
|
||||||
phase: event.phase,
|
|
||||||
status: event.status,
|
|
||||||
title: event.title,
|
|
||||||
detail: event.detail,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
elapsedMs,
|
|
||||||
elapsedSnapshotAt,
|
|
||||||
durationMs,
|
|
||||||
};
|
|
||||||
if (index >= 0) {
|
|
||||||
next[index] = nextItem;
|
|
||||||
} else {
|
|
||||||
next.push(nextItem);
|
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|
||||||
progress?.map((item) => {
|
|
||||||
if (item.status !== "running") {
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
const endedAt = Date.now();
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
status: "completed" as const,
|
|
||||||
endedAt,
|
|
||||||
elapsedMs: undefined,
|
|
||||||
elapsedSnapshotAt: undefined,
|
|
||||||
durationMs:
|
|
||||||
item.durationMs ??
|
|
||||||
(item.startedAt !== undefined
|
|
||||||
? Math.max(0, endedAt - item.startedAt)
|
|
||||||
: item.elapsedMs),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const upsertPermission = (
|
|
||||||
permissions: AgentPermissionRequest[] | undefined,
|
|
||||||
event: StreamEvent & { type: "permission_request" },
|
|
||||||
) => {
|
|
||||||
const next = [...(permissions ?? [])];
|
|
||||||
const index = next.findIndex((item) => item.requestId === event.requestId);
|
|
||||||
const nextItem: AgentPermissionRequest = {
|
|
||||||
requestId: event.requestId,
|
|
||||||
sessionId: event.sessionId,
|
|
||||||
permission: event.permission,
|
|
||||||
patterns: event.patterns,
|
|
||||||
metadata: event.metadata,
|
|
||||||
always: event.always,
|
|
||||||
tool: event.tool,
|
|
||||||
createdAt: event.createdAt,
|
|
||||||
status: "pending",
|
|
||||||
};
|
|
||||||
if (index >= 0) {
|
|
||||||
next[index] = {
|
|
||||||
...next[index],
|
|
||||||
...nextItem,
|
|
||||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
next.push(nextItem);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
|
||||||
if (reply === "always") return "approved_always";
|
|
||||||
if (reply === "once") return "approved_once";
|
|
||||||
return "rejected";
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
|
||||||
const hasVisibleOutput =
|
|
||||||
message.content.trim().length > 0 ||
|
|
||||||
Boolean(message.artifacts?.length) ||
|
|
||||||
Boolean(completedProgress?.length);
|
|
||||||
|
|
||||||
if (!hasVisibleOutput) {
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
||||||
...message,
|
if (phrase) return phrase;
|
||||||
content: message.content || "⚠️ **请求已中断**",
|
|
||||||
isError: true,
|
|
||||||
progress: completedProgress,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createUserMessage = (content: string): Message => {
|
const cjkChunk = content.match(
|
||||||
const id = createId();
|
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
||||||
return {
|
)?.[0];
|
||||||
id,
|
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAssistantMessage = (): Message => ({
|
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
||||||
id: createId(),
|
if (wordChunk) {
|
||||||
role: "assistant",
|
return wordChunk.length <= maxChars
|
||||||
content: "",
|
? wordChunk
|
||||||
});
|
: sliceCodePoints(wordChunk, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliceCodePoints(content, Math.min(maxChars, 12));
|
||||||
|
};
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -211,7 +80,6 @@ export const useAgentChatSession = ({
|
|||||||
getModel,
|
getModel,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
const hydrationCompletedRef = useRef(false);
|
|
||||||
const hydrationNonceRef = useRef(0);
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -221,6 +89,7 @@ export const useAgentChatSession = ({
|
|||||||
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [isHydrating, setIsHydrating] = useState(true);
|
const [isHydrating, setIsHydrating] = useState(true);
|
||||||
|
const [loadingSessionId, setLoadingSessionId] = useState<string | undefined>(undefined);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | undefined>(undefined);
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||||
const messagesRef = useRef<Message[]>([]);
|
const messagesRef = useRef<Message[]>([]);
|
||||||
@@ -228,14 +97,11 @@ export const useAgentChatSession = ({
|
|||||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
const titleUpdateNonceRef = useRef(0);
|
const titleUpdateNonceRef = useRef(0);
|
||||||
const lastPersistedStateKeyRef = useRef(
|
const pendingTokenRef = useRef<{
|
||||||
createPersistedStateKey({
|
assistantMessageId: string;
|
||||||
sessionId: undefined,
|
content: string;
|
||||||
title: undefined,
|
} | null>(null);
|
||||||
isTitleManuallyEdited: false,
|
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||||
messages: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -245,6 +111,99 @@ export const useAgentChatSession = ({
|
|||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
@@ -255,17 +214,9 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
hydrationCompletedRef.current = false;
|
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -286,12 +237,12 @@ export const useAgentChatSession = ({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
sessionIdRef.current = loadedState.sessionId;
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
|
|
||||||
setMessages(loadedState.messages);
|
setMessages(
|
||||||
|
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
|
||||||
|
);
|
||||||
setSessionTitle(loadedState.title);
|
setSessionTitle(loadedState.title);
|
||||||
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(loadedState.sessionId);
|
setSessionId(loadedState.sessionId);
|
||||||
@@ -318,51 +269,6 @@ export const useAgentChatSession = ({
|
|||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [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) => {
|
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -390,6 +296,10 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId?: string;
|
assistantMessageId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
if (event.type !== "token") {
|
||||||
|
flushPendingTokens();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type !== "session_title" &&
|
event.type !== "session_title" &&
|
||||||
"sessionId" in event &&
|
"sessionId" in event &&
|
||||||
@@ -401,7 +311,9 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
const nextMessages = cloneMessages(event.messages as Message[]);
|
const nextMessages = normalizeSessionTodos(
|
||||||
|
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
|
||||||
|
);
|
||||||
messagesRef.current = nextMessages;
|
messagesRef.current = nextMessages;
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setIsStreaming(event.isStreaming);
|
setIsStreaming(event.isStreaming);
|
||||||
@@ -415,12 +327,6 @@ export const useAgentChatSession = ({
|
|||||||
const targetSessionId = event.sessionId || currentSessionId;
|
const targetSessionId = event.sessionId || currentSessionId;
|
||||||
if (targetSessionId === currentSessionId) {
|
if (targetSessionId === currentSessionId) {
|
||||||
setSessionTitle(nextTitle);
|
setSessionTitle(nextTitle);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: nextTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: messagesRef.current,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (targetSessionId) {
|
if (targetSessionId) {
|
||||||
const currentNonce = ++titleUpdateNonceRef.current;
|
const currentNonce = ++titleUpdateNonceRef.current;
|
||||||
@@ -446,17 +352,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "token") {
|
if (event.type === "token") {
|
||||||
setMessages((prev) =>
|
queueTokenContent(assistantMessageId, event.content);
|
||||||
prev.map((message) =>
|
|
||||||
message.id === assistantMessageId
|
|
||||||
? {
|
|
||||||
...message,
|
|
||||||
content: message.content + event.content,
|
|
||||||
isError: false,
|
|
||||||
}
|
|
||||||
: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (event.type === "progress") {
|
} else if (event.type === "progress") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -502,6 +398,29 @@ export const useAgentChatSession = ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (event.type === "question_request") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
|
||||||
|
);
|
||||||
|
} else if (event.type === "question_response") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.questions?.some((question) => question.requestId === event.requestId)
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
questions: applyQuestionResponse(message.questions, event),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "todo_update") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
normalizeSessionTodos(
|
||||||
|
prev,
|
||||||
|
createTodoUpdateFromEvent(event),
|
||||||
|
assistantMessageId,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
@@ -531,6 +450,22 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **错误:** ${event.message}`,
|
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
todos: cancelRunningTodos(message.todos),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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,
|
: message,
|
||||||
),
|
),
|
||||||
@@ -538,7 +473,13 @@ export const useAgentChatSession = ({
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appendArtifact, getLastAssistantMessageId, onToolCall],
|
[
|
||||||
|
appendArtifact,
|
||||||
|
flushPendingTokens,
|
||||||
|
getLastAssistantMessageId,
|
||||||
|
onToolCall,
|
||||||
|
queueTokenContent,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resumeStreamingSession = useCallback(
|
const resumeStreamingSession = useCallback(
|
||||||
@@ -554,18 +495,20 @@ export const useAgentChatSession = ({
|
|||||||
onEvent: (event) => applyStreamEvent(event),
|
onEvent: (event) => applyStreamEvent(event),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
flushPendingTokens();
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
flushPendingTokens();
|
||||||
if (abortRef.current === controller) {
|
if (abortRef.current === controller) {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyStreamEvent],
|
[applyStreamEvent, flushPendingTokens],
|
||||||
);
|
);
|
||||||
resumeStreamingSessionRef.current = resumeStreamingSession;
|
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||||
|
|
||||||
@@ -573,7 +516,6 @@ export const useAgentChatSession = ({
|
|||||||
async ({
|
async ({
|
||||||
prompt: rawPrompt,
|
prompt: rawPrompt,
|
||||||
sessionIdOverride,
|
sessionIdOverride,
|
||||||
regenerateFromMessageIndex,
|
|
||||||
preparedMessages,
|
preparedMessages,
|
||||||
userMessage,
|
userMessage,
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
@@ -608,7 +550,6 @@ export const useAgentChatSession = ({
|
|||||||
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
||||||
model: getModel?.(),
|
model: getModel?.(),
|
||||||
approvalMode: getApprovalMode?.(),
|
approvalMode: getApprovalMode?.(),
|
||||||
regenerateFromMessageIndex,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onEvent: (event) =>
|
onEvent: (event) =>
|
||||||
applyStreamEvent(event, {
|
applyStreamEvent(event, {
|
||||||
@@ -616,16 +557,13 @@ export const useAgentChatSession = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
flushPendingTokens();
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev
|
prev
|
||||||
.map((message) =>
|
.map((message) =>
|
||||||
message.id === nextAssistantMessage.id
|
message.id === nextAssistantMessage.id
|
||||||
? {
|
? finalizeAssistantMessageAfterAbort(message)
|
||||||
...message,
|
|
||||||
content: message.content || "⚠️ **请求已中断**",
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
: message,
|
: message,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -635,7 +573,8 @@ export const useAgentChatSession = ({
|
|||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length) &&
|
!(message.artifacts?.length) &&
|
||||||
!(message.progress?.length)
|
!(message.progress?.length) &&
|
||||||
|
!message.todos
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -655,12 +594,14 @@ export const useAgentChatSession = ({
|
|||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
flushPendingTokens();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
|
flushPendingTokens,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
getModel,
|
getModel,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
@@ -673,6 +614,7 @@ export const useAgentChatSession = ({
|
|||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
|
flushPendingTokens();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
const assistantMessageId = getLastAssistantMessageId();
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
@@ -695,7 +637,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, [getLastAssistantMessageId]);
|
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||||||
|
|
||||||
const replyPermission = useCallback(
|
const replyPermission = useCallback(
|
||||||
async (requestId: string, reply: PermissionReply) => {
|
async (requestId: string, reply: PermissionReply) => {
|
||||||
@@ -766,34 +708,173 @@ export const useAgentChatSession = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const replyQuestion = useCallback(
|
||||||
|
async (requestId: string, answers: string[][]) => {
|
||||||
|
const target = messagesRef.current
|
||||||
|
.flatMap((message) => message.questions ?? [])
|
||||||
|
.find((question) => question.requestId === requestId);
|
||||||
|
if (!target || target.status === "submitting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? { ...question, status: "submitting", error: undefined }
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replyAgentQuestion(target.sessionId, requestId, answers);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "answered",
|
||||||
|
answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rejectQuestion = useCallback(
|
||||||
|
async (requestId: string) => {
|
||||||
|
const target = messagesRef.current
|
||||||
|
.flatMap((message) => message.questions ?? [])
|
||||||
|
.find((question) => question.requestId === requestId);
|
||||||
|
if (!target || target.status === "submitting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? { ...question, status: "submitting", error: undefined }
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rejectAgentQuestion(target.sessionId, requestId);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
flushPendingTokens();
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle("新对话");
|
setSessionTitle("新对话");
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [isHydrating, isStreaming]);
|
}, [flushPendingTokens, isHydrating, isStreaming]);
|
||||||
|
|
||||||
const switchSession = useCallback(
|
const switchSession = useCallback(
|
||||||
async (nextSessionId: string) => {
|
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||||
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
|
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
|
setLoadingSessionId(nextSessionId);
|
||||||
|
const nextTitle = optimisticTitle?.trim();
|
||||||
|
if (nextTitle) {
|
||||||
|
setSessionTitle(nextTitle);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [nextState, sessions] = await Promise.all([
|
const [nextState, sessions] = await Promise.all([
|
||||||
loadChatSessionById(nextSessionId),
|
loadChatSessionById(nextSessionId),
|
||||||
@@ -803,7 +884,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -817,6 +897,7 @@ export const useAgentChatSession = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to switch chat session:", error);
|
console.error("[GlobalChatbox] Failed to switch chat session:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
setLoadingSessionId(undefined);
|
||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -846,12 +927,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
@@ -867,7 +942,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -909,57 +983,14 @@ export const useAgentChatSession = ({
|
|||||||
if (sessionIdRef.current === targetSessionId) {
|
if (sessionIdRef.current === targetSessionId) {
|
||||||
setSessionTitle(normalizedTitle);
|
setSessionTitle(normalizedTitle);
|
||||||
setIsSessionTitleManuallyEdited(true);
|
setIsSessionTitleManuallyEdited(true);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: normalizedTitle,
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, messages],
|
[isHydrating],
|
||||||
);
|
);
|
||||||
|
|
||||||
const regenerate = useCallback(async (messageId: string) => {
|
|
||||||
if (isHydrating || isStreaming || messages.length === 0) return;
|
|
||||||
|
|
||||||
const targetAssistantIndex = messages.findIndex(
|
|
||||||
(message) => message.id === messageId && message.role === "assistant",
|
|
||||||
);
|
|
||||||
if (targetAssistantIndex < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetUserIndex = targetAssistantIndex - 1;
|
|
||||||
while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") {
|
|
||||||
targetUserIndex--;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetUserIndex < 0) return;
|
|
||||||
|
|
||||||
const targetUser = messages[targetUserIndex];
|
|
||||||
const targetUserContent = targetUser.content;
|
|
||||||
const nextMessages = cloneMessages(messages.slice(0, targetUserIndex));
|
|
||||||
const nextUserMessage = createUserMessage(targetUserContent);
|
|
||||||
const nextAssistantMessage = createAssistantMessage();
|
|
||||||
|
|
||||||
setMessages(nextMessages);
|
|
||||||
await runPrompt({
|
|
||||||
prompt: targetUserContent,
|
|
||||||
regenerateFromMessageIndex: targetUserIndex,
|
|
||||||
preparedMessages: [
|
|
||||||
...nextMessages,
|
|
||||||
nextUserMessage,
|
|
||||||
nextAssistantMessage,
|
|
||||||
],
|
|
||||||
userMessage: nextUserMessage,
|
|
||||||
assistantMessage: nextAssistantMessage,
|
|
||||||
});
|
|
||||||
}, [isHydrating, isStreaming, messages, runPrompt]);
|
|
||||||
|
|
||||||
const createBranch = useCallback(
|
const createBranch = useCallback(
|
||||||
async (messageId: string) => {
|
async (messageId: string) => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
@@ -982,12 +1013,6 @@ export const useAgentChatSession = ({
|
|||||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||||
setSessionTitle(forkTitle);
|
setSessionTitle(forkTitle);
|
||||||
try {
|
try {
|
||||||
await saveActiveChatState({
|
|
||||||
title: forkTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: copiedMessages,
|
|
||||||
sessionId: forkedSessionId,
|
|
||||||
});
|
|
||||||
setChatSessions(await listChatSessions());
|
setChatSessions(await listChatSessions());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||||
@@ -1001,14 +1026,16 @@ export const useAgentChatSession = ({
|
|||||||
chatSessions,
|
chatSessions,
|
||||||
activeSessionId: sessionIdRef.current,
|
activeSessionId: sessionIdRef.current,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
|
loadingSessionId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
sessionId,
|
sessionId,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
|
||||||
createBranch,
|
createBranch,
|
||||||
abort,
|
abort,
|
||||||
replyPermission,
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||||
|
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
|
||||||
|
|
||||||
|
export type UseAgentChatSessionOptions = {
|
||||||
|
projectId?: string | null;
|
||||||
|
onToolCall: (
|
||||||
|
event: StreamEvent & { type: "tool_call" },
|
||||||
|
options: {
|
||||||
|
assistantMessageId: string;
|
||||||
|
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
onBeforeSend?: () => void;
|
||||||
|
getModel?: () => AgentModel | undefined;
|
||||||
|
getApprovalMode?: () => AgentApprovalMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptRunOptions = {
|
||||||
|
prompt: string;
|
||||||
|
sessionIdOverride?: string;
|
||||||
|
preparedMessages?: Message[];
|
||||||
|
userMessage?: Message;
|
||||||
|
assistantMessage?: Message;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user